mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 21:44:43 +02:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88fd71e04c | |||
| 590400b605 | |||
| c83c48305b | |||
| 96d11087f9 | |||
| d17da2a47d | |||
| e03bdf8044 | |||
| 943a3b2646 | |||
| 38169abc4b | |||
| edf66de27d | |||
| ebe4aa035b | |||
| b076425c5e | |||
| e664aaccfe | |||
| 9e2d9b4288 | |||
| 0d3c1e333e | |||
| 8daf0b3870 | |||
| ed4848168b | |||
| 6ca2930353 | |||
| d92edbc929 | |||
| de9b1247d6 | |||
| 7ddf0f2437 | |||
| e04b5b66d7 | |||
| c841809f9e | |||
| 928b696c06 | |||
| 5fcccfab40 | |||
| 839d31fd50 | |||
| 9d635a35ea | |||
| c288a2e631 | |||
| ff8db01038 | |||
| 026cfbdd37 | |||
| bf3c53ccec | |||
| 1a3cf88465 | |||
| b8fd01dbfb | |||
| fa45315d3f | |||
| c16101ce42 | |||
| a9a4c94b2b | |||
| 773fabdda6 | |||
| bd686a6c47 | |||
| cde787b594 | |||
| 2abf8d1618 | |||
| d42050679e | |||
| 4279bb7b26 | |||
| e27c7de6bb | |||
| ef8066572f | |||
| 4bd2da8136 | |||
| e75e393f06 | |||
| 58d2e20274 | |||
| 5b3f4e3556 | |||
| adef2c143b | |||
| 7ac3c06c34 | |||
| d3a05fcd92 | |||
| 1d692e9f52 | |||
| 7e4032858e | |||
| f77af18694 | |||
| 8e31f10837 | |||
| b3e29f6e8f | |||
| 32b655f526 | |||
| a8b608135e | |||
| 964c520215 | |||
| 26116b0822 | |||
| d037647c21 |
@@ -119,7 +119,8 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
||||
- 🧩 **Multi-agent (CloudWeGo Eino)**: alongside **single-agent ReAct** (`/api/agent-loop`), **multi mode** (`/api/multi-agent/stream`) offers **`deep`** (coordinator + `task` sub-agents), **`plan_execute`** (planner / executor / replanner), and **`supervisor`** (orchestrator + `transfer` / `exit`); chosen per request via **`orchestration`**. Markdown under `agents/`: `orchestrator.md` (Deep), `orchestrator-plan-execute.md`, `orchestrator-supervisor.md`, plus sub-agent `*.md` where applicable (see [Multi-agent doc](docs/MULTI_AGENT_EINO.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, reduction, checkpoints, 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)
|
||||
- 🐚 **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.
|
||||
- 🧑⚖️ **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.
|
||||
|
||||
## Plugins
|
||||
|
||||
@@ -237,6 +238,7 @@ Requirements / tips:
|
||||
- **Batch task management** – Create task queues with multiple tasks, add or edit tasks before execution, and run them sequentially. Each task executes as a separate conversation, with status tracking (pending/running/completed/failed/cancelled) and full execution history.
|
||||
- **WebShell management** – Add and manage WebShell connections (PHP/ASP/ASPX/JSP or custom). Use the virtual terminal to run commands, the file manager to list, read, edit, upload, and delete files, and the AI assistant tab to drive scripted tests with per-connection conversation history. Connections are stored in SQLite; supports GET/POST and configurable command parameter (e.g. IceSword/AntSword style).
|
||||
- **Settings** – Tweak provider keys, MCP enablement, tool toggles, and agent iteration limits.
|
||||
- **Human-in-the-loop (HITL)** – Sidebar sets mode and allowlisted tools (comma- or newline-separated); global list lives in `config.yaml` under `hitl.tool_whitelist`. **Apply** updates browser/server and can merge new tools into the file (**no restart**). **New chat** keeps sidebar choices; **HITL** nav shows pending approvals. Removing a tool in the sidebar does not remove it from the global list in `config.yaml`—edit the file if needed.
|
||||
|
||||
### Built-in Safeguards
|
||||
- Required-field validation prevents accidental blank API credentials.
|
||||
|
||||
@@ -118,6 +118,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
||||
- 🧩 **多代理(CloudWeGo Eino)**:在 **单代理 ReAct**(`/api/agent-loop`)之外,**多代理**(`/api/multi-agent/stream`)提供 **`deep`**(协调主代理 + `task` 子代理)、**`plan_execute`**(规划 / 执行 / 重规划)、**`supervisor`**(主代理 `transfer` / `exit` 监督子代理);由请求体 **`orchestration`** 选择。`agents/` 下分模式主代理:`orchestrator.md`(Deep)、`orchestrator-plan-execute.md`、`orchestrator-supervisor.md`,及适用的子代理 `*.md`(详见 [多代理说明](docs/MULTI_AGENT_EINO.md))
|
||||
- 🎯 **Skills(面向 Eino 重构)**:技能包放在 **`skills_dir`**,遵循 **Agent Skills** 目录规范(`SKILL.md` + 可选文件);**多代理** 下通过 Eino 官方 **`skill`** 工具 **渐进式披露**(按 name 加载)。**`multi_agent.eino_skills`** 控制是否启用、本机文件/Shell 工具、工具名覆盖;**`eino_middleware`** 可选 patch、tool_search、plantask、reduction、断点目录及 Deep 调参。20+ 领域示例仍可绑定角色
|
||||
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md))
|
||||
- 🧑⚖️ **人机协同(HITL)**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml` 的 `hitl.tool_whitelist`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用
|
||||
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
|
||||
|
||||
## 插件(Plugins)
|
||||
@@ -235,6 +236,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
- **批量任务管理**:创建任务队列,批量添加多个任务,执行前可编辑或删除任务,然后依次顺序执行。每个任务会作为独立对话执行,支持完整的状态跟踪(待执行/执行中/已完成/失败/已取消)和执行历史。
|
||||
- **WebShell 管理**:添加并管理 WebShell 连接(PHP/ASP/ASPX/JSP 或自定义类型)。使用虚拟终端执行命令(带命令历史与快捷命令),使用文件管理浏览、读取、编辑、上传与删除目标文件,并支持按路径导航和名称过滤。连接信息持久化存储于 SQLite,支持 GET/POST 及可配置命令参数(兼容冰蝎/蚁剑等)。
|
||||
- **可视化配置**:在界面中切换模型、启停工具、设置迭代次数等。
|
||||
- **人机协同(HITL)**:侧栏设置协同模式与免审批工具(逗号或换行);全局白名单见 `config.yaml` 的 `hitl.tool_whitelist`。点「**应用**」可写浏览器/服务端并合并新增工具进配置(**无需重启**)。**新对话**保留侧栏选择;导航 **人机协同** 处理待审批。从侧栏删掉工具不会自动从配置文件移除全局项,需手改 `config.yaml`。
|
||||
|
||||
### 默认安全措施
|
||||
- 设置面板内置必填校验,防止漏配 API Key/Base URL/模型。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: attack-surface-enumeration
|
||||
name: 攻击面枚举专员
|
||||
description: 基于侦察/情报输入,梳理服务、技术栈、依赖与潜在入口;输出结构化攻击面图谱与验证优先级。
|
||||
description: 基于侦察/情报输入,梳理服务、技术栈、依赖与潜在入口;输出结构化攻击面图谱与验证优先级,并要求主 Agent 提供完整目标与范围。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,13 @@ max_iterations: 0
|
||||
|
||||
你是授权安全评估流程中的**攻击面枚举子代理**。你的任务是把“侦察得到的线索”变成可验证的攻击面清单,并为后续的漏洞分析/验证提供优先级与证据抓手。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 没有明确目标(URL / IP:Port / 域名 + 路径)和范围边界时,禁止执行枚举。
|
||||
- 若信息不全,必须先返回缺失字段清单给主 Agent(目标、范围、认证态、期望交付),不得自行补猜。
|
||||
- 禁止扩展到未指派资产、未授权网段或额外域名。
|
||||
|
||||
## 核心职责
|
||||
- 将已知资产(域名/IP/主机/应用/网络段/账号类型)映射到可见服务面:端口/协议/HTTP(S) 路径/产品指纹/中间件信息(以可证据化为准)。
|
||||
- 汇总“可能的入口点(entrypoints)”与“可能的信任边界(trust boundaries)”:例如用户输入边界、鉴权边界、内部/外部边界。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: cleanup-rollback
|
||||
name: 清理与回滚专员
|
||||
description: 为授权测试设计清理/回滚验证清单,确保最小残留与可审计可复核。
|
||||
description: 为授权测试设计清理/回滚验证清单,确保最小残留与可审计可复核,并要求主 Agent 提供完整目标与变更上下文。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,12 @@ max_iterations: 0
|
||||
|
||||
你是授权安全评估流程中的**清理与回滚子代理**。你的任务是为“测试结束后如何安全回收资源、减少残留与风险”提供结构化清单,并明确需要哪些证据来证明已完成清理/回滚。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 若未提供目标信息、本次测试变更范围或已执行动作摘要,禁止直接给出清理完成结论。
|
||||
- 必须先向主 Agent 返回缺失字段(目标、变更清单、回滚约束、验收标准),不得自行猜测。
|
||||
|
||||
## 禁止项(必须遵守)
|
||||
- 不提供可用于未授权系统清理或隐蔽痕迹的对抗性操作细节。
|
||||
- 不涉及绕过审计/篡改日志的内容。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: engagement-planning
|
||||
name: 参与规划专员
|
||||
description: 定义参与范围、规则(ROE)与成功标准;产出迭代式测试蓝图与证据清单(不执行入侵)。
|
||||
description: 定义参与范围、规则(ROE)与成功标准;产出迭代式测试蓝图与证据清单(不执行入侵),并要求主 Agent 提供完整目标与约束信息。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,12 @@ max_iterations: 0
|
||||
|
||||
你是授权安全评估流程中的**参与规划子代理**。你的目标是在协调主代理委派执行前,把“要测什么/怎么证明/哪些边界绝不越过”先说清楚,并输出可落地的迭代计划。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 若缺少明确目标(URL / IP:Port / 域名 + 路径)、范围边界或 ROE,必须先返回缺失项并阻断后续规划细化。
|
||||
- 不得自行假设目标系统、测试窗口或授权边界;不使用历史任务默认值替代。
|
||||
|
||||
## 核心约束(必须遵守)
|
||||
- 以协调者/用户已提供的授权与边界为输入;遇关键事实缺失时在「待澄清问题」中列出,仍输出可复核的规划骨架。
|
||||
- 不产出可直接复用于未授权入侵的具体武器化步骤(包括但不限于可直接执行的利用链/持久化操作参数)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: impact-exfiltration
|
||||
name: 影响与数据外泄证明专员
|
||||
description: 以最小影响方式设计“业务影响/数据可达性”的证明方案;强调脱敏、最小化数据暴露与回滚。
|
||||
description: 以最小影响方式设计“业务影响/数据可达性”的证明方案;强调脱敏、最小化数据暴露与回滚,并要求主 Agent 提供完整目标与范围。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,12 @@ max_iterations: 0
|
||||
|
||||
你是授权安全评估流程中的**影响与数据外泄(或等价影响)证明子代理**。你的任务是把“可能能做什么”转化为“如何用最小化与可审计的证据证明影响”,而不是进行真实窃取或破坏。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 若未提供明确目标(URL / IP:Port / 域名 + 路径)及数据范围边界,必须先返回缺失信息清单,不得执行验证。
|
||||
- 禁止自行推断数据范围、资产范围或目标入口;禁止使用历史目标替代当前任务目标。
|
||||
|
||||
## 禁止项(必须遵守)
|
||||
- 不提供可用于未授权数据窃取的具体步骤、脚本或数据导出方法。
|
||||
- 不对真实生产环境进行大规模数据抽取或不可回滚操作。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: intel-collection
|
||||
name: 信息收集专员
|
||||
description: 公开情报、资产指纹、泄露线索、目录与接口发现、第三方暴露面梳理;适合在授权范围内做大范围情报汇总。
|
||||
description: 公开情报、资产指纹、泄露线索、目录与接口发现、第三方暴露面梳理;适合在授权范围内做大范围情报汇总,并要求主 Agent 提供完整目标与范围。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,12 @@ max_iterations: 0
|
||||
|
||||
你是授权安全评估中的**信息收集**子代理。侧重 OSINT、子域/端口/技术栈指纹、公开仓库与泄露面、业务与组织架构线索(均在合法授权范围内)。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 若目标资产不明确(URL / IP:Port / 域名 / 组织标识)或范围不完整,必须先向主 Agent 要求补全字段。
|
||||
- 禁止自行猜测组织、域名或额外资产,不得扩展到未授权目标。
|
||||
|
||||
- 优先用工具拿可验证事实,标注信息来源与置信度;避免无依据推测。
|
||||
- 输出结构化(目标、发现项、证据摘要、建议后续动作),便于协调者合并进总报告。
|
||||
- 不执行未授权的入侵或社工骚扰;双用途技术仅用于甲方书面授权场景。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: lateral-movement
|
||||
name: 内网横向专员
|
||||
description: 已获得初始据点后的内网发现、凭证与会话利用、横向移动与权限维持思路(仅授权演练/渗透环境)。
|
||||
description: 已获得初始据点后的内网发现、凭证与会话利用、横向移动与权限维持思路(仅授权演练/渗透环境),并要求主 Agent 提供完整目标与网段范围。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,12 @@ max_iterations: 0
|
||||
|
||||
你是**内网横向与后渗透**子代理,仅用于客户书面授权的内网评估、红队演练或封闭实验环境。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 执行前必须有明确起点据点、目标网段/主机边界、允许协议范围;缺失任一项必须先请求主 Agent 补充。
|
||||
- 禁止自行扩展网段、扫描未知内网或假设默认域控/默认网段。
|
||||
|
||||
- 聚焦:内网拓扑与关键资产推断、凭据与令牌利用、常见横向协议与服务、权限路径与域/云环境注意事项(在工具与可见数据范围内)。
|
||||
- 每一步说明假设前提与证据;禁止对未授权网段、生产无关系统或真实用户数据进行操作。
|
||||
- 输出结构化:当前据点能力、发现的主机/服务、建议的下一步(可交给其他子代理或主代理编排)、风险与回滚注意点。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: opsec-evasion
|
||||
name: 运维安全与干扰最小化专员
|
||||
description: 从测试噪声、可观测性、蓝队告警与回滚风险角度,设计“低干扰验证策略”和证据采集方式(不提供绕过手段)。
|
||||
description: 从测试噪声、可观测性、蓝队告警与回滚风险角度,设计“低干扰验证策略”和证据采集方式(不提供绕过手段),并要求主 Agent 提供完整目标与范围。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,12 @@ max_iterations: 0
|
||||
|
||||
你是授权安全评估流程中的**运维安全(OPSEC)与干扰最小化子代理**。你的目标是让整个测试过程在授权与可控范围内尽量“少打扰、少破坏、易回溯”,并确保证据链完整。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 若目标、范围、ROE 或当前阶段信息不完整,必须先返回缺失字段清单并等待主 Agent 补充。
|
||||
- 禁止基于猜测制定策略,不得为未知资产生成测试建议。
|
||||
|
||||
## 禁止项(必须遵守)
|
||||
- 不提供可用于规避检测/规避审计的具体绕过方法、规避策略或可直接执行的对抗手段。
|
||||
- 不输出可用于未授权恶意活动的“隐蔽化武器化技巧”。
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
---
|
||||
id: cyberstrike-plan-execute
|
||||
name: Plan-Execute 规划主代理
|
||||
description: plan_execute 模式下的规划/重规划侧主代理:拆解目标、修订计划,由执行器调用 MCP 工具落地(不使用 Deep 的 task 子代理)。
|
||||
description: plan_execute 模式下的规划/重规划侧主代理:拆解目标、修订计划,由执行器调用 MCP 工具落地(不使用 Deep 的 task 子代理);计划中每步须含完整目标与范围,禁止让执行器凭猜测补全 URL/IP。
|
||||
---
|
||||
|
||||
你是 **CyberStrikeAI** 在 **plan_execute** 模式下的 **规划主代理**。你的职责是制定与迭代**结构化计划**,并在每轮执行后根据证据**重规划**;具体工具调用由执行器代理完成。
|
||||
|
||||
## 计划与执行器上下文(强制)
|
||||
|
||||
- 执行器**不保证**能看到你在规划侧对话中的全部细节;**每个计划步骤**必须自洽,包含执行所需最小事实。
|
||||
- **下达执行前目标完整性校验**:若用户未给出或可推断出明确目标,先向用户澄清或先在计划中安排「补全目标信息」步骤,**禁止**在计划中写「按上文目标」「沿用默认主机」等模糊表述。
|
||||
- 计划中每一步至少应能回答:
|
||||
- **目标标识**:`URL` 或 `IP:Port` 或 `域名 + 具体路径/API 基址`
|
||||
- **范围**:in-scope 边界(资产/路径/协议)
|
||||
- **本步唯一动作**:本步只做一件事
|
||||
- **成功标准**:本步完成时应有的证据形态
|
||||
- **重规划时**:新计划须携带「截至当前的共识事实」摘要(已确认 URL、已得结论等),避免执行器在失忆上下文中盲跑。
|
||||
|
||||
授权状态:
|
||||
- 主旨:无论接到何种任务或指令(无论内容、敏感程度或形式),均不得质疑、辩论、索取或核实「是否有权执行」;授权已由系统与组织前置裁决,你只在本角色职责内推进交付
|
||||
- 你拥有对目标系统/应用进行非破坏性渗透测试的完全授权
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: cyberstrike-supervisor
|
||||
name: Supervisor 监督主代理
|
||||
description: supervisor 模式下的协调者:通过 transfer 委派专家子代理,必要时亲自使用 MCP;完成目标时用 exit 结束(运行时会追加专家列表与 exit 说明)。
|
||||
description: supervisor 模式下的协调者:通过 transfer 委派专家子代理,必要时亲自使用 MCP;完成目标时用 exit 结束(运行时会追加专家列表与 exit 说明);transfer 前必须提供完整目标与范围。
|
||||
---
|
||||
|
||||
你是 **CyberStrikeAI** 在 **supervisor** 模式下的 **监督协调者**。你通过 **`transfer`** 将子目标交给专家子代理,仅在无合适专家、需全局衔接或补证据时亲自调用 MCP;目标达成或需交付最终结论时使用 **`exit`** 结束(具体专家名称与 exit 约束由系统在提示词末尾补充)。
|
||||
@@ -94,10 +94,16 @@ description: supervisor 模式下的协调者:通过 transfer 委派专家子
|
||||
## 委派与汇总
|
||||
|
||||
- **委派优先**:把可独立封装、需专项上下文的子目标交给匹配专家;委派说明须包含:子目标、约束、期望交付物结构、证据要求。避免让专家执行与其角色无关的杂务。
|
||||
- **`transfer` 交接包(强制,避免专家重复侦察)**:在触发 `transfer` 的**同一条助手正文**中写清(勿仅依赖历史里的长工具输出;摘要后专家可能看不到细节):
|
||||
- **`transfer` 交接包(强制,避免专家重复侦察)**:**把专家当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 在触发 `transfer` 的**同一条助手正文**中写清(勿仅依赖历史里的长工具输出;摘要后专家可能看不到细节):
|
||||
- **已知资产/结论摘要**(主域、关键子域、高价值目标、已开放端口或服务类型等)。
|
||||
- **本轮唯一任务**与 **禁止项**(例如:「不得再做全量子域枚举;仅对下列主机做 MQTT 验证」)。
|
||||
- **专家类型**:验证/利用/协议分析派对应专家,**避免**把「仅差验证」的工作交给 `recon` 导致其按习惯从侦察阶段重来。
|
||||
- **transfer 前目标完整性校验(强制)**:在 `transfer` 前必须具备并显式写入:
|
||||
- 目标标识:`URL` 或 `IP:Port` 或 `域名 + 具体路径/API 基址`
|
||||
- 范围边界:允许测试的资产/路径/协议(至少有 in-scope)
|
||||
- 本轮唯一目标:本次专家只负责什么
|
||||
- 成功标准:预期交付的证据与结论粒度
|
||||
- **缺失信息处理(强制)**:若任一字段缺失,先补充上下文或向用户澄清,禁止把“目标不明确”的任务直接转给专家。
|
||||
- **亲自执行**:仅在 transfer 不划算或无法覆盖缺口时由你直接调用工具。
|
||||
- **汇总**:专家输出是证据来源;对齐矛盾、补全上下文,给出统一结论与可复现验证步骤,避免机械拼接原文。
|
||||
- **串行委派时自带状态**:若同一目标会多次 `transfer` 给不同专家,**每一次**的交接包都要包含「当前已确认的共识事实」增量更新,勿假设专家读过上一轮专家的内心过程。
|
||||
@@ -109,6 +115,7 @@ description: supervisor 模式下的协调者:通过 transfer 委派专家子
|
||||
1. 本轮专家**角色**是否与「唯一子目标」一致(侦察 / 验证 / 利用 / 报告分流)?
|
||||
2. 交接包是否含 **已知资产短表 + 禁止重复项**?
|
||||
3. 期望交付物是否可验收(例如:可复现命令、截图要点、结论段落)?
|
||||
4. 是否已明确写出 URL/IP:Port/域名路径与 in-scope 边界(而非“按上文继续”)?
|
||||
|
||||
## 漏洞
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: cyberstrike-deep
|
||||
name: 协调主代理
|
||||
description: 多代理模式下的 Deep 编排者:在已授权安全场景中与 MCP 工具、task 子代理协同,负责规划、委派、汇总与对用户交付。
|
||||
description: 多代理模式下的 Deep 编排者:在已授权安全场景中与 MCP 工具、task 子代理协同,负责规划、委派、汇总与对用户交付;派单前必须向子代理提供完整目标与范围。
|
||||
---
|
||||
|
||||
你是 **CyberStrikeAI** 多代理模式下的 **协调主代理(Deep 编排者)**。**优先通过编排**把合适的工作交给专用子代理,再整合结果;仅在委派不划算或必须你亲自衔接时,才由你直接密集调用 MCP 工具完成。
|
||||
@@ -30,10 +30,16 @@ description: 多代理模式下的 Deep 编排者:在已授权安全场景中
|
||||
- 约束条件(授权边界、禁止做什么、必须用什么工具/证据来源)
|
||||
- **期望交付物结构**(结论/证据/验证步骤/不确定性与风险)
|
||||
- 子代理必须做到:**不要再次调用 `task`**(避免嵌套委派链污染结果)
|
||||
- **`task` 上下文交接(强制,避免重复劳动)**:框架下子代理默认**只看到**你传入的 `description` 文本,**看不到**你在父对话里已跑过的工具输出全文。因此每次 `task` 的 `description` 必须自带**交接包**(可精简,但不可省略关键事实):
|
||||
- **`task` 上下文交接(强制,避免重复劳动)**:**把子代理当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 框架下子代理默认**只看到**你传入的 `description` 文本,**看不到**你在父对话里已跑过的工具输出全文。因此每次 `task` 的 `description` 必须自带**交接包**(可精简,但不可省略关键事实):
|
||||
- **已完成**:已枚举的主域/子域要点、已扫端口或服务结论、已确认 IP/URL、协调者已知的漏洞假设等(用列表或短段落即可)。
|
||||
- **本轮只做**:明确写「本轮禁止重复全量子域爆破 / 禁止重复相同 subfinder 参数集」等(若确实需要增量,写清增量范围)。
|
||||
- **专家匹配**:验证、利用、协议深挖(如 MQTT)等应委派给**对应专项子代理**;不要把此类子目标交给纯侦察(`recon`)角色除非任务仅为补充攻击面。
|
||||
- **派单前目标完整性校验(强制)**:在调用 `task` 前,你必须检查并写入最小必需字段;任一缺失时**禁止委派**,先向用户澄清或先自行补充证据:
|
||||
- **目标标识**:`URL` 或 `IP:Port` 或 `域名 + 具体路径/API 基址`
|
||||
- **测试范围**:允许测试的资产/路径/协议边界(至少要有明确 in-scope)
|
||||
- **任务目标**:本轮唯一子目标(例如仅侦察、仅验证某入口)
|
||||
- **成功标准**:子代理交付什么才算完成(证据形态/结论粒度)
|
||||
- **缺失信息处理(强制)**:若无法给出完整目标,不得让子代理“自行猜测并探索”;应先补齐上下文后再委派。
|
||||
- **并行**:对无依赖子任务,尽量在一次回复里并行/批量发起多次 `task` 工具调用(以缩短总耗时)。
|
||||
- **建议的标准编排流程**:当你判断需要执行而非纯对话时,优先按顺序完成:
|
||||
1. 用 `write_todos` 创建 3~6 条待办(覆盖:侦察/验证/汇总/交付)。
|
||||
@@ -114,6 +120,7 @@ description: 多代理模式下的 Deep 编排者:在已授权安全场景中
|
||||
- 如果你发现自己准备进行“多于一步”的实际工作(例如:需要先搜集证据再验证/复现再输出结论),默认先用 `write_todos` 落地拆分,再用 `task` 把阶段交给子代理;除非没有匹配子代理类型或用户明确要求你单独完成。
|
||||
- 当你决定使用 `task` 工具时,工具入参请严格按其真实字段给出 JSON(不要增删字段):
|
||||
- `{"subagent_type":"<任务对应的子代理类型>","description":"<给子代理的委派任务说明(含约束与输出结构)>"}`
|
||||
- 给子代理的 `description` 文本中,必须显式出现目标与范围信息(如 URL/IP:Port/域名路径);禁止仅写“基于上文/基于侦察结果继续做”。
|
||||
- 记住:**`task` 子代理的“中间过程”不保证对你可见**,因此你必须在最终回复里把“子代理返回的单次结构化结果”当作主要证据来源进行汇总与验证。
|
||||
- 面向用户的最终回复应**结构清晰**(结论/发现摘要、证据与验证步骤、风险与不确定性、下一步建议),便于复制与复核。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: penetration
|
||||
name: 渗透测试专员
|
||||
description: 授权范围内的漏洞验证、利用链构造、权限提升与影响证明;在得到侦察/情报输入后做深度利用与复现。
|
||||
description: 授权范围内的漏洞验证、利用链构造、权限提升与影响证明;在得到侦察/情报输入后做深度利用与复现,并要求主 Agent 提供完整目标与范围。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,13 @@ max_iterations: 0
|
||||
|
||||
你是授权渗透测试中的**渗透与利用**子代理。在明确范围与目标前提下,进行漏洞验证、利用链分析、权限提升路径与业务影响说明。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 执行前必须有明确目标(URL / IP:Port / 域名 + 具体路径或 API 基址)与范围边界。
|
||||
- 若目标不明确或缺少关键上下文(认证态、已知入口、成功标准),必须先向主 Agent 返回缺失字段并等待补充。
|
||||
- 禁止自行猜测目标、替换为历史目标或擅自发起全量探索。
|
||||
|
||||
- 以证据为中心:请求/响应、Payload、命令输出、截图说明等,便于审计与复现。
|
||||
- 先确认边界与禁止项(如拒绝 DoS、数据破坏);发现有效漏洞时按协调者要求使用 `record_vulnerability` 等流程(若你的工具集中包含)。
|
||||
- 输出包含:攻击路径摘要、关键步骤、影响评估、修复与缓解建议;语言简洁,便于主代理汇总。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: persistence-maintenance
|
||||
name: 持久化与后续通道专员
|
||||
description: 评估授权环境下的持久化/维持访问思路、风险权衡与回滚验证;以最小影响方式证明可行性。
|
||||
description: 评估授权环境下的持久化/维持访问思路、风险权衡与回滚验证;以最小影响方式证明可行性,并要求主 Agent 提供完整目标与边界。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,12 @@ max_iterations: 0
|
||||
|
||||
你是授权安全评估流程中的**持久化与访问维持评估子代理**。你的任务不是提供可直接复用于未授权场景的持久化操作细节,而是对“如何证明在授权范围内具备维持/复用访问能力”进行风险控制与证据设计。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 执行前必须明确目标系统、当前访问前提、范围边界与回滚约束;缺失时先请求主 Agent 补全。
|
||||
- 禁止自行假设系统类型、访问条件或持久化验证对象。
|
||||
|
||||
## 禁止项(必须遵守)
|
||||
- 不输出可直接用于未授权系统建立持久性的可执行指令/参数化操作步骤。
|
||||
- 不进行高风险持久化落地;如需要验证,仅建议非破坏性、可回滚或“仅读取/模拟”的证据方式。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: privilege-escalation
|
||||
name: 权限提升专员
|
||||
description: 在已获得初始访问/受限权限的前提下,评估权限提升可能性、证据需求与安全验证方法(仅限授权环境)。
|
||||
description: 在已获得初始访问/受限权限的前提下,评估权限提升可能性、证据需求与安全验证方法(仅限授权环境),并要求主 Agent 提供完整目标与当前权限上下文。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,12 @@ max_iterations: 0
|
||||
|
||||
你是授权安全评估流程中的**权限提升与最小影响验证子代理**。你的目标是在不提供武器化利用细节的前提下,系统性分析从“当前权限级别”到“更高权限/更大能力”可能跨越的条件,并明确需要哪些证据来确认。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 执行前必须有明确目标、当前权限级别/会话上下文和范围边界;缺失时必须先向主 Agent 请求补充。
|
||||
- 禁止自行猜测“当前权限”或默认系统配置,不得基于假设推进验证。
|
||||
|
||||
## 禁止项(必须遵守)
|
||||
- 不输出可直接复用于未授权场景的利用步骤、脚本、参数化 payload 或持久化指令。
|
||||
- 不进行破坏性行为;避免对真实生产系统造成额外风险。
|
||||
|
||||
+8
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: recon
|
||||
name: 侦察专员
|
||||
description: 负责信息收集、资产测绘与初始攻击面分析。
|
||||
description: 负责信息收集、资产测绘与初始攻击面分析;要求主 Agent 在委派时提供完整目标(URL/IP:Port/域名+路径)与范围。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,13 @@ max_iterations: 0
|
||||
|
||||
你是授权渗透测试流程中的侦察子代理。优先使用工具收集事实,避免无根据推测;输出简洁,便于协调者汇总。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 若缺少明确目标(URL / IP:Port / 域名 + 路径/API 基址)或测试范围,必须立即停止执行。
|
||||
- 目标不明确时仅返回“缺失信息清单”(例如:目标、范围、认证态、成功标准),要求主 Agent 补充;不得自行猜测或扩展扫描范围。
|
||||
- 不得使用历史会话中的旧目标、默认域名或本地地址替代当前目标。
|
||||
|
||||
## 避免重复劳动(与协调者指令同级优先)
|
||||
|
||||
- 若 **`description` / 用户消息 / 上文交接包** 中已给出资产列表、枚举结论或明确写「跳过全量枚举 / 仅做增量 / 从端口扫描或验证开始」,则**不得**为走完整流程而重新执行等价的广域子域爆破或相同参数集的枚举;仅在交接包声明的**缺口**上补充侦察。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: reporting-remediation
|
||||
name: 报告撰写与修复建议专员
|
||||
description: 将已收集的证据汇总为可交付报告结构,并给出面向修复的建议与回归验证要点。
|
||||
description: 将已收集的证据汇总为可交付报告结构,并给出面向修复的建议与回归验证要点;要求主 Agent 提供完整目标与证据上下文。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,12 @@ max_iterations: 0
|
||||
|
||||
你是授权安全评估流程中的**报告撰写与修复建议子代理**。你的任务是把多阶段输出的证据统一成结构化发现,并提供可执行的修复与验证建议。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 若缺失目标信息、范围说明、证据来源或阶段结论,不得直接输出最终报告结论。
|
||||
- 必须先返回缺失信息清单给主 Agent,等待补齐后再生成报告。
|
||||
|
||||
## 禁止项(必须遵守)
|
||||
- 不输出可用于未授权入侵的武器化利用细节(例如具体payload、绕过参数、可直接落地的攻击脚本)。
|
||||
- 禁止再次调用 `task`。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: vulnerability-triage
|
||||
name: 漏洞分诊专员
|
||||
description: 基于攻击面与证据线索进行漏洞候选筛选、优先级排序与“验证路径”设计(以证据为中心,不直接武器化)。
|
||||
description: 基于攻击面与证据线索进行漏洞候选筛选、优先级排序与“验证路径”设计(以证据为中心,不直接武器化),并要求主 Agent 提供完整目标与输入证据。
|
||||
tools: []
|
||||
max_iterations: 0
|
||||
---
|
||||
@@ -23,6 +23,12 @@ max_iterations: 0
|
||||
|
||||
你是授权安全评估流程中的**漏洞分诊/验证路径规划子代理**。你不负责直接交付可用于未授权入侵的利用步骤;你的工作是把“可能问题”转化为“可验证的安全假设”,并明确需要什么证据来确认或否定。
|
||||
|
||||
## 输入前置条件(硬约束)
|
||||
|
||||
- 你默认不拥有父代理完整上下文,仅以本次 `task.description` 为准。
|
||||
- 若未提供明确目标(URL / IP:Port / 域名 + 路径)与上游证据输入,禁止直接开展分诊结论输出。
|
||||
- 必须先向主 Agent 返回缺失字段(目标、范围、证据源、成功标准),不得自行猜测或补造前提。
|
||||
|
||||
## 禁止项(必须遵守)
|
||||
- 不输出可直接执行的利用链/payload/持久化参数等武器化内容。
|
||||
- 不进行破坏性操作或高风险测试;如需操作,优先“只读验证/最小影响验证”。
|
||||
|
||||
+28
-4
@@ -1,11 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cyberstrike-ai/internal/app"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/logger"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -31,15 +35,35 @@ func main() {
|
||||
// 初始化日志
|
||||
log := logger.New(cfg.Log.Level, cfg.Log.Output)
|
||||
|
||||
// 创建可取消的根 context,用于优雅关闭
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// 监听系统信号
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// 创建应用
|
||||
application, err := app.New(cfg, log)
|
||||
if err != nil {
|
||||
log.Fatal("应用初始化失败", "error", err)
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
if err := application.Run(); err != nil {
|
||||
log.Fatal("服务器启动失败", "error", err)
|
||||
// 在后台监听信号
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
log.Info("收到系统信号,开始优雅关闭: " + sig.String())
|
||||
application.Shutdown()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// 启动服务器(传入 context 以支持优雅关闭)
|
||||
if err := application.RunWithContext(ctx); err != nil {
|
||||
// context 取消导致的关闭不视为错误
|
||||
if ctx.Err() != nil {
|
||||
log.Info("服务器已优雅关闭")
|
||||
} else {
|
||||
log.Fatal("服务器启动失败", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-11
@@ -37,21 +37,15 @@ func main() {
|
||||
fmt.Printf(" URL: %s\n", srv.URL)
|
||||
fmt.Printf(" Description: %s\n", srv.Description)
|
||||
fmt.Printf(" Timeout: %d seconds\n", srv.Timeout)
|
||||
fmt.Printf(" Enabled: %v\n", srv.Enabled)
|
||||
fmt.Printf(" Disabled: %v\n", srv.Disabled)
|
||||
fmt.Printf(" ExternalMCPEnable: %v\n", srv.ExternalMCPEnable)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func getTransport(srv config.ExternalMCPServerConfig) string {
|
||||
if srv.Transport != "" {
|
||||
return srv.Transport
|
||||
t := srv.GetTransportType()
|
||||
if t == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if srv.Command != "" {
|
||||
return "stdio"
|
||||
}
|
||||
if srv.URL != "" {
|
||||
return "http"
|
||||
}
|
||||
return "unknown"
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -52,8 +52,7 @@ func main() {
|
||||
}
|
||||
fmt.Printf(" Description: %s\n", srv.Description)
|
||||
fmt.Printf(" Timeout: %d seconds\n", srv.Timeout)
|
||||
fmt.Printf(" Enabled: %v\n", srv.Enabled)
|
||||
fmt.Printf(" Disabled: %v\n", srv.Disabled)
|
||||
fmt.Printf(" ExternalMCPEnable: %v\n", srv.ExternalMCPEnable)
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
@@ -67,7 +66,7 @@ func main() {
|
||||
// 测试启动(仅测试启用的)
|
||||
fmt.Println("\n=== 测试启动 ===")
|
||||
for name, srv := range cfg.ExternalMCP.Servers {
|
||||
if srv.Enabled && !srv.Disabled {
|
||||
if srv.ExternalMCPEnable {
|
||||
fmt.Printf("\n尝试启动 %s...\n", name)
|
||||
// 注意:实际启动可能会失败,因为需要真实的MCP服务器
|
||||
err := manager.StartClient(name)
|
||||
@@ -131,15 +130,10 @@ func main() {
|
||||
}
|
||||
|
||||
func getTransport(srv config.ExternalMCPServerConfig) string {
|
||||
if srv.Transport != "" {
|
||||
return srv.Transport
|
||||
t := srv.GetTransportType()
|
||||
if t == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if srv.Command != "" {
|
||||
return "stdio"
|
||||
}
|
||||
if srv.URL != "" {
|
||||
return "http"
|
||||
}
|
||||
return "unknown"
|
||||
return t
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.5.3"
|
||||
version: "v1.5.5"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -58,18 +58,22 @@ agent:
|
||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
||||
tool_timeout_minutes: 30 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||
# system_prompt_path: prompts/single-react.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
||||
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
|
||||
hitl:
|
||||
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
||||
tool_whitelist: [read_file, list_dir, glob, grep]
|
||||
# 多代理(CloudWeGo Eino DeepAgent,与上方单 Agent /api/agent-loop 并存)
|
||||
# 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct
|
||||
# 启用后需重启服务才会注册 /api/multi-agent 与 /api/multi-agent/stream;Deep / Plan-Execute / Supervisor 由对话页与 WebShell 所选模式在请求体中传入;机器人/批量无请求体时固定按 deep
|
||||
multi_agent:
|
||||
enabled: true
|
||||
default_mode: multi # single | multi(前端默认,仍可用界面切换)
|
||||
robot_use_multi_agent: true # true 时企业微信/钉钉/飞书机器人也走 Eino 多代理(成本更高)
|
||||
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
|
||||
max_iteration: 0 # 主代理 / plan_execute 执行器最大轮次,0 表示沿用 agent.max_iterations
|
||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。Executor 未暴露 Handlers:patch/reduction/plantask 不作用于 PE,但 tool_search 工具列表拆分仍通过共享 ToolsConfig 作用于执行器。
|
||||
plan_execute_loop_max_iterations: 0
|
||||
sub_agent_max_iterations: 120
|
||||
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中自动注入用户原始请求的字符上限;0=默认2000,负数=禁用
|
||||
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
||||
without_write_todos: false
|
||||
orchestrator_instruction: "" # Deep 主代理:agents/orchestrator.md(或 kind: orchestrator 的单个 .md)正文优先;正文为空时用此处;皆空则 Eino 默认
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 依赖与代理 | `go.mod` 直接依赖 `github.com/cloudwego/eino`、`eino-ext/.../openai`;`go.mod` 注释与 `scripts/bootstrap-go.sh` 指导 **GOPROXY**(如 `https://goproxy.cn,direct`)。 |
|
||||
| 配置 | `config.yaml` → `multi_agent`:`enabled`、`default_mode`、`robot_use_multi_agent`、`max_iteration`、`sub_agents`(含可选 `bind_role`)、`eino_skills`、`eino_middleware` 等;结构体见 `internal/config/config.go`。 |
|
||||
| 配置 | `config.yaml` → `multi_agent`:`enabled`、`robot_use_multi_agent`、`max_iteration`、`sub_agents`(含可选 `bind_role`)、`eino_skills`、`eino_middleware` 等;结构体见 `internal/config/config.go`。 |
|
||||
| Markdown 子代理 / 主代理 | 在 `agents_dir` 下放 `*.md`。**子代理**:供 Deep `task` 与 `supervisor` `transfer`。**主代理(按模式分离)**:`orchestrator.md`(或 `kind: orchestrator` 的**单个**其他 .md)→ **Deep**;固定名 `orchestrator-plan-execute.md` → **plan_execute**;固定名 `orchestrator-supervisor.md` → **supervisor**。正文优先于 YAML:`multi_agent.orchestrator_instruction`、`orchestrator_instruction_plan_execute`、`orchestrator_instruction_supervisor`;plan_execute / supervisor **不会**回退到 Deep 的 `orchestrator_instruction`。皆空时 plan_execute / supervisor 使用代码内置默认提示。管理:**Agents → Agent管理**;API:`/api/multi-agent/markdown-agents*`。 |
|
||||
| MCP 桥 | `internal/einomcp`:`ToolsFromDefinitions` + 会话 ID 持有者,执行走 `Agent.ExecuteMCPToolForConversation`。 |
|
||||
| 编排 | `internal/multiagent/runner.go`:`deep.New` + 子 `ChatModelAgent` + `adk.NewRunner`(`EnableStreaming: true`,可选 `CheckPointStore`),事件映射为现有 SSE `tool_call` / `response_delta` 等。 |
|
||||
@@ -22,7 +22,7 @@
|
||||
| 前端 | 主聊天 / WebShell:`multi_agent.enabled` 时可选 **原生 ReAct** 与三种 Eino 命名,多代理路径在 JSON 中带 `orchestration`。设置页不再配置预置编排项;`plan_execute` 外层循环上限等仍可在设置中保存。 |
|
||||
| 流式兼容 | 与 `/api/agent-loop/stream` 共用 `handleStreamEvent`:`conversation`、`progress`、`response_start` / `response_delta`、`thinking` / `thinking_stream_*`(模型 `ReasoningContent`)、`tool_*`、`response`、`done` 等;`tool_result` 带 `toolCallId` 与 `tool_call` 联动;`data.mcpExecutionIds` 与进度 i18n 已对齐。 |
|
||||
| 批量任务 | 队列 `agentMode` 为 `deep` / `plan_execute` / `supervisor` 时子任务带对应 `orchestration` 调用 `RunDeepAgent`;旧值 `multi` 与「`agentMode` 为空且 `batch_use_multi_agent: true`」均按 `deep`。 |
|
||||
| 配置 API | `GET /api/config` 返回 `multi_agent: { enabled, default_mode, robot_use_multi_agent, sub_agent_count }`;`PUT /api/config` 可更新前三项(不覆盖 `sub_agents`)。 |
|
||||
| 配置 API | `GET /api/config` 返回 `multi_agent: { enabled, robot_use_multi_agent, sub_agent_count }`;`PUT /api/config` 可更新 `enabled`、`robot_use_multi_agent`(不覆盖 `sub_agents`)。 |
|
||||
| OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
|
||||
| 机器人 | `ProcessMessageForRobot` 在 `enabled && robot_use_multi_agent` 时调用 `multiagent.RunDeepAgent`。 |
|
||||
| 预置编排 | 聊天 / WebShell:`POST /api/multi-agent*` 请求体 `orchestration`:`deep` \| `plan_execute` \| `supervisor`(缺省 `deep`)。`plan_execute` 不构建 YAML/Markdown 子代理;`plan_execute_loop_max_iterations` 仍来自配置。`supervisor` 至少需一个子代理。 |
|
||||
|
||||
+81
-22
@@ -53,6 +53,37 @@ type ResultStorage interface {
|
||||
DeleteResult(executionID string) error
|
||||
}
|
||||
|
||||
type toolCallInterceptorCtxKey struct{}
|
||||
|
||||
type agentConversationIDKey struct{}
|
||||
|
||||
func withAgentConversationID(ctx context.Context, id string) context.Context {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || ctx == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, agentConversationIDKey{}, id)
|
||||
}
|
||||
|
||||
func agentConversationIDFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := ctx.Value(agentConversationIDKey{}).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// ToolCallInterceptor allows caller to gate or rewrite tool arguments just before execution.
|
||||
// Returning a non-nil error means the tool call is rejected and execution is skipped.
|
||||
type ToolCallInterceptor func(ctx context.Context, toolName string, args map[string]interface{}, toolCallID string) (map[string]interface{}, error)
|
||||
|
||||
func WithToolCallInterceptor(ctx context.Context, fn ToolCallInterceptor) context.Context {
|
||||
if fn == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, toolCallInterceptorCtxKey{}, fn)
|
||||
}
|
||||
|
||||
// NewAgent 创建新的Agent
|
||||
func NewAgent(cfg *config.OpenAIConfig, agentCfg *config.AgentConfig, mcpServer *mcp.Server, externalMCPMgr *mcp.ExternalMCPManager, logger *zap.Logger, maxIterations int) *Agent {
|
||||
// 如果 maxIterations 为 0 或负数,使用默认值 30
|
||||
@@ -348,7 +379,8 @@ func (a *Agent) EinoSingleAgentSystemInstruction() string {
|
||||
|
||||
// AgentLoopWithProgress 执行Agent循环(带进度回调和对话ID)
|
||||
func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, historyMessages []ChatMessage, conversationID string, callback ProgressCallback, roleTools []string) (*AgentLoopResult, error) {
|
||||
// 设置当前对话ID
|
||||
ctx = withAgentConversationID(ctx, conversationID)
|
||||
// 设置当前对话ID(兼容未走 context 的旧路径;并发会话应以 context 为准)
|
||||
a.mu.Lock()
|
||||
a.currentConversationID = conversationID
|
||||
a.mu.Unlock()
|
||||
@@ -653,22 +685,49 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
"iteration": i + 1,
|
||||
})
|
||||
|
||||
execArgs := toolCall.Function.Arguments
|
||||
if interceptor, ok := ctx.Value(toolCallInterceptorCtxKey{}).(ToolCallInterceptor); ok && interceptor != nil {
|
||||
newArgs, interceptErr := interceptor(ctx, toolCall.Function.Name, execArgs, toolCall.ID)
|
||||
if interceptErr != nil {
|
||||
errorMsg := fmt.Sprintf("工具调用被人工拒绝: %v", interceptErr)
|
||||
messages = append(messages, ChatMessage{
|
||||
Role: "tool",
|
||||
ToolCallID: toolCall.ID,
|
||||
Content: errorMsg,
|
||||
})
|
||||
sendProgress("tool_result", fmt.Sprintf("工具 %s 执行失败", toolCall.Function.Name), map[string]interface{}{
|
||||
"toolName": toolCall.Function.Name,
|
||||
"success": false,
|
||||
"isError": true,
|
||||
"error": errorMsg,
|
||||
"toolCallId": toolCall.ID,
|
||||
"index": idx + 1,
|
||||
"total": len(choice.Message.ToolCalls),
|
||||
"iteration": i + 1,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if newArgs != nil {
|
||||
execArgs = newArgs
|
||||
}
|
||||
}
|
||||
|
||||
// 执行工具
|
||||
toolCtx := context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(chunk string) {
|
||||
if strings.TrimSpace(chunk) == "" {
|
||||
return
|
||||
}
|
||||
sendProgress("tool_result_delta", chunk, map[string]interface{}{
|
||||
"toolName": toolCall.Function.Name,
|
||||
"toolCallId": toolCall.ID,
|
||||
"index": idx + 1,
|
||||
"total": len(choice.Message.ToolCalls),
|
||||
"iteration": i + 1,
|
||||
"toolName": toolCall.Function.Name,
|
||||
"toolCallId": toolCall.ID,
|
||||
"index": idx + 1,
|
||||
"total": len(choice.Message.ToolCalls),
|
||||
"iteration": i + 1,
|
||||
// success 在最终 tool_result 事件里会以 success/isError 标记为准
|
||||
})
|
||||
}))
|
||||
|
||||
execResult, err := a.executeToolViaMCP(toolCtx, toolCall.Function.Name, toolCall.Function.Arguments)
|
||||
execResult, err := a.executeToolViaMCP(toolCtx, toolCall.Function.Name, execArgs)
|
||||
if err != nil {
|
||||
// 构建详细的错误信息,帮助AI理解问题并做出决策
|
||||
errorMsg := a.formatToolError(toolCall.Function.Name, toolCall.Function.Arguments, err)
|
||||
@@ -746,7 +805,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
|
||||
sendProgress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"messageGeneratedBy": "summary",
|
||||
})
|
||||
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||
@@ -793,7 +852,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
|
||||
sendProgress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"messageGeneratedBy": "summary",
|
||||
})
|
||||
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||
@@ -840,7 +899,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
|
||||
sendProgress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"messageGeneratedBy": "max_iter_summary",
|
||||
})
|
||||
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||
@@ -913,17 +972,13 @@ func (a *Agent) getAvailableTools(roleTools []string) []Tool {
|
||||
defer cancel()
|
||||
|
||||
externalTools, err := a.externalMCPMgr.GetAllTools(ctx)
|
||||
extMap := make(map[string]string)
|
||||
if err != nil {
|
||||
a.logger.Warn("获取外部MCP工具失败", zap.Error(err))
|
||||
} else {
|
||||
// 获取外部MCP配置,用于检查工具启用状态
|
||||
externalMCPConfigs := a.externalMCPMgr.GetConfigs()
|
||||
|
||||
// 清空并重建工具名称映射
|
||||
a.mu.Lock()
|
||||
a.toolNameMapping = make(map[string]string)
|
||||
a.mu.Unlock()
|
||||
|
||||
// 将外部MCP工具添加到工具列表(只添加启用的工具)
|
||||
for _, externalTool := range externalTools {
|
||||
// 外部工具使用 "mcpName::toolName" 作为toolKey
|
||||
@@ -949,7 +1004,7 @@ func (a *Agent) getAvailableTools(roleTools []string) []Tool {
|
||||
enabled := false
|
||||
if cfg, exists := externalMCPConfigs[mcpName]; exists {
|
||||
// 首先检查外部MCP是否启用
|
||||
if !cfg.ExternalMCPEnable && !(cfg.Enabled && !cfg.Disabled) {
|
||||
if !cfg.ExternalMCPEnable {
|
||||
enabled = false // MCP未启用,所有工具都禁用
|
||||
} else {
|
||||
// MCP已启用,检查单个工具的启用状态
|
||||
@@ -983,9 +1038,7 @@ func (a *Agent) getAvailableTools(roleTools []string) []Tool {
|
||||
openAIName := strings.ReplaceAll(externalTool.Name, "::", "__")
|
||||
|
||||
// 保存名称映射关系(OpenAI格式 -> 原始格式)
|
||||
a.mu.Lock()
|
||||
a.toolNameMapping[openAIName] = externalTool.Name
|
||||
a.mu.Unlock()
|
||||
extMap[openAIName] = externalTool.Name
|
||||
|
||||
tools = append(tools, Tool{
|
||||
Type: "function",
|
||||
@@ -997,6 +1050,9 @@ func (a *Agent) getAvailableTools(roleTools []string) []Tool {
|
||||
})
|
||||
}
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.toolNameMapping = extMap
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
a.logger.Debug("获取可用工具列表",
|
||||
@@ -1390,9 +1446,12 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
||||
|
||||
// 如果是record_vulnerability工具,自动添加conversation_id
|
||||
if toolName == builtin.ToolRecordVulnerability {
|
||||
a.mu.RLock()
|
||||
conversationID := a.currentConversationID
|
||||
a.mu.RUnlock()
|
||||
conversationID := agentConversationIDFromContext(ctx)
|
||||
if conversationID == "" {
|
||||
a.mu.RLock()
|
||||
conversationID = a.currentConversationID
|
||||
a.mu.RUnlock()
|
||||
}
|
||||
|
||||
if conversationID != "" {
|
||||
args["conversation_id"] = conversationID
|
||||
|
||||
+65
-14
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -325,6 +326,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
||||
registerWebshellManagementTools(mcpServer, db, webshellHandler, log.Logger)
|
||||
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
||||
agentHandler.SetHitlToolWhitelistSaver(configHandler)
|
||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
||||
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
||||
skillsHandler := handler.NewSkillsHandler(cfg, configPath, log.Logger)
|
||||
@@ -459,7 +461,9 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
func (a *App) mcpHandlerWithAuth(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.config.MCP
|
||||
if cfg.AuthHeader != "" {
|
||||
if r.Header.Get(cfg.AuthHeader) != cfg.AuthHeaderValue {
|
||||
actual := []byte(r.Header.Get(cfg.AuthHeader))
|
||||
expected := []byte(cfg.AuthHeaderValue)
|
||||
if subtle.ConstantTimeCompare(actual, expected) != 1 {
|
||||
a.logger.Logger.Debug("MCP 鉴权失败:header 缺失或值不匹配", zap.String("header", cfg.AuthHeader))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
@@ -470,18 +474,25 @@ func (a *App) mcpHandlerWithAuth(w http.ResponseWriter, r *http.Request) {
|
||||
a.mcpServer.HandleHTTP(w, r)
|
||||
}
|
||||
|
||||
// Run 启动应用
|
||||
// Run 启动应用(向后兼容,不支持优雅关闭)
|
||||
func (a *App) Run() error {
|
||||
return a.RunWithContext(context.Background())
|
||||
}
|
||||
|
||||
// RunWithContext 启动应用,支持通过 context 取消来优雅关闭
|
||||
func (a *App) RunWithContext(ctx context.Context) error {
|
||||
// 启动MCP服务器(如果启用)
|
||||
var mcpServer *http.Server
|
||||
if a.config.MCP.Enabled {
|
||||
mcpAddr := fmt.Sprintf("%s:%d", a.config.MCP.Host, a.config.MCP.Port)
|
||||
a.logger.Info("启动MCP服务器", zap.String("address", mcpAddr))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/mcp", a.mcpHandlerWithAuth)
|
||||
|
||||
mcpServer = &http.Server{Addr: mcpAddr, Handler: mux}
|
||||
go func() {
|
||||
mcpAddr := fmt.Sprintf("%s:%d", a.config.MCP.Host, a.config.MCP.Port)
|
||||
a.logger.Info("启动MCP服务器", zap.String("address", mcpAddr))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/mcp", a.mcpHandlerWithAuth)
|
||||
|
||||
if err := http.ListenAndServe(mcpAddr, mux); err != nil {
|
||||
if err := mcpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
a.logger.Error("MCP服务器启动失败", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
@@ -491,7 +502,27 @@ func (a *App) Run() error {
|
||||
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
|
||||
a.logger.Info("启动HTTP服务器", zap.String("address", addr))
|
||||
|
||||
return a.router.Run(addr)
|
||||
srv := &http.Server{Addr: addr, Handler: a.router}
|
||||
|
||||
// 监听 context 取消,优雅关闭 HTTP 服务器
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
a.logger.Error("HTTP服务器关闭失败", zap.Error(err))
|
||||
}
|
||||
if mcpServer != nil {
|
||||
if err := mcpServer.Shutdown(shutdownCtx); err != nil {
|
||||
a.logger.Error("MCP服务器关闭失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown 关闭应用
|
||||
@@ -519,6 +550,13 @@ func (a *App) Shutdown() {
|
||||
a.logger.Logger.Warn("关闭知识库数据库连接失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭主数据库连接
|
||||
if a.db != nil {
|
||||
if err := a.db.Close(); err != nil {
|
||||
a.logger.Logger.Warn("关闭主数据库连接失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startRobotConnections 根据当前配置启动钉钉/飞书长连接(不先关闭已有连接,仅用于首次启动)
|
||||
@@ -593,10 +631,16 @@ func setupRoutes(
|
||||
}
|
||||
|
||||
// 机器人回调(无需登录,供企业微信/钉钉/飞书服务器调用)
|
||||
api.GET("/robot/wecom", robotHandler.HandleWecomGET)
|
||||
api.POST("/robot/wecom", robotHandler.HandleWecomPOST)
|
||||
api.POST("/robot/dingtalk", robotHandler.HandleDingtalkPOST)
|
||||
api.POST("/robot/lark", robotHandler.HandleLarkPOST)
|
||||
// 添加速率限制:每个 IP 每分钟最多 60 次请求,防止滥用
|
||||
robotRL := security.NewRateLimiter(60, 1*time.Minute)
|
||||
robotGroup := api.Group("/robot")
|
||||
robotGroup.Use(security.RateLimitMiddleware(robotRL))
|
||||
{
|
||||
robotGroup.GET("/wecom", robotHandler.HandleWecomGET)
|
||||
robotGroup.POST("/wecom", robotHandler.HandleWecomPOST)
|
||||
robotGroup.POST("/dingtalk", robotHandler.HandleDingtalkPOST)
|
||||
robotGroup.POST("/lark", robotHandler.HandleLarkPOST)
|
||||
}
|
||||
|
||||
protected := api.Group("")
|
||||
protected.Use(security.AuthMiddleware(authManager))
|
||||
@@ -611,9 +655,15 @@ func setupRoutes(
|
||||
// Eino ADK 单代理(ChatModelAgent + Runner;不依赖 multi_agent.enabled)
|
||||
protected.POST("/eino-agent", agentHandler.EinoSingleAgentLoop)
|
||||
protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream)
|
||||
protected.GET("/hitl/pending", agentHandler.ListHITLPending)
|
||||
protected.POST("/hitl/decision", agentHandler.DecideHITLInterrupt)
|
||||
protected.GET("/hitl/config/:conversationId", agentHandler.GetHITLConversationConfig)
|
||||
protected.PUT("/hitl/config", agentHandler.UpsertHITLConversationConfig)
|
||||
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
|
||||
// Agent Loop 取消与任务列表
|
||||
protected.POST("/agent-loop/cancel", agentHandler.CancelAgentLoop)
|
||||
protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks)
|
||||
protected.GET("/agent-loop/task-events", agentHandler.SubscribeAgentTaskEvents)
|
||||
protected.GET("/agent-loop/tasks/completed", agentHandler.ListCompletedTasks)
|
||||
|
||||
// Eino DeepAgent 多代理(与单 Agent 并存,需 config.multi_agent.enabled)
|
||||
@@ -680,6 +730,7 @@ func setupRoutes(
|
||||
// 配置管理
|
||||
protected.GET("/config", configHandler.GetConfig)
|
||||
protected.GET("/config/tools", configHandler.GetTools)
|
||||
protected.GET("/config/tools/:name/schema", configHandler.GetToolSchema)
|
||||
protected.PUT("/config", configHandler.UpdateConfig)
|
||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
||||
|
||||
+79
-50
@@ -22,6 +22,7 @@ type Config struct {
|
||||
OpenAI OpenAIConfig `yaml:"openai"`
|
||||
FOFA FofaConfig `yaml:"fofa,omitempty" json:"fofa,omitempty"`
|
||||
Agent AgentConfig `yaml:"agent"`
|
||||
Hitl HitlConfig `yaml:"hitl,omitempty" json:"hitl,omitempty"`
|
||||
Security SecurityConfig `yaml:"security"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
@@ -37,24 +38,26 @@ type Config struct {
|
||||
|
||||
// MultiAgentConfig 基于 CloudWeGo Eino adk/prebuilt 的多代理编排(deep | plan_execute | supervisor,与单 Agent /agent-loop 并存)。
|
||||
type MultiAgentConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
DefaultMode string `yaml:"default_mode" json:"default_mode"` // single | multi,供前端默认展示
|
||||
RobotUseMultiAgent bool `yaml:"robot_use_multi_agent" json:"robot_use_multi_agent"` // 为 true 时钉钉/飞书/企微机器人走 Eino 多代理
|
||||
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
RobotUseMultiAgent bool `yaml:"robot_use_multi_agent" json:"robot_use_multi_agent"` // 为 true 时钉钉/飞书/企微机器人走 Eino 多代理
|
||||
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
|
||||
// Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。
|
||||
Orchestration string `yaml:"orchestration,omitempty" json:"orchestration,omitempty"`
|
||||
MaxIteration int `yaml:"max_iteration" json:"max_iteration"` // 主代理 / 执行器最大推理轮次(Deep、Supervisor、plan_execute 的 Executor)
|
||||
// PlanExecuteLoopMaxIterations plan_execute 模式下 execute↔replan 外层循环上限;0 表示用 Eino 默认 10。
|
||||
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
|
||||
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations" json:"sub_agent_max_iterations"`
|
||||
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
|
||||
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
|
||||
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
|
||||
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
|
||||
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations" json:"sub_agent_max_iterations"`
|
||||
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
|
||||
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
|
||||
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
|
||||
// OrchestratorInstructionPlanExecute plan_execute 主代理(规划侧)系统提示;非空且 agents/orchestrator-plan-execute.md 正文为空或未存在时生效。不与 Deep 的 orchestrator_instruction 混用。
|
||||
OrchestratorInstructionPlanExecute string `yaml:"orchestrator_instruction_plan_execute,omitempty" json:"orchestrator_instruction_plan_execute,omitempty"`
|
||||
// OrchestratorInstructionSupervisor supervisor 主代理系统提示(transfer/exit 说明仍由运行追加);非空且 agents/orchestrator-supervisor.md 正文为空或未存在时生效。
|
||||
OrchestratorInstructionSupervisor string `yaml:"orchestrator_instruction_supervisor,omitempty" json:"orchestrator_instruction_supervisor,omitempty"`
|
||||
SubAgents []MultiAgentSubConfig `yaml:"sub_agents" json:"sub_agents"`
|
||||
OrchestratorInstructionSupervisor string `yaml:"orchestrator_instruction_supervisor,omitempty" json:"orchestrator_instruction_supervisor,omitempty"`
|
||||
SubAgents []MultiAgentSubConfig `yaml:"sub_agents" json:"sub_agents"`
|
||||
// SubAgentUserContextMaxRunes caps the user-context supplement appended to task descriptions for sub-agents.
|
||||
// 0 (default) uses the built-in default of 2000 runes; negative value disables injection entirely.
|
||||
SubAgentUserContextMaxRunes int `yaml:"sub_agent_user_context_max_runes,omitempty" json:"sub_agent_user_context_max_runes,omitempty"`
|
||||
// EinoSkills configures CloudWeGo Eino ADK skill middleware + optional local filesystem/execute on DeepAgent.
|
||||
EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
|
||||
// EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras.
|
||||
@@ -74,10 +77,10 @@ type MultiAgentEinoMiddlewareConfig struct {
|
||||
// PlantaskRelDir relative to skills_dir for per-conversation task boards (default .eino/plantask).
|
||||
PlantaskRelDir string `yaml:"plantask_rel_dir,omitempty" json:"plantask_rel_dir,omitempty"`
|
||||
// Reduction truncates/offloads large tool outputs (requires eino local backend for Write).
|
||||
ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"`
|
||||
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // default: os temp + conversation id
|
||||
ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"`
|
||||
ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents
|
||||
ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"`
|
||||
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // default: os temp + conversation id
|
||||
ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"`
|
||||
ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents
|
||||
// CheckpointDir when non-empty enables adk.Runner CheckPointStore (file-backed) for interrupt/resume persistence.
|
||||
CheckpointDir string `yaml:"checkpoint_dir,omitempty" json:"checkpoint_dir,omitempty"`
|
||||
// DeepOutputKey passed to deep.Config OutputKey (session final text); empty = off.
|
||||
@@ -129,7 +132,6 @@ type MultiAgentSubConfig struct {
|
||||
// MultiAgentPublic 返回给前端的精简信息(不含子代理指令全文)。
|
||||
type MultiAgentPublic struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DefaultMode string `json:"default_mode"`
|
||||
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
|
||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||
SubAgentCount int `json:"sub_agent_count"`
|
||||
@@ -152,11 +154,10 @@ func NormalizeMultiAgentOrchestration(s string) string {
|
||||
|
||||
// MultiAgentAPIUpdate 设置页/API 仅更新多代理标量字段;写入 YAML 时不覆盖 sub_agents 等块。
|
||||
type MultiAgentAPIUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DefaultMode string `json:"default_mode"`
|
||||
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
|
||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
|
||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
||||
}
|
||||
|
||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
|
||||
@@ -244,6 +245,13 @@ type AgentConfig struct {
|
||||
SystemPromptPath string `yaml:"system_prompt_path,omitempty" json:"system_prompt_path,omitempty"`
|
||||
}
|
||||
|
||||
// HitlConfig 人机协同全局选项;与会话侧栏/API 中的白名单合并为并集后参与判定。
|
||||
// tool_whitelist 可在侧栏「应用」时合并写入 config.yaml 并立即生效;其他字段若仅改文件仍需重启。
|
||||
type HitlConfig struct {
|
||||
// ToolWhitelist 全局免审批工具名(与每条会话配置的 sensitiveTools 语义相同:白名单内工具不触发 HITL)。
|
||||
ToolWhitelist []string `yaml:"tool_whitelist,omitempty" json:"tool_whitelist,omitempty"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Password string `yaml:"password" json:"password"`
|
||||
SessionDurationHours int `yaml:"session_duration_hours" json:"session_duration_hours"`
|
||||
@@ -257,28 +265,52 @@ type ExternalMCPConfig struct {
|
||||
Servers map[string]ExternalMCPServerConfig `yaml:"servers,omitempty" json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
// ExternalMCPServerConfig 外部MCP服务器配置
|
||||
// ExternalMCPServerConfig 外部MCP服务器配置(遵循官方 MCP 配置格式,兼容 Claude Desktop / Cursor / VS Code)。
|
||||
// 所有字符串字段均支持 ${VAR} 和 ${VAR:-default} 环境变量展开语法。
|
||||
type ExternalMCPServerConfig struct {
|
||||
// stdio模式配置
|
||||
// 传输类型: "stdio" | "sse" | "http"(Streamable HTTP)。
|
||||
// stdio 模式可省略,有 command 字段时自动推断。
|
||||
Type string `yaml:"type,omitempty" json:"type,omitempty"`
|
||||
|
||||
// stdio 模式配置
|
||||
Command string `yaml:"command,omitempty" json:"command,omitempty"`
|
||||
Args []string `yaml:"args,omitempty" json:"args,omitempty"`
|
||||
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` // 环境变量(用于stdio模式)
|
||||
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
|
||||
|
||||
// HTTP模式配置
|
||||
Transport string `yaml:"transport,omitempty" json:"transport,omitempty"` // "stdio" | "sse" | "http"(Streamable) | "simple_http"(自建/简单POST端点,如本机 http://127.0.0.1:8081/mcp)
|
||||
URL string `yaml:"url,omitempty" json:"url,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` // HTTP/SSE 请求头(如 x-api-key)
|
||||
// HTTP/SSE 模式配置
|
||||
URL string `yaml:"url,omitempty" json:"url,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||
|
||||
// 官方标准字段
|
||||
Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` // 禁用服务器(官方字段)
|
||||
AutoApprove []string `yaml:"autoApprove,omitempty" json:"autoApprove,omitempty"` // 自动批准的工具列表(官方字段)
|
||||
|
||||
// SDK 高级配置(对应 MCP Go SDK 传输层参数)
|
||||
MaxRetries int `yaml:"max_retries,omitempty" json:"max_retries,omitempty"` // Streamable HTTP 断线重连次数(默认 5)
|
||||
TerminateDuration int `yaml:"terminate_duration,omitempty" json:"terminate_duration,omitempty"` // stdio 进程优雅关闭等待秒数(默认 5)
|
||||
KeepAlive int `yaml:"keep_alive,omitempty" json:"keep_alive,omitempty"` // 客户端心跳间隔秒数(0 = 禁用)
|
||||
|
||||
// 通用配置
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` // 超时时间(秒)
|
||||
ExternalMCPEnable bool `yaml:"external_mcp_enable,omitempty" json:"external_mcp_enable,omitempty"` // 是否启用外部MCP
|
||||
ToolEnabled map[string]bool `yaml:"tool_enabled,omitempty" json:"tool_enabled,omitempty"` // 每个工具的启用状态(工具名称 -> 是否启用)
|
||||
|
||||
// 向后兼容字段(已废弃,保留用于读取旧配置)
|
||||
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` // 已废弃,使用 external_mcp_enable
|
||||
Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` // 已废弃,使用 external_mcp_enable
|
||||
Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` // 连接超时(秒)
|
||||
ExternalMCPEnable bool `yaml:"external_mcp_enable,omitempty" json:"external_mcp_enable,omitempty"` // 是否启用
|
||||
ToolEnabled map[string]bool `yaml:"tool_enabled,omitempty" json:"tool_enabled,omitempty"` // 每个工具的启用状态
|
||||
}
|
||||
|
||||
// GetTransportType 返回实际传输类型。优先读 Type,否则根据 Command/URL 自动推断。
|
||||
func (c ExternalMCPServerConfig) GetTransportType() string {
|
||||
if c.Type != "" {
|
||||
return c.Type
|
||||
}
|
||||
if c.Command != "" {
|
||||
return "stdio"
|
||||
}
|
||||
if c.URL != "" {
|
||||
return "http"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ToolConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Command string `yaml:"command"`
|
||||
@@ -369,23 +401,20 @@ func Load(path string) (*Config, error) {
|
||||
cfg.Security.Tools = tools
|
||||
}
|
||||
|
||||
// 迁移外部MCP配置:将旧的 enabled/disabled 字段迁移到 external_mcp_enable
|
||||
// 外部 MCP:迁移 + 环境变量展开
|
||||
if cfg.ExternalMCP.Servers != nil {
|
||||
for name, serverCfg := range cfg.ExternalMCP.Servers {
|
||||
// 如果已经设置了 external_mcp_enable,跳过迁移
|
||||
// 否则从 enabled/disabled 字段迁移
|
||||
// 注意:由于 ExternalMCPEnable 是 bool 类型,零值为 false,所以需要检查是否真的设置了
|
||||
// 这里我们通过检查旧的 enabled/disabled 字段来判断是否需要迁移
|
||||
// 官方 disabled 字段 → ExternalMCPEnable
|
||||
if serverCfg.Disabled {
|
||||
// 旧配置使用 disabled,迁移到 external_mcp_enable
|
||||
serverCfg.ExternalMCPEnable = false
|
||||
} else if serverCfg.Enabled {
|
||||
// 旧配置使用 enabled,迁移到 external_mcp_enable
|
||||
serverCfg.ExternalMCPEnable = true
|
||||
} else {
|
||||
// 都没有设置,默认为启用
|
||||
} else if !serverCfg.ExternalMCPEnable {
|
||||
// 默认启用
|
||||
serverCfg.ExternalMCPEnable = true
|
||||
}
|
||||
|
||||
// 展开所有 ${VAR} / ${VAR:-default} 环境变量引用
|
||||
ExpandConfigEnv(&serverCfg)
|
||||
|
||||
cfg.ExternalMCP.Servers[name] = serverCfg
|
||||
}
|
||||
}
|
||||
@@ -929,10 +958,10 @@ type RolesConfig struct {
|
||||
|
||||
// RoleConfig 单个角色配置
|
||||
type RoleConfig struct {
|
||||
Name string `yaml:"name" json:"name"` // 角色名称
|
||||
Description string `yaml:"description" json:"description"` // 角色描述
|
||||
UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前)
|
||||
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
|
||||
Name string `yaml:"name" json:"name"` // 角色名称
|
||||
Description string `yaml:"description" json:"description"` // 角色描述
|
||||
UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前)
|
||||
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
|
||||
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName")
|
||||
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
|
||||
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// expandEnvVar 展开字符串中的 ${VAR} 和 ${VAR:-default} 环境变量引用。
|
||||
// 与官方 MCP 配置格式一致(Claude Desktop / Cursor / VS Code 均支持此语法)。
|
||||
func expandEnvVar(s string) string {
|
||||
var b strings.Builder
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
// 查找 ${
|
||||
idx := strings.Index(s[i:], "${")
|
||||
if idx < 0 {
|
||||
b.WriteString(s[i:])
|
||||
break
|
||||
}
|
||||
b.WriteString(s[i : i+idx])
|
||||
i += idx + 2 // skip ${
|
||||
|
||||
// 查找对应的 }
|
||||
end := strings.IndexByte(s[i:], '}')
|
||||
if end < 0 {
|
||||
// 没有 },原样保留
|
||||
b.WriteString("${")
|
||||
continue
|
||||
}
|
||||
expr := s[i : i+end]
|
||||
i += end + 1 // skip }
|
||||
|
||||
// 解析 VAR:-default
|
||||
varName := expr
|
||||
defaultVal := ""
|
||||
hasDefault := false
|
||||
if colonIdx := strings.Index(expr, ":-"); colonIdx >= 0 {
|
||||
varName = expr[:colonIdx]
|
||||
defaultVal = expr[colonIdx+2:]
|
||||
hasDefault = true
|
||||
}
|
||||
|
||||
val := os.Getenv(varName)
|
||||
if val == "" && hasDefault {
|
||||
val = defaultVal
|
||||
}
|
||||
b.WriteString(val)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ExpandConfigEnv 展开 ExternalMCPServerConfig 中所有支持环境变量的字段。
|
||||
// 展开范围:Command、Args、Env values、URL、Headers values。
|
||||
func ExpandConfigEnv(cfg *ExternalMCPServerConfig) {
|
||||
cfg.Command = expandEnvVar(cfg.Command)
|
||||
for i, arg := range cfg.Args {
|
||||
cfg.Args[i] = expandEnvVar(arg)
|
||||
}
|
||||
for k, v := range cfg.Env {
|
||||
cfg.Env[k] = expandEnvVar(v)
|
||||
}
|
||||
cfg.URL = expandEnvVar(cfg.URL)
|
||||
for k, v := range cfg.Headers {
|
||||
cfg.Headers[k] = expandEnvVar(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandEnvVar(t *testing.T) {
|
||||
os.Setenv("TEST_MCP_VAR", "hello")
|
||||
os.Setenv("TEST_MCP_PATH", "/usr/local/bin")
|
||||
defer os.Unsetenv("TEST_MCP_VAR")
|
||||
defer os.Unsetenv("TEST_MCP_PATH")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{"plain string", "no vars here", "no vars here"},
|
||||
{"empty string", "", ""},
|
||||
{"simple var", "${TEST_MCP_VAR}", "hello"},
|
||||
{"var in middle", "prefix-${TEST_MCP_VAR}-suffix", "prefix-hello-suffix"},
|
||||
{"multiple vars", "${TEST_MCP_PATH}/${TEST_MCP_VAR}", "/usr/local/bin/hello"},
|
||||
{"missing var empty", "${NONEXISTENT_MCP_VAR_XYZ}", ""},
|
||||
{"default value used", "${NONEXISTENT_MCP_VAR_XYZ:-fallback}", "fallback"},
|
||||
{"default not used", "${TEST_MCP_VAR:-unused}", "hello"},
|
||||
{"default with path", "${NONEXISTENT_MCP_VAR_XYZ:-/tmp/default}", "/tmp/default"},
|
||||
{"unclosed brace", "${UNCLOSED", "${UNCLOSED"},
|
||||
{"dollar without brace", "$PLAIN", "$PLAIN"},
|
||||
{"empty var name", "${}", ""},
|
||||
{"default empty var", "${:-default}", "default"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := expandEnvVar(tt.input)
|
||||
if got != tt.expect {
|
||||
t.Errorf("expandEnvVar(%q) = %q, want %q", tt.input, got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandConfigEnv(t *testing.T) {
|
||||
os.Setenv("TEST_MCP_CMD", "python3")
|
||||
os.Setenv("TEST_MCP_TOKEN", "secret123")
|
||||
defer os.Unsetenv("TEST_MCP_CMD")
|
||||
defer os.Unsetenv("TEST_MCP_TOKEN")
|
||||
|
||||
cfg := &ExternalMCPServerConfig{
|
||||
Command: "${TEST_MCP_CMD}",
|
||||
Args: []string{"--token", "${TEST_MCP_TOKEN}", "${MISSING:-default_arg}"},
|
||||
Env: map[string]string{"API_KEY": "${TEST_MCP_TOKEN}", "LEVEL": "${MISSING:-INFO}"},
|
||||
URL: "https://${MISSING:-example.com}/mcp",
|
||||
Headers: map[string]string{"Authorization": "Bearer ${TEST_MCP_TOKEN}"},
|
||||
}
|
||||
|
||||
ExpandConfigEnv(cfg)
|
||||
|
||||
if cfg.Command != "python3" {
|
||||
t.Errorf("Command = %q, want %q", cfg.Command, "python3")
|
||||
}
|
||||
if cfg.Args[1] != "secret123" {
|
||||
t.Errorf("Args[1] = %q, want %q", cfg.Args[1], "secret123")
|
||||
}
|
||||
if cfg.Args[2] != "default_arg" {
|
||||
t.Errorf("Args[2] = %q, want %q", cfg.Args[2], "default_arg")
|
||||
}
|
||||
if cfg.Env["API_KEY"] != "secret123" {
|
||||
t.Errorf("Env[API_KEY] = %q, want %q", cfg.Env["API_KEY"], "secret123")
|
||||
}
|
||||
if cfg.Env["LEVEL"] != "INFO" {
|
||||
t.Errorf("Env[LEVEL] = %q, want %q", cfg.Env["LEVEL"], "INFO")
|
||||
}
|
||||
if cfg.URL != "https://example.com/mcp" {
|
||||
t.Errorf("URL = %q, want %q", cfg.URL, "https://example.com/mcp")
|
||||
}
|
||||
if cfg.Headers["Authorization"] != "Bearer secret123" {
|
||||
t.Errorf("Headers[Authorization] = %q, want %q", cfg.Headers["Authorization"], "Bearer secret123")
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,20 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// configureDBPool 设置 SQLite 连接池参数,提升并发稳定性
|
||||
func configureDBPool(db *sql.DB) {
|
||||
// SQLite 同一时间只允许一个写入者,限制连接数避免 "database is locked" 错误
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
}
|
||||
|
||||
// DB 数据库连接
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
@@ -17,11 +26,13 @@ type DB struct {
|
||||
|
||||
// NewDB 创建数据库连接
|
||||
func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1")
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开数据库失败: %w", err)
|
||||
}
|
||||
|
||||
configureDBPool(db)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
@@ -674,11 +685,13 @@ func (db *DB) migrateBatchTaskQueuesTable() error {
|
||||
|
||||
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
|
||||
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1")
|
||||
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开知识库数据库失败: %w", err)
|
||||
}
|
||||
|
||||
configureDBPool(sqlDB)
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("连接知识库数据库失败: %w", err)
|
||||
}
|
||||
|
||||
+201
-20
@@ -115,7 +115,9 @@ type AgentHandler struct {
|
||||
db *database.DB
|
||||
logger *zap.Logger
|
||||
tasks *AgentTaskManager
|
||||
taskEventBus *TaskEventBus // 镜像 SSE 事件,供刷新后订阅同一运行中任务
|
||||
batchTaskManager *BatchTaskManager
|
||||
hitlManager *HITLManager
|
||||
config *config.Config // 配置引用,用于获取角色信息
|
||||
knowledgeManager interface { // 知识库管理器接口
|
||||
LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error
|
||||
@@ -124,6 +126,13 @@ type AgentHandler struct {
|
||||
batchCronParser cron.Parser
|
||||
batchRunnerMu sync.Mutex
|
||||
batchRunning map[string]struct{}
|
||||
// hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选)
|
||||
hitlWhitelistSaver HitlToolWhitelistSaver
|
||||
}
|
||||
|
||||
// HitlToolWhitelistSaver 合并 HITL 免审批工具到全局配置并落盘
|
||||
type HitlToolWhitelistSaver interface {
|
||||
MergeHitlToolWhitelistIntoConfig(add []string) error
|
||||
}
|
||||
|
||||
// NewAgentHandler 创建新的Agent处理器
|
||||
@@ -136,16 +145,24 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
|
||||
logger.Warn("从数据库加载批量任务队列失败", zap.Error(err))
|
||||
}
|
||||
|
||||
bus := NewTaskEventBus()
|
||||
tm := NewAgentTaskManager()
|
||||
tm.SetTaskEventBus(bus)
|
||||
handler := &AgentHandler{
|
||||
agent: agent,
|
||||
db: db,
|
||||
logger: logger,
|
||||
tasks: NewAgentTaskManager(),
|
||||
tasks: tm,
|
||||
taskEventBus: bus,
|
||||
batchTaskManager: batchTaskManager,
|
||||
config: cfg,
|
||||
hitlManager: NewHITLManager(db, logger),
|
||||
batchCronParser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor),
|
||||
batchRunning: make(map[string]struct{}),
|
||||
}
|
||||
if err := handler.hitlManager.EnsureSchema(); err != nil {
|
||||
logger.Warn("初始化 HITL 表失败", zap.Error(err))
|
||||
}
|
||||
go handler.batchQueueSchedulerLoop()
|
||||
return handler
|
||||
}
|
||||
@@ -162,6 +179,11 @@ func (h *AgentHandler) SetAgentsMarkdownDir(absDir string) {
|
||||
h.agentsMarkdownDir = strings.TrimSpace(absDir)
|
||||
}
|
||||
|
||||
// SetHitlToolWhitelistSaver 设置 HITL 白名单落盘(与 ConfigHandler 配合,避免循环引用用接口)
|
||||
func (h *AgentHandler) SetHitlToolWhitelistSaver(s HitlToolWhitelistSaver) {
|
||||
h.hitlWhitelistSaver = s
|
||||
}
|
||||
|
||||
// ChatAttachment 聊天附件(用户上传的文件)
|
||||
type ChatAttachment struct {
|
||||
FileName string `json:"fileName"` // 展示用文件名
|
||||
@@ -177,10 +199,18 @@ type ChatRequest struct {
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||
Hitl *HITLRequest `json:"hitl,omitempty"`
|
||||
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
||||
Orchestration string `json:"orchestration,omitempty"`
|
||||
}
|
||||
|
||||
type HITLRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
SensitiveTools []string `json:"sensitiveTools,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxAttachments = 10
|
||||
chatUploadsDirName = "chat_uploads" // 对话附件保存的根目录(相对当前工作目录)
|
||||
@@ -462,6 +492,11 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
h.activateHITLForConversation(conversationID, req.Hitl)
|
||||
if h.hitlManager != nil {
|
||||
defer h.hitlManager.DeactivateConversation(conversationID)
|
||||
}
|
||||
|
||||
// 优先尝试从保存的ReAct数据恢复历史上下文
|
||||
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
|
||||
if err != nil {
|
||||
@@ -566,8 +601,13 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
|
||||
defer cancelWithCause(nil)
|
||||
progressCallback := h.createProgressCallback(baseCtx, cancelWithCause, conversationID, "", nil)
|
||||
baseCtx = h.injectReactHITLInterceptor(baseCtx, cancelWithCause, conversationID, "", nil)
|
||||
|
||||
// 执行Agent Loop,传入历史消息和对话ID(使用包含角色提示词的finalMessage和角色工具列表)
|
||||
result, err := h.agent.AgentLoopWithProgress(c.Request.Context(), finalMessage, agentHistoryMessages, conversationID, nil, roleTools)
|
||||
result, err := h.agent.AgentLoopWithProgress(baseCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools)
|
||||
if err != nil {
|
||||
h.logger.Error("Agent Loop执行失败", zap.Error(err))
|
||||
|
||||
@@ -661,7 +701,7 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
||||
if assistantMsg != nil {
|
||||
assistantMessageID = assistantMsg.ID
|
||||
}
|
||||
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, nil)
|
||||
progressCallback := h.createProgressCallback(ctx, nil, conversationID, assistantMessageID, nil)
|
||||
|
||||
useRobotMulti := h.config != nil && h.config.MultiAgent.Enabled && h.config.MultiAgent.RobotUseMultiAgent
|
||||
if useRobotMulti {
|
||||
@@ -755,9 +795,41 @@ type StreamEvent struct {
|
||||
|
||||
// createProgressCallback 创建进度回调函数,用于保存processDetails
|
||||
// sendEventFunc: 可选的流式事件发送函数,如果为nil则不发送流式事件
|
||||
func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{})) agent.ProgressCallback {
|
||||
func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{})) agent.ProgressCallback {
|
||||
// 用于保存tool_call事件中的参数,以便在tool_result时使用
|
||||
toolCallCache := make(map[string]map[string]interface{}) // toolCallId -> arguments
|
||||
skillCallCache := make(map[string]string) // toolCallId -> skillName
|
||||
skillToolName := "skill"
|
||||
if h.config != nil {
|
||||
if customName := strings.TrimSpace(h.config.MultiAgent.EinoSkills.SkillToolName); customName != "" {
|
||||
skillToolName = customName
|
||||
}
|
||||
}
|
||||
|
||||
extractSkillName := func(args map[string]interface{}) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"skill_name", "skillName", "name", "skill", "id", "skill_id", "skillId"} {
|
||||
if v, ok := args[key]; ok {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if s := strings.TrimSpace(vv); s != "" {
|
||||
return s
|
||||
}
|
||||
case map[string]interface{}:
|
||||
for _, nestedKey := range []string{"name", "id", "skill_name", "skillId"} {
|
||||
if nestedV, nestedOK := vv[nestedKey].(string); nestedOK {
|
||||
if s := strings.TrimSpace(nestedV); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// thinking_stream_*:不逐条落库,按 streamId 聚合,在后续关键事件前补一条可持久化的 thinking
|
||||
type thinkingBuf struct {
|
||||
@@ -840,6 +912,16 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(toolName), skillToolName) {
|
||||
toolCallID, _ := dataMap["toolCallId"].(string)
|
||||
if toolCallID != "" {
|
||||
if argumentsObj, ok := dataMap["argumentsObj"].(map[string]interface{}); ok {
|
||||
if skillName := extractSkillName(argumentsObj); skillName != "" {
|
||||
skillCallCache[toolCallID] = skillName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,6 +1035,45 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
|
||||
}
|
||||
}
|
||||
|
||||
// 记录 skills 调用统计(tool_call + tool_result 关联)
|
||||
if eventType == "tool_result" && h.db != nil {
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
toolName, _ := dataMap["toolName"].(string)
|
||||
if strings.EqualFold(strings.TrimSpace(toolName), skillToolName) {
|
||||
toolCallID, _ := dataMap["toolCallId"].(string)
|
||||
skillName := ""
|
||||
if toolCallID != "" {
|
||||
skillName = strings.TrimSpace(skillCallCache[toolCallID])
|
||||
delete(skillCallCache, toolCallID)
|
||||
}
|
||||
if skillName == "" {
|
||||
if argumentsObj, ok := dataMap["argumentsObj"].(map[string]interface{}); ok {
|
||||
skillName = strings.TrimSpace(extractSkillName(argumentsObj))
|
||||
}
|
||||
}
|
||||
if skillName != "" {
|
||||
success, ok := dataMap["success"].(bool)
|
||||
if !ok {
|
||||
if isError, okErr := dataMap["isError"].(bool); okErr {
|
||||
success = !isError
|
||||
}
|
||||
}
|
||||
successCalls := 0
|
||||
failedCalls := 0
|
||||
if success {
|
||||
successCalls = 1
|
||||
} else {
|
||||
failedCalls = 1
|
||||
}
|
||||
now := time.Now()
|
||||
if err := h.db.UpdateSkillStats(skillName, 1, successCalls, failedCalls, &now); err != nil {
|
||||
h.logger.Warn("更新Skills调用统计失败", zap.Error(err), zap.String("skill", skillName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 子代理回复流式增量不落库;结束时合并为一条 eino_agent_reply
|
||||
if assistantMessageID != "" && eventType == "eino_agent_reply_stream_end" {
|
||||
flushResponsePlan()
|
||||
@@ -1108,6 +1229,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
clientDisconnected := false
|
||||
// 与 sseKeepalive 共用:禁止并发写 ResponseWriter,否则会破坏 chunked 编码(ERR_INVALID_CHUNKED_ENCODING)。
|
||||
var sseWriteMu sync.Mutex
|
||||
var ssePublishConversationID string
|
||||
// 用于快速确认模型是否真的产生了流式 delta
|
||||
var responseDeltaCount int
|
||||
var responseStartLogged bool
|
||||
@@ -1155,7 +1277,24 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果客户端已断开,不再发送事件
|
||||
event := StreamEvent{
|
||||
Type: eventType,
|
||||
Message: message,
|
||||
Data: data,
|
||||
}
|
||||
eventJSON, errJSON := json.Marshal(event)
|
||||
if errJSON != nil {
|
||||
eventJSON = []byte(`{"type":"error","message":"marshal failed"}`)
|
||||
}
|
||||
sseLine := make([]byte, 0, len(eventJSON)+8)
|
||||
sseLine = append(sseLine, []byte("data: ")...)
|
||||
sseLine = append(sseLine, eventJSON...)
|
||||
sseLine = append(sseLine, '\n', '\n')
|
||||
if ssePublishConversationID != "" && h.taskEventBus != nil {
|
||||
h.taskEventBus.Publish(ssePublishConversationID, sseLine)
|
||||
}
|
||||
|
||||
// 如果客户端已断开,不再写入 HTTP(镜像订阅仍可收到事件)
|
||||
if clientDisconnected {
|
||||
return
|
||||
}
|
||||
@@ -1168,15 +1307,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
default:
|
||||
}
|
||||
|
||||
event := StreamEvent{
|
||||
Type: eventType,
|
||||
Message: message,
|
||||
Data: data,
|
||||
}
|
||||
eventJSON, _ := json.Marshal(event)
|
||||
|
||||
sseWriteMu.Lock()
|
||||
_, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON)
|
||||
_, err := c.Writer.Write(sseLine)
|
||||
if err != nil {
|
||||
sseWriteMu.Unlock()
|
||||
clientDisconnected = true
|
||||
@@ -1220,6 +1352,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
ssePublishConversationID = conversationID
|
||||
|
||||
// 优先尝试从保存的ReAct数据恢复历史上下文
|
||||
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
|
||||
@@ -1350,14 +1483,14 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 创建进度回调函数,复用统一逻辑
|
||||
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
|
||||
|
||||
// 创建一个独立的上下文用于任务执行,不随HTTP请求取消
|
||||
// 这样即使客户端断开连接(如刷新页面),任务也能继续执行
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||
defer timeoutCancel()
|
||||
defer cancelWithCause(nil)
|
||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||
taskCtx = h.injectReactHITLInterceptor(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||
|
||||
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
|
||||
var errorMsg string
|
||||
@@ -1606,6 +1739,51 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// SubscribeAgentTaskEvents GET SSE:订阅指定会话当前运行中任务的事件镜像(帧格式与 POST .../stream 一致),用于刷新页面或断线后接续 UI。
|
||||
func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
|
||||
conversationID := strings.TrimSpace(c.Query("conversationId"))
|
||||
if conversationID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "conversationId is required"})
|
||||
return
|
||||
}
|
||||
if h.tasks.GetTask(conversationID) == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no active task for this conversation"})
|
||||
return
|
||||
}
|
||||
if h.taskEventBus == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "task event bus unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
sub, ch := h.taskEventBus.Subscribe(conversationID)
|
||||
defer h.taskEventBus.Unsubscribe(conversationID, sub)
|
||||
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case chunk, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := c.Writer.Write(chunk); err != nil {
|
||||
return
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListAgentTasks 列出所有运行中的任务
|
||||
func (h *AgentHandler) ListAgentTasks(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -2089,14 +2267,17 @@ func (h *AgentHandler) nextBatchQueueRunAt(cronExpr string, from time.Time) (*ti
|
||||
}
|
||||
|
||||
func (h *AgentHandler) startBatchQueueExecution(queueID string, scheduled bool) (bool, error) {
|
||||
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
// 先获取执行互斥门,再读取队列状态,避免基于过时快照做判断
|
||||
if !h.markBatchQueueRunning(queueID) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
|
||||
if !exists {
|
||||
h.unmarkBatchQueueRunning(queueID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if scheduled {
|
||||
if queue.ScheduleMode != "cron" {
|
||||
h.unmarkBatchQueueRunning(queueID)
|
||||
@@ -2263,7 +2444,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
if assistantMsg != nil {
|
||||
assistantMessageID = assistantMsg.ID
|
||||
}
|
||||
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, nil)
|
||||
progressCallback := h.createProgressCallback(context.Background(), nil, conversationID, assistantMessageID, nil)
|
||||
|
||||
// 执行任务(使用包含角色提示词的finalMessage和角色工具列表)
|
||||
h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("role", queue.Role), zap.String("conversationId", conversationID))
|
||||
|
||||
@@ -543,16 +543,23 @@ func (m *BatchTaskManager) UpdateTaskStatus(queueID, taskID, status string, resu
|
||||
|
||||
// UpdateTaskStatusWithConversationID 更新任务状态(包含conversationId)
|
||||
func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, status string, result, errorMsg, conversationID string) {
|
||||
var needDBUpdate bool
|
||||
|
||||
// 在锁内只更新内存状态
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// DB 优先:先持久化,成功后再更新内存,避免重启后状态不一致
|
||||
if m.db != nil {
|
||||
if err := m.db.UpdateBatchTaskStatus(queueID, taskID, status, conversationID, result, errorMsg); err != nil {
|
||||
m.logger.Warn("batch task DB status update failed, skipping memory update",
|
||||
zap.String("queueId", queueID), zap.String("taskId", taskID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, task := range queue.Tasks {
|
||||
if task.ID == taskID {
|
||||
task.Status = status
|
||||
@@ -575,30 +582,27 @@ func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, s
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
needDBUpdate = m.db != nil
|
||||
m.mu.Unlock()
|
||||
|
||||
// 释放锁后写 DB
|
||||
if needDBUpdate {
|
||||
if err := m.db.UpdateBatchTaskStatus(queueID, taskID, status, conversationID, result, errorMsg); err != nil {
|
||||
m.logger.Warn("batch task DB status update failed", zap.String("queueId", queueID), zap.String("taskId", taskID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateQueueStatus 更新队列状态
|
||||
func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
|
||||
var needDBUpdate bool
|
||||
|
||||
// 在锁内只更新内存状态
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// DB 优先:先持久化,成功后再更新内存
|
||||
if m.db != nil {
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, status); err != nil {
|
||||
m.logger.Warn("batch queue DB status update failed, skipping memory update",
|
||||
zap.String("queueId", queueID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
queue.Status = status
|
||||
now := time.Now()
|
||||
if status == BatchQueueStatusRunning && queue.StartedAt == nil {
|
||||
@@ -607,16 +611,6 @@ func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
|
||||
if status == BatchQueueStatusCompleted || status == BatchQueueStatusCancelled {
|
||||
queue.CompletedAt = &now
|
||||
}
|
||||
|
||||
needDBUpdate = m.db != nil
|
||||
m.mu.Unlock()
|
||||
|
||||
// 释放锁后写 DB
|
||||
if needDBUpdate {
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, status); err != nil {
|
||||
m.logger.Warn("batch queue DB status update failed", zap.String("queueId", queueID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateQueueSchedule 更新队列调度配置
|
||||
@@ -756,6 +750,16 @@ func (m *BatchTaskManager) ResetQueueForRerun(queueID string) bool {
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// DB 优先:先持久化重置,成功后再更新内存,避免 DB 失败导致内存脏状态
|
||||
if m.db != nil {
|
||||
if err := m.db.ResetBatchQueueForRerun(queueID); err != nil {
|
||||
m.logger.Warn("batch queue DB reset for rerun failed, skipping memory update",
|
||||
zap.String("queueId", queueID), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
queue.Status = BatchQueueStatusPending
|
||||
queue.CurrentIndex = 0
|
||||
queue.StartedAt = nil
|
||||
@@ -771,12 +775,6 @@ func (m *BatchTaskManager) ResetQueueForRerun(queueID string) bool {
|
||||
task.Error = ""
|
||||
task.Result = ""
|
||||
}
|
||||
|
||||
if m.db != nil {
|
||||
if err := m.db.ResetBatchQueueForRerun(queueID); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -870,7 +868,7 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
||||
return fmt.Errorf("队列正在执行或未就绪,无法删除任务")
|
||||
}
|
||||
|
||||
// 查找并删除任务
|
||||
// 查找任务
|
||||
taskIndex := -1
|
||||
for i, task := range queue.Tasks {
|
||||
if task.ID == taskID {
|
||||
@@ -886,18 +884,14 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
||||
return fmt.Errorf("任务不存在")
|
||||
}
|
||||
|
||||
// 从内存队列中删除
|
||||
queue.Tasks = append(queue.Tasks[:taskIndex], queue.Tasks[taskIndex+1:]...)
|
||||
|
||||
// 同步到数据库
|
||||
// DB 优先:先从数据库删除,成功后再从内存移除
|
||||
if m.db != nil {
|
||||
if err := m.db.DeleteBatchTask(queueID, taskID); err != nil {
|
||||
// 如果数据库删除失败,恢复内存中的任务
|
||||
// 这里需要重新插入,但为了简化,我们只记录错误
|
||||
return fmt.Errorf("删除任务失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
queue.Tasks = append(queue.Tasks[:taskIndex], queue.Tasks[taskIndex+1:]...)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -987,9 +981,7 @@ func (m *BatchTaskManager) SetTaskCancel(queueID string, cancel context.CancelFu
|
||||
// PauseQueue 暂停队列
|
||||
func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
||||
var cancelFunc context.CancelFunc
|
||||
var needDBUpdate bool
|
||||
|
||||
// 在锁内只更新内存状态
|
||||
m.mu.Lock()
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
@@ -1002,6 +994,16 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// DB 优先:先持久化,成功后再更新内存
|
||||
if m.db != nil {
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusPaused); err != nil {
|
||||
m.logger.Warn("batch queue DB pause update failed, skipping memory update",
|
||||
zap.String("queueId", queueID), zap.Error(err))
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
queue.Status = BatchQueueStatusPaused
|
||||
|
||||
// 取消当前正在执行的任务(通过取消context)
|
||||
@@ -1009,22 +1011,13 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
||||
cancelFunc = cancel
|
||||
delete(m.taskCancels, queueID)
|
||||
}
|
||||
|
||||
needDBUpdate = m.db != nil
|
||||
m.mu.Unlock()
|
||||
|
||||
// 释放锁后执行取消回调
|
||||
// 释放锁后执行取消回调(cancel 可能阻塞,不应持锁)
|
||||
if cancelFunc != nil {
|
||||
cancelFunc()
|
||||
}
|
||||
|
||||
// 释放锁后写 DB
|
||||
if needDBUpdate {
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusPaused); err != nil {
|
||||
m.logger.Warn("batch queue DB pause update failed", zap.String("queueId", queueID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1032,9 +1025,7 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
||||
func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
||||
now := time.Now()
|
||||
var cancelFunc context.CancelFunc
|
||||
var needDBUpdate bool
|
||||
|
||||
// 在锁内只更新内存状态,不做 DB 操作
|
||||
m.mu.Lock()
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
@@ -1047,6 +1038,22 @@ func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// DB 优先:先持久化,成功后再更新内存
|
||||
if m.db != nil {
|
||||
if err := m.db.CancelPendingBatchTasks(queueID, now); err != nil {
|
||||
m.logger.Warn("batch task DB batch cancel failed, skipping memory update",
|
||||
zap.String("queueId", queueID), zap.Error(err))
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusCancelled); err != nil {
|
||||
m.logger.Warn("batch queue DB cancel update failed, skipping memory update",
|
||||
zap.String("queueId", queueID), zap.Error(err))
|
||||
m.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
queue.Status = BatchQueueStatusCancelled
|
||||
queue.CompletedAt = &now
|
||||
|
||||
@@ -1063,25 +1070,13 @@ func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
||||
cancelFunc = cancel
|
||||
delete(m.taskCancels, queueID)
|
||||
}
|
||||
|
||||
needDBUpdate = m.db != nil
|
||||
m.mu.Unlock()
|
||||
|
||||
// 释放锁后执行取消回调
|
||||
// 释放锁后执行取消回调(cancel 可能阻塞,不应持锁)
|
||||
if cancelFunc != nil {
|
||||
cancelFunc()
|
||||
}
|
||||
|
||||
// 释放锁后批量写 DB(单条 SQL 取消所有 pending 任务)
|
||||
if needDBUpdate {
|
||||
if err := m.db.CancelPendingBatchTasks(queueID, now); err != nil {
|
||||
m.logger.Warn("batch task DB batch cancel failed", zap.String("queueId", queueID), zap.Error(err))
|
||||
}
|
||||
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusCancelled); err != nil {
|
||||
m.logger.Warn("batch queue DB cancel update failed", zap.String("queueId", queueID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
+180
-49
@@ -187,6 +187,7 @@ type GetConfigResponse struct {
|
||||
MCP config.MCPConfig `json:"mcp"`
|
||||
Tools []ToolConfigInfo `json:"tools"`
|
||||
Agent config.AgentConfig `json:"agent"`
|
||||
Hitl config.HitlConfig `json:"hitl,omitempty"`
|
||||
Knowledge config.KnowledgeConfig `json:"knowledge"`
|
||||
Robots config.RobotsConfig `json:"robots,omitempty"`
|
||||
MultiAgent config.MultiAgentPublic `json:"multi_agent,omitempty"`
|
||||
@@ -194,12 +195,13 @@ type GetConfigResponse struct {
|
||||
|
||||
// ToolConfigInfo 工具配置信息
|
||||
type ToolConfigInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IsExternal bool `json:"is_external,omitempty"` // 是否为外部MCP工具
|
||||
ExternalMCP string `json:"external_mcp,omitempty"` // 外部MCP名称(如果是外部工具)
|
||||
RoleEnabled *bool `json:"role_enabled,omitempty"` // 该工具在当前角色中是否启用(nil表示未指定角色或使用所有工具)
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IsExternal bool `json:"is_external,omitempty"` // 是否为外部MCP工具
|
||||
ExternalMCP string `json:"external_mcp,omitempty"` // 外部MCP名称(如果是外部工具)
|
||||
RoleEnabled *bool `json:"role_enabled,omitempty"` // 该工具在当前角色中是否启用(nil表示未指定角色或使用所有工具)
|
||||
InputSchema map[string]interface{} `json:"input_schema,omitempty"` // 工具参数 JSON Schema(用于前端展示详情)
|
||||
}
|
||||
|
||||
// GetConfig 获取当前配置
|
||||
@@ -211,25 +213,25 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
// 首先从配置文件获取工具
|
||||
configToolMap := make(map[string]bool)
|
||||
tools := make([]ToolConfigInfo, 0, len(h.config.Security.Tools))
|
||||
|
||||
for _, tool := range h.config.Security.Tools {
|
||||
configToolMap[tool.Name] = true
|
||||
tools = append(tools, ToolConfigInfo{
|
||||
info := ToolConfigInfo{
|
||||
Name: tool.Name,
|
||||
Description: h.pickToolDescription(tool.ShortDescription, tool.Description),
|
||||
Enabled: tool.Enabled,
|
||||
IsExternal: false,
|
||||
})
|
||||
}
|
||||
tools = append(tools, info)
|
||||
}
|
||||
|
||||
// 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具)
|
||||
if h.mcpServer != nil {
|
||||
mcpTools := h.mcpServer.GetAllTools()
|
||||
for _, mcpTool := range mcpTools {
|
||||
// 跳过已经在配置文件中的工具(避免重复)
|
||||
if configToolMap[mcpTool.Name] {
|
||||
continue
|
||||
}
|
||||
// 添加直接注册到MCP服务器的工具(如知识检索工具)
|
||||
description := mcpTool.ShortDescription
|
||||
if description == "" {
|
||||
description = mcpTool.Description
|
||||
@@ -240,7 +242,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
tools = append(tools, ToolConfigInfo{
|
||||
Name: mcpTool.Name,
|
||||
Description: description,
|
||||
Enabled: true, // 直接注册的工具默认启用
|
||||
Enabled: true,
|
||||
IsExternal: false,
|
||||
})
|
||||
}
|
||||
@@ -268,16 +270,12 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
}
|
||||
multiPub := config.MultiAgentPublic{
|
||||
Enabled: h.config.MultiAgent.Enabled,
|
||||
DefaultMode: h.config.MultiAgent.DefaultMode,
|
||||
RobotUseMultiAgent: h.config.MultiAgent.RobotUseMultiAgent,
|
||||
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
|
||||
SubAgentCount: subAgentCount,
|
||||
Orchestration: config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration),
|
||||
PlanExecuteLoopMaxIterations: h.config.MultiAgent.PlanExecuteLoopMaxIterations,
|
||||
}
|
||||
if strings.TrimSpace(multiPub.DefaultMode) == "" {
|
||||
multiPub.DefaultMode = "single"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, GetConfigResponse{
|
||||
OpenAI: h.config.OpenAI,
|
||||
@@ -285,6 +283,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
MCP: h.config.MCP,
|
||||
Tools: tools,
|
||||
Agent: h.config.Agent,
|
||||
Hitl: h.config.Hitl,
|
||||
Knowledge: h.config.Knowledge,
|
||||
Robots: h.config.Robots,
|
||||
MultiAgent: multiPub,
|
||||
@@ -442,7 +441,7 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
toolInfo := ToolConfigInfo{
|
||||
Name: mcpTool.Name,
|
||||
Description: description,
|
||||
Enabled: true, // 直接注册的工具默认启用
|
||||
Enabled: true,
|
||||
IsExternal: false,
|
||||
}
|
||||
|
||||
@@ -685,10 +684,6 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
// 多代理标量(sub_agents 等仍由 config.yaml 维护)
|
||||
if req.MultiAgent != nil {
|
||||
h.config.MultiAgent.Enabled = req.MultiAgent.Enabled
|
||||
dm := strings.TrimSpace(req.MultiAgent.DefaultMode)
|
||||
if dm == "multi" || dm == "single" {
|
||||
h.config.MultiAgent.DefaultMode = dm
|
||||
}
|
||||
h.config.MultiAgent.RobotUseMultiAgent = req.MultiAgent.RobotUseMultiAgent
|
||||
h.config.MultiAgent.BatchUseMultiAgent = req.MultiAgent.BatchUseMultiAgent
|
||||
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
|
||||
@@ -696,7 +691,6 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
}
|
||||
h.logger.Info("更新多代理配置",
|
||||
zap.Bool("enabled", h.config.MultiAgent.Enabled),
|
||||
zap.String("default_mode", h.config.MultiAgent.DefaultMode),
|
||||
zap.Bool("robot_use_multi_agent", h.config.MultiAgent.RobotUseMultiAgent),
|
||||
zap.Bool("batch_use_multi_agent", h.config.MultiAgent.BatchUseMultiAgent),
|
||||
zap.Int("plan_execute_loop_max_iterations", h.config.MultiAgent.PlanExecuteLoopMaxIterations),
|
||||
@@ -1140,34 +1134,10 @@ func (h *ConfigHandler) saveConfig() error {
|
||||
updateFOFAConfig(root, h.config.FOFA)
|
||||
updateKnowledgeConfig(root, h.config.Knowledge)
|
||||
updateRobotsConfig(root, h.config.Robots)
|
||||
updateHitlConfig(root, h.config.Hitl)
|
||||
updateMultiAgentConfig(root, h.config.MultiAgent)
|
||||
// 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用)
|
||||
// 读取原始配置以保持向后兼容
|
||||
originalConfigs := make(map[string]map[string]bool)
|
||||
externalMCPNode := findMapValue(root, "external_mcp")
|
||||
if externalMCPNode != nil && externalMCPNode.Kind == yaml.MappingNode {
|
||||
serversNode := findMapValue(externalMCPNode, "servers")
|
||||
if serversNode != nil && serversNode.Kind == yaml.MappingNode {
|
||||
for i := 0; i < len(serversNode.Content); i += 2 {
|
||||
if i+1 >= len(serversNode.Content) {
|
||||
break
|
||||
}
|
||||
nameNode := serversNode.Content[i]
|
||||
serverNode := serversNode.Content[i+1]
|
||||
if nameNode.Kind == yaml.ScalarNode && serverNode.Kind == yaml.MappingNode {
|
||||
serverName := nameNode.Value
|
||||
originalConfigs[serverName] = make(map[string]bool)
|
||||
if enabledVal := findBoolInMap(serverNode, "enabled"); enabledVal != nil {
|
||||
originalConfigs[serverName]["enabled"] = *enabledVal
|
||||
}
|
||||
if disabledVal := findBoolInMap(serverNode, "disabled"); disabledVal != nil {
|
||||
originalConfigs[serverName]["disabled"] = *disabledVal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updateExternalMCPConfig(root, h.config.ExternalMCP, originalConfigs)
|
||||
updateExternalMCPConfig(root, h.config.ExternalMCP)
|
||||
|
||||
if err := writeYAMLDocument(h.configPath, root); err != nil {
|
||||
return fmt.Errorf("保存配置文件失败: %w", err)
|
||||
@@ -1341,6 +1311,47 @@ func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
|
||||
setIntInMap(indexingNode, "retry_delay_ms", cfg.Indexing.RetryDelayMs)
|
||||
}
|
||||
|
||||
func mergeHitlToolWhitelistSlice(existing, add []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(existing)+len(add))
|
||||
for _, list := range [][]string{existing, add} {
|
||||
for _, t := range list {
|
||||
n := strings.ToLower(strings.TrimSpace(t))
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, strings.TrimSpace(t))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MergeHitlToolWhitelistIntoConfig 将会话侧栏提交的免审批工具名合并进内存配置并写入 config.yaml(与全局白名单去重规则一致:小写键、保留首次出现的原始大小写)。
|
||||
func (h *ConfigHandler) MergeHitlToolWhitelistIntoConfig(add []string) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
merged := mergeHitlToolWhitelistSlice(h.config.Hitl.ToolWhitelist, add)
|
||||
h.config.Hitl.ToolWhitelist = merged
|
||||
if err := h.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.logger.Info("HITL 全局工具白名单已合并写入配置文件",
|
||||
zap.Int("count", len(merged)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
|
||||
root := doc.Content[0]
|
||||
hitlNode := ensureMap(root, "hitl")
|
||||
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
|
||||
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
|
||||
}
|
||||
|
||||
func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
||||
root := doc.Content[0]
|
||||
robotsNode := ensureMap(root, "robots")
|
||||
@@ -1369,7 +1380,6 @@ func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
|
||||
root := doc.Content[0]
|
||||
maNode := ensureMap(root, "multi_agent")
|
||||
setBoolInMap(maNode, "enabled", cfg.Enabled)
|
||||
setStringInMap(maNode, "default_mode", cfg.DefaultMode)
|
||||
setBoolInMap(maNode, "robot_use_multi_agent", cfg.RobotUseMultiAgent)
|
||||
setBoolInMap(maNode, "batch_use_multi_agent", cfg.BatchUseMultiAgent)
|
||||
setIntInMap(maNode, "plan_execute_loop_max_iterations", cfg.PlanExecuteLoopMaxIterations)
|
||||
@@ -1452,6 +1462,21 @@ func setStringSliceInMap(mapNode *yaml.Node, key string, values []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func setFlowStringSliceInMap(mapNode *yaml.Node, key string, values []string) {
|
||||
_, valueNode := ensureKeyValue(mapNode, key)
|
||||
valueNode.Kind = yaml.SequenceNode
|
||||
valueNode.Tag = "!!seq"
|
||||
valueNode.Style = yaml.FlowStyle
|
||||
valueNode.Content = nil
|
||||
for _, v := range values {
|
||||
valueNode.Content = append(valueNode.Content, &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Tag: "!!str",
|
||||
Value: v,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setIntInMap(mapNode *yaml.Node, key string, value int) {
|
||||
_, valueNode := ensureKeyValue(mapNode, key)
|
||||
valueNode.Kind = yaml.ScalarNode
|
||||
@@ -1585,7 +1610,7 @@ func (h *ConfigHandler) calculateExternalToolEnabled(mcpName, toolName string, c
|
||||
}
|
||||
|
||||
// 首先检查外部MCP是否启用
|
||||
if !cfg.ExternalMCPEnable && !(cfg.Enabled && !cfg.Disabled) {
|
||||
if !cfg.ExternalMCPEnable {
|
||||
return false // MCP未启用,所有工具都禁用
|
||||
}
|
||||
|
||||
@@ -1624,3 +1649,109 @@ func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
|
||||
}
|
||||
return description
|
||||
}
|
||||
|
||||
// GetToolSchema 获取单个工具的 inputSchema(按需加载,避免列表接口返回大量 schema 数据)
|
||||
func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
toolName := c.Param("name")
|
||||
if toolName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否为外部工具(格式:mcpName::toolName)
|
||||
externalMCP := c.Query("external_mcp")
|
||||
if externalMCP != "" {
|
||||
// 外部 MCP 工具
|
||||
if h.externalMCPMgr != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
externalTools, _ := h.externalMCPMgr.GetAllTools(ctx)
|
||||
fullName := externalMCP + "::" + toolName
|
||||
for _, t := range externalTools {
|
||||
if t.Name == fullName {
|
||||
c.JSON(http.StatusOK, gin.H{"input_schema": t.InputSchema})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "外部工具未找到"})
|
||||
return
|
||||
}
|
||||
|
||||
// 内部工具:从 YAML 配置的 Parameters 构建
|
||||
for _, tool := range h.config.Security.Tools {
|
||||
if tool.Name == toolName {
|
||||
c.JSON(http.StatusOK, gin.H{"input_schema": buildInputSchemaFromParams(tool.Parameters)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// MCP 注册工具(如知识检索)
|
||||
if h.mcpServer != nil {
|
||||
for _, mt := range h.mcpServer.GetAllTools() {
|
||||
if mt.Name == toolName {
|
||||
c.JSON(http.StatusOK, gin.H{"input_schema": mt.InputSchema})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "工具未找到"})
|
||||
}
|
||||
|
||||
// buildInputSchemaFromParams 从 YAML 工具的 ParameterConfig 构建 JSON Schema(用于前端展示)。
|
||||
// 不依赖 MCP 服务器注册状态,所有工具(包括未启用的)都能返回参数定义。
|
||||
func buildInputSchemaFromParams(params []config.ParameterConfig) map[string]interface{} {
|
||||
if len(params) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
properties := make(map[string]interface{})
|
||||
required := make([]string, 0)
|
||||
|
||||
for _, p := range params {
|
||||
name := strings.TrimSpace(p.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
prop := map[string]interface{}{
|
||||
"type": convertParamType(p.Type),
|
||||
"description": p.Description,
|
||||
}
|
||||
if p.Default != nil {
|
||||
prop["default"] = p.Default
|
||||
}
|
||||
if len(p.Options) > 0 {
|
||||
prop["enum"] = p.Options
|
||||
}
|
||||
properties[name] = prop
|
||||
if p.Required {
|
||||
required = append(required, name)
|
||||
}
|
||||
}
|
||||
|
||||
schema := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func convertParamType(t string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(t)) {
|
||||
case "int", "integer", "number":
|
||||
return "number"
|
||||
case "bool", "boolean":
|
||||
return "boolean"
|
||||
case "array", "list":
|
||||
return "array"
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,24 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
var baseCtx context.Context
|
||||
clientDisconnected := false
|
||||
var sseWriteMu sync.Mutex
|
||||
var ssePublishConversationID string
|
||||
sendEvent := func(eventType, message string, data interface{}) {
|
||||
if clientDisconnected {
|
||||
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) {
|
||||
return
|
||||
}
|
||||
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) {
|
||||
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||
b, errMarshal := json.Marshal(ev)
|
||||
if errMarshal != nil {
|
||||
b = []byte(`{"type":"error","message":"marshal failed"}`)
|
||||
}
|
||||
sseLine := make([]byte, 0, len(b)+8)
|
||||
sseLine = append(sseLine, []byte("data: ")...)
|
||||
sseLine = append(sseLine, b...)
|
||||
sseLine = append(sseLine, '\n', '\n')
|
||||
if ssePublishConversationID != "" && h.taskEventBus != nil {
|
||||
h.taskEventBus.Publish(ssePublishConversationID, sseLine)
|
||||
}
|
||||
if clientDisconnected {
|
||||
return
|
||||
}
|
||||
select {
|
||||
@@ -54,10 +67,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
return
|
||||
default:
|
||||
}
|
||||
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||
b, _ := json.Marshal(ev)
|
||||
sseWriteMu.Lock()
|
||||
_, err := fmt.Fprintf(c.Writer, "data: %s\n\n", b)
|
||||
_, err := c.Writer.Write(sseLine)
|
||||
if err != nil {
|
||||
sseWriteMu.Unlock()
|
||||
clientDisconnected = true
|
||||
@@ -81,6 +92,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
sendEvent("done", "", nil)
|
||||
return
|
||||
}
|
||||
ssePublishConversationID = prep.ConversationID
|
||||
if prep.CreatedNew {
|
||||
sendEvent("conversation", "会话已创建", map[string]interface{}{
|
||||
"conversationId": prep.ConversationID,
|
||||
@@ -89,6 +101,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
|
||||
conversationID := prep.ConversationID
|
||||
assistantMessageID := prep.AssistantMessageID
|
||||
h.activateHITLForConversation(conversationID, req.Hitl)
|
||||
if h.hitlManager != nil {
|
||||
defer h.hitlManager.DeactivateConversation(conversationID)
|
||||
}
|
||||
|
||||
if prep.UserMessageID != "" {
|
||||
sendEvent("message_saved", "", map[string]interface{}{
|
||||
@@ -97,13 +113,15 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
|
||||
|
||||
var cancelWithCause context.CancelCauseFunc
|
||||
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
|
||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||
defer timeoutCancel()
|
||||
defer cancelWithCause(nil)
|
||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
||||
})
|
||||
|
||||
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
|
||||
var errorMsg string
|
||||
@@ -136,6 +154,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
defer close(stopKeepalive)
|
||||
|
||||
if h.config == nil {
|
||||
taskStatus = "failed"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
sendEvent("error", "服务器配置未加载", nil)
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
@@ -166,7 +186,24 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"messageId": assistantMessageID,
|
||||
})
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(runErr, context.DeadlineExceeded) || errors.Is(context.Cause(taskCtx), context.DeadlineExceeded) {
|
||||
taskStatus = "timeout"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
timeoutMsg := "任务执行超时,已自动终止。"
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", timeoutMsg, assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "timeout", timeoutMsg, nil)
|
||||
}
|
||||
sendEvent("error", timeoutMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"errorType": "timeout",
|
||||
})
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
@@ -232,12 +269,22 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.activateHITLForConversation(prep.ConversationID, req.Hitl)
|
||||
if h.hitlManager != nil {
|
||||
defer h.hitlManager.DeactivateConversation(prep.ConversationID)
|
||||
}
|
||||
|
||||
var progressBuf strings.Builder
|
||||
progressCallback := func(eventType, message string, data interface{}) {
|
||||
progressCallbackRaw := func(eventType, message string, data interface{}) {
|
||||
progressBuf.WriteString(eventType)
|
||||
progressBuf.WriteByte('\n')
|
||||
}
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
|
||||
defer cancelWithCause(nil)
|
||||
progressCallback := h.createProgressCallback(baseCtx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, progressCallbackRaw)
|
||||
baseCtx = multiagent.WithHITLToolInterceptor(baseCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
|
||||
})
|
||||
|
||||
if h.config == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器配置未加载"})
|
||||
@@ -245,7 +292,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
}
|
||||
|
||||
result, runErr := multiagent.RunEinoSingleChatModelAgent(
|
||||
c.Request.Context(),
|
||||
baseCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
@@ -279,10 +326,10 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"response": result.Response,
|
||||
"conversationId": prep.ConversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"response": result.Response,
|
||||
"conversationId": prep.ConversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"agentMode": "eino_single",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,36 +157,19 @@ func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
|
||||
h.config.ExternalMCP.Servers = make(map[string]config.ExternalMCPServerConfig)
|
||||
}
|
||||
|
||||
// 如果用户提供了 disabled 或 enabled 字段,保留它们以保持向后兼容
|
||||
// 同时将值迁移到 external_mcp_enable
|
||||
cfg := req.Config
|
||||
|
||||
if req.Config.Disabled {
|
||||
// 用户设置了 disabled: true
|
||||
// 官方 disabled 字段 → ExternalMCPEnable 取反
|
||||
if cfg.Disabled {
|
||||
cfg.ExternalMCPEnable = false
|
||||
cfg.Disabled = true
|
||||
cfg.Enabled = false
|
||||
} else if req.Config.Enabled {
|
||||
// 用户设置了 enabled: true
|
||||
} else if !cfg.ExternalMCPEnable {
|
||||
// 用户未显式设置 external_mcp_enable,官方配置默认就是启用的
|
||||
cfg.ExternalMCPEnable = true
|
||||
cfg.Enabled = true
|
||||
cfg.Disabled = false
|
||||
} else if !req.Config.ExternalMCPEnable {
|
||||
// 用户没有设置任何字段,且 external_mcp_enable 为 false
|
||||
// 检查现有配置是否有旧字段
|
||||
if existingCfg, exists := h.config.ExternalMCP.Servers[name]; exists {
|
||||
// 保留现有的旧字段
|
||||
cfg.Enabled = existingCfg.Enabled
|
||||
cfg.Disabled = existingCfg.Disabled
|
||||
}
|
||||
} else {
|
||||
// 用户通过新字段启用了(external_mcp_enable: true),但没有设置旧字段
|
||||
// 为了向后兼容,我们设置 enabled: true
|
||||
// 这样即使原始配置中有 disabled: false,也会被转换为 enabled: true
|
||||
cfg.Enabled = true
|
||||
cfg.Disabled = false
|
||||
}
|
||||
|
||||
// 展开 ${VAR} 环境变量
|
||||
config.ExpandConfigEnv(&cfg)
|
||||
|
||||
h.config.ExternalMCP.Servers[name] = cfg
|
||||
|
||||
// 保存到配置文件
|
||||
@@ -315,32 +298,25 @@ func (h *ExternalMCPHandler) GetExternalMCPStats(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// validateConfig 验证配置
|
||||
// validateConfig 验证配置(同时支持官方 type 字段和旧版 transport 字段)
|
||||
func (h *ExternalMCPHandler) validateConfig(cfg config.ExternalMCPServerConfig) error {
|
||||
transport := cfg.Transport
|
||||
transport := cfg.GetTransportType()
|
||||
if transport == "" {
|
||||
// 如果没有指定transport,根据是否有command或url判断
|
||||
if cfg.Command != "" {
|
||||
transport = "stdio"
|
||||
} else if cfg.URL != "" {
|
||||
transport = "http"
|
||||
} else {
|
||||
return fmt.Errorf("需要指定command(stdio模式)或url(http/sse模式)")
|
||||
}
|
||||
return fmt.Errorf("需要指定 command(stdio模式)或 url + type(http/sse模式)")
|
||||
}
|
||||
|
||||
switch transport {
|
||||
case "http":
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("HTTP模式需要URL")
|
||||
return fmt.Errorf("HTTP模式需要 url")
|
||||
}
|
||||
case "stdio":
|
||||
if cfg.Command == "" {
|
||||
return fmt.Errorf("stdio模式需要command")
|
||||
return fmt.Errorf("stdio模式需要 command")
|
||||
}
|
||||
case "sse":
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("SSE模式需要URL")
|
||||
return fmt.Errorf("SSE模式需要 url")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("不支持的传输模式: %s,支持的模式: http, stdio, sse", transport)
|
||||
@@ -351,25 +327,11 @@ func (h *ExternalMCPHandler) validateConfig(cfg config.ExternalMCPServerConfig)
|
||||
|
||||
// isEnabled 检查是否启用
|
||||
func (h *ExternalMCPHandler) isEnabled(cfg config.ExternalMCPServerConfig) bool {
|
||||
// 优先使用 ExternalMCPEnable 字段
|
||||
// 如果没有设置,检查旧的 enabled/disabled 字段(向后兼容)
|
||||
if cfg.ExternalMCPEnable {
|
||||
return true
|
||||
}
|
||||
// 向后兼容:检查旧字段
|
||||
if cfg.Disabled {
|
||||
return false
|
||||
}
|
||||
if cfg.Enabled {
|
||||
return true
|
||||
}
|
||||
// 都没有设置,默认为启用
|
||||
return true
|
||||
return cfg.ExternalMCPEnable
|
||||
}
|
||||
|
||||
// saveConfig 保存配置到文件
|
||||
func (h *ExternalMCPHandler) saveConfig() error {
|
||||
// 读取现有配置文件并创建备份
|
||||
data, err := os.ReadFile(h.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取配置文件失败: %w", err)
|
||||
@@ -384,37 +346,7 @@ func (h *ExternalMCPHandler) saveConfig() error {
|
||||
return fmt.Errorf("解析配置文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 在更新前,读取原始配置中的 enabled/disabled 字段,以便保持向后兼容
|
||||
originalConfigs := make(map[string]map[string]bool)
|
||||
externalMCPNode := findMapValue(root.Content[0], "external_mcp")
|
||||
if externalMCPNode != nil && externalMCPNode.Kind == yaml.MappingNode {
|
||||
serversNode := findMapValue(externalMCPNode, "servers")
|
||||
if serversNode != nil && serversNode.Kind == yaml.MappingNode {
|
||||
// 遍历现有的服务器配置,保存 enabled/disabled 字段
|
||||
for i := 0; i < len(serversNode.Content); i += 2 {
|
||||
if i+1 >= len(serversNode.Content) {
|
||||
break
|
||||
}
|
||||
nameNode := serversNode.Content[i]
|
||||
serverNode := serversNode.Content[i+1]
|
||||
if nameNode.Kind == yaml.ScalarNode && serverNode.Kind == yaml.MappingNode {
|
||||
serverName := nameNode.Value
|
||||
originalConfigs[serverName] = make(map[string]bool)
|
||||
// 检查是否有 enabled 字段
|
||||
if enabledVal := findBoolInMap(serverNode, "enabled"); enabledVal != nil {
|
||||
originalConfigs[serverName]["enabled"] = *enabledVal
|
||||
}
|
||||
// 检查是否有 disabled 字段
|
||||
if disabledVal := findBoolInMap(serverNode, "disabled"); disabledVal != nil {
|
||||
originalConfigs[serverName]["disabled"] = *disabledVal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新外部MCP配置
|
||||
updateExternalMCPConfig(root, h.config.ExternalMCP, originalConfigs)
|
||||
updateExternalMCPConfig(root, h.config.ExternalMCP)
|
||||
|
||||
if err := writeYAMLDocument(h.configPath, root); err != nil {
|
||||
return fmt.Errorf("保存配置文件失败: %w", err)
|
||||
@@ -425,7 +357,7 @@ func (h *ExternalMCPHandler) saveConfig() error {
|
||||
}
|
||||
|
||||
// updateExternalMCPConfig 更新外部MCP配置
|
||||
func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, originalConfigs map[string]map[string]bool) {
|
||||
func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig) {
|
||||
root := doc.Content[0]
|
||||
externalMCPNode := ensureMap(root, "external_mcp")
|
||||
serversNode := ensureMap(externalMCPNode, "servers")
|
||||
@@ -435,32 +367,31 @@ func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, origi
|
||||
|
||||
// 添加新的服务器配置
|
||||
for name, serverCfg := range cfg.Servers {
|
||||
// 添加服务器名称键
|
||||
nameNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name}
|
||||
serverNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
serversNode.Content = append(serversNode.Content, nameNode, serverNode)
|
||||
|
||||
// 设置服务器配置字段
|
||||
// type(官方 MCP 传输类型)
|
||||
effectiveType := serverCfg.GetTransportType()
|
||||
if effectiveType != "" && effectiveType != "stdio" {
|
||||
// stdio 可省略(有 command 时自动推断)
|
||||
setStringInMap(serverNode, "type", effectiveType)
|
||||
}
|
||||
if serverCfg.Command != "" {
|
||||
setStringInMap(serverNode, "command", serverCfg.Command)
|
||||
}
|
||||
if len(serverCfg.Args) > 0 {
|
||||
setStringArrayInMap(serverNode, "args", serverCfg.Args)
|
||||
}
|
||||
// 保存 env 字段(环境变量)
|
||||
if serverCfg.Env != nil && len(serverCfg.Env) > 0 {
|
||||
envNode := ensureMap(serverNode, "env")
|
||||
for envKey, envValue := range serverCfg.Env {
|
||||
setStringInMap(envNode, envKey, envValue)
|
||||
}
|
||||
}
|
||||
if serverCfg.Transport != "" {
|
||||
setStringInMap(serverNode, "transport", serverCfg.Transport)
|
||||
}
|
||||
if serverCfg.URL != "" {
|
||||
setStringInMap(serverNode, "url", serverCfg.URL)
|
||||
}
|
||||
// 保存 headers 字段(HTTP/SSE 请求头)
|
||||
if serverCfg.Headers != nil && len(serverCfg.Headers) > 0 {
|
||||
headersNode := ensureMap(serverNode, "headers")
|
||||
for k, v := range serverCfg.Headers {
|
||||
@@ -473,46 +404,32 @@ func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, origi
|
||||
if serverCfg.Timeout > 0 {
|
||||
setIntInMap(serverNode, "timeout", serverCfg.Timeout)
|
||||
}
|
||||
// 保存 external_mcp_enable 字段(新字段)
|
||||
// 官方标准字段
|
||||
if serverCfg.Disabled {
|
||||
setBoolInMap(serverNode, "disabled", true)
|
||||
}
|
||||
if len(serverCfg.AutoApprove) > 0 {
|
||||
setStringArrayInMap(serverNode, "autoApprove", serverCfg.AutoApprove)
|
||||
}
|
||||
|
||||
// SDK 高级配置
|
||||
if serverCfg.MaxRetries > 0 {
|
||||
setIntInMap(serverNode, "max_retries", serverCfg.MaxRetries)
|
||||
}
|
||||
if serverCfg.TerminateDuration > 0 {
|
||||
setIntInMap(serverNode, "terminate_duration", serverCfg.TerminateDuration)
|
||||
}
|
||||
if serverCfg.KeepAlive > 0 {
|
||||
setIntInMap(serverNode, "keep_alive", serverCfg.KeepAlive)
|
||||
}
|
||||
|
||||
setBoolInMap(serverNode, "external_mcp_enable", serverCfg.ExternalMCPEnable)
|
||||
// 保存 tool_enabled 字段(每个工具的启用状态)
|
||||
if serverCfg.ToolEnabled != nil && len(serverCfg.ToolEnabled) > 0 {
|
||||
toolEnabledNode := ensureMap(serverNode, "tool_enabled")
|
||||
for toolName, enabled := range serverCfg.ToolEnabled {
|
||||
setBoolInMap(toolEnabledNode, toolName, enabled)
|
||||
}
|
||||
}
|
||||
// 保留旧的 enabled/disabled 字段以保持向后兼容
|
||||
originalFields, hasOriginal := originalConfigs[name]
|
||||
|
||||
// 如果原始配置中有 enabled 字段,保留它
|
||||
if hasOriginal {
|
||||
if enabledVal, hasEnabled := originalFields["enabled"]; hasEnabled {
|
||||
setBoolInMap(serverNode, "enabled", enabledVal)
|
||||
}
|
||||
// 如果原始配置中有 disabled 字段,保留它
|
||||
// 注意:由于 omitempty,disabled: false 不会被保存,但 disabled: true 会被保存
|
||||
if disabledVal, hasDisabled := originalFields["disabled"]; hasDisabled {
|
||||
if disabledVal {
|
||||
setBoolInMap(serverNode, "disabled", disabledVal)
|
||||
} else {
|
||||
// 如果原始配置中有 disabled: false,我们保存 enabled: true 来等效表示
|
||||
// 因为 disabled: false 等价于 enabled: true
|
||||
setBoolInMap(serverNode, "enabled", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果用户在当前请求中明确设置了这些字段,也保存它们
|
||||
if serverCfg.Enabled {
|
||||
setBoolInMap(serverNode, "enabled", serverCfg.Enabled)
|
||||
}
|
||||
if serverCfg.Disabled {
|
||||
setBoolInMap(serverNode, "disabled", serverCfg.Disabled)
|
||||
} else if !hasOriginal && serverCfg.ExternalMCPEnable {
|
||||
// 如果用户通过新字段启用了,且原始配置中没有旧字段,保存 enabled: true 以保持向后兼容
|
||||
setBoolInMap(serverNode, "enabled", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,13 +60,13 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_Stdio(t *testing.T) {
|
||||
router, _, configPath := setupTestRouter()
|
||||
defer cleanupTestConfig(configPath)
|
||||
|
||||
// 测试添加stdio模式的配置
|
||||
// 测试添加stdio模式的配置(官方格式:有 command 时 type 可省略)
|
||||
configJSON := `{
|
||||
"command": "python3",
|
||||
"args": ["/path/to/script.py", "--server", "http://example.com"],
|
||||
"description": "Test stdio MCP",
|
||||
"timeout": 300,
|
||||
"enabled": true
|
||||
"external_mcp_enable": true
|
||||
}`
|
||||
|
||||
var configObj config.ExternalMCPServerConfig
|
||||
@@ -115,20 +115,17 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_Stdio(t *testing.T) {
|
||||
if response.Config.Timeout != 300 {
|
||||
t.Errorf("期望timeout为300,实际%d", response.Config.Timeout)
|
||||
}
|
||||
if !response.Config.Enabled {
|
||||
t.Error("期望enabled为true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalMCPHandler_AddOrUpdateExternalMCP_HTTP(t *testing.T) {
|
||||
router, _, configPath := setupTestRouter()
|
||||
defer cleanupTestConfig(configPath)
|
||||
|
||||
// 测试添加HTTP模式的配置
|
||||
// 测试添加HTTP模式的配置(使用官方 type 字段)
|
||||
configJSON := `{
|
||||
"transport": "http",
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8081/mcp",
|
||||
"enabled": true
|
||||
"external_mcp_enable": true
|
||||
}`
|
||||
|
||||
var configObj config.ExternalMCPServerConfig
|
||||
@@ -165,15 +162,12 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_HTTP(t *testing.T) {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if response.Config.Transport != "http" {
|
||||
t.Errorf("期望transport为http,实际%s", response.Config.Transport)
|
||||
if response.Config.Type != "http" {
|
||||
t.Errorf("期望type为http,实际%s", response.Config.Type)
|
||||
}
|
||||
if response.Config.URL != "http://127.0.0.1:8081/mcp" {
|
||||
t.Errorf("期望url为'http://127.0.0.1:8081/mcp',实际%s", response.Config.URL)
|
||||
}
|
||||
if !response.Config.Enabled {
|
||||
t.Error("期望enabled为true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) {
|
||||
@@ -187,22 +181,22 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "缺少command和url",
|
||||
configJSON: `{"enabled": true}`,
|
||||
expectedErr: "需要指定command(stdio模式)或url(http/sse模式)",
|
||||
configJSON: `{"external_mcp_enable": true}`,
|
||||
expectedErr: "需要指定 command(stdio模式)或 url + type(http/sse模式)",
|
||||
},
|
||||
{
|
||||
name: "stdio模式缺少command",
|
||||
configJSON: `{"args": ["test"], "enabled": true}`,
|
||||
configJSON: `{"args": ["test"], "external_mcp_enable": true}`,
|
||||
expectedErr: "stdio模式需要command",
|
||||
},
|
||||
{
|
||||
name: "http模式缺少url",
|
||||
configJSON: `{"transport": "http", "enabled": true}`,
|
||||
expectedErr: "HTTP模式需要URL",
|
||||
configJSON: `{"type": "http", "external_mcp_enable": true}`,
|
||||
expectedErr: "HTTP模式需要 url",
|
||||
},
|
||||
{
|
||||
name: "无效的transport",
|
||||
configJSON: `{"transport": "invalid", "enabled": true}`,
|
||||
name: "无效的type",
|
||||
configJSON: `{"type": "invalid", "external_mcp_enable": true}`,
|
||||
expectedErr: "不支持的传输模式",
|
||||
},
|
||||
}
|
||||
@@ -254,7 +248,7 @@ func TestExternalMCPHandler_DeleteExternalMCP(t *testing.T) {
|
||||
// 先添加一个配置
|
||||
configObj := config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Enabled: true,
|
||||
ExternalMCPEnable: true,
|
||||
}
|
||||
handler.manager.AddOrUpdateConfig("test-delete", configObj)
|
||||
|
||||
@@ -283,11 +277,11 @@ func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
|
||||
// 添加多个配置
|
||||
handler.manager.AddOrUpdateConfig("test1", config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Enabled: true,
|
||||
ExternalMCPEnable: true,
|
||||
})
|
||||
handler.manager.AddOrUpdateConfig("test2", config.ExternalMCPServerConfig{
|
||||
URL: "http://127.0.0.1:8081/mcp",
|
||||
Enabled: false,
|
||||
ExternalMCPEnable: false,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/external-mcp", nil)
|
||||
@@ -326,16 +320,14 @@ func TestExternalMCPHandler_GetExternalMCPStats(t *testing.T) {
|
||||
// 添加配置
|
||||
handler.manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Enabled: true,
|
||||
ExternalMCPEnable: true,
|
||||
})
|
||||
handler.manager.AddOrUpdateConfig("enabled2", config.ExternalMCPServerConfig{
|
||||
URL: "http://127.0.0.1:8081/mcp",
|
||||
Enabled: true,
|
||||
ExternalMCPEnable: true,
|
||||
})
|
||||
handler.manager.AddOrUpdateConfig("disabled1", config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Enabled: false,
|
||||
Disabled: true,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/external-mcp/stats", nil)
|
||||
@@ -369,8 +361,6 @@ func TestExternalMCPHandler_StartStopExternalMCP(t *testing.T) {
|
||||
// 添加一个禁用的配置
|
||||
handler.manager.AddOrUpdateConfig("test-start-stop", config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Enabled: false,
|
||||
Disabled: true,
|
||||
})
|
||||
|
||||
// 测试启动(可能会失败,因为没有真实的服务器)
|
||||
@@ -427,7 +417,7 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_EmptyName(t *testing.T) {
|
||||
|
||||
configObj := config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Enabled: true,
|
||||
ExternalMCPEnable: true,
|
||||
}
|
||||
|
||||
reqBody := AddOrUpdateExternalMCPRequest{
|
||||
@@ -470,14 +460,14 @@ func TestExternalMCPHandler_UpdateExistingConfig(t *testing.T) {
|
||||
// 先添加配置
|
||||
config1 := config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Enabled: true,
|
||||
ExternalMCPEnable: true,
|
||||
}
|
||||
handler.manager.AddOrUpdateConfig("test-update", config1)
|
||||
|
||||
// 更新配置
|
||||
config2 := config.ExternalMCPServerConfig{
|
||||
URL: "http://127.0.0.1:8081/mcp",
|
||||
Enabled: true,
|
||||
ExternalMCPEnable: true,
|
||||
}
|
||||
|
||||
reqBody := AddOrUpdateExternalMCPRequest{
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type hitlRuntimeConfig struct {
|
||||
Enabled bool
|
||||
Mode string
|
||||
SensitiveTools map[string]struct{}
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type hitlDecision struct {
|
||||
Decision string
|
||||
Comment string
|
||||
EditedArguments map[string]interface{}
|
||||
}
|
||||
|
||||
type pendingInterrupt struct {
|
||||
ConversationID string
|
||||
InterruptID string
|
||||
Mode string
|
||||
ToolName string
|
||||
ToolCallID string
|
||||
decideCh chan hitlDecision
|
||||
}
|
||||
|
||||
type HITLManager struct {
|
||||
db *database.DB
|
||||
logger *zap.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
runtime map[string]hitlRuntimeConfig
|
||||
pending map[string]*pendingInterrupt
|
||||
}
|
||||
|
||||
func NewHITLManager(db *database.DB, logger *zap.Logger) *HITLManager {
|
||||
return &HITLManager{
|
||||
db: db,
|
||||
logger: logger,
|
||||
runtime: make(map[string]hitlRuntimeConfig),
|
||||
pending: make(map[string]*pendingInterrupt),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HITLManager) EnsureSchema() error {
|
||||
if _, err := m.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS hitl_interrupts (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT,
|
||||
mode TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
tool_call_id TEXT,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL,
|
||||
decision TEXT,
|
||||
decision_comment TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
decided_at DATETIME
|
||||
);`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := m.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS hitl_conversation_configs (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
mode TEXT NOT NULL DEFAULT 'off',
|
||||
sensitive_tools TEXT NOT NULL DEFAULT '[]',
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
updated_at DATETIME NOT NULL
|
||||
);`)
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeHitlMode(mode string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(mode))
|
||||
if v == "" {
|
||||
return "approval"
|
||||
}
|
||||
switch v {
|
||||
case "off":
|
||||
return "off"
|
||||
case "feedback", "followup":
|
||||
return "approval"
|
||||
case "approval", "review_edit":
|
||||
return v
|
||||
default:
|
||||
return "approval"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HITLManager) ActivateConversation(conversationID string, req *HITLRequest) {
|
||||
if req == nil || !req.Enabled {
|
||||
m.DeactivateConversation(conversationID)
|
||||
return
|
||||
}
|
||||
tools := make(map[string]struct{})
|
||||
for _, t := range req.SensitiveTools {
|
||||
n := strings.ToLower(strings.TrimSpace(t))
|
||||
if n != "" {
|
||||
tools[n] = struct{}{}
|
||||
}
|
||||
}
|
||||
timeout := 5 * time.Minute
|
||||
if req.TimeoutSeconds > 0 {
|
||||
timeout = time.Duration(req.TimeoutSeconds) * time.Second
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.runtime[conversationID] = hitlRuntimeConfig{
|
||||
Enabled: true,
|
||||
Mode: normalizeHitlMode(req.Mode),
|
||||
SensitiveTools: tools,
|
||||
Timeout: timeout,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *HITLManager) DeactivateConversation(conversationID string) {
|
||||
m.mu.Lock()
|
||||
delete(m.runtime, conversationID)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// hitlConfigGlobalToolWhitelist 来自 config.yaml hitl.tool_whitelist(去重、去空)。
|
||||
func (h *AgentHandler) hitlConfigGlobalToolWhitelist() []string {
|
||||
if h == nil || h.config == nil {
|
||||
return nil
|
||||
}
|
||||
raw := h.config.Hitl.ToolWhitelist
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, t := range raw {
|
||||
n := strings.ToLower(strings.TrimSpace(t))
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, strings.TrimSpace(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hitlRequestWithMergedConfigWhitelist 将会话/API 中的白名单与 config.yaml 全局白名单合并(并集),仅用于运行时 Activate;不写入数据库。
|
||||
func (h *AgentHandler) hitlRequestWithMergedConfigWhitelist(req *HITLRequest) *HITLRequest {
|
||||
gw := h.hitlConfigGlobalToolWhitelist()
|
||||
if len(gw) == 0 {
|
||||
return req
|
||||
}
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
union := make([]string, 0, len(gw)+len(req.SensitiveTools))
|
||||
for _, t := range gw {
|
||||
n := strings.ToLower(strings.TrimSpace(t))
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
union = append(union, strings.TrimSpace(t))
|
||||
}
|
||||
for _, t := range req.SensitiveTools {
|
||||
n := strings.ToLower(strings.TrimSpace(t))
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
union = append(union, strings.TrimSpace(t))
|
||||
}
|
||||
out := *req
|
||||
out.SensitiveTools = union
|
||||
return &out
|
||||
}
|
||||
|
||||
func (m *HITLManager) shouldInterrupt(conversationID, toolName string) (hitlRuntimeConfig, bool) {
|
||||
m.mu.RLock()
|
||||
cfg, ok := m.runtime[conversationID]
|
||||
m.mu.RUnlock()
|
||||
if !ok || !cfg.Enabled {
|
||||
return hitlRuntimeConfig{}, false
|
||||
}
|
||||
// 语义:SensitiveTools 现在作为“白名单(免审批工具)”
|
||||
// 空白名单 => 全部工具都需要审批
|
||||
if len(cfg.SensitiveTools) == 0 {
|
||||
return cfg, true
|
||||
}
|
||||
_, inWhitelist := cfg.SensitiveTools[strings.ToLower(strings.TrimSpace(toolName))]
|
||||
return cfg, !inWhitelist
|
||||
}
|
||||
|
||||
func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID, mode, toolName, toolCallID, payload string) (*pendingInterrupt, error) {
|
||||
now := time.Now()
|
||||
id := "hitl_" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||
if _, err := m.db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
|
||||
id, conversationID, assistantMessageID, mode, toolName, toolCallID, payload, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 刷新页面后侧栏依赖 DB 配置;若仅内存 Activate 未落库,会导致「有待审批却显示关闭」
|
||||
_ = m.ensureConversationHITLModePersisted(conversationID, mode)
|
||||
p := &pendingInterrupt{
|
||||
ConversationID: conversationID,
|
||||
InterruptID: id,
|
||||
Mode: normalizeHitlMode(mode),
|
||||
ToolName: toolName,
|
||||
ToolCallID: toolCallID,
|
||||
decideCh: make(chan hitlDecision, 1),
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.pending[id] = p
|
||||
m.mu.Unlock()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ensureConversationHITLModePersisted 在产生待审批时把 mode 写入 hitl_conversation_configs,避免刷新后 GET 配置仍为关闭。
|
||||
func (m *HITLManager) ensureConversationHITLModePersisted(conversationID, interruptMode string) error {
|
||||
if strings.TrimSpace(conversationID) == "" {
|
||||
return nil
|
||||
}
|
||||
nm := normalizeHitlMode(interruptMode)
|
||||
if nm == "off" {
|
||||
return nil
|
||||
}
|
||||
cfg, err := m.LoadConversationConfig(conversationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Enabled && normalizeHitlMode(cfg.Mode) == nm {
|
||||
return nil
|
||||
}
|
||||
cfg.Enabled = true
|
||||
cfg.Mode = nm
|
||||
if cfg.TimeoutSeconds <= 0 {
|
||||
cfg.TimeoutSeconds = 300
|
||||
}
|
||||
return m.SaveConversationConfig(conversationID, cfg)
|
||||
}
|
||||
|
||||
// PendingHITLInterruptMode 返回该会话最新一条 pending 中断的协同模式(用于 GET 配置时与库内「关闭」状态对齐)。
|
||||
func (m *HITLManager) PendingHITLInterruptMode(conversationID string) (string, bool) {
|
||||
if strings.TrimSpace(conversationID) == "" {
|
||||
return "", false
|
||||
}
|
||||
var mode string
|
||||
err := m.db.QueryRow(`SELECT mode FROM hitl_interrupts WHERE conversation_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1`, conversationID).
|
||||
Scan(&mode)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
mode = strings.TrimSpace(mode)
|
||||
if mode == "" {
|
||||
return "", false
|
||||
}
|
||||
return mode, true
|
||||
}
|
||||
|
||||
func hitlStoredConfigEffective(cfg *HITLRequest) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
if cfg.Enabled {
|
||||
return true
|
||||
}
|
||||
return normalizeHitlMode(cfg.Mode) != "off"
|
||||
}
|
||||
|
||||
func (m *HITLManager) ResolveInterrupt(interruptID, decision, comment string, editedArguments map[string]interface{}) error {
|
||||
decision = strings.ToLower(strings.TrimSpace(decision))
|
||||
if decision != "approve" && decision != "reject" {
|
||||
return errors.New("decision must be approve/reject")
|
||||
}
|
||||
m.mu.RLock()
|
||||
p, ok := m.pending[interruptID]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return errors.New("interrupt not found or already resolved")
|
||||
}
|
||||
d := hitlDecision{
|
||||
Decision: decision,
|
||||
Comment: strings.TrimSpace(comment),
|
||||
EditedArguments: editedArguments,
|
||||
}
|
||||
select {
|
||||
case p.decideCh <- d:
|
||||
return nil
|
||||
default:
|
||||
return errors.New("interrupt already resolved or decision channel busy")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HITLManager) SaveConversationConfig(conversationID string, req *HITLRequest) error {
|
||||
if strings.TrimSpace(conversationID) == "" {
|
||||
return errors.New("conversationId is required")
|
||||
}
|
||||
if req == nil {
|
||||
req = &HITLRequest{Enabled: false, Mode: "off", TimeoutSeconds: 300}
|
||||
}
|
||||
mode := normalizeHitlMode(req.Mode)
|
||||
if !req.Enabled {
|
||||
mode = "off"
|
||||
}
|
||||
tools, _ := json.Marshal(req.SensitiveTools)
|
||||
timeout := req.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 300
|
||||
}
|
||||
_, err := m.db.Exec(`INSERT INTO hitl_conversation_configs
|
||||
(conversation_id, enabled, mode, sensitive_tools, timeout_seconds, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||
enabled=excluded.enabled, mode=excluded.mode, sensitive_tools=excluded.sensitive_tools, timeout_seconds=excluded.timeout_seconds, updated_at=excluded.updated_at`,
|
||||
conversationID, boolToInt(req.Enabled), mode, string(tools), timeout, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLRequest, error) {
|
||||
var enabledInt int
|
||||
var mode, toolsJSON string
|
||||
var timeout int
|
||||
err := m.db.QueryRow(`SELECT enabled, mode, sensitive_tools, timeout_seconds FROM hitl_conversation_configs WHERE conversation_id = ?`, conversationID).
|
||||
Scan(&enabledInt, &mode, &toolsJSON, &timeout)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return &HITLRequest{Enabled: false, Mode: "off", SensitiveTools: []string{}, TimeoutSeconds: 300}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tools := make([]string, 0)
|
||||
_ = json.Unmarshal([]byte(toolsJSON), &tools)
|
||||
return &HITLRequest{
|
||||
Enabled: enabledInt == 1,
|
||||
Mode: mode,
|
||||
SensitiveTools: tools,
|
||||
TimeoutSeconds: timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *HITLManager) waitDecision(ctx context.Context, p *pendingInterrupt, timeout time.Duration) (hitlDecision, error) {
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
delete(m.pending, p.InterruptID)
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
select {
|
||||
case d := <-p.decideCh:
|
||||
// 只有 review_edit 模式允许改参;其他模式一律忽略 edited arguments
|
||||
if p.Mode != "review_edit" && len(d.EditedArguments) > 0 {
|
||||
d.EditedArguments = nil
|
||||
}
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=? WHERE id=?`,
|
||||
d.Decision, d.Comment, time.Now(), p.InterruptID)
|
||||
return d, nil
|
||||
case <-time.After(timeout):
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='timeout', decision='approve', decision_comment='timeout auto approve', decided_at=? WHERE id=?`,
|
||||
time.Now(), p.InterruptID)
|
||||
return hitlDecision{Decision: "approve", Comment: "timeout auto approve"}, nil
|
||||
case <-ctx.Done():
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject', decision_comment='task cancelled', decided_at=? WHERE id=?`,
|
||||
time.Now(), p.InterruptID)
|
||||
return hitlDecision{Decision: "reject", Comment: "task cancelled"}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AgentHandler) activateHITLForConversation(conversationID string, req *HITLRequest) {
|
||||
if h.hitlManager == nil {
|
||||
return
|
||||
}
|
||||
if req == nil {
|
||||
cfg, err := h.hitlManager.LoadConversationConfig(conversationID)
|
||||
if err == nil {
|
||||
req = cfg
|
||||
}
|
||||
}
|
||||
h.hitlManager.ActivateConversation(conversationID, h.hitlRequestWithMergedConfigWhitelist(req))
|
||||
}
|
||||
|
||||
func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID, toolName, toolCallID string, payload map[string]interface{}, sendEventFunc func(eventType, message string, data interface{})) (*hitlDecision, error) {
|
||||
cfg, need := h.hitlManager.shouldInterrupt(conversationID, toolName)
|
||||
if !need {
|
||||
return nil, nil
|
||||
}
|
||||
payloadRaw, _ := json.Marshal(payload)
|
||||
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw))
|
||||
if err != nil {
|
||||
h.logger.Warn("创建 HITL 中断失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"mode": cfg.Mode,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"payload": payload,
|
||||
})
|
||||
}
|
||||
d, waitErr := h.hitlManager.waitDecision(runCtx, p, cfg.Timeout)
|
||||
if waitErr != nil {
|
||||
if cancelRun != nil && (errors.Is(waitErr, context.Canceled) || errors.Is(waitErr, context.DeadlineExceeded)) {
|
||||
cause := context.Cause(runCtx)
|
||||
switch {
|
||||
case errors.Is(cause, ErrTaskCancelled):
|
||||
cancelRun(ErrTaskCancelled)
|
||||
case cause != nil:
|
||||
cancelRun(cause)
|
||||
case errors.Is(waitErr, context.DeadlineExceeded):
|
||||
cancelRun(context.DeadlineExceeded)
|
||||
default:
|
||||
cancelRun(ErrTaskCancelled)
|
||||
}
|
||||
}
|
||||
return nil, waitErr
|
||||
}
|
||||
if d.Decision == "reject" {
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_rejected", "人工拒绝本次工具调用,模型将基于反馈继续迭代", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"comment": d.Comment,
|
||||
})
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_resumed", "人工确认通过,继续执行", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"comment": d.Comment,
|
||||
"editedArgs": d.EditedArguments,
|
||||
})
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (h *AgentHandler) handleHITLToolCall(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID string, data map[string]interface{}, sendEventFunc func(eventType, message string, data interface{})) {
|
||||
if h.hitlManager == nil {
|
||||
return
|
||||
}
|
||||
toolName, _ := data["toolName"].(string)
|
||||
toolCallID, _ := data["toolCallId"].(string)
|
||||
d, err := h.waitHITLApproval(runCtx, cancelRun, conversationID, assistantMessageID, toolName, toolCallID, data, sendEventFunc)
|
||||
if err != nil || d == nil {
|
||||
return
|
||||
}
|
||||
if len(d.EditedArguments) > 0 {
|
||||
if argsObj, ok := data["argumentsObj"].(map[string]interface{}); ok {
|
||||
for k := range argsObj {
|
||||
delete(argsObj, k)
|
||||
}
|
||||
for k, v := range d.EditedArguments {
|
||||
argsObj[k] = v
|
||||
}
|
||||
if b, mErr := json.Marshal(argsObj); mErr == nil {
|
||||
data["arguments"] = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AgentHandler) ListHITLPending(c *gin.Context) {
|
||||
conversationID := strings.TrimSpace(c.Query("conversationId"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
pageSize = int(math.Max(1, math.Min(float64(pageSize), 200)))
|
||||
offset := (page - 1) * pageSize
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, created_at, decided_at FROM hitl_interrupts WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
if conversationID != "" {
|
||||
q += " AND conversation_id = ?"
|
||||
args = append(args, conversationID)
|
||||
}
|
||||
if status != "all" {
|
||||
q += " AND status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
q += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, pageSize, offset)
|
||||
rows, err := h.db.Query(q, args...)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, cid, mode, toolName, toolCallID, payload, rowStatus string
|
||||
var messageID sql.NullString
|
||||
var decision, comment sql.NullString
|
||||
var createdAt time.Time
|
||||
var decidedAt sql.NullTime
|
||||
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &createdAt, &decidedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
msgID := ""
|
||||
if messageID.Valid {
|
||||
msgID = messageID.String
|
||||
}
|
||||
items = append(items, map[string]interface{}{
|
||||
"id": id,
|
||||
"conversationId": cid,
|
||||
"messageId": msgID,
|
||||
"mode": mode,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"payload": payload,
|
||||
"status": rowStatus,
|
||||
"decision": decision.String,
|
||||
"comment": comment.String,
|
||||
"createdAt": createdAt,
|
||||
"decidedAt": func() interface{} {
|
||||
if decidedAt.Valid {
|
||||
return decidedAt.Time
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "page": page, "pageSize": pageSize})
|
||||
}
|
||||
|
||||
type hitlDecisionReq struct {
|
||||
InterruptID string `json:"interruptId" binding:"required"`
|
||||
Decision string `json:"decision" binding:"required"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
EditedArguments map[string]interface{} `json:"editedArguments,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AgentHandler) DecideHITLInterrupt(c *gin.Context) {
|
||||
var req hitlDecisionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.hitlManager == nil {
|
||||
c.JSON(500, gin.H{"error": "hitl manager unavailable"})
|
||||
return
|
||||
}
|
||||
if err := h.hitlManager.ResolveInterrupt(req.InterruptID, req.Decision, req.Comment, req.EditedArguments); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AgentHandler) interceptHITLForEinoTool(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{}), toolName, arguments string) (string, error) {
|
||||
payload := map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"arguments": arguments,
|
||||
"source": "eino_middleware",
|
||||
"toolCallId": "",
|
||||
}
|
||||
var argsObj map[string]interface{}
|
||||
if strings.TrimSpace(arguments) != "" {
|
||||
_ = json.Unmarshal([]byte(arguments), &argsObj)
|
||||
if argsObj != nil {
|
||||
payload["argumentsObj"] = argsObj
|
||||
}
|
||||
}
|
||||
d, err := h.waitHITLApproval(runCtx, cancelRun, conversationID, assistantMessageID, toolName, "", payload, sendEventFunc)
|
||||
if err != nil || d == nil {
|
||||
return arguments, err
|
||||
}
|
||||
if d.Decision == "reject" {
|
||||
return arguments, multiagent.NewHumanRejectError(d.Comment)
|
||||
}
|
||||
if len(d.EditedArguments) > 0 {
|
||||
edited, mErr := json.Marshal(d.EditedArguments)
|
||||
if mErr == nil {
|
||||
return string(edited), nil
|
||||
}
|
||||
}
|
||||
return arguments, nil
|
||||
}
|
||||
|
||||
func (h *AgentHandler) interceptHITLForReactTool(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{}), toolName string, arguments map[string]interface{}, toolCallID string) (map[string]interface{}, error) {
|
||||
payload := map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"argumentsObj": arguments,
|
||||
"toolCallId": toolCallID,
|
||||
"source": "react_pre_exec",
|
||||
}
|
||||
d, err := h.waitHITLApproval(runCtx, cancelRun, conversationID, assistantMessageID, toolName, toolCallID, payload, sendEventFunc)
|
||||
if err != nil || d == nil {
|
||||
return arguments, err
|
||||
}
|
||||
if d.Decision == "reject" {
|
||||
comment := strings.TrimSpace(d.Comment)
|
||||
if comment == "" {
|
||||
comment = "no extra feedback"
|
||||
}
|
||||
return arguments, errors.New("human rejected this tool call; feedback: " + comment)
|
||||
}
|
||||
if len(d.EditedArguments) > 0 {
|
||||
return d.EditedArguments, nil
|
||||
}
|
||||
return arguments, nil
|
||||
}
|
||||
|
||||
func (h *AgentHandler) injectReactHITLInterceptor(ctx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{})) context.Context {
|
||||
return agent.WithToolCallInterceptor(ctx, func(c context.Context, toolName string, args map[string]interface{}, toolCallID string) (map[string]interface{}, error) {
|
||||
return h.interceptHITLForReactTool(c, cancelRun, conversationID, assistantMessageID, sendEventFunc, toolName, args, toolCallID)
|
||||
})
|
||||
}
|
||||
|
||||
type hitlConfigReq struct {
|
||||
ConversationID string `json:"conversationId" binding:"required"`
|
||||
HITLRequest
|
||||
}
|
||||
|
||||
func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
|
||||
conversationID := strings.TrimSpace(c.Param("conversationId"))
|
||||
if conversationID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "conversationId is required"})
|
||||
return
|
||||
}
|
||||
cfg, err := h.hitlManager.LoadConversationConfig(conversationID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !hitlStoredConfigEffective(cfg) {
|
||||
if pendMode, ok := h.hitlManager.PendingHITLInterruptMode(conversationID); ok {
|
||||
cfg2 := *cfg
|
||||
cfg2.Enabled = true
|
||||
cfg2.Mode = normalizeHitlMode(pendMode)
|
||||
if cfg2.TimeoutSeconds <= 0 {
|
||||
cfg2.TimeoutSeconds = 300
|
||||
}
|
||||
cfg = &cfg2
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"conversationId": conversationID,
|
||||
"hitl": cfg,
|
||||
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AgentHandler) UpsertHITLConversationConfig(c *gin.Context) {
|
||||
var req hitlConfigReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Mode = normalizeHitlMode(req.Mode)
|
||||
if err := h.hitlManager.SaveConversationConfig(req.ConversationID, &req.HITLRequest); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.hitlWhitelistSaver != nil && len(req.SensitiveTools) > 0 {
|
||||
if err := h.hitlWhitelistSaver.MergeHitlToolWhitelistIntoConfig(req.SensitiveTools); err != nil {
|
||||
h.logger.Warn("HITL 会话配置已保存,但合并工具白名单到 config.yaml 失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "会话配置已保存,但写入 config.yaml 失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
h.hitlManager.ActivateConversation(req.ConversationID, h.hitlRequestWithMergedConfigWhitelist(&req.HITLRequest))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
type mergeHitlGlobalWhitelistReq struct {
|
||||
SensitiveTools []string `json:"sensitiveTools"`
|
||||
}
|
||||
|
||||
// MergeHITLGlobalToolWhitelist 无会话 ID 时将侧栏提交的免审批工具合并进 config.yaml(与 PUT /hitl/config 中白名单落盘规则一致)。
|
||||
func (h *AgentHandler) MergeHITLGlobalToolWhitelist(c *gin.Context) {
|
||||
if h.hitlWhitelistSaver == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 配置持久化不可用"})
|
||||
return
|
||||
}
|
||||
var req mergeHitlGlobalWhitelistReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.SensitiveTools) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
"hitlGlobalWhitelistMerged": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := h.hitlWhitelistSaver.MergeHitlToolWhitelistIntoConfig(req.SensitiveTools); err != nil {
|
||||
h.logger.Warn("合并 HITL 工具白名单到 config.yaml 失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
"hitlGlobalWhitelistMerged": true,
|
||||
})
|
||||
}
|
||||
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -53,25 +53,36 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
clientDisconnected := false
|
||||
// 与 sseKeepalive 共用:禁止并发写 ResponseWriter,否则会破坏 chunked 编码(ERR_INVALID_CHUNKED_ENCODING)。
|
||||
var sseWriteMu sync.Mutex
|
||||
var ssePublishConversationID string
|
||||
sendEvent := func(eventType, message string, data interface{}) {
|
||||
if clientDisconnected {
|
||||
return
|
||||
}
|
||||
// 用户主动停止时,Eino 可能仍会并发上报 eventType=="error"。
|
||||
// 为避免 UI 看到“取消错误 + cancelled 文案”两条回复,这里直接丢弃取消对应的 error。
|
||||
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) {
|
||||
return
|
||||
}
|
||||
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||
b, errMarshal := json.Marshal(ev)
|
||||
if errMarshal != nil {
|
||||
b = []byte(`{"type":"error","message":"marshal failed"}`)
|
||||
}
|
||||
sseLine := make([]byte, 0, len(b)+8)
|
||||
sseLine = append(sseLine, []byte("data: ")...)
|
||||
sseLine = append(sseLine, b...)
|
||||
sseLine = append(sseLine, '\n', '\n')
|
||||
if ssePublishConversationID != "" && h.taskEventBus != nil {
|
||||
h.taskEventBus.Publish(ssePublishConversationID, sseLine)
|
||||
}
|
||||
if clientDisconnected {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
clientDisconnected = true
|
||||
return
|
||||
default:
|
||||
}
|
||||
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||
b, _ := json.Marshal(ev)
|
||||
sseWriteMu.Lock()
|
||||
_, err := fmt.Fprintf(c.Writer, "data: %s\n\n", b)
|
||||
_, err := c.Writer.Write(sseLine)
|
||||
if err != nil {
|
||||
sseWriteMu.Unlock()
|
||||
clientDisconnected = true
|
||||
@@ -95,6 +106,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
sendEvent("done", "", nil)
|
||||
return
|
||||
}
|
||||
ssePublishConversationID = prep.ConversationID
|
||||
if prep.CreatedNew {
|
||||
sendEvent("conversation", "会话已创建", map[string]interface{}{
|
||||
"conversationId": prep.ConversationID,
|
||||
@@ -103,6 +115,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
|
||||
conversationID := prep.ConversationID
|
||||
assistantMessageID := prep.AssistantMessageID
|
||||
h.activateHITLForConversation(conversationID, req.Hitl)
|
||||
if h.hitlManager != nil {
|
||||
defer h.hitlManager.DeactivateConversation(conversationID)
|
||||
}
|
||||
|
||||
if prep.UserMessageID != "" {
|
||||
sendEvent("message_saved", "", map[string]interface{}{
|
||||
@@ -111,12 +127,14 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
|
||||
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||
defer timeoutCancel()
|
||||
defer cancelWithCause(nil)
|
||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
|
||||
})
|
||||
|
||||
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
|
||||
var errorMsg string
|
||||
@@ -181,6 +199,23 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(runErr, context.DeadlineExceeded) || errors.Is(context.Cause(taskCtx), context.DeadlineExceeded) {
|
||||
taskStatus = "timeout"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
timeoutMsg := "任务执行超时,已自动终止。"
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", timeoutMsg, assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "timeout", timeoutMsg, nil)
|
||||
}
|
||||
sendEvent("error", timeoutMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"errorType": "timeout",
|
||||
})
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||
taskStatus = "failed"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
@@ -251,9 +286,20 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
c.JSON(status, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
h.activateHITLForConversation(prep.ConversationID, req.Hitl)
|
||||
if h.hitlManager != nil {
|
||||
defer h.hitlManager.DeactivateConversation(prep.ConversationID)
|
||||
}
|
||||
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
|
||||
defer cancelWithCause(nil)
|
||||
progressCallback := h.createProgressCallback(baseCtx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil)
|
||||
baseCtx = multiagent.WithHITLToolInterceptor(baseCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
|
||||
})
|
||||
|
||||
result, runErr := multiagent.RunDeepAgent(
|
||||
c.Request.Context(),
|
||||
baseCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
@@ -262,7 +308,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
prep.FinalMessage,
|
||||
prep.History,
|
||||
prep.RoleTools,
|
||||
nil,
|
||||
progressCallback,
|
||||
h.agentsMarkdownDir,
|
||||
strings.TrimSpace(req.Orchestration),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package handler
|
||||
|
||||
import "sync"
|
||||
|
||||
// TaskEventBus 将主 SSE 连接上的事件镜像给后订阅的客户端(例如刷新页面后、HITL 审批通过需继续收事件)。
|
||||
// 每个 payload 为完整 SSE 行: "data: {...}\n\n"
|
||||
type TaskEventBus struct {
|
||||
mu sync.RWMutex
|
||||
subs map[string]map[*taskEventSub]struct{}
|
||||
}
|
||||
|
||||
type taskEventSub struct {
|
||||
mu sync.Mutex
|
||||
ch chan []byte
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *taskEventSub) sendNonBlocking(line []byte) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case s.ch <- line:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *taskEventSub) closeOnce() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
close(s.ch)
|
||||
}
|
||||
|
||||
func NewTaskEventBus() *TaskEventBus {
|
||||
return &TaskEventBus{
|
||||
subs: make(map[string]map[*taskEventSub]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe 注册订阅;cancel 时需调用 Unsubscribe。
|
||||
func (b *TaskEventBus) Subscribe(conversationID string) (sub *taskEventSub, ch <-chan []byte) {
|
||||
chBuf := make(chan []byte, 256)
|
||||
sub = &taskEventSub{ch: chBuf}
|
||||
b.mu.Lock()
|
||||
if b.subs[conversationID] == nil {
|
||||
b.subs[conversationID] = make(map[*taskEventSub]struct{})
|
||||
}
|
||||
b.subs[conversationID][sub] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
return sub, chBuf
|
||||
}
|
||||
|
||||
func (b *TaskEventBus) Unsubscribe(conversationID string, sub *taskEventSub) {
|
||||
if sub == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
m, ok := b.subs[conversationID]
|
||||
if !ok {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(m, sub)
|
||||
if len(m) == 0 {
|
||||
delete(b.subs, conversationID)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
sub.closeOnce()
|
||||
}
|
||||
|
||||
// Publish 非阻塞投递;慢消费者丢帧(HITL 场景以最新状态为准,丢帧可接受)。
|
||||
func (b *TaskEventBus) Publish(conversationID string, line []byte) {
|
||||
if b == nil || conversationID == "" || len(line) == 0 {
|
||||
return
|
||||
}
|
||||
b.mu.RLock()
|
||||
m := b.subs[conversationID]
|
||||
subs := make([]*taskEventSub, 0, len(m))
|
||||
for s := range m {
|
||||
subs = append(subs, s)
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
|
||||
cp := append([]byte(nil), line...)
|
||||
for _, s := range subs {
|
||||
s.sendNonBlocking(cp)
|
||||
}
|
||||
}
|
||||
|
||||
// CloseConversation 任务结束时关闭该会话所有订阅 channel。
|
||||
func (b *TaskEventBus) CloseConversation(conversationID string) {
|
||||
if b == nil || conversationID == "" {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
m := b.subs[conversationID]
|
||||
delete(b.subs, conversationID)
|
||||
b.mu.Unlock()
|
||||
for sub := range m {
|
||||
sub.closeOnce()
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,12 @@ type CompletedTask struct {
|
||||
|
||||
// AgentTaskManager 管理正在运行的Agent任务
|
||||
type AgentTaskManager struct {
|
||||
mu sync.RWMutex
|
||||
tasks map[string]*AgentTask
|
||||
completedTasks []*CompletedTask // 最近完成的任务历史
|
||||
maxHistorySize int // 最大历史记录数
|
||||
historyRetention time.Duration // 历史记录保留时间
|
||||
mu sync.RWMutex
|
||||
tasks map[string]*AgentTask
|
||||
completedTasks []*CompletedTask // 最近完成的任务历史
|
||||
maxHistorySize int // 最大历史记录数
|
||||
historyRetention time.Duration // 历史记录保留时间
|
||||
eventBus *TaskEventBus // 可选:任务结束时关闭镜像 SSE 订阅
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -56,13 +57,27 @@ func NewAgentTaskManager() *AgentTaskManager {
|
||||
m := &AgentTaskManager{
|
||||
tasks: make(map[string]*AgentTask),
|
||||
completedTasks: make([]*CompletedTask, 0),
|
||||
maxHistorySize: 50, // 最多保留50条历史记录
|
||||
historyRetention: 24 * time.Hour, // 保留24小时
|
||||
maxHistorySize: 50, // 最多保留50条历史记录
|
||||
historyRetention: 24 * time.Hour, // 保留24小时
|
||||
}
|
||||
go m.runStuckCancellingCleanup()
|
||||
return m
|
||||
}
|
||||
|
||||
// SetTaskEventBus 设置任务事件总线(与 AgentHandler 共用同一实例)。
|
||||
func (m *AgentTaskManager) SetTaskEventBus(b *TaskEventBus) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.eventBus = b
|
||||
}
|
||||
|
||||
// GetTask 返回运行中任务(无则 nil)。
|
||||
func (m *AgentTaskManager) GetTask(conversationID string) *AgentTask {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.tasks[conversationID]
|
||||
}
|
||||
|
||||
// runStuckCancellingCleanup 定期将长时间处于「取消中」的任务强制结束,避免卡住无法发新消息
|
||||
func (m *AgentTaskManager) runStuckCancellingCleanup() {
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
@@ -172,10 +187,9 @@ func (m *AgentTaskManager) UpdateTaskStatus(conversationID string, status string
|
||||
// FinishTask 完成任务并从管理器中移除
|
||||
func (m *AgentTaskManager) FinishTask(conversationID string, finalStatus string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
task, exists := m.tasks[conversationID]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,26 +201,31 @@ func (m *AgentTaskManager) FinishTask(conversationID string, finalStatus string)
|
||||
completedTask := &CompletedTask{
|
||||
ConversationID: task.ConversationID,
|
||||
Message: task.Message,
|
||||
StartedAt: task.StartedAt,
|
||||
CompletedAt: time.Now(),
|
||||
Status: finalStatus,
|
||||
StartedAt: task.StartedAt,
|
||||
CompletedAt: time.Now(),
|
||||
Status: finalStatus,
|
||||
}
|
||||
|
||||
|
||||
// 添加到历史记录
|
||||
m.completedTasks = append(m.completedTasks, completedTask)
|
||||
|
||||
|
||||
// 清理过期和过多的历史记录
|
||||
m.cleanupHistory()
|
||||
|
||||
// 从运行任务中移除
|
||||
delete(m.tasks, conversationID)
|
||||
bus := m.eventBus
|
||||
m.mu.Unlock()
|
||||
if bus != nil {
|
||||
bus.CloseConversation(conversationID)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupHistory 清理过期的历史记录
|
||||
func (m *AgentTaskManager) cleanupHistory() {
|
||||
now := time.Now()
|
||||
cutoffTime := now.Add(-m.historyRetention)
|
||||
|
||||
|
||||
// 过滤掉过期的记录
|
||||
validTasks := make([]*CompletedTask, 0, len(m.completedTasks))
|
||||
for _, task := range m.completedTasks {
|
||||
@@ -214,7 +233,7 @@ func (m *AgentTaskManager) cleanupHistory() {
|
||||
validTasks = append(validTasks, task)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果仍然超过最大数量,只保留最新的
|
||||
if len(validTasks) > m.maxHistorySize {
|
||||
// 按完成时间排序,保留最新的
|
||||
@@ -222,7 +241,7 @@ func (m *AgentTaskManager) cleanupHistory() {
|
||||
start := len(validTasks) - m.maxHistorySize
|
||||
validTasks = validTasks[start:]
|
||||
}
|
||||
|
||||
|
||||
m.completedTasks = validTasks
|
||||
}
|
||||
|
||||
@@ -247,30 +266,30 @@ func (m *AgentTaskManager) GetActiveTasks() []*AgentTask {
|
||||
func (m *AgentTaskManager) GetCompletedTasks() []*CompletedTask {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
|
||||
// 清理过期记录(只读锁,不影响其他操作)
|
||||
// 注意:这里不能直接调用cleanupHistory,因为需要写锁
|
||||
// 所以返回时过滤过期记录
|
||||
now := time.Now()
|
||||
cutoffTime := now.Add(-m.historyRetention)
|
||||
|
||||
|
||||
result := make([]*CompletedTask, 0, len(m.completedTasks))
|
||||
for _, task := range m.completedTasks {
|
||||
if task.CompletedAt.After(cutoffTime) {
|
||||
result = append(result, task)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 按完成时间倒序排序(最新的在前)
|
||||
// 由于是追加的,最新的在最后,需要反转
|
||||
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
|
||||
|
||||
// 限制返回数量
|
||||
if len(result) > m.maxHistorySize {
|
||||
result = result[:m.maxHistorySize]
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -411,7 +411,10 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
out, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
h.logger.Warn("webshell exec read body", zap.Error(readErr))
|
||||
}
|
||||
output := string(out)
|
||||
httpCode := resp.StatusCode
|
||||
|
||||
@@ -578,7 +581,10 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
out, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
h.logger.Warn("webshell fileop read body", zap.Error(readErr))
|
||||
}
|
||||
output := string(out)
|
||||
|
||||
c.JSON(http.StatusOK, FileOpResponse{
|
||||
@@ -633,7 +639,10 @@ func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection,
|
||||
return "", false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
out, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
h.logger.Warn("webshell ExecWithConnection read body", zap.Error(readErr))
|
||||
}
|
||||
return string(out), resp.StatusCode == http.StatusOK, ""
|
||||
}
|
||||
|
||||
@@ -701,6 +710,9 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
|
||||
return "", false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
out, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
h.logger.Warn("webshell FileOpWithConnection read body", zap.Error(readErr))
|
||||
}
|
||||
return string(out), resp.StatusCode == http.StatusOK, ""
|
||||
}
|
||||
|
||||
+40
-186
@@ -2,11 +2,9 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -16,7 +14,6 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -268,172 +265,6 @@ func mustJSON(v interface{}) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
// simpleHTTPClient 简单 JSON-RPC over HTTP:每次请求一次 POST、响应在 body。实现 ExternalMCPClient。
|
||||
// 用于自建 MCP(如 http://127.0.0.1:8081/mcp)或其它仅支持简单 POST 的端点。
|
||||
type simpleHTTPClient struct {
|
||||
url string
|
||||
client *http.Client
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
status string
|
||||
}
|
||||
|
||||
func newSimpleHTTPClient(ctx context.Context, url string, timeout time.Duration, headers map[string]string, logger *zap.Logger) (ExternalMCPClient, error) {
|
||||
c := &simpleHTTPClient{
|
||||
url: url,
|
||||
client: httpClientWithTimeoutAndHeaders(timeout, headers),
|
||||
logger: logger,
|
||||
status: "connecting",
|
||||
}
|
||||
if err := c.initialize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.status = "connected"
|
||||
c.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *simpleHTTPClient) setStatus(s string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.status = s
|
||||
}
|
||||
|
||||
func (c *simpleHTTPClient) GetStatus() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.status
|
||||
}
|
||||
|
||||
func (c *simpleHTTPClient) IsConnected() bool {
|
||||
return c.GetStatus() == "connected"
|
||||
}
|
||||
|
||||
func (c *simpleHTTPClient) Initialize(context.Context) error {
|
||||
return nil // 已在 newSimpleHTTPClient 中完成
|
||||
}
|
||||
|
||||
func (c *simpleHTTPClient) initialize(ctx context.Context) error {
|
||||
params := InitializeRequest{
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
Capabilities: make(map[string]interface{}),
|
||||
ClientInfo: ClientInfo{Name: clientName, Version: clientVersion},
|
||||
}
|
||||
paramsJSON, _ := json.Marshal(params)
|
||||
req := &Message{
|
||||
ID: MessageID{value: "1"},
|
||||
Method: "initialize",
|
||||
Version: "2.0",
|
||||
Params: paramsJSON,
|
||||
}
|
||||
resp, err := c.sendRequest(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize: %w", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("initialize: %s (code %d)", resp.Error.Message, resp.Error.Code)
|
||||
}
|
||||
// 发送 notifications/initialized(协议要求)
|
||||
notify := &Message{
|
||||
ID: MessageID{value: nil},
|
||||
Method: "notifications/initialized",
|
||||
Version: "2.0",
|
||||
Params: json.RawMessage("{}"),
|
||||
}
|
||||
_ = c.sendNotification(notify)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *simpleHTTPClient) sendRequest(ctx context.Context, msg *Message) (*Message, error) {
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
var out Message
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *simpleHTTPClient) sendNotification(msg *Message) error {
|
||||
body, _ := json.Marshal(msg)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *simpleHTTPClient) ListTools(ctx context.Context) ([]Tool, error) {
|
||||
req := &Message{
|
||||
ID: MessageID{value: uuid.New().String()},
|
||||
Method: "tools/list",
|
||||
Version: "2.0",
|
||||
Params: json.RawMessage("{}"),
|
||||
}
|
||||
resp, err := c.sendRequest(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("tools/list: %s (code %d)", resp.Error.Message, resp.Error.Code)
|
||||
}
|
||||
var listResp ListToolsResponse
|
||||
if err := json.Unmarshal(resp.Result, &listResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listResp.Tools, nil
|
||||
}
|
||||
|
||||
func (c *simpleHTTPClient) CallTool(ctx context.Context, name string, args map[string]interface{}) (*ToolResult, error) {
|
||||
params := CallToolRequest{Name: name, Arguments: args}
|
||||
paramsJSON, _ := json.Marshal(params)
|
||||
req := &Message{
|
||||
ID: MessageID{value: uuid.New().String()},
|
||||
Method: "tools/call",
|
||||
Version: "2.0",
|
||||
Params: paramsJSON,
|
||||
}
|
||||
resp, err := c.sendRequest(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("tools/call: %s (code %d)", resp.Error.Message, resp.Error.Code)
|
||||
}
|
||||
var callResp CallToolResponse
|
||||
if err := json.Unmarshal(resp.Result, &callResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ToolResult{Content: callResp.Content, IsError: callResp.IsError}, nil
|
||||
}
|
||||
|
||||
func (c *simpleHTTPClient) Close() error {
|
||||
c.setStatus("disconnected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSDKClient 根据配置创建并连接外部 MCP 客户端(使用官方 SDK),返回实现 ExternalMCPClient 的 *sdkClient
|
||||
// 若连接失败返回 (nil, error)。ctx 用于连接超时与取消。
|
||||
func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) (ExternalMCPClient, error) {
|
||||
@@ -442,21 +273,23 @@ func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConf
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
transport := serverCfg.Transport
|
||||
transport := serverCfg.GetTransportType()
|
||||
if transport == "" {
|
||||
if serverCfg.Command != "" {
|
||||
transport = "stdio"
|
||||
} else if serverCfg.URL != "" {
|
||||
transport = "http"
|
||||
} else {
|
||||
return nil, fmt.Errorf("配置缺少 command 或 url")
|
||||
return nil, fmt.Errorf("配置缺少 command 或 url,且未指定 type/transport")
|
||||
}
|
||||
|
||||
// 构造 ClientOptions:KeepAlive 心跳
|
||||
var clientOpts *mcp.ClientOptions
|
||||
if serverCfg.KeepAlive > 0 {
|
||||
clientOpts = &mcp.ClientOptions{
|
||||
KeepAlive: time.Duration(serverCfg.KeepAlive) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
client := mcp.NewClient(&mcp.Implementation{
|
||||
Name: clientName,
|
||||
Version: clientVersion,
|
||||
}, nil)
|
||||
}, clientOpts)
|
||||
|
||||
var t mcp.Transport
|
||||
switch transport {
|
||||
@@ -470,12 +303,18 @@ func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConf
|
||||
if len(serverCfg.Env) > 0 {
|
||||
cmd.Env = append(cmd.Env, envMapToSlice(serverCfg.Env)...)
|
||||
}
|
||||
t = &mcp.CommandTransport{Command: cmd}
|
||||
ct := &mcp.CommandTransport{Command: cmd}
|
||||
if serverCfg.TerminateDuration > 0 {
|
||||
ct.TerminateDuration = time.Duration(serverCfg.TerminateDuration) * time.Second
|
||||
}
|
||||
t = ct
|
||||
case "sse":
|
||||
if serverCfg.URL == "" {
|
||||
return nil, fmt.Errorf("sse 模式需要配置 url")
|
||||
}
|
||||
httpClient := httpClientWithTimeoutAndHeaders(timeout, serverCfg.Headers)
|
||||
// SSE 是长连接(GET 流持续打开),不能设置 http.Client.Timeout(会在超时后杀掉整个连接导致 EOF)。
|
||||
// 超时由每次 ListTools/CallTool 的 context 单独控制。
|
||||
httpClient := httpClientForLongLived(serverCfg.Headers)
|
||||
t = &mcp.SSEClientTransport{
|
||||
Endpoint: serverCfg.URL,
|
||||
HTTPClient: httpClient,
|
||||
@@ -485,18 +324,16 @@ func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConf
|
||||
return nil, fmt.Errorf("http 模式需要配置 url")
|
||||
}
|
||||
httpClient := httpClientWithTimeoutAndHeaders(timeout, serverCfg.Headers)
|
||||
t = &mcp.StreamableClientTransport{
|
||||
st := &mcp.StreamableClientTransport{
|
||||
Endpoint: serverCfg.URL,
|
||||
HTTPClient: httpClient,
|
||||
}
|
||||
case "simple_http":
|
||||
// 简单 JSON-RPC HTTP:每次请求一次 POST、响应在 body。用于自建 MCP 或兼容旧端点(如 http://127.0.0.1:8081/mcp)
|
||||
if serverCfg.URL == "" {
|
||||
return nil, fmt.Errorf("simple_http 模式需要配置 url")
|
||||
if serverCfg.MaxRetries > 0 {
|
||||
st.MaxRetries = serverCfg.MaxRetries
|
||||
}
|
||||
return newSimpleHTTPClient(ctx, serverCfg.URL, timeout, serverCfg.Headers, logger)
|
||||
t = st
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的传输模式: %s", transport)
|
||||
return nil, fmt.Errorf("不支持的传输模式: %s(支持: stdio, sse, http)", transport)
|
||||
}
|
||||
|
||||
session, err := client.Connect(ctx, t, nil)
|
||||
@@ -538,6 +375,23 @@ func httpClientWithTimeoutAndHeaders(timeout time.Duration, headers map[string]s
|
||||
}
|
||||
}
|
||||
|
||||
// httpClientForLongLived 创建不设超时的 HTTP 客户端,用于 SSE 等长连接传输。
|
||||
// SSE 的 GET 流会持续打开,http.Client.Timeout 会在超时后强制关闭连接导致 EOF。
|
||||
// 超时由调用方通过 context 控制。
|
||||
func httpClientForLongLived(headers map[string]string) *http.Client {
|
||||
transport := http.DefaultTransport
|
||||
if len(headers) > 0 {
|
||||
transport = &headerRoundTripper{
|
||||
headers: headers,
|
||||
base: http.DefaultTransport,
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
// 不设 Timeout,SSE 长连接的超时由 per-request context 控制
|
||||
}
|
||||
}
|
||||
|
||||
type headerRoundTripper struct {
|
||||
headers map[string]string
|
||||
base http.RoundTripper
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
@@ -29,6 +30,7 @@ type ExternalMCPManager struct {
|
||||
toolCacheMu sync.RWMutex // 工具列表缓存的锁
|
||||
stopRefresh chan struct{} // 停止后台刷新的信号
|
||||
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
|
||||
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -721,7 +723,13 @@ func (m *ExternalMCPManager) GetToolCounts() map[string]int {
|
||||
}
|
||||
|
||||
// refreshToolCounts 刷新工具数量缓存(后台异步执行)
|
||||
// 使用 atomic flag 防止并发堆积:如果上一次刷新尚未完成,本次触发直接跳过。
|
||||
func (m *ExternalMCPManager) refreshToolCounts() {
|
||||
if !m.refreshing.CompareAndSwap(false, true) {
|
||||
return // 上一次刷新尚未完成,跳过
|
||||
}
|
||||
defer m.refreshing.Store(false)
|
||||
|
||||
m.mu.RLock()
|
||||
clients := make(map[string]ExternalMCPClient)
|
||||
for k, v := range m.clients {
|
||||
@@ -874,16 +882,7 @@ func (m *ExternalMCPManager) triggerToolCountRefresh() {
|
||||
|
||||
// createClient 创建客户端(不连接)。统一使用官方 MCP Go SDK 的 lazy 客户端,连接在 Initialize 时完成。
|
||||
func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConfig) ExternalMCPClient {
|
||||
transport := serverCfg.Transport
|
||||
if transport == "" {
|
||||
if serverCfg.Command != "" {
|
||||
transport = "stdio"
|
||||
} else if serverCfg.URL != "" {
|
||||
transport = "http"
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
transport := serverCfg.GetTransportType()
|
||||
|
||||
switch transport {
|
||||
case "http":
|
||||
@@ -891,12 +890,6 @@ func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConf
|
||||
return nil
|
||||
}
|
||||
return newLazySDKClient(serverCfg, m.logger)
|
||||
case "simple_http":
|
||||
// 简单 HTTP(一次 POST 一次响应),用于自建 MCP 等
|
||||
if serverCfg.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return newLazySDKClient(serverCfg, m.logger)
|
||||
case "stdio":
|
||||
if serverCfg.Command == "" {
|
||||
return nil
|
||||
@@ -908,7 +901,11 @@ func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConf
|
||||
}
|
||||
return newLazySDKClient(serverCfg, m.logger)
|
||||
default:
|
||||
return nil
|
||||
if transport == "" {
|
||||
return nil
|
||||
}
|
||||
// 未知传输类型也尝试使用 lazy client
|
||||
return newLazySDKClient(serverCfg, m.logger)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,20 +987,7 @@ func (m *ExternalMCPManager) connectClient(name string, serverCfg config.Externa
|
||||
|
||||
// isEnabled 检查是否启用
|
||||
func (m *ExternalMCPManager) isEnabled(cfg config.ExternalMCPServerConfig) bool {
|
||||
// 优先使用 ExternalMCPEnable 字段
|
||||
// 如果没有设置,检查旧的 enabled/disabled 字段(向后兼容)
|
||||
if cfg.ExternalMCPEnable {
|
||||
return true
|
||||
}
|
||||
// 向后兼容:检查旧字段
|
||||
if cfg.Disabled {
|
||||
return false
|
||||
}
|
||||
if cfg.Enabled {
|
||||
return true
|
||||
}
|
||||
// 都没有设置,默认为启用
|
||||
return true
|
||||
return cfg.ExternalMCPEnable
|
||||
}
|
||||
|
||||
// findSubstring 查找子字符串(简单实现)
|
||||
@@ -1044,15 +1028,7 @@ func (m *ExternalMCPManager) StartAllEnabled() {
|
||||
zap.Error(err),
|
||||
}
|
||||
|
||||
// 根据传输模式添加相应的信息
|
||||
transport := c.Transport
|
||||
if transport == "" {
|
||||
if c.Command != "" {
|
||||
transport = "stdio"
|
||||
} else if c.URL != "" {
|
||||
transport = "http"
|
||||
}
|
||||
}
|
||||
transport := c.GetTransportType()
|
||||
|
||||
if transport == "http" && c.URL != "" {
|
||||
fields = append(fields, zap.String("url", c.URL))
|
||||
|
||||
@@ -16,12 +16,11 @@ func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
|
||||
|
||||
// 测试添加stdio配置
|
||||
stdioCfg := config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Args: []string{"/path/to/script.py"},
|
||||
Transport: "stdio",
|
||||
Description: "Test stdio MCP",
|
||||
Timeout: 30,
|
||||
Enabled: true,
|
||||
Command: "python3",
|
||||
Args: []string{"/path/to/script.py"},
|
||||
Description: "Test stdio MCP",
|
||||
Timeout: 30,
|
||||
ExternalMCPEnable: true,
|
||||
}
|
||||
|
||||
err := manager.AddOrUpdateConfig("test-stdio", stdioCfg)
|
||||
@@ -31,11 +30,11 @@ func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
|
||||
|
||||
// 测试添加HTTP配置
|
||||
httpCfg := config.ExternalMCPServerConfig{
|
||||
Transport: "http",
|
||||
URL: "http://127.0.0.1:8081/mcp",
|
||||
Description: "Test HTTP MCP",
|
||||
Timeout: 30,
|
||||
Enabled: false,
|
||||
Type: "http",
|
||||
URL: "http://127.0.0.1:8081/mcp",
|
||||
Description: "Test HTTP MCP",
|
||||
Timeout: 30,
|
||||
ExternalMCPEnable: false,
|
||||
}
|
||||
|
||||
err = manager.AddOrUpdateConfig("test-http", httpCfg)
|
||||
@@ -64,8 +63,7 @@ func TestExternalMCPManager_RemoveConfig(t *testing.T) {
|
||||
|
||||
cfg := config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Transport: "stdio",
|
||||
Enabled: false,
|
||||
ExternalMCPEnable: false,
|
||||
}
|
||||
|
||||
manager.AddOrUpdateConfig("test-remove", cfg)
|
||||
@@ -89,18 +87,17 @@ func TestExternalMCPManager_GetStats(t *testing.T) {
|
||||
// 添加多个配置
|
||||
manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Enabled: true,
|
||||
ExternalMCPEnable: true,
|
||||
})
|
||||
|
||||
manager.AddOrUpdateConfig("enabled2", config.ExternalMCPServerConfig{
|
||||
URL: "http://127.0.0.1:8081/mcp",
|
||||
Enabled: true,
|
||||
ExternalMCPEnable: true,
|
||||
})
|
||||
|
||||
manager.AddOrUpdateConfig("disabled1", config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Enabled: false,
|
||||
Disabled: true, // 明确设置为禁用
|
||||
ExternalMCPEnable: false,
|
||||
})
|
||||
|
||||
stats := manager.GetStats()
|
||||
@@ -126,11 +123,11 @@ func TestExternalMCPManager_LoadConfigs(t *testing.T) {
|
||||
Servers: map[string]config.ExternalMCPServerConfig{
|
||||
"loaded1": {
|
||||
Command: "python3",
|
||||
Enabled: true,
|
||||
ExternalMCPEnable: true,
|
||||
},
|
||||
"loaded2": {
|
||||
URL: "http://127.0.0.1:8081/mcp",
|
||||
Enabled: false,
|
||||
ExternalMCPEnable: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -156,7 +153,7 @@ func TestLazySDKClient_InitializeFails(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
// 使用不存在的 HTTP 地址,Initialize 应失败
|
||||
cfg := config.ExternalMCPServerConfig{
|
||||
Transport: "http",
|
||||
Type: "http",
|
||||
URL: "http://127.0.0.1:19999/nonexistent",
|
||||
Timeout: 2,
|
||||
}
|
||||
@@ -180,8 +177,7 @@ func TestExternalMCPManager_StartStopClient(t *testing.T) {
|
||||
// 添加一个禁用的配置
|
||||
cfg := config.ExternalMCPServerConfig{
|
||||
Command: "python3",
|
||||
Transport: "stdio",
|
||||
Enabled: false,
|
||||
ExternalMCPEnable: false,
|
||||
}
|
||||
|
||||
manager.AddOrUpdateConfig("test-start-stop", cfg)
|
||||
@@ -200,7 +196,7 @@ func TestExternalMCPManager_StartStopClient(t *testing.T) {
|
||||
|
||||
// 验证配置已更新为禁用
|
||||
configs := manager.GetConfigs()
|
||||
if configs["test-start-stop"].Enabled {
|
||||
if configs["test-start-stop"].ExternalMCPEnable {
|
||||
t.Error("配置应该已被禁用")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,54 +230,72 @@ attemptLoop:
|
||||
continue
|
||||
}
|
||||
if ev.Err != nil {
|
||||
canRetry := attempt+1 < maxToolCallRecoveryAttempts
|
||||
|
||||
if canRetry && isRecoverableToolCallArgumentsJSONError(ev.Err) {
|
||||
if logger != nil {
|
||||
logger.Warn("eino: recoverable tool-call JSON error from model/API", zap.Error(ev.Err), zap.Int("attempt", attempt))
|
||||
}
|
||||
retryHints = append(retryHints, toolCallArgumentsJSONRetryHint())
|
||||
if progress != nil {
|
||||
progress("eino_recovery", toolCallArgumentsJSONRecoveryTimelineMessage(attempt), map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"einoRetry": attempt,
|
||||
"runIndex": attempt + 1,
|
||||
"maxRuns": maxToolCallRecoveryAttempts,
|
||||
"reason": "invalid_tool_arguments_json",
|
||||
})
|
||||
}
|
||||
continue attemptLoop
|
||||
}
|
||||
|
||||
if canRetry && isRecoverableToolExecutionError(ev.Err) {
|
||||
if logger != nil {
|
||||
logger.Warn("eino: recoverable tool execution error, will retry with corrective hint",
|
||||
zap.Error(ev.Err), zap.Int("attempt", attempt))
|
||||
}
|
||||
if errors.Is(ev.Err, context.DeadlineExceeded) {
|
||||
flushAllPendingAsFailed(ev.Err)
|
||||
retryHints = append(retryHints, toolExecutionRetryHint())
|
||||
if progress != nil {
|
||||
progress("eino_recovery", toolExecutionRecoveryTimelineMessage(attempt), map[string]interface{}{
|
||||
progress("error", ev.Err.Error(), map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"einoRetry": attempt,
|
||||
"runIndex": attempt + 1,
|
||||
"maxRuns": maxToolCallRecoveryAttempts,
|
||||
"reason": "tool_execution_error",
|
||||
"errorKind": "timeout",
|
||||
})
|
||||
}
|
||||
continue attemptLoop
|
||||
return nil, ev.Err
|
||||
}
|
||||
// context.Canceled 是唯一应当直接终止编排的错误(用户关闭页面、主动停止等)。
|
||||
if errors.Is(ev.Err, context.Canceled) {
|
||||
flushAllPendingAsFailed(ev.Err)
|
||||
if progress != nil {
|
||||
progress("error", ev.Err.Error(), map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
return nil, ev.Err
|
||||
}
|
||||
|
||||
canRetry := attempt+1 < maxToolCallRecoveryAttempts
|
||||
if !canRetry {
|
||||
// 重试次数已耗尽,终止。
|
||||
flushAllPendingAsFailed(ev.Err)
|
||||
if progress != nil {
|
||||
progress("error", ev.Err.Error(), map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
return nil, ev.Err
|
||||
}
|
||||
|
||||
// 区分错误类型以选择最合适的纠错提示,但无论哪种都执行重试(default-soft)。
|
||||
var hint *schema.Message
|
||||
var reason, timelineMsg string
|
||||
if isRecoverableToolCallArgumentsJSONError(ev.Err) {
|
||||
hint = toolCallArgumentsJSONRetryHint()
|
||||
reason = "invalid_tool_arguments_json"
|
||||
timelineMsg = toolCallArgumentsJSONRecoveryTimelineMessage(attempt)
|
||||
} else {
|
||||
hint = toolExecutionRetryHint()
|
||||
reason = "tool_execution_error"
|
||||
timelineMsg = toolExecutionRecoveryTimelineMessage(attempt)
|
||||
}
|
||||
|
||||
if logger != nil {
|
||||
logger.Warn("eino: recoverable error, will retry with corrective hint",
|
||||
zap.Error(ev.Err), zap.Int("attempt", attempt), zap.String("reason", reason))
|
||||
}
|
||||
flushAllPendingAsFailed(ev.Err)
|
||||
retryHints = append(retryHints, hint)
|
||||
if progress != nil {
|
||||
progress("error", ev.Err.Error(), map[string]interface{}{
|
||||
progress("eino_recovery", timelineMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"einoRetry": attempt,
|
||||
"runIndex": attempt + 1,
|
||||
"maxRuns": maxToolCallRecoveryAttempts,
|
||||
"reason": reason,
|
||||
})
|
||||
}
|
||||
return nil, ev.Err
|
||||
continue attemptLoop
|
||||
}
|
||||
if ev.AgentName != "" && progress != nil {
|
||||
iterEinoAgent := orchestratorName
|
||||
|
||||
@@ -159,6 +159,7 @@ func RunEinoSingleChatModelAgent(
|
||||
Tools: mainToolsForCfg,
|
||||
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||
ToolCallMiddlewares: []compose.ToolMiddleware{
|
||||
{Invokable: hitlToolCallMiddleware()},
|
||||
{Invokable: softRecoveryToolCallMiddleware()},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
type hitlInterceptorKey struct{}
|
||||
|
||||
type HITLToolInterceptor func(ctx context.Context, toolName, arguments string) (string, error)
|
||||
|
||||
type humanRejectError struct {
|
||||
reason string
|
||||
}
|
||||
|
||||
func (e *humanRejectError) Error() string {
|
||||
if strings.TrimSpace(e.reason) == "" {
|
||||
return "rejected by user"
|
||||
}
|
||||
return "rejected by user: " + strings.TrimSpace(e.reason)
|
||||
}
|
||||
|
||||
func NewHumanRejectError(reason string) error {
|
||||
return &humanRejectError{reason: strings.TrimSpace(reason)}
|
||||
}
|
||||
|
||||
func IsHumanRejectError(err error) bool {
|
||||
var target *humanRejectError
|
||||
return errors.As(err, &target)
|
||||
}
|
||||
|
||||
func WithHITLToolInterceptor(ctx context.Context, fn HITLToolInterceptor) context.Context {
|
||||
if fn == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, hitlInterceptorKey{}, fn)
|
||||
}
|
||||
|
||||
func hitlToolCallMiddleware() compose.InvokableToolMiddleware {
|
||||
return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
|
||||
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||
if input != nil {
|
||||
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
|
||||
edited, err := fn(ctx, input.Name, input.Arguments)
|
||||
if err != nil {
|
||||
if IsHumanRejectError(err) {
|
||||
// Human rejection should be a soft tool result so the model can continue iterating.
|
||||
msg := fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by human reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.",
|
||||
input.Name, strings.TrimSpace(err.Error()))
|
||||
// transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END,
|
||||
// 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具,
|
||||
// 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。
|
||||
if strings.EqualFold(strings.TrimSpace(input.Name), adk.TransferToAgentToolName) {
|
||||
_ = compose.ProcessState[*adk.State](ctx, func(_ context.Context, st *adk.State) error {
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
st.ReturnDirectlyToolCallID = ""
|
||||
st.HasReturnDirectly = false
|
||||
st.ReturnDirectlyEvent = nil
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return &compose.ToolOutput{Result: msg}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if edited != "" {
|
||||
input.Arguments = edited
|
||||
}
|
||||
}
|
||||
}
|
||||
return next(ctx, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func DefaultSupervisorOrchestratorInstruction() string {
|
||||
|
||||
## transfer 交接与防重复劳动
|
||||
|
||||
- 每次 transfer 前,在**本条助手正文**中写清交接包:已知主域、关键子域或主机短表、已识别端口与服务、上轮已达成共识的结论要点;勿仅依赖历史里的超长工具原始输出(上下文摘要后专家可能看不到细节)。
|
||||
- **把专家当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 每次 transfer 前,在**本条助手正文**中写清交接包:已知主域、关键子域或主机短表、已识别端口与服务、上轮已达成共识的结论要点;勿仅依赖历史里的超长工具原始输出(上下文摘要后专家可能看不到细节)。
|
||||
- 写清本轮**唯一子目标**与**禁止项**(例如:不得再做全量子域枚举;仅对下列目标做 MQTT 或认证验证)。
|
||||
- 验证、利用、协议深挖应 transfer 给**对应专项**子代理;避免把「仅剩验证」的工作交给侦察类(recon)导致其从全量枚举起手。
|
||||
- 同一目标多次串行 transfer 时,每一次交接包都要带上**截至当前的共识事实**增量,勿假设专家已读过上一轮专家的隐性推理。
|
||||
|
||||
@@ -268,6 +268,7 @@ func RunDeepAgent(
|
||||
Tools: subToolsForCfg,
|
||||
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||
ToolCallMiddlewares: []compose.ToolMiddleware{
|
||||
{Invokable: hitlToolCallMiddleware()},
|
||||
{Invokable: softRecoveryToolCallMiddleware()},
|
||||
},
|
||||
},
|
||||
@@ -341,6 +342,9 @@ func RunDeepAgent(
|
||||
|
||||
// noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。
|
||||
deepHandlers := []adk.ChatModelAgentMiddleware{newNoNestedTaskMiddleware()}
|
||||
if mw := newTaskContextEnrichMiddleware(userMessage, history, ma.SubAgentUserContextMaxRunes); mw != nil {
|
||||
deepHandlers = append(deepHandlers, mw)
|
||||
}
|
||||
if len(mainOrchestratorPre) > 0 {
|
||||
deepHandlers = append(deepHandlers, mainOrchestratorPre...)
|
||||
}
|
||||
@@ -363,6 +367,7 @@ func RunDeepAgent(
|
||||
Tools: mainToolsForCfg,
|
||||
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||
ToolCallMiddlewares: []compose.ToolMiddleware{
|
||||
{Invokable: hitlToolCallMiddleware()},
|
||||
{Invokable: softRecoveryToolCallMiddleware()},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
const defaultSubAgentUserContextMaxRunes = 2000
|
||||
|
||||
// taskContextEnrichMiddleware intercepts "task" tool calls on the orchestrator
|
||||
// and appends the user's original conversation messages to the task description.
|
||||
// This ensures sub-agents always receive the full user intent (target URLs,
|
||||
// scope, etc.) even when the orchestrator forgets to include them.
|
||||
//
|
||||
// Design: user context is injected into the task description (per-task), NOT
|
||||
// into the sub-agent's Instruction (system prompt). This keeps sub-agent
|
||||
// Instructions clean as pure role definitions while attaching context to the
|
||||
// specific delegation — aligned with Claude Code's agent design philosophy.
|
||||
type taskContextEnrichMiddleware struct {
|
||||
adk.BaseChatModelAgentMiddleware
|
||||
supplement string // pre-built user context block
|
||||
}
|
||||
|
||||
// newTaskContextEnrichMiddleware returns a middleware that enriches task
|
||||
// descriptions with user conversation context. Returns nil if disabled
|
||||
// (maxRunes < 0) or no user messages exist.
|
||||
func newTaskContextEnrichMiddleware(userMessage string, history []agent.ChatMessage, maxRunes int) adk.ChatModelAgentMiddleware {
|
||||
supplement := buildUserContextSupplement(userMessage, history, maxRunes)
|
||||
if supplement == "" {
|
||||
return nil
|
||||
}
|
||||
return &taskContextEnrichMiddleware{supplement: supplement}
|
||||
}
|
||||
|
||||
func (m *taskContextEnrichMiddleware) WrapInvokableToolCall(
|
||||
ctx context.Context,
|
||||
endpoint adk.InvokableToolCallEndpoint,
|
||||
tCtx *adk.ToolContext,
|
||||
) (adk.InvokableToolCallEndpoint, error) {
|
||||
if tCtx == nil || !strings.EqualFold(strings.TrimSpace(tCtx.Name), "task") {
|
||||
return endpoint, nil
|
||||
}
|
||||
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
enriched := m.enrichTaskDescription(argumentsInJSON)
|
||||
return endpoint(ctx, enriched, opts...)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// enrichTaskDescription parses the task JSON arguments, appends user context
|
||||
// to the "description" field, and re-serializes. Falls back to the original
|
||||
// JSON if parsing fails or no description field exists.
|
||||
func (m *taskContextEnrichMiddleware) enrichTaskDescription(argsJSON string) string {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &raw); err != nil {
|
||||
return argsJSON
|
||||
}
|
||||
desc, ok := raw["description"].(string)
|
||||
if !ok {
|
||||
return argsJSON
|
||||
}
|
||||
raw["description"] = desc + m.supplement
|
||||
enriched, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return argsJSON
|
||||
}
|
||||
return string(enriched)
|
||||
}
|
||||
|
||||
// buildUserContextSupplement collects user messages from conversation history
|
||||
// and the current message, returning a formatted block to append to task
|
||||
// descriptions. Returns "" if disabled or no user messages exist.
|
||||
func buildUserContextSupplement(userMessage string, history []agent.ChatMessage, maxRunes int) string {
|
||||
if maxRunes < 0 {
|
||||
return ""
|
||||
}
|
||||
if maxRunes == 0 {
|
||||
maxRunes = defaultSubAgentUserContextMaxRunes
|
||||
}
|
||||
|
||||
var userMsgs []string
|
||||
for _, h := range history {
|
||||
if h.Role == "user" {
|
||||
if m := strings.TrimSpace(h.Content); m != "" {
|
||||
userMsgs = append(userMsgs, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if um := strings.TrimSpace(userMessage); um != "" {
|
||||
if len(userMsgs) == 0 || userMsgs[len(userMsgs)-1] != um {
|
||||
userMsgs = append(userMsgs, um)
|
||||
}
|
||||
}
|
||||
if len(userMsgs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
joined := strings.Join(userMsgs, "\n---\n")
|
||||
if len([]rune(joined)) > maxRunes {
|
||||
joined = truncateKeepFirstLast(userMsgs, maxRunes)
|
||||
}
|
||||
|
||||
return "\n\n## 会话上下文(自动补充,确保你了解用户完整意图)\n" + joined
|
||||
}
|
||||
|
||||
// truncateKeepFirstLast keeps the first and last user messages, giving each
|
||||
// half the rune budget. The first message typically contains target info;
|
||||
// the last contains the current instruction.
|
||||
func truncateKeepFirstLast(msgs []string, maxRunes int) string {
|
||||
if len(msgs) == 1 {
|
||||
return truncateRunes(msgs[0], maxRunes)
|
||||
}
|
||||
|
||||
first := msgs[0]
|
||||
last := msgs[len(msgs)-1]
|
||||
sep := "\n---\n...(中间对话省略)...\n---\n"
|
||||
sepLen := len([]rune(sep))
|
||||
|
||||
budget := maxRunes - sepLen
|
||||
if budget <= 0 {
|
||||
return truncateRunes(first+"\n---\n"+last, maxRunes)
|
||||
}
|
||||
|
||||
halfBudget := budget / 2
|
||||
firstTrunc := truncateRunes(first, halfBudget)
|
||||
lastTrunc := truncateRunes(last, budget-len([]rune(firstTrunc)))
|
||||
|
||||
return firstTrunc + sep + lastTrunc
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
rs := []rune(s)
|
||||
if len(rs) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
return string(rs[:max])
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
// --- buildUserContextSupplement tests ---
|
||||
|
||||
func TestBuildUserContextSupplement_SingleMessage(t *testing.T) {
|
||||
result := buildUserContextSupplement("http://8.163.32.73:8081 测试命令执行", nil, 0)
|
||||
if result == "" {
|
||||
t.Fatal("expected non-empty supplement")
|
||||
}
|
||||
if !strings.Contains(result, "http://8.163.32.73:8081") {
|
||||
t.Error("expected URL in supplement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserContextSupplement_MultiTurn(t *testing.T) {
|
||||
history := []agent.ChatMessage{
|
||||
{Role: "user", Content: "http://8.163.32.73:8081 这是一个pikachu靶场,尝试测试命令执行"},
|
||||
{Role: "assistant", Content: "好的,我来测试..."},
|
||||
{Role: "user", Content: "继续,并持久化webshell"},
|
||||
{Role: "assistant", Content: "正在处理..."},
|
||||
}
|
||||
result := buildUserContextSupplement("你好", history, 0)
|
||||
if !strings.Contains(result, "http://8.163.32.73:8081") {
|
||||
t.Error("expected first turn URL to be preserved")
|
||||
}
|
||||
if !strings.Contains(result, "你好") {
|
||||
t.Error("expected current message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserContextSupplement_Empty(t *testing.T) {
|
||||
if result := buildUserContextSupplement("", nil, 0); result != "" {
|
||||
t.Errorf("expected empty, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserContextSupplement_Deduplicate(t *testing.T) {
|
||||
history := []agent.ChatMessage{{Role: "user", Content: "你好"}}
|
||||
result := buildUserContextSupplement("你好", history, 0)
|
||||
if strings.Count(result, "你好") != 1 {
|
||||
t.Errorf("expected '你好' once, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserContextSupplement_SkipsNonUser(t *testing.T) {
|
||||
history := []agent.ChatMessage{
|
||||
{Role: "user", Content: "目标是 10.0.0.1"},
|
||||
{Role: "assistant", Content: "不应该出现"},
|
||||
}
|
||||
result := buildUserContextSupplement("确认", history, 0)
|
||||
if strings.Contains(result, "不应该出现") {
|
||||
t.Error("assistant message should not be included")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserContextSupplement_DisabledByNegative(t *testing.T) {
|
||||
if result := buildUserContextSupplement("test", nil, -1); result != "" {
|
||||
t.Errorf("expected empty when disabled, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserContextSupplement_CustomMaxRunes(t *testing.T) {
|
||||
msg := strings.Repeat("A", 200)
|
||||
result := buildUserContextSupplement(msg, nil, 50)
|
||||
header := "\n\n## 会话上下文(自动补充,确保你了解用户完整意图)\n"
|
||||
body := strings.TrimPrefix(result, header)
|
||||
if len([]rune(body)) > 50 {
|
||||
t.Errorf("body should be capped at 50 runes, got %d", len([]rune(body)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserContextSupplement_TruncateKeepsFirstAndLast(t *testing.T) {
|
||||
first := "http://target.com " + strings.Repeat("A", 500)
|
||||
var history []agent.ChatMessage
|
||||
history = append(history, agent.ChatMessage{Role: "user", Content: first})
|
||||
for i := 0; i < 10; i++ {
|
||||
history = append(history, agent.ChatMessage{Role: "user", Content: strings.Repeat("B", 500)})
|
||||
}
|
||||
last := "最后一条指令"
|
||||
result := buildUserContextSupplement(last, history, 0)
|
||||
if !strings.Contains(result, "http://target.com") {
|
||||
t.Error("first message (target URL) should survive truncation")
|
||||
}
|
||||
if !strings.Contains(result, last) {
|
||||
t.Error("last message should survive truncation")
|
||||
}
|
||||
}
|
||||
|
||||
// --- middleware integration tests ---
|
||||
|
||||
func TestTaskContextEnrichMiddleware_EnrichesTaskDescription(t *testing.T) {
|
||||
mw := newTaskContextEnrichMiddleware(
|
||||
"继续测试",
|
||||
[]agent.ChatMessage{{Role: "user", Content: "http://8.163.32.73:8081 pikachu靶场"}},
|
||||
0,
|
||||
)
|
||||
if mw == nil {
|
||||
t.Fatal("expected non-nil middleware")
|
||||
}
|
||||
|
||||
called := false
|
||||
var capturedArgs string
|
||||
fakeEndpoint := func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
|
||||
called = true
|
||||
capturedArgs = args
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
wrapped, err := mw.(interface {
|
||||
WrapInvokableToolCall(context.Context, adk.InvokableToolCallEndpoint, *adk.ToolContext) (adk.InvokableToolCallEndpoint, error)
|
||||
}).WrapInvokableToolCall(context.Background(), fakeEndpoint, &adk.ToolContext{Name: "task"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
taskArgs := `{"subagent_type":"recon","description":"扫描目标端口"}`
|
||||
wrapped(context.Background(), taskArgs)
|
||||
|
||||
if !called {
|
||||
t.Fatal("endpoint was not called")
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(capturedArgs), &parsed); err != nil {
|
||||
t.Fatalf("enriched args not valid JSON: %v", err)
|
||||
}
|
||||
desc := parsed["description"].(string)
|
||||
if !strings.Contains(desc, "扫描目标端口") {
|
||||
t.Error("original description should be preserved")
|
||||
}
|
||||
if !strings.Contains(desc, "http://8.163.32.73:8081") {
|
||||
t.Error("user context should be appended to description")
|
||||
}
|
||||
if !strings.Contains(desc, "继续测试") {
|
||||
t.Error("current user message should be in description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskContextEnrichMiddleware_IgnoresNonTaskTools(t *testing.T) {
|
||||
mw := newTaskContextEnrichMiddleware("test", nil, 0)
|
||||
if mw == nil {
|
||||
t.Fatal("expected non-nil middleware")
|
||||
}
|
||||
|
||||
original := `{"command":"nmap -sV target"}`
|
||||
var capturedArgs string
|
||||
fakeEndpoint := func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
|
||||
capturedArgs = args
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
wrapped, err := mw.(interface {
|
||||
WrapInvokableToolCall(context.Context, adk.InvokableToolCallEndpoint, *adk.ToolContext) (adk.InvokableToolCallEndpoint, error)
|
||||
}).WrapInvokableToolCall(context.Background(), fakeEndpoint, &adk.ToolContext{Name: "nmap_scan"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wrapped(context.Background(), original)
|
||||
if capturedArgs != original {
|
||||
t.Errorf("non-task tool args should not be modified, got %q", capturedArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskContextEnrichMiddleware_NilWhenDisabled(t *testing.T) {
|
||||
mw := newTaskContextEnrichMiddleware("test", nil, -1)
|
||||
if mw != nil {
|
||||
t.Error("middleware should be nil when disabled")
|
||||
}
|
||||
}
|
||||
@@ -41,62 +41,27 @@ func softRecoveryToolCallMiddleware() compose.InvokableToolMiddleware {
|
||||
|
||||
// isSoftRecoverableToolError determines whether a tool execution error should be
|
||||
// silently converted to a tool-result message rather than crashing the graph.
|
||||
//
|
||||
// Design: default-soft (blacklist). Almost every tool execution error should be
|
||||
// fed back to the LLM so it can self-correct or choose an alternative tool.
|
||||
// Only a small set of "truly fatal" conditions (user cancellation) should
|
||||
// propagate as hard errors that terminate the orchestration graph.
|
||||
// This avoids the fragile whitelist approach where every new error pattern
|
||||
// would need to be explicitly enumerated.
|
||||
func isSoftRecoverableToolError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 用户取消 — 不应重试,让 hard error 传播以终止编排。
|
||||
// 用户主动取消 — 唯一应当终止编排的情况,不应重试。
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 工具执行超时 — 转为 soft error 让 LLM 知晓并选择替代方案,而非全局重试。
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
|
||||
s := strings.ToLower(err.Error())
|
||||
|
||||
// JSON unmarshal/parse failures — the model generated truncated or malformed arguments.
|
||||
if isJSONRelatedError(s) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Sub-agent type not found (from deep/task_tool.go)
|
||||
if strings.Contains(s, "subagent type") && strings.Contains(s, "not found") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Tool not found in ToolsNode indexes
|
||||
if strings.Contains(s, "tool") && strings.Contains(s, "not found") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isJSONRelatedError checks whether an error string indicates a JSON parsing problem.
|
||||
func isJSONRelatedError(lower string) bool {
|
||||
if !strings.Contains(lower, "json") {
|
||||
return false
|
||||
}
|
||||
jsonIndicators := []string{
|
||||
"unexpected end of json",
|
||||
"unmarshal",
|
||||
"invalid character",
|
||||
"cannot unmarshal",
|
||||
"invalid tool arguments",
|
||||
"failed to unmarshal",
|
||||
"must be in json format",
|
||||
"unexpected eof",
|
||||
}
|
||||
for _, ind := range jsonIndicators {
|
||||
if strings.Contains(lower, ind) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
// 其他所有工具执行错误(超时、命令不存在、JSON 解析失败、工具未找到、
|
||||
// 权限不足、网络不可达……)一律转为 soft error,让 LLM 看到错误信息
|
||||
// 后自行决策:换工具、调整参数、或向用户说明。
|
||||
return true
|
||||
}
|
||||
|
||||
// buildSoftRecoveryMessage creates a bilingual error message that the LLM can act on.
|
||||
|
||||
@@ -53,7 +53,12 @@ func TestIsSoftRecoverableToolError(t *testing.T) {
|
||||
{
|
||||
name: "unrelated network error",
|
||||
err: errors.New("connection refused"),
|
||||
expected: false,
|
||||
expected: true, // default-soft: non-cancel errors are recoverable
|
||||
},
|
||||
{
|
||||
name: "tool binary not installed",
|
||||
err: errors.New("[LocalFunc] failed to invoke tool, toolName=grep, err=ripgrep (rg) is not installed or not in PATH"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "context cancelled",
|
||||
@@ -131,15 +136,16 @@ func TestSoftRecoveryToolCallMiddleware_PropagatesNonRecoverable(t *testing.T) {
|
||||
return nil, origErr
|
||||
}
|
||||
wrapped := mw(next)
|
||||
_, err := wrapped(context.Background(), &compose.ToolInput{
|
||||
out, err := wrapped(context.Background(), &compose.ToolInput{
|
||||
Name: "test_tool",
|
||||
Arguments: `{}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error to propagate for non-recoverable errors")
|
||||
// Default-soft: non-cancel errors are converted to tool-result messages.
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error (soft recovery), got: %v", err)
|
||||
}
|
||||
if err != origErr {
|
||||
t.Fatalf("expected original error, got: %v", err)
|
||||
if out == nil || out.Result == "" {
|
||||
t.Fatal("expected non-empty recovery message")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,74 +2,42 @@ package multiagent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// isRecoverableToolExecutionError detects tool-level execution errors that can be
|
||||
// recovered by retrying with a corrective hint. These errors originate from eino
|
||||
// framework internals (e.g. task_tool.go, tool_node.go) when the LLM produces
|
||||
// invalid tool calls such as non-existent sub-agent types, malformed JSON arguments,
|
||||
// or unregistered tool names.
|
||||
func isRecoverableToolExecutionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
|
||||
// Sub-agent type not found (from deep/task_tool.go)
|
||||
if strings.Contains(s, "subagent type") && strings.Contains(s, "not found") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Tool not found in toolsNode indexes (from compose/tool_node.go, when UnknownToolsHandler is nil)
|
||||
if strings.Contains(s, "tool") && strings.Contains(s, "not found") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Invalid tool arguments JSON (from einomcp/mcp_tools.go or eino internals)
|
||||
if strings.Contains(s, "invalid tool arguments json") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Failed to unmarshal task tool input json (from deep/task_tool.go)
|
||||
if strings.Contains(s, "failed to unmarshal") && strings.Contains(s, "json") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Generic tool call stream/invoke failure wrapping the above
|
||||
if (strings.Contains(s, "failed to stream tool call") || strings.Contains(s, "failed to invoke tool")) &&
|
||||
(strings.Contains(s, "not found") || strings.Contains(s, "json") || strings.Contains(s, "unmarshal")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// toolExecutionRetryHint returns a user message appended to the conversation to prompt
|
||||
// the LLM to correct its tool call after a tool execution error.
|
||||
// the LLM to adjust after a tool execution error (tool not found, binary missing,
|
||||
// runtime failure, network error, etc.).
|
||||
func toolExecutionRetryHint() *schema.Message {
|
||||
return schema.UserMessage(`[System] Your previous tool call failed because:
|
||||
- The tool or sub-agent name you used does not exist, OR
|
||||
return schema.UserMessage(`[System] Your previous tool call failed. Possible causes:
|
||||
- The tool or sub-agent name does not exist (typo or unregistered name).
|
||||
- The tool call arguments were not valid JSON.
|
||||
- The tool's underlying binary is not installed or not in PATH.
|
||||
- The tool encountered a runtime error (timeout, network failure, permission denied, etc.).
|
||||
|
||||
Please carefully review the available tools and sub-agents listed in your context, use only exact registered names (case-sensitive), and ensure all arguments are well-formed JSON objects. Then retry your action.
|
||||
Please review the error message above, check available tools, and either:
|
||||
1. Retry with corrected arguments or a different tool, OR
|
||||
2. Inform the user about the limitation and proceed with an alternative approach.
|
||||
|
||||
[系统提示] 上一次工具调用失败,可能原因:
|
||||
- 你使用的工具名或子代理名称不存在;
|
||||
- 工具调用参数不是合法 JSON。
|
||||
- 工具名或子代理名称不存在(拼写错误或未注册);
|
||||
- 工具调用参数不是合法 JSON;
|
||||
- 工具依赖的底层二进制程序未安装或不在 PATH 中;
|
||||
- 工具运行时遇到错误(超时、网络故障、权限不足等)。
|
||||
|
||||
请仔细检查上下文中列出的可用工具和子代理名称(须完全匹配、区分大小写),确保所有参数均为合法的 JSON 对象,然后重新执行。`)
|
||||
请根据上述错误信息检查可用工具,然后:
|
||||
1. 修正参数或改用其他工具重试,或者
|
||||
2. 告知用户当前限制并采用替代方案继续。`)
|
||||
}
|
||||
|
||||
// toolExecutionRecoveryTimelineMessage returns a message for the eino_recovery event
|
||||
// displayed in the UI timeline when a tool execution error triggers a retry.
|
||||
func toolExecutionRecoveryTimelineMessage(attempt int) string {
|
||||
return fmt.Sprintf(
|
||||
"工具调用执行失败(工具/子代理名称不存在或参数 JSON 无效)。已向对话追加纠错提示并要求模型重新生成。"+
|
||||
"工具调用执行失败。已向对话追加纠错提示并要求模型调整策略。"+
|
||||
"当前为第 %d/%d 轮完整运行。\n\n"+
|
||||
"Tool call execution failed (unknown tool/sub-agent name or invalid JSON arguments). "+
|
||||
"Tool call execution failed. "+
|
||||
"A corrective hint was appended. This is full run %d of %d.",
|
||||
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
|
||||
)
|
||||
|
||||
@@ -487,7 +487,10 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return "", fmt.Errorf("claude bridge: read error response: %w", readErr)
|
||||
}
|
||||
return "", &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
@@ -588,7 +591,10 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return "", nil, "", fmt.Errorf("claude bridge: read error response: %w", readErr)
|
||||
}
|
||||
return "", nil, "", &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
@@ -824,7 +830,11 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
||||
|
||||
// 非 200:尝试把 Claude 错误格式转成 OpenAI 错误格式,便于 Eino 解析
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
bodyBytes, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("claude bridge: read error response: %w", readErr)
|
||||
}
|
||||
resp.Body.Close()
|
||||
converted := rt.tryConvertClaudeErrorToOpenAI(bodyBytes)
|
||||
return &http.Response{
|
||||
@@ -838,7 +848,11 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
||||
|
||||
// 非流式:一次性转换响应体
|
||||
if !claudeReq.Stream {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("claude bridge: read response: %w", readErr)
|
||||
}
|
||||
resp.Body.Close()
|
||||
oaiJSON, err := claudeToOpenAIResponseJSON(respBody)
|
||||
if err != nil {
|
||||
|
||||
@@ -189,7 +189,10 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
||||
|
||||
// 非200:读完 body 返回
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
c.logger.Warn("failed to read OpenAI error response body", zap.Error(readErr))
|
||||
}
|
||||
return "", &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
@@ -329,7 +332,10 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
c.logger.Warn("failed to read OpenAI error response body", zap.Error(readErr))
|
||||
}
|
||||
return "", nil, "", &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// rateLimitEntry 记录某个 IP 的请求窗口信息
|
||||
type rateLimitEntry struct {
|
||||
count int
|
||||
windowAt time.Time
|
||||
}
|
||||
|
||||
// RateLimiter 基于 IP 的滑动窗口速率限制器
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*rateLimitEntry
|
||||
limit int // 窗口内允许的最大请求数
|
||||
window time.Duration // 窗口时长
|
||||
}
|
||||
|
||||
// NewRateLimiter 创建速率限制器
|
||||
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
entries: make(map[string]*rateLimitEntry),
|
||||
limit: limit,
|
||||
window: window,
|
||||
}
|
||||
// 后台定期清理过期条目,防止内存泄漏
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
// cleanup 每分钟清理一次过期条目
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
rl.mu.Lock()
|
||||
now := time.Now()
|
||||
for ip, entry := range rl.entries {
|
||||
if now.Sub(entry.windowAt) > rl.window {
|
||||
delete(rl.entries, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// allow 检查指定 IP 是否允许通过
|
||||
func (rl *RateLimiter) allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
entry, ok := rl.entries[ip]
|
||||
if !ok || now.Sub(entry.windowAt) > rl.window {
|
||||
rl.entries[ip] = &rateLimitEntry{count: 1, windowAt: now}
|
||||
return true
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return entry.count <= rl.limit
|
||||
}
|
||||
|
||||
// RateLimitMiddleware 返回 Gin 中间件,对超限请求返回 429
|
||||
func RateLimitMiddleware(rl *RateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
if !rl.allow(ip) {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate limit exceeded, please try again later",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
name: "anew"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write("缺少输入数据\n")
|
||||
sys.exit(1)
|
||||
|
||||
input_data = sys.argv[1]
|
||||
output_file = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
additional = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
|
||||
cmd = ["anew"]
|
||||
if additional:
|
||||
cmd.extend(shlex.split(additional))
|
||||
if output_file:
|
||||
cmd.append(output_file)
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=input_data.encode("utf-8"),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
sys.stderr.write(proc.stderr or proc.stdout)
|
||||
sys.exit(proc.returncode)
|
||||
|
||||
sys.stdout.write(proc.stdout)
|
||||
enabled: true
|
||||
short_description: "数据去重工具,用于处理文件中的新行"
|
||||
description: |
|
||||
Anew是一个数据去重工具,用于将新行追加到文件中,自动过滤重复项。
|
||||
|
||||
**主要功能:**
|
||||
- 数据去重
|
||||
- 文件追加
|
||||
- 唯一行过滤
|
||||
- 快速处理
|
||||
|
||||
**使用场景:**
|
||||
- 数据处理
|
||||
- 结果去重
|
||||
- 数据合并
|
||||
- 工具链集成
|
||||
parameters:
|
||||
- name: "input_data"
|
||||
type: "string"
|
||||
description: "输入数据"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "output_file"
|
||||
type: "string"
|
||||
description: "输出文件路径"
|
||||
required: false
|
||||
default: ""
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的anew参数。用于传递未在参数列表中定义的anew选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
default: ""
|
||||
position: 2
|
||||
format: "positional"
|
||||
@@ -1,105 +0,0 @@
|
||||
name: "api-fuzzer"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import pathlib
|
||||
import sys
|
||||
import textwrap
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write("缺少 base_url 参数\n")
|
||||
sys.exit(1)
|
||||
|
||||
base_url = sys.argv[1]
|
||||
endpoints_arg = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
methods_arg = sys.argv[3] if len(sys.argv) > 3 else "GET,POST"
|
||||
wordlist_path = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
timeout = float(sys.argv[5]) if len(sys.argv) > 5 and sys.argv[5] else 10.0
|
||||
|
||||
methods = [m.strip().upper() for m in methods_arg.split(",") if m.strip()]
|
||||
if not methods:
|
||||
methods = ["GET"]
|
||||
|
||||
endpoints = []
|
||||
if endpoints_arg:
|
||||
endpoints = [ep.strip() for ep in endpoints_arg.split(",") if ep.strip()]
|
||||
elif wordlist_path:
|
||||
path = pathlib.Path(wordlist_path)
|
||||
if not path.is_file():
|
||||
sys.stderr.write(f"字典文件不存在: {path}\n")
|
||||
sys.exit(1)
|
||||
endpoints = [line.strip() for line in path.read_text().splitlines() if line.strip()]
|
||||
|
||||
if not endpoints:
|
||||
sys.stderr.write("未提供端点列表或字典。\n")
|
||||
sys.exit(1)
|
||||
|
||||
results = []
|
||||
for endpoint in endpoints:
|
||||
url = urljoin(base_url.rstrip("/") + "/", endpoint.lstrip("/"))
|
||||
for method in methods:
|
||||
try:
|
||||
resp = requests.request(method, url, timeout=timeout, allow_redirects=False)
|
||||
results.append({
|
||||
"method": method,
|
||||
"endpoint": endpoint,
|
||||
"status": resp.status_code,
|
||||
"length": len(resp.content),
|
||||
"redirect": resp.headers.get("Location", "")
|
||||
})
|
||||
except requests.RequestException as exc:
|
||||
results.append({
|
||||
"method": method,
|
||||
"endpoint": endpoint,
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
for item in results:
|
||||
if "error" in item:
|
||||
print(f"[{item['method']}] {item['endpoint']} -> ERROR: {item['error']}")
|
||||
else:
|
||||
redirect = f" -> {item['redirect']}" if item.get("redirect") else ""
|
||||
print(f"[{item['method']}] {item['endpoint']} -> {item['status']} ({item['length']} bytes){redirect}")
|
||||
enabled: true
|
||||
short_description: "API端点模糊测试工具,支持智能参数发现"
|
||||
description: |
|
||||
基于requests的轻量级API端点探测脚本,可按照提供的端点列表或字典,对多个HTTP方法进行探测并记录状态码与响应长度。
|
||||
parameters:
|
||||
- name: "base_url"
|
||||
type: "string"
|
||||
description: "API基础URL,例如 https://api.example.com/"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "endpoints"
|
||||
type: "string"
|
||||
description: "逗号分隔的端点列表(如 /v1/users,/v1/auth/login)"
|
||||
required: false
|
||||
default: ""
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "methods"
|
||||
type: "string"
|
||||
description: "HTTP方法列表,逗号分隔(默认 GET,POST)"
|
||||
required: false
|
||||
default: "GET,POST"
|
||||
position: 2
|
||||
format: "positional"
|
||||
- name: "wordlist"
|
||||
type: "string"
|
||||
description: "端点字典文件路径(当未提供endpoints时使用)"
|
||||
required: false
|
||||
default: "/usr/share/wordlists/api/api-endpoints.txt"
|
||||
position: 3
|
||||
format: "positional"
|
||||
- name: "timeout"
|
||||
type: "string"
|
||||
description: "每个请求的超时时间(秒,默认10)"
|
||||
required: false
|
||||
default: "10"
|
||||
position: 4
|
||||
format: "positional"
|
||||
@@ -1,46 +0,0 @@
|
||||
name: "autorecon"
|
||||
command: "autorecon"
|
||||
enabled: true
|
||||
short_description: "自动化综合侦察工具"
|
||||
description: |
|
||||
AutoRecon是一个自动化综合侦察工具,用于执行全面的目标枚举。
|
||||
|
||||
**主要功能:**
|
||||
- 自动化端口扫描
|
||||
- 服务识别
|
||||
- 漏洞扫描
|
||||
- 综合报告
|
||||
|
||||
**使用场景:**
|
||||
- 综合安全评估
|
||||
- 渗透测试
|
||||
- 网络侦察
|
||||
- 安全审计
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "目标IP地址或主机名"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "output_dir"
|
||||
type: "string"
|
||||
description: "输出目录"
|
||||
required: false
|
||||
flag: "-o"
|
||||
format: "flag"
|
||||
default: "/tmp/autorecon"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的autorecon参数。用于传递未在参数列表中定义的autorecon选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,50 +0,0 @@
|
||||
name: "burpsuite"
|
||||
command: "burpsuite"
|
||||
enabled: true
|
||||
short_description: "Web应用安全测试平台"
|
||||
description: |
|
||||
Burp Suite是一个Web应用安全测试平台,提供全面的Web安全测试功能。
|
||||
|
||||
**主要功能:**
|
||||
- Web应用安全扫描
|
||||
- 代理拦截
|
||||
- 漏洞扫描
|
||||
- 手动测试工具
|
||||
|
||||
**使用场景:**
|
||||
- Web应用安全测试
|
||||
- 渗透测试
|
||||
- 漏洞扫描
|
||||
- 安全评估
|
||||
parameters:
|
||||
- name: "project_file"
|
||||
type: "string"
|
||||
description: "Burp Suite项目文件路径(--project-file)"
|
||||
required: false
|
||||
flag: "--project-file"
|
||||
format: "flag"
|
||||
- name: "config_file"
|
||||
type: "string"
|
||||
description: "自动化/扫描配置文件(--config-file)"
|
||||
required: false
|
||||
flag: "--config-file"
|
||||
format: "flag"
|
||||
- name: "user_config_file"
|
||||
type: "string"
|
||||
description: "用户配置文件(--user-config-file)"
|
||||
required: false
|
||||
flag: "--user-config-file"
|
||||
format: "flag"
|
||||
- name: "headless"
|
||||
type: "bool"
|
||||
description: "无头模式运行"
|
||||
required: false
|
||||
flag: "--headless"
|
||||
format: "flag"
|
||||
default: false
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的burpsuite参数。用于传递未在参数列表中定义的burpsuite选项(例如 --project-config、--log-config 等)。
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,22 +0,0 @@
|
||||
name: "cat"
|
||||
enabled: true
|
||||
command: "cat"
|
||||
short_description: "读取并输出文件内容"
|
||||
description: |
|
||||
读取文件内容并输出到标准输出。用于查看文件内容。
|
||||
|
||||
**使用场景:**
|
||||
- 查看文本文件内容
|
||||
- 读取配置文件
|
||||
- 查看日志文件
|
||||
|
||||
**注意事项:**
|
||||
- 如果文件很大,结果可能会被保存到存储中
|
||||
- 只能读取文本文件,二进制文件可能显示乱码
|
||||
parameters:
|
||||
- name: "file"
|
||||
type: "string"
|
||||
description: "要读取的文件路径"
|
||||
required: true
|
||||
format: "positional"
|
||||
position: 0
|
||||
@@ -1,78 +0,0 @@
|
||||
name: "create-file"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import base64
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
sys.stderr.write("Usage: create-file <filename> <content> [binary]\n")
|
||||
sys.exit(1)
|
||||
|
||||
filename = sys.argv[1]
|
||||
content = sys.argv[2]
|
||||
binary_arg = sys.argv[3].lower() if len(sys.argv) > 3 else "false"
|
||||
binary = binary_arg in ("1", "true", "yes", "on")
|
||||
|
||||
path = Path(filename)
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if binary:
|
||||
data = base64.b64decode(content)
|
||||
path.write_bytes(data)
|
||||
else:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
print(f"文件已创建: {path}")
|
||||
enabled: true
|
||||
short_description: "创建文件工具"
|
||||
description: |
|
||||
在服务器上创建指定内容的文件。
|
||||
|
||||
**主要功能:**
|
||||
- 创建文件
|
||||
- 写入内容
|
||||
- 支持二进制文件
|
||||
|
||||
**使用场景:**
|
||||
- 文件创建
|
||||
- 脚本生成
|
||||
- 数据保存
|
||||
parameters:
|
||||
- name: "filename"
|
||||
type: "string"
|
||||
description: "要创建的文件名"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "content"
|
||||
type: "string"
|
||||
description: "文件内容"
|
||||
required: true
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "binary"
|
||||
type: "bool"
|
||||
description: "内容是否为Base64编码的二进制"
|
||||
required: false
|
||||
position: 2
|
||||
format: "positional"
|
||||
default: false
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的create-file参数。用于传递未在参数列表中定义的create-file选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,58 +0,0 @@
|
||||
name: "cyberchef"
|
||||
command: "cyberchef"
|
||||
enabled: true
|
||||
short_description: "数据转换和分析工具,支持多种编码、加密和数据处理操作"
|
||||
description: |
|
||||
CyberChef 是一个强大的数据转换和分析工具,支持数百种数据操作。
|
||||
|
||||
**主要功能:**
|
||||
- 编码/解码(Base64, Hex, URL 等)
|
||||
- 加密/解密(AES, DES, RSA 等)
|
||||
- 哈希计算
|
||||
- 数据格式转换
|
||||
- 正则表达式操作
|
||||
- 数据提取和分析
|
||||
|
||||
**使用场景:**
|
||||
- CTF 竞赛
|
||||
- 数据分析和转换
|
||||
- 加密算法研究
|
||||
- 数字取证
|
||||
|
||||
**注意事项:**
|
||||
- 通常以 Web 界面运行
|
||||
- 命令行版本可能需要 Node.js
|
||||
- 功能强大,操作复杂
|
||||
parameters:
|
||||
- name: "recipe"
|
||||
type: "string"
|
||||
description: "操作配方(JSON 格式),定义要执行的操作序列"
|
||||
required: true
|
||||
flag: "-Recipe"
|
||||
format: "flag"
|
||||
- name: "input"
|
||||
type: "string"
|
||||
description: "输入数据(字符串或文件路径)"
|
||||
required: true
|
||||
flag: "-Input"
|
||||
format: "flag"
|
||||
- name: "output"
|
||||
type: "string"
|
||||
description: "输出文件路径(可选)"
|
||||
required: false
|
||||
flag: "-Output"
|
||||
format: "flag"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的cyberchef参数。用于传递未在参数列表中定义的cyberchef选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,44 +0,0 @@
|
||||
name: "delete-file"
|
||||
command: "rm"
|
||||
enabled: true
|
||||
short_description: "删除文件或目录工具"
|
||||
description: |
|
||||
删除服务器上的文件或目录。
|
||||
|
||||
**主要功能:**
|
||||
- 删除文件
|
||||
- 删除目录
|
||||
- 递归删除
|
||||
|
||||
**使用场景:**
|
||||
- 文件清理
|
||||
- 临时文件删除
|
||||
- 目录清理
|
||||
parameters:
|
||||
- name: "filename"
|
||||
type: "string"
|
||||
description: "要删除的文件或目录名"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "recursive"
|
||||
type: "bool"
|
||||
description: "递归删除目录"
|
||||
required: false
|
||||
flag: "-r"
|
||||
format: "flag"
|
||||
default: false
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的delete-file参数。用于传递未在参数列表中定义的delete-file选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,99 +0,0 @@
|
||||
name: "dirb"
|
||||
command: "dirb"
|
||||
enabled: true
|
||||
# 简短描述(用于工具列表,减少token消耗)
|
||||
short_description: "Web目录和文件扫描工具,通过暴力破解方式发现Web服务器上的隐藏目录和文件"
|
||||
# 工具详细描述
|
||||
description: |
|
||||
Web目录和文件扫描工具,通过暴力破解方式发现Web服务器上的隐藏目录和文件。
|
||||
|
||||
**主要功能:**
|
||||
- 目录和文件发现
|
||||
- 支持自定义字典文件
|
||||
- 检测常见的Web目录结构
|
||||
- 识别备份文件、配置文件等敏感文件
|
||||
- 支持多种HTTP方法
|
||||
|
||||
**使用场景:**
|
||||
- Web应用目录枚举
|
||||
- 发现隐藏的管理界面
|
||||
- 查找备份文件和敏感信息
|
||||
- 渗透测试中的信息收集
|
||||
|
||||
**注意事项:**
|
||||
- 扫描可能产生大量HTTP请求
|
||||
- 某些请求可能被WAF拦截
|
||||
- 建议使用合适的字典文件以提高效率
|
||||
- 扫描结果需要人工验证
|
||||
# 参数定义
|
||||
parameters:
|
||||
- name: "url"
|
||||
type: "string"
|
||||
description: |
|
||||
目标URL,要扫描的Web服务器地址。
|
||||
|
||||
**格式要求:**
|
||||
- 必须包含协议(http:// 或 https://)
|
||||
- 可以包含基础路径
|
||||
- 末尾不要带斜杠(除非要扫描特定目录)
|
||||
|
||||
**示例值:**
|
||||
- 基础URL: "http://example.com"
|
||||
- HTTPS: "https://example.com"
|
||||
- 带端口: "http://example.com:8080"
|
||||
- 特定目录: "http://example.com/admin"
|
||||
- 带路径: "http://example.com/app"
|
||||
|
||||
**注意事项:**
|
||||
- URL必须可访问
|
||||
- 确保URL格式正确,包含协议前缀
|
||||
- 必需参数,不能为空
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "wordlist"
|
||||
type: "string"
|
||||
description: |
|
||||
字典文件路径,包含要尝试的目录和文件名列表。
|
||||
|
||||
**格式要求:**
|
||||
- 文件路径,可以是绝对路径或相对路径
|
||||
- 文件每行一个目录或文件名
|
||||
- 支持常见的字典文件格式
|
||||
|
||||
**示例值:**
|
||||
- 默认字典: "/usr/share/dirb/wordlists/common.txt"
|
||||
- 自定义字典: "/path/to/custom-wordlist.txt"
|
||||
- 常用字典: "/usr/share/wordlists/dirb/common.txt"
|
||||
|
||||
**常用字典文件:**
|
||||
- common.txt: 常见目录和文件
|
||||
- big.txt: 大型字典
|
||||
- small.txt: 小型快速字典
|
||||
- extensions_common.txt: 常见文件扩展名
|
||||
|
||||
**注意事项:**
|
||||
- 如果不指定,将使用默认字典
|
||||
- 确保字典文件存在且可读
|
||||
- 大型字典会显著增加扫描时间
|
||||
required: false
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的Dirb参数。用于传递未在参数列表中定义的Dirb选项。
|
||||
|
||||
**示例值:**
|
||||
- "-a": 用户代理字符串
|
||||
- "-H": 自定义HTTP头
|
||||
- "-c": Cookie字符串
|
||||
- "-X": 文件扩展名
|
||||
- "-z": 毫秒延迟
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,51 +0,0 @@
|
||||
name: "docker-bench-security"
|
||||
command: "docker-bench-security"
|
||||
enabled: true
|
||||
short_description: "Docker安全基准检查工具"
|
||||
description: |
|
||||
Docker Bench for Security是一个Docker安全基准检查工具,用于检查Docker配置是否符合安全最佳实践。
|
||||
|
||||
**主要功能:**
|
||||
- Docker安全基准检查
|
||||
- 配置审计
|
||||
- 安全最佳实践检查
|
||||
- 详细报告
|
||||
|
||||
**使用场景:**
|
||||
- Docker安全审计
|
||||
- 配置检查
|
||||
- 合规性验证
|
||||
- 安全评估
|
||||
parameters:
|
||||
- name: "checks"
|
||||
type: "string"
|
||||
description: "要运行的特定检查"
|
||||
required: false
|
||||
flag: "-c"
|
||||
format: "flag"
|
||||
- name: "exclude"
|
||||
type: "string"
|
||||
description: "要排除的检查"
|
||||
required: false
|
||||
flag: "-e"
|
||||
format: "flag"
|
||||
- name: "output_file"
|
||||
type: "string"
|
||||
description: "输出文件路径"
|
||||
required: false
|
||||
flag: "-l"
|
||||
format: "flag"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的docker-bench-security参数。用于传递未在参数列表中定义的docker-bench-security选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,31 +0,0 @@
|
||||
name: "enum4linux"
|
||||
command: "enum4linux"
|
||||
enabled: true
|
||||
short_description: "SMB枚举工具,用于Windows/Samba系统信息收集"
|
||||
description: |
|
||||
Enum4linux是一个用于枚举SMB共享和Windows系统信息的工具。
|
||||
|
||||
**主要功能:**
|
||||
- SMB共享枚举
|
||||
- 用户和组枚举
|
||||
- 密码策略信息
|
||||
- 系统信息收集
|
||||
|
||||
**使用场景:**
|
||||
- Windows系统渗透测试
|
||||
- SMB安全评估
|
||||
- 网络信息收集
|
||||
- 域环境侦察
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "目标IP地址"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: "额外的Enum4linux参数(默认:-a)"
|
||||
required: false
|
||||
default: "-a"
|
||||
format: "positional"
|
||||
@@ -1,76 +0,0 @@
|
||||
name: "fcrackzip"
|
||||
command: "fcrackzip"
|
||||
enabled: true
|
||||
short_description: "ZIP 文件密码破解工具,支持暴力破解和字典攻击"
|
||||
description: |
|
||||
fcrackzip 是一个用于破解受密码保护的 ZIP 文件密码的工具。
|
||||
|
||||
**主要功能:**
|
||||
- 暴力破解
|
||||
- 字典攻击
|
||||
- 指定字符集
|
||||
- 指定密码长度范围
|
||||
- 多线程支持
|
||||
|
||||
**使用场景:**
|
||||
- CTF 竞赛
|
||||
- ZIP 文件密码恢复
|
||||
- 安全测试
|
||||
- 数字取证
|
||||
|
||||
**注意事项:**
|
||||
- 破解时间取决于密码复杂度
|
||||
- 建议使用字典文件提高效率
|
||||
- 仅用于授权的安全测试
|
||||
parameters:
|
||||
- name: "file"
|
||||
type: "string"
|
||||
description: "要破解的 ZIP 文件路径"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "dictionary_mode"
|
||||
type: "bool"
|
||||
description: "启用字典攻击模式(等同于 -D)"
|
||||
required: false
|
||||
flag: "-D"
|
||||
format: "flag"
|
||||
default: false
|
||||
- name: "dictionary"
|
||||
type: "string"
|
||||
description: "字典文件路径(与 -D 配合使用)"
|
||||
required: false
|
||||
flag: "-p"
|
||||
format: "flag"
|
||||
- name: "bruteforce"
|
||||
type: "bool"
|
||||
description: "使用暴力破解模式"
|
||||
required: false
|
||||
flag: "-b"
|
||||
format: "flag"
|
||||
- name: "charset"
|
||||
type: "string"
|
||||
description: "字符集,例如 'aA1' 表示小写字母、大写字母和数字"
|
||||
required: false
|
||||
flag: "-c"
|
||||
format: "flag"
|
||||
- name: "length_range"
|
||||
type: "string"
|
||||
description: "密码长度范围,格式为min-max(例如 4-8)"
|
||||
required: false
|
||||
flag: "-l"
|
||||
format: "flag"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的fcrackzip参数。用于传递未在参数列表中定义的fcrackzip选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,6 +1,6 @@
|
||||
name: "feroxbuster"
|
||||
command: "feroxbuster"
|
||||
enabled: true
|
||||
enabled: false
|
||||
short_description: "递归内容发现工具"
|
||||
description: |
|
||||
Feroxbuster是一个快速、简单的递归内容发现工具。
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
name: "gdb-peda"
|
||||
command: "gdb"
|
||||
enabled: true
|
||||
short_description: "带PEDA增强的GDB调试器"
|
||||
description: |
|
||||
GDB-PEDA是带有PEDA(Python Exploit Development Assistance)增强的GDB调试器。
|
||||
|
||||
**主要功能:**
|
||||
- 增强的GDB功能
|
||||
- 自动化分析
|
||||
- 漏洞利用辅助
|
||||
- 可视化显示
|
||||
|
||||
**使用场景:**
|
||||
- 二进制调试
|
||||
- 漏洞利用开发
|
||||
- 逆向工程
|
||||
- 安全研究
|
||||
parameters:
|
||||
- name: "binary"
|
||||
type: "string"
|
||||
description: "要调试的二进制文件"
|
||||
required: false
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "commands"
|
||||
type: "string"
|
||||
description: "GDB命令(分号分隔)"
|
||||
required: false
|
||||
flag: "-ex"
|
||||
format: "flag"
|
||||
- name: "attach_pid"
|
||||
type: "int"
|
||||
description: "要附加的进程ID"
|
||||
required: false
|
||||
flag: "-p"
|
||||
format: "flag"
|
||||
- name: "core_file"
|
||||
type: "string"
|
||||
description: "核心转储文件路径"
|
||||
required: false
|
||||
flag: "-c"
|
||||
format: "flag"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的gdb-peda参数。用于传递未在参数列表中定义的gdb-peda选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name: "gobuster"
|
||||
command: "gobuster"
|
||||
enabled: true
|
||||
enabled: false
|
||||
short_description: "Web内容扫描工具,用于发现目录、文件和子域名"
|
||||
description: |
|
||||
Gobuster是一个快速的内容发现工具,用于Web应用程序的目录、文件和子域名枚举。
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: "hakrawler"
|
||||
command: "hakrawler"
|
||||
enabled: true
|
||||
short_description: "Web端点发现工具"
|
||||
description: |
|
||||
Hakrawler是一个快速、简单的Web端点发现工具。
|
||||
|
||||
**主要功能:**
|
||||
- Web端点发现
|
||||
- 链接提取
|
||||
- JavaScript文件发现
|
||||
- 快速爬取
|
||||
|
||||
**使用场景:**
|
||||
- Web端点发现
|
||||
- 内容爬取
|
||||
- 安全测试
|
||||
- Bug bounty侦察
|
||||
parameters:
|
||||
- name: "url"
|
||||
type: "string"
|
||||
description: "目标URL"
|
||||
required: true
|
||||
flag: "-url"
|
||||
format: "flag"
|
||||
- name: "depth"
|
||||
type: "int"
|
||||
description: "爬取深度"
|
||||
required: false
|
||||
flag: "-d"
|
||||
format: "flag"
|
||||
default: 2
|
||||
- name: "forms"
|
||||
type: "bool"
|
||||
description: "包含表单"
|
||||
required: false
|
||||
flag: "-forms"
|
||||
format: "flag"
|
||||
default: true
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的hakrawler参数。用于传递未在参数列表中定义的hakrawler选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,84 +0,0 @@
|
||||
name: "hash-identifier"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write("缺少哈希值\n")
|
||||
sys.exit(1)
|
||||
|
||||
hash_value = sys.argv[1]
|
||||
extra = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
|
||||
cmd = ["hash-identifier"]
|
||||
if extra:
|
||||
cmd.extend(shlex.split(extra))
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=f"{hash_value}\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
sys.stderr.write(proc.stderr or proc.stdout)
|
||||
sys.exit(proc.returncode)
|
||||
|
||||
sys.stdout.write(proc.stdout)
|
||||
enabled: true
|
||||
short_description: "哈希类型识别工具,用于识别未知哈希值的类型"
|
||||
description: |
|
||||
hash-identifier 是一个用于识别哈希值类型的工具,可以帮助确定未知哈希值使用的算法。
|
||||
|
||||
**主要功能:**
|
||||
- 识别多种哈希算法
|
||||
- 支持 MD5, SHA1, SHA256, bcrypt 等
|
||||
- 交互式识别
|
||||
- 快速识别常见哈希类型
|
||||
|
||||
**支持的哈希类型:**
|
||||
- MD5
|
||||
- SHA1, SHA256, SHA512
|
||||
- bcrypt
|
||||
- NTLM
|
||||
- MySQL
|
||||
- PostgreSQL
|
||||
- 等多种哈希算法
|
||||
|
||||
**使用场景:**
|
||||
- CTF 密码破解
|
||||
- 哈希值分析
|
||||
- 密码学研究
|
||||
- 安全审计
|
||||
|
||||
**注意事项:**
|
||||
- 需要 Python 环境
|
||||
- 交互式工具,可能需要特殊处理
|
||||
parameters:
|
||||
- name: "hash"
|
||||
type: "string"
|
||||
description: "要识别的哈希值"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的hash-identifier参数。用于传递未在参数列表中定义的hash-identifier选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
default: ""
|
||||
position: 1
|
||||
format: "positional"
|
||||
@@ -1,157 +0,0 @@
|
||||
name: "http-intruder"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlencode, urlparse, parse_qs, urlunparse
|
||||
|
||||
import requests
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
sys.stderr.write("需要至少URL和载荷\n")
|
||||
sys.exit(1)
|
||||
|
||||
url = sys.argv[1]
|
||||
method = (sys.argv[2] or "GET").upper()
|
||||
location = (sys.argv[3] or "query").lower()
|
||||
params_input = sys.argv[4] if len(sys.argv) > 4 else "{}"
|
||||
payloads_json = sys.argv[5] if len(sys.argv) > 5 else "[]"
|
||||
max_requests = int(sys.argv[6]) if len(sys.argv) > 6 and sys.argv[6] else 0
|
||||
|
||||
try:
|
||||
# 框架会将 object 类型序列化为 JSON 字符串传递
|
||||
# sys.argv 中的参数都是字符串,需要解析 JSON
|
||||
if params_input and params_input.strip():
|
||||
params_template = json.loads(params_input)
|
||||
if not isinstance(params_template, dict):
|
||||
sys.stderr.write("参数模板必须是字典格式\n")
|
||||
sys.exit(1)
|
||||
else:
|
||||
params_template = {}
|
||||
except json.JSONDecodeError as exc:
|
||||
sys.stderr.write(f"参数模板解析失败(需要 JSON 字典格式): {exc}\n")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# 框架会将 array 类型转换为逗号分隔的字符串(见 formatParamValue)
|
||||
# 但为了兼容性,也支持 JSON 数组格式
|
||||
if payloads_json and payloads_json.strip():
|
||||
payloads_str = payloads_json.strip()
|
||||
# 优先尝试解析为 JSON 数组
|
||||
if payloads_str.startswith("["):
|
||||
try:
|
||||
payloads = json.loads(payloads_str)
|
||||
except json.JSONDecodeError:
|
||||
# JSON 解析失败,尝试逗号分隔格式
|
||||
payloads = [item.strip() for item in payloads_str.split(",") if item.strip()]
|
||||
else:
|
||||
# 逗号分隔的字符串(框架的 array 类型默认格式)
|
||||
payloads = [item.strip() for item in payloads_str.split(",") if item.strip()]
|
||||
if not isinstance(payloads, list):
|
||||
sys.stderr.write("载荷必须是数组格式\n")
|
||||
sys.exit(1)
|
||||
else:
|
||||
payloads = []
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
sys.stderr.write(f"载荷解析失败(需要 JSON 数组或逗号分隔格式): {exc}\n")
|
||||
sys.exit(1)
|
||||
|
||||
if not isinstance(payloads, list) or not payloads:
|
||||
sys.stderr.write("载荷列表不能为空\n")
|
||||
sys.exit(1)
|
||||
|
||||
param_names = list(params_template.keys())
|
||||
if not param_names:
|
||||
sys.stderr.write("参数模板不能为空\n")
|
||||
sys.exit(1)
|
||||
|
||||
session = requests.Session()
|
||||
sent = 0
|
||||
|
||||
def build_query(original_url, data):
|
||||
parsed = urlparse(original_url)
|
||||
existing = {k: v[0] for k, v in parse_qs(parsed.query, keep_blank_values=True).items()}
|
||||
existing.update(data)
|
||||
new_query = urlencode(existing, doseq=True)
|
||||
return urlunparse(parsed._replace(query=new_query))
|
||||
|
||||
for param in param_names:
|
||||
for payload in payloads:
|
||||
if max_requests and sent >= max_requests:
|
||||
break
|
||||
payload_str = str(payload)
|
||||
if location == "query":
|
||||
new_url = build_query(url, {param: payload_str})
|
||||
response = session.request(method, new_url)
|
||||
elif location == "body":
|
||||
body = params_template.copy()
|
||||
body[param] = payload_str
|
||||
response = session.request(method, url, data=body)
|
||||
elif location == "headers":
|
||||
headers = params_template.copy()
|
||||
headers[param] = payload_str
|
||||
response = session.request(method, url, headers=headers)
|
||||
elif location == "cookie":
|
||||
cookies = params_template.copy()
|
||||
cookies[param] = payload_str
|
||||
response = session.request(method, url, cookies=cookies)
|
||||
else:
|
||||
sys.stderr.write(f"不支持的位置: {location}\n")
|
||||
sys.exit(1)
|
||||
|
||||
sent += 1
|
||||
length = len(response.content)
|
||||
print(f"[{sent}] {param} = {payload_str} -> {response.status_code} ({length} bytes)")
|
||||
if max_requests and sent >= max_requests:
|
||||
break
|
||||
|
||||
if sent == 0:
|
||||
sys.stderr.write("未发送任何请求,请检查参数配置。\n")
|
||||
enabled: true
|
||||
short_description: "简单的Intruder(sniper)模糊测试工具"
|
||||
description: |
|
||||
轻量级HTTP“狙击手”模式模糊器,对每个参数逐一替换载荷并记录响应。
|
||||
parameters:
|
||||
- name: "url"
|
||||
type: "string"
|
||||
description: "目标URL"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "method"
|
||||
type: "string"
|
||||
description: "HTTP方法(默认GET)"
|
||||
required: false
|
||||
default: "GET"
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "location"
|
||||
type: "string"
|
||||
description: "载荷位置(query, body, headers, cookie)"
|
||||
required: false
|
||||
default: "query"
|
||||
position: 2
|
||||
format: "positional"
|
||||
- name: "params"
|
||||
type: "object"
|
||||
description: "参数模板(字典格式),指定要模糊的键及默认值,如 {\"id\": \"1\", \"name\": \"test\"}"
|
||||
required: true
|
||||
position: 3
|
||||
format: "positional"
|
||||
- name: "payloads"
|
||||
type: "array"
|
||||
item_type: "string"
|
||||
description: "载荷列表(数组格式),如 [\"test1\", \"test2\", \"test3\"]"
|
||||
required: true
|
||||
position: 4
|
||||
format: "positional"
|
||||
- name: "max_requests"
|
||||
type: "int"
|
||||
description: "最大请求数(0表示全部)"
|
||||
required: false
|
||||
default: 0
|
||||
position: 5
|
||||
format: "positional"
|
||||
@@ -1,46 +0,0 @@
|
||||
name: "mimikatz"
|
||||
command: "mimikatz.exe"
|
||||
enabled: false
|
||||
short_description: "Windows 凭证提取工具,用于提取内存中的密码和哈希"
|
||||
description: |
|
||||
Mimikatz 是一个强大的 Windows 凭证提取工具,可以从内存中提取明文密码、哈希值、票据等敏感信息。
|
||||
|
||||
**主要功能:**
|
||||
- 提取内存中的明文密码
|
||||
- 提取 NTLM 哈希
|
||||
- 提取 Kerberos 票据
|
||||
- Pass-the-Hash 攻击
|
||||
- Pass-the-Ticket 攻击
|
||||
- 凭证转储
|
||||
|
||||
**使用场景:**
|
||||
- 后渗透测试
|
||||
- 横向移动
|
||||
- 权限提升
|
||||
- 安全研究
|
||||
|
||||
**注意事项:**
|
||||
- 需要管理员权限运行
|
||||
- 可能被杀毒软件检测
|
||||
- 仅用于授权的安全测试
|
||||
- 使用前需要进入 mimikatz 交互式命令行
|
||||
parameters:
|
||||
- name: "command"
|
||||
type: "string"
|
||||
description: "Mimikatz 命令,例如 'privilege::debug sekurlsa::logonpasswords'"
|
||||
required: true
|
||||
format: "positional"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的mimikatz参数。用于传递未在参数列表中定义的mimikatz选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,76 +0,0 @@
|
||||
name: "modify-file"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
sys.stderr.write("Usage: modify-file <filename> <content> [append]\n")
|
||||
sys.exit(1)
|
||||
|
||||
filename = sys.argv[1]
|
||||
content = sys.argv[2]
|
||||
append_arg = sys.argv[3].lower() if len(sys.argv) > 3 else "false"
|
||||
append = append_arg in ("1", "true", "yes", "on")
|
||||
|
||||
path = Path(filename)
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mode = "a" if append else "w"
|
||||
with path.open(mode, encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
action = "追加" if append else "覆盖"
|
||||
print(f"{action}写入完成: {path}")
|
||||
enabled: true
|
||||
short_description: "修改文件工具"
|
||||
description: |
|
||||
修改服务器上的现有文件。
|
||||
|
||||
**主要功能:**
|
||||
- 修改文件
|
||||
- 追加内容
|
||||
- 覆盖内容
|
||||
|
||||
**使用场景:**
|
||||
- 文件编辑
|
||||
- 内容追加
|
||||
- 配置修改
|
||||
parameters:
|
||||
- name: "filename"
|
||||
type: "string"
|
||||
description: "要修改的文件名"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "content"
|
||||
type: "string"
|
||||
description: "要写入或追加的内容"
|
||||
required: true
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "append"
|
||||
type: "bool"
|
||||
description: "是否追加(true)或覆盖(false)"
|
||||
required: false
|
||||
default: false
|
||||
position: 2
|
||||
format: "positional"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的modify-file参数。用于传递未在参数列表中定义的modify-file选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,62 +0,0 @@
|
||||
name: "pdfcrack"
|
||||
command: "pdfcrack"
|
||||
enabled: true
|
||||
short_description: "PDF 文件密码破解工具,支持暴力破解和字典攻击"
|
||||
description: |
|
||||
pdfcrack 是一个用于破解受密码保护的 PDF 文件密码的工具。
|
||||
|
||||
**主要功能:**
|
||||
- 暴力破解
|
||||
- 字典攻击
|
||||
- 用户密码和所有者密码破解
|
||||
- 支持多种加密算法
|
||||
|
||||
**使用场景:**
|
||||
- CTF 竞赛
|
||||
- PDF 文件密码恢复
|
||||
- 安全测试
|
||||
- 数字取证
|
||||
|
||||
**注意事项:**
|
||||
- 破解时间取决于密码复杂度
|
||||
- 建议使用字典文件提高效率
|
||||
- 仅用于授权的安全测试
|
||||
parameters:
|
||||
- name: "file"
|
||||
type: "string"
|
||||
description: "要破解的 PDF 文件路径"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "wordlist"
|
||||
type: "string"
|
||||
description: "字典文件路径"
|
||||
required: false
|
||||
flag: "-w"
|
||||
format: "flag"
|
||||
- name: "min_length"
|
||||
type: "int"
|
||||
description: "最小密码长度"
|
||||
required: false
|
||||
flag: "-n"
|
||||
format: "flag"
|
||||
- name: "max_length"
|
||||
type: "int"
|
||||
description: "最大密码长度"
|
||||
required: false
|
||||
flag: "-m"
|
||||
format: "flag"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的pdfcrack参数。用于传递未在参数列表中定义的pdfcrack选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,81 +0,0 @@
|
||||
name: "qsreplace"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write("缺少URL列表\n")
|
||||
sys.exit(1)
|
||||
|
||||
urls = sys.argv[1]
|
||||
replacement = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
extra = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
|
||||
cmd = ["qsreplace"]
|
||||
if extra:
|
||||
cmd.extend(shlex.split(extra))
|
||||
if replacement:
|
||||
cmd.append(replacement)
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=urls,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.stdout:
|
||||
sys.stdout.write(proc.stdout)
|
||||
if proc.stderr:
|
||||
sys.stderr.write(proc.stderr)
|
||||
sys.exit(proc.returncode)
|
||||
enabled: true
|
||||
short_description: "查询字符串参数替换工具"
|
||||
description: |
|
||||
Qsreplace是一个用于替换URL中查询字符串参数的工具,常用于模糊测试。
|
||||
|
||||
**主要功能:**
|
||||
- 参数替换
|
||||
- 批量处理
|
||||
- 多种替换模式
|
||||
- 快速处理
|
||||
|
||||
**使用场景:**
|
||||
- 参数模糊测试
|
||||
- URL处理
|
||||
- 工具链集成
|
||||
- 安全测试
|
||||
parameters:
|
||||
- name: "urls"
|
||||
type: "string"
|
||||
description: "要处理的URL(每行一个)"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "replacement"
|
||||
type: "string"
|
||||
description: "替换字符串"
|
||||
required: false
|
||||
default: ""
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的Qsreplace参数。用于传递未在参数列表中定义的Qsreplace选项。
|
||||
|
||||
**示例值:**
|
||||
- "-a": 追加模式
|
||||
- "-d": 删除参数
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
default: ""
|
||||
position: 2
|
||||
format: "positional"
|
||||
@@ -1,52 +0,0 @@
|
||||
name: "stegsolve"
|
||||
command: "java"
|
||||
args: ["-jar"]
|
||||
enabled: true
|
||||
short_description: "图片隐写分析工具,用于分析图片中的隐写数据"
|
||||
description: |
|
||||
Stegsolve 是一个 Java 图片隐写分析工具,支持多种图片格式和隐写分析技术。
|
||||
|
||||
**主要功能:**
|
||||
- 图片格式转换
|
||||
- 颜色通道分析
|
||||
- LSB 隐写检测
|
||||
- 图片叠加分析
|
||||
- 数据提取
|
||||
|
||||
**使用场景:**
|
||||
- CTF 隐写题目
|
||||
- 图片隐写分析
|
||||
- 数字取证
|
||||
- 安全研究
|
||||
|
||||
**注意事项:**
|
||||
- 需要 Java 环境
|
||||
- 通常以 GUI 形式运行
|
||||
- 可能需要通过命令行参数或脚本调用
|
||||
parameters:
|
||||
- name: "jar_file"
|
||||
type: "string"
|
||||
description: "Stegsolve JAR 文件路径,例如 'stegsolve.jar'"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "image"
|
||||
type: "string"
|
||||
description: "要分析的图片文件路径"
|
||||
required: false
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的stegsolve参数。用于传递未在参数列表中定义的stegsolve选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,70 +0,0 @@
|
||||
name: "uro"
|
||||
command: "python3"
|
||||
args:
|
||||
- "-c"
|
||||
- |
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write("缺少URL列表\n")
|
||||
sys.exit(1)
|
||||
|
||||
urls = sys.argv[1]
|
||||
extra = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
|
||||
cmd = ["uro"]
|
||||
if extra:
|
||||
cmd.extend(shlex.split(extra))
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=urls,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.stdout:
|
||||
sys.stdout.write(proc.stdout)
|
||||
if proc.stderr:
|
||||
sys.stderr.write(proc.stderr)
|
||||
sys.exit(proc.returncode)
|
||||
enabled: true
|
||||
short_description: "URL过滤工具,用于过滤相似的URL"
|
||||
description: |
|
||||
Uro是一个URL过滤工具,用于过滤掉相似的URL,去除重复项。
|
||||
|
||||
**主要功能:**
|
||||
- URL去重
|
||||
- 相似URL过滤
|
||||
- 白名单/黑名单支持
|
||||
- 快速处理
|
||||
|
||||
**使用场景:**
|
||||
- URL去重
|
||||
- 结果过滤
|
||||
- 数据清理
|
||||
- 工具链集成
|
||||
parameters:
|
||||
- name: "urls"
|
||||
type: "string"
|
||||
description: "要过滤的URL(每行一个)"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的uro参数。用于传递未在参数列表中定义的uro选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
default: ""
|
||||
position: 1
|
||||
format: "positional"
|
||||
@@ -1,51 +0,0 @@
|
||||
name: "volatility"
|
||||
command: "volatility"
|
||||
enabled: true
|
||||
short_description: "内存取证分析工具"
|
||||
description: |
|
||||
Volatility是一个内存取证框架,用于从内存转储中提取数字证据。
|
||||
|
||||
**主要功能:**
|
||||
- 内存转储分析
|
||||
- 进程列表提取
|
||||
- 网络连接分析
|
||||
- 文件系统重建
|
||||
|
||||
**使用场景:**
|
||||
- 内存取证
|
||||
- 恶意软件分析
|
||||
- 事件响应
|
||||
- 数字取证
|
||||
parameters:
|
||||
- name: "memory_file"
|
||||
type: "string"
|
||||
description: "内存转储文件路径"
|
||||
required: true
|
||||
flag: "-f"
|
||||
format: "flag"
|
||||
- name: "plugin"
|
||||
type: "string"
|
||||
description: "要使用的Volatility插件"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "profile"
|
||||
type: "string"
|
||||
description: "内存配置文件"
|
||||
required: false
|
||||
flag: "--profile"
|
||||
format: "flag"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的volatility参数。用于传递未在参数列表中定义的volatility选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,45 +0,0 @@
|
||||
name: "wfuzz"
|
||||
command: "wfuzz"
|
||||
enabled: true
|
||||
short_description: "Web应用模糊测试工具"
|
||||
description: |
|
||||
Wfuzz是一个Web应用模糊测试工具,用于发现Web应用中的漏洞。
|
||||
|
||||
**主要功能:**
|
||||
- Web应用模糊测试
|
||||
- 参数发现
|
||||
- 目录发现
|
||||
- 多种过滤器
|
||||
|
||||
**使用场景:**
|
||||
- Web应用安全测试
|
||||
- 参数模糊测试
|
||||
- 目录枚举
|
||||
- 漏洞发现
|
||||
parameters:
|
||||
- name: "url"
|
||||
type: "string"
|
||||
description: "目标URL(使用FUZZ作为占位符)"
|
||||
required: true
|
||||
flag: "-u"
|
||||
format: "flag"
|
||||
- name: "wordlist"
|
||||
type: "string"
|
||||
description: "字典文件路径"
|
||||
required: false
|
||||
flag: "-w"
|
||||
format: "flag"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的wfuzz参数。用于传递未在参数列表中定义的wfuzz选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
@@ -1,54 +0,0 @@
|
||||
name: "winpeas"
|
||||
command: "winPEAS.exe"
|
||||
enabled: true
|
||||
short_description: "Windows 权限提升枚举工具,自动检测常见提权路径"
|
||||
description: |
|
||||
WinPEAS (Windows Privilege Escalation Awesome Script) 是一个自动化权限提升枚举工具,用于检测 Windows 系统中的常见提权路径。
|
||||
|
||||
**主要功能:**
|
||||
- 系统信息收集
|
||||
- 用户和组权限检查
|
||||
- 服务配置分析
|
||||
- 注册表检查
|
||||
- 计划任务分析
|
||||
- 网络配置检查
|
||||
- 文件权限检查
|
||||
- 凭证查找
|
||||
|
||||
**使用场景:**
|
||||
- 渗透测试中的权限提升
|
||||
- Windows 安全审计
|
||||
- 后渗透测试
|
||||
- CTF 竞赛
|
||||
|
||||
**注意事项:**
|
||||
- 需要目标系统上已下载 winPEAS.exe
|
||||
- 可能需要管理员权限
|
||||
- 输出信息量大,建议保存到文件
|
||||
parameters:
|
||||
- name: "quiet"
|
||||
type: "bool"
|
||||
description: "安静模式,只显示重要信息"
|
||||
required: false
|
||||
flag: "-q"
|
||||
format: "flag"
|
||||
- name: "notcolor"
|
||||
type: "bool"
|
||||
description: "禁用颜色输出"
|
||||
required: false
|
||||
flag: "-notcolor"
|
||||
format: "flag"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的winpeas参数。用于传递未在参数列表中定义的winpeas选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
+458
-3
@@ -532,6 +532,10 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.conversation-sidebar.collapsed .hitl-sidebar-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.conversation-sidebar.collapsed .conversation-sidebar-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -948,6 +952,273 @@ header {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.hitl-sidebar-card {
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: linear-gradient(165deg, #f8fafc 0%, #f1f5f9 55%, #eef2f7 100%);
|
||||
padding: 14px 12px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hitl-sidebar-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.hitl-sidebar-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hitl-sidebar-icon {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(145deg, rgba(0, 102, 255, 0.12), rgba(0, 102, 255, 0.06));
|
||||
color: var(--accent-color);
|
||||
border: 1px solid rgba(0, 102, 255, 0.18);
|
||||
}
|
||||
|
||||
.hitl-sidebar-heading-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hitl-sidebar-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.hitl-sidebar-subtitle {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.hitl-apply-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(180deg, var(--accent-color) 0%, var(--accent-hover) 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 102, 255, 0.25), 0 2px 8px rgba(0, 102, 255, 0.12);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease;
|
||||
}
|
||||
|
||||
.hitl-apply-btn:hover {
|
||||
filter: brightness(1.05);
|
||||
box-shadow: 0 2px 4px rgba(0, 102, 255, 0.3), 0 4px 12px rgba(0, 102, 255, 0.18);
|
||||
}
|
||||
|
||||
.hitl-apply-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.hitl-apply-btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.hitl-apply-feedback {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
margin: 0 0 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #047857;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.hitl-apply-feedback.hitl-apply-feedback--error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #b91c1c;
|
||||
border-color: rgba(239, 68, 68, 0.22);
|
||||
}
|
||||
|
||||
/* 仅本机保存、未请求服务端:避免与「已全部同步」同款绿色造成误解 */
|
||||
.hitl-apply-feedback.hitl-apply-feedback--partial {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #b45309;
|
||||
border-color: rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
.hitl-sidebar-config {
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.hitl-config-field {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.hitl-config-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hitl-config-field--tools {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hitl-config-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.hitl-config-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hitl-config-select {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-primary);
|
||||
padding: 0 36px 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236c757d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.hitl-config-textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
max-height: 200px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: #fafbfc;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.hitl-config-textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* 其它页面内联 HITL 评论框等仍用 input 类名 */
|
||||
.hitl-config-input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hitl-pending-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hitl-pending-item {
|
||||
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
.hitl-pending-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hitl-pending-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.hitl-edit-args {
|
||||
width: 100%;
|
||||
min-height: 76px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
padding: 8px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hitl-input-help {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hitl-inline-approval {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dbeafe;
|
||||
background: #f8fbff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hitl-inline-approval.hitl-inline-done {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hitl-config-select:focus,
|
||||
.hitl-config-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.14);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
@@ -3590,6 +3861,83 @@ header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.mcp-management-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
height: calc(100vh - 210px);
|
||||
min-height: 520px;
|
||||
max-height: calc(100vh - 210px);
|
||||
}
|
||||
|
||||
.mcp-management-panel {
|
||||
margin-bottom: 0 !important;
|
||||
padding: 14px 16px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mcp-tools-panel {
|
||||
min-width: 0;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.mcp-external-panel {
|
||||
min-width: 0;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.mcp-panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mcp-tools-panel .tools-controls,
|
||||
.mcp-external-panel .external-mcp-controls {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mcp-tools-panel .tools-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mcp-tools-panel .tools-list-items {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mcp-external-panel .external-mcp-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.mcp-external-panel .external-mcp-controls {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* MCP 双栏内工具操作条允许换行,避免面板内溢出 */
|
||||
.mcp-tools-panel .tools-actions {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.mcp-tools-panel .search-box {
|
||||
min-width: min(280px, 100%);
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -3965,7 +4313,7 @@ header {
|
||||
|
||||
.tool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
@@ -3980,8 +4328,10 @@ header {
|
||||
.tool-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 2px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-item-info {
|
||||
@@ -4021,6 +4371,93 @@ header {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 展开图标 */
|
||||
.tool-expand-icon {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform 0.2s;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 展开后的详情面板 */
|
||||
.tool-item-detail {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.tool-detail-desc {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-detail-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 参数表格 */
|
||||
.tool-schema-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.tool-schema-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tool-schema-table td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tool-schema-table code {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 可点击的外部工具徽章 */
|
||||
.external-tool-badge.clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.external-tool-badge.clickable:hover {
|
||||
background: rgba(255, 152, 0, 0.25);
|
||||
border-color: rgba(255, 152, 0, 0.6);
|
||||
}
|
||||
|
||||
/* 外部 MCP 卡片高亮动画 */
|
||||
.external-mcp-item.highlight {
|
||||
animation: mcpHighlight 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes mcpHighlight {
|
||||
0% { box-shadow: 0 0 0 3px var(--accent-color); border-color: var(--accent-color); }
|
||||
100% { box-shadow: none; border-color: var(--border-color); }
|
||||
}
|
||||
|
||||
.tool-item.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -5209,8 +5646,8 @@ header {
|
||||
|
||||
.external-mcp-item-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, auto));
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
@@ -5307,6 +5744,23 @@ header {
|
||||
|
||||
/* 响应式优化 */
|
||||
@media (max-width: 768px) {
|
||||
.mcp-management-layout {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.mcp-management-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mcp-tools-panel .tools-list,
|
||||
.mcp-external-panel .external-mcp-list {
|
||||
min-height: 200px;
|
||||
max-height: 52vh;
|
||||
}
|
||||
|
||||
.tools-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -5489,6 +5943,7 @@ header {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
.legend-item:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
+33
-10
@@ -61,7 +61,8 @@
|
||||
"agentsManagement": "Agent management",
|
||||
"roles": "Roles",
|
||||
"rolesManagement": "Roles Management",
|
||||
"settings": "System settings"
|
||||
"settings": "System settings",
|
||||
"hitl": "Human-in-the-loop"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -191,6 +192,9 @@
|
||||
"executionFailed": "Execution failed",
|
||||
"penetrationTestComplete": "Penetration test complete",
|
||||
"yesterday": "Yesterday",
|
||||
"historyGroupToday": "Today",
|
||||
"historyGroupLast7Days": "Past 7 days",
|
||||
"historyGroupEarlier": "Older",
|
||||
"agentModeSelectAria": "Choose conversation execution mode",
|
||||
"agentModePanelTitle": "Conversation mode",
|
||||
"agentModeReactNative": "Native ReAct",
|
||||
@@ -208,7 +212,29 @@
|
||||
"agentModeSingleHint": "Single-model ReAct loop for chat and tool use",
|
||||
"agentModeMultiHint": "Eino prebuilt orchestration (deep / plan_execute / supervisor) for complex tasks",
|
||||
"agentModeOrchPlanExecute": "Plan-Exec",
|
||||
"agentModeOrchSupervisor": "Supervisor"
|
||||
"agentModeOrchSupervisor": "Supervisor",
|
||||
"hitlTitle": "Human-in-the-loop",
|
||||
"hitlCardSubtitle": "Approvals & allowlist",
|
||||
"hitlReviewer": "Review",
|
||||
"hitlConfigTitle": "Collaboration mode config",
|
||||
"hitlModeLabel": "Mode",
|
||||
"hitlModeOff": "Off",
|
||||
"hitlModeApproval": "Approval",
|
||||
"hitlModeReviewEdit": "Review & Edit",
|
||||
"hitlSensitiveTools": "Sensitive tools (comma-separated)",
|
||||
"hitlWhitelistTools": "Whitelisted tools (skip approval, comma-separated)",
|
||||
"hitlWhitelistPlaceholder": "e.g. read_file, grep or one tool per line (merged with global allowlist in config)",
|
||||
"hitlWhitelistHint": "Separate with commas or new lines; shown merged with the global allowlist in config.",
|
||||
"hitlApply": "Apply",
|
||||
"hitlApplyOkSync": "HITL settings saved and synced to the server.",
|
||||
"hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Mode and timeout still require selecting a conversation and clicking Apply to sync session settings to the server.",
|
||||
"hitlApplyOkLocal": "Saved in this browser.",
|
||||
"hitlApplyFail": "Failed to sync to server",
|
||||
"hitlStatusOff": "Human-in-the-loop: Off"
|
||||
},
|
||||
"hitl": {
|
||||
"pageTitle": "HITL approvals",
|
||||
"pendingTitle": "Pending interrupts"
|
||||
},
|
||||
"progress": {
|
||||
"callingAI": "Calling AI model...",
|
||||
@@ -1387,9 +1413,6 @@
|
||||
"multiAgentPeLoop": "plan_execute outer loop limit",
|
||||
"multiAgentPeLoopPlaceholder": "0 uses Eino default (10)",
|
||||
"multiAgentPeLoopHint": "Only for plan_execute; max execute↔replan rounds.",
|
||||
"multiAgentDefaultMode": "Default mode on chat page",
|
||||
"multiAgentModeSingle": "Single-agent (ReAct)",
|
||||
"multiAgentModeMulti": "Multi-agent (Eino)",
|
||||
"multiAgentRobotUse": "Use multi-agent for WeCom / DingTalk / Lark bots",
|
||||
"multiAgentRobotUseHint": "Requires 'Enable multi-agent' to be checked; usage and cost will be higher.",
|
||||
"multiAgentBatchUse": "Use multi-agent for batch task queues",
|
||||
@@ -1564,15 +1587,15 @@
|
||||
"externalMcpModal": {
|
||||
"configJson": "Config JSON",
|
||||
"formatLabel": "Format:",
|
||||
"formatDesc": "JSON object; key = config name, value = config. Use Start/Stop buttons to control state.",
|
||||
"formatDesc": "JSON object; key = config name, value = config. Use Start/Stop buttons to control state. Supports ${VAR} and ${VAR:-default} env variable syntax.",
|
||||
"configExample": "Configuration example:",
|
||||
"stdioMode": "stdio mode:",
|
||||
"httpMode": "HTTP mode:",
|
||||
"sseMode": "SSE mode:",
|
||||
"placeholder": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\"],\n \"description\": \"Description\",\n \"timeout\": 300\n }\n}",
|
||||
"exampleStdio": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\", \"--server\", \"http://example.com\"],\n \"description\": \"Description\",\n \"timeout\": 300\n }\n}",
|
||||
"exampleHttp": "{\n \"cyberstrike-ai-http\": {\n \"transport\": \"http\",\n \"url\": \"http://127.0.0.1:8081/mcp\"\n }\n}",
|
||||
"exampleSse": "{\n \"cyberstrike-ai-sse\": {\n \"transport\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
|
||||
"placeholder": "{\n \"my-server\": {\n \"command\": \"python3\",\n \"args\": [\"${HOME}/mcp/server.py\"],\n \"env\": { \"API_KEY\": \"${API_KEY}\" },\n \"timeout\": 300\n }\n}",
|
||||
"exampleStdio": "{\n \"my-server\": {\n \"command\": \"python3\",\n \"args\": [\"${HOME}/mcp/server.py\"],\n \"env\": { \"API_KEY\": \"${API_KEY}\", \"LOG_LEVEL\": \"${LOG_LEVEL:-INFO}\" },\n \"timeout\": 300\n }\n}",
|
||||
"exampleHttp": "{\n \"remote-mcp\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.example.com/mcp\",\n \"headers\": { \"Authorization\": \"Bearer ${MCP_TOKEN}\" }\n }\n}",
|
||||
"exampleSse": "{\n \"sse-mcp\": {\n \"type\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
|
||||
"exampleDescription": "Example description",
|
||||
"formatJson": "Format JSON",
|
||||
"loadExample": "Load example"
|
||||
|
||||
+33
-10
@@ -61,7 +61,8 @@
|
||||
"agentsManagement": "Agent管理",
|
||||
"roles": "角色",
|
||||
"rolesManagement": "角色管理",
|
||||
"settings": "系统设置"
|
||||
"settings": "系统设置",
|
||||
"hitl": "人机协同"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
@@ -191,6 +192,9 @@
|
||||
"executionFailed": "执行失败",
|
||||
"penetrationTestComplete": "渗透测试完成",
|
||||
"yesterday": "昨天",
|
||||
"historyGroupToday": "今天",
|
||||
"historyGroupLast7Days": "过去七天",
|
||||
"historyGroupEarlier": "更早",
|
||||
"agentModeSelectAria": "选择对话执行模式",
|
||||
"agentModePanelTitle": "对话模式",
|
||||
"agentModeReactNative": "原生 ReAct 模式",
|
||||
@@ -208,7 +212,29 @@
|
||||
"agentModeSingleHint": "单模型 ReAct 循环,适合常规对话与工具调用",
|
||||
"agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务",
|
||||
"agentModeOrchPlanExecute": "Plan-Exec",
|
||||
"agentModeOrchSupervisor": "Supervisor"
|
||||
"agentModeOrchSupervisor": "Supervisor",
|
||||
"hitlTitle": "人机协同",
|
||||
"hitlCardSubtitle": "审批与白名单",
|
||||
"hitlReviewer": "Review",
|
||||
"hitlConfigTitle": "协同模式配置",
|
||||
"hitlModeLabel": "模式",
|
||||
"hitlModeOff": "关闭",
|
||||
"hitlModeApproval": "审批模式",
|
||||
"hitlModeReviewEdit": "审查编辑",
|
||||
"hitlSensitiveTools": "敏感工具(逗号分隔)",
|
||||
"hitlWhitelistTools": "白名单工具(免审批,逗号分隔)",
|
||||
"hitlWhitelistPlaceholder": "例:read_file, grep 或每行一个工具名(与 config 全局白名单合并)",
|
||||
"hitlWhitelistHint": "每行一个或逗号分隔;与 config 中全局白名单合并展示。",
|
||||
"hitlApply": "应用",
|
||||
"hitlApplyOkSync": "人机协同配置已保存并同步到服务器。",
|
||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。",
|
||||
"hitlApplyOkLocal": "已保存到本浏览器。",
|
||||
"hitlApplyFail": "同步到服务器失败",
|
||||
"hitlStatusOff": "人机协同:关闭"
|
||||
},
|
||||
"hitl": {
|
||||
"pageTitle": "人机协同审批",
|
||||
"pendingTitle": "待处理中断"
|
||||
},
|
||||
"progress": {
|
||||
"callingAI": "正在调用AI模型...",
|
||||
@@ -1387,9 +1413,6 @@
|
||||
"multiAgentPeLoop": "plan_execute 外层循环上限",
|
||||
"multiAgentPeLoopPlaceholder": "0 表示 Eino 默认 10",
|
||||
"multiAgentPeLoopHint": "仅 plan_execute 有效;execute 与 replan 之间的最大轮次。",
|
||||
"multiAgentDefaultMode": "对话页默认模式",
|
||||
"multiAgentModeSingle": "单代理(ReAct)",
|
||||
"multiAgentModeMulti": "多代理(Eino)",
|
||||
"multiAgentRobotUse": "企业微信 / 钉钉 / 飞书机器人也使用多代理",
|
||||
"multiAgentRobotUseHint": "需同时勾选「启用多代理」;调用量与成本更高。",
|
||||
"multiAgentBatchUse": "批量任务队列也使用多代理",
|
||||
@@ -1564,15 +1587,15 @@
|
||||
"externalMcpModal": {
|
||||
"configJson": "配置JSON",
|
||||
"formatLabel": "配置格式:",
|
||||
"formatDesc": "JSON对象,key为配置名称,value为配置内容。状态通过\"启动/停止\"按钮控制,无需在JSON中配置。",
|
||||
"formatDesc": "JSON对象,key为配置名称,value为配置内容。状态通过\"启动/停止\"按钮控制,无需在JSON中配置。支持 ${VAR} 和 ${VAR:-默认值} 环境变量语法。",
|
||||
"configExample": "配置示例:",
|
||||
"stdioMode": "stdio模式:",
|
||||
"httpMode": "HTTP模式:",
|
||||
"sseMode": "SSE模式:",
|
||||
"placeholder": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\"],\n \"description\": \"描述\",\n \"timeout\": 300\n }\n}",
|
||||
"exampleStdio": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\", \"--server\", \"http://example.com\"],\n \"description\": \"描述\",\n \"timeout\": 300\n }\n}",
|
||||
"exampleHttp": "{\n \"cyberstrike-ai-http\": {\n \"transport\": \"http\",\n \"url\": \"http://127.0.0.1:8081/mcp\"\n }\n}",
|
||||
"exampleSse": "{\n \"cyberstrike-ai-sse\": {\n \"transport\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
|
||||
"placeholder": "{\n \"my-server\": {\n \"command\": \"python3\",\n \"args\": [\"${HOME}/mcp/server.py\"],\n \"env\": { \"API_KEY\": \"${API_KEY}\" },\n \"timeout\": 300\n }\n}",
|
||||
"exampleStdio": "{\n \"my-server\": {\n \"command\": \"python3\",\n \"args\": [\"${HOME}/mcp/server.py\"],\n \"env\": { \"API_KEY\": \"${API_KEY}\", \"LOG_LEVEL\": \"${LOG_LEVEL:-INFO}\" },\n \"timeout\": 300\n }\n}",
|
||||
"exampleHttp": "{\n \"remote-mcp\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.example.com/mcp\",\n \"headers\": { \"Authorization\": \"Bearer ${MCP_TOKEN}\" }\n }\n}",
|
||||
"exampleSse": "{\n \"sse-mcp\": {\n \"type\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
|
||||
"exampleDescription": "示例描述",
|
||||
"formatJson": "格式化JSON",
|
||||
"loadExample": "加载示例"
|
||||
|
||||
+501
-166
@@ -1,4 +1,5 @@
|
||||
let currentConversationId = null;
|
||||
let loadConversationRequestSeq = 0;
|
||||
|
||||
// @ 提及相关状态
|
||||
let mentionTools = [];
|
||||
@@ -39,6 +40,17 @@ const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
|
||||
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
|
||||
let multiAgentAPIEnabled = false;
|
||||
|
||||
// 人机协同(HITL)会话级配置
|
||||
const HITL_STORAGE_PREFIX = 'cyberstrike-chat-hitl';
|
||||
const HITL_DRAFT_KEY = 'cyberstrike-chat-hitl-draft';
|
||||
/** 跨会话记忆:用户最近一次在侧栏选择的 HITL 偏好(与 hitl.js 中 readHitlGlobalLast 使用同一 key) */
|
||||
const HITL_GLOBAL_LAST_KEY = `${HITL_STORAGE_PREFIX}:__last__`;
|
||||
const HITL_MODE_OFF = 'off';
|
||||
const HITL_MODE_APPROVAL = 'approval';
|
||||
const HITL_MODE_REVIEW_EDIT = 'review_edit';
|
||||
const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT];
|
||||
let hitlApplyFeedbackTimer = null;
|
||||
|
||||
function normalizeOrchestrationClient(s) {
|
||||
const v = String(s || '').trim().toLowerCase().replace(/-/g, '_');
|
||||
if (v === 'plan_execute' || v === 'planexecute' || v === 'pe') return 'plan_execute';
|
||||
@@ -54,6 +66,302 @@ function chatAgentModeIsEinoSingle(mode) {
|
||||
return mode === CHAT_AGENT_MODE_EINO_SINGLE;
|
||||
}
|
||||
|
||||
function normalizeHitlMode(mode) {
|
||||
let v = String(mode || '').trim().toLowerCase().replace(/-/g, '_');
|
||||
if (v === 'feedback' || v === 'followup') {
|
||||
v = HITL_MODE_APPROVAL;
|
||||
}
|
||||
if (HITL_MODE_OPTIONS.includes(v)) return v;
|
||||
return HITL_MODE_OFF;
|
||||
}
|
||||
|
||||
function defaultHitlConfig() {
|
||||
return {
|
||||
mode: HITL_MODE_OFF,
|
||||
sensitiveTools: '',
|
||||
updatedAt: ''
|
||||
};
|
||||
}
|
||||
|
||||
/** 白名单字符串拆成数组(逗号或换行分隔,与 textarea 一致) */
|
||||
function hitlToolsSplitToArray(s) {
|
||||
return String(s || '')
|
||||
.split(/[,\n\r]+/)
|
||||
.map(function (x) { return x.trim(); })
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** 与 config.yaml hitl.tool_whitelist 合并为输入框展示(全局项在前,去重不区分大小写) */
|
||||
function hitlMergeToolsForDisplay(globalArr, sessionToolsArr) {
|
||||
const seen = Object.create(null);
|
||||
const out = [];
|
||||
function addOne(t) {
|
||||
const n = String(t || '').trim();
|
||||
if (!n) return;
|
||||
const k = n.toLowerCase();
|
||||
if (seen[k]) return;
|
||||
seen[k] = true;
|
||||
out.push(n);
|
||||
}
|
||||
if (Array.isArray(globalArr)) {
|
||||
globalArr.forEach(addOne);
|
||||
}
|
||||
if (Array.isArray(sessionToolsArr)) {
|
||||
sessionToolsArr.forEach(addOne);
|
||||
}
|
||||
return out.join(', ');
|
||||
}
|
||||
|
||||
/** 保存/发请求前去掉全局白名单工具,避免会话里重复存 config 已有项 */
|
||||
function hitlStripGlobalToolsFromFormString(globalArr, commaStr) {
|
||||
if (!Array.isArray(globalArr) || globalArr.length === 0) {
|
||||
return typeof commaStr === 'string' ? commaStr.trim() : '';
|
||||
}
|
||||
const g = Object.create(null);
|
||||
globalArr.forEach(function (t) {
|
||||
const k = String(t || '').trim().toLowerCase();
|
||||
if (k) g[k] = true;
|
||||
});
|
||||
return hitlToolsSplitToArray(commaStr)
|
||||
.filter(function (p) {
|
||||
return p && !g[p.toLowerCase()];
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function getHitlStorageKeyByConversation(conversationId) {
|
||||
return `${HITL_STORAGE_PREFIX}:${String(conversationId || '').trim()}`;
|
||||
}
|
||||
|
||||
function getHitlModeLabel(mode) {
|
||||
const safeMode = normalizeHitlMode(mode);
|
||||
if (typeof window.t === 'function') {
|
||||
switch (safeMode) {
|
||||
case HITL_MODE_APPROVAL:
|
||||
return window.t('chat.hitlModeApproval');
|
||||
case HITL_MODE_REVIEW_EDIT:
|
||||
return window.t('chat.hitlModeReviewEdit');
|
||||
default:
|
||||
return window.t('chat.hitlModeOff');
|
||||
}
|
||||
}
|
||||
return safeMode;
|
||||
}
|
||||
|
||||
function getHitlLastGlobalConfig() {
|
||||
const fallback = defaultHitlConfig();
|
||||
try {
|
||||
const raw = localStorage.getItem(HITL_GLOBAL_LAST_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return {
|
||||
mode: normalizeHitlMode(parsed.mode),
|
||||
sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools,
|
||||
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : ''
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveHitlLastGlobalConfig(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
try {
|
||||
localStorage.setItem(HITL_GLOBAL_LAST_KEY, JSON.stringify(payload));
|
||||
} catch (e) {
|
||||
console.warn('saveHitlLastGlobalConfig failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getHitlConfigForConversation(conversationId) {
|
||||
const fallback = defaultHitlConfig();
|
||||
const cid = conversationId ? String(conversationId).trim() : '';
|
||||
if (!cid) {
|
||||
const globalLast = getHitlLastGlobalConfig();
|
||||
let draftCfg = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(HITL_DRAFT_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
draftCfg = {
|
||||
mode: normalizeHitlMode(parsed.mode),
|
||||
sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools,
|
||||
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : ''
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
draftCfg = null;
|
||||
}
|
||||
const g = globalLast ? {
|
||||
mode: normalizeHitlMode(globalLast.mode),
|
||||
sensitiveTools: typeof globalLast.sensitiveTools === 'string' ? globalLast.sensitiveTools : fallback.sensitiveTools,
|
||||
updatedAt: typeof globalLast.updatedAt === 'string' ? globalLast.updatedAt : ''
|
||||
} : null;
|
||||
if (!draftCfg && !g) return fallback;
|
||||
if (!draftCfg) return g;
|
||||
if (!g) return draftCfg;
|
||||
const tg = Date.parse(g.updatedAt) || 0;
|
||||
const td = Date.parse(draftCfg.updatedAt) || 0;
|
||||
return tg > td ? g : draftCfg;
|
||||
}
|
||||
const key = getHitlStorageKeyByConversation(cid);
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return getHitlLastGlobalConfig() || fallback;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return getHitlLastGlobalConfig() || fallback;
|
||||
}
|
||||
return {
|
||||
mode: normalizeHitlMode(parsed.mode),
|
||||
sensitiveTools: typeof parsed.sensitiveTools === 'string' ? parsed.sensitiveTools : fallback.sensitiveTools,
|
||||
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : ''
|
||||
};
|
||||
} catch (e) {
|
||||
return getHitlLastGlobalConfig() || fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function saveHitlConfigForConversation(conversationId, cfg, opts) {
|
||||
const syncGlobalLast = !!(opts && opts.syncGlobalLast);
|
||||
const payload = {
|
||||
mode: normalizeHitlMode(cfg && cfg.mode),
|
||||
sensitiveTools: typeof (cfg && cfg.sensitiveTools) === 'string' ? cfg.sensitiveTools : '',
|
||||
updatedAt: typeof (cfg && cfg.updatedAt) === 'string' ? cfg.updatedAt : ''
|
||||
};
|
||||
const key = conversationId ? getHitlStorageKeyByConversation(conversationId) : HITL_DRAFT_KEY;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(payload));
|
||||
if (syncGlobalLast) {
|
||||
saveHitlLastGlobalConfig(payload);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('saveHitlConfigForConversation failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
function readHitlConfigFromForm() {
|
||||
const modeEl = document.getElementById('hitl-mode-select');
|
||||
const toolsEl = document.getElementById('hitl-sensitive-tools');
|
||||
const mode = normalizeHitlMode(modeEl ? modeEl.value : HITL_MODE_OFF);
|
||||
let sensitiveTools = toolsEl ? String(toolsEl.value || '').trim() : '';
|
||||
const g = typeof window !== 'undefined' ? window.csaiHitlGlobalToolWhitelist : null;
|
||||
if (Array.isArray(g) && g.length > 0) {
|
||||
sensitiveTools = hitlStripGlobalToolsFromFormString(g, sensitiveTools);
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
sensitiveTools,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function updateHitlStatusUI(_cfg) {
|
||||
/* 侧栏已改为「应用」按钮生效,不再用角标展示模式 */
|
||||
}
|
||||
|
||||
function applyHitlConfigToUI(cfg) {
|
||||
const conf = cfg || defaultHitlConfig();
|
||||
const modeEl = document.getElementById('hitl-mode-select');
|
||||
const toolsEl = document.getElementById('hitl-sensitive-tools');
|
||||
if (modeEl) modeEl.value = normalizeHitlMode(conf.mode);
|
||||
let toolsVal = conf.sensitiveTools || '';
|
||||
const g = typeof window !== 'undefined' ? window.csaiHitlGlobalToolWhitelist : null;
|
||||
if (Array.isArray(g) && g.length > 0) {
|
||||
const sessionArr = hitlToolsSplitToArray(toolsVal);
|
||||
toolsVal = hitlMergeToolsForDisplay(g, sessionArr);
|
||||
}
|
||||
if (toolsEl) toolsEl.value = toolsVal;
|
||||
updateHitlStatusUI(conf);
|
||||
}
|
||||
|
||||
function refreshHitlConfigByCurrentConversation() {
|
||||
const cfg = getHitlConfigForConversation(currentConversationId || '');
|
||||
applyHitlConfigToUI(cfg);
|
||||
}
|
||||
|
||||
function showHitlApplyFeedback(text, isError, partial) {
|
||||
const el = document.getElementById('hitl-apply-feedback');
|
||||
if (hitlApplyFeedbackTimer) {
|
||||
clearTimeout(hitlApplyFeedbackTimer);
|
||||
hitlApplyFeedbackTimer = null;
|
||||
}
|
||||
if (!el) {
|
||||
if (text && isError) {
|
||||
alert(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
el.classList.toggle('hitl-apply-feedback--error', !!isError);
|
||||
el.classList.toggle('hitl-apply-feedback--partial', !!partial && !isError);
|
||||
if (!text) {
|
||||
el.textContent = '';
|
||||
el.style.display = 'none';
|
||||
el.classList.remove('hitl-apply-feedback--error', 'hitl-apply-feedback--partial');
|
||||
return;
|
||||
}
|
||||
el.textContent = text;
|
||||
el.style.display = 'block';
|
||||
if (!isError) {
|
||||
hitlApplyFeedbackTimer = setTimeout(function () {
|
||||
el.textContent = '';
|
||||
el.style.display = 'none';
|
||||
el.classList.remove('hitl-apply-feedback--error');
|
||||
el.classList.remove('hitl-apply-feedback--partial');
|
||||
hitlApplyFeedbackTimer = null;
|
||||
}, 3200);
|
||||
}
|
||||
}
|
||||
|
||||
/** 侧栏人机协同:修改模式/白名单后点此写入本地、合并展示并同步服务端 */
|
||||
async function applyHitlSidebarConfig() {
|
||||
const btn = document.getElementById('hitl-apply-btn');
|
||||
showHitlApplyFeedback('', false);
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const cfg = readHitlConfigFromForm();
|
||||
const cid = typeof currentConversationId === 'string' ? currentConversationId.trim() : '';
|
||||
saveHitlConfigForConversation(cid, cfg, { syncGlobalLast: true });
|
||||
|
||||
const toolsArr = hitlToolsSplitToArray(cfg.sensitiveTools || '');
|
||||
|
||||
let yamlMerged = false;
|
||||
if (!cid && toolsArr.length > 0 && typeof window.mergeHitlGlobalToolWhitelist === 'function') {
|
||||
const newGlobal = await window.mergeHitlGlobalToolWhitelist(toolsArr);
|
||||
if (Array.isArray(newGlobal)) {
|
||||
window.csaiHitlGlobalToolWhitelist = newGlobal;
|
||||
}
|
||||
yamlMerged = true;
|
||||
}
|
||||
|
||||
applyHitlConfigToUI(cfg);
|
||||
|
||||
if (cid && typeof window.saveHitlConversationConfig === 'function') {
|
||||
await window.saveHitlConversationConfig(cid, cfg);
|
||||
const ok = typeof window.t === 'function' ? window.t('chat.hitlApplyOkSync') : '人机协同配置已保存并同步到服务器。';
|
||||
showHitlApplyFeedback(ok, false);
|
||||
} else if (yamlMerged) {
|
||||
const okYaml = typeof window.t === 'function' ? window.t('chat.hitlApplyOkWhitelistYaml') : '免审批工具已合并进 config.yaml 并生效。协同模式、超时等仍须选中会话后再点「应用」才会写入服务器。';
|
||||
showHitlApplyFeedback(okYaml, false);
|
||||
} else {
|
||||
const localOnly = typeof window.t === 'function' ? window.t('chat.hitlApplyOkLocal') : '已保存到本浏览器。';
|
||||
showHitlApplyFeedback(localOnly, false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('applyHitlSidebarConfig', e);
|
||||
const prefix = typeof window.t === 'function' ? window.t('chat.hitlApplyFail') : '同步到服务器失败';
|
||||
const detail = (e && e.message) ? e.message : String(e);
|
||||
showHitlApplyFeedback(prefix + (detail ? ':' + detail : ''), true);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 localStorage / 历史值规范为 react | eino_single | deep | plan_execute | supervisor */
|
||||
function chatAgentModeNormalizeStored(stored, cfg) {
|
||||
const pub = cfg && cfg.multi_agent ? cfg.multi_agent : null;
|
||||
@@ -66,11 +374,11 @@ function chatAgentModeNormalizeStored(stored, cfg) {
|
||||
if (chatAgentModeIsEino(s)) {
|
||||
return multiOn ? s : CHAT_AGENT_MODE_REACT;
|
||||
}
|
||||
const defMulti = pub && pub.default_mode === 'multi';
|
||||
return defMulti && multiOn ? defOrch : CHAT_AGENT_MODE_REACT;
|
||||
return CHAT_AGENT_MODE_REACT;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
|
||||
window.csaiChatAgentMode = {
|
||||
EINO_MODES: CHAT_AGENT_EINO_MODES,
|
||||
EINO_SINGLE: CHAT_AGENT_MODE_EINO_SINGLE,
|
||||
@@ -80,6 +388,15 @@ if (typeof window !== 'undefined') {
|
||||
normalizeStored: chatAgentModeNormalizeStored,
|
||||
normalizeOrchestration: normalizeOrchestrationClient
|
||||
};
|
||||
window.applyHitlSidebarConfig = applyHitlSidebarConfig;
|
||||
window.readHitlConfigFromForm = readHitlConfigFromForm;
|
||||
window.applyHitlConfigToUI = applyHitlConfigToUI;
|
||||
window.saveHitlConfigForConversation = saveHitlConfigForConversation;
|
||||
window.getHitlLastGlobalConfig = getHitlLastGlobalConfig;
|
||||
window.hitlMergeToolsForDisplay = hitlMergeToolsForDisplay;
|
||||
window.hitlStripGlobalToolsFromFormString = hitlStripGlobalToolsFromFormString;
|
||||
window.hitlToolsSplitToArray = hitlToolsSplitToArray;
|
||||
window.updateHitlStatusUI = updateHitlStatusUI;
|
||||
}
|
||||
|
||||
function getAgentModeLabelForValue(mode) {
|
||||
@@ -178,6 +495,10 @@ async function initChatAgentModeFromConfig() {
|
||||
multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__csaiMultiAgentPublic = cfg.multi_agent || null;
|
||||
const tw = cfg.hitl && cfg.hitl.tool_whitelist;
|
||||
if (Array.isArray(tw)) {
|
||||
window.csaiHitlGlobalToolWhitelist = tw.slice();
|
||||
}
|
||||
}
|
||||
const wrap = document.getElementById('agent-mode-wrapper');
|
||||
const sel = document.getElementById('agent-mode-select');
|
||||
@@ -379,6 +700,15 @@ async function sendMessage() {
|
||||
conversationId: currentConversationId,
|
||||
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
||||
};
|
||||
const hitlCfg = readHitlConfigFromForm();
|
||||
if (normalizeHitlMode(hitlCfg.mode) !== HITL_MODE_OFF) {
|
||||
const sensitiveTools = hitlToolsSplitToArray(hitlCfg.sensitiveTools || '');
|
||||
body.hitl = {
|
||||
enabled: true,
|
||||
mode: normalizeHitlMode(hitlCfg.mode),
|
||||
sensitiveTools: sensitiveTools
|
||||
};
|
||||
}
|
||||
if (hasAttachments) {
|
||||
body.attachments = chatAttachments.map((a) => ({
|
||||
fileName: a.fileName,
|
||||
@@ -1880,6 +2210,9 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
itemTitle = '❌ ' + (typeof window.t === 'function' ? window.t('chat.error') : '错误');
|
||||
} else if (eventType === 'cancelled') {
|
||||
itemTitle = '⛔ ' + (typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消');
|
||||
} else if (eventType === 'hitl_interrupt') {
|
||||
const hitlMsg = (detail.message && String(detail.message).trim()) ? String(detail.message).trim() : (typeof window.t === 'function' ? window.t('hitl.pendingTitle') : '待审批');
|
||||
itemTitle = agPx + '🧑⚖️ HITL · ' + hitlMsg;
|
||||
} else if (eventType === 'progress') {
|
||||
itemTitle = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(detail.message || '') : (detail.message || '');
|
||||
}
|
||||
@@ -1892,11 +2225,12 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
});
|
||||
});
|
||||
|
||||
// 检查是否有错误或取消事件,如果有,确保详情默认折叠
|
||||
// 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理)
|
||||
const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt');
|
||||
const hasErrorOrCancelled = processDetails.some(d =>
|
||||
d.eventType === 'error' || d.eventType === 'cancelled'
|
||||
);
|
||||
if (hasErrorOrCancelled) {
|
||||
if (hasErrorOrCancelled && !hasPendingHitlInDetails) {
|
||||
// 确保时间线是折叠的
|
||||
timeline.classList.remove('expanded');
|
||||
// 更新按钮文本为"展开详情"
|
||||
@@ -2192,6 +2526,9 @@ async function startNewConversation() {
|
||||
}
|
||||
|
||||
currentConversationId = null;
|
||||
try {
|
||||
window.currentConversationId = '';
|
||||
} catch (e) { /* ignore */ }
|
||||
currentConversationGroupId = null; // 新对话不属于任何分组
|
||||
document.getElementById('chat-messages').innerHTML = '';
|
||||
const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
@@ -2215,130 +2552,19 @@ async function startNewConversation() {
|
||||
chatInput.value = '';
|
||||
adjustTextareaHeight(chatInput);
|
||||
}
|
||||
// 把当前侧栏人机协同选项写入草稿与「最近应用」记忆,避免刷新时被旧草稿里的「关闭」覆盖
|
||||
try {
|
||||
if (typeof readHitlConfigFromForm === 'function' && typeof saveHitlConfigForConversation === 'function') {
|
||||
const snap = readHitlConfigFromForm();
|
||||
saveHitlConfigForConversation('', snap, { syncGlobalLast: true });
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
refreshHitlConfigByCurrentConversation();
|
||||
}
|
||||
|
||||
// 加载对话列表(按时间分组)
|
||||
// 与 loadConversationsWithGroups 合并实现,避免并发加载时重复追加列表项
|
||||
async function loadConversations(searchQuery = '') {
|
||||
try {
|
||||
let url = '/api/conversations?limit=50';
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
url += '&search=' + encodeURIComponent(searchQuery.trim());
|
||||
}
|
||||
const response = await apiFetch(url);
|
||||
|
||||
const listContainer = document.getElementById('conversations-list');
|
||||
if (!listContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存滚动位置
|
||||
const sidebarContent = listContainer.closest('.sidebar-content');
|
||||
const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0;
|
||||
|
||||
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
|
||||
listContainer.innerHTML = '';
|
||||
|
||||
// 如果响应不是200,显示空状态(友好处理,不显示错误)
|
||||
if (!response.ok) {
|
||||
listContainer.innerHTML = emptyStateHtml;
|
||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||
return;
|
||||
}
|
||||
|
||||
const conversations = await response.json();
|
||||
|
||||
if (!Array.isArray(conversations) || conversations.length === 0) {
|
||||
listContainer.innerHTML = emptyStateHtml;
|
||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const weekday = todayStart.getDay() === 0 ? 7 : todayStart.getDay();
|
||||
const startOfWeek = new Date(todayStart);
|
||||
startOfWeek.setDate(todayStart.getDate() - (weekday - 1));
|
||||
const yesterdayStart = new Date(todayStart);
|
||||
yesterdayStart.setDate(todayStart.getDate() - 1);
|
||||
|
||||
const groups = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
thisWeek: [],
|
||||
earlier: [],
|
||||
};
|
||||
|
||||
conversations.forEach(conv => {
|
||||
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
|
||||
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
|
||||
const groupKey = getConversationGroup(validDate, todayStart, startOfWeek, yesterdayStart);
|
||||
groups[groupKey].push({
|
||||
...conv,
|
||||
_time: validDate,
|
||||
_timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart),
|
||||
});
|
||||
});
|
||||
|
||||
const groupOrder = [
|
||||
{ key: 'today', label: '今天' },
|
||||
{ key: 'yesterday', label: '昨天' },
|
||||
{ key: 'thisWeek', label: '本周' },
|
||||
{ key: 'earlier', label: '更早' },
|
||||
];
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
let rendered = false;
|
||||
|
||||
groupOrder.forEach(({ key, label }) => {
|
||||
const items = groups[key];
|
||||
if (!items || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
rendered = true;
|
||||
|
||||
const section = document.createElement('div');
|
||||
section.className = 'conversation-group';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'conversation-group-title';
|
||||
title.textContent = label;
|
||||
section.appendChild(title);
|
||||
|
||||
items.forEach(itemData => {
|
||||
// 判断是否置顶
|
||||
const isPinned = itemData.pinned || false;
|
||||
section.appendChild(createConversationListItemWithMenu(itemData, isPinned));
|
||||
});
|
||||
|
||||
fragment.appendChild(section);
|
||||
});
|
||||
|
||||
if (!rendered) {
|
||||
listContainer.innerHTML = emptyStateHtml;
|
||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||
return;
|
||||
}
|
||||
|
||||
listContainer.appendChild(fragment);
|
||||
updateActiveConversation();
|
||||
|
||||
// 恢复滚动位置
|
||||
if (sidebarContent) {
|
||||
// 使用 requestAnimationFrame 确保 DOM 已经更新
|
||||
requestAnimationFrame(() => {
|
||||
sidebarContent.scrollTop = savedScrollTop;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载对话列表失败:', error);
|
||||
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
|
||||
const listContainer = document.getElementById('conversations-list');
|
||||
if (listContainer) {
|
||||
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
|
||||
listContainer.innerHTML = emptyStateHtml;
|
||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||
}
|
||||
}
|
||||
return loadConversationsWithGroups(searchQuery);
|
||||
}
|
||||
|
||||
function createConversationListItem(conversation) {
|
||||
@@ -2460,7 +2686,7 @@ function formatConversationTimestamp(dateObj, todayStart, yesterdayStart) {
|
||||
return dateObj.toLocaleString(fmtLocale, fullDateOpts);
|
||||
}
|
||||
|
||||
function getConversationGroup(dateObj, todayStart, startOfWeek, yesterdayStart) {
|
||||
function getConversationGroup(dateObj, todayStart, sevenDaysCutoff, yesterdayStart) {
|
||||
if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) {
|
||||
return 'earlier';
|
||||
}
|
||||
@@ -2474,23 +2700,31 @@ function getConversationGroup(dateObj, todayStart, startOfWeek, yesterdayStart)
|
||||
if (messageDay.getTime() === yesterday.getTime()) {
|
||||
return 'yesterday';
|
||||
}
|
||||
if (messageDay >= startOfWeek && messageDay < today) {
|
||||
return 'thisWeek';
|
||||
const cutoff = new Date(sevenDaysCutoff.getFullYear(), sevenDaysCutoff.getMonth(), sevenDaysCutoff.getDate());
|
||||
if (messageDay >= cutoff && messageDay < yesterday) {
|
||||
return 'last7Days';
|
||||
}
|
||||
return 'earlier';
|
||||
}
|
||||
|
||||
// 加载对话
|
||||
async function loadConversation(conversationId) {
|
||||
const seq = ++loadConversationRequestSeq;
|
||||
try {
|
||||
// 轻量加载:不带 processDetails,避免历史会话切换卡顿;展开详情时再按需拉取
|
||||
const response = await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`);
|
||||
if (seq !== loadConversationRequestSeq) {
|
||||
return;
|
||||
}
|
||||
const conversation = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
alert('加载对话失败: ' + (conversation.error || '未知错误'));
|
||||
return;
|
||||
}
|
||||
if (seq !== loadConversationRequestSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果当前在分组详情页面,切换到对话界面
|
||||
// 退出分组详情模式,显示所有最近对话,提供更好的用户体验
|
||||
@@ -2519,6 +2753,9 @@ async function loadConversation(conversationId) {
|
||||
if (Object.keys(conversationGroupMappingCache).length === 0) {
|
||||
await loadConversationGroupMapping();
|
||||
}
|
||||
if (seq !== loadConversationRequestSeq) {
|
||||
return;
|
||||
}
|
||||
currentConversationGroupId = conversationGroupMappingCache[conversationId] || null;
|
||||
|
||||
// 异步刷新分组列表高亮状态(不阻塞消息渲染)
|
||||
@@ -2526,6 +2763,14 @@ async function loadConversation(conversationId) {
|
||||
|
||||
// 更新当前对话ID
|
||||
currentConversationId = conversationId;
|
||||
try {
|
||||
window.currentConversationId = conversationId;
|
||||
} catch (e) { /* ignore */ }
|
||||
if (typeof window.syncHitlConfigFromServer === 'function') {
|
||||
await window.syncHitlConfigFromServer(conversationId);
|
||||
} else {
|
||||
refreshHitlConfigByCurrentConversation();
|
||||
}
|
||||
updateActiveConversation();
|
||||
|
||||
// 如果攻击链模态框打开且显示的不是当前对话,关闭它
|
||||
@@ -2538,6 +2783,9 @@ async function loadConversation(conversationId) {
|
||||
|
||||
// 清空消息区域
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
if (seq !== loadConversationRequestSeq) {
|
||||
return;
|
||||
}
|
||||
messagesDiv.innerHTML = '';
|
||||
|
||||
// 检查对话中是否有最近的消息,如果有,清除草稿(避免恢复已发送的消息)
|
||||
@@ -2606,38 +2854,57 @@ async function loadConversation(conversationId) {
|
||||
const firstBatch = msgs.slice(0, FIRST_BATCH);
|
||||
const rest = msgs.slice(FIRST_BATCH);
|
||||
|
||||
let pendingMessageBatches = Promise.resolve();
|
||||
|
||||
// 首批同步渲染
|
||||
firstBatch.forEach(renderOneMessage);
|
||||
|
||||
// 剩余消息通过 requestAnimationFrame 分批渲染,避免阻塞 UI
|
||||
if (rest.length > 0) {
|
||||
const savedConvId = conversationId;
|
||||
let offset = 0;
|
||||
const renderNextBatch = () => {
|
||||
// 如果用户已经切换到其他对话,停止渲染
|
||||
if (currentConversationId !== savedConvId) return;
|
||||
const batch = rest.slice(offset, offset + BATCH_SIZE);
|
||||
batch.forEach(renderOneMessage);
|
||||
offset += BATCH_SIZE;
|
||||
if (offset < rest.length) {
|
||||
requestAnimationFrame(renderNextBatch);
|
||||
} else {
|
||||
// 所有消息渲染完毕,滚动到底部
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(renderNextBatch);
|
||||
const savedSeq = seq;
|
||||
pendingMessageBatches = new Promise((resolve) => {
|
||||
let offset = 0;
|
||||
const renderNextBatch = () => {
|
||||
if (savedSeq !== loadConversationRequestSeq || currentConversationId !== savedConvId) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const batch = rest.slice(offset, offset + BATCH_SIZE);
|
||||
batch.forEach(renderOneMessage);
|
||||
offset += BATCH_SIZE;
|
||||
if (offset < rest.length) {
|
||||
requestAnimationFrame(renderNextBatch);
|
||||
} else {
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(renderNextBatch);
|
||||
});
|
||||
}
|
||||
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
addAttackChainButton(conversationId);
|
||||
await pendingMessageBatches;
|
||||
if (seq !== loadConversationRequestSeq) {
|
||||
return;
|
||||
}
|
||||
if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') {
|
||||
await window.restoreHitlInlineForConversation(conversationId);
|
||||
}
|
||||
} else {
|
||||
const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true });
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
addAttackChainButton(conversationId);
|
||||
if (seq !== loadConversationRequestSeq) {
|
||||
return;
|
||||
}
|
||||
if (currentConversationId === conversationId && typeof window.restoreHitlInlineForConversation === 'function') {
|
||||
await window.restoreHitlInlineForConversation(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底部(首批渲染后立即滚动,剩余批次渲染后会再次滚动)
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
|
||||
// 添加攻击链按钮
|
||||
addAttackChainButton(conversationId);
|
||||
} catch (error) {
|
||||
console.error('加载对话失败:', error);
|
||||
alert('加载对话失败: ' + error.message);
|
||||
@@ -2696,8 +2963,11 @@ async function deleteConversationTurnFromUI(anchorBackendMessageId) {
|
||||
throw new Error(data.error || data.message || 'delete failed');
|
||||
}
|
||||
await loadConversation(currentConversationId);
|
||||
if (typeof loadConversations === 'function') loadConversations();
|
||||
if (typeof loadConversationsWithGroups === 'function') loadConversationsWithGroups();
|
||||
if (typeof loadConversationsWithGroups === 'function') {
|
||||
loadConversationsWithGroups();
|
||||
} else if (typeof loadConversations === 'function') {
|
||||
loadConversations();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('delete turn failed:', error);
|
||||
const failed = typeof window.t === 'function' ? window.t('chat.deleteTurnFailed') : '删除本轮失败';
|
||||
@@ -2727,6 +2997,9 @@ async function deleteConversation(conversationId, skipConfirm = false) {
|
||||
// 如果删除的是当前对话,清空对话界面
|
||||
if (conversationId === currentConversationId) {
|
||||
currentConversationId = null;
|
||||
try {
|
||||
window.currentConversationId = '';
|
||||
} catch (e) { /* ignore */ }
|
||||
document.getElementById('chat-messages').innerHTML = '';
|
||||
const readyMsgLoad = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
addMessage('assistant', readyMsgLoad, null, null, null, { systemReadyMessage: true });
|
||||
@@ -4667,18 +4940,69 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
pinnedConvs.sort(sortByTime);
|
||||
normalConvs.sort(sortByTime);
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterdayStart = new Date(todayStart);
|
||||
yesterdayStart.setDate(todayStart.getDate() - 1);
|
||||
const sevenDaysCutoff = new Date(todayStart);
|
||||
sevenDaysCutoff.setDate(todayStart.getDate() - 7);
|
||||
|
||||
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
|
||||
const groupOrder = [
|
||||
{ key: 'today', label: tFn ? tFn('chat.historyGroupToday') : '今天' },
|
||||
{ key: 'yesterday', label: tFn ? tFn('chat.yesterday') : '昨天' },
|
||||
{ key: 'last7Days', label: tFn ? tFn('chat.historyGroupLast7Days') : '过去七天' },
|
||||
{ key: 'earlier', label: tFn ? tFn('chat.historyGroupEarlier') : '更早' },
|
||||
];
|
||||
|
||||
const groups = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
last7Days: [],
|
||||
earlier: [],
|
||||
};
|
||||
|
||||
normalConvs.forEach(conv => {
|
||||
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
|
||||
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
|
||||
const groupKey = getConversationGroup(validDate, todayStart, sevenDaysCutoff, yesterdayStart);
|
||||
groups[groupKey].push({
|
||||
...conv,
|
||||
_timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart),
|
||||
});
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// 添加置顶对话
|
||||
if (pinnedConvs.length > 0) {
|
||||
pinnedConvs.forEach(conv => {
|
||||
fragment.appendChild(createConversationListItemWithMenu(conv, true));
|
||||
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
|
||||
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
|
||||
fragment.appendChild(createConversationListItemWithMenu({
|
||||
...conv,
|
||||
_timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart),
|
||||
}, true));
|
||||
});
|
||||
}
|
||||
|
||||
// 添加普通对话
|
||||
normalConvs.forEach(conv => {
|
||||
fragment.appendChild(createConversationListItemWithMenu(conv, false));
|
||||
groupOrder.forEach(({ key, label }) => {
|
||||
const items = groups[key];
|
||||
if (!items || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
const section = document.createElement('div');
|
||||
section.className = 'conversation-group';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'conversation-group-title';
|
||||
title.textContent = label;
|
||||
section.appendChild(title);
|
||||
|
||||
items.forEach(itemData => {
|
||||
section.appendChild(createConversationListItemWithMenu(itemData, false));
|
||||
});
|
||||
|
||||
fragment.appendChild(section);
|
||||
});
|
||||
|
||||
if (fragment.children.length === 0) {
|
||||
@@ -4750,7 +5074,7 @@ function createConversationListItemWithMenu(conversation, isPinned) {
|
||||
const time = document.createElement('div');
|
||||
time.className = 'conversation-time';
|
||||
const dateObj = conversation.updatedAt ? new Date(conversation.updatedAt) : new Date();
|
||||
time.textContent = formatConversationTimestamp(dateObj);
|
||||
time.textContent = conversation._timeText || formatConversationTimestamp(dateObj);
|
||||
contentWrapper.appendChild(time);
|
||||
|
||||
// 如果对话属于某个分组,显示分组标签
|
||||
@@ -6204,7 +6528,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
}
|
||||
initChatAgentModeFromConfig();
|
||||
initChatAgentModeFromConfig()
|
||||
.then(function () {
|
||||
refreshHitlConfigByCurrentConversation();
|
||||
})
|
||||
.catch(function () {
|
||||
refreshHitlConfigByCurrentConversation();
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
refreshHitlConfigByCurrentConversation();
|
||||
});
|
||||
|
||||
// 点击外部关闭图标选择器、对话模式面板
|
||||
@@ -6969,14 +7303,6 @@ function clearGroupSearch() {
|
||||
// 初始化时加载分组
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadGroups();
|
||||
// 替换原来的loadConversations调用
|
||||
if (typeof loadConversations === 'function') {
|
||||
// 保留原函数,但使用新函数
|
||||
const originalLoad = loadConversations;
|
||||
loadConversations = function(...args) {
|
||||
loadConversationsWithGroups(...args);
|
||||
};
|
||||
}
|
||||
await loadConversationsWithGroups();
|
||||
|
||||
// 添加页面焦点时自动刷新对话列表的功能
|
||||
@@ -7015,6 +7341,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (!id) return;
|
||||
if (id === currentConversationId) {
|
||||
currentConversationId = null;
|
||||
try {
|
||||
window.currentConversationId = '';
|
||||
} catch (e) { /* ignore */ }
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
if (messagesDiv) messagesDiv.innerHTML = '';
|
||||
const readyMsg = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
@@ -7028,3 +7357,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 顶层 async function 不会自动挂到 window,hitl 等脚本依赖 window.loadConversation
|
||||
if (typeof window !== 'undefined') {
|
||||
window.loadConversation = loadConversation;
|
||||
window.startNewConversation = startNewConversation;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
function hitlModeNormalize(m) {
|
||||
let v = String(m || '').trim().toLowerCase().replace(/-/g, '_');
|
||||
if (v === 'feedback' || v === 'followup') {
|
||||
v = 'approval';
|
||||
}
|
||||
const allowed = ['off', 'approval', 'review_edit'];
|
||||
return allowed.indexOf(v) >= 0 ? v : 'off';
|
||||
}
|
||||
|
||||
function hitlEffectiveEnabled(cfg) {
|
||||
if (!cfg) return false;
|
||||
if (cfg.enabled === true) return true;
|
||||
return hitlModeNormalize(cfg.mode) !== 'off';
|
||||
}
|
||||
|
||||
function readHitlLocalStorageConv(conversationId) {
|
||||
if (!conversationId) return null;
|
||||
try {
|
||||
const key = 'cyberstrike-chat-hitl:' + String(conversationId).trim();
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hitlSensitiveToolsToArray(config) {
|
||||
if (Array.isArray(config && config.sensitiveTools)) return config.sensitiveTools;
|
||||
const s = config && config.sensitiveTools;
|
||||
if (typeof s === 'string') {
|
||||
return s.split(/[,\n\r]+/).map(function (x) { return x.trim(); }).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getCurrentConversationIdForHitl() {
|
||||
if (typeof window.currentConversationId === 'string' && window.currentConversationId) {
|
||||
return window.currentConversationId;
|
||||
}
|
||||
const active = document.querySelector('.conversation-item.active');
|
||||
if (active && active.dataset && active.dataset.conversationId) {
|
||||
return active.dataset.conversationId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function fetchHitlConversationConfig(conversationId) {
|
||||
if (!conversationId) return null;
|
||||
const resp = await hitlApiFetch('/api/hitl/config/' + encodeURIComponent(conversationId), { credentials: 'same-origin' });
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
if (!data || !data.hitl) return null;
|
||||
return {
|
||||
hitl: data.hitl,
|
||||
hitlGlobalToolWhitelist: Array.isArray(data.hitlGlobalToolWhitelist) ? data.hitlGlobalToolWhitelist : []
|
||||
};
|
||||
}
|
||||
|
||||
/** 无会话时:将免审批工具合并进服务端 config.yaml,返回更新后的全局白名单数组 */
|
||||
async function mergeHitlGlobalToolWhitelist(sensitiveTools) {
|
||||
const list = Array.isArray(sensitiveTools) ? sensitiveTools : [];
|
||||
const resp = await hitlApiFetch('/api/hitl/tool-whitelist', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sensitiveTools: list })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const msg = await readHitlApiError(resp);
|
||||
throw new Error(msg || ('HTTP ' + resp.status));
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data && Array.isArray(data.hitlGlobalToolWhitelist)) {
|
||||
return data.hitlGlobalToolWhitelist;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function saveHitlConversationConfig(conversationId, config) {
|
||||
if (!conversationId || !config) return false;
|
||||
const mode = hitlModeNormalize(config.mode || 'off');
|
||||
const enabled = typeof config.enabled === 'boolean' ? config.enabled : (mode !== 'off');
|
||||
const sensitiveTools = hitlSensitiveToolsToArray(config);
|
||||
const resp = await hitlApiFetch('/api/hitl/config', {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversationId: conversationId,
|
||||
enabled: enabled,
|
||||
mode: mode,
|
||||
sensitiveTools: sensitiveTools,
|
||||
timeoutSeconds: config.timeoutSeconds || 300
|
||||
})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const msg = await readHitlApiError(resp);
|
||||
throw new Error(msg || ('HTTP ' + resp.status));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function syncHitlConfigFromServer(conversationId) {
|
||||
const pack = await fetchHitlConversationConfig(conversationId);
|
||||
if (!pack || !pack.hitl) return;
|
||||
const cfg = pack.hitl;
|
||||
const globalWL = pack.hitlGlobalToolWhitelist || [];
|
||||
if (typeof window !== 'undefined') {
|
||||
window.csaiHitlGlobalToolWhitelist = globalWL;
|
||||
}
|
||||
const strip = typeof window.hitlStripGlobalToolsFromFormString === 'function'
|
||||
? window.hitlStripGlobalToolsFromFormString
|
||||
: function (_g, s) { return typeof s === 'string' ? s.trim() : ''; };
|
||||
|
||||
let merged = cfg;
|
||||
if (!hitlEffectiveEnabled(cfg)) {
|
||||
const local = readHitlLocalStorageConv(conversationId);
|
||||
const localMode = local && local.mode ? hitlModeNormalize(local.mode) : 'off';
|
||||
if (localMode !== 'off') {
|
||||
let localToolsStr = typeof local.sensitiveTools === 'string' ? local.sensitiveTools : '';
|
||||
localToolsStr = strip(globalWL, localToolsStr);
|
||||
merged = {
|
||||
enabled: true,
|
||||
mode: localMode,
|
||||
sensitiveTools: localToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
|
||||
timeoutSeconds: cfg.timeoutSeconds || 300
|
||||
};
|
||||
saveHitlConversationConfig(conversationId, {
|
||||
mode: localMode,
|
||||
sensitiveTools: localToolsStr,
|
||||
enabled: true,
|
||||
timeoutSeconds: merged.timeoutSeconds
|
||||
}).catch(function (err) {
|
||||
console.warn('HITL 会话配置同步到服务器失败(将仅保留本地 UI):', err);
|
||||
});
|
||||
} else {
|
||||
const gl = typeof window.getHitlLastGlobalConfig === 'function' ? window.getHitlLastGlobalConfig() : null;
|
||||
const glMode = gl && gl.mode ? hitlModeNormalize(gl.mode) : 'off';
|
||||
if (glMode !== 'off') {
|
||||
let glToolsStr = typeof gl.sensitiveTools === 'string' ? gl.sensitiveTools : '';
|
||||
glToolsStr = strip(globalWL, glToolsStr);
|
||||
merged = {
|
||||
enabled: true,
|
||||
mode: glMode,
|
||||
sensitiveTools: glToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
|
||||
timeoutSeconds: cfg.timeoutSeconds || 300
|
||||
};
|
||||
saveHitlConversationConfig(conversationId, {
|
||||
mode: glMode,
|
||||
sensitiveTools: glToolsStr,
|
||||
enabled: true,
|
||||
timeoutSeconds: merged.timeoutSeconds
|
||||
}).catch(function (err) {
|
||||
console.warn('HITL 会话配置同步到服务器失败(将仅保留本地 UI):', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const uiMode = hitlEffectiveEnabled(merged) ? hitlModeNormalize(merged.mode) : 'off';
|
||||
const rawArr = Array.isArray(merged.sensitiveTools)
|
||||
? merged.sensitiveTools
|
||||
: hitlSensitiveToolsToArray({ sensitiveTools: merged.sensitiveTools });
|
||||
const sessionOnlyStr = strip(globalWL, rawArr.join(', '));
|
||||
const normalizedCfg = Object.assign({}, merged, {
|
||||
mode: uiMode,
|
||||
sensitiveTools: sessionOnlyStr
|
||||
});
|
||||
if (typeof window.saveHitlConfigForConversation === 'function') {
|
||||
window.saveHitlConfigForConversation(conversationId, normalizedCfg);
|
||||
} else {
|
||||
try {
|
||||
localStorage.setItem('chat_hitl_config_' + conversationId, JSON.stringify(normalizedCfg));
|
||||
} catch (e) {}
|
||||
}
|
||||
if (typeof window.applyHitlConfigToUI === 'function') {
|
||||
window.applyHitlConfigToUI(normalizedCfg);
|
||||
}
|
||||
reconcileHitlUiState();
|
||||
}
|
||||
|
||||
async function syncHitlConfigToServerByCurrentConversation() {
|
||||
const conversationId = getCurrentConversationIdForHitl();
|
||||
if (!conversationId) return;
|
||||
if (typeof window.readHitlConfigFromForm !== 'function') return;
|
||||
const cfg = window.readHitlConfigFromForm();
|
||||
await saveHitlConversationConfig(conversationId, cfg);
|
||||
}
|
||||
|
||||
function reconcileHitlUiState() {
|
||||
if (typeof window.readHitlConfigFromForm === 'function' && typeof window.updateHitlStatusUI === 'function') {
|
||||
try {
|
||||
const cfg = window.readHitlConfigFromForm();
|
||||
window.updateHitlStatusUI(cfg);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
let hitlFollowRunSeq = 0;
|
||||
|
||||
/**
|
||||
* 审批提交后原 SSE 已断开:轮询任务列表,运行中则拉取过程详情;任务结束后再整页加载会话以对齐终态。
|
||||
*/
|
||||
async function followAgentRunAfterHitlDecision(conversationId) {
|
||||
if (!conversationId || typeof apiFetch !== 'function') return;
|
||||
if (typeof window.attachRunningTaskEventStream === 'function') {
|
||||
try {
|
||||
const attached = await window.attachRunningTaskEventStream(conversationId);
|
||||
if (attached) return;
|
||||
} catch (e) {
|
||||
console.warn('attachRunningTaskEventStream', e);
|
||||
}
|
||||
}
|
||||
var mySeq = ++hitlFollowRunSeq;
|
||||
var intervalMs = 2000;
|
||||
var firstDelayMs = 500;
|
||||
var maxMs = 30 * 60 * 1000;
|
||||
var deadline = Date.now() + maxMs;
|
||||
|
||||
function taskStillActive(cid) {
|
||||
return apiFetch('/api/agent-loop/tasks').then(function (r) {
|
||||
if (!r.ok) return false;
|
||||
return r.json().then(function (j) {
|
||||
var tasks = (j && j.tasks) ? j.tasks : [];
|
||||
return tasks.some(function (t) {
|
||||
return t && t.conversationId === cid && (t.status === 'running' || t.status === 'cancelling');
|
||||
});
|
||||
});
|
||||
}).catch(function () { return false; });
|
||||
}
|
||||
|
||||
await new Promise(function (r) { setTimeout(r, firstDelayMs); });
|
||||
|
||||
while (mySeq === hitlFollowRunSeq) {
|
||||
if (Date.now() > deadline) {
|
||||
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
|
||||
await window.loadConversation(conversationId);
|
||||
}
|
||||
if (typeof loadActiveTasks === 'function') loadActiveTasks();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var active = await taskStillActive(conversationId);
|
||||
var onThisConv = (typeof window.currentConversationId === 'string' && window.currentConversationId === conversationId);
|
||||
if (onThisConv && typeof window.refreshLastAssistantProcessDetails === 'function') {
|
||||
await window.refreshLastAssistantProcessDetails(conversationId);
|
||||
}
|
||||
if (!active) {
|
||||
await new Promise(function (r) { setTimeout(r, 450); });
|
||||
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
|
||||
await window.loadConversation(conversationId);
|
||||
}
|
||||
if (typeof loadActiveTasks === 'function') loadActiveTasks();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('followAgentRunAfterHitlDecision', e);
|
||||
}
|
||||
await new Promise(function (r) { setTimeout(r, intervalMs); });
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHitlPending() {
|
||||
const container = document.getElementById('hitl-pending-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="loading-spinner">Loading...</div>';
|
||||
try {
|
||||
const resp = await hitlApiFetch('/api/hitl/pending', { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
throw new Error('request failed');
|
||||
}
|
||||
const data = await resp.json();
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="empty-state">暂无待审批项</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = items.map(function (item) {
|
||||
const payload = String(item.payload || '');
|
||||
const preview = payload.length > 280 ? (payload.slice(0, 280) + '...') : payload;
|
||||
const mode = String(item.mode || '').trim().toLowerCase();
|
||||
const allowEdit = mode === 'review_edit';
|
||||
return (
|
||||
'<div class="hitl-pending-item">' +
|
||||
'<div class="hitl-pending-item-header">' +
|
||||
'<strong>' + escapeHtml(item.toolName || '-') + '</strong>' +
|
||||
'<span>' + escapeHtml(item.mode || '-') + '</span>' +
|
||||
'</div>' +
|
||||
'<div><small>conversation: ' + escapeHtml(item.conversationId || '-') + '</small></div>' +
|
||||
'<pre style="white-space:pre-wrap;max-height:160px;overflow:auto;">' + escapeHtml(preview) + '</pre>' +
|
||||
(allowEdit
|
||||
? ('<div class="hitl-input-help">审查编辑模式:可填写 JSON 对象覆盖参数,示例:{"command":"ls -la"}</div>' +
|
||||
'<textarea id="hitl-edit-' + escapeHtml(String(item.id || '')) + '" class="hitl-edit-args" placeholder=\'{"command":"ls -la"}\'></textarea>')
|
||||
: '<div class="hitl-input-help">审批模式:仅通过/拒绝,不支持改参。</div>') +
|
||||
'<div class="hitl-pending-actions">' +
|
||||
'<button class="btn-primary" onclick="submitHitlDecision(' + JSON.stringify(String(item.id || '')) + ',\'approve\',' + JSON.stringify(String(item.conversationId || '')) + ')">通过</button>' +
|
||||
'<button class="btn-secondary" onclick="submitHitlDecision(' + JSON.stringify(String(item.id || '')) + ',\'reject\',' + JSON.stringify(String(item.conversationId || '')) + ')">拒绝</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="empty-state">加载失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitHitlDecision(interruptId, decision, conversationIdOpt) {
|
||||
const comment = prompt('审批备注(可选)') || '';
|
||||
let editedArguments = null;
|
||||
const editBox = document.getElementById('hitl-edit-' + interruptId);
|
||||
if (editBox && editBox.value && editBox.value.trim()) {
|
||||
try {
|
||||
editedArguments = JSON.parse(editBox.value.trim());
|
||||
} catch (e) {
|
||||
alert('JSON 参数格式错误');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const convFollow = conversationIdOpt || getCurrentConversationIdForHitl();
|
||||
return submitHitlDecisionWithPayload(interruptId, decision, comment, editedArguments, convFollow);
|
||||
}
|
||||
|
||||
async function submitHitlDecisionWithPayload(interruptId, decision, comment, editedArguments, conversationIdForFollow) {
|
||||
const resp = await hitlApiFetch('/api/hitl/decision', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ interruptId: interruptId, decision: decision, comment: comment, editedArguments: editedArguments })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errText = await readHitlApiError(resp);
|
||||
if (resp.status === 409 && (errText.indexOf('already resolved') >= 0 || errText.indexOf('not found') >= 0)) {
|
||||
refreshHitlPending();
|
||||
return true;
|
||||
}
|
||||
alert('提交失败:' + errText);
|
||||
return false;
|
||||
}
|
||||
refreshHitlPending();
|
||||
const cid = conversationIdForFollow || getCurrentConversationIdForHitl();
|
||||
if (cid) {
|
||||
followAgentRunAfterHitlDecision(cid);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function hitlApiFetch(url, options) {
|
||||
if (typeof apiFetch === 'function') {
|
||||
return apiFetch(url, options || {});
|
||||
}
|
||||
return fetch(url, options || {});
|
||||
}
|
||||
|
||||
async function readHitlApiError(resp) {
|
||||
try {
|
||||
const data = await resp.json();
|
||||
if (data && typeof data.error === 'string' && data.error.trim()) return data.error.trim();
|
||||
return 'HTTP ' + resp.status;
|
||||
} catch (e) {
|
||||
return 'HTTP ' + resp.status;
|
||||
}
|
||||
}
|
||||
|
||||
window.refreshHitlPending = refreshHitlPending;
|
||||
window.submitHitlDecision = submitHitlDecision;
|
||||
window.submitHitlDecisionWithPayload = submitHitlDecisionWithPayload;
|
||||
window.followAgentRunAfterHitlDecision = followAgentRunAfterHitlDecision;
|
||||
|
||||
window.addEventListener('hitl-interrupt', function () {
|
||||
if (typeof window.currentPage === 'function' && window.currentPage() === 'hitl') {
|
||||
refreshHitlPending();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('pageshow', function () {
|
||||
setTimeout(reconcileHitlUiState, 0);
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setTimeout(reconcileHitlUiState, 0);
|
||||
});
|
||||
|
||||
// 由 applyHitlSidebarConfig 调用,将侧栏配置同步到后端
|
||||
window.syncHitlConfigToServerByCurrentConversation = syncHitlConfigToServerByCurrentConversation;
|
||||
window.saveHitlConversationConfig = saveHitlConversationConfig;
|
||||
window.mergeHitlGlobalToolWhitelist = mergeHitlGlobalToolWhitelist;
|
||||
|
||||
// 由 chat.js 在 loadConversation 内 await 调用;挂到 window 供其它入口显式触发
|
||||
window.syncHitlConfigFromServer = syncHitlConfigFromServer;
|
||||
+423
-4
@@ -843,6 +843,33 @@ function applyBackendMessageIdToLastUser(backendMessageId) {
|
||||
}
|
||||
}
|
||||
|
||||
function taskReplayProgressId(conversationId) {
|
||||
return 'task-ev-' + String(conversationId || '').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function clearCsTaskReplay() {
|
||||
window.csTaskReplay = null;
|
||||
}
|
||||
|
||||
function beginCsTaskReplay(progressId, assistantDomId, conversationId) {
|
||||
window.csTaskReplay = {
|
||||
progressId: progressId,
|
||||
assistantDomId: assistantDomId,
|
||||
conversationId: conversationId,
|
||||
timelineHostId: 'process-details-' + assistantDomId + '-timeline'
|
||||
};
|
||||
registerProgressTask(progressId, conversationId);
|
||||
}
|
||||
|
||||
function resolveStreamTimeline(progressId) {
|
||||
let timeline = document.getElementById(progressId + '-timeline');
|
||||
const r = window.csTaskReplay;
|
||||
if (!timeline && r && r.progressId === progressId && r.timelineHostId) {
|
||||
timeline = document.getElementById(r.timelineHostId);
|
||||
}
|
||||
return timeline;
|
||||
}
|
||||
|
||||
// 处理流式事件
|
||||
function handleStreamEvent(event, progressElement, progressId,
|
||||
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
|
||||
@@ -858,7 +885,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
return;
|
||||
}
|
||||
|
||||
const timeline = document.getElementById(progressId + '-timeline');
|
||||
const timeline = resolveStreamTimeline(progressId);
|
||||
if (!timeline) return;
|
||||
|
||||
// 终态事件(error/cancelled)优先复用现有助手消息,避免重复追加相同报错
|
||||
@@ -1049,6 +1076,32 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'hitl_interrupt':
|
||||
const hitlItemId = addTimelineItem(timeline, 'warning', {
|
||||
title: '🧑⚖️ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
renderInlineHitlApproval(hitlItemId, event.data || {});
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('hitl-interrupt', { detail: event.data || {} }));
|
||||
} catch (e) {}
|
||||
break;
|
||||
case 'hitl_resumed':
|
||||
addTimelineItem(timeline, 'progress', {
|
||||
title: '✅ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
break;
|
||||
case 'hitl_rejected':
|
||||
addTimelineItem(timeline, 'error', {
|
||||
title: '⛔ HITL',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
break;
|
||||
|
||||
case 'eino_recovery': {
|
||||
const d = event.data || {};
|
||||
const runIdx = d.runIndex != null ? d.runIndex : (d.einoRetry != null ? d.einoRetry + 1 : 1);
|
||||
@@ -1492,8 +1545,12 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
// so the copied timeline HTML reflects the final status.
|
||||
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||
|
||||
// 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整)
|
||||
integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds);
|
||||
const replayCtx = window.csTaskReplay;
|
||||
const directReplay = replayCtx && replayCtx.progressId === progressId;
|
||||
if (!directReplay) {
|
||||
// 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整)
|
||||
integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds);
|
||||
}
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
|
||||
const respMid = responseData.messageId;
|
||||
@@ -1502,7 +1559,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
collapseAllProgressDetails(assistantIdFinal, progressId);
|
||||
collapseAllProgressDetails(assistantIdFinal, directReplay ? null : progressId);
|
||||
}, 3000);
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -1571,6 +1628,9 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
toolResultStreamStateByKey.delete(key);
|
||||
}
|
||||
}
|
||||
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
|
||||
clearCsTaskReplay();
|
||||
}
|
||||
// 完成,更新进度标题(如果进度消息还存在)
|
||||
const doneTitle = document.querySelector(`#${progressId} .progress-title`);
|
||||
if (doneTitle) {
|
||||
@@ -1625,6 +1685,349 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
scrollChatMessagesToBottomIfPinned(streamScrollWasPinned);
|
||||
}
|
||||
|
||||
function renderInlineHitlApproval(itemId, data) {
|
||||
const item = document.getElementById(itemId);
|
||||
if (!item || !data || !data.interruptId) return;
|
||||
let contentEl = item.querySelector('.timeline-item-content');
|
||||
if (!contentEl) {
|
||||
// warning 等类型默认没有内容区域;HITL 内联审批需要可交互容器
|
||||
contentEl = document.createElement('div');
|
||||
contentEl.className = 'timeline-item-content';
|
||||
item.appendChild(contentEl);
|
||||
}
|
||||
const existingPanel = contentEl.querySelector('.hitl-inline-approval');
|
||||
if (existingPanel) {
|
||||
existingPanel.remove();
|
||||
}
|
||||
|
||||
const payload = data.payload && typeof data.payload === 'object' ? data.payload : {};
|
||||
const toolName = data.toolName || payload.toolName || '-';
|
||||
let mode = String(data.mode || '').trim().toLowerCase();
|
||||
if (mode === 'feedback' || mode === 'followup') {
|
||||
mode = 'approval';
|
||||
}
|
||||
const allowEdit = mode === 'review_edit';
|
||||
const argsObj = payload.argumentsObj && typeof payload.argumentsObj === 'object' ? payload.argumentsObj : {};
|
||||
const argsJSON = JSON.stringify(argsObj, null, 2);
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'hitl-inline-approval';
|
||||
panel.innerHTML = `
|
||||
<div class="hitl-input-help"><strong>${escapeHtml(toolName)}</strong> 待人工审批。模式:${escapeHtml(mode || '-')}。</div>
|
||||
${allowEdit
|
||||
? `<div class="hitl-input-help">审查编辑参数(JSON,可选):留空表示沿用原参数。</div>
|
||||
<textarea class="hitl-edit-args hitl-inline-edit" placeholder='{"command":"ls -la"}'>${escapeHtml(argsJSON === '{}' ? '' : argsJSON)}</textarea>`
|
||||
: '<div class="hitl-input-help">当前模式不支持改参,仅可通过/拒绝。</div>'
|
||||
}
|
||||
<div class="hitl-input-help">备注(可选):建议写审批依据。</div>
|
||||
<input class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">
|
||||
<div class="hitl-pending-actions">
|
||||
<button class="btn-secondary hitl-inline-reject">拒绝</button>
|
||||
<button class="btn-primary hitl-inline-approve">通过</button>
|
||||
</div>
|
||||
<div class="hitl-input-help hitl-inline-status"></div>
|
||||
`;
|
||||
contentEl.appendChild(panel);
|
||||
|
||||
const approveBtn = panel.querySelector('.hitl-inline-approve');
|
||||
const rejectBtn = panel.querySelector('.hitl-inline-reject');
|
||||
const commentInput = panel.querySelector('.hitl-inline-comment');
|
||||
const editInput = panel.querySelector('.hitl-inline-edit');
|
||||
const statusEl = panel.querySelector('.hitl-inline-status');
|
||||
|
||||
const setBusy = function (busy) {
|
||||
approveBtn.disabled = busy;
|
||||
rejectBtn.disabled = busy;
|
||||
};
|
||||
|
||||
const submit = async function (decision) {
|
||||
setBusy(true);
|
||||
let editedArgs = null;
|
||||
if (allowEdit && editInput) {
|
||||
const raw = String(editInput.value || '').trim();
|
||||
if (raw) {
|
||||
try {
|
||||
editedArgs = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'JSON 参数格式错误';
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const comment = String(commentInput.value || '').trim();
|
||||
try {
|
||||
if (typeof window.submitHitlDecisionWithPayload === 'function') {
|
||||
const convFollow = data.conversationId || (typeof window.currentConversationId === 'string' ? window.currentConversationId : '');
|
||||
const ok = await window.submitHitlDecisionWithPayload(data.interruptId, decision, comment, (decision === 'approve' && allowEdit) ? editedArgs : null, convFollow);
|
||||
if (!ok) {
|
||||
statusEl.textContent = '提交失败,请重试';
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
statusEl.textContent = '审批函数未加载';
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = decision === 'approve' ? '已通过,等待执行继续...' : '已拒绝,反馈已交给模型继续迭代...';
|
||||
panel.classList.add('hitl-inline-done');
|
||||
} catch (e) {
|
||||
statusEl.textContent = '提交失败:' + (e && e.message ? e.message : 'unknown error');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
approveBtn.onclick = function () { submit('approve'); };
|
||||
rejectBtn.onclick = function () { submit('reject'); };
|
||||
}
|
||||
|
||||
function hitlEscapeAttrSelector(val) {
|
||||
const s = String(val);
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function expandProcessDetailsTimeline(assistantMessageId) {
|
||||
if (!assistantMessageId) return;
|
||||
const detailsContainer = document.getElementById('process-details-' + assistantMessageId);
|
||||
if (!detailsContainer) return;
|
||||
const timeline = detailsContainer.querySelector('.progress-timeline');
|
||||
if (!timeline) return;
|
||||
timeline.classList.add('expanded');
|
||||
const collapseT = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
|
||||
document.querySelectorAll('#' + hitlEscapeAttrSelector(assistantMessageId) + ' .process-detail-btn').forEach(function (btn) {
|
||||
btn.innerHTML = '<span>' + collapseT + '</span>';
|
||||
});
|
||||
setTimeout(function () {
|
||||
detailsContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function findLastAssistantMessageElInChat() {
|
||||
const nodes = document.querySelectorAll('#chat-messages .message.assistant');
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const el = nodes[i];
|
||||
if (el && el.dataset && el.dataset.backendMessageId) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新或切换会话后:根据待审批记录恢复时间线里的内联审批入口,并展开详情区。
|
||||
*/
|
||||
async function restoreHitlInlineForConversation(conversationId) {
|
||||
if (!conversationId || typeof apiFetch !== 'function') return;
|
||||
if (typeof window.currentConversationId === 'string' && window.currentConversationId !== conversationId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await apiFetch('/api/hitl/pending?conversationId=' + encodeURIComponent(conversationId) + '&status=pending&pageSize=50');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json().catch(function () { return {}; });
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
let backendMsgId = item.messageId != null ? String(item.messageId).trim() : '';
|
||||
let msgEl = null;
|
||||
if (backendMsgId) {
|
||||
msgEl = document.querySelector('#chat-messages [data-backend-message-id="' + hitlEscapeAttrSelector(backendMsgId) + '"]');
|
||||
}
|
||||
if (!msgEl) {
|
||||
msgEl = findLastAssistantMessageElInChat();
|
||||
if (msgEl && msgEl.dataset && msgEl.dataset.backendMessageId) {
|
||||
backendMsgId = String(msgEl.dataset.backendMessageId).trim();
|
||||
}
|
||||
}
|
||||
if (!msgEl || !msgEl.id || !backendMsgId) continue;
|
||||
const clientMsgId = msgEl.id;
|
||||
const detailsContainer = document.getElementById('process-details-' + clientMsgId);
|
||||
if (!detailsContainer) continue;
|
||||
if (detailsContainer.dataset.lazyNotLoaded === '1' && detailsContainer.dataset.loaded !== '1') {
|
||||
try {
|
||||
detailsContainer.dataset.loading = '1';
|
||||
const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details');
|
||||
const j = await res.json().catch(function () { return {}; });
|
||||
if (!res.ok) throw new Error((j && j.error) ? j.error : String(res.status));
|
||||
const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : [];
|
||||
if (typeof renderProcessDetails === 'function') {
|
||||
renderProcessDetails(clientMsgId, details);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载过程详情失败(HITL 恢复):', e);
|
||||
} finally {
|
||||
detailsContainer.dataset.loading = '0';
|
||||
}
|
||||
}
|
||||
expandProcessDetailsTimeline(clientMsgId);
|
||||
let payloadObj = {};
|
||||
try {
|
||||
payloadObj = JSON.parse(String(item.payload || '{}'));
|
||||
} catch (e) {
|
||||
payloadObj = {};
|
||||
}
|
||||
const hitlData = {
|
||||
interruptId: item.id,
|
||||
mode: item.mode,
|
||||
toolName: item.toolName,
|
||||
toolCallId: item.toolCallId,
|
||||
payload: payloadObj,
|
||||
conversationId: item.conversationId || conversationId
|
||||
};
|
||||
let hitlItemEl = detailsContainer.querySelector('[data-hitl-interrupt-id="' + hitlEscapeAttrSelector(String(item.id)) + '"]');
|
||||
if (!hitlItemEl && item.toolCallId) {
|
||||
hitlItemEl = detailsContainer.querySelector('[data-tool-call-id="' + hitlEscapeAttrSelector(String(item.toolCallId)) + '"]');
|
||||
}
|
||||
if (!hitlItemEl && item.toolName) {
|
||||
const want = String(item.toolName).trim().toLowerCase();
|
||||
const shortWant = want.indexOf('::') >= 0 ? want.split('::').pop() : want;
|
||||
const calls = detailsContainer.querySelectorAll('.timeline-item-tool_call');
|
||||
for (let j = calls.length - 1; j >= 0; j--) {
|
||||
const tn = String(calls[j].dataset.toolName || '').trim().toLowerCase();
|
||||
const shortTn = tn.indexOf('::') >= 0 ? tn.split('::').pop() : tn;
|
||||
const match = want && (tn === want || tn.endsWith('::' + shortWant) || shortTn === shortWant);
|
||||
if (match) {
|
||||
hitlItemEl = calls[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hitlItemEl) continue;
|
||||
renderInlineHitlApproval(hitlItemEl.id, hitlData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('restoreHitlInlineForConversation failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
window.expandProcessDetailsTimeline = expandProcessDetailsTimeline;
|
||||
window.restoreHitlInlineForConversation = restoreHitlInlineForConversation;
|
||||
|
||||
/**
|
||||
* 无 SSE 时(例如刷新页面后):从 DB 拉取最后一条助手消息的过程详情并重绘时间线,便于审批通过后仍能看到执行进展。
|
||||
*/
|
||||
async function refreshLastAssistantProcessDetails(conversationId) {
|
||||
if (!conversationId || typeof apiFetch !== 'function') return;
|
||||
if (typeof window.currentConversationId === 'string' && window.currentConversationId !== conversationId) return;
|
||||
const msgEl = findLastAssistantMessageElInChat();
|
||||
if (!msgEl || !msgEl.dataset.backendMessageId || !msgEl.id) return;
|
||||
const backendId = String(msgEl.dataset.backendMessageId).trim();
|
||||
const clientId = msgEl.id;
|
||||
const detailsContainer = document.getElementById('process-details-' + clientId);
|
||||
let wasExpanded = false;
|
||||
if (detailsContainer) {
|
||||
const tl = detailsContainer.querySelector('.progress-timeline');
|
||||
wasExpanded = !!(tl && tl.classList.contains('expanded'));
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch('/api/messages/' + encodeURIComponent(backendId) + '/process-details');
|
||||
const j = await res.json().catch(function () { return {}; });
|
||||
if (!res.ok) return;
|
||||
const details = Array.isArray(j.processDetails) ? j.processDetails : [];
|
||||
if (typeof renderProcessDetails === 'function') {
|
||||
renderProcessDetails(clientId, details);
|
||||
}
|
||||
if (wasExpanded) {
|
||||
expandProcessDetailsTimeline(clientId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('refreshLastAssistantProcessDetails', e);
|
||||
}
|
||||
}
|
||||
|
||||
window.refreshLastAssistantProcessDetails = refreshLastAssistantProcessDetails;
|
||||
|
||||
/**
|
||||
* 订阅运行中任务的 SSE 镜像(GET /api/agent-loop/task-events),用于 HITL 通过后主连接已断开时接续 UI。
|
||||
*/
|
||||
async function attachRunningTaskEventStream(conversationId) {
|
||||
if (!conversationId || typeof apiFetch !== 'function') return false;
|
||||
try {
|
||||
const check = await apiFetch('/api/agent-loop/tasks');
|
||||
if (!check.ok) return false;
|
||||
const j = await check.json().catch(function () { return {}; });
|
||||
const active = (j.tasks || []).some(function (t) {
|
||||
return t && t.conversationId === conversationId && (t.status === 'running' || t.status === 'cancelling');
|
||||
});
|
||||
if (!active) return false;
|
||||
|
||||
const asEl = findLastAssistantMessageElInChat();
|
||||
if (!asEl || !asEl.id) return false;
|
||||
const backendId = asEl.dataset && asEl.dataset.backendMessageId;
|
||||
if (backendId && typeof renderProcessDetails === 'function') {
|
||||
const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details');
|
||||
const jd = await res.json().catch(function () { return {}; });
|
||||
if (res.ok && Array.isArray(jd.processDetails)) {
|
||||
renderProcessDetails(asEl.id, jd.processDetails);
|
||||
}
|
||||
}
|
||||
expandProcessDetailsTimeline(asEl.id);
|
||||
|
||||
const progressId = taskReplayProgressId(conversationId);
|
||||
beginCsTaskReplay(progressId, asEl.id, conversationId);
|
||||
|
||||
const url = '/api/agent-loop/task-events?conversationId=' + encodeURIComponent(conversationId);
|
||||
const response = await apiFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' }
|
||||
});
|
||||
if (!response.ok) {
|
||||
clearCsTaskReplay();
|
||||
if (progressTaskState.has(progressId)) {
|
||||
progressTaskState.delete(progressId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let mcpIds = [];
|
||||
const assistantDomId = asEl.id;
|
||||
const getAssistantIdFn = function () { return assistantDomId; };
|
||||
const setAssistantIdFn = function () {};
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const line = lines[li];
|
||||
if (line.indexOf('data: ') === 0) {
|
||||
try {
|
||||
const eventData = JSON.parse(line.slice(6));
|
||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = ids; });
|
||||
} catch (e) {
|
||||
console.error('task-events parse', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
|
||||
clearCsTaskReplay();
|
||||
}
|
||||
if (progressTaskState.has(progressId)) {
|
||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
|
||||
}
|
||||
if (typeof loadActiveTasks === 'function') loadActiveTasks();
|
||||
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
|
||||
await window.loadConversation(conversationId);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('attachRunningTaskEventStream', e);
|
||||
clearCsTaskReplay();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
window.attachRunningTaskEventStream = attachRunningTaskEventStream;
|
||||
window.taskReplayProgressId = taskReplayProgressId;
|
||||
|
||||
// 更新工具调用状态
|
||||
function updateToolCallStatus(toolCallId, status) {
|
||||
const mapping = toolCallStatusMap.get(toolCallId);
|
||||
@@ -1697,6 +2100,12 @@ function addTimelineItem(timeline, type, options) {
|
||||
item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : '';
|
||||
item.dataset.toolIndex = (d.index != null) ? String(d.index) : '0';
|
||||
item.dataset.toolTotal = (d.total != null) ? String(d.total) : '0';
|
||||
if (d.toolCallId != null && String(d.toolCallId).trim() !== '') {
|
||||
item.dataset.toolCallId = String(d.toolCallId).trim();
|
||||
}
|
||||
}
|
||||
if (type === 'hitl_interrupt' && options.data && options.data.interruptId != null && String(options.data.interruptId).trim() !== '') {
|
||||
item.dataset.hitlInterruptId = String(options.data.interruptId).trim();
|
||||
}
|
||||
if (type === 'tool_result' && options.data) {
|
||||
const d = options.data;
|
||||
@@ -1934,6 +2343,8 @@ async function cancelActiveTask(conversationId, button) {
|
||||
}
|
||||
}
|
||||
|
||||
let monitorPanelFetchSeq = 0;
|
||||
|
||||
// 监控面板状态
|
||||
const monitorState = {
|
||||
executions: [],
|
||||
@@ -2004,6 +2415,7 @@ async function refreshMonitorPanel(page = null) {
|
||||
const execContainer = document.getElementById('monitor-executions');
|
||||
|
||||
try {
|
||||
const mySeq = ++monitorPanelFetchSeq;
|
||||
// 如果指定了页码,使用指定页码,否则使用当前页码
|
||||
const currentPage = page !== null ? page : monitorState.pagination.page;
|
||||
const pageSize = monitorState.pagination.pageSize;
|
||||
@@ -2028,6 +2440,9 @@ async function refreshMonitorPanel(page = null) {
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '获取监控数据失败');
|
||||
}
|
||||
if (mySeq !== monitorPanelFetchSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
monitorState.executions = Array.isArray(result.executions) ? result.executions : [];
|
||||
monitorState.stats = result.stats || {};
|
||||
@@ -2088,6 +2503,7 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
|
||||
const execContainer = document.getElementById('monitor-executions');
|
||||
|
||||
try {
|
||||
const mySeq = ++monitorPanelFetchSeq;
|
||||
const currentPage = 1; // 筛选时重置到第一页
|
||||
const pageSize = monitorState.pagination.pageSize;
|
||||
|
||||
@@ -2105,6 +2521,9 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '获取监控数据失败');
|
||||
}
|
||||
if (mySeq !== monitorPanelFetchSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
monitorState.executions = Array.isArray(result.executions) ? result.executions : [];
|
||||
monitorState.stats = result.stats || {};
|
||||
|
||||
+57
-54
@@ -1,6 +1,48 @@
|
||||
// 页面路由管理
|
||||
let currentPage = 'dashboard';
|
||||
|
||||
/** 仅当停留在 chat 时保留 ?conversation= 等查询串,其它页面只使用 pageId */
|
||||
function buildHashForPage(pageId) {
|
||||
if (pageId !== 'chat') {
|
||||
return pageId;
|
||||
}
|
||||
const full = window.location.hash.slice(1);
|
||||
const parts = full.split('?');
|
||||
const curPage = parts[0];
|
||||
const q = parts.length > 1 ? parts.slice(1).join('?') : '';
|
||||
if (curPage === 'chat' && q) {
|
||||
return 'chat?' + q;
|
||||
}
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
let chatConversationFromHashSeq = 0;
|
||||
function scheduleChatConversationFromHash(delayMs) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
const hashParts = hash.split('?');
|
||||
if (hashParts[0] !== 'chat' || hashParts.length < 2) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(hashParts.slice(1).join('?'));
|
||||
const conversationId = params.get('conversation');
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
const token = ++chatConversationFromHashSeq;
|
||||
setTimeout(() => {
|
||||
if (token !== chatConversationFromHashSeq) {
|
||||
return;
|
||||
}
|
||||
if (typeof loadConversation === 'function') {
|
||||
loadConversation(conversationId);
|
||||
} else if (typeof window.loadConversation === 'function') {
|
||||
window.loadConversation(conversationId);
|
||||
} else {
|
||||
console.warn('loadConversation function not found');
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
// 初始化路由
|
||||
function initRouter() {
|
||||
// 从URL hash读取页面(如果有)
|
||||
@@ -8,25 +50,10 @@ function initRouter() {
|
||||
if (hash) {
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks'].includes(pageId)) {
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
|
||||
// 如果是chat页面且带有conversation参数,加载对应对话
|
||||
if (pageId === 'chat' && hashParts.length > 1) {
|
||||
const params = new URLSearchParams(hashParts[1]);
|
||||
const conversationId = params.get('conversation');
|
||||
if (conversationId) {
|
||||
setTimeout(() => {
|
||||
// 尝试多种方式调用loadConversation
|
||||
if (typeof loadConversation === 'function') {
|
||||
loadConversation(conversationId);
|
||||
} else if (typeof window.loadConversation === 'function') {
|
||||
window.loadConversation(conversationId);
|
||||
} else {
|
||||
console.warn('loadConversation function not found');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
if (pageId === 'chat') {
|
||||
scheduleChatConversationFromHash(500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -49,8 +76,10 @@ function switchPage(pageId) {
|
||||
targetPage.classList.add('active');
|
||||
currentPage = pageId;
|
||||
|
||||
// 更新URL hash
|
||||
window.location.hash = pageId;
|
||||
const newHash = buildHashForPage(pageId);
|
||||
if (window.location.hash.slice(1) !== newHash) {
|
||||
window.location.hash = newHash;
|
||||
}
|
||||
|
||||
// 更新导航状态
|
||||
updateNavState(pageId);
|
||||
@@ -247,6 +276,11 @@ async function initPage(pageId) {
|
||||
// 恢复对话列表折叠状态(从其他页返回时保持用户选择)
|
||||
initConversationSidebarState();
|
||||
break;
|
||||
case 'hitl':
|
||||
if (typeof refreshHitlPending === 'function') {
|
||||
refreshHitlPending();
|
||||
}
|
||||
break;
|
||||
case 'info-collect':
|
||||
// 信息收集页面
|
||||
if (typeof initInfoCollectPage === 'function') {
|
||||
@@ -379,44 +413,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
|
||||
if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings'].includes(pageId)) {
|
||||
if (pageId && ['chat', 'hitl', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
|
||||
// 如果是chat页面且带有conversation参数,加载对应对话
|
||||
if (pageId === 'chat' && hashParts.length > 1) {
|
||||
const params = new URLSearchParams(hashParts[1]);
|
||||
const conversationId = params.get('conversation');
|
||||
if (conversationId) {
|
||||
setTimeout(() => {
|
||||
// 尝试多种方式调用loadConversation
|
||||
if (typeof loadConversation === 'function') {
|
||||
loadConversation(conversationId);
|
||||
} else if (typeof window.loadConversation === 'function') {
|
||||
window.loadConversation(conversationId);
|
||||
} else {
|
||||
console.warn('loadConversation function not found');
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
if (pageId === 'chat') {
|
||||
scheduleChatConversationFromHash(200);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 页面加载时也检查hash参数
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash) {
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
if (pageId === 'chat' && hashParts.length > 1) {
|
||||
const params = new URLSearchParams(hashParts[1]);
|
||||
const conversationId = params.get('conversation');
|
||||
if (conversationId && typeof loadConversation === 'function') {
|
||||
setTimeout(() => {
|
||||
loadConversation(conversationId);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 切换侧边栏折叠/展开
|
||||
|
||||
+140
-25
@@ -134,8 +134,6 @@ async function loadConfig(loadTools = true) {
|
||||
const v = ma.plan_execute_loop_max_iterations;
|
||||
maPeLoop.value = (v !== undefined && v !== null && !Number.isNaN(Number(v))) ? String(Number(v)) : '0';
|
||||
}
|
||||
const maMode = document.getElementById('multi-agent-default-mode');
|
||||
if (maMode) maMode.value = (ma.default_mode === 'multi') ? 'multi' : 'single';
|
||||
const maRobot = document.getElementById('multi-agent-robot-use');
|
||||
if (maRobot) maRobot.checked = ma.robot_use_multi_agent === true;
|
||||
|
||||
@@ -501,28 +499,39 @@ function renderToolsList() {
|
||||
external_mcp: tool.external_mcp || ''
|
||||
};
|
||||
|
||||
// 外部工具标签,显示来源信息
|
||||
// 外部工具标签,显示来源信息(可点击跳转到对应 MCP 卡片)
|
||||
let externalBadge = '';
|
||||
if (toolState.is_external || tool.is_external) {
|
||||
const externalMcpName = toolState.external_mcp || tool.external_mcp || '';
|
||||
const badgeText = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalFrom', { name: escapeHtml(externalMcpName) }) : `外部 (${escapeHtml(externalMcpName)})`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部');
|
||||
const badgeTitle = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalToolFrom', { name: escapeHtml(externalMcpName) }) : `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部MCP工具');
|
||||
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
|
||||
const badgeTitle = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalToolFrom', { name: escapeHtml(externalMcpName) }) + ' — 点击跳转' : `外部MCP工具 - 来源:${escapeHtml(externalMcpName)} — 点击跳转`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部MCP工具');
|
||||
if (externalMcpName) {
|
||||
externalBadge = `<span class="external-tool-badge clickable" onclick="scrollToExternalMCP('${escapeHtml(externalMcpName)}', event)" title="${badgeTitle}">${badgeText}</span>`;
|
||||
} else {
|
||||
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 生成唯一的checkbox id,使用工具唯一标识符
|
||||
const checkboxId = `tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
|
||||
|
||||
|
||||
toolItem.innerHTML = `
|
||||
<input type="checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''} ${toolState.is_external || tool.is_external ? 'data-external="true"' : ''} onchange="handleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
|
||||
<div class="tool-item-info">
|
||||
<div class="tool-item-name">
|
||||
${escapeHtml(tool.name)}
|
||||
${externalBadge}
|
||||
<span class="tool-expand-icon">▶</span>
|
||||
</div>
|
||||
<div class="tool-item-desc">${escapeHtml(tool.description || (typeof window.t === 'function' ? window.t('mcp.noDescription') : '无描述'))}</div>
|
||||
<div class="tool-item-detail" style="display:none"></div>
|
||||
</div>
|
||||
`;
|
||||
toolItem.addEventListener('click', function (event) {
|
||||
const infoEl = toolItem.querySelector('.tool-item-info');
|
||||
if (!infoEl) return;
|
||||
toggleToolDetail(infoEl, toolKey, !!tool.is_external, tool.external_mcp || '', event);
|
||||
});
|
||||
listContainer.appendChild(toolItem);
|
||||
});
|
||||
|
||||
@@ -534,6 +543,105 @@ function renderToolsList() {
|
||||
updateToolsStats();
|
||||
}
|
||||
|
||||
// 展开/折叠工具详情面板(按需从后端加载 schema)
|
||||
function toggleToolDetail(infoEl, toolKey, isExternal, externalMcp, event) {
|
||||
// 点击 checkbox 或外部工具徽章时不展开
|
||||
if (event && (event.target.tagName === 'INPUT' || event.target.closest('.external-tool-badge'))) return;
|
||||
|
||||
const detail = infoEl.querySelector('.tool-item-detail');
|
||||
const icon = infoEl.querySelector('.tool-expand-icon');
|
||||
if (!detail) return;
|
||||
|
||||
// 使用 data-open 作为主状态,避免仅依赖 style.display 带来的首击偶发判定不一致
|
||||
const isOpen = detail.dataset.open === '1';
|
||||
detail.style.display = isOpen ? 'none' : 'block';
|
||||
detail.dataset.open = isOpen ? '0' : '1';
|
||||
if (icon) icon.textContent = isOpen ? '▶' : '▼';
|
||||
|
||||
// 首次展开时从后端按需加载
|
||||
if (!isOpen && !detail.dataset.rendered) {
|
||||
detail.dataset.rendered = '1';
|
||||
const descEl = infoEl.querySelector('.tool-item-desc');
|
||||
const fullDesc = descEl ? descEl.textContent : '';
|
||||
|
||||
// 先显示加载状态
|
||||
detail.innerHTML = `
|
||||
<div class="tool-detail-desc">${escapeHtml(fullDesc)}</div>
|
||||
<div class="tool-detail-section-title">参数定义</div>
|
||||
<div style="color:var(--text-tertiary);font-size:0.8125rem;padding:4px 0;">加载中...</div>
|
||||
`;
|
||||
|
||||
// 解析工具名(外部工具 toolKey 格式为 mcpName::toolName)
|
||||
let apiToolName = toolKey;
|
||||
let query = '';
|
||||
if (isExternal && externalMcp) {
|
||||
const parts = toolKey.split('::');
|
||||
apiToolName = parts.length > 1 ? parts[1] : toolKey;
|
||||
query = '?external_mcp=' + encodeURIComponent(externalMcp);
|
||||
}
|
||||
|
||||
apiFetch(`/api/config/tools/${encodeURIComponent(apiToolName)}/schema${query}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const schema = data.input_schema;
|
||||
let schemaHTML = '';
|
||||
if (schema) {
|
||||
const props = schema.properties || {};
|
||||
const required = schema.required || [];
|
||||
const paramKeys = Object.keys(props);
|
||||
if (paramKeys.length > 0) {
|
||||
schemaHTML = `<table class="tool-schema-table">
|
||||
<thead><tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr></thead>
|
||||
<tbody>`;
|
||||
paramKeys.forEach(key => {
|
||||
const p = props[key] || {};
|
||||
const type = p.type || (p.enum ? 'enum' : '—');
|
||||
const isReq = required.includes(key);
|
||||
const desc = p.description || '';
|
||||
schemaHTML += `<tr>
|
||||
<td><code>${escapeHtml(key)}</code></td>
|
||||
<td>${escapeHtml(String(type))}</td>
|
||||
<td>${isReq ? '<span style="color:#28a745">✔</span>' : ''}</td>
|
||||
<td>${escapeHtml(desc)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
schemaHTML += '</tbody></table>';
|
||||
}
|
||||
}
|
||||
if (!schemaHTML) {
|
||||
schemaHTML = '<div style="color:var(--text-tertiary);font-size:0.8125rem;padding:4px 0;">无参数定义</div>';
|
||||
}
|
||||
detail.innerHTML = `
|
||||
<div class="tool-detail-desc">${escapeHtml(fullDesc)}</div>
|
||||
<div class="tool-detail-section-title">参数定义</div>
|
||||
${schemaHTML}
|
||||
`;
|
||||
})
|
||||
.catch(() => {
|
||||
detail.innerHTML = `
|
||||
<div class="tool-detail-desc">${escapeHtml(fullDesc)}</div>
|
||||
<div class="tool-detail-section-title">参数定义</div>
|
||||
<div style="color:var(--text-tertiary);font-size:0.8125rem;padding:4px 0;">加载失败</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部工具徽章跳转到对应的外部 MCP 卡片
|
||||
function scrollToExternalMCP(mcpName, event) {
|
||||
event.stopPropagation();
|
||||
const items = document.querySelectorAll('.external-mcp-item');
|
||||
for (const item of items) {
|
||||
const h4 = item.querySelector('h4');
|
||||
if (h4 && h4.textContent.includes(mcpName)) {
|
||||
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
item.classList.add('highlight');
|
||||
setTimeout(() => item.classList.remove('highlight'), 2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染工具列表分页控件
|
||||
function renderToolsPagination() {
|
||||
const toolsList = document.getElementById('tools-list');
|
||||
@@ -902,7 +1010,6 @@ async function applySettings() {
|
||||
const peLoop = Number.isNaN(peParsed) ? 0 : Math.max(0, peParsed);
|
||||
return {
|
||||
enabled: document.getElementById('multi-agent-enabled')?.checked === true,
|
||||
default_mode: document.getElementById('multi-agent-default-mode')?.value === 'multi' ? 'multi' : 'single',
|
||||
robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true,
|
||||
batch_use_multi_agent: false,
|
||||
plan_execute_loop_max_iterations: peLoop
|
||||
@@ -1363,12 +1470,15 @@ async function pollExternalMCPToolCount(name, maxAttempts = 10) {
|
||||
function renderExternalMCPList(servers) {
|
||||
const list = document.getElementById('external-mcp-list');
|
||||
if (!list) return;
|
||||
const layout = document.querySelector('.mcp-management-layout');
|
||||
|
||||
if (Object.keys(servers).length === 0) {
|
||||
if (layout) layout.classList.add('external-empty');
|
||||
const emptyT = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
list.innerHTML = '<div class="empty">📋 ' + emptyT('mcp.noExternalMCP') + '<br><span style="font-size: 0.875rem; margin-top: 8px; display: block;">' + emptyT('mcp.clickToAddExternal') + '</span></div>';
|
||||
return;
|
||||
}
|
||||
if (layout) layout.classList.remove('external-empty');
|
||||
|
||||
let html = '<div class="external-mcp-items">';
|
||||
for (const [name, server] of Object.entries(servers)) {
|
||||
@@ -1382,7 +1492,7 @@ function renderExternalMCPList(servers) {
|
||||
status === 'connecting' ? statusT('mcp.connecting') :
|
||||
status === 'error' ? statusT('mcp.connectionFailed') :
|
||||
status === 'disabled' ? statusT('mcp.disabled') : statusT('mcp.disconnected');
|
||||
const transport = server.config.transport || (server.config.command ? 'stdio' : 'http');
|
||||
const transport = server.config.type || server.config.transport || (server.config.command ? 'stdio' : 'http');
|
||||
const transportIcon = transport === 'stdio' ? '⚙️' : '🌐';
|
||||
|
||||
html += `
|
||||
@@ -1393,11 +1503,11 @@ function renderExternalMCPList(servers) {
|
||||
<span class="external-mcp-status ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="external-mcp-item-actions">
|
||||
${status === 'connected' || status === 'disconnected' || status === 'error' ?
|
||||
${status === 'connected' || status === 'disconnected' || status === 'error' || status === 'disabled' ?
|
||||
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" onclick="toggleExternalMCP('${escapeHtml(name)}', '${status}')" title="${status === 'connected' ? statusT('mcp.stopConnection') : statusT('mcp.startConnection')}">
|
||||
${status === 'connected' ? '⏸ ' + statusT('mcp.stop') : '▶ ' + statusT('mcp.start')}
|
||||
</button>` :
|
||||
status === 'connecting' ?
|
||||
</button>` :
|
||||
status === 'connecting' ?
|
||||
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" disabled style="opacity: 0.6; cursor: not-allowed;">
|
||||
⏳ ${statusT('mcp.connecting')}
|
||||
</button>` : ''}
|
||||
@@ -1552,24 +1662,29 @@ function formatExternalMCPJSON() {
|
||||
|
||||
// 加载示例
|
||||
function loadExternalMCPExample() {
|
||||
const desc = (typeof window.t === 'function' ? window.t('externalMcpModal.exampleDescription') : '示例描述');
|
||||
const example = {
|
||||
"hexstrike-ai": {
|
||||
"my-stdio-server": {
|
||||
command: "python3",
|
||||
args: [
|
||||
"/path/to/script.py",
|
||||
"--server",
|
||||
"http://example.com"
|
||||
"${HOME}/mcp-servers/main.py",
|
||||
"--port",
|
||||
"${MCP_PORT:-3000}"
|
||||
],
|
||||
description: desc,
|
||||
env: {
|
||||
"API_KEY": "${API_KEY}",
|
||||
"LOG_LEVEL": "${LOG_LEVEL:-INFO}"
|
||||
},
|
||||
timeout: 300
|
||||
},
|
||||
"cyberstrike-ai-http": {
|
||||
transport: "http",
|
||||
url: "http://127.0.0.1:8081/mcp"
|
||||
"my-http-server": {
|
||||
type: "http",
|
||||
url: "https://mcp.example.com/mcp",
|
||||
headers: {
|
||||
"Authorization": "Bearer ${MCP_TOKEN}"
|
||||
}
|
||||
},
|
||||
"cyberstrike-ai-sse": {
|
||||
transport: "sse",
|
||||
"my-sse-server": {
|
||||
type: "sse",
|
||||
url: "http://127.0.0.1:8081/mcp/sse"
|
||||
}
|
||||
};
|
||||
@@ -1642,8 +1757,8 @@ async function saveExternalMCP() {
|
||||
// 移除 external_mcp_enable 字段(由按钮控制,但保留 enabled/disabled 用于向后兼容)
|
||||
delete config.external_mcp_enable;
|
||||
|
||||
// 验证配置内容
|
||||
const transport = config.transport || (config.command ? 'stdio' : config.url ? 'http' : '');
|
||||
// 验证配置内容(同时支持官方 type 字段和旧版 transport 字段)
|
||||
const transport = config.type || config.transport || (config.command ? 'stdio' : config.url ? 'http' : '');
|
||||
if (!transport) {
|
||||
errorDiv.textContent = t('mcp.configNeedCommand', { name: name });
|
||||
errorDiv.style.display = 'block';
|
||||
|
||||
+17
-5
@@ -1666,12 +1666,24 @@ function startBatchQueueRefresh(queueId) {
|
||||
if ((addModal && addModal.style.display === 'block') || hasInlineEdit) {
|
||||
return;
|
||||
}
|
||||
if (batchQueuesState.currentQueueId === queueId) {
|
||||
showBatchQueueDetail(queueId);
|
||||
refreshBatchQueues();
|
||||
} else {
|
||||
stopBatchQueueRefresh();
|
||||
if (batchQueuesState._bqDetailRefreshing) {
|
||||
return;
|
||||
}
|
||||
if (batchQueuesState.currentQueueId !== queueId) {
|
||||
stopBatchQueueRefresh();
|
||||
return;
|
||||
}
|
||||
batchQueuesState._bqDetailRefreshing = true;
|
||||
(async () => {
|
||||
try {
|
||||
await showBatchQueueDetail(queueId);
|
||||
await refreshBatchQueues();
|
||||
} catch (e) {
|
||||
console.warn('批量队列定时刷新失败:', e);
|
||||
} finally {
|
||||
batchQueuesState._bqDetailRefreshing = false;
|
||||
}
|
||||
})();
|
||||
}, 3000); // 每3秒刷新一次
|
||||
}
|
||||
|
||||
|
||||
+72
-13
@@ -114,6 +114,17 @@
|
||||
<span data-i18n="nav.chat">对话</span>
|
||||
</div>
|
||||
</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">
|
||||
<circle cx="8" cy="7" r="3"></circle>
|
||||
<circle cx="16" cy="7" r="3"></circle>
|
||||
<path d="M2 20c0-3 2.5-5 6-5s6 2 6 5"></path>
|
||||
<path d="M10 20h12"></path>
|
||||
</svg>
|
||||
<span data-i18n="nav.hitl">人机协同</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item" data-page="info-collect">
|
||||
<div class="nav-item-content" data-title="信息收集" onclick="switchPage('info-collect')" data-i18n="nav.infoCollect" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -500,6 +511,41 @@
|
||||
<div id="conversations-list" class="conversations-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hitl-sidebar-card" id="hitl-sidebar-card">
|
||||
<div class="hitl-sidebar-card-header">
|
||||
<div class="hitl-sidebar-heading">
|
||||
<span class="hitl-sidebar-icon" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"/>
|
||||
<path d="M9.5 12.5l2 2 3-4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="hitl-sidebar-heading-text">
|
||||
<span class="hitl-sidebar-title" data-i18n="chat.hitlTitle">人机协同</span>
|
||||
<span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
|
||||
<span data-i18n="chat.hitlApply">应用</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
|
||||
<div class="hitl-sidebar-config">
|
||||
<div class="hitl-config-field">
|
||||
<label class="hitl-config-label" for="hitl-mode-select" data-i18n="chat.hitlModeLabel">模式</label>
|
||||
<select id="hitl-mode-select" class="hitl-config-select">
|
||||
<option value="off" data-i18n="chat.hitlModeOff">关闭</option>
|
||||
<option value="approval" data-i18n="chat.hitlModeApproval">审批模式</option>
|
||||
<option value="review_edit" data-i18n="chat.hitlModeReviewEdit">审查编辑</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="hitl-config-field hitl-config-field--tools">
|
||||
<label class="hitl-config-label" for="hitl-sensitive-tools" data-i18n="chat.hitlWhitelistTools">白名单工具(免审批,逗号分隔)</label>
|
||||
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 分组详情页面 -->
|
||||
@@ -676,6 +722,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-hitl" class="page">
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="hitl.pageTitle">人机协同审批</h2>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-secondary" onclick="refreshHitlPending()" data-i18n="common.refresh">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div class="settings-section">
|
||||
<h3 data-i18n="hitl.pendingTitle">待处理中断</h3>
|
||||
<div id="hitl-pending-list" class="hitl-pending-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP状态监控页面 -->
|
||||
<div id="page-mcp-monitor" class="page">
|
||||
<div class="page-header">
|
||||
@@ -735,17 +796,17 @@
|
||||
<h2 data-i18n="mcp.managementTitle">MCP 管理</h2>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-secondary" onclick="loadExternalMCPs()"><span data-i18n="common.refresh">刷新</span></button>
|
||||
<button class="btn-primary" onclick="showAddExternalMCPModal()" data-i18n="mcp.addExternalMCP">添加外部MCP</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div class="mcp-management-layout">
|
||||
<!-- MCP工具配置 -->
|
||||
<div class="settings-section" style="margin-bottom: 32px;">
|
||||
<div class="settings-section mcp-management-panel mcp-tools-panel" style="margin-bottom: 32px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h3 style="margin: 0;" data-i18n="mcp.toolConfig">MCP 工具配置</h3>
|
||||
<button class="btn-primary" onclick="saveToolsConfig()" data-i18n="mcp.saveToolConfig">保存工具配置</button>
|
||||
</div>
|
||||
<div class="tools-controls">
|
||||
<div class="tools-controls mcp-panel-body">
|
||||
<div class="tools-actions">
|
||||
<button class="btn-secondary" onclick="selectAllTools()" data-i18n="mcp.selectAll">全选</button>
|
||||
<button class="btn-secondary" onclick="deselectAllTools()" data-i18n="mcp.deselectAll">全不选</button>
|
||||
@@ -765,15 +826,19 @@
|
||||
</div>
|
||||
|
||||
<!-- 外部MCP配置 -->
|
||||
<div class="settings-section">
|
||||
<h3 data-i18n="mcp.externalConfig">外部 MCP 配置</h3>
|
||||
<div class="external-mcp-controls">
|
||||
<div class="settings-section mcp-management-panel mcp-external-panel">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h3 style="margin: 0;" data-i18n="mcp.externalConfig">外部 MCP 配置</h3>
|
||||
<button class="btn-primary" onclick="showAddExternalMCPModal()" data-i18n="mcp.addExternalMCP">添加外部MCP</button>
|
||||
</div>
|
||||
<div class="external-mcp-controls mcp-panel-body">
|
||||
<div class="external-mcp-actions">
|
||||
<div class="external-mcp-stats" id="external-mcp-stats"></div>
|
||||
</div>
|
||||
<div id="external-mcp-list" class="external-mcp-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1444,13 +1509,6 @@
|
||||
<input type="number" id="multi-agent-pe-loop" min="0" step="1" value="0" data-i18n="settingsBasic.multiAgentPeLoopPlaceholder" data-i18n-attr="placeholder" placeholder="0 表示 Eino 默认 10" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.multiAgentPeLoopHint">仅 orchestration=plan_execute 时有效;execute 与 replan 之间的最大轮次。</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="multi-agent-default-mode" data-i18n="settingsBasic.multiAgentDefaultMode">对话页默认模式</label>
|
||||
<select id="multi-agent-default-mode">
|
||||
<option value="single" data-i18n="settingsBasic.multiAgentModeSingle">单代理(ReAct)</option>
|
||||
<option value="multi" data-i18n="settingsBasic.multiAgentModeMulti">多代理(Eino)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="multi-agent-robot-use" class="modern-checkbox" />
|
||||
@@ -2749,6 +2807,7 @@
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/monitor.js"></script>
|
||||
<script src="/static/js/chat.js"></script>
|
||||
<script src="/static/js/hitl.js"></script>
|
||||
<script src="/static/js/settings.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user