mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 21:44:43 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b3f4e3556 | |||
| adef2c143b | |||
| 7ac3c06c34 | |||
| d3a05fcd92 | |||
| 1d692e9f52 | |||
| 7e4032858e | |||
| f77af18694 | |||
| 8e31f10837 | |||
| b3e29f6e8f | |||
| 32b655f526 | |||
| a8b608135e | |||
| 964c520215 | |||
| 26116b0822 | |||
| d037647c21 | |||
| f2a701a846 | |||
| 0ce79c6ef4 | |||
| 0d4f608c14 | |||
| c801a97add | |||
| 68978b82e9 | |||
| c43fde2612 | |||
| fbd1ede8cb | |||
| 2d8ef3a1b0 |
@@ -117,7 +117,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
|||||||
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
|
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
|
||||||
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
|
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
|
||||||
- 🧩 **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))
|
- 🧩 **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, …) can still be bound to roles
|
- 🎯 **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)
|
- 📱 **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.
|
- 🐚 **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.
|
||||||
|
|
||||||
@@ -250,8 +250,8 @@ Requirements / tips:
|
|||||||
- **Predefined roles** – System includes 12+ predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, Binary Analysis, Cloud Security Audit, etc.) in the `roles/` directory.
|
- **Predefined roles** – System includes 12+ predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, Binary Analysis, Cloud Security Audit, etc.) in the `roles/` directory.
|
||||||
- **Custom prompts** – Each role can define a `user_prompt` that prepends to user messages, guiding the AI to adopt specialized testing methodologies and focus areas.
|
- **Custom prompts** – Each role can define a `user_prompt` that prepends to user messages, guiding the AI to adopt specialized testing methodologies and focus areas.
|
||||||
- **Tool restrictions** – Roles can specify a `tools` list to limit available tools, ensuring focused testing workflows (e.g., CTF role restricts to CTF-specific utilities).
|
- **Tool restrictions** – Roles can specify a `tools` list to limit available tools, ensuring focused testing workflows (e.g., CTF role restricts to CTF-specific utilities).
|
||||||
- **Skills integration** – Roles can attach security testing skills. Skill ids are hinted in the system prompt; in **multi-agent** sessions the Eino ADK **`skill`** tool loads package content **on demand** (progressive disclosure). **`multi_agent.eino_skills`** toggles the middleware, tool name override, and optional **read_file / glob / grep / write / edit / execute** on the host (**Deep / Supervisor** main and sub-agents when enabled; **plan_execute** executor has no custom skill middleware—see docs). Single-agent ReAct does not mount this Eino skill stack today.
|
- **Skills** – Skill packs live under `skills_dir` and are loaded in **multi-agent / Eino** sessions via the ADK **`skill`** tool (**progressive disclosure**). Configure **`multi_agent.eino_skills`** for middleware, tool name override, and optional host **read_file / glob / grep / write / edit / execute** (**Deep / Supervisor** when enabled; **plan_execute** differs—see docs). Single-agent ReAct does not mount this Eino skill stack today.
|
||||||
- **Easy role creation** – Create custom roles by adding YAML files to the `roles/` directory. Each role defines `name`, `description`, `user_prompt`, `icon`, `tools`, `skills`, and `enabled` fields.
|
- **Easy role creation** – Create custom roles by adding YAML files to the `roles/` directory. Each role defines `name`, `description`, `user_prompt`, `icon`, `tools`, and `enabled` fields.
|
||||||
- **Web UI integration** – Select roles from a dropdown in the chat interface. Role selection affects both AI behavior and available tool suggestions.
|
- **Web UI integration** – Select roles from a dropdown in the chat interface. Role selection affects both AI behavior and available tool suggestions.
|
||||||
|
|
||||||
**Creating a custom role (example):**
|
**Creating a custom role (example):**
|
||||||
@@ -265,8 +265,6 @@ Requirements / tips:
|
|||||||
- api-fuzzer
|
- api-fuzzer
|
||||||
- arjun
|
- arjun
|
||||||
- graphql-scanner
|
- graphql-scanner
|
||||||
skills:
|
|
||||||
- cyberstrike-eino-demo
|
|
||||||
enabled: true
|
enabled: true
|
||||||
```
|
```
|
||||||
2. Restart the server or reload configuration; the role appears in the role selector dropdown.
|
2. Restart the server or reload configuration; the role appears in the role selector dropdown.
|
||||||
@@ -286,14 +284,13 @@ Requirements / tips:
|
|||||||
- **Layout** – Each skill is a directory with **required** `SKILL.md` only ([Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview)): YAML front matter **only** `name` and `description`, plus Markdown body. Optional sibling files (`FORMS.md`, `REFERENCE.md`, `scripts/*`, …). **No** `SKILL.yaml` (not part of Claude or Eino specs); sections/scripts/progressive behavior are **derived at runtime** from Markdown and the filesystem.
|
- **Layout** – Each skill is a directory with **required** `SKILL.md` only ([Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview)): YAML front matter **only** `name` and `description`, plus Markdown body. Optional sibling files (`FORMS.md`, `REFERENCE.md`, `scripts/*`, …). **No** `SKILL.yaml` (not part of Claude or Eino specs); sections/scripts/progressive behavior are **derived at runtime** from Markdown and the filesystem.
|
||||||
- **Runtime refactor** – **`skills_dir`** is the single root for packs. **Multi-agent** loads them through Eino’s official **`skill`** middleware (**progressive disclosure**: model calls `skill` with a pack **name** instead of receiving full SKILL text up front). Configure via **`multi_agent.eino_skills`**: `disable`, `filesystem_tools` (host read/glob/grep/write/edit/execute), `skill_tool_name`.
|
- **Runtime refactor** – **`skills_dir`** is the single root for packs. **Multi-agent** loads them through Eino’s official **`skill`** middleware (**progressive disclosure**: model calls `skill` with a pack **name** instead of receiving full SKILL text up front). Configure via **`multi_agent.eino_skills`**: `disable`, `filesystem_tools` (host read/glob/grep/write/edit/execute), `skill_tool_name`.
|
||||||
- **Eino / RAG** – Packages are also split into `schema.Document` chunks for `FilesystemSkillsRetriever` (`skills.AsEinoRetriever()`) in **compose** graphs (e.g. knowledge/indexing pipelines).
|
- **Eino / RAG** – Packages are also split into `schema.Document` chunks for `FilesystemSkillsRetriever` (`skills.AsEinoRetriever()`) in **compose** graphs (e.g. knowledge/indexing pipelines).
|
||||||
- **Skill hints in prompts** – Role-bound skill **ids** (directory names) are recommended in the system prompt; full text is not injected by default.
|
|
||||||
- **HTTP API** – `/api/skills` listing and `depth` (`summary` | `full`), `section`, and `resource_path` remain for the web UI and ops; **model-side** skill loading in multi-agent uses the **`skill`** tool, not MCP.
|
- **HTTP API** – `/api/skills` listing and `depth` (`summary` | `full`), `section`, and `resource_path` remain for the web UI and ops; **model-side** skill loading in multi-agent uses the **`skill`** tool, not MCP.
|
||||||
- **Optional `eino_middleware`** – e.g. `tool_search` (dynamic MCP tool list), `patch_tool_calls`, `plantask` (structured tasks; persistence defaults under a subdirectory of `skills_dir`), `reduction`, `checkpoint_dir`, Deep output key / model retries / task-tool description prefix—see `config.yaml` and `internal/config/config.go`.
|
- **Optional `eino_middleware`** – e.g. `tool_search` (dynamic MCP tool list), `patch_tool_calls`, `plantask` (structured tasks; persistence defaults under a subdirectory of `skills_dir`), `reduction`, `checkpoint_dir`, Deep output key / model retries / task-tool description prefix—see `config.yaml` and `internal/config/config.go`.
|
||||||
- **Shipped demo** – `skills/cyberstrike-eino-demo/`; see `skills/README.md`.
|
- **Shipped demo** – `skills/cyberstrike-eino-demo/`; see `skills/README.md`.
|
||||||
|
|
||||||
**Creating a skill:**
|
**Creating a skill:**
|
||||||
1. `mkdir skills/<skill-id>` and add standard `SKILL.md` (+ any optional files), or drop in an open-source skill folder as-is.
|
1. `mkdir skills/<skill-id>` and add standard `SKILL.md` (+ any optional files), or drop in an open-source skill folder as-is.
|
||||||
2. Reference `<skill-id>` in a role’s `skills` list in `roles/*.yaml`.
|
2. Use **multi-agent** with **`multi_agent.eino_skills`** enabled so the model can call the **`skill`** tool with that pack **name**.
|
||||||
|
|
||||||
### Tool Orchestration & Extensions
|
### Tool Orchestration & Extensions
|
||||||
- **YAML recipes** in `tools/*.yaml` describe commands, arguments, prompts, and metadata.
|
- **YAML recipes** in `tools/*.yaml` describe commands, arguments, prompts, and metadata.
|
||||||
|
|||||||
+3
-6
@@ -248,8 +248,8 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
- **预设角色**:系统内置 12+ 个预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试、二进制分析、云安全审计等),位于 `roles/` 目录。
|
- **预设角色**:系统内置 12+ 个预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试、二进制分析、云安全审计等),位于 `roles/` 目录。
|
||||||
- **自定义提示词**:每个角色可定义 `user_prompt`,会在用户消息前自动添加,引导 AI 采用特定的测试方法和关注重点。
|
- **自定义提示词**:每个角色可定义 `user_prompt`,会在用户消息前自动添加,引导 AI 采用特定的测试方法和关注重点。
|
||||||
- **工具限制**:角色可指定 `tools` 列表,限制可用工具,实现聚焦的测试流程(如 CTF 角色限制为 CTF 专用工具)。
|
- **工具限制**:角色可指定 `tools` 列表,限制可用工具,实现聚焦的测试流程(如 CTF 角色限制为 CTF 专用工具)。
|
||||||
- **Skills 集成**:角色可附加安全测试技能,id 写入提示;**多代理** 下由 Eino **`skill`** 工具 **按需加载**(渐进式披露)。**`multi_agent.eino_skills`** 控制中间件与本机 read_file/glob/grep/write/edit/execute(**Deep / Supervisor** 主/子代理;**plan_execute** 执行器无独立 skill 中间件,见文档)。**单代理 ReAct** 当前不挂载该 Eino skill 链。
|
- **Skills**:技能包位于 `skills_dir`;**多代理 / Eino** 下由 **`skill`** 工具 **按需加载**(渐进式披露)。**`multi_agent.eino_skills`** 控制中间件与本机 read_file/glob/grep/write/edit/execute(**Deep / Supervisor** 主/子代理;**plan_execute** 执行器无独立 skill 中间件,见文档)。**单代理 ReAct** 当前不挂载该 Eino skill 链。
|
||||||
- **轻松创建角色**:通过在 `roles/` 目录添加 YAML 文件即可创建自定义角色。每个角色定义 `name`、`description`、`user_prompt`、`icon`、`tools`、`skills`、`enabled` 字段。
|
- **轻松创建角色**:通过在 `roles/` 目录添加 YAML 文件即可创建自定义角色。每个角色定义 `name`、`description`、`user_prompt`、`icon`、`tools`、`enabled` 字段。
|
||||||
- **Web 界面集成**:在聊天界面通过下拉菜单选择角色。角色选择会影响 AI 行为和可用工具建议。
|
- **Web 界面集成**:在聊天界面通过下拉菜单选择角色。角色选择会影响 AI 行为和可用工具建议。
|
||||||
|
|
||||||
**创建自定义角色示例:**
|
**创建自定义角色示例:**
|
||||||
@@ -263,8 +263,6 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
- api-fuzzer
|
- api-fuzzer
|
||||||
- arjun
|
- arjun
|
||||||
- graphql-scanner
|
- graphql-scanner
|
||||||
skills:
|
|
||||||
- cyberstrike-eino-demo
|
|
||||||
enabled: true
|
enabled: true
|
||||||
```
|
```
|
||||||
2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。
|
2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。
|
||||||
@@ -284,14 +282,13 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
- **目录规范**:与 [Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) 一致,**仅**需目录下的 **`SKILL.md`**:YAML 头只用官方的 **`name` 与 `description`**,正文为 Markdown。可选同目录其他文件(`FORMS.md`、`REFERENCE.md`、`scripts/*` 等)。**不使用 `SKILL.yaml`**(Claude / Eino 官方均无此文件);章节、`scripts/` 列表、渐进式行为由运行时从正文与磁盘 **自动推导**。
|
- **目录规范**:与 [Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) 一致,**仅**需目录下的 **`SKILL.md`**:YAML 头只用官方的 **`name` 与 `description`**,正文为 Markdown。可选同目录其他文件(`FORMS.md`、`REFERENCE.md`、`scripts/*` 等)。**不使用 `SKILL.yaml`**(Claude / Eino 官方均无此文件);章节、`scripts/` 列表、渐进式行为由运行时从正文与磁盘 **自动推导**。
|
||||||
- **运行侧重构**:**`skills_dir`** 为技能包唯一根目录;**多代理** 通过 Eino 官方 **`skill`** 中间件做 **渐进式披露**(模型按 **name** 调用 `skill`,而非一次性注入全文)。由 **`multi_agent.eino_skills`** 控制:`disable`、`filesystem_tools`(本机读写与 Shell)、`skill_tool_name`。
|
- **运行侧重构**:**`skills_dir`** 为技能包唯一根目录;**多代理** 通过 Eino 官方 **`skill`** 中间件做 **渐进式披露**(模型按 **name** 调用 `skill`,而非一次性注入全文)。由 **`multi_agent.eino_skills`** 控制:`disable`、`filesystem_tools`(本机读写与 Shell)、`skill_tool_name`。
|
||||||
- **Eino / 知识流水线**:技能包可切分为 `schema.Document`,供 `FilesystemSkillsRetriever`(`skills.AsEinoRetriever()`)在 **compose** 图(如索引/编排)中使用。
|
- **Eino / 知识流水线**:技能包可切分为 `schema.Document`,供 `FilesystemSkillsRetriever`(`skills.AsEinoRetriever()`)在 **compose** 图(如索引/编排)中使用。
|
||||||
- **提示词**:角色绑定的技能 **id**(文件夹名)会作为推荐写入系统提示;正文默认不整包注入。
|
|
||||||
- **HTTP 管理**:`/api/skills` 列表与 `depth=summary|full`、`section`、`resource_path` 等仍用于 Web 与运维;**模型侧** 多代理走 **`skill`** 工具,而非 MCP。
|
- **HTTP 管理**:`/api/skills` 列表与 `depth=summary|full`、`section`、`resource_path` 等仍用于 Web 与运维;**模型侧** 多代理走 **`skill`** 工具,而非 MCP。
|
||||||
- **可选 `eino_middleware`**:如 `tool_search`(动态工具列表)、`patch_tool_calls`、`plantask`(结构化任务;默认落在 `skills_dir` 下子目录)、`reduction`、`checkpoint_dir`、Deep 输出键 / 模型重试 / task 描述前缀等,见 `config.yaml` 与 `internal/config/config.go`。
|
- **可选 `eino_middleware`**:如 `tool_search`(动态工具列表)、`patch_tool_calls`、`plantask`(结构化任务;默认落在 `skills_dir` 下子目录)、`reduction`、`checkpoint_dir`、Deep 输出键 / 模型重试 / task 描述前缀等,见 `config.yaml` 与 `internal/config/config.go`。
|
||||||
- **自带示例**:`skills/cyberstrike-eino-demo/`;说明见 `skills/README.md`。
|
- **自带示例**:`skills/cyberstrike-eino-demo/`;说明见 `skills/README.md`。
|
||||||
|
|
||||||
**新建技能:**
|
**新建技能:**
|
||||||
1. 在 `skills/` 下创建 `<skill-id>/`,放入标准 `SKILL.md`(及任意可选文件),或直接解压开源技能包到该目录。
|
1. 在 `skills/` 下创建 `<skill-id>/`,放入标准 `SKILL.md`(及任意可选文件),或直接解压开源技能包到该目录。
|
||||||
2. 在 `roles/*.yaml` 的 `skills` 列表中引用该 `<skill-id>`。
|
2. 启用 **`multi_agent.eino_skills`** 并使用 **多代理** 会话,由模型通过 **`skill`** 工具按包 **name** 加载。
|
||||||
|
|
||||||
### 工具编排与扩展
|
### 工具编排与扩展
|
||||||
- `tools/*.yaml` 定义命令、参数、提示词与元数据,可热加载。
|
- `tools/*.yaml` 定义命令、参数、提示词与元数据,可热加载。
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.5.2"
|
version: "v1.5.4"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
|
|||||||
@@ -58,3 +58,4 @@
|
|||||||
| 2026-03-22 | `agents/*.md` 子代理定义、`agents_dir`、合并进 `RunDeepAgent`、前端 Agents 菜单与 CRUD API。 |
|
| 2026-03-22 | `agents/*.md` 子代理定义、`agents_dir`、合并进 `RunDeepAgent`、前端 Agents 菜单与 CRUD API。 |
|
||||||
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
|
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
|
||||||
| 2026-04-19 | 主聊天「对话模式」:原生 ReAct 与 Deep / Plan-Execute / Supervisor;`POST /api/multi-agent*` 请求体 `orchestration` 与界面一致;`config.yaml` / 设置页不再维护预置编排字段(机器人/批量默认 `deep`)。 |
|
| 2026-04-19 | 主聊天「对话模式」:原生 ReAct 与 Deep / Plan-Execute / Supervisor;`POST /api/multi-agent*` 请求体 `orchestration` 与界面一致;`config.yaml` / 设置页不再维护预置编排字段(机器人/批量默认 `deep`)。 |
|
||||||
|
| 2026-04-21 | 移除角色 `skills` 与 `/api/roles/skills/list`;`bind_role` 仅继承 tools;Skills 仅通过 Eino `skill` 工具按需加载。 |
|
||||||
|
|||||||
+6
-45
@@ -316,16 +316,16 @@ type ProgressCallback func(eventType, message string, data interface{})
|
|||||||
|
|
||||||
// AgentLoop 执行Agent循环
|
// AgentLoop 执行Agent循环
|
||||||
func (a *Agent) AgentLoop(ctx context.Context, userInput string, historyMessages []ChatMessage) (*AgentLoopResult, error) {
|
func (a *Agent) AgentLoop(ctx context.Context, userInput string, historyMessages []ChatMessage) (*AgentLoopResult, error) {
|
||||||
return a.AgentLoopWithProgress(ctx, userInput, historyMessages, "", nil, nil, nil)
|
return a.AgentLoopWithProgress(ctx, userInput, historyMessages, "", nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentLoopWithConversationID 执行Agent循环(带对话ID)
|
// AgentLoopWithConversationID 执行Agent循环(带对话ID)
|
||||||
func (a *Agent) AgentLoopWithConversationID(ctx context.Context, userInput string, historyMessages []ChatMessage, conversationID string) (*AgentLoopResult, error) {
|
func (a *Agent) AgentLoopWithConversationID(ctx context.Context, userInput string, historyMessages []ChatMessage, conversationID string) (*AgentLoopResult, error) {
|
||||||
return a.AgentLoopWithProgress(ctx, userInput, historyMessages, conversationID, nil, nil, nil)
|
return a.AgentLoopWithProgress(ctx, userInput, historyMessages, conversationID, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EinoSingleAgentSystemInstruction 供 Eino adk.ChatModelAgent.Instruction 使用,与 AgentLoopWithProgress 首条 system 对齐(含 system_prompt_path 与 Skills 提示)。
|
// EinoSingleAgentSystemInstruction 供 Eino adk.ChatModelAgent.Instruction 使用,与 AgentLoopWithProgress 首条 system 对齐(含 system_prompt_path)。
|
||||||
func (a *Agent) EinoSingleAgentSystemInstruction(roleSkills []string) string {
|
func (a *Agent) EinoSingleAgentSystemInstruction() string {
|
||||||
systemPrompt := DefaultSingleAgentSystemPrompt()
|
systemPrompt := DefaultSingleAgentSystemPrompt()
|
||||||
if a.agentConfig != nil {
|
if a.agentConfig != nil {
|
||||||
if p := strings.TrimSpace(a.agentConfig.SystemPromptPath); p != "" {
|
if p := strings.TrimSpace(a.agentConfig.SystemPromptPath); p != "" {
|
||||||
@@ -343,30 +343,11 @@ func (a *Agent) EinoSingleAgentSystemInstruction(roleSkills []string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(roleSkills) > 0 {
|
|
||||||
var skillsHint strings.Builder
|
|
||||||
skillsHint.WriteString("\n\n本角色推荐使用的Skills:\n")
|
|
||||||
for i, skillName := range roleSkills {
|
|
||||||
if i > 0 {
|
|
||||||
skillsHint.WriteString("、")
|
|
||||||
}
|
|
||||||
skillsHint.WriteString("`")
|
|
||||||
skillsHint.WriteString(skillName)
|
|
||||||
skillsHint.WriteString("`")
|
|
||||||
}
|
|
||||||
skillsHint.WriteString("\n- 这些名称与 skills/ 下 SKILL.md 的 `name` 一致。")
|
|
||||||
skillsHint.WriteString("\n- 若当前会话已启用 Eino 内置 `skill` 工具,请按需加载;否则以 MCP 与文本工作流完成。")
|
|
||||||
skillsHint.WriteString("\n- 例如传入 skill 参数为 `")
|
|
||||||
skillsHint.WriteString(roleSkills[0])
|
|
||||||
skillsHint.WriteString("`")
|
|
||||||
systemPrompt += skillsHint.String()
|
|
||||||
}
|
|
||||||
return systemPrompt
|
return systemPrompt
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentLoopWithProgress 执行Agent循环(带进度回调和对话ID)
|
// AgentLoopWithProgress 执行Agent循环(带进度回调和对话ID)
|
||||||
// roleSkills: 角色配置的skills列表(用于在系统提示词中提示AI,但不硬编码内容)
|
func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, historyMessages []ChatMessage, conversationID string, callback ProgressCallback, roleTools []string) (*AgentLoopResult, error) {
|
||||||
func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, historyMessages []ChatMessage, conversationID string, callback ProgressCallback, roleTools []string, roleSkills []string) (*AgentLoopResult, error) {
|
|
||||||
// 设置当前对话ID
|
// 设置当前对话ID
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.currentConversationID = conversationID
|
a.currentConversationID = conversationID
|
||||||
@@ -396,26 +377,6 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果角色配置了skills,在系统提示词中提示AI(但不硬编码内容)
|
|
||||||
if len(roleSkills) > 0 {
|
|
||||||
var skillsHint strings.Builder
|
|
||||||
skillsHint.WriteString("\n\n本角色推荐使用的Skills:\n")
|
|
||||||
for i, skillName := range roleSkills {
|
|
||||||
if i > 0 {
|
|
||||||
skillsHint.WriteString("、")
|
|
||||||
}
|
|
||||||
skillsHint.WriteString("`")
|
|
||||||
skillsHint.WriteString(skillName)
|
|
||||||
skillsHint.WriteString("`")
|
|
||||||
}
|
|
||||||
skillsHint.WriteString("\n- 这些名称与 skills/ 下 SKILL.md 的 `name` 一致;在 **Eino 多代理** 会话中请用内置 `skill` 工具按需加载全文")
|
|
||||||
skillsHint.WriteString("\n- 例如:在支持 Eino skill 工具时传入 skill 参数为 `")
|
|
||||||
skillsHint.WriteString(roleSkills[0])
|
|
||||||
skillsHint.WriteString("`")
|
|
||||||
skillsHint.WriteString("\n- 单代理 MCP 模式不会注入 skill 工具;需要时请使用多代理(DeepAgent)")
|
|
||||||
systemPrompt += skillsHint.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := []ChatMessage{
|
messages := []ChatMessage{
|
||||||
{
|
{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
@@ -988,7 +949,7 @@ func (a *Agent) getAvailableTools(roleTools []string) []Tool {
|
|||||||
enabled := false
|
enabled := false
|
||||||
if cfg, exists := externalMCPConfigs[mcpName]; exists {
|
if cfg, exists := externalMCPConfigs[mcpName]; exists {
|
||||||
// 首先检查外部MCP是否启用
|
// 首先检查外部MCP是否启用
|
||||||
if !cfg.ExternalMCPEnable && !(cfg.Enabled && !cfg.Disabled) {
|
if !cfg.ExternalMCPEnable {
|
||||||
enabled = false // MCP未启用,所有工具都禁用
|
enabled = false // MCP未启用,所有工具都禁用
|
||||||
} else {
|
} else {
|
||||||
// MCP已启用,检查单个工具的启用状态
|
// MCP已启用,检查单个工具的启用状态
|
||||||
|
|||||||
+58
-16
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -327,7 +328,6 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
|||||||
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
||||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
||||||
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
||||||
roleHandler.SetSkillsManager(skillpackage.DirLister{SkillsRoot: skillsDir})
|
|
||||||
skillsHandler := handler.NewSkillsHandler(cfg, configPath, log.Logger)
|
skillsHandler := handler.NewSkillsHandler(cfg, configPath, log.Logger)
|
||||||
fofaHandler := handler.NewFofaHandler(cfg, log.Logger)
|
fofaHandler := handler.NewFofaHandler(cfg, log.Logger)
|
||||||
terminalHandler := handler.NewTerminalHandler(log.Logger)
|
terminalHandler := handler.NewTerminalHandler(log.Logger)
|
||||||
@@ -460,7 +460,9 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
|||||||
func (a *App) mcpHandlerWithAuth(w http.ResponseWriter, r *http.Request) {
|
func (a *App) mcpHandlerWithAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg := a.config.MCP
|
cfg := a.config.MCP
|
||||||
if cfg.AuthHeader != "" {
|
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))
|
a.logger.Logger.Debug("MCP 鉴权失败:header 缺失或值不匹配", zap.String("header", cfg.AuthHeader))
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
@@ -471,18 +473,25 @@ func (a *App) mcpHandlerWithAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.mcpServer.HandleHTTP(w, r)
|
a.mcpServer.HandleHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run 启动应用
|
// Run 启动应用(向后兼容,不支持优雅关闭)
|
||||||
func (a *App) Run() error {
|
func (a *App) Run() error {
|
||||||
|
return a.RunWithContext(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithContext 启动应用,支持通过 context 取消来优雅关闭
|
||||||
|
func (a *App) RunWithContext(ctx context.Context) error {
|
||||||
// 启动MCP服务器(如果启用)
|
// 启动MCP服务器(如果启用)
|
||||||
|
var mcpServer *http.Server
|
||||||
if a.config.MCP.Enabled {
|
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() {
|
go func() {
|
||||||
mcpAddr := fmt.Sprintf("%s:%d", a.config.MCP.Host, a.config.MCP.Port)
|
if err := mcpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
a.logger.Info("启动MCP服务器", zap.String("address", mcpAddr))
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/mcp", a.mcpHandlerWithAuth)
|
|
||||||
|
|
||||||
if err := http.ListenAndServe(mcpAddr, mux); err != nil {
|
|
||||||
a.logger.Error("MCP服务器启动失败", zap.Error(err))
|
a.logger.Error("MCP服务器启动失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -492,7 +501,27 @@ func (a *App) Run() error {
|
|||||||
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
|
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
|
||||||
a.logger.Info("启动HTTP服务器", zap.String("address", addr))
|
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 关闭应用
|
// Shutdown 关闭应用
|
||||||
@@ -520,6 +549,13 @@ func (a *App) Shutdown() {
|
|||||||
a.logger.Logger.Warn("关闭知识库数据库连接失败", zap.Error(err))
|
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 根据当前配置启动钉钉/飞书长连接(不先关闭已有连接,仅用于首次启动)
|
// startRobotConnections 根据当前配置启动钉钉/飞书长连接(不先关闭已有连接,仅用于首次启动)
|
||||||
@@ -594,10 +630,16 @@ func setupRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 机器人回调(无需登录,供企业微信/钉钉/飞书服务器调用)
|
// 机器人回调(无需登录,供企业微信/钉钉/飞书服务器调用)
|
||||||
api.GET("/robot/wecom", robotHandler.HandleWecomGET)
|
// 添加速率限制:每个 IP 每分钟最多 60 次请求,防止滥用
|
||||||
api.POST("/robot/wecom", robotHandler.HandleWecomPOST)
|
robotRL := security.NewRateLimiter(60, 1*time.Minute)
|
||||||
api.POST("/robot/dingtalk", robotHandler.HandleDingtalkPOST)
|
robotGroup := api.Group("/robot")
|
||||||
api.POST("/robot/lark", robotHandler.HandleLarkPOST)
|
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 := api.Group("")
|
||||||
protected.Use(security.AuthMiddleware(authManager))
|
protected.Use(security.AuthMiddleware(authManager))
|
||||||
@@ -681,6 +723,7 @@ func setupRoutes(
|
|||||||
// 配置管理
|
// 配置管理
|
||||||
protected.GET("/config", configHandler.GetConfig)
|
protected.GET("/config", configHandler.GetConfig)
|
||||||
protected.GET("/config/tools", configHandler.GetTools)
|
protected.GET("/config/tools", configHandler.GetTools)
|
||||||
|
protected.GET("/config/tools/:name/schema", configHandler.GetToolSchema)
|
||||||
protected.PUT("/config", configHandler.UpdateConfig)
|
protected.PUT("/config", configHandler.UpdateConfig)
|
||||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||||
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
||||||
@@ -881,7 +924,6 @@ func setupRoutes(
|
|||||||
// 角色管理
|
// 角色管理
|
||||||
protected.GET("/roles", roleHandler.GetRoles)
|
protected.GET("/roles", roleHandler.GetRoles)
|
||||||
protected.GET("/roles/:name", roleHandler.GetRole)
|
protected.GET("/roles/:name", roleHandler.GetRole)
|
||||||
protected.GET("/roles/skills/list", roleHandler.GetSkills)
|
|
||||||
protected.POST("/roles", roleHandler.CreateRole)
|
protected.POST("/roles", roleHandler.CreateRole)
|
||||||
protected.PUT("/roles/:name", roleHandler.UpdateRole)
|
protected.PUT("/roles/:name", roleHandler.UpdateRole)
|
||||||
protected.DELETE("/roles/:name", roleHandler.DeleteRole)
|
protected.DELETE("/roles/:name", roleHandler.DeleteRole)
|
||||||
|
|||||||
+50
-30
@@ -120,7 +120,7 @@ type MultiAgentSubConfig struct {
|
|||||||
Name string `yaml:"name" json:"name"`
|
Name string `yaml:"name" json:"name"`
|
||||||
Description string `yaml:"description" json:"description"`
|
Description string `yaml:"description" json:"description"`
|
||||||
Instruction string `yaml:"instruction" json:"instruction"`
|
Instruction string `yaml:"instruction" json:"instruction"`
|
||||||
BindRole string `yaml:"bind_role,omitempty" json:"bind_role,omitempty"` // 可选:关联主配置 roles 中的角色名;未配 role_tools 时沿用该角色的 tools,并把 skills 写入指令提示
|
BindRole string `yaml:"bind_role,omitempty" json:"bind_role,omitempty"` // 可选:关联主配置 roles 中的角色名;未配 role_tools 时沿用该角色的 tools
|
||||||
RoleTools []string `yaml:"role_tools" json:"role_tools"` // 与单 Agent 角色工具相同 key;空表示全部工具(bind_role 可补全 tools)
|
RoleTools []string `yaml:"role_tools" json:"role_tools"` // 与单 Agent 角色工具相同 key;空表示全部工具(bind_role 可补全 tools)
|
||||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||||
Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` // 仅 Markdown:kind=orchestrator 表示 Deep 主代理(与 orchestrator.md 二选一约定)
|
Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` // 仅 Markdown:kind=orchestrator 表示 Deep 主代理(与 orchestrator.md 二选一约定)
|
||||||
@@ -257,28 +257,52 @@ type ExternalMCPConfig struct {
|
|||||||
Servers map[string]ExternalMCPServerConfig `yaml:"servers,omitempty" json:"servers,omitempty"`
|
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 {
|
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"`
|
Command string `yaml:"command,omitempty" json:"command,omitempty"`
|
||||||
Args []string `yaml:"args,omitempty" json:"args,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模式配置
|
// HTTP/SSE 模式配置
|
||||||
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"`
|
||||||
URL string `yaml:"url,omitempty" json:"url,omitempty"`
|
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` // HTTP/SSE 请求头(如 x-api-key)
|
|
||||||
|
// 官方标准字段
|
||||||
|
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"`
|
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||||
Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` // 超时时间(秒)
|
Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` // 连接超时(秒)
|
||||||
ExternalMCPEnable bool `yaml:"external_mcp_enable,omitempty" json:"external_mcp_enable,omitempty"` // 是否启用外部MCP
|
ExternalMCPEnable bool `yaml:"external_mcp_enable,omitempty" json:"external_mcp_enable,omitempty"` // 是否启用
|
||||||
ToolEnabled map[string]bool `yaml:"tool_enabled,omitempty" json:"tool_enabled,omitempty"` // 每个工具的启用状态(工具名称 -> 是否启用)
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
type ToolConfig struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Command string `yaml:"command"`
|
Command string `yaml:"command"`
|
||||||
@@ -369,23 +393,20 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.Security.Tools = tools
|
cfg.Security.Tools = tools
|
||||||
}
|
}
|
||||||
|
|
||||||
// 迁移外部MCP配置:将旧的 enabled/disabled 字段迁移到 external_mcp_enable
|
// 外部 MCP:迁移 + 环境变量展开
|
||||||
if cfg.ExternalMCP.Servers != nil {
|
if cfg.ExternalMCP.Servers != nil {
|
||||||
for name, serverCfg := range cfg.ExternalMCP.Servers {
|
for name, serverCfg := range cfg.ExternalMCP.Servers {
|
||||||
// 如果已经设置了 external_mcp_enable,跳过迁移
|
// 官方 disabled 字段 → ExternalMCPEnable
|
||||||
// 否则从 enabled/disabled 字段迁移
|
|
||||||
// 注意:由于 ExternalMCPEnable 是 bool 类型,零值为 false,所以需要检查是否真的设置了
|
|
||||||
// 这里我们通过检查旧的 enabled/disabled 字段来判断是否需要迁移
|
|
||||||
if serverCfg.Disabled {
|
if serverCfg.Disabled {
|
||||||
// 旧配置使用 disabled,迁移到 external_mcp_enable
|
|
||||||
serverCfg.ExternalMCPEnable = false
|
serverCfg.ExternalMCPEnable = false
|
||||||
} else if serverCfg.Enabled {
|
} else if !serverCfg.ExternalMCPEnable {
|
||||||
// 旧配置使用 enabled,迁移到 external_mcp_enable
|
// 默认启用
|
||||||
serverCfg.ExternalMCPEnable = true
|
|
||||||
} else {
|
|
||||||
// 都没有设置,默认为启用
|
|
||||||
serverCfg.ExternalMCPEnable = true
|
serverCfg.ExternalMCPEnable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 展开所有 ${VAR} / ${VAR:-default} 环境变量引用
|
||||||
|
ExpandConfigEnv(&serverCfg)
|
||||||
|
|
||||||
cfg.ExternalMCP.Servers[name] = serverCfg
|
cfg.ExternalMCP.Servers[name] = serverCfg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -933,8 +954,7 @@ type RoleConfig struct {
|
|||||||
Description string `yaml:"description" json:"description"` // 角色描述
|
Description string `yaml:"description" json:"description"` // 角色描述
|
||||||
UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前)
|
UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前)
|
||||||
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
|
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
|
||||||
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName")
|
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName")
|
||||||
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
|
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
|
||||||
Skills []string `yaml:"skills,omitempty" json:"skills,omitempty"` // 关联的skills列表(skill名称列表,在执行任务前会读取这些skills的内容)
|
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用
|
||||||
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"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"go.uber.org/zap"
|
"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 数据库连接
|
// DB 数据库连接
|
||||||
type DB struct {
|
type DB struct {
|
||||||
*sql.DB
|
*sql.DB
|
||||||
@@ -17,11 +26,13 @@ type DB struct {
|
|||||||
|
|
||||||
// NewDB 创建数据库连接
|
// NewDB 创建数据库连接
|
||||||
func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("打开数据库失败: %w", err)
|
return nil, fmt.Errorf("打开数据库失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureDBPool(db)
|
||||||
|
|
||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -674,11 +685,13 @@ func (db *DB) migrateBatchTaskQueuesTable() error {
|
|||||||
|
|
||||||
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
|
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
|
||||||
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("打开知识库数据库失败: %w", err)
|
return nil, fmt.Errorf("打开知识库数据库失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureDBPool(sqlDB)
|
||||||
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
if err := sqlDB.Ping(); err != nil {
|
||||||
return nil, fmt.Errorf("连接知识库数据库失败: %w", err)
|
return nil, fmt.Errorf("连接知识库数据库失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-37
@@ -494,8 +494,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
|
|
||||||
// 应用角色用户提示词和工具配置
|
// 应用角色用户提示词和工具配置
|
||||||
finalMessage := req.Message
|
finalMessage := req.Message
|
||||||
var roleTools []string // 角色配置的工具列表
|
var roleTools []string // 角色配置的工具列表
|
||||||
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
|
|
||||||
|
|
||||||
// WebShell AI 助手模式:绑定当前连接,仅开放 webshell_* 工具并注入 connection_id
|
// WebShell AI 助手模式:绑定当前连接,仅开放 webshell_* 工具并注入 connection_id
|
||||||
if req.WebShellConnectionID != "" {
|
if req.WebShellConnectionID != "" {
|
||||||
@@ -509,8 +508,19 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
if remark == "" {
|
if remark == "" {
|
||||||
remark = conn.URL
|
remark = conn.URL
|
||||||
}
|
}
|
||||||
finalMessage = fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。\n\n用户请求:%s",
|
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。\n\n用户请求:%s",
|
||||||
conn.ID, remark, conn.ID, req.Message)
|
conn.ID, remark, conn.ID, req.Message)
|
||||||
|
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
|
||||||
|
if req.Role != "" && req.Role != "默认" && h.config.Roles != nil {
|
||||||
|
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
|
||||||
|
finalMessage = role.UserPrompt + "\n\n" + webshellContext
|
||||||
|
h.logger.Info("WebShell + 角色: 应用角色提示词", zap.String("role", req.Role))
|
||||||
|
} else {
|
||||||
|
finalMessage = webshellContext
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalMessage = webshellContext
|
||||||
|
}
|
||||||
roleTools = []string{
|
roleTools = []string{
|
||||||
builtin.ToolWebshellExec,
|
builtin.ToolWebshellExec,
|
||||||
builtin.ToolWebshellFileList,
|
builtin.ToolWebshellFileList,
|
||||||
@@ -520,7 +530,6 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
builtin.ToolListKnowledgeRiskTypes,
|
builtin.ToolListKnowledgeRiskTypes,
|
||||||
builtin.ToolSearchKnowledgeBase,
|
builtin.ToolSearchKnowledgeBase,
|
||||||
}
|
}
|
||||||
roleSkills = nil
|
|
||||||
} else if req.Role != "" && req.Role != "默认" {
|
} else if req.Role != "" && req.Role != "默认" {
|
||||||
if h.config.Roles != nil {
|
if h.config.Roles != nil {
|
||||||
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
|
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
|
||||||
@@ -534,11 +543,6 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
roleTools = role.Tools
|
roleTools = role.Tools
|
||||||
h.logger.Info("使用角色配置的工具列表", zap.String("role", req.Role), zap.Int("toolCount", len(roleTools)))
|
h.logger.Info("使用角色配置的工具列表", zap.String("role", req.Role), zap.Int("toolCount", len(roleTools)))
|
||||||
}
|
}
|
||||||
// 获取角色配置的skills列表(用于在系统提示词中提示AI,但不硬编码内容)
|
|
||||||
if len(role.Skills) > 0 {
|
|
||||||
roleSkills = role.Skills
|
|
||||||
h.logger.Info("角色配置了skills,将在系统提示词中提示AI", zap.String("role", req.Role), zap.Int("skillCount", len(roleSkills)), zap.Strings("skills", roleSkills))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,8 +567,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 执行Agent Loop,传入历史消息和对话ID(使用包含角色提示词的finalMessage和角色工具列表)
|
// 执行Agent Loop,传入历史消息和对话ID(使用包含角色提示词的finalMessage和角色工具列表)
|
||||||
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
|
result, err := h.agent.AgentLoopWithProgress(c.Request.Context(), finalMessage, agentHistoryMessages, conversationID, nil, roleTools)
|
||||||
result, err := h.agent.AgentLoopWithProgress(c.Request.Context(), finalMessage, agentHistoryMessages, conversationID, nil, roleTools, roleSkills)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Agent Loop执行失败", zap.Error(err))
|
h.logger.Error("Agent Loop执行失败", zap.Error(err))
|
||||||
|
|
||||||
@@ -635,14 +638,13 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
|||||||
}
|
}
|
||||||
|
|
||||||
finalMessage := message
|
finalMessage := message
|
||||||
var roleTools, roleSkills []string
|
var roleTools []string
|
||||||
if role != "" && role != "默认" && h.config.Roles != nil {
|
if role != "" && role != "默认" && h.config.Roles != nil {
|
||||||
if r, exists := h.config.Roles[role]; exists && r.Enabled {
|
if r, exists := h.config.Roles[role]; exists && r.Enabled {
|
||||||
if r.UserPrompt != "" {
|
if r.UserPrompt != "" {
|
||||||
finalMessage = r.UserPrompt + "\n\n" + message
|
finalMessage = r.UserPrompt + "\n\n" + message
|
||||||
}
|
}
|
||||||
roleTools = r.Tools
|
roleTools = r.Tools
|
||||||
roleSkills = r.Skills
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,7 +711,7 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
|||||||
return resultMA.Response, conversationID, nil
|
return resultMA.Response, conversationID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.agent.AgentLoopWithProgress(ctx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, roleSkills)
|
result, err := h.agent.AgentLoopWithProgress(ctx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := "执行失败: " + err.Error()
|
errMsg := "执行失败: " + err.Error()
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID != "" {
|
||||||
@@ -1252,7 +1254,6 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
// 应用角色用户提示词和工具配置
|
// 应用角色用户提示词和工具配置
|
||||||
finalMessage := req.Message
|
finalMessage := req.Message
|
||||||
var roleTools []string // 角色配置的工具列表
|
var roleTools []string // 角色配置的工具列表
|
||||||
var roleSkills []string
|
|
||||||
if req.WebShellConnectionID != "" {
|
if req.WebShellConnectionID != "" {
|
||||||
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
|
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
|
||||||
if errConn != nil || conn == nil {
|
if errConn != nil || conn == nil {
|
||||||
@@ -1264,8 +1265,19 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
if remark == "" {
|
if remark == "" {
|
||||||
remark = conn.URL
|
remark = conn.URL
|
||||||
}
|
}
|
||||||
finalMessage = fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。\n\n用户请求:%s",
|
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。\n\n用户请求:%s",
|
||||||
conn.ID, remark, conn.ID, req.Message)
|
conn.ID, remark, conn.ID, req.Message)
|
||||||
|
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
|
||||||
|
if req.Role != "" && req.Role != "默认" && h.config.Roles != nil {
|
||||||
|
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
|
||||||
|
finalMessage = role.UserPrompt + "\n\n" + webshellContext
|
||||||
|
h.logger.Info("WebShell + 角色: 应用角色提示词(流式)", zap.String("role", req.Role))
|
||||||
|
} else {
|
||||||
|
finalMessage = webshellContext
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalMessage = webshellContext
|
||||||
|
}
|
||||||
roleTools = []string{
|
roleTools = []string{
|
||||||
builtin.ToolWebshellExec,
|
builtin.ToolWebshellExec,
|
||||||
builtin.ToolWebshellFileList,
|
builtin.ToolWebshellFileList,
|
||||||
@@ -1292,11 +1304,6 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
// 因为mcps是MCP服务器名称,不是工具列表
|
// 因为mcps是MCP服务器名称,不是工具列表
|
||||||
h.logger.Info("角色配置使用旧的mcps字段,将使用所有工具", zap.String("role", req.Role))
|
h.logger.Info("角色配置使用旧的mcps字段,将使用所有工具", zap.String("role", req.Role))
|
||||||
}
|
}
|
||||||
// 注意:角色 skills 仅在系统提示词中提示;运行时加载请使用 Eino 多代理内置 `skill` 工具
|
|
||||||
if len(role.Skills) > 0 {
|
|
||||||
roleSkills = role.Skills
|
|
||||||
h.logger.Info("角色配置了skills,AI可通过工具按需调用", zap.String("role", req.Role), zap.Int("skillCount", len(role.Skills)), zap.Strings("skills", role.Skills))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1401,12 +1408,11 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
// 执行Agent Loop,传入独立的上下文,确保任务不会因客户端断开而中断(使用包含角色提示词的finalMessage和角色工具列表)
|
// 执行Agent Loop,传入独立的上下文,确保任务不会因客户端断开而中断(使用包含角色提示词的finalMessage和角色工具列表)
|
||||||
sendEvent("progress", "正在分析您的请求...", nil)
|
sendEvent("progress", "正在分析您的请求...", nil)
|
||||||
// 注意:roleSkills 已在上方根据 req.Role 或 WebShell 模式设置
|
|
||||||
stopKeepalive := make(chan struct{})
|
stopKeepalive := make(chan struct{})
|
||||||
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
|
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
|
||||||
defer close(stopKeepalive)
|
defer close(stopKeepalive)
|
||||||
|
|
||||||
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, roleSkills)
|
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Agent Loop执行失败", zap.Error(err))
|
h.logger.Error("Agent Loop执行失败", zap.Error(err))
|
||||||
cause := context.Cause(baseCtx)
|
cause := context.Cause(baseCtx)
|
||||||
@@ -2083,14 +2089,17 @@ func (h *AgentHandler) nextBatchQueueRunAt(cronExpr string, from time.Time) (*ti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) startBatchQueueExecution(queueID string, scheduled bool) (bool, error) {
|
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) {
|
if !h.markBatchQueueRunning(queueID) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
|
||||||
|
if !exists {
|
||||||
|
h.unmarkBatchQueueRunning(queueID)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
if scheduled {
|
if scheduled {
|
||||||
if queue.ScheduleMode != "cron" {
|
if queue.ScheduleMode != "cron" {
|
||||||
h.unmarkBatchQueueRunning(queueID)
|
h.unmarkBatchQueueRunning(queueID)
|
||||||
@@ -2220,8 +2229,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
|
|
||||||
// 应用角色用户提示词和工具配置
|
// 应用角色用户提示词和工具配置
|
||||||
finalMessage := task.Message
|
finalMessage := task.Message
|
||||||
var roleTools []string // 角色配置的工具列表
|
var roleTools []string // 角色配置的工具列表
|
||||||
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
|
|
||||||
if queue.Role != "" && queue.Role != "默认" {
|
if queue.Role != "" && queue.Role != "默认" {
|
||||||
if h.config.Roles != nil {
|
if h.config.Roles != nil {
|
||||||
if role, exists := h.config.Roles[queue.Role]; exists && role.Enabled {
|
if role, exists := h.config.Roles[queue.Role]; exists && role.Enabled {
|
||||||
@@ -2235,11 +2243,6 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
roleTools = role.Tools
|
roleTools = role.Tools
|
||||||
h.logger.Info("使用角色配置的工具列表", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("role", queue.Role), zap.Int("toolCount", len(roleTools)))
|
h.logger.Info("使用角色配置的工具列表", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("role", queue.Role), zap.Int("toolCount", len(roleTools)))
|
||||||
}
|
}
|
||||||
// 获取角色配置的skills列表(用于在系统提示词中提示AI,但不硬编码内容)
|
|
||||||
if len(role.Skills) > 0 {
|
|
||||||
roleSkills = role.Skills
|
|
||||||
h.logger.Info("角色配置了skills,将在系统提示词中提示AI", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("role", queue.Role), zap.Int("skillCount", len(roleSkills)), zap.Strings("skills", roleSkills))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2273,7 +2276,6 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
// 存储取消函数,以便在取消队列时能够取消当前任务
|
// 存储取消函数,以便在取消队列时能够取消当前任务
|
||||||
h.batchTaskManager.SetTaskCancel(queueID, cancel)
|
h.batchTaskManager.SetTaskCancel(queueID, cancel)
|
||||||
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
||||||
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
|
|
||||||
useBatchMulti := false
|
useBatchMulti := false
|
||||||
useEinoSingle := false
|
useEinoSingle := false
|
||||||
batchOrch := "deep"
|
batchOrch := "deep"
|
||||||
@@ -2304,10 +2306,10 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
|||||||
if h.config == nil {
|
if h.config == nil {
|
||||||
runErr = fmt.Errorf("服务器配置未加载")
|
runErr = fmt.Errorf("服务器配置未加载")
|
||||||
} else {
|
} else {
|
||||||
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(ctx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, roleSkills, progressCallback)
|
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(ctx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
result, runErr = h.agent.AgentLoopWithProgress(ctx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools, roleSkills)
|
result, runErr = h.agent.AgentLoopWithProgress(ctx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools)
|
||||||
}
|
}
|
||||||
// 任务执行完成,清理取消函数
|
// 任务执行完成,清理取消函数
|
||||||
h.batchTaskManager.SetTaskCancel(queueID, nil)
|
h.batchTaskManager.SetTaskCancel(queueID, nil)
|
||||||
|
|||||||
@@ -543,16 +543,23 @@ func (m *BatchTaskManager) UpdateTaskStatus(queueID, taskID, status string, resu
|
|||||||
|
|
||||||
// UpdateTaskStatusWithConversationID 更新任务状态(包含conversationId)
|
// UpdateTaskStatusWithConversationID 更新任务状态(包含conversationId)
|
||||||
func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, status string, result, errorMsg, conversationID string) {
|
func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, status string, result, errorMsg, conversationID string) {
|
||||||
var needDBUpdate bool
|
|
||||||
|
|
||||||
// 在锁内只更新内存状态
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
queue, exists := m.queues[queueID]
|
queue, exists := m.queues[queueID]
|
||||||
if !exists {
|
if !exists {
|
||||||
m.mu.Unlock()
|
|
||||||
return
|
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 {
|
for _, task := range queue.Tasks {
|
||||||
if task.ID == taskID {
|
if task.ID == taskID {
|
||||||
task.Status = status
|
task.Status = status
|
||||||
@@ -575,30 +582,27 @@ func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, s
|
|||||||
break
|
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 更新队列状态
|
// UpdateQueueStatus 更新队列状态
|
||||||
func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
|
func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
|
||||||
var needDBUpdate bool
|
|
||||||
|
|
||||||
// 在锁内只更新内存状态
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
queue, exists := m.queues[queueID]
|
queue, exists := m.queues[queueID]
|
||||||
if !exists {
|
if !exists {
|
||||||
m.mu.Unlock()
|
|
||||||
return
|
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
|
queue.Status = status
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if status == BatchQueueStatusRunning && queue.StartedAt == nil {
|
if status == BatchQueueStatusRunning && queue.StartedAt == nil {
|
||||||
@@ -607,16 +611,6 @@ func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
|
|||||||
if status == BatchQueueStatusCompleted || status == BatchQueueStatusCancelled {
|
if status == BatchQueueStatusCompleted || status == BatchQueueStatusCancelled {
|
||||||
queue.CompletedAt = &now
|
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 更新队列调度配置
|
// UpdateQueueSchedule 更新队列调度配置
|
||||||
@@ -756,6 +750,16 @@ func (m *BatchTaskManager) ResetQueueForRerun(queueID string) bool {
|
|||||||
if !exists {
|
if !exists {
|
||||||
return false
|
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.Status = BatchQueueStatusPending
|
||||||
queue.CurrentIndex = 0
|
queue.CurrentIndex = 0
|
||||||
queue.StartedAt = nil
|
queue.StartedAt = nil
|
||||||
@@ -771,12 +775,6 @@ func (m *BatchTaskManager) ResetQueueForRerun(queueID string) bool {
|
|||||||
task.Error = ""
|
task.Error = ""
|
||||||
task.Result = ""
|
task.Result = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.db != nil {
|
|
||||||
if err := m.db.ResetBatchQueueForRerun(queueID); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,7 +868,7 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
|||||||
return fmt.Errorf("队列正在执行或未就绪,无法删除任务")
|
return fmt.Errorf("队列正在执行或未就绪,无法删除任务")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找并删除任务
|
// 查找任务
|
||||||
taskIndex := -1
|
taskIndex := -1
|
||||||
for i, task := range queue.Tasks {
|
for i, task := range queue.Tasks {
|
||||||
if task.ID == taskID {
|
if task.ID == taskID {
|
||||||
@@ -886,18 +884,14 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
|||||||
return fmt.Errorf("任务不存在")
|
return fmt.Errorf("任务不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从内存队列中删除
|
// DB 优先:先从数据库删除,成功后再从内存移除
|
||||||
queue.Tasks = append(queue.Tasks[:taskIndex], queue.Tasks[taskIndex+1:]...)
|
|
||||||
|
|
||||||
// 同步到数据库
|
|
||||||
if m.db != nil {
|
if m.db != nil {
|
||||||
if err := m.db.DeleteBatchTask(queueID, taskID); err != nil {
|
if err := m.db.DeleteBatchTask(queueID, taskID); err != nil {
|
||||||
// 如果数据库删除失败,恢复内存中的任务
|
|
||||||
// 这里需要重新插入,但为了简化,我们只记录错误
|
|
||||||
return fmt.Errorf("删除任务失败: %w", err)
|
return fmt.Errorf("删除任务失败: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queue.Tasks = append(queue.Tasks[:taskIndex], queue.Tasks[taskIndex+1:]...)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,9 +981,7 @@ func (m *BatchTaskManager) SetTaskCancel(queueID string, cancel context.CancelFu
|
|||||||
// PauseQueue 暂停队列
|
// PauseQueue 暂停队列
|
||||||
func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
||||||
var cancelFunc context.CancelFunc
|
var cancelFunc context.CancelFunc
|
||||||
var needDBUpdate bool
|
|
||||||
|
|
||||||
// 在锁内只更新内存状态
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
queue, exists := m.queues[queueID]
|
queue, exists := m.queues[queueID]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -1002,6 +994,16 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
|||||||
return false
|
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
|
queue.Status = BatchQueueStatusPaused
|
||||||
|
|
||||||
// 取消当前正在执行的任务(通过取消context)
|
// 取消当前正在执行的任务(通过取消context)
|
||||||
@@ -1009,22 +1011,13 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
|||||||
cancelFunc = cancel
|
cancelFunc = cancel
|
||||||
delete(m.taskCancels, queueID)
|
delete(m.taskCancels, queueID)
|
||||||
}
|
}
|
||||||
|
|
||||||
needDBUpdate = m.db != nil
|
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
// 释放锁后执行取消回调
|
// 释放锁后执行取消回调(cancel 可能阻塞,不应持锁)
|
||||||
if cancelFunc != nil {
|
if cancelFunc != nil {
|
||||||
cancelFunc()
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,9 +1025,7 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
|
|||||||
func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var cancelFunc context.CancelFunc
|
var cancelFunc context.CancelFunc
|
||||||
var needDBUpdate bool
|
|
||||||
|
|
||||||
// 在锁内只更新内存状态,不做 DB 操作
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
queue, exists := m.queues[queueID]
|
queue, exists := m.queues[queueID]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -1047,6 +1038,22 @@ func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
|||||||
return false
|
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.Status = BatchQueueStatusCancelled
|
||||||
queue.CompletedAt = &now
|
queue.CompletedAt = &now
|
||||||
|
|
||||||
@@ -1063,25 +1070,13 @@ func (m *BatchTaskManager) CancelQueue(queueID string) bool {
|
|||||||
cancelFunc = cancel
|
cancelFunc = cancel
|
||||||
delete(m.taskCancels, queueID)
|
delete(m.taskCancels, queueID)
|
||||||
}
|
}
|
||||||
|
|
||||||
needDBUpdate = m.db != nil
|
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
// 释放锁后执行取消回调
|
// 释放锁后执行取消回调(cancel 可能阻塞,不应持锁)
|
||||||
if cancelFunc != nil {
|
if cancelFunc != nil {
|
||||||
cancelFunc()
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
// --- list ---
|
// --- list ---
|
||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskList,
|
Name: builtin.ToolBatchTaskList,
|
||||||
Description: "列出批量任务队列(精简摘要,省上下文)。含队列元数据、子任务 id/status/截断后的 message、各状态计数。完整子任务(含 result/error/conversationId/时间等)请用 batch_task_get(queue_id)。",
|
Description: "列出批量任务队列(精简摘要,省上下文)。含队列元数据、子任务 id/status/截断后的 message、各状态计数。完整子任务(含 result/error/conversationId/时间等)请用 batch_task_get(queue_id)。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确提及查看/管理批量任务、任务队列时才可调用。不要在用户未要求时自行调用。",
|
||||||
ShortDescription: "列出批量任务队列",
|
ShortDescription: "列出批量任务队列",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -101,7 +101,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
// --- get ---
|
// --- get ---
|
||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskGet,
|
Name: builtin.ToolBatchTaskGet,
|
||||||
Description: "根据 queue_id 获取单个批量任务队列详情(含子任务列表、Cron、调度开关与最近错误信息)。",
|
Description: "根据 queue_id 获取单个批量任务队列详情(含子任务列表、Cron、调度开关与最近错误信息)。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确提及查看/管理批量任务、任务队列时才可调用。不要在用户未要求时自行调用。",
|
||||||
ShortDescription: "获取批量任务队列详情",
|
ShortDescription: "获取批量任务队列详情",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -128,11 +128,13 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
// --- create ---
|
// --- create ---
|
||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskCreate,
|
Name: builtin.ToolBatchTaskCreate,
|
||||||
Description: `【用途】应用内「任务管理 / 批量任务队列」:把多条彼此独立的用户指令登记成一条队列,便于在界面里查看进度、暂停/继续、定时重跑等。这是队列数据与调度入口,不是再开一个“子代理会话”替你探索当前问题。
|
Description: `⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求创建批量任务、任务队列时才可调用。禁止在用户未提及”批量任务””任务队列””定时任务”等关键词时自行调用。如果用户只是让你做某件事,请在当前对话中直接完成,不要自作主张创建任务队列。
|
||||||
|
|
||||||
【何时用】用户明确要批量排队执行、Cron 周期跑同一批指令、或需要与任务管理页面对齐时调用。需要即时追问、强依赖当前对话上下文的分析/编码,应在本对话内直接完成,不要为了“委派”而创建队列。
|
【用途】应用内「任务管理 / 批量任务队列」:把多条彼此独立的用户指令登记成一条队列,便于在界面里查看进度、暂停/继续、定时重跑等。这是队列数据与调度入口,不是再开一个”子代理会话”替你探索当前问题。
|
||||||
|
|
||||||
【参数】tasks(字符串数组)或 tasks_text(多行,每行一条)二选一;每项是一条将来由系统按队列顺序执行的指令文案。agent_mode:single(原生 ReAct,默认)、eino_single(Eino ADK 单代理)、deep / plan_execute / supervisor(需系统启用多代理);兼容旧值 multi(视为 deep)。非“把主对话拆给子代理”。schedule_mode:manual(默认)或 cron;cron 须填 cron_expr(5 段,如 "0 */6 * * *")。
|
【何时用】用户明确要批量排队执行、Cron 周期跑同一批指令、或需要与任务管理页面对齐时调用。需要即时追问、强依赖当前对话上下文的分析/编码,应在本对话内直接完成,不要为了”委派”而创建队列。
|
||||||
|
|
||||||
|
【参数】tasks(字符串数组)或 tasks_text(多行,每行一条)二选一;每项是一条将来由系统按队列顺序执行的指令文案。agent_mode:single(原生 ReAct,默认)、eino_single(Eino ADK 单代理)、deep / plan_execute / supervisor(需系统启用多代理);兼容旧值 multi(视为 deep)。非”把主对话拆给子代理”。schedule_mode:manual(默认)或 cron;cron 须填 cron_expr(5 段,如 “0 */6 * * *”)。
|
||||||
|
|
||||||
【执行】默认创建后为 pending,不自动跑。execute_now=true 可创建后立即跑;否则之后调用 batch_task_start。Cron 自动下一轮需 schedule_enabled 为 true(可用 batch_task_schedule_enabled)。`,
|
【执行】默认创建后为 pending,不自动跑。execute_now=true 可创建后立即跑;否则之后调用 batch_task_start。Cron 自动下一轮需 schedule_enabled 为 true(可用 batch_task_schedule_enabled)。`,
|
||||||
ShortDescription: "任务管理:创建批量任务队列(登记多条指令,可选立即或 Cron)",
|
ShortDescription: "任务管理:创建批量任务队列(登记多条指令,可选立即或 Cron)",
|
||||||
@@ -239,7 +241,9 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskStart,
|
Name: builtin.ToolBatchTaskStart,
|
||||||
Description: `启动或继续执行批量任务队列(pending / paused)。
|
Description: `启动或继续执行批量任务队列(pending / paused)。
|
||||||
与 batch_task_create 配合使用:仅创建队列不会自动执行,需调用本工具才会开始跑子任务。`,
|
与 batch_task_create 配合使用:仅创建队列不会自动执行,需调用本工具才会开始跑子任务。
|
||||||
|
|
||||||
|
⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求启动/继续批量任务时才可调用。不要在用户未要求时自行调用。`,
|
||||||
ShortDescription: "启动/继续批量任务队列(创建后需调用才会执行)",
|
ShortDescription: "启动/继续批量任务队列(创建后需调用才会执行)",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -270,7 +274,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
// --- rerun (reset + start for completed/cancelled queues) ---
|
// --- rerun (reset + start for completed/cancelled queues) ---
|
||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskRerun,
|
Name: builtin.ToolBatchTaskRerun,
|
||||||
Description: "重跑已完成或已取消的批量任务队列。会重置所有子任务状态后重新执行一轮。",
|
Description: "重跑已完成或已取消的批量任务队列。会重置所有子任务状态后重新执行一轮。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求重跑批量任务时才可调用。不要在用户未要求时自行调用。",
|
||||||
ShortDescription: "重跑批量任务队列",
|
ShortDescription: "重跑批量任务队列",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -311,7 +315,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
// --- pause ---
|
// --- pause ---
|
||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskPause,
|
Name: builtin.ToolBatchTaskPause,
|
||||||
Description: "暂停正在运行的批量任务队列(当前子任务会被取消)。",
|
Description: "暂停正在运行的批量任务队列(当前子任务会被取消)。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求暂停批量任务时才可调用。不要在用户未要求时自行调用。",
|
||||||
ShortDescription: "暂停批量任务队列",
|
ShortDescription: "暂停批量任务队列",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -338,7 +342,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
// --- delete queue ---
|
// --- delete queue ---
|
||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskDelete,
|
Name: builtin.ToolBatchTaskDelete,
|
||||||
Description: "删除批量任务队列及其子任务记录。",
|
Description: "删除批量任务队列及其子任务记录。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求删除批量任务队列时才可调用。不要在用户未要求时自行调用。",
|
||||||
ShortDescription: "删除批量任务队列",
|
ShortDescription: "删除批量任务队列",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -365,7 +369,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
// --- update metadata (title/role/agentMode) ---
|
// --- update metadata (title/role/agentMode) ---
|
||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskUpdateMetadata,
|
Name: builtin.ToolBatchTaskUpdateMetadata,
|
||||||
Description: "修改批量任务队列的标题、角色和代理模式。仅在队列非 running 状态下可修改。",
|
Description: "修改批量任务队列的标题、角色和代理模式。仅在队列非 running 状态下可修改。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求修改批量任务队列属性时才可调用。不要在用户未要求时自行调用。",
|
||||||
ShortDescription: "修改批量任务队列标题/角色/代理模式",
|
ShortDescription: "修改批量任务队列标题/角色/代理模式",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -410,7 +414,9 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskUpdateSchedule,
|
Name: builtin.ToolBatchTaskUpdateSchedule,
|
||||||
Description: `修改批量任务队列的调度方式和 Cron 表达式。仅在队列非 running 状态下可修改。
|
Description: `修改批量任务队列的调度方式和 Cron 表达式。仅在队列非 running 状态下可修改。
|
||||||
schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除 Cron 配置。`,
|
schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除 Cron 配置。
|
||||||
|
|
||||||
|
⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求修改批量任务调度配置时才可调用。不要在用户未要求时自行调用。`,
|
||||||
ShortDescription: "修改批量任务调度配置(Cron 表达式)",
|
ShortDescription: "修改批量任务调度配置(Cron 表达式)",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -467,7 +473,9 @@ schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除
|
|||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskScheduleEnabled,
|
Name: builtin.ToolBatchTaskScheduleEnabled,
|
||||||
Description: `设置是否允许 Cron 自动触发该队列。关闭后仍保留 Cron 表达式,仅停止定时自动跑;可用手工「启动」执行。
|
Description: `设置是否允许 Cron 自动触发该队列。关闭后仍保留 Cron 表达式,仅停止定时自动跑;可用手工「启动」执行。
|
||||||
仅对 schedule_mode 为 cron 的队列有意义。`,
|
仅对 schedule_mode 为 cron 的队列有意义。
|
||||||
|
|
||||||
|
⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求开关批量任务自动调度时才可调用。不要在用户未要求时自行调用。`,
|
||||||
ShortDescription: "开关批量任务 Cron 自动调度",
|
ShortDescription: "开关批量任务 Cron 自动调度",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -506,7 +514,7 @@ schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除
|
|||||||
// --- add task ---
|
// --- add task ---
|
||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskAdd,
|
Name: builtin.ToolBatchTaskAdd,
|
||||||
Description: "向处于 pending 状态的队列追加一条子任务。",
|
Description: "向处于 pending 状态的队列追加一条子任务。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求向批量任务队列添加子任务时才可调用。不要在用户未要求时自行调用。",
|
||||||
ShortDescription: "批量队列添加子任务",
|
ShortDescription: "批量队列添加子任务",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -540,7 +548,7 @@ schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除
|
|||||||
// --- update task ---
|
// --- update task ---
|
||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskUpdate,
|
Name: builtin.ToolBatchTaskUpdate,
|
||||||
Description: "修改 pending 队列中仍为 pending 的子任务文案。",
|
Description: "修改 pending 队列中仍为 pending 的子任务文案。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求修改批量子任务内容时才可调用。不要在用户未要求时自行调用。",
|
||||||
ShortDescription: "更新批量子任务内容",
|
ShortDescription: "更新批量子任务内容",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -578,7 +586,7 @@ schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除
|
|||||||
// --- remove task ---
|
// --- remove task ---
|
||||||
reg(mcp.Tool{
|
reg(mcp.Tool{
|
||||||
Name: builtin.ToolBatchTaskRemove,
|
Name: builtin.ToolBatchTaskRemove,
|
||||||
Description: "从 pending 队列中删除仍为 pending 的子任务。",
|
Description: "从 pending 队列中删除仍为 pending 的子任务。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求删除批量子任务时才可调用。不要在用户未要求时自行调用。",
|
||||||
ShortDescription: "删除批量子任务",
|
ShortDescription: "删除批量子任务",
|
||||||
InputSchema: map[string]interface{}{
|
InputSchema: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
+150
-43
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -193,12 +194,13 @@ type GetConfigResponse struct {
|
|||||||
|
|
||||||
// ToolConfigInfo 工具配置信息
|
// ToolConfigInfo 工具配置信息
|
||||||
type ToolConfigInfo struct {
|
type ToolConfigInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
IsExternal bool `json:"is_external,omitempty"` // 是否为外部MCP工具
|
IsExternal bool `json:"is_external,omitempty"` // 是否为外部MCP工具
|
||||||
ExternalMCP string `json:"external_mcp,omitempty"` // 外部MCP名称(如果是外部工具)
|
ExternalMCP string `json:"external_mcp,omitempty"` // 外部MCP名称(如果是外部工具)
|
||||||
RoleEnabled *bool `json:"role_enabled,omitempty"` // 该工具在当前角色中是否启用(nil表示未指定角色或使用所有工具)
|
RoleEnabled *bool `json:"role_enabled,omitempty"` // 该工具在当前角色中是否启用(nil表示未指定角色或使用所有工具)
|
||||||
|
InputSchema map[string]interface{} `json:"input_schema,omitempty"` // 工具参数 JSON Schema(用于前端展示详情)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfig 获取当前配置
|
// GetConfig 获取当前配置
|
||||||
@@ -210,25 +212,25 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
|||||||
// 首先从配置文件获取工具
|
// 首先从配置文件获取工具
|
||||||
configToolMap := make(map[string]bool)
|
configToolMap := make(map[string]bool)
|
||||||
tools := make([]ToolConfigInfo, 0, len(h.config.Security.Tools))
|
tools := make([]ToolConfigInfo, 0, len(h.config.Security.Tools))
|
||||||
|
|
||||||
for _, tool := range h.config.Security.Tools {
|
for _, tool := range h.config.Security.Tools {
|
||||||
configToolMap[tool.Name] = true
|
configToolMap[tool.Name] = true
|
||||||
tools = append(tools, ToolConfigInfo{
|
info := ToolConfigInfo{
|
||||||
Name: tool.Name,
|
Name: tool.Name,
|
||||||
Description: h.pickToolDescription(tool.ShortDescription, tool.Description),
|
Description: h.pickToolDescription(tool.ShortDescription, tool.Description),
|
||||||
Enabled: tool.Enabled,
|
Enabled: tool.Enabled,
|
||||||
IsExternal: false,
|
IsExternal: false,
|
||||||
})
|
}
|
||||||
|
tools = append(tools, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具)
|
// 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具)
|
||||||
if h.mcpServer != nil {
|
if h.mcpServer != nil {
|
||||||
mcpTools := h.mcpServer.GetAllTools()
|
mcpTools := h.mcpServer.GetAllTools()
|
||||||
for _, mcpTool := range mcpTools {
|
for _, mcpTool := range mcpTools {
|
||||||
// 跳过已经在配置文件中的工具(避免重复)
|
|
||||||
if configToolMap[mcpTool.Name] {
|
if configToolMap[mcpTool.Name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 添加直接注册到MCP服务器的工具(如知识检索工具)
|
|
||||||
description := mcpTool.ShortDescription
|
description := mcpTool.ShortDescription
|
||||||
if description == "" {
|
if description == "" {
|
||||||
description = mcpTool.Description
|
description = mcpTool.Description
|
||||||
@@ -239,7 +241,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
|||||||
tools = append(tools, ToolConfigInfo{
|
tools = append(tools, ToolConfigInfo{
|
||||||
Name: mcpTool.Name,
|
Name: mcpTool.Name,
|
||||||
Description: description,
|
Description: description,
|
||||||
Enabled: true, // 直接注册的工具默认启用
|
Enabled: true,
|
||||||
IsExternal: false,
|
IsExternal: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -305,6 +307,8 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||||
|
|
||||||
// 解析分页参数
|
// 解析分页参数
|
||||||
page := 1
|
page := 1
|
||||||
pageSize := 20
|
pageSize := 20
|
||||||
@@ -326,15 +330,26 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
searchTermLower = strings.ToLower(searchTerm)
|
searchTermLower = strings.ToLower(searchTerm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析状态筛选参数: "true" = 仅已启用, "false" = 仅已停用, "" = 全部
|
// 解析状态筛选: tool_filter=on|off(角色弹窗等优先,避免与网关/代理对 enabled 的特殊处理冲突)
|
||||||
enabledFilter := c.Query("enabled")
|
// 兼容旧参数 enabled=true|false
|
||||||
var filterEnabled *bool
|
var filterEnabled *bool
|
||||||
if enabledFilter == "true" {
|
toolFilter := strings.TrimSpace(strings.ToLower(c.Query("tool_filter")))
|
||||||
|
switch toolFilter {
|
||||||
|
case "on", "1", "true", "enabled":
|
||||||
v := true
|
v := true
|
||||||
filterEnabled = &v
|
filterEnabled = &v
|
||||||
} else if enabledFilter == "false" {
|
case "off", "0", "false", "disabled":
|
||||||
v := false
|
v := false
|
||||||
filterEnabled = &v
|
filterEnabled = &v
|
||||||
|
default:
|
||||||
|
enabledFilter := strings.TrimSpace(c.Query("enabled"))
|
||||||
|
if enabledFilter == "true" {
|
||||||
|
v := true
|
||||||
|
filterEnabled = &v
|
||||||
|
} else if enabledFilter == "false" {
|
||||||
|
v := false
|
||||||
|
filterEnabled = &v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析角色参数,用于过滤工具并标注启用状态
|
// 解析角色参数,用于过滤工具并标注启用状态
|
||||||
@@ -428,7 +443,7 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
toolInfo := ToolConfigInfo{
|
toolInfo := ToolConfigInfo{
|
||||||
Name: mcpTool.Name,
|
Name: mcpTool.Name,
|
||||||
Description: description,
|
Description: description,
|
||||||
Enabled: true, // 直接注册的工具默认启用
|
Enabled: true,
|
||||||
IsExternal: false,
|
IsExternal: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +536,17 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
// 注意:这里我们不直接过滤掉工具,而是保留所有工具,但通过 role_enabled 字段标注状态
|
// 注意:这里我们不直接过滤掉工具,而是保留所有工具,但通过 role_enabled 字段标注状态
|
||||||
// 这样前端可以显示所有工具,并标注哪些工具在当前角色中可用
|
// 这样前端可以显示所有工具,并标注哪些工具在当前角色中可用
|
||||||
|
|
||||||
|
// 统一按名称排序后再分页,避免配置文件中顺序导致「全部」与「仅已启用」前几页看起来完全一致
|
||||||
|
sort.SliceStable(allTools, func(i, j int) bool {
|
||||||
|
key := func(t ToolConfigInfo) string {
|
||||||
|
if t.IsExternal && t.ExternalMCP != "" {
|
||||||
|
return strings.ToLower(t.ExternalMCP + "::" + t.Name)
|
||||||
|
}
|
||||||
|
return strings.ToLower(t.Name)
|
||||||
|
}
|
||||||
|
return key(allTools[i]) < key(allTools[j])
|
||||||
|
})
|
||||||
|
|
||||||
total := len(allTools)
|
total := len(allTools)
|
||||||
// 统计已启用的工具数(在角色中的启用工具数)
|
// 统计已启用的工具数(在角色中的启用工具数)
|
||||||
totalEnabled := 0
|
totalEnabled := 0
|
||||||
@@ -1117,32 +1143,7 @@ func (h *ConfigHandler) saveConfig() error {
|
|||||||
updateRobotsConfig(root, h.config.Robots)
|
updateRobotsConfig(root, h.config.Robots)
|
||||||
updateMultiAgentConfig(root, h.config.MultiAgent)
|
updateMultiAgentConfig(root, h.config.MultiAgent)
|
||||||
// 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用)
|
// 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用)
|
||||||
// 读取原始配置以保持向后兼容
|
updateExternalMCPConfig(root, h.config.ExternalMCP)
|
||||||
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)
|
|
||||||
|
|
||||||
if err := writeYAMLDocument(h.configPath, root); err != nil {
|
if err := writeYAMLDocument(h.configPath, root); err != nil {
|
||||||
return fmt.Errorf("保存配置文件失败: %w", err)
|
return fmt.Errorf("保存配置文件失败: %w", err)
|
||||||
@@ -1560,7 +1561,7 @@ func (h *ConfigHandler) calculateExternalToolEnabled(mcpName, toolName string, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 首先检查外部MCP是否启用
|
// 首先检查外部MCP是否启用
|
||||||
if !cfg.ExternalMCPEnable && !(cfg.Enabled && !cfg.Disabled) {
|
if !cfg.ExternalMCPEnable {
|
||||||
return false // MCP未启用,所有工具都禁用
|
return false // MCP未启用,所有工具都禁用
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1599,3 +1600,109 @@ func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
|
|||||||
}
|
}
|
||||||
return description
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
prep.FinalMessage,
|
prep.FinalMessage,
|
||||||
prep.History,
|
prep.History,
|
||||||
prep.RoleTools,
|
prep.RoleTools,
|
||||||
prep.RoleSkills,
|
|
||||||
progressCallback,
|
progressCallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -255,7 +254,6 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
prep.FinalMessage,
|
prep.FinalMessage,
|
||||||
prep.History,
|
prep.History,
|
||||||
prep.RoleTools,
|
prep.RoleTools,
|
||||||
prep.RoleSkills,
|
|
||||||
progressCallback,
|
progressCallback,
|
||||||
)
|
)
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
|
|||||||
@@ -157,36 +157,19 @@ func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
|
|||||||
h.config.ExternalMCP.Servers = make(map[string]config.ExternalMCPServerConfig)
|
h.config.ExternalMCP.Servers = make(map[string]config.ExternalMCPServerConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果用户提供了 disabled 或 enabled 字段,保留它们以保持向后兼容
|
|
||||||
// 同时将值迁移到 external_mcp_enable
|
|
||||||
cfg := req.Config
|
cfg := req.Config
|
||||||
|
|
||||||
if req.Config.Disabled {
|
// 官方 disabled 字段 → ExternalMCPEnable 取反
|
||||||
// 用户设置了 disabled: true
|
if cfg.Disabled {
|
||||||
cfg.ExternalMCPEnable = false
|
cfg.ExternalMCPEnable = false
|
||||||
cfg.Disabled = true
|
} else if !cfg.ExternalMCPEnable {
|
||||||
cfg.Enabled = false
|
// 用户未显式设置 external_mcp_enable,官方配置默认就是启用的
|
||||||
} else if req.Config.Enabled {
|
|
||||||
// 用户设置了 enabled: true
|
|
||||||
cfg.ExternalMCPEnable = true
|
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
|
h.config.ExternalMCP.Servers[name] = cfg
|
||||||
|
|
||||||
// 保存到配置文件
|
// 保存到配置文件
|
||||||
@@ -315,32 +298,25 @@ func (h *ExternalMCPHandler) GetExternalMCPStats(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, stats)
|
c.JSON(http.StatusOK, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateConfig 验证配置
|
// validateConfig 验证配置(同时支持官方 type 字段和旧版 transport 字段)
|
||||||
func (h *ExternalMCPHandler) validateConfig(cfg config.ExternalMCPServerConfig) error {
|
func (h *ExternalMCPHandler) validateConfig(cfg config.ExternalMCPServerConfig) error {
|
||||||
transport := cfg.Transport
|
transport := cfg.GetTransportType()
|
||||||
if transport == "" {
|
if transport == "" {
|
||||||
// 如果没有指定transport,根据是否有command或url判断
|
return fmt.Errorf("需要指定 command(stdio模式)或 url + type(http/sse模式)")
|
||||||
if cfg.Command != "" {
|
|
||||||
transport = "stdio"
|
|
||||||
} else if cfg.URL != "" {
|
|
||||||
transport = "http"
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("需要指定command(stdio模式)或url(http/sse模式)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch transport {
|
switch transport {
|
||||||
case "http":
|
case "http":
|
||||||
if cfg.URL == "" {
|
if cfg.URL == "" {
|
||||||
return fmt.Errorf("HTTP模式需要URL")
|
return fmt.Errorf("HTTP模式需要 url")
|
||||||
}
|
}
|
||||||
case "stdio":
|
case "stdio":
|
||||||
if cfg.Command == "" {
|
if cfg.Command == "" {
|
||||||
return fmt.Errorf("stdio模式需要command")
|
return fmt.Errorf("stdio模式需要 command")
|
||||||
}
|
}
|
||||||
case "sse":
|
case "sse":
|
||||||
if cfg.URL == "" {
|
if cfg.URL == "" {
|
||||||
return fmt.Errorf("SSE模式需要URL")
|
return fmt.Errorf("SSE模式需要 url")
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("不支持的传输模式: %s,支持的模式: http, stdio, sse", transport)
|
return fmt.Errorf("不支持的传输模式: %s,支持的模式: http, stdio, sse", transport)
|
||||||
@@ -351,25 +327,11 @@ func (h *ExternalMCPHandler) validateConfig(cfg config.ExternalMCPServerConfig)
|
|||||||
|
|
||||||
// isEnabled 检查是否启用
|
// isEnabled 检查是否启用
|
||||||
func (h *ExternalMCPHandler) isEnabled(cfg config.ExternalMCPServerConfig) bool {
|
func (h *ExternalMCPHandler) isEnabled(cfg config.ExternalMCPServerConfig) bool {
|
||||||
// 优先使用 ExternalMCPEnable 字段
|
return cfg.ExternalMCPEnable
|
||||||
// 如果没有设置,检查旧的 enabled/disabled 字段(向后兼容)
|
|
||||||
if cfg.ExternalMCPEnable {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 向后兼容:检查旧字段
|
|
||||||
if cfg.Disabled {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if cfg.Enabled {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 都没有设置,默认为启用
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveConfig 保存配置到文件
|
// saveConfig 保存配置到文件
|
||||||
func (h *ExternalMCPHandler) saveConfig() error {
|
func (h *ExternalMCPHandler) saveConfig() error {
|
||||||
// 读取现有配置文件并创建备份
|
|
||||||
data, err := os.ReadFile(h.configPath)
|
data, err := os.ReadFile(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("读取配置文件失败: %w", err)
|
return fmt.Errorf("读取配置文件失败: %w", err)
|
||||||
@@ -384,37 +346,7 @@ func (h *ExternalMCPHandler) saveConfig() error {
|
|||||||
return fmt.Errorf("解析配置文件失败: %w", err)
|
return fmt.Errorf("解析配置文件失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在更新前,读取原始配置中的 enabled/disabled 字段,以便保持向后兼容
|
updateExternalMCPConfig(root, h.config.ExternalMCP)
|
||||||
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)
|
|
||||||
|
|
||||||
if err := writeYAMLDocument(h.configPath, root); err != nil {
|
if err := writeYAMLDocument(h.configPath, root); err != nil {
|
||||||
return fmt.Errorf("保存配置文件失败: %w", err)
|
return fmt.Errorf("保存配置文件失败: %w", err)
|
||||||
@@ -425,7 +357,7 @@ func (h *ExternalMCPHandler) saveConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updateExternalMCPConfig 更新外部MCP配置
|
// 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]
|
root := doc.Content[0]
|
||||||
externalMCPNode := ensureMap(root, "external_mcp")
|
externalMCPNode := ensureMap(root, "external_mcp")
|
||||||
serversNode := ensureMap(externalMCPNode, "servers")
|
serversNode := ensureMap(externalMCPNode, "servers")
|
||||||
@@ -435,32 +367,31 @@ func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, origi
|
|||||||
|
|
||||||
// 添加新的服务器配置
|
// 添加新的服务器配置
|
||||||
for name, serverCfg := range cfg.Servers {
|
for name, serverCfg := range cfg.Servers {
|
||||||
// 添加服务器名称键
|
|
||||||
nameNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name}
|
nameNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name}
|
||||||
serverNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
serverNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||||
serversNode.Content = append(serversNode.Content, nameNode, serverNode)
|
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 != "" {
|
if serverCfg.Command != "" {
|
||||||
setStringInMap(serverNode, "command", serverCfg.Command)
|
setStringInMap(serverNode, "command", serverCfg.Command)
|
||||||
}
|
}
|
||||||
if len(serverCfg.Args) > 0 {
|
if len(serverCfg.Args) > 0 {
|
||||||
setStringArrayInMap(serverNode, "args", serverCfg.Args)
|
setStringArrayInMap(serverNode, "args", serverCfg.Args)
|
||||||
}
|
}
|
||||||
// 保存 env 字段(环境变量)
|
|
||||||
if serverCfg.Env != nil && len(serverCfg.Env) > 0 {
|
if serverCfg.Env != nil && len(serverCfg.Env) > 0 {
|
||||||
envNode := ensureMap(serverNode, "env")
|
envNode := ensureMap(serverNode, "env")
|
||||||
for envKey, envValue := range serverCfg.Env {
|
for envKey, envValue := range serverCfg.Env {
|
||||||
setStringInMap(envNode, envKey, envValue)
|
setStringInMap(envNode, envKey, envValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if serverCfg.Transport != "" {
|
|
||||||
setStringInMap(serverNode, "transport", serverCfg.Transport)
|
|
||||||
}
|
|
||||||
if serverCfg.URL != "" {
|
if serverCfg.URL != "" {
|
||||||
setStringInMap(serverNode, "url", serverCfg.URL)
|
setStringInMap(serverNode, "url", serverCfg.URL)
|
||||||
}
|
}
|
||||||
// 保存 headers 字段(HTTP/SSE 请求头)
|
|
||||||
if serverCfg.Headers != nil && len(serverCfg.Headers) > 0 {
|
if serverCfg.Headers != nil && len(serverCfg.Headers) > 0 {
|
||||||
headersNode := ensureMap(serverNode, "headers")
|
headersNode := ensureMap(serverNode, "headers")
|
||||||
for k, v := range serverCfg.Headers {
|
for k, v := range serverCfg.Headers {
|
||||||
@@ -473,46 +404,32 @@ func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, origi
|
|||||||
if serverCfg.Timeout > 0 {
|
if serverCfg.Timeout > 0 {
|
||||||
setIntInMap(serverNode, "timeout", serverCfg.Timeout)
|
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)
|
setBoolInMap(serverNode, "external_mcp_enable", serverCfg.ExternalMCPEnable)
|
||||||
// 保存 tool_enabled 字段(每个工具的启用状态)
|
|
||||||
if serverCfg.ToolEnabled != nil && len(serverCfg.ToolEnabled) > 0 {
|
if serverCfg.ToolEnabled != nil && len(serverCfg.ToolEnabled) > 0 {
|
||||||
toolEnabledNode := ensureMap(serverNode, "tool_enabled")
|
toolEnabledNode := ensureMap(serverNode, "tool_enabled")
|
||||||
for toolName, enabled := range serverCfg.ToolEnabled {
|
for toolName, enabled := range serverCfg.ToolEnabled {
|
||||||
setBoolInMap(toolEnabledNode, toolName, enabled)
|
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()
|
router, _, configPath := setupTestRouter()
|
||||||
defer cleanupTestConfig(configPath)
|
defer cleanupTestConfig(configPath)
|
||||||
|
|
||||||
// 测试添加stdio模式的配置
|
// 测试添加stdio模式的配置(官方格式:有 command 时 type 可省略)
|
||||||
configJSON := `{
|
configJSON := `{
|
||||||
"command": "python3",
|
"command": "python3",
|
||||||
"args": ["/path/to/script.py", "--server", "http://example.com"],
|
"args": ["/path/to/script.py", "--server", "http://example.com"],
|
||||||
"description": "Test stdio MCP",
|
"description": "Test stdio MCP",
|
||||||
"timeout": 300,
|
"timeout": 300,
|
||||||
"enabled": true
|
"external_mcp_enable": true
|
||||||
}`
|
}`
|
||||||
|
|
||||||
var configObj config.ExternalMCPServerConfig
|
var configObj config.ExternalMCPServerConfig
|
||||||
@@ -115,20 +115,17 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_Stdio(t *testing.T) {
|
|||||||
if response.Config.Timeout != 300 {
|
if response.Config.Timeout != 300 {
|
||||||
t.Errorf("期望timeout为300,实际%d", response.Config.Timeout)
|
t.Errorf("期望timeout为300,实际%d", response.Config.Timeout)
|
||||||
}
|
}
|
||||||
if !response.Config.Enabled {
|
|
||||||
t.Error("期望enabled为true")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExternalMCPHandler_AddOrUpdateExternalMCP_HTTP(t *testing.T) {
|
func TestExternalMCPHandler_AddOrUpdateExternalMCP_HTTP(t *testing.T) {
|
||||||
router, _, configPath := setupTestRouter()
|
router, _, configPath := setupTestRouter()
|
||||||
defer cleanupTestConfig(configPath)
|
defer cleanupTestConfig(configPath)
|
||||||
|
|
||||||
// 测试添加HTTP模式的配置
|
// 测试添加HTTP模式的配置(使用官方 type 字段)
|
||||||
configJSON := `{
|
configJSON := `{
|
||||||
"transport": "http",
|
"type": "http",
|
||||||
"url": "http://127.0.0.1:8081/mcp",
|
"url": "http://127.0.0.1:8081/mcp",
|
||||||
"enabled": true
|
"external_mcp_enable": true
|
||||||
}`
|
}`
|
||||||
|
|
||||||
var configObj config.ExternalMCPServerConfig
|
var configObj config.ExternalMCPServerConfig
|
||||||
@@ -165,15 +162,12 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_HTTP(t *testing.T) {
|
|||||||
t.Fatalf("解析响应失败: %v", err)
|
t.Fatalf("解析响应失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.Config.Transport != "http" {
|
if response.Config.Type != "http" {
|
||||||
t.Errorf("期望transport为http,实际%s", response.Config.Transport)
|
t.Errorf("期望type为http,实际%s", response.Config.Type)
|
||||||
}
|
}
|
||||||
if response.Config.URL != "http://127.0.0.1:8081/mcp" {
|
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)
|
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) {
|
func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) {
|
||||||
@@ -187,22 +181,22 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "缺少command和url",
|
name: "缺少command和url",
|
||||||
configJSON: `{"enabled": true}`,
|
configJSON: `{"external_mcp_enable": true}`,
|
||||||
expectedErr: "需要指定command(stdio模式)或url(http/sse模式)",
|
expectedErr: "需要指定 command(stdio模式)或 url + type(http/sse模式)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "stdio模式缺少command",
|
name: "stdio模式缺少command",
|
||||||
configJSON: `{"args": ["test"], "enabled": true}`,
|
configJSON: `{"args": ["test"], "external_mcp_enable": true}`,
|
||||||
expectedErr: "stdio模式需要command",
|
expectedErr: "stdio模式需要command",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "http模式缺少url",
|
name: "http模式缺少url",
|
||||||
configJSON: `{"transport": "http", "enabled": true}`,
|
configJSON: `{"type": "http", "external_mcp_enable": true}`,
|
||||||
expectedErr: "HTTP模式需要URL",
|
expectedErr: "HTTP模式需要 url",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "无效的transport",
|
name: "无效的type",
|
||||||
configJSON: `{"transport": "invalid", "enabled": true}`,
|
configJSON: `{"type": "invalid", "external_mcp_enable": true}`,
|
||||||
expectedErr: "不支持的传输模式",
|
expectedErr: "不支持的传输模式",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -254,7 +248,7 @@ func TestExternalMCPHandler_DeleteExternalMCP(t *testing.T) {
|
|||||||
// 先添加一个配置
|
// 先添加一个配置
|
||||||
configObj := config.ExternalMCPServerConfig{
|
configObj := config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Enabled: true,
|
ExternalMCPEnable: true,
|
||||||
}
|
}
|
||||||
handler.manager.AddOrUpdateConfig("test-delete", configObj)
|
handler.manager.AddOrUpdateConfig("test-delete", configObj)
|
||||||
|
|
||||||
@@ -283,11 +277,11 @@ func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
|
|||||||
// 添加多个配置
|
// 添加多个配置
|
||||||
handler.manager.AddOrUpdateConfig("test1", config.ExternalMCPServerConfig{
|
handler.manager.AddOrUpdateConfig("test1", config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Enabled: true,
|
ExternalMCPEnable: true,
|
||||||
})
|
})
|
||||||
handler.manager.AddOrUpdateConfig("test2", config.ExternalMCPServerConfig{
|
handler.manager.AddOrUpdateConfig("test2", config.ExternalMCPServerConfig{
|
||||||
URL: "http://127.0.0.1:8081/mcp",
|
URL: "http://127.0.0.1:8081/mcp",
|
||||||
Enabled: false,
|
ExternalMCPEnable: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/external-mcp", nil)
|
req := httptest.NewRequest("GET", "/api/external-mcp", nil)
|
||||||
@@ -326,16 +320,14 @@ func TestExternalMCPHandler_GetExternalMCPStats(t *testing.T) {
|
|||||||
// 添加配置
|
// 添加配置
|
||||||
handler.manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
|
handler.manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Enabled: true,
|
ExternalMCPEnable: true,
|
||||||
})
|
})
|
||||||
handler.manager.AddOrUpdateConfig("enabled2", config.ExternalMCPServerConfig{
|
handler.manager.AddOrUpdateConfig("enabled2", config.ExternalMCPServerConfig{
|
||||||
URL: "http://127.0.0.1:8081/mcp",
|
URL: "http://127.0.0.1:8081/mcp",
|
||||||
Enabled: true,
|
ExternalMCPEnable: true,
|
||||||
})
|
})
|
||||||
handler.manager.AddOrUpdateConfig("disabled1", config.ExternalMCPServerConfig{
|
handler.manager.AddOrUpdateConfig("disabled1", config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Enabled: false,
|
|
||||||
Disabled: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/external-mcp/stats", nil)
|
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{
|
handler.manager.AddOrUpdateConfig("test-start-stop", config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Enabled: false,
|
|
||||||
Disabled: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 测试启动(可能会失败,因为没有真实的服务器)
|
// 测试启动(可能会失败,因为没有真实的服务器)
|
||||||
@@ -427,7 +417,7 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_EmptyName(t *testing.T) {
|
|||||||
|
|
||||||
configObj := config.ExternalMCPServerConfig{
|
configObj := config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Enabled: true,
|
ExternalMCPEnable: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
reqBody := AddOrUpdateExternalMCPRequest{
|
reqBody := AddOrUpdateExternalMCPRequest{
|
||||||
@@ -470,14 +460,14 @@ func TestExternalMCPHandler_UpdateExistingConfig(t *testing.T) {
|
|||||||
// 先添加配置
|
// 先添加配置
|
||||||
config1 := config.ExternalMCPServerConfig{
|
config1 := config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Enabled: true,
|
ExternalMCPEnable: true,
|
||||||
}
|
}
|
||||||
handler.manager.AddOrUpdateConfig("test-update", config1)
|
handler.manager.AddOrUpdateConfig("test-update", config1)
|
||||||
|
|
||||||
// 更新配置
|
// 更新配置
|
||||||
config2 := config.ExternalMCPServerConfig{
|
config2 := config.ExternalMCPServerConfig{
|
||||||
URL: "http://127.0.0.1:8081/mcp",
|
URL: "http://127.0.0.1:8081/mcp",
|
||||||
Enabled: true,
|
ExternalMCPEnable: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
reqBody := AddOrUpdateExternalMCPRequest{
|
reqBody := AddOrUpdateExternalMCPRequest{
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ type multiAgentPrepared struct {
|
|||||||
History []agent.ChatMessage
|
History []agent.ChatMessage
|
||||||
FinalMessage string
|
FinalMessage string
|
||||||
RoleTools []string
|
RoleTools []string
|
||||||
RoleSkills []string
|
|
||||||
AssistantMessageID string
|
AssistantMessageID string
|
||||||
UserMessageID string
|
UserMessageID string
|
||||||
}
|
}
|
||||||
@@ -68,7 +67,6 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
|
|||||||
|
|
||||||
finalMessage := req.Message
|
finalMessage := req.Message
|
||||||
var roleTools []string
|
var roleTools []string
|
||||||
var roleSkills []string
|
|
||||||
if req.WebShellConnectionID != "" {
|
if req.WebShellConnectionID != "" {
|
||||||
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
|
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
|
||||||
if errConn != nil || conn == nil {
|
if errConn != nil || conn == nil {
|
||||||
@@ -79,8 +77,19 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
|
|||||||
if remark == "" {
|
if remark == "" {
|
||||||
remark = conn.URL
|
remark = conn.URL
|
||||||
}
|
}
|
||||||
finalMessage = fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用 Eino 多代理内置 `skill` 工具。\n\n用户请求:%s",
|
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用 Eino 多代理内置 `skill` 工具。\n\n用户请求:%s",
|
||||||
conn.ID, remark, conn.ID, req.Message)
|
conn.ID, remark, conn.ID, req.Message)
|
||||||
|
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
|
||||||
|
if req.Role != "" && req.Role != "默认" && h.config != nil && h.config.Roles != nil {
|
||||||
|
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
|
||||||
|
finalMessage = role.UserPrompt + "\n\n" + webshellContext
|
||||||
|
h.logger.Info("WebShell + 角色: 应用角色提示词(多代理)", zap.String("role", req.Role))
|
||||||
|
} else {
|
||||||
|
finalMessage = webshellContext
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalMessage = webshellContext
|
||||||
|
}
|
||||||
roleTools = []string{
|
roleTools = []string{
|
||||||
builtin.ToolWebshellExec,
|
builtin.ToolWebshellExec,
|
||||||
builtin.ToolWebshellFileList,
|
builtin.ToolWebshellFileList,
|
||||||
@@ -96,7 +105,6 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
|
|||||||
finalMessage = role.UserPrompt + "\n\n" + req.Message
|
finalMessage = role.UserPrompt + "\n\n" + req.Message
|
||||||
}
|
}
|
||||||
roleTools = role.Tools
|
roleTools = role.Tools
|
||||||
roleSkills = role.Skills
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +143,6 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
|
|||||||
History: agentHistoryMessages,
|
History: agentHistoryMessages,
|
||||||
FinalMessage: finalMessage,
|
FinalMessage: finalMessage,
|
||||||
RoleTools: roleTools,
|
RoleTools: roleTools,
|
||||||
RoleSkills: roleSkills,
|
|
||||||
AssistantMessageID: assistantMessageID,
|
AssistantMessageID: assistantMessageID,
|
||||||
UserMessageID: userMessageID,
|
UserMessageID: userMessageID,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
+1611
-39
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ var apiDocI18nTagToKey = map[string]string{
|
|||||||
"角色管理": "roleManagement", "Skills管理": "skillsManagement", "监控": "monitoring",
|
"角色管理": "roleManagement", "Skills管理": "skillsManagement", "监控": "monitoring",
|
||||||
"配置管理": "configManagement", "外部MCP管理": "externalMCPManagement", "攻击链": "attackChain",
|
"配置管理": "configManagement", "外部MCP管理": "externalMCPManagement", "攻击链": "attackChain",
|
||||||
"知识库": "knowledgeBase", "MCP": "mcp",
|
"知识库": "knowledgeBase", "MCP": "mcp",
|
||||||
|
"FOFA信息收集": "fofaRecon", "终端": "terminal", "WebShell管理": "webshellManagement",
|
||||||
|
"对话附件": "chatUploads", "机器人集成": "robotIntegration", "多代理Markdown": "markdownAgents",
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiDocI18nSummaryToKey = map[string]string{
|
var apiDocI18nSummaryToKey = map[string]string{
|
||||||
@@ -45,6 +47,29 @@ var apiDocI18nSummaryToKey = map[string]string{
|
|||||||
"获取检索日志": "getRetrievalLogs", "删除检索日志": "deleteRetrievalLog",
|
"获取检索日志": "getRetrievalLogs", "删除检索日志": "deleteRetrievalLog",
|
||||||
"MCP端点": "mcpEndpoint", "列出所有工具": "listAllTools", "调用工具": "invokeTool", "初始化连接": "initConnection",
|
"MCP端点": "mcpEndpoint", "列出所有工具": "listAllTools", "调用工具": "invokeTool", "初始化连接": "initConnection",
|
||||||
"成功响应": "successResponse", "错误响应": "errorResponse",
|
"成功响应": "successResponse", "错误响应": "errorResponse",
|
||||||
|
// 新增缺失端点
|
||||||
|
"删除对话轮次": "deleteConversationTurn", "获取消息过程详情": "getMessageProcessDetails",
|
||||||
|
"重跑批量任务队列": "rerunBatchQueue", "修改队列元数据": "updateBatchQueueMetadata",
|
||||||
|
"修改队列调度配置": "updateBatchQueueSchedule", "开关Cron自动调度": "setBatchQueueScheduleEnabled",
|
||||||
|
"获取所有分组映射": "getAllGroupMappings",
|
||||||
|
"FOFA搜索": "fofaSearch", "自然语言解析为FOFA语法": "fofaParse",
|
||||||
|
"测试OpenAI API连接": "testOpenAI",
|
||||||
|
"执行终端命令": "terminalRun", "流式执行终端命令": "terminalRunStream", "WebSocket终端": "terminalWS",
|
||||||
|
"列出WebShell连接": "listWebshellConnections", "创建WebShell连接": "createWebshellConnection",
|
||||||
|
"更新WebShell连接": "updateWebshellConnection", "删除WebShell连接": "deleteWebshellConnection",
|
||||||
|
"获取连接状态": "getWebshellConnectionState", "保存连接状态": "saveWebshellConnectionState",
|
||||||
|
"获取AI对话历史": "getWebshellAIHistory", "列出AI对话": "listWebshellAIConversations",
|
||||||
|
"执行WebShell命令": "webshellExec", "WebShell文件操作": "webshellFileOp",
|
||||||
|
"列出附件": "listChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload",
|
||||||
|
"下载附件": "downloadChatUpload", "获取附件文本内容": "getChatUploadContent",
|
||||||
|
"写入附件文本内容": "putChatUploadContent", "创建附件目录": "mkdirChatUpload", "重命名附件": "renameChatUpload",
|
||||||
|
"企业微信回调验证": "wecomCallbackVerify", "企业微信消息回调": "wecomCallbackMessage",
|
||||||
|
"钉钉消息回调": "dingtalkCallback", "飞书消息回调": "larkCallback", "测试机器人消息处理": "testRobot",
|
||||||
|
"列出Markdown代理": "listMarkdownAgents", "创建Markdown代理": "createMarkdownAgent",
|
||||||
|
"获取Markdown代理详情": "getMarkdownAgent", "更新Markdown代理": "updateMarkdownAgent", "删除Markdown代理": "deleteMarkdownAgent",
|
||||||
|
"列出技能包文件": "listSkillPackageFiles", "获取技能包文件内容": "getSkillPackageFile", "写入技能包文件": "putSkillPackageFile",
|
||||||
|
"批量获取工具名称": "batchGetToolNames",
|
||||||
|
"获取知识库统计": "getKnowledgeStats",
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiDocI18nResponseDescToKey = map[string]string{
|
var apiDocI18nResponseDescToKey = map[string]string{
|
||||||
@@ -62,6 +87,16 @@ var apiDocI18nResponseDescToKey = map[string]string{
|
|||||||
"任务不存在": "taskNotFound", "对话或分组不存在": "conversationOrGroupNotFound",
|
"任务不存在": "taskNotFound", "对话或分组不存在": "conversationOrGroupNotFound",
|
||||||
"取消请求已提交": "cancelSubmitted", "未找到正在执行的任务": "noRunningTask",
|
"取消请求已提交": "cancelSubmitted", "未找到正在执行的任务": "noRunningTask",
|
||||||
"消息发送成功,返回AI回复": "messageSent", "流式响应(Server-Sent Events)": "streamResponse",
|
"消息发送成功,返回AI回复": "messageSent", "流式响应(Server-Sent Events)": "streamResponse",
|
||||||
|
// 新增缺失端点响应
|
||||||
|
"参数错误或删除失败": "badRequestOrDeleteFailed",
|
||||||
|
"参数错误": "paramError", "仅已完成或已取消的队列可以重跑": "onlyCompletedOrCancelledCanRerun",
|
||||||
|
"参数错误或队列正在运行中": "badRequestOrQueueRunning", "设置成功": "setSuccess",
|
||||||
|
"搜索成功": "searchSuccess", "解析成功": "parseSuccess", "测试结果": "testResult",
|
||||||
|
"执行完成": "executionDone", "SSE事件流": "sseEventStream", "WebSocket连接已建立": "wsEstablished",
|
||||||
|
"文件下载": "fileDownload", "文件不存在": "fileNotFound", "写入成功": "writeSuccess",
|
||||||
|
"重命名成功": "renameSuccess", "验证成功,返回解密后的echostr": "wecomVerifySuccess",
|
||||||
|
"处理成功": "processSuccess", "代理不存在": "agentNotFound", "保存成功": "saveSuccess",
|
||||||
|
"操作结果": "operationResult", "执行结果": "executionResult", "连接不存在": "connectionNotFound",
|
||||||
}
|
}
|
||||||
|
|
||||||
// enrichSpecWithI18nKeys 在 spec 的每个 operation 上写入 x-i18n-tags、x-i18n-summary,
|
// enrichSpecWithI18nKeys 在 spec 的每个 operation 上写入 x-i18n-tags、x-i18n-summary,
|
||||||
|
|||||||
@@ -18,15 +18,9 @@ import (
|
|||||||
|
|
||||||
// RoleHandler 角色处理器
|
// RoleHandler 角色处理器
|
||||||
type RoleHandler struct {
|
type RoleHandler struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
configPath string
|
configPath string
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
skillsManager SkillsManager // Skills管理器接口(可选)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SkillsManager Skills管理器接口
|
|
||||||
type SkillsManager interface {
|
|
||||||
ListSkills() ([]string, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRoleHandler 创建新的角色处理器
|
// NewRoleHandler 创建新的角色处理器
|
||||||
@@ -38,34 +32,6 @@ func NewRoleHandler(cfg *config.Config, configPath string, logger *zap.Logger) *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSkillsManager 设置Skills管理器
|
|
||||||
func (h *RoleHandler) SetSkillsManager(manager SkillsManager) {
|
|
||||||
h.skillsManager = manager
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSkills 获取所有可用的skills列表
|
|
||||||
func (h *RoleHandler) GetSkills(c *gin.Context) {
|
|
||||||
if h.skillsManager == nil {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"skills": []string{},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
skills, err := h.skillsManager.ListSkills()
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Warn("获取skills列表失败", zap.Error(err))
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"skills": []string{},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"skills": skills,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRoles 获取所有角色
|
// GetRoles 获取所有角色
|
||||||
func (h *RoleHandler) GetRoles(c *gin.Context) {
|
func (h *RoleHandler) GetRoles(c *gin.Context) {
|
||||||
if h.config.Roles == nil {
|
if h.config.Roles == nil {
|
||||||
|
|||||||
@@ -308,31 +308,10 @@ func (h *SkillsHandler) GetSkillBoundRoles(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRolesBoundToSkill 获取绑定指定skill的角色列表(不修改配置)
|
// getRolesBoundToSkill 预留:角色不再配置 skill 绑定,始终返回空列表。
|
||||||
func (h *SkillsHandler) getRolesBoundToSkill(skillName string) []string {
|
func (h *SkillsHandler) getRolesBoundToSkill(skillName string) []string {
|
||||||
if h.config.Roles == nil {
|
_ = skillName
|
||||||
return []string{}
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
boundRoles := make([]string, 0)
|
|
||||||
for roleName, role := range h.config.Roles {
|
|
||||||
// 确保角色名称正确设置
|
|
||||||
if role.Name == "" {
|
|
||||||
role.Name = roleName
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查角色的Skills列表中是否包含该skill
|
|
||||||
if len(role.Skills) > 0 {
|
|
||||||
for _, skill := range role.Skills {
|
|
||||||
if skill == skillName {
|
|
||||||
boundRoles = append(boundRoles, roleName)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return boundRoles
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSkill 创建新 skill(标准 Agent Skills:生成 SKILL.md + YAML front matter)
|
// CreateSkill 创建新 skill(标准 Agent Skills:生成 SKILL.md + YAML front matter)
|
||||||
@@ -600,55 +579,10 @@ func (h *SkillsHandler) ClearSkillStatsByName(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeSkillFromRoles 从所有角色中移除指定的skill绑定
|
// removeSkillFromRoles 预留:角色不再存储 skill 绑定,无操作。
|
||||||
// 返回受影响角色名称列表
|
|
||||||
func (h *SkillsHandler) removeSkillFromRoles(skillName string) []string {
|
func (h *SkillsHandler) removeSkillFromRoles(skillName string) []string {
|
||||||
if h.config.Roles == nil {
|
_ = skillName
|
||||||
return []string{}
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
affectedRoles := make([]string, 0)
|
|
||||||
rolesToUpdate := make(map[string]config.RoleConfig)
|
|
||||||
|
|
||||||
// 遍历所有角色,查找并移除skill绑定
|
|
||||||
for roleName, role := range h.config.Roles {
|
|
||||||
// 确保角色名称正确设置
|
|
||||||
if role.Name == "" {
|
|
||||||
role.Name = roleName
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查角色的Skills列表中是否包含要删除的skill
|
|
||||||
if len(role.Skills) > 0 {
|
|
||||||
updated := false
|
|
||||||
newSkills := make([]string, 0, len(role.Skills))
|
|
||||||
for _, skill := range role.Skills {
|
|
||||||
if skill != skillName {
|
|
||||||
newSkills = append(newSkills, skill)
|
|
||||||
} else {
|
|
||||||
updated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if updated {
|
|
||||||
role.Skills = newSkills
|
|
||||||
rolesToUpdate[roleName] = role
|
|
||||||
affectedRoles = append(affectedRoles, roleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有角色需要更新,保存到文件
|
|
||||||
if len(rolesToUpdate) > 0 {
|
|
||||||
// 更新内存中的配置
|
|
||||||
for roleName, role := range rolesToUpdate {
|
|
||||||
h.config.Roles[roleName] = role
|
|
||||||
}
|
|
||||||
// 保存更新后的角色配置到文件
|
|
||||||
if err := h.saveRolesConfig(); err != nil {
|
|
||||||
h.logger.Error("保存角色配置失败", zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return affectedRoles
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveRolesConfig 保存角色配置到文件(从SkillsHandler调用)
|
// saveRolesConfig 保存角色配置到文件(从SkillsHandler调用)
|
||||||
|
|||||||
@@ -411,7 +411,10 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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)
|
output := string(out)
|
||||||
httpCode := resp.StatusCode
|
httpCode := resp.StatusCode
|
||||||
|
|
||||||
@@ -578,7 +581,10 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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)
|
output := string(out)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, FileOpResponse{
|
c.JSON(http.StatusOK, FileOpResponse{
|
||||||
@@ -633,7 +639,10 @@ func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection,
|
|||||||
return "", false, err.Error()
|
return "", false, err.Error()
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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, ""
|
return string(out), resp.StatusCode == http.StatusOK, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,6 +710,9 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
|
|||||||
return "", false, err.Error()
|
return "", false, err.Error()
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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, ""
|
return string(out), resp.StatusCode == http.StatusOK, ""
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-186
@@ -2,11 +2,9 @@
|
|||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -16,7 +14,6 @@ import (
|
|||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -268,172 +265,6 @@ func mustJSON(v interface{}) []byte {
|
|||||||
return b
|
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
|
// createSDKClient 根据配置创建并连接外部 MCP 客户端(使用官方 SDK),返回实现 ExternalMCPClient 的 *sdkClient
|
||||||
// 若连接失败返回 (nil, error)。ctx 用于连接超时与取消。
|
// 若连接失败返回 (nil, error)。ctx 用于连接超时与取消。
|
||||||
func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) (ExternalMCPClient, error) {
|
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
|
timeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
transport := serverCfg.Transport
|
transport := serverCfg.GetTransportType()
|
||||||
if transport == "" {
|
if transport == "" {
|
||||||
if serverCfg.Command != "" {
|
return nil, fmt.Errorf("配置缺少 command 或 url,且未指定 type/transport")
|
||||||
transport = "stdio"
|
}
|
||||||
} else if serverCfg.URL != "" {
|
|
||||||
transport = "http"
|
// 构造 ClientOptions:KeepAlive 心跳
|
||||||
} else {
|
var clientOpts *mcp.ClientOptions
|
||||||
return nil, fmt.Errorf("配置缺少 command 或 url")
|
if serverCfg.KeepAlive > 0 {
|
||||||
|
clientOpts = &mcp.ClientOptions{
|
||||||
|
KeepAlive: time.Duration(serverCfg.KeepAlive) * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client := mcp.NewClient(&mcp.Implementation{
|
client := mcp.NewClient(&mcp.Implementation{
|
||||||
Name: clientName,
|
Name: clientName,
|
||||||
Version: clientVersion,
|
Version: clientVersion,
|
||||||
}, nil)
|
}, clientOpts)
|
||||||
|
|
||||||
var t mcp.Transport
|
var t mcp.Transport
|
||||||
switch transport {
|
switch transport {
|
||||||
@@ -470,12 +303,18 @@ func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConf
|
|||||||
if len(serverCfg.Env) > 0 {
|
if len(serverCfg.Env) > 0 {
|
||||||
cmd.Env = append(cmd.Env, envMapToSlice(serverCfg.Env)...)
|
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":
|
case "sse":
|
||||||
if serverCfg.URL == "" {
|
if serverCfg.URL == "" {
|
||||||
return nil, fmt.Errorf("sse 模式需要配置 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{
|
t = &mcp.SSEClientTransport{
|
||||||
Endpoint: serverCfg.URL,
|
Endpoint: serverCfg.URL,
|
||||||
HTTPClient: httpClient,
|
HTTPClient: httpClient,
|
||||||
@@ -485,18 +324,16 @@ func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConf
|
|||||||
return nil, fmt.Errorf("http 模式需要配置 url")
|
return nil, fmt.Errorf("http 模式需要配置 url")
|
||||||
}
|
}
|
||||||
httpClient := httpClientWithTimeoutAndHeaders(timeout, serverCfg.Headers)
|
httpClient := httpClientWithTimeoutAndHeaders(timeout, serverCfg.Headers)
|
||||||
t = &mcp.StreamableClientTransport{
|
st := &mcp.StreamableClientTransport{
|
||||||
Endpoint: serverCfg.URL,
|
Endpoint: serverCfg.URL,
|
||||||
HTTPClient: httpClient,
|
HTTPClient: httpClient,
|
||||||
}
|
}
|
||||||
case "simple_http":
|
if serverCfg.MaxRetries > 0 {
|
||||||
// 简单 JSON-RPC HTTP:每次请求一次 POST、响应在 body。用于自建 MCP 或兼容旧端点(如 http://127.0.0.1:8081/mcp)
|
st.MaxRetries = serverCfg.MaxRetries
|
||||||
if serverCfg.URL == "" {
|
|
||||||
return nil, fmt.Errorf("simple_http 模式需要配置 url")
|
|
||||||
}
|
}
|
||||||
return newSimpleHTTPClient(ctx, serverCfg.URL, timeout, serverCfg.Headers, logger)
|
t = st
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("不支持的传输模式: %s", transport)
|
return nil, fmt.Errorf("不支持的传输模式: %s(支持: stdio, sse, http)", transport)
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := client.Connect(ctx, t, nil)
|
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 {
|
type headerRoundTripper struct {
|
||||||
headers map[string]string
|
headers map[string]string
|
||||||
base http.RoundTripper
|
base http.RoundTripper
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
@@ -29,6 +30,7 @@ type ExternalMCPManager struct {
|
|||||||
toolCacheMu sync.RWMutex // 工具列表缓存的锁
|
toolCacheMu sync.RWMutex // 工具列表缓存的锁
|
||||||
stopRefresh chan struct{} // 停止后台刷新的信号
|
stopRefresh chan struct{} // 停止后台刷新的信号
|
||||||
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
|
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
|
||||||
|
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,7 +723,13 @@ func (m *ExternalMCPManager) GetToolCounts() map[string]int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// refreshToolCounts 刷新工具数量缓存(后台异步执行)
|
// refreshToolCounts 刷新工具数量缓存(后台异步执行)
|
||||||
|
// 使用 atomic flag 防止并发堆积:如果上一次刷新尚未完成,本次触发直接跳过。
|
||||||
func (m *ExternalMCPManager) refreshToolCounts() {
|
func (m *ExternalMCPManager) refreshToolCounts() {
|
||||||
|
if !m.refreshing.CompareAndSwap(false, true) {
|
||||||
|
return // 上一次刷新尚未完成,跳过
|
||||||
|
}
|
||||||
|
defer m.refreshing.Store(false)
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
clients := make(map[string]ExternalMCPClient)
|
clients := make(map[string]ExternalMCPClient)
|
||||||
for k, v := range m.clients {
|
for k, v := range m.clients {
|
||||||
@@ -874,16 +882,7 @@ func (m *ExternalMCPManager) triggerToolCountRefresh() {
|
|||||||
|
|
||||||
// createClient 创建客户端(不连接)。统一使用官方 MCP Go SDK 的 lazy 客户端,连接在 Initialize 时完成。
|
// createClient 创建客户端(不连接)。统一使用官方 MCP Go SDK 的 lazy 客户端,连接在 Initialize 时完成。
|
||||||
func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConfig) ExternalMCPClient {
|
func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConfig) ExternalMCPClient {
|
||||||
transport := serverCfg.Transport
|
transport := serverCfg.GetTransportType()
|
||||||
if transport == "" {
|
|
||||||
if serverCfg.Command != "" {
|
|
||||||
transport = "stdio"
|
|
||||||
} else if serverCfg.URL != "" {
|
|
||||||
transport = "http"
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch transport {
|
switch transport {
|
||||||
case "http":
|
case "http":
|
||||||
@@ -891,12 +890,6 @@ func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConf
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return newLazySDKClient(serverCfg, m.logger)
|
return newLazySDKClient(serverCfg, m.logger)
|
||||||
case "simple_http":
|
|
||||||
// 简单 HTTP(一次 POST 一次响应),用于自建 MCP 等
|
|
||||||
if serverCfg.URL == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return newLazySDKClient(serverCfg, m.logger)
|
|
||||||
case "stdio":
|
case "stdio":
|
||||||
if serverCfg.Command == "" {
|
if serverCfg.Command == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -908,7 +901,11 @@ func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConf
|
|||||||
}
|
}
|
||||||
return newLazySDKClient(serverCfg, m.logger)
|
return newLazySDKClient(serverCfg, m.logger)
|
||||||
default:
|
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 检查是否启用
|
// isEnabled 检查是否启用
|
||||||
func (m *ExternalMCPManager) isEnabled(cfg config.ExternalMCPServerConfig) bool {
|
func (m *ExternalMCPManager) isEnabled(cfg config.ExternalMCPServerConfig) bool {
|
||||||
// 优先使用 ExternalMCPEnable 字段
|
return cfg.ExternalMCPEnable
|
||||||
// 如果没有设置,检查旧的 enabled/disabled 字段(向后兼容)
|
|
||||||
if cfg.ExternalMCPEnable {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 向后兼容:检查旧字段
|
|
||||||
if cfg.Disabled {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if cfg.Enabled {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// 都没有设置,默认为启用
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// findSubstring 查找子字符串(简单实现)
|
// findSubstring 查找子字符串(简单实现)
|
||||||
@@ -1044,15 +1028,7 @@ func (m *ExternalMCPManager) StartAllEnabled() {
|
|||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据传输模式添加相应的信息
|
transport := c.GetTransportType()
|
||||||
transport := c.Transport
|
|
||||||
if transport == "" {
|
|
||||||
if c.Command != "" {
|
|
||||||
transport = "stdio"
|
|
||||||
} else if c.URL != "" {
|
|
||||||
transport = "http"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if transport == "http" && c.URL != "" {
|
if transport == "http" && c.URL != "" {
|
||||||
fields = append(fields, zap.String("url", c.URL))
|
fields = append(fields, zap.String("url", c.URL))
|
||||||
|
|||||||
@@ -16,12 +16,11 @@ func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
|
|||||||
|
|
||||||
// 测试添加stdio配置
|
// 测试添加stdio配置
|
||||||
stdioCfg := config.ExternalMCPServerConfig{
|
stdioCfg := config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Args: []string{"/path/to/script.py"},
|
Args: []string{"/path/to/script.py"},
|
||||||
Transport: "stdio",
|
Description: "Test stdio MCP",
|
||||||
Description: "Test stdio MCP",
|
Timeout: 30,
|
||||||
Timeout: 30,
|
ExternalMCPEnable: true,
|
||||||
Enabled: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := manager.AddOrUpdateConfig("test-stdio", stdioCfg)
|
err := manager.AddOrUpdateConfig("test-stdio", stdioCfg)
|
||||||
@@ -31,11 +30,11 @@ func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
|
|||||||
|
|
||||||
// 测试添加HTTP配置
|
// 测试添加HTTP配置
|
||||||
httpCfg := config.ExternalMCPServerConfig{
|
httpCfg := config.ExternalMCPServerConfig{
|
||||||
Transport: "http",
|
Type: "http",
|
||||||
URL: "http://127.0.0.1:8081/mcp",
|
URL: "http://127.0.0.1:8081/mcp",
|
||||||
Description: "Test HTTP MCP",
|
Description: "Test HTTP MCP",
|
||||||
Timeout: 30,
|
Timeout: 30,
|
||||||
Enabled: false,
|
ExternalMCPEnable: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = manager.AddOrUpdateConfig("test-http", httpCfg)
|
err = manager.AddOrUpdateConfig("test-http", httpCfg)
|
||||||
@@ -64,8 +63,7 @@ func TestExternalMCPManager_RemoveConfig(t *testing.T) {
|
|||||||
|
|
||||||
cfg := config.ExternalMCPServerConfig{
|
cfg := config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Transport: "stdio",
|
ExternalMCPEnable: false,
|
||||||
Enabled: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.AddOrUpdateConfig("test-remove", cfg)
|
manager.AddOrUpdateConfig("test-remove", cfg)
|
||||||
@@ -89,18 +87,17 @@ func TestExternalMCPManager_GetStats(t *testing.T) {
|
|||||||
// 添加多个配置
|
// 添加多个配置
|
||||||
manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
|
manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Enabled: true,
|
ExternalMCPEnable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
manager.AddOrUpdateConfig("enabled2", config.ExternalMCPServerConfig{
|
manager.AddOrUpdateConfig("enabled2", config.ExternalMCPServerConfig{
|
||||||
URL: "http://127.0.0.1:8081/mcp",
|
URL: "http://127.0.0.1:8081/mcp",
|
||||||
Enabled: true,
|
ExternalMCPEnable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
manager.AddOrUpdateConfig("disabled1", config.ExternalMCPServerConfig{
|
manager.AddOrUpdateConfig("disabled1", config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Enabled: false,
|
ExternalMCPEnable: false,
|
||||||
Disabled: true, // 明确设置为禁用
|
|
||||||
})
|
})
|
||||||
|
|
||||||
stats := manager.GetStats()
|
stats := manager.GetStats()
|
||||||
@@ -126,11 +123,11 @@ func TestExternalMCPManager_LoadConfigs(t *testing.T) {
|
|||||||
Servers: map[string]config.ExternalMCPServerConfig{
|
Servers: map[string]config.ExternalMCPServerConfig{
|
||||||
"loaded1": {
|
"loaded1": {
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Enabled: true,
|
ExternalMCPEnable: true,
|
||||||
},
|
},
|
||||||
"loaded2": {
|
"loaded2": {
|
||||||
URL: "http://127.0.0.1:8081/mcp",
|
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()
|
logger := zap.NewNop()
|
||||||
// 使用不存在的 HTTP 地址,Initialize 应失败
|
// 使用不存在的 HTTP 地址,Initialize 应失败
|
||||||
cfg := config.ExternalMCPServerConfig{
|
cfg := config.ExternalMCPServerConfig{
|
||||||
Transport: "http",
|
Type: "http",
|
||||||
URL: "http://127.0.0.1:19999/nonexistent",
|
URL: "http://127.0.0.1:19999/nonexistent",
|
||||||
Timeout: 2,
|
Timeout: 2,
|
||||||
}
|
}
|
||||||
@@ -180,8 +177,7 @@ func TestExternalMCPManager_StartStopClient(t *testing.T) {
|
|||||||
// 添加一个禁用的配置
|
// 添加一个禁用的配置
|
||||||
cfg := config.ExternalMCPServerConfig{
|
cfg := config.ExternalMCPServerConfig{
|
||||||
Command: "python3",
|
Command: "python3",
|
||||||
Transport: "stdio",
|
ExternalMCPEnable: false,
|
||||||
Enabled: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.AddOrUpdateConfig("test-start-stop", cfg)
|
manager.AddOrUpdateConfig("test-start-stop", cfg)
|
||||||
@@ -200,7 +196,7 @@ func TestExternalMCPManager_StartStopClient(t *testing.T) {
|
|||||||
|
|
||||||
// 验证配置已更新为禁用
|
// 验证配置已更新为禁用
|
||||||
configs := manager.GetConfigs()
|
configs := manager.GetConfigs()
|
||||||
if configs["test-start-stop"].Enabled {
|
if configs["test-start-stop"].ExternalMCPEnable {
|
||||||
t.Error("配置应该已被禁用")
|
t.Error("配置应该已被禁用")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,21 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
mcpIDsMu := args.McpIDsMu
|
mcpIDsMu := args.McpIDsMu
|
||||||
mcpIDs := args.McpIDs
|
mcpIDs := args.McpIDs
|
||||||
|
|
||||||
|
// panic recovery:防止 Eino 框架内部 panic 导致整个 goroutine 崩溃、连接无法正常关闭。
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Error("eino runner panic recovered", zap.Any("recover", r), zap.Stack("stack"))
|
||||||
|
}
|
||||||
|
if progress != nil {
|
||||||
|
progress("error", fmt.Sprintf("Internal error: %v / 内部错误: %v", r, r), map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var lastRunMsgs []adk.Message
|
var lastRunMsgs []adk.Message
|
||||||
var lastAssistant string
|
var lastAssistant string
|
||||||
var lastPlanExecuteExecutor string
|
var lastPlanExecuteExecutor string
|
||||||
@@ -86,7 +101,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
|
|
||||||
emptyHint := strings.TrimSpace(args.EmptyResponseMessage)
|
emptyHint := strings.TrimSpace(args.EmptyResponseMessage)
|
||||||
if emptyHint == "" {
|
if emptyHint == "" {
|
||||||
emptyHint = "(Eino 会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)"
|
emptyHint = "(Eino session completed but no assistant text was captured. Check process details or logs.) " +
|
||||||
|
"(Eino 会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)"
|
||||||
}
|
}
|
||||||
|
|
||||||
attemptLoop:
|
attemptLoop:
|
||||||
@@ -191,6 +207,20 @@ attemptLoop:
|
|||||||
iter := runner.Run(ctx, msgs)
|
iter := runner.Run(ctx, msgs)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
// 检测 context 取消(用户关闭浏览器、请求超时等),flush pending 工具状态避免 UI 卡在 "执行中"。
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
flushAllPendingAsFailed(ctx.Err())
|
||||||
|
if progress != nil {
|
||||||
|
progress("error", "Request cancelled / 请求已取消", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
ev, ok := iter.Next()
|
ev, ok := iter.Next()
|
||||||
if !ok {
|
if !ok {
|
||||||
lastRunMsgs = msgs
|
lastRunMsgs = msgs
|
||||||
@@ -200,54 +230,61 @@ attemptLoop:
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ev.Err != nil {
|
if ev.Err != nil {
|
||||||
canRetry := attempt+1 < maxToolCallRecoveryAttempts
|
// context.Canceled 是唯一应当直接终止编排的错误(用户关闭页面、主动停止等)。
|
||||||
|
if errors.Is(ev.Err, context.Canceled) {
|
||||||
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))
|
|
||||||
}
|
|
||||||
flushAllPendingAsFailed(ev.Err)
|
flushAllPendingAsFailed(ev.Err)
|
||||||
retryHints = append(retryHints, toolExecutionRetryHint())
|
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress("eino_recovery", toolExecutionRecoveryTimelineMessage(attempt), map[string]interface{}{
|
progress("error", ev.Err.Error(), map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "eino",
|
"source": "eino",
|
||||||
"einoRetry": attempt,
|
|
||||||
"runIndex": attempt + 1,
|
|
||||||
"maxRuns": maxToolCallRecoveryAttempts,
|
|
||||||
"reason": "tool_execution_error",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
continue attemptLoop
|
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)
|
flushAllPendingAsFailed(ev.Err)
|
||||||
|
retryHints = append(retryHints, hint)
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress("error", ev.Err.Error(), map[string]interface{}{
|
progress("eino_recovery", timelineMsg, map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "eino",
|
"source": "eino",
|
||||||
|
"einoRetry": attempt,
|
||||||
|
"runIndex": attempt + 1,
|
||||||
|
"maxRuns": maxToolCallRecoveryAttempts,
|
||||||
|
"reason": reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return nil, ev.Err
|
continue attemptLoop
|
||||||
}
|
}
|
||||||
if ev.AgentName != "" && progress != nil {
|
if ev.AgentName != "" && progress != nil {
|
||||||
iterEinoAgent := orchestratorName
|
iterEinoAgent := orchestratorName
|
||||||
@@ -308,7 +345,10 @@ attemptLoop:
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Warn("eino stream recv", zap.Error(rerr))
|
logger.Warn("eino stream recv error, flushing incomplete stream",
|
||||||
|
zap.Error(rerr),
|
||||||
|
zap.String("agent", ev.AgentName),
|
||||||
|
zap.Int("toolFragments", len(toolStreamFragments)))
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -531,6 +571,11 @@ attemptLoop:
|
|||||||
}
|
}
|
||||||
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
||||||
cleaned = dedupeParagraphsByLineFingerprint(cleaned, 100)
|
cleaned = dedupeParagraphsByLineFingerprint(cleaned, 100)
|
||||||
|
// 防止超长响应导致 JSON 序列化慢或 OOM(多代理拼接大量工具输出时可能触发)。
|
||||||
|
const maxResponseRunes = 100000
|
||||||
|
if rs := []rune(cleaned); len(rs) > maxResponseRunes {
|
||||||
|
cleaned = string(rs[:maxResponseRunes]) + "\n\n... (response truncated / 响应已截断)"
|
||||||
|
}
|
||||||
out := &RunResult{
|
out := &RunResult{
|
||||||
Response: cleaned,
|
Response: cleaned,
|
||||||
MCPExecutionIDs: ids,
|
MCPExecutionIDs: ids,
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ type PlanExecuteRootArgs struct {
|
|||||||
// AppCfg / Logger 非空时为 Executor 挂载与 Deep/Supervisor 一致的 Eino summarization 中间件。
|
// AppCfg / Logger 非空时为 Executor 挂载与 Deep/Supervisor 一致的 Eino summarization 中间件。
|
||||||
AppCfg *config.Config
|
AppCfg *config.Config
|
||||||
Logger *zap.Logger
|
Logger *zap.Logger
|
||||||
|
// ExecPreMiddlewares 是由 prependEinoMiddlewares 构建的前置中间件(patchtoolcalls, reduction, toolsearch, plantask),
|
||||||
|
// 与 Deep/Supervisor 主代理的 mainOrchestratorPre 一致。
|
||||||
|
ExecPreMiddlewares []adk.ChatModelAgentMiddleware
|
||||||
|
// SkillMiddleware 是 Eino 官方 skill 渐进式披露中间件(可选)。
|
||||||
|
SkillMiddleware adk.ChatModelAgentMiddleware
|
||||||
|
// FilesystemMiddleware 是 Eino filesystem 中间件,当 eino_skills.filesystem_tools 启用时提供本机文件读写与 Shell 能力(可选)。
|
||||||
|
FilesystemMiddleware adk.ChatModelAgentMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPlanExecuteRoot 返回 plan → execute → replan 预置编排根节点(与 Deep / Supervisor 并列)。
|
// NewPlanExecuteRoot 返回 plan → execute → replan 预置编排根节点(与 Deep / Supervisor 并列)。
|
||||||
@@ -40,20 +47,39 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("plan_execute: 主模型需实现 ToolCallingChatModel")
|
return nil, fmt.Errorf("plan_execute: 主模型需实现 ToolCallingChatModel")
|
||||||
}
|
}
|
||||||
planner, err := planexecute.NewPlanner(ctx, &planexecute.PlannerConfig{
|
plannerCfg := &planexecute.PlannerConfig{
|
||||||
ToolCallingChatModel: tcm,
|
ToolCallingChatModel: tcm,
|
||||||
})
|
}
|
||||||
|
if fn := planExecutePlannerGenInput(a.OrchInstruction); fn != nil {
|
||||||
|
plannerCfg.GenInputFn = fn
|
||||||
|
}
|
||||||
|
planner, err := planexecute.NewPlanner(ctx, plannerCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("plan_execute planner: %w", err)
|
return nil, fmt.Errorf("plan_execute planner: %w", err)
|
||||||
}
|
}
|
||||||
replanner, err := planexecute.NewReplanner(ctx, &planexecute.ReplannerConfig{
|
replanner, err := planexecute.NewReplanner(ctx, &planexecute.ReplannerConfig{
|
||||||
ChatModel: tcm,
|
ChatModel: tcm,
|
||||||
GenInputFn: planExecuteReplannerGenInput,
|
GenInputFn: planExecuteReplannerGenInput(a.OrchInstruction),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("plan_execute replanner: %w", err)
|
return nil, fmt.Errorf("plan_execute replanner: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 组装 executor handler 栈,顺序与 Deep/Supervisor 主代理一致(outermost first)。
|
||||||
var execHandlers []adk.ChatModelAgentMiddleware
|
var execHandlers []adk.ChatModelAgentMiddleware
|
||||||
|
// 1. patchtoolcalls, reduction, toolsearch, plantask(来自 prependEinoMiddlewares)
|
||||||
|
if len(a.ExecPreMiddlewares) > 0 {
|
||||||
|
execHandlers = append(execHandlers, a.ExecPreMiddlewares...)
|
||||||
|
}
|
||||||
|
// 2. filesystem 中间件(可选)
|
||||||
|
if a.FilesystemMiddleware != nil {
|
||||||
|
execHandlers = append(execHandlers, a.FilesystemMiddleware)
|
||||||
|
}
|
||||||
|
// 3. skill 中间件(可选)
|
||||||
|
if a.SkillMiddleware != nil {
|
||||||
|
execHandlers = append(execHandlers, a.SkillMiddleware)
|
||||||
|
}
|
||||||
|
// 4. summarization(最后,与 Deep/Supervisor 一致)
|
||||||
if a.AppCfg != nil {
|
if a.AppCfg != nil {
|
||||||
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.Logger)
|
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.Logger)
|
||||||
if sumErr != nil {
|
if sumErr != nil {
|
||||||
@@ -82,6 +108,21 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// planExecutePlannerGenInput 将 orchestrator instruction 作为 SystemMessage 注入 planner 输入。
|
||||||
|
// 返回 nil 时 Eino 使用内置默认 planner prompt。
|
||||||
|
func planExecutePlannerGenInput(orchInstruction string) planexecute.GenPlannerModelInputFn {
|
||||||
|
oi := strings.TrimSpace(orchInstruction)
|
||||||
|
if oi == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func(ctx context.Context, userInput []adk.Message) ([]adk.Message, error) {
|
||||||
|
msgs := make([]adk.Message, 0, 1+len(userInput))
|
||||||
|
msgs = append(msgs, schema.SystemMessage(oi))
|
||||||
|
msgs = append(msgs, userInput...)
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func planExecuteExecutorGenInput(orchInstruction string) planexecute.GenModelInputFn {
|
func planExecuteExecutorGenInput(orchInstruction string) planexecute.GenModelInputFn {
|
||||||
oi := strings.TrimSpace(orchInstruction)
|
oi := strings.TrimSpace(orchInstruction)
|
||||||
return func(ctx context.Context, in *planexecute.ExecutionContext) ([]adk.Message, error) {
|
return func(ctx context.Context, in *planexecute.ExecutionContext) ([]adk.Message, error) {
|
||||||
@@ -123,19 +164,30 @@ func planExecuteFormatExecutedSteps(results []planexecute.ExecutedStep) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// planExecuteReplannerGenInput 与 Eino 默认 Replanner 输入一致,但 executed_steps 经 cap 后再写入 prompt。
|
// planExecuteReplannerGenInput 与 Eino 默认 Replanner 输入一致,但 executed_steps 经 cap 后再写入 prompt,
|
||||||
func planExecuteReplannerGenInput(ctx context.Context, in *planexecute.ExecutionContext) ([]adk.Message, error) {
|
// 且在 orchInstruction 非空时 prepend SystemMessage 使 replanner 也能接收全局指令。
|
||||||
planContent, err := in.Plan.MarshalJSON()
|
func planExecuteReplannerGenInput(orchInstruction string) planexecute.GenModelInputFn {
|
||||||
if err != nil {
|
oi := strings.TrimSpace(orchInstruction)
|
||||||
return nil, err
|
return func(ctx context.Context, in *planexecute.ExecutionContext) ([]adk.Message, error) {
|
||||||
|
planContent, err := in.Plan.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgs, err := planexecute.ReplannerPrompt.Format(ctx, map[string]any{
|
||||||
|
"plan": string(planContent),
|
||||||
|
"input": planExecuteFormatInput(in.UserInput),
|
||||||
|
"executed_steps": planExecuteFormatExecutedSteps(in.ExecutedSteps),
|
||||||
|
"plan_tool": planexecute.PlanToolInfo.Name,
|
||||||
|
"respond_tool": planexecute.RespondToolInfo.Name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if oi != "" {
|
||||||
|
msgs = append([]adk.Message{schema.SystemMessage(oi)}, msgs...)
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
}
|
}
|
||||||
return planexecute.ReplannerPrompt.Format(ctx, map[string]any{
|
|
||||||
"plan": string(planContent),
|
|
||||||
"input": planExecuteFormatInput(in.UserInput),
|
|
||||||
"executed_steps": planExecuteFormatExecutedSteps(in.ExecutedSteps),
|
|
||||||
"plan_tool": planexecute.PlanToolInfo.Name,
|
|
||||||
"respond_tool": planexecute.RespondToolInfo.Name,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// planExecuteStreamsMainAssistant 将规划/执行/重规划各阶段助手流式输出映射到主对话区。
|
// planExecuteStreamsMainAssistant 将规划/执行/重规划各阶段助手流式输出映射到主对话区。
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ func RunEinoSingleChatModelAgent(
|
|||||||
userMessage string,
|
userMessage string,
|
||||||
history []agent.ChatMessage,
|
history []agent.ChatMessage,
|
||||||
roleTools []string,
|
roleTools []string,
|
||||||
roleSkills []string,
|
|
||||||
progress func(eventType, message string, data interface{}),
|
progress func(eventType, message string, data interface{}),
|
||||||
) (*RunResult, error) {
|
) (*RunResult, error) {
|
||||||
if appCfg == nil || ag == nil {
|
if appCfg == nil || ag == nil {
|
||||||
@@ -169,7 +168,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
chatCfg := &adk.ChatModelAgentConfig{
|
chatCfg := &adk.ChatModelAgentConfig{
|
||||||
Name: einoSingleAgentName,
|
Name: einoSingleAgentName,
|
||||||
Description: "Eino ADK ChatModelAgent with MCP tools for authorized security testing.",
|
Description: "Eino ADK ChatModelAgent with MCP tools for authorized security testing.",
|
||||||
Instruction: ag.EinoSingleAgentSystemInstruction(roleSkills),
|
Instruction: ag.EinoSingleAgentSystemInstruction(),
|
||||||
Model: mainModel,
|
Model: mainModel,
|
||||||
ToolsConfig: mainToolsCfg,
|
ToolsConfig: mainToolsCfg,
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
@@ -212,6 +211,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
McpIDsMu: &mcpIDsMu,
|
McpIDsMu: &mcpIDsMu,
|
||||||
McpIDs: &mcpIDs,
|
McpIDs: &mcpIDs,
|
||||||
DA: chatAgent,
|
DA: chatAgent,
|
||||||
EmptyResponseMessage: "(Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
EmptyResponseMessage: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " +
|
||||||
|
"(Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
||||||
}, baseMsgs)
|
}, baseMsgs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ func newPlanExecuteExecutor(ctx context.Context, cfg *planexecute.ExecutorConfig
|
|||||||
genInput := func(ctx context.Context, instruction string, _ *adk.AgentInput) ([]adk.Message, error) {
|
genInput := func(ctx context.Context, instruction string, _ *adk.AgentInput) ([]adk.Message, error) {
|
||||||
plan, ok := adk.GetSessionValue(ctx, planexecute.PlanSessionKey)
|
plan, ok := adk.GetSessionValue(ctx, planexecute.PlanSessionKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
panic("impossible: plan not found")
|
return nil, fmt.Errorf("plan_execute executor: session value %q missing (possible session corruption)", planexecute.PlanSessionKey)
|
||||||
}
|
}
|
||||||
plan_ := plan.(planexecute.Plan)
|
plan_ := plan.(planexecute.Plan)
|
||||||
|
|
||||||
userInput, ok := adk.GetSessionValue(ctx, planexecute.UserInputSessionKey)
|
userInput, ok := adk.GetSessionValue(ctx, planexecute.UserInputSessionKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
panic("impossible: user input not found")
|
return nil, fmt.Errorf("plan_execute executor: session value %q missing (possible session corruption)", planexecute.UserInputSessionKey)
|
||||||
}
|
}
|
||||||
userInput_ := userInput.([]adk.Message)
|
userInput_ := userInput.([]adk.Message)
|
||||||
|
|
||||||
|
|||||||
@@ -213,19 +213,6 @@ func RunDeepAgent(
|
|||||||
if len(roleTools) == 0 && len(r.Tools) > 0 {
|
if len(roleTools) == 0 && len(r.Tools) > 0 {
|
||||||
roleTools = r.Tools
|
roleTools = r.Tools
|
||||||
}
|
}
|
||||||
if len(r.Skills) > 0 {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString(instr)
|
|
||||||
b.WriteString("\n\n本角色推荐优先通过 Eino `skill` 工具(渐进式披露)加载的技能包 name:")
|
|
||||||
for i, s := range r.Skills {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString("、")
|
|
||||||
}
|
|
||||||
b.WriteString(s)
|
|
||||||
}
|
|
||||||
b.WriteString("。")
|
|
||||||
instr = b.String()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +322,9 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
sb.WriteString("你是监督协调者:可将任务通过 transfer 工具委派给下列专家子代理(使用其在系统中的 Agent 名称)。专家列表:")
|
sb.WriteString("你是监督协调者:可将任务通过 transfer 工具委派给下列专家子代理(使用其在系统中的 Agent 名称)。专家列表:")
|
||||||
for _, sa := range subAgents {
|
for _, sa := range subAgents {
|
||||||
|
if sa == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
sb.WriteString("\n- ")
|
sb.WriteString("\n- ")
|
||||||
sb.WriteString(sa.Name(ctx))
|
sb.WriteString(sa.Name(ctx))
|
||||||
}
|
}
|
||||||
@@ -349,14 +339,15 @@ func RunDeepAgent(
|
|||||||
deepShell = einoLoc
|
deepShell = einoLoc
|
||||||
}
|
}
|
||||||
|
|
||||||
deepHandlers := []adk.ChatModelAgentMiddleware{}
|
// noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。
|
||||||
|
deepHandlers := []adk.ChatModelAgentMiddleware{newNoNestedTaskMiddleware()}
|
||||||
if len(mainOrchestratorPre) > 0 {
|
if len(mainOrchestratorPre) > 0 {
|
||||||
deepHandlers = append(deepHandlers, mainOrchestratorPre...)
|
deepHandlers = append(deepHandlers, mainOrchestratorPre...)
|
||||||
}
|
}
|
||||||
if einoSkillMW != nil {
|
if einoSkillMW != nil {
|
||||||
deepHandlers = append(deepHandlers, einoSkillMW)
|
deepHandlers = append(deepHandlers, einoSkillMW)
|
||||||
}
|
}
|
||||||
deepHandlers = append(deepHandlers, newNoNestedTaskMiddleware(), mainSumMw)
|
deepHandlers = append(deepHandlers, mainSumMw)
|
||||||
|
|
||||||
supHandlers := []adk.ChatModelAgentMiddleware{}
|
supHandlers := []adk.ChatModelAgentMiddleware{}
|
||||||
if len(mainOrchestratorPre) > 0 {
|
if len(mainOrchestratorPre) > 0 {
|
||||||
@@ -387,6 +378,14 @@ func RunDeepAgent(
|
|||||||
if perr != nil {
|
if perr != nil {
|
||||||
return nil, fmt.Errorf("plan_execute 执行器模型: %w", perr)
|
return nil, fmt.Errorf("plan_execute 执行器模型: %w", perr)
|
||||||
}
|
}
|
||||||
|
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
|
||||||
|
var peFsMw adk.ChatModelAgentMiddleware
|
||||||
|
if einoSkillMW != nil && einoFSTools && einoLoc != nil {
|
||||||
|
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
peRoot, perr := NewPlanExecuteRoot(ctx, &PlanExecuteRootArgs{
|
peRoot, perr := NewPlanExecuteRoot(ctx, &PlanExecuteRootArgs{
|
||||||
MainToolCallingModel: mainModel,
|
MainToolCallingModel: mainModel,
|
||||||
ExecModel: execModel,
|
ExecModel: execModel,
|
||||||
@@ -396,6 +395,9 @@ func RunDeepAgent(
|
|||||||
LoopMaxIter: ma.PlanExecuteLoopMaxIterations,
|
LoopMaxIter: ma.PlanExecuteLoopMaxIterations,
|
||||||
AppCfg: appCfg,
|
AppCfg: appCfg,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
|
ExecPreMiddlewares: mainOrchestratorPre,
|
||||||
|
SkillMiddleware: einoSkillMW,
|
||||||
|
FilesystemMiddleware: peFsMw,
|
||||||
})
|
})
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return nil, perr
|
return nil, perr
|
||||||
@@ -493,7 +495,8 @@ func RunDeepAgent(
|
|||||||
McpIDsMu: &mcpIDsMu,
|
McpIDsMu: &mcpIDsMu,
|
||||||
McpIDs: &mcpIDs,
|
McpIDs: &mcpIDs,
|
||||||
DA: da,
|
DA: da,
|
||||||
EmptyResponseMessage: "(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
EmptyResponseMessage: "(Eino multi-agent orchestration completed but no assistant text was captured. Check process details or logs.) " +
|
||||||
|
"(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
|
||||||
}, baseMsgs)
|
}, baseMsgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package multiagent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -40,51 +41,27 @@ func softRecoveryToolCallMiddleware() compose.InvokableToolMiddleware {
|
|||||||
|
|
||||||
// isSoftRecoverableToolError determines whether a tool execution error should be
|
// isSoftRecoverableToolError determines whether a tool execution error should be
|
||||||
// silently converted to a tool-result message rather than crashing the graph.
|
// 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 {
|
func isSoftRecoverableToolError(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
s := strings.ToLower(err.Error())
|
|
||||||
|
|
||||||
// JSON unmarshal/parse failures — the model generated truncated or malformed arguments.
|
// 用户主动取消 — 唯一应当终止编排的情况,不应重试。
|
||||||
if isJSONRelatedError(s) {
|
if errors.Is(err, context.Canceled) {
|
||||||
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
|
return false
|
||||||
}
|
}
|
||||||
jsonIndicators := []string{
|
|
||||||
"unexpected end of json",
|
// 其他所有工具执行错误(超时、命令不存在、JSON 解析失败、工具未找到、
|
||||||
"unmarshal",
|
// 权限不足、网络不可达……)一律转为 soft error,让 LLM 看到错误信息
|
||||||
"invalid character",
|
// 后自行决策:换工具、调整参数、或向用户说明。
|
||||||
"cannot unmarshal",
|
return true
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSoftRecoveryMessage creates a bilingual error message that the LLM can act on.
|
// 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",
|
name: "unrelated network error",
|
||||||
err: errors.New("connection refused"),
|
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",
|
name: "context cancelled",
|
||||||
@@ -131,15 +136,16 @@ func TestSoftRecoveryToolCallMiddleware_PropagatesNonRecoverable(t *testing.T) {
|
|||||||
return nil, origErr
|
return nil, origErr
|
||||||
}
|
}
|
||||||
wrapped := mw(next)
|
wrapped := mw(next)
|
||||||
_, err := wrapped(context.Background(), &compose.ToolInput{
|
out, err := wrapped(context.Background(), &compose.ToolInput{
|
||||||
Name: "test_tool",
|
Name: "test_tool",
|
||||||
Arguments: `{}`,
|
Arguments: `{}`,
|
||||||
})
|
})
|
||||||
if err == nil {
|
// Default-soft: non-cancel errors are converted to tool-result messages.
|
||||||
t.Fatal("expected error to propagate for non-recoverable errors")
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error (soft recovery), got: %v", err)
|
||||||
}
|
}
|
||||||
if err != origErr {
|
if out == nil || out.Result == "" {
|
||||||
t.Fatalf("expected original error, got: %v", err)
|
t.Fatal("expected non-empty recovery message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,74 +2,42 @@ package multiagent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/cloudwego/eino/schema"
|
"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
|
// 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 {
|
func toolExecutionRetryHint() *schema.Message {
|
||||||
return schema.UserMessage(`[System] Your previous tool call failed because:
|
return schema.UserMessage(`[System] Your previous tool call failed. Possible causes:
|
||||||
- The tool or sub-agent name you used does not exist, OR
|
- The tool or sub-agent name does not exist (typo or unregistered name).
|
||||||
- The tool call arguments were not valid JSON.
|
- 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
|
// toolExecutionRecoveryTimelineMessage returns a message for the eino_recovery event
|
||||||
// displayed in the UI timeline when a tool execution error triggers a retry.
|
// displayed in the UI timeline when a tool execution error triggers a retry.
|
||||||
func toolExecutionRecoveryTimelineMessage(attempt int) string {
|
func toolExecutionRecoveryTimelineMessage(attempt int) string {
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"工具调用执行失败(工具/子代理名称不存在或参数 JSON 无效)。已向对话追加纠错提示并要求模型重新生成。"+
|
"工具调用执行失败。已向对话追加纠错提示并要求模型调整策略。"+
|
||||||
"当前为第 %d/%d 轮完整运行。\n\n"+
|
"当前为第 %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.",
|
"A corrective hint was appended. This is full run %d of %d.",
|
||||||
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
|
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -487,7 +487,10 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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{
|
return "", &APIError{
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
Body: string(respBody),
|
Body: string(respBody),
|
||||||
@@ -588,7 +591,10 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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{
|
return "", nil, "", &APIError{
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
Body: string(respBody),
|
Body: string(respBody),
|
||||||
@@ -824,7 +830,11 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
|||||||
|
|
||||||
// 非 200:尝试把 Claude 错误格式转成 OpenAI 错误格式,便于 Eino 解析
|
// 非 200:尝试把 Claude 错误格式转成 OpenAI 错误格式,便于 Eino 解析
|
||||||
if resp.StatusCode != http.StatusOK {
|
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()
|
resp.Body.Close()
|
||||||
converted := rt.tryConvertClaudeErrorToOpenAI(bodyBytes)
|
converted := rt.tryConvertClaudeErrorToOpenAI(bodyBytes)
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
@@ -838,7 +848,11 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
|||||||
|
|
||||||
// 非流式:一次性转换响应体
|
// 非流式:一次性转换响应体
|
||||||
if !claudeReq.Stream {
|
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()
|
resp.Body.Close()
|
||||||
oaiJSON, err := claudeToOpenAIResponseJSON(respBody)
|
oaiJSON, err := claudeToOpenAIResponseJSON(respBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -189,7 +189,10 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
|
|||||||
|
|
||||||
// 非200:读完 body 返回
|
// 非200:读完 body 返回
|
||||||
if resp.StatusCode != http.StatusOK {
|
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{
|
return "", &APIError{
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
Body: string(respBody),
|
Body: string(respBody),
|
||||||
@@ -329,7 +332,10 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
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{
|
return "", nil, "", &APIError{
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
Body: string(respBody),
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,12 +33,12 @@ func SkillsRootFromConfig(skillsDir string, configPath string) string {
|
|||||||
return skillsDir
|
return skillsDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// DirLister satisfies handler.SkillsManager for role UI (lists package directory names).
|
// DirLister lists skill package directory names under SkillsRoot.
|
||||||
type DirLister struct {
|
type DirLister struct {
|
||||||
SkillsRoot string
|
SkillsRoot string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSkills implements the role handler dependency.
|
// ListSkills returns skill package directory names that contain SKILL.md.
|
||||||
func (d DirLister) ListSkills() ([]string, error) {
|
func (d DirLister) ListSkills() ([]string, error) {
|
||||||
return ListSkillDirNames(d.SkillsRoot)
|
return ListSkillDirNames(d.SkillsRoot)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
# 角色配置文件说明
|
# 角色配置文件说明
|
||||||
|
|
||||||
本目录包含所有角色配置文件,每个角色定义了AI的行为模式、可用工具和技能。
|
本目录包含所有角色配置文件,每个角色定义了AI的行为模式与可用工具。
|
||||||
|
|
||||||
## 创建新角色
|
## 创建新角色
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ enabled: true
|
|||||||
|
|
||||||
按需还可加入 WebShell、批量任务等其它内置或外部工具(以 MCP 管理中已启用的为准)。
|
按需还可加入 WebShell、批量任务等其它内置或外部工具(以 MCP 管理中已启用的为准)。
|
||||||
|
|
||||||
**Skills(技能包)**:不由 MCP 工具列表提供。角色 `skills` 字段绑定技能 id 后,在 **多代理(Eino DeepAgent)** 会话中由 ADK **`skill`** 工具渐进加载;单代理路径不含该能力。
|
**Skills(技能包)**:在 **多代理 / Eino** 会话中由内置 **`skill`** 工具按需加载 `skills_dir` 下的包,与角色 YAML 无绑定关系。
|
||||||
|
|
||||||
**注意**:如果不设置 `tools` 字段,系统会默认使用所有 MCP 管理中已开启的工具。为明确控制角色可用工具,建议显式设置 `tools` 字段。
|
**注意**:如果不设置 `tools` 字段,系统会默认使用所有 MCP 管理中已开启的工具。为明确控制角色可用工具,建议显式设置 `tools` 字段。
|
||||||
|
|
||||||
@@ -54,7 +54,6 @@ enabled: true
|
|||||||
- **tools**: 工具列表,指定该角色可用的工具(可选)
|
- **tools**: 工具列表,指定该角色可用的工具(可选)
|
||||||
- **如果不设置 `tools` 字段**:默认会选中**全部MCP管理中已开启的工具**
|
- **如果不设置 `tools` 字段**:默认会选中**全部MCP管理中已开启的工具**
|
||||||
- **如果设置了 `tools` 字段**:只使用列表中指定的工具(建议至少包含上述核心内置工具)
|
- **如果设置了 `tools` 字段**:只使用列表中指定的工具(建议至少包含上述核心内置工具)
|
||||||
- **skills**: 技能列表,指定该角色关联的技能(可选)
|
|
||||||
- **enabled**: 是否启用该角色(必填,true/false)
|
- **enabled**: 是否启用该角色(必填,true/false)
|
||||||
|
|
||||||
## 示例
|
## 示例
|
||||||
|
|||||||
@@ -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,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,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,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"
|
|
||||||
+205
-117
@@ -3965,7 +3965,7 @@ header {
|
|||||||
|
|
||||||
.tool-item {
|
.tool-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -3980,8 +3980,10 @@ header {
|
|||||||
.tool-item input[type="checkbox"] {
|
.tool-item input[type="checkbox"] {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
margin-top: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
accent-color: var(--accent-color);
|
accent-color: var(--accent-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-item-info {
|
.tool-item-info {
|
||||||
@@ -4021,6 +4023,93 @@ header {
|
|||||||
white-space: nowrap;
|
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 {
|
.tool-item.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -11353,12 +11442,53 @@ header {
|
|||||||
.webshell-ai-msg ol {
|
.webshell-ai-msg ol {
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
}
|
}
|
||||||
.webshell-ai-input-row {
|
/* WebShell AI 输入区域:选择器 + 输入框同行 */
|
||||||
|
.webshell-ai-input-area {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.webshell-ai-selectors-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.webshell-ai-selectors-row .role-selector-btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.webshell-ai-selectors-row .role-selector-icon {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.webshell-ai-selectors-row .role-selector-text {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
max-width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ws-role-selector-wrapper {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ws-agent-mode-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ws-agent-mode-wrapper .agent-mode-inner {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.webshell-ai-input-row {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.webshell-ai-input {
|
.webshell-ai-input {
|
||||||
@@ -11391,7 +11521,8 @@ header {
|
|||||||
.webshell-ai-input::-webkit-scrollbar-thumb:hover {
|
.webshell-ai-input::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(15, 23, 42, 0.4);
|
background: rgba(15, 23, 42, 0.4);
|
||||||
}
|
}
|
||||||
.webshell-ai-input-row .btn-primary {
|
.webshell-ai-input-row .btn-primary,
|
||||||
|
.webshell-ai-input-row .webshell-ai-stop-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
min-width: 72px;
|
min-width: 72px;
|
||||||
@@ -11400,6 +11531,18 @@ header {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
.webshell-ai-stop-btn {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.webshell-ai-stop-btn:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
/* WebShell 数据库管理 Tab */
|
/* WebShell 数据库管理 Tab */
|
||||||
.webshell-pane-db {
|
.webshell-pane-db {
|
||||||
@@ -13465,6 +13608,7 @@ header {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-selection-item-name-main {
|
.role-selection-item-name-main {
|
||||||
@@ -14133,7 +14277,9 @@ header {
|
|||||||
|
|
||||||
.role-tools-stats {
|
.role-tools-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -14142,6 +14288,60 @@ header {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.role-tools-stats-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tools-stats-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tool-mcp-disabled-badge {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(108, 117, 125, 0.15);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tools-filter-banner {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.role-tools-filter-banner-on {
|
||||||
|
background: rgba(0, 102, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: rgba(0, 102, 255, 0.25);
|
||||||
|
}
|
||||||
|
.role-tools-filter-banner-off {
|
||||||
|
background: rgba(108, 117, 125, 0.1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tool-mcp-on-badge {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: rgba(25, 135, 84, 0.12);
|
||||||
|
color: #198754;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.role-tools-stats span {
|
.role-tools-stats span {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -14393,118 +14593,6 @@ header {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skills选择相关样式 */
|
|
||||||
.role-skills-controls {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skills-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skills-search-box {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skills-search-box input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 32px 8px 12px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skills-search-box input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skills-search-clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skills-search-clear:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skills-stats {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skills-list {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skill-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skill-item:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skill-item:hover {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-color: var(--accent-color);
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 102, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-skill-item .checkbox-text {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skills-loading,
|
|
||||||
.skills-empty,
|
|
||||||
.skills-error {
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skills-error {
|
|
||||||
color: var(--error-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skills管理页面样式 */
|
/* Skills管理页面样式 */
|
||||||
.skills-controls {
|
.skills-controls {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
+103
-22
@@ -845,7 +845,13 @@
|
|||||||
"externalMCPManagement": "External MCP Management",
|
"externalMCPManagement": "External MCP Management",
|
||||||
"attackChain": "Attack Chain",
|
"attackChain": "Attack Chain",
|
||||||
"knowledgeBase": "Knowledge Base",
|
"knowledgeBase": "Knowledge Base",
|
||||||
"mcp": "MCP"
|
"mcp": "MCP",
|
||||||
|
"fofaRecon": "FOFA Recon",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"webshellManagement": "WebShell Management",
|
||||||
|
"chatUploads": "Chat Uploads",
|
||||||
|
"robotIntegration": "Robot Integration",
|
||||||
|
"markdownAgents": "Markdown Agents"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"login": "User login",
|
"login": "User login",
|
||||||
@@ -945,7 +951,53 @@
|
|||||||
"invokeTool": "Invoke tool",
|
"invokeTool": "Invoke tool",
|
||||||
"initConnection": "Initialize connection",
|
"initConnection": "Initialize connection",
|
||||||
"successResponse": "Success response",
|
"successResponse": "Success response",
|
||||||
"errorResponse": "Error response"
|
"errorResponse": "Error response",
|
||||||
|
"deleteConversationTurn": "Delete conversation turn",
|
||||||
|
"getMessageProcessDetails": "Get message process details",
|
||||||
|
"rerunBatchQueue": "Rerun batch task queue",
|
||||||
|
"updateBatchQueueMetadata": "Update queue metadata",
|
||||||
|
"updateBatchQueueSchedule": "Update queue schedule",
|
||||||
|
"setBatchQueueScheduleEnabled": "Toggle cron auto-schedule",
|
||||||
|
"getAllGroupMappings": "Get all group mappings",
|
||||||
|
"fofaSearch": "FOFA search",
|
||||||
|
"fofaParse": "Parse natural language to FOFA syntax",
|
||||||
|
"testOpenAI": "Test OpenAI API connection",
|
||||||
|
"terminalRun": "Run terminal command",
|
||||||
|
"terminalRunStream": "Run terminal command (stream)",
|
||||||
|
"terminalWS": "WebSocket terminal",
|
||||||
|
"listWebshellConnections": "List WebShell connections",
|
||||||
|
"createWebshellConnection": "Create WebShell connection",
|
||||||
|
"updateWebshellConnection": "Update WebShell connection",
|
||||||
|
"deleteWebshellConnection": "Delete WebShell connection",
|
||||||
|
"getWebshellConnectionState": "Get connection state",
|
||||||
|
"saveWebshellConnectionState": "Save connection state",
|
||||||
|
"getWebshellAIHistory": "Get AI chat history",
|
||||||
|
"listWebshellAIConversations": "List AI conversations",
|
||||||
|
"webshellExec": "Execute WebShell command",
|
||||||
|
"webshellFileOp": "WebShell file operation",
|
||||||
|
"listChatUploads": "List uploads",
|
||||||
|
"uploadChatFile": "Upload file",
|
||||||
|
"deleteChatUpload": "Delete upload",
|
||||||
|
"downloadChatUpload": "Download upload",
|
||||||
|
"getChatUploadContent": "Get file text content",
|
||||||
|
"putChatUploadContent": "Write file text content",
|
||||||
|
"mkdirChatUpload": "Create upload directory",
|
||||||
|
"renameChatUpload": "Rename upload",
|
||||||
|
"wecomCallbackVerify": "WeCom callback verification",
|
||||||
|
"wecomCallbackMessage": "WeCom message callback",
|
||||||
|
"dingtalkCallback": "DingTalk message callback",
|
||||||
|
"larkCallback": "Lark message callback",
|
||||||
|
"testRobot": "Test robot message processing",
|
||||||
|
"listMarkdownAgents": "List Markdown agents",
|
||||||
|
"createMarkdownAgent": "Create Markdown agent",
|
||||||
|
"getMarkdownAgent": "Get Markdown agent detail",
|
||||||
|
"updateMarkdownAgent": "Update Markdown agent",
|
||||||
|
"deleteMarkdownAgent": "Delete Markdown agent",
|
||||||
|
"listSkillPackageFiles": "List skill package files",
|
||||||
|
"getSkillPackageFile": "Get skill package file content",
|
||||||
|
"putSkillPackageFile": "Write skill package file",
|
||||||
|
"batchGetToolNames": "Batch get tool names",
|
||||||
|
"getKnowledgeStats": "Get knowledge base stats"
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"getSuccess": "Success",
|
"getSuccess": "Success",
|
||||||
@@ -980,7 +1032,29 @@
|
|||||||
"cancelSubmitted": "Cancel request submitted",
|
"cancelSubmitted": "Cancel request submitted",
|
||||||
"noRunningTask": "No running task found",
|
"noRunningTask": "No running task found",
|
||||||
"messageSent": "Message sent, AI reply returned",
|
"messageSent": "Message sent, AI reply returned",
|
||||||
"streamResponse": "Stream response (Server-Sent Events)"
|
"streamResponse": "Stream response (Server-Sent Events)",
|
||||||
|
"badRequestOrDeleteFailed": "Bad request or delete failed",
|
||||||
|
"paramError": "Invalid parameters",
|
||||||
|
"onlyCompletedOrCancelledCanRerun": "Only completed or cancelled queues can be rerun",
|
||||||
|
"badRequestOrQueueRunning": "Bad request or queue is running",
|
||||||
|
"setSuccess": "Set successfully",
|
||||||
|
"searchSuccess": "Search successful",
|
||||||
|
"parseSuccess": "Parse successful",
|
||||||
|
"testResult": "Test result",
|
||||||
|
"executionDone": "Execution completed",
|
||||||
|
"sseEventStream": "SSE event stream",
|
||||||
|
"wsEstablished": "WebSocket connection established",
|
||||||
|
"fileDownload": "File download",
|
||||||
|
"fileNotFound": "File not found",
|
||||||
|
"writeSuccess": "Written successfully",
|
||||||
|
"renameSuccess": "Renamed successfully",
|
||||||
|
"wecomVerifySuccess": "Verification successful, decrypted echostr returned",
|
||||||
|
"processSuccess": "Processed successfully",
|
||||||
|
"agentNotFound": "Agent not found",
|
||||||
|
"saveSuccess": "Saved successfully",
|
||||||
|
"operationResult": "Operation result",
|
||||||
|
"executionResult": "Execution result",
|
||||||
|
"connectionNotFound": "Connection not found"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chatGroup": {
|
"chatGroup": {
|
||||||
@@ -1490,15 +1564,15 @@
|
|||||||
"externalMcpModal": {
|
"externalMcpModal": {
|
||||||
"configJson": "Config JSON",
|
"configJson": "Config JSON",
|
||||||
"formatLabel": "Format:",
|
"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:",
|
"configExample": "Configuration example:",
|
||||||
"stdioMode": "stdio mode:",
|
"stdioMode": "stdio mode:",
|
||||||
"httpMode": "HTTP mode:",
|
"httpMode": "HTTP mode:",
|
||||||
"sseMode": "SSE 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}",
|
"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 \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\", \"--server\", \"http://example.com\"],\n \"description\": \"Description\",\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 \"cyberstrike-ai-http\": {\n \"transport\": \"http\",\n \"url\": \"http://127.0.0.1:8081/mcp\"\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 \"cyberstrike-ai-sse\": {\n \"transport\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
|
"exampleSse": "{\n \"sse-mcp\": {\n \"type\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
|
||||||
"exampleDescription": "Example description",
|
"exampleDescription": "Example description",
|
||||||
"formatJson": "Format JSON",
|
"formatJson": "Format JSON",
|
||||||
"loadExample": "Load example"
|
"loadExample": "Load example"
|
||||||
@@ -1710,28 +1784,30 @@
|
|||||||
"defaultRoleToolsDesc": "Default role uses all tools enabled in MCP Management.",
|
"defaultRoleToolsDesc": "Default role uses all tools enabled in MCP Management.",
|
||||||
"searchToolsPlaceholder": "Search tools...",
|
"searchToolsPlaceholder": "Search tools...",
|
||||||
"loadingTools": "Loading tools...",
|
"loadingTools": "Loading tools...",
|
||||||
"relatedToolsHint": "Select tools to link; empty = use all from MCP Management.",
|
"relatedToolsHint": "Use “Linked / Not linked” above to filter by this role’s checkboxes. MCP-wide on/off is in MCP Management.",
|
||||||
"relatedSkills": "Related Skills (optional)",
|
|
||||||
"searchSkillsPlaceholder": "Search skill...",
|
|
||||||
"loadingSkills": "Loading skills...",
|
|
||||||
"relatedSkillsHint": "Selected skills are injected into system prompt before task execution.",
|
|
||||||
"enableRole": "Enable this role",
|
"enableRole": "Enable this role",
|
||||||
"selectAll": "Select All",
|
"selectAll": "Select All",
|
||||||
"deselectAll": "Deselect All",
|
"deselectAll": "Deselect All",
|
||||||
"roleNameRequired": "Role name is required",
|
"roleNameRequired": "Role name is required",
|
||||||
"roleNotFound": "Role not found",
|
"roleNotFound": "Role not found",
|
||||||
"firstRoleNoToolsHint": "First role with no tools selected will use all tools by default.",
|
"firstRoleNoToolsHint": "First role with no tools selected will use all tools by default.",
|
||||||
"currentPageSelected": "Current page: {{current}} / {{total}}",
|
"filterRoleAll": "All",
|
||||||
"totalSelected": "Total selected: {{current}} / {{total}}",
|
"filterRoleOn": "Linked to role",
|
||||||
|
"filterRoleOff": "Not linked",
|
||||||
|
"statsPageLinked": "This page checked: {{current}} / {{total}}",
|
||||||
|
"statsPageLinkedTitle": "Checked = link tool to this role; unrelated to MCP on/off",
|
||||||
|
"statsRoleLinked": "Role checked: {{current}} / {{max}}",
|
||||||
|
"statsRoleLinkedTitle": "Numerator: checked tools (MCP on only). Denominator: MCP-on tool count (same as MCP Management filter)",
|
||||||
|
"statsRoleLinkedNoMax": "Role checked: {{current}} (switch filter to All, no search, load once to sync cap)",
|
||||||
|
"statsRoleLinkedNoMaxTitle": "MCP-on total not cached yet",
|
||||||
|
"statsRoleUsesAll": "Policy: all MCP-on tools ({{mcpOn}}) · {{all}} total in catalog (incl. MCP off)",
|
||||||
|
"statsRoleUsesAllTitle": "Matches MCP Management “enabled” count; no explicit tool list",
|
||||||
|
"statsListScopeAll": "List: all {{n}}",
|
||||||
|
"statsListScopeRoleOn": "List: linked to this role {{n}}",
|
||||||
|
"statsListScopeRoleOff": "List: not linked to this role {{n}}",
|
||||||
"usingAllEnabledTools": "(Using all enabled tools)",
|
"usingAllEnabledTools": "(Using all enabled tools)",
|
||||||
"currentPageSelectedTitle": "Selected on current page (enabled tools only)",
|
|
||||||
"totalSelectedTitle": "Total tools linked to this role",
|
|
||||||
"skillsSelectedCount": "Selected {{count}} / {{total}}",
|
|
||||||
"loadToolsFailed": "Failed to load tools",
|
"loadToolsFailed": "Failed to load tools",
|
||||||
"loadSkillsFailed": "Failed to load skills",
|
|
||||||
"cannotDeleteDefaultRole": "Cannot delete default role",
|
"cannotDeleteDefaultRole": "Cannot delete default role",
|
||||||
"noMatchingSkills": "No matching skills",
|
|
||||||
"noSkillsAvailable": "No skills available",
|
|
||||||
"usingAllTools": "Use all tools",
|
"usingAllTools": "Use all tools",
|
||||||
"andNMore": " and {{count}} more",
|
"andNMore": " and {{count}} more",
|
||||||
"toolsLabel": "Tools:",
|
"toolsLabel": "Tools:",
|
||||||
@@ -1742,6 +1818,11 @@
|
|||||||
"prevPage": "Previous",
|
"prevPage": "Previous",
|
||||||
"pageOf": "Page {{page}} / {{total}}",
|
"pageOf": "Page {{page}} / {{total}}",
|
||||||
"nextPage": "Next",
|
"nextPage": "Next",
|
||||||
"lastPage": "Last"
|
"lastPage": "Last",
|
||||||
|
"mcpDisabledBadge": "MCP off",
|
||||||
|
"mcpDisabledBadgeTitle": "Off in MCP Management; check only expresses role linkage—turn on in MCP to run",
|
||||||
|
"roleFilterOnBanner": "These tools are checked and linked to this role (independent of MCP-wide enable).",
|
||||||
|
"roleFilterOffBanner": "These tools are unchecked and not linked to this role.",
|
||||||
|
"checkboxLinkTitle": "Check to link this tool to this role"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-22
@@ -845,7 +845,13 @@
|
|||||||
"externalMCPManagement": "外部MCP管理",
|
"externalMCPManagement": "外部MCP管理",
|
||||||
"attackChain": "攻击链",
|
"attackChain": "攻击链",
|
||||||
"knowledgeBase": "知识库",
|
"knowledgeBase": "知识库",
|
||||||
"mcp": "MCP"
|
"mcp": "MCP",
|
||||||
|
"fofaRecon": "FOFA信息收集",
|
||||||
|
"terminal": "终端",
|
||||||
|
"webshellManagement": "WebShell管理",
|
||||||
|
"chatUploads": "对话附件",
|
||||||
|
"robotIntegration": "机器人集成",
|
||||||
|
"markdownAgents": "多代理Markdown"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"login": "用户登录",
|
"login": "用户登录",
|
||||||
@@ -945,7 +951,53 @@
|
|||||||
"invokeTool": "调用工具",
|
"invokeTool": "调用工具",
|
||||||
"initConnection": "初始化连接",
|
"initConnection": "初始化连接",
|
||||||
"successResponse": "成功响应",
|
"successResponse": "成功响应",
|
||||||
"errorResponse": "错误响应"
|
"errorResponse": "错误响应",
|
||||||
|
"deleteConversationTurn": "删除对话轮次",
|
||||||
|
"getMessageProcessDetails": "获取消息过程详情",
|
||||||
|
"rerunBatchQueue": "重跑批量任务队列",
|
||||||
|
"updateBatchQueueMetadata": "修改队列元数据",
|
||||||
|
"updateBatchQueueSchedule": "修改队列调度配置",
|
||||||
|
"setBatchQueueScheduleEnabled": "开关Cron自动调度",
|
||||||
|
"getAllGroupMappings": "获取所有分组映射",
|
||||||
|
"fofaSearch": "FOFA搜索",
|
||||||
|
"fofaParse": "自然语言解析为FOFA语法",
|
||||||
|
"testOpenAI": "测试OpenAI API连接",
|
||||||
|
"terminalRun": "执行终端命令",
|
||||||
|
"terminalRunStream": "流式执行终端命令",
|
||||||
|
"terminalWS": "WebSocket终端",
|
||||||
|
"listWebshellConnections": "列出WebShell连接",
|
||||||
|
"createWebshellConnection": "创建WebShell连接",
|
||||||
|
"updateWebshellConnection": "更新WebShell连接",
|
||||||
|
"deleteWebshellConnection": "删除WebShell连接",
|
||||||
|
"getWebshellConnectionState": "获取连接状态",
|
||||||
|
"saveWebshellConnectionState": "保存连接状态",
|
||||||
|
"getWebshellAIHistory": "获取AI对话历史",
|
||||||
|
"listWebshellAIConversations": "列出AI对话",
|
||||||
|
"webshellExec": "执行WebShell命令",
|
||||||
|
"webshellFileOp": "WebShell文件操作",
|
||||||
|
"listChatUploads": "列出附件",
|
||||||
|
"uploadChatFile": "上传附件",
|
||||||
|
"deleteChatUpload": "删除附件",
|
||||||
|
"downloadChatUpload": "下载附件",
|
||||||
|
"getChatUploadContent": "获取附件文本内容",
|
||||||
|
"putChatUploadContent": "写入附件文本内容",
|
||||||
|
"mkdirChatUpload": "创建附件目录",
|
||||||
|
"renameChatUpload": "重命名附件",
|
||||||
|
"wecomCallbackVerify": "企业微信回调验证",
|
||||||
|
"wecomCallbackMessage": "企业微信消息回调",
|
||||||
|
"dingtalkCallback": "钉钉消息回调",
|
||||||
|
"larkCallback": "飞书消息回调",
|
||||||
|
"testRobot": "测试机器人消息处理",
|
||||||
|
"listMarkdownAgents": "列出Markdown代理",
|
||||||
|
"createMarkdownAgent": "创建Markdown代理",
|
||||||
|
"getMarkdownAgent": "获取Markdown代理详情",
|
||||||
|
"updateMarkdownAgent": "更新Markdown代理",
|
||||||
|
"deleteMarkdownAgent": "删除Markdown代理",
|
||||||
|
"listSkillPackageFiles": "列出技能包文件",
|
||||||
|
"getSkillPackageFile": "获取技能包文件内容",
|
||||||
|
"putSkillPackageFile": "写入技能包文件",
|
||||||
|
"batchGetToolNames": "批量获取工具名称",
|
||||||
|
"getKnowledgeStats": "获取知识库统计"
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"getSuccess": "获取成功",
|
"getSuccess": "获取成功",
|
||||||
@@ -980,7 +1032,29 @@
|
|||||||
"cancelSubmitted": "取消请求已提交",
|
"cancelSubmitted": "取消请求已提交",
|
||||||
"noRunningTask": "未找到正在执行的任务",
|
"noRunningTask": "未找到正在执行的任务",
|
||||||
"messageSent": "消息发送成功,返回AI回复",
|
"messageSent": "消息发送成功,返回AI回复",
|
||||||
"streamResponse": "流式响应(Server-Sent Events)"
|
"streamResponse": "流式响应(Server-Sent Events)",
|
||||||
|
"badRequestOrDeleteFailed": "参数错误或删除失败",
|
||||||
|
"paramError": "参数错误",
|
||||||
|
"onlyCompletedOrCancelledCanRerun": "仅已完成或已取消的队列可以重跑",
|
||||||
|
"badRequestOrQueueRunning": "参数错误或队列正在运行中",
|
||||||
|
"setSuccess": "设置成功",
|
||||||
|
"searchSuccess": "搜索成功",
|
||||||
|
"parseSuccess": "解析成功",
|
||||||
|
"testResult": "测试结果",
|
||||||
|
"executionDone": "执行完成",
|
||||||
|
"sseEventStream": "SSE事件流",
|
||||||
|
"wsEstablished": "WebSocket连接已建立",
|
||||||
|
"fileDownload": "文件下载",
|
||||||
|
"fileNotFound": "文件不存在",
|
||||||
|
"writeSuccess": "写入成功",
|
||||||
|
"renameSuccess": "重命名成功",
|
||||||
|
"wecomVerifySuccess": "验证成功,返回解密后的echostr",
|
||||||
|
"processSuccess": "处理成功",
|
||||||
|
"agentNotFound": "代理不存在",
|
||||||
|
"saveSuccess": "保存成功",
|
||||||
|
"operationResult": "操作结果",
|
||||||
|
"executionResult": "执行结果",
|
||||||
|
"connectionNotFound": "连接不存在"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chatGroup": {
|
"chatGroup": {
|
||||||
@@ -1490,15 +1564,15 @@
|
|||||||
"externalMcpModal": {
|
"externalMcpModal": {
|
||||||
"configJson": "配置JSON",
|
"configJson": "配置JSON",
|
||||||
"formatLabel": "配置格式:",
|
"formatLabel": "配置格式:",
|
||||||
"formatDesc": "JSON对象,key为配置名称,value为配置内容。状态通过\"启动/停止\"按钮控制,无需在JSON中配置。",
|
"formatDesc": "JSON对象,key为配置名称,value为配置内容。状态通过\"启动/停止\"按钮控制,无需在JSON中配置。支持 ${VAR} 和 ${VAR:-默认值} 环境变量语法。",
|
||||||
"configExample": "配置示例:",
|
"configExample": "配置示例:",
|
||||||
"stdioMode": "stdio模式:",
|
"stdioMode": "stdio模式:",
|
||||||
"httpMode": "HTTP模式:",
|
"httpMode": "HTTP模式:",
|
||||||
"sseMode": "SSE模式:",
|
"sseMode": "SSE模式:",
|
||||||
"placeholder": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\"],\n \"description\": \"描述\",\n \"timeout\": 300\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 \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\", \"--server\", \"http://example.com\"],\n \"description\": \"描述\",\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 \"cyberstrike-ai-http\": {\n \"transport\": \"http\",\n \"url\": \"http://127.0.0.1:8081/mcp\"\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 \"cyberstrike-ai-sse\": {\n \"transport\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
|
"exampleSse": "{\n \"sse-mcp\": {\n \"type\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
|
||||||
"exampleDescription": "示例描述",
|
"exampleDescription": "示例描述",
|
||||||
"formatJson": "格式化JSON",
|
"formatJson": "格式化JSON",
|
||||||
"loadExample": "加载示例"
|
"loadExample": "加载示例"
|
||||||
@@ -1710,28 +1784,30 @@
|
|||||||
"defaultRoleToolsDesc": "默认角色会自动使用MCP管理中启用的所有工具,无需单独配置。",
|
"defaultRoleToolsDesc": "默认角色会自动使用MCP管理中启用的所有工具,无需单独配置。",
|
||||||
"searchToolsPlaceholder": "搜索工具...",
|
"searchToolsPlaceholder": "搜索工具...",
|
||||||
"loadingTools": "正在加载工具列表...",
|
"loadingTools": "正在加载工具列表...",
|
||||||
"relatedToolsHint": "勾选要关联的工具,留空则使用MCP管理中的全部工具配置。",
|
"relatedToolsHint": "上方「本角色已开/已关」按复选框筛选;留空工具清单表示不限制。MCP 全局开关请在 MCP 管理中操作。",
|
||||||
"relatedSkills": "关联的Skills(可选)",
|
|
||||||
"searchSkillsPlaceholder": "搜索skill...",
|
|
||||||
"loadingSkills": "正在加载skills列表...",
|
|
||||||
"relatedSkillsHint": "勾选要关联的skills,这些skills的内容会在执行任务前注入到系统提示词中,帮助AI更好地理解相关专业知识。",
|
|
||||||
"enableRole": "启用此角色",
|
"enableRole": "启用此角色",
|
||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
"deselectAll": "全不选",
|
"deselectAll": "全不选",
|
||||||
"roleNameRequired": "角色名称不能为空",
|
"roleNameRequired": "角色名称不能为空",
|
||||||
"roleNotFound": "角色不存在",
|
"roleNotFound": "角色不存在",
|
||||||
"firstRoleNoToolsHint": "检测到这是首次添加角色且未选择工具,将默认使用全部工具",
|
"firstRoleNoToolsHint": "检测到这是首次添加角色且未选择工具,将默认使用全部工具",
|
||||||
"currentPageSelected": "当前页已选中: {{current}} / {{total}}",
|
"filterRoleAll": "全部",
|
||||||
"totalSelected": "总计已选中: {{current}} / {{total}}",
|
"filterRoleOn": "本角色已开",
|
||||||
|
"filterRoleOff": "本角色已关",
|
||||||
|
"statsPageLinked": "本页已勾选: {{current}} / {{total}}",
|
||||||
|
"statsPageLinkedTitle": "勾选=关联到本角色;与 MCP 里是否开启无关",
|
||||||
|
"statsRoleLinked": "本角色已勾选: {{current}} / {{max}}",
|
||||||
|
"statsRoleLinkedTitle": "分子为全库勾选数(仅 MCP 为开的工具);分母为 MCP 已开工具总数,与「MCP管理」里筛选 MCP已开 的条数一致",
|
||||||
|
"statsRoleLinkedNoMax": "本角色已勾选: {{current}}(请先切到「全部」且无搜索,加载一页以同步上限)",
|
||||||
|
"statsRoleLinkedNoMaxTitle": "尚未缓存 MCP 已开总数",
|
||||||
|
"statsRoleUsesAll": "工具策略: 使用全部 MCP 已开工具({{mcpOn}} 个)· 全库共 {{all}} 个(含 MCP 已关)",
|
||||||
|
"statsRoleUsesAllTitle": "与 MCP 管理中「MCP已开」数量一致;未单独限定工具清单",
|
||||||
|
"statsListScopeAll": "当前列表: 全部 {{n}} 条",
|
||||||
|
"statsListScopeRoleOn": "当前列表: 本角色已关联 {{n}} 条",
|
||||||
|
"statsListScopeRoleOff": "当前列表: 本角色未关联 {{n}} 条",
|
||||||
"usingAllEnabledTools": "(使用所有已启用工具)",
|
"usingAllEnabledTools": "(使用所有已启用工具)",
|
||||||
"currentPageSelectedTitle": "当前页选中的工具数(只统计已启用的工具)",
|
|
||||||
"totalSelectedTitle": "角色已关联的工具总数(基于角色实际配置)",
|
|
||||||
"skillsSelectedCount": "已选择 {{count}} / {{total}}",
|
|
||||||
"loadToolsFailed": "加载工具列表失败",
|
"loadToolsFailed": "加载工具列表失败",
|
||||||
"loadSkillsFailed": "加载skills列表失败",
|
|
||||||
"cannotDeleteDefaultRole": "不能删除默认角色",
|
"cannotDeleteDefaultRole": "不能删除默认角色",
|
||||||
"noMatchingSkills": "没有找到匹配的skills",
|
|
||||||
"noSkillsAvailable": "暂无可用skills",
|
|
||||||
"usingAllTools": "使用所有工具",
|
"usingAllTools": "使用所有工具",
|
||||||
"andNMore": " 等 {{count}} 个",
|
"andNMore": " 等 {{count}} 个",
|
||||||
"toolsLabel": "工具:",
|
"toolsLabel": "工具:",
|
||||||
@@ -1742,6 +1818,11 @@
|
|||||||
"prevPage": "上一页",
|
"prevPage": "上一页",
|
||||||
"pageOf": "第 {{page}} / {{total}} 页",
|
"pageOf": "第 {{page}} / {{total}} 页",
|
||||||
"nextPage": "下一页",
|
"nextPage": "下一页",
|
||||||
"lastPage": "末页"
|
"lastPage": "末页",
|
||||||
|
"mcpDisabledBadge": "MCP已关",
|
||||||
|
"mcpDisabledBadgeTitle": "MCP 管理里该工具为关闭;勾选只表示想关联到本角色,实际调用需先在 MCP 中打开",
|
||||||
|
"roleFilterOnBanner": "以下为「已勾选、关联到本角色」的工具(与 MCP 管理里全局开/关无关)。",
|
||||||
|
"roleFilterOffBanner": "以下为「未勾选、未关联到本角色」的工具。",
|
||||||
|
"checkboxLinkTitle": "勾选表示本角色关联使用该工具"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+306
-328
@@ -7,9 +7,22 @@ let roles = [];
|
|||||||
let rolesSearchKeyword = ''; // 角色搜索关键词
|
let rolesSearchKeyword = ''; // 角色搜索关键词
|
||||||
let rolesSearchTimeout = null; // 搜索防抖定时器
|
let rolesSearchTimeout = null; // 搜索防抖定时器
|
||||||
let allRoleTools = []; // 存储所有工具列表(用于角色工具选择)
|
let allRoleTools = []; // 存储所有工具列表(用于角色工具选择)
|
||||||
|
// 与 MCP 工具配置共用 localStorage,便于统一运维习惯
|
||||||
|
function getRoleToolsPageSize() {
|
||||||
|
const saved = localStorage.getItem('toolsPageSize');
|
||||||
|
const n = saved ? parseInt(saved, 10) : 20;
|
||||||
|
return isNaN(n) || n < 1 ? 20 : n;
|
||||||
|
}
|
||||||
|
// 本角色关联筛选: '' = 全部, 'role_on' = 本角色已勾选关联, 'role_off' = 本角色未关联
|
||||||
|
let roleToolsStatusFilter = '';
|
||||||
|
/** 按角色关联筛选时缓存全量列表(匹配当前搜索),避免翻页丢状态 */
|
||||||
|
let roleToolsListCacheFull = [];
|
||||||
|
let roleToolsListCacheSearch = '';
|
||||||
|
/** 是否使用客户端分页(角色关联筛选模式下为 true) */
|
||||||
|
let roleToolsClientMode = false;
|
||||||
let roleToolsPagination = {
|
let roleToolsPagination = {
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: getRoleToolsPageSize(),
|
||||||
total: 0,
|
total: 0,
|
||||||
totalPages: 1
|
totalPages: 1
|
||||||
};
|
};
|
||||||
@@ -17,13 +30,11 @@ let roleToolsSearchKeyword = ''; // 工具搜索关键词
|
|||||||
let roleToolStateMap = new Map(); // 工具状态映射:toolKey -> { enabled: boolean, ... }
|
let roleToolStateMap = new Map(); // 工具状态映射:toolKey -> { enabled: boolean, ... }
|
||||||
let roleUsesAllTools = false; // 标记角色是否使用所有工具(当没有配置tools时)
|
let roleUsesAllTools = false; // 标记角色是否使用所有工具(当没有配置tools时)
|
||||||
let totalEnabledToolsInMCP = 0; // 已启用的工具总数(从MCP管理中获取,从API响应中获取)
|
let totalEnabledToolsInMCP = 0; // 已启用的工具总数(从MCP管理中获取,从API响应中获取)
|
||||||
|
// 仅在「无状态筛选、无搜索」的请求结果上更新,供统计条分母使用(避免筛选后 total 变小导致 25/9 这类错误)
|
||||||
|
let roleToolsStatsGrandTotal = 0; // 工具总条数(与 MCP 列表「全部」一致)
|
||||||
|
let roleToolsStatsMcpEnabledTotal = 0; // MCP 全局已启用工具数
|
||||||
let roleConfiguredTools = new Set(); // 角色配置的工具列表(用于确定哪些工具应该被选中)
|
let roleConfiguredTools = new Set(); // 角色配置的工具列表(用于确定哪些工具应该被选中)
|
||||||
|
|
||||||
// Skills相关
|
|
||||||
let allRoleSkills = []; // 存储所有skills列表
|
|
||||||
let roleSkillsSearchKeyword = ''; // Skills搜索关键词
|
|
||||||
let roleSelectedSkills = new Set(); // 选中的skills集合
|
|
||||||
|
|
||||||
// 对角色列表进行排序:默认角色排在第一个,其他按名称排序
|
// 对角色列表进行排序:默认角色排在第一个,其他按名称排序
|
||||||
function sortRoles(rolesArray) {
|
function sortRoles(rolesArray) {
|
||||||
const sortedRoles = [...rolesArray];
|
const sortedRoles = [...rolesArray];
|
||||||
@@ -418,6 +429,91 @@ function getToolKey(tool) {
|
|||||||
return tool.name;
|
return tool.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将单个工具合并进 roleToolStateMap(与 loadRoleTools 中单条逻辑一致)
|
||||||
|
function mergeToolIntoRoleStateMap(tool) {
|
||||||
|
const toolKey = getToolKey(tool);
|
||||||
|
if (!roleToolStateMap.has(toolKey)) {
|
||||||
|
let enabled = false;
|
||||||
|
if (roleUsesAllTools) {
|
||||||
|
enabled = tool.enabled ? true : false;
|
||||||
|
} else {
|
||||||
|
enabled = roleConfiguredTools.has(toolKey);
|
||||||
|
}
|
||||||
|
roleToolStateMap.set(toolKey, {
|
||||||
|
enabled: enabled,
|
||||||
|
is_external: tool.is_external || false,
|
||||||
|
external_mcp: tool.external_mcp || '',
|
||||||
|
name: tool.name,
|
||||||
|
mcpEnabled: tool.enabled
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const state = roleToolStateMap.get(toolKey);
|
||||||
|
if (roleUsesAllTools && tool.enabled) {
|
||||||
|
state.enabled = true;
|
||||||
|
}
|
||||||
|
state.is_external = tool.is_external || false;
|
||||||
|
state.external_mcp = tool.external_mcp || '';
|
||||||
|
state.mcpEnabled = tool.enabled;
|
||||||
|
if (!state.name || state.name === toolKey.split('::').pop()) {
|
||||||
|
state.name = tool.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleLinkedForTool(toolKey, tool) {
|
||||||
|
if (roleToolStateMap.has(toolKey)) {
|
||||||
|
return !!roleToolStateMap.get(toolKey).enabled;
|
||||||
|
}
|
||||||
|
if (roleUsesAllTools) {
|
||||||
|
return tool.enabled !== false;
|
||||||
|
}
|
||||||
|
return roleConfiguredTools.has(toolKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRoleLinkFilteredTools() {
|
||||||
|
if (!roleToolsListCacheFull.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return roleToolsListCacheFull.filter(tool => {
|
||||||
|
const key = getToolKey(tool);
|
||||||
|
const linked = getRoleLinkedForTool(key, tool);
|
||||||
|
if (roleToolsStatusFilter === 'role_on') {
|
||||||
|
return linked;
|
||||||
|
}
|
||||||
|
if (roleToolsStatusFilter === 'role_off') {
|
||||||
|
return !linked;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllRoleToolsIntoCache(searchKeyword) {
|
||||||
|
const pageSize = 100;
|
||||||
|
let page = 1;
|
||||||
|
const all = [];
|
||||||
|
let totalPages = 1;
|
||||||
|
do {
|
||||||
|
let url = `/api/config/tools?page=${page}&page_size=${pageSize}`;
|
||||||
|
if (searchKeyword) {
|
||||||
|
url += `&search=${encodeURIComponent(searchKeyword)}`;
|
||||||
|
}
|
||||||
|
const response = await apiFetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取工具列表失败');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
const tools = result.tools || [];
|
||||||
|
tools.forEach(tool => mergeToolIntoRoleStateMap(tool));
|
||||||
|
all.push(...tools);
|
||||||
|
totalPages = Math.max(1, result.total_pages || 1);
|
||||||
|
page++;
|
||||||
|
} while (page <= totalPages);
|
||||||
|
roleToolsListCacheFull = all;
|
||||||
|
roleToolsStatsGrandTotal = all.length;
|
||||||
|
roleToolsStatsMcpEnabledTotal = all.filter(t => t.enabled !== false).length;
|
||||||
|
totalEnabledToolsInMCP = roleToolsStatsMcpEnabledTotal;
|
||||||
|
}
|
||||||
|
|
||||||
// 保存当前页的工具状态到全局映射
|
// 保存当前页的工具状态到全局映射
|
||||||
function saveCurrentRolePageToolStates() {
|
function saveCurrentRolePageToolStates() {
|
||||||
document.querySelectorAll('#role-tools-list .role-tool-item').forEach(item => {
|
document.querySelectorAll('#role-tools-list .role-tool-item').forEach(item => {
|
||||||
@@ -444,72 +540,70 @@ async function loadRoleTools(page = 1, searchKeyword = '') {
|
|||||||
try {
|
try {
|
||||||
// 在加载新页面之前,先保存当前页的状态到全局映射
|
// 在加载新页面之前,先保存当前页的状态到全局映射
|
||||||
saveCurrentRolePageToolStates();
|
saveCurrentRolePageToolStates();
|
||||||
|
|
||||||
const pageSize = roleToolsPagination.pageSize;
|
const pageSize = roleToolsPagination.pageSize;
|
||||||
let url = `/api/config/tools?page=${page}&page_size=${pageSize}`;
|
const needRoleLinkFilter =
|
||||||
if (searchKeyword) {
|
roleToolsStatusFilter === 'role_on' || roleToolsStatusFilter === 'role_off';
|
||||||
url += `&search=${encodeURIComponent(searchKeyword)}`;
|
|
||||||
}
|
if (needRoleLinkFilter) {
|
||||||
|
roleToolsClientMode = true;
|
||||||
const response = await apiFetch(url);
|
const searchChanged = searchKeyword !== roleToolsListCacheSearch;
|
||||||
if (!response.ok) {
|
if (searchChanged || roleToolsListCacheFull.length === 0) {
|
||||||
throw new Error('获取工具列表失败');
|
await fetchAllRoleToolsIntoCache(searchKeyword);
|
||||||
}
|
roleToolsListCacheSearch = searchKeyword;
|
||||||
|
}
|
||||||
const result = await response.json();
|
const filtered = computeRoleLinkFilteredTools();
|
||||||
allRoleTools = result.tools || [];
|
const total = filtered.length;
|
||||||
roleToolsPagination = {
|
let totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
|
||||||
page: result.page || page,
|
let p = page;
|
||||||
pageSize: result.page_size || pageSize,
|
if (p > totalPages) {
|
||||||
total: result.total || 0,
|
p = totalPages;
|
||||||
totalPages: result.total_pages || 1
|
}
|
||||||
};
|
if (p < 1) {
|
||||||
|
p = 1;
|
||||||
// 更新已启用的工具总数(从API响应中获取)
|
}
|
||||||
if (result.total_enabled !== undefined) {
|
roleToolsPagination = {
|
||||||
totalEnabledToolsInMCP = result.total_enabled;
|
page: p,
|
||||||
}
|
pageSize,
|
||||||
|
total,
|
||||||
// 初始化工具状态映射(如果工具不在映射中,使用服务器返回的状态)
|
totalPages
|
||||||
// 但要注意:如果工具已经在映射中(比如编辑角色时预先设置的选中工具),则保留映射中的状态
|
};
|
||||||
allRoleTools.forEach(tool => {
|
allRoleTools = filtered.slice((p - 1) * pageSize, p * pageSize);
|
||||||
const toolKey = getToolKey(tool);
|
} else {
|
||||||
if (!roleToolStateMap.has(toolKey)) {
|
roleToolsClientMode = false;
|
||||||
// 工具不在映射中
|
roleToolsListCacheFull = [];
|
||||||
let enabled = false;
|
roleToolsListCacheSearch = '';
|
||||||
if (roleUsesAllTools) {
|
|
||||||
// 如果使用所有工具,且工具在MCP管理中已启用,则标记为选中
|
let url = `/api/config/tools?page=${page}&page_size=${pageSize}`;
|
||||||
enabled = tool.enabled ? true : false;
|
if (searchKeyword) {
|
||||||
} else {
|
url += `&search=${encodeURIComponent(searchKeyword)}`;
|
||||||
// 如果不使用所有工具,只有工具在角色配置的工具列表中才标记为选中
|
}
|
||||||
enabled = roleConfiguredTools.has(toolKey);
|
|
||||||
}
|
const response = await apiFetch(url);
|
||||||
roleToolStateMap.set(toolKey, {
|
if (!response.ok) {
|
||||||
enabled: enabled,
|
throw new Error('获取工具列表失败');
|
||||||
is_external: tool.is_external || false,
|
}
|
||||||
external_mcp: tool.external_mcp || '',
|
|
||||||
name: tool.name,
|
const result = await response.json();
|
||||||
mcpEnabled: tool.enabled // 保存MCP管理中的原始启用状态
|
allRoleTools = result.tools || [];
|
||||||
});
|
roleToolsPagination = {
|
||||||
} else {
|
page: result.page || page,
|
||||||
// 工具已在映射中(可能是预先设置的选中工具或用户手动选择的),保留映射中的状态
|
pageSize: result.page_size || pageSize,
|
||||||
// 注意:即使使用所有工具,也不要强制覆盖用户已取消的工具选择
|
total: result.total || 0,
|
||||||
const state = roleToolStateMap.get(toolKey);
|
totalPages: result.total_pages || 1
|
||||||
// 如果使用所有工具,且工具在MCP管理中已启用,确保标记为选中
|
};
|
||||||
if (roleUsesAllTools && tool.enabled) {
|
|
||||||
// 使用所有工具时,确保所有已启用的工具都被选中
|
if (roleToolsStatusFilter === '' && !searchKeyword) {
|
||||||
state.enabled = true;
|
roleToolsStatsGrandTotal = result.total || 0;
|
||||||
}
|
if (result.total_enabled !== undefined) {
|
||||||
// 如果不使用所有工具,保留映射中的状态(不要覆盖,因为状态已经在初始化时正确设置了)
|
roleToolsStatsMcpEnabledTotal = result.total_enabled;
|
||||||
state.is_external = tool.is_external || false;
|
totalEnabledToolsInMCP = result.total_enabled;
|
||||||
state.external_mcp = tool.external_mcp || '';
|
|
||||||
state.mcpEnabled = tool.enabled; // 更新MCP管理中的原始启用状态
|
|
||||||
if (!state.name || state.name === toolKey.split('::').pop()) {
|
|
||||||
state.name = tool.name; // 更新工具名称
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
allRoleTools.forEach(tool => mergeToolIntoRoleStateMap(tool));
|
||||||
|
}
|
||||||
|
|
||||||
renderRoleToolsList();
|
renderRoleToolsList();
|
||||||
renderRoleToolsPagination();
|
renderRoleToolsPagination();
|
||||||
updateRoleToolsStats();
|
updateRoleToolsStats();
|
||||||
@@ -529,6 +623,20 @@ function renderRoleToolsList() {
|
|||||||
|
|
||||||
// 清除加载提示和旧内容
|
// 清除加载提示和旧内容
|
||||||
toolsList.innerHTML = '';
|
toolsList.innerHTML = '';
|
||||||
|
|
||||||
|
if (roleToolsStatusFilter === 'role_on') {
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.className = 'role-tools-filter-banner role-tools-filter-banner-on';
|
||||||
|
banner.setAttribute('role', 'status');
|
||||||
|
banner.textContent = _t('roleModal.roleFilterOnBanner');
|
||||||
|
toolsList.appendChild(banner);
|
||||||
|
} else if (roleToolsStatusFilter === 'role_off') {
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.className = 'role-tools-filter-banner role-tools-filter-banner-off';
|
||||||
|
banner.setAttribute('role', 'status');
|
||||||
|
banner.textContent = _t('roleModal.roleFilterOffBanner');
|
||||||
|
toolsList.appendChild(banner);
|
||||||
|
}
|
||||||
|
|
||||||
const listContainer = document.createElement('div');
|
const listContainer = document.createElement('div');
|
||||||
listContainer.className = 'role-tools-list-items';
|
listContainer.className = 'role-tools-list-items';
|
||||||
@@ -539,6 +647,8 @@ function renderRoleToolsList() {
|
|||||||
toolsList.appendChild(listContainer);
|
toolsList.appendChild(listContainer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chkTitle = escapeHtml(_t('roleModal.checkboxLinkTitle'));
|
||||||
|
|
||||||
allRoleTools.forEach(tool => {
|
allRoleTools.forEach(tool => {
|
||||||
const toolKey = getToolKey(tool);
|
const toolKey = getToolKey(tool);
|
||||||
@@ -564,17 +674,22 @@ function renderRoleToolsList() {
|
|||||||
const badgeTitle = externalMcpName ? `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}` : '外部MCP工具';
|
const badgeTitle = externalMcpName ? `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}` : '外部MCP工具';
|
||||||
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
|
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
|
||||||
}
|
}
|
||||||
|
let mcpDisabledBadge = '';
|
||||||
|
if (tool.enabled === false) {
|
||||||
|
mcpDisabledBadge = `<span class="role-tool-mcp-disabled-badge" title="${escapeHtml(_t('roleModal.mcpDisabledBadgeTitle'))}">${escapeHtml(_t('roleModal.mcpDisabledBadge'))}</span>`;
|
||||||
|
}
|
||||||
// 生成唯一的checkbox id
|
// 生成唯一的checkbox id
|
||||||
const checkboxId = `role-tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
|
const checkboxId = `role-tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
|
||||||
|
|
||||||
toolItem.innerHTML = `
|
toolItem.innerHTML = `
|
||||||
<input type="checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''}
|
<input type="checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''}
|
||||||
|
title="${chkTitle}" aria-label="${chkTitle}"
|
||||||
onchange="handleRoleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
|
onchange="handleRoleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
|
||||||
<div class="role-tool-item-info">
|
<div class="role-tool-item-info">
|
||||||
<div class="role-tool-item-name">
|
<div class="role-tool-item-name">
|
||||||
${escapeHtml(tool.name)}
|
${escapeHtml(tool.name)}
|
||||||
${externalBadge}
|
${externalBadge}
|
||||||
|
${mcpDisabledBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="role-tool-item-desc">${escapeHtml(tool.description || '无描述')}</div>
|
<div class="role-tool-item-desc">${escapeHtml(tool.description || '无描述')}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -585,7 +700,7 @@ function renderRoleToolsList() {
|
|||||||
toolsList.appendChild(listContainer);
|
toolsList.appendChild(listContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染工具列表分页控件
|
// 渲染工具列表分页控件(始终展示范围与每页条数,便于在仅一页时仍可调整 page size)
|
||||||
function renderRoleToolsPagination() {
|
function renderRoleToolsPagination() {
|
||||||
const toolsList = document.getElementById('role-tools-list');
|
const toolsList = document.getElementById('role-tools-list');
|
||||||
if (!toolsList) return;
|
if (!toolsList) return;
|
||||||
@@ -596,34 +711,78 @@ function renderRoleToolsPagination() {
|
|||||||
oldPagination.remove();
|
oldPagination.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果只有一页或没有数据,不显示分页
|
|
||||||
if (roleToolsPagination.totalPages <= 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pagination = document.createElement('div');
|
const pagination = document.createElement('div');
|
||||||
pagination.className = 'role-tools-pagination';
|
pagination.className = 'role-tools-pagination';
|
||||||
|
|
||||||
const { page, totalPages, total } = roleToolsPagination;
|
const { page, totalPages, total, pageSize } = roleToolsPagination;
|
||||||
const startItem = (page - 1) * roleToolsPagination.pageSize + 1;
|
const startItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
const endItem = Math.min(page * roleToolsPagination.pageSize, total);
|
const endItem = total === 0 ? 0 : Math.min(page * pageSize, total);
|
||||||
|
const savedPageSize = getRoleToolsPageSize();
|
||||||
|
const perPageLabel = typeof window.t === 'function' ? window.t('mcp.perPage') : '每页';
|
||||||
|
|
||||||
const paginationShowText = _t('roleModal.paginationShow', { start: startItem, end: endItem, total: total }) +
|
const paginationShowText = _t('roleModal.paginationShow', { start: startItem, end: endItem, total: total }) +
|
||||||
(roleToolsSearchKeyword ? _t('roleModal.paginationSearch', { keyword: roleToolsSearchKeyword }) : '');
|
(roleToolsSearchKeyword ? _t('roleModal.paginationSearch', { keyword: roleToolsSearchKeyword }) : '');
|
||||||
|
const navDisabled = total === 0 || totalPages <= 1;
|
||||||
pagination.innerHTML = `
|
pagination.innerHTML = `
|
||||||
<div class="pagination-info">${paginationShowText}</div>
|
<div class="pagination-info">${paginationShowText}</div>
|
||||||
|
<div class="pagination-page-size">
|
||||||
|
<label for="role-tools-page-size-pagination">${escapeHtml(perPageLabel)}</label>
|
||||||
|
<select id="role-tools-page-size-pagination" onchange="changeRoleToolsPageSize()">
|
||||||
|
<option value="10" ${savedPageSize === 10 ? 'selected' : ''}>10</option>
|
||||||
|
<option value="20" ${savedPageSize === 20 ? 'selected' : ''}>20</option>
|
||||||
|
<option value="50" ${savedPageSize === 50 ? 'selected' : ''}>50</option>
|
||||||
|
<option value="100" ${savedPageSize === 100 ? 'selected' : ''}>100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="pagination-controls">
|
<div class="pagination-controls">
|
||||||
<button class="btn-secondary" onclick="loadRoleTools(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${_t('roleModal.firstPage')}</button>
|
<button class="btn-secondary" onclick="loadRoleTools(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.firstPage')}</button>
|
||||||
<button class="btn-secondary" onclick="loadRoleTools(${page - 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${_t('roleModal.prevPage')}</button>
|
<button class="btn-secondary" onclick="loadRoleTools(${page - 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.prevPage')}</button>
|
||||||
<span class="pagination-page">${_t('roleModal.pageOf', { page: page, total: totalPages })}</span>
|
<span class="pagination-page">${_t('roleModal.pageOf', { page: page, total: totalPages })}</span>
|
||||||
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${_t('roleModal.nextPage')}</button>
|
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.nextPage')}</button>
|
||||||
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${_t('roleModal.lastPage')}</button>
|
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.lastPage')}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
toolsList.appendChild(pagination);
|
toolsList.appendChild(pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncRoleToolsFilterButtons() {
|
||||||
|
const wrap = document.getElementById('role-tools-status-filter');
|
||||||
|
if (!wrap) return;
|
||||||
|
wrap.querySelectorAll('.btn-filter').forEach(btn => {
|
||||||
|
const v = btn.getAttribute('data-filter');
|
||||||
|
const filterVal = v === null || v === undefined ? '' : String(v);
|
||||||
|
btn.classList.toggle('active', filterVal === roleToolsStatusFilter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleToolsListScopeLine() {
|
||||||
|
const n = roleToolsPagination.total || 0;
|
||||||
|
if (roleToolsStatusFilter === 'role_on') {
|
||||||
|
return _t('roleModal.statsListScopeRoleOn', { n: n });
|
||||||
|
}
|
||||||
|
if (roleToolsStatusFilter === 'role_off') {
|
||||||
|
return _t('roleModal.statsListScopeRoleOff', { n: n });
|
||||||
|
}
|
||||||
|
return _t('roleModal.statsListScopeAll', { n: n });
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRoleToolsByStatus(status) {
|
||||||
|
roleToolsStatusFilter = status;
|
||||||
|
syncRoleToolsFilterButtons();
|
||||||
|
loadRoleTools(1, roleToolsSearchKeyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeRoleToolsPageSize() {
|
||||||
|
const sel = document.getElementById('role-tools-page-size-pagination');
|
||||||
|
if (!sel) return;
|
||||||
|
const newPageSize = parseInt(sel.value, 10);
|
||||||
|
if (isNaN(newPageSize) || newPageSize < 1) return;
|
||||||
|
localStorage.setItem('toolsPageSize', String(newPageSize));
|
||||||
|
roleToolsPagination.pageSize = newPageSize;
|
||||||
|
await loadRoleTools(1, roleToolsSearchKeyword);
|
||||||
|
}
|
||||||
|
|
||||||
// 处理工具checkbox状态变化
|
// 处理工具checkbox状态变化
|
||||||
function handleRoleToolCheckboxChange(toolKey, enabled) {
|
function handleRoleToolCheckboxChange(toolKey, enabled) {
|
||||||
const toolItem = document.querySelector(`.role-tool-item[data-tool-key="${toolKey}"]`);
|
const toolItem = document.querySelector(`.role-tool-item[data-tool-key="${toolKey}"]`);
|
||||||
@@ -640,7 +799,14 @@ function handleRoleToolCheckboxChange(toolKey, enabled) {
|
|||||||
mcpEnabled: existingState ? existingState.mcpEnabled : true // 保留MCP启用状态
|
mcpEnabled: existingState ? existingState.mcpEnabled : true // 保留MCP启用状态
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updateRoleToolsStats();
|
if (
|
||||||
|
roleToolsClientMode &&
|
||||||
|
(roleToolsStatusFilter === 'role_on' || roleToolsStatusFilter === 'role_off')
|
||||||
|
) {
|
||||||
|
loadRoleTools(roleToolsPagination.page, roleToolsSearchKeyword);
|
||||||
|
} else {
|
||||||
|
updateRoleToolsStats();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全选工具
|
// 全选工具
|
||||||
@@ -667,7 +833,14 @@ function selectAllRoleTools() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
updateRoleToolsStats();
|
if (
|
||||||
|
roleToolsClientMode &&
|
||||||
|
(roleToolsStatusFilter === 'role_on' || roleToolsStatusFilter === 'role_off')
|
||||||
|
) {
|
||||||
|
loadRoleTools(roleToolsPagination.page, roleToolsSearchKeyword);
|
||||||
|
} else {
|
||||||
|
updateRoleToolsStats();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全不选工具
|
// 全不选工具
|
||||||
@@ -692,7 +865,14 @@ function deselectAllRoleTools() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
updateRoleToolsStats();
|
if (
|
||||||
|
roleToolsClientMode &&
|
||||||
|
(roleToolsStatusFilter === 'role_on' || roleToolsStatusFilter === 'role_off')
|
||||||
|
) {
|
||||||
|
loadRoleTools(roleToolsPagination.page, roleToolsSearchKeyword);
|
||||||
|
} else {
|
||||||
|
updateRoleToolsStats();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索工具
|
// 搜索工具
|
||||||
@@ -711,90 +891,64 @@ function clearRoleToolsSearch() {
|
|||||||
searchRoleTools('');
|
searchRoleTools('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新工具统计信息
|
// 更新工具统计信息(口径:分母「可关联上限」= 全库 MCP 已开工具数,与 MCP 管理页筛选「MCP已开」条数一致;勾选=关联本角色)
|
||||||
function updateRoleToolsStats() {
|
function updateRoleToolsStats() {
|
||||||
const statsEl = document.getElementById('role-tools-stats');
|
const statsEl = document.getElementById('role-tools-stats');
|
||||||
if (!statsEl) return;
|
if (!statsEl) return;
|
||||||
|
|
||||||
// 统计当前页已选中的工具数
|
const pageChecked = Array.from(document.querySelectorAll('#role-tools-list input[type="checkbox"]:checked')).length;
|
||||||
const currentPageEnabled = Array.from(document.querySelectorAll('#role-tools-list input[type="checkbox"]:checked')).length;
|
const pageTotal = document.querySelectorAll('#role-tools-list input[type="checkbox"]').length;
|
||||||
|
const mcpOnMax =
|
||||||
// 统计当前页已启用的工具数(在MCP管理中已启用的工具)
|
(roleToolsStatsMcpEnabledTotal > 0 ? roleToolsStatsMcpEnabledTotal : totalEnabledToolsInMCP) || 0;
|
||||||
// 优先从状态映射中获取,如果没有则从工具数据中获取
|
const grandAll =
|
||||||
let currentPageEnabledInMCP = 0;
|
(roleToolsStatsGrandTotal > 0 ? roleToolsStatsGrandTotal : roleToolsPagination.total) || 0;
|
||||||
allRoleTools.forEach(tool => {
|
const scopeLine = roleToolsListScopeLine();
|
||||||
const toolKey = getToolKey(tool);
|
|
||||||
const state = roleToolStateMap.get(toolKey);
|
|
||||||
// 如果工具在MCP管理中已启用(从状态映射或工具数据中获取),计入当前页已启用工具数
|
|
||||||
const mcpEnabled = state ? (state.mcpEnabled !== false) : (tool.enabled !== false);
|
|
||||||
if (mcpEnabled) {
|
|
||||||
currentPageEnabledInMCP++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 如果使用所有工具,使用从API获取的已启用工具总数
|
|
||||||
if (roleUsesAllTools) {
|
if (roleUsesAllTools) {
|
||||||
// 使用从API响应中获取的已启用工具总数
|
|
||||||
const totalEnabled = totalEnabledToolsInMCP || 0;
|
|
||||||
// 当前页分母应该是当前页的总工具数(每页20个),而不是当前页已启用的工具数
|
|
||||||
const currentPageTotal = document.querySelectorAll('#role-tools-list input[type="checkbox"]').length;
|
|
||||||
// 总工具数(所有工具,包括已启用和未启用的)
|
|
||||||
const totalTools = roleToolsPagination.total || 0;
|
|
||||||
statsEl.innerHTML = `
|
statsEl.innerHTML = `
|
||||||
<span title="${_t('roleModal.currentPageSelectedTitle')}">✅ ${_t('roleModal.currentPageSelected', { current: currentPageEnabled, total: currentPageTotal })}</span>
|
<div class="role-tools-stats-row">
|
||||||
<span title="${_t('roleModal.totalSelectedTitle')}">📊 ${_t('roleModal.totalSelected', { current: totalEnabled, total: totalTools })} <em>${_t('roleModal.usingAllEnabledTools')}</em></span>
|
<span title="${escapeHtml(_t('roleModal.statsPageLinkedTitle'))}">✅ ${_t('roleModal.statsPageLinked', { current: pageChecked, total: pageTotal })}</span>
|
||||||
|
</div>
|
||||||
|
<div class="role-tools-stats-row">
|
||||||
|
<span title="${escapeHtml(_t('roleModal.statsRoleUsesAllTitle'))}">📊 ${_t('roleModal.statsRoleUsesAll', { mcpOn: mcpOnMax, all: grandAll })}</span>
|
||||||
|
</div>
|
||||||
|
<div class="role-tools-stats-hint">📋 ${escapeHtml(scopeLine)}</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统计角色实际选中的工具数(只统计在MCP管理中已启用的工具)
|
let roleLinked = 0;
|
||||||
let totalSelected = 0;
|
|
||||||
roleToolStateMap.forEach(state => {
|
roleToolStateMap.forEach(state => {
|
||||||
// 只统计在MCP管理中已启用且被角色选中的工具
|
|
||||||
if (state.enabled && state.mcpEnabled !== false) {
|
if (state.enabled && state.mcpEnabled !== false) {
|
||||||
totalSelected++;
|
roleLinked++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果当前页有未保存的状态,需要合并计算
|
|
||||||
document.querySelectorAll('#role-tools-list input[type="checkbox"]').forEach(checkbox => {
|
document.querySelectorAll('#role-tools-list input[type="checkbox"]').forEach(checkbox => {
|
||||||
const toolItem = checkbox.closest('.role-tool-item');
|
const toolItem = checkbox.closest('.role-tool-item');
|
||||||
if (toolItem) {
|
if (toolItem) {
|
||||||
const toolKey = toolItem.dataset.toolKey;
|
const toolKey = toolItem.dataset.toolKey;
|
||||||
const savedState = roleToolStateMap.get(toolKey);
|
const savedState = roleToolStateMap.get(toolKey);
|
||||||
if (savedState && savedState.enabled !== checkbox.checked && savedState.mcpEnabled !== false) {
|
if (savedState && savedState.enabled !== checkbox.checked && savedState.mcpEnabled !== false) {
|
||||||
// 状态不一致,使用checkbox状态(但只统计MCP管理中已启用的工具)
|
|
||||||
if (checkbox.checked && !savedState.enabled) {
|
if (checkbox.checked && !savedState.enabled) {
|
||||||
totalSelected++;
|
roleLinked++;
|
||||||
} else if (!checkbox.checked && savedState.enabled) {
|
} else if (!checkbox.checked && savedState.enabled) {
|
||||||
totalSelected--;
|
roleLinked--;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 角色可选择的所有已启用工具总数(应该基于MCP管理中的总数,而不是状态映射)
|
const roleRow =
|
||||||
// 因为角色可以选择任意已启用的工具,所以总数应该是所有已启用工具的总数
|
mcpOnMax > 0
|
||||||
let totalEnabledForRole = totalEnabledToolsInMCP || 0;
|
? `<span title="${escapeHtml(_t('roleModal.statsRoleLinkedTitle'))}">📊 ${_t('roleModal.statsRoleLinked', { current: roleLinked, max: mcpOnMax })}</span>`
|
||||||
|
: `<span title="${escapeHtml(_t('roleModal.statsRoleLinkedNoMaxTitle'))}">📊 ${_t('roleModal.statsRoleLinkedNoMax', { current: roleLinked })}</span>`;
|
||||||
// 如果API返回的总数为0或未设置,尝试从状态映射中统计(作为备选方案)
|
|
||||||
if (totalEnabledForRole === 0) {
|
|
||||||
roleToolStateMap.forEach(state => {
|
|
||||||
// 只统计在MCP管理中已启用的工具
|
|
||||||
if (state.mcpEnabled !== false) { // mcpEnabled 为 true 或 undefined(未设置时默认为启用)
|
|
||||||
totalEnabledForRole++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 当前页分母应该是当前页的总工具数(每页20个),而不是当前页已启用的工具数
|
|
||||||
const currentPageTotal = document.querySelectorAll('#role-tools-list input[type="checkbox"]').length;
|
|
||||||
// 总工具数(所有工具,包括已启用和未启用的)
|
|
||||||
const totalTools = roleToolsPagination.total || 0;
|
|
||||||
|
|
||||||
statsEl.innerHTML = `
|
statsEl.innerHTML = `
|
||||||
<span title="${_t('roleModal.currentPageSelectedTitle')}">✅ ${_t('roleModal.currentPageSelected', { current: currentPageEnabled, total: currentPageTotal })}</span>
|
<div class="role-tools-stats-row">
|
||||||
<span title="${_t('roleModal.totalSelectedTitle')}">📊 ${_t('roleModal.totalSelected', { current: totalSelected, total: totalTools })}</span>
|
<span title="${escapeHtml(_t('roleModal.statsPageLinkedTitle'))}">✅ ${_t('roleModal.statsPageLinked', { current: pageChecked, total: pageTotal })}</span>
|
||||||
|
</div>
|
||||||
|
<div class="role-tools-stats-row">${roleRow}</div>
|
||||||
|
<div class="role-tools-stats-hint">📋 ${escapeHtml(scopeLine)}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,24 +1047,15 @@ async function showAddRoleModal() {
|
|||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.style.display = 'none';
|
clearBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
roleToolsStatusFilter = '';
|
||||||
|
syncRoleToolsFilterButtons();
|
||||||
|
roleToolsPagination.pageSize = getRoleToolsPageSize();
|
||||||
|
|
||||||
// 清空工具列表 DOM,避免 loadRoleTools 中的 saveCurrentRolePageToolStates 读取旧状态
|
// 清空工具列表 DOM,避免 loadRoleTools 中的 saveCurrentRolePageToolStates 读取旧状态
|
||||||
if (toolsList) {
|
if (toolsList) {
|
||||||
toolsList.innerHTML = '';
|
toolsList.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置skills状态
|
|
||||||
roleSelectedSkills.clear();
|
|
||||||
roleSkillsSearchKeyword = '';
|
|
||||||
const skillsSearchInput = document.getElementById('role-skills-search');
|
|
||||||
if (skillsSearchInput) {
|
|
||||||
skillsSearchInput.value = '';
|
|
||||||
}
|
|
||||||
const skillsClearBtn = document.getElementById('role-skills-search-clear');
|
|
||||||
if (skillsClearBtn) {
|
|
||||||
skillsClearBtn.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载并渲染工具列表
|
// 加载并渲染工具列表
|
||||||
await loadRoleTools(1, '');
|
await loadRoleTools(1, '');
|
||||||
|
|
||||||
@@ -922,9 +1067,6 @@ async function showAddRoleModal() {
|
|||||||
// 确保统计信息正确更新(显示0/108)
|
// 确保统计信息正确更新(显示0/108)
|
||||||
updateRoleToolsStats();
|
updateRoleToolsStats();
|
||||||
|
|
||||||
// 加载并渲染skills列表
|
|
||||||
await loadRoleSkills();
|
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,6 +1149,9 @@ async function editRole(roleName) {
|
|||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.style.display = 'none';
|
clearBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
roleToolsStatusFilter = '';
|
||||||
|
syncRoleToolsFilterButtons();
|
||||||
|
roleToolsPagination.pageSize = getRoleToolsPageSize();
|
||||||
|
|
||||||
// 优先使用tools字段,如果没有则使用mcps字段(向后兼容)
|
// 优先使用tools字段,如果没有则使用mcps字段(向后兼容)
|
||||||
const selectedTools = role.tools || (role.mcps && role.mcps.length > 0 ? role.mcps : []);
|
const selectedTools = role.tools || (role.mcps && role.mcps.length > 0 ? role.mcps : []);
|
||||||
@@ -1084,16 +1229,6 @@ async function editRole(roleName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载并设置skills
|
|
||||||
await loadRoleSkills();
|
|
||||||
// 设置角色配置的skills
|
|
||||||
const selectedSkills = role.skills || [];
|
|
||||||
roleSelectedSkills.clear();
|
|
||||||
selectedSkills.forEach(skill => {
|
|
||||||
roleSelectedSkills.add(skill);
|
|
||||||
});
|
|
||||||
renderRoleSkills();
|
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1317,16 +1452,12 @@ async function saveRole() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取选中的skills
|
|
||||||
const skills = Array.from(roleSelectedSkills);
|
|
||||||
|
|
||||||
const roleData = {
|
const roleData = {
|
||||||
name: name,
|
name: name,
|
||||||
description: description,
|
description: description,
|
||||||
icon: icon || undefined, // 如果为空字符串,则不发送该字段
|
icon: icon || undefined, // 如果为空字符串,则不发送该字段
|
||||||
user_prompt: userPrompt,
|
user_prompt: userPrompt,
|
||||||
tools: tools, // 默认角色为空数组,表示使用所有工具
|
tools: tools, // 默认角色为空数组,表示使用所有工具
|
||||||
skills: skills, // Skills列表
|
|
||||||
enabled: enabled
|
enabled: enabled
|
||||||
};
|
};
|
||||||
const url = isEdit ? `/api/roles/${encodeURIComponent(name)}` : '/api/roles';
|
const url = isEdit ? `/api/roles/${encodeURIComponent(name)}` : '/api/roles';
|
||||||
@@ -1459,6 +1590,7 @@ if (typeof window !== 'undefined') {
|
|||||||
window.getCurrentRole = getCurrentRole;
|
window.getCurrentRole = getCurrentRole;
|
||||||
window.toggleRoleSelectionPanel = toggleRoleSelectionPanel;
|
window.toggleRoleSelectionPanel = toggleRoleSelectionPanel;
|
||||||
window.closeRoleSelectionPanel = closeRoleSelectionPanel;
|
window.closeRoleSelectionPanel = closeRoleSelectionPanel;
|
||||||
|
window.filterRoleToolsByStatus = filterRoleToolsByStatus;
|
||||||
window.currentSelectedRole = getCurrentRole();
|
window.currentSelectedRole = getCurrentRole();
|
||||||
|
|
||||||
// 监听角色变化,更新全局变量
|
// 监听角色变化,更新全局变量
|
||||||
@@ -1470,157 +1602,3 @@ if (typeof window !== 'undefined') {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Skills相关函数 ====================
|
|
||||||
|
|
||||||
// 加载skills列表
|
|
||||||
async function loadRoleSkills() {
|
|
||||||
try {
|
|
||||||
const response = await apiFetch('/api/roles/skills/list');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('加载skills列表失败');
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
allRoleSkills = data.skills || [];
|
|
||||||
renderRoleSkills();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载skills列表失败:', error);
|
|
||||||
allRoleSkills = [];
|
|
||||||
const skillsList = document.getElementById('role-skills-list');
|
|
||||||
if (skillsList) {
|
|
||||||
skillsList.innerHTML = '<div class="skills-error">' + _t('roleModal.loadSkillsFailed') + ': ' + error.message + '</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染skills列表
|
|
||||||
function renderRoleSkills() {
|
|
||||||
const skillsList = document.getElementById('role-skills-list');
|
|
||||||
if (!skillsList) return;
|
|
||||||
|
|
||||||
// 过滤skills
|
|
||||||
let filteredSkills = allRoleSkills;
|
|
||||||
if (roleSkillsSearchKeyword) {
|
|
||||||
const keyword = roleSkillsSearchKeyword.toLowerCase();
|
|
||||||
filteredSkills = allRoleSkills.filter(skill =>
|
|
||||||
skill.toLowerCase().includes(keyword)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredSkills.length === 0) {
|
|
||||||
skillsList.innerHTML = '<div class="skills-empty">' +
|
|
||||||
(roleSkillsSearchKeyword ? _t('roleModal.noMatchingSkills') : _t('roleModal.noSkillsAvailable')) +
|
|
||||||
'</div>';
|
|
||||||
updateRoleSkillsStats();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染skills列表
|
|
||||||
skillsList.innerHTML = filteredSkills.map(skill => {
|
|
||||||
const isSelected = roleSelectedSkills.has(skill);
|
|
||||||
return `
|
|
||||||
<div class="role-skill-item" data-skill="${skill}">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" class="modern-checkbox"
|
|
||||||
${isSelected ? 'checked' : ''}
|
|
||||||
onchange="toggleRoleSkill('${skill}', this.checked)" />
|
|
||||||
<span class="checkbox-custom"></span>
|
|
||||||
<span class="checkbox-text">${escapeHtml(skill)}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
updateRoleSkillsStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换skill选中状态
|
|
||||||
function toggleRoleSkill(skill, checked) {
|
|
||||||
if (checked) {
|
|
||||||
roleSelectedSkills.add(skill);
|
|
||||||
} else {
|
|
||||||
roleSelectedSkills.delete(skill);
|
|
||||||
}
|
|
||||||
updateRoleSkillsStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全选skills
|
|
||||||
function selectAllRoleSkills() {
|
|
||||||
let filteredSkills = allRoleSkills;
|
|
||||||
if (roleSkillsSearchKeyword) {
|
|
||||||
const keyword = roleSkillsSearchKeyword.toLowerCase();
|
|
||||||
filteredSkills = allRoleSkills.filter(skill =>
|
|
||||||
skill.toLowerCase().includes(keyword)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
filteredSkills.forEach(skill => {
|
|
||||||
roleSelectedSkills.add(skill);
|
|
||||||
});
|
|
||||||
renderRoleSkills();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全不选skills
|
|
||||||
function deselectAllRoleSkills() {
|
|
||||||
let filteredSkills = allRoleSkills;
|
|
||||||
if (roleSkillsSearchKeyword) {
|
|
||||||
const keyword = roleSkillsSearchKeyword.toLowerCase();
|
|
||||||
filteredSkills = allRoleSkills.filter(skill =>
|
|
||||||
skill.toLowerCase().includes(keyword)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
filteredSkills.forEach(skill => {
|
|
||||||
roleSelectedSkills.delete(skill);
|
|
||||||
});
|
|
||||||
renderRoleSkills();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索skills
|
|
||||||
function searchRoleSkills(keyword) {
|
|
||||||
roleSkillsSearchKeyword = keyword;
|
|
||||||
const clearBtn = document.getElementById('role-skills-search-clear');
|
|
||||||
if (clearBtn) {
|
|
||||||
clearBtn.style.display = keyword ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
renderRoleSkills();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除skills搜索
|
|
||||||
function clearRoleSkillsSearch() {
|
|
||||||
const searchInput = document.getElementById('role-skills-search');
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.value = '';
|
|
||||||
}
|
|
||||||
roleSkillsSearchKeyword = '';
|
|
||||||
const clearBtn = document.getElementById('role-skills-search-clear');
|
|
||||||
if (clearBtn) {
|
|
||||||
clearBtn.style.display = 'none';
|
|
||||||
}
|
|
||||||
renderRoleSkills();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新skills统计信息
|
|
||||||
function updateRoleSkillsStats() {
|
|
||||||
const statsEl = document.getElementById('role-skills-stats');
|
|
||||||
if (!statsEl) return;
|
|
||||||
|
|
||||||
let filteredSkills = allRoleSkills;
|
|
||||||
if (roleSkillsSearchKeyword) {
|
|
||||||
const keyword = roleSkillsSearchKeyword.toLowerCase();
|
|
||||||
filteredSkills = allRoleSkills.filter(skill =>
|
|
||||||
skill.toLowerCase().includes(keyword)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedCount = Array.from(roleSelectedSkills).filter(skill =>
|
|
||||||
filteredSkills.includes(skill)
|
|
||||||
).length;
|
|
||||||
|
|
||||||
statsEl.textContent = _t('roleModal.skillsSelectedCount', { count: selectedCount, total: filteredSkills.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTML转义函数
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ function switchPage(pageId) {
|
|||||||
initPage(pageId);
|
initPage(pageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
window.switchPage = switchPage;
|
||||||
|
|
||||||
// 更新导航状态
|
// 更新导航状态
|
||||||
function updateNavState(pageId) {
|
function updateNavState(pageId) {
|
||||||
@@ -159,6 +160,7 @@ function toggleSubmenu(menuId) {
|
|||||||
navItem.classList.toggle('expanded');
|
navItem.classList.toggle('expanded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
window.toggleSubmenu = toggleSubmenu;
|
||||||
|
|
||||||
// 显示子菜单弹出框
|
// 显示子菜单弹出框
|
||||||
function showSubmenuPopup(navItem, menuId) {
|
function showSubmenuPopup(navItem, menuId) {
|
||||||
@@ -427,6 +429,7 @@ function toggleSidebar() {
|
|||||||
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
|
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
window.toggleSidebar = toggleSidebar;
|
||||||
|
|
||||||
// 初始化侧边栏状态
|
// 初始化侧边栏状态
|
||||||
function initSidebarState() {
|
function initSidebarState() {
|
||||||
@@ -449,6 +452,7 @@ function toggleConversationSidebar() {
|
|||||||
localStorage.setItem('conversationSidebarCollapsed', isCollapsed ? 'true' : 'false');
|
localStorage.setItem('conversationSidebarCollapsed', isCollapsed ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
window.toggleConversationSidebar = toggleConversationSidebar;
|
||||||
|
|
||||||
// 恢复对话列表折叠状态(进入对话页时生效)
|
// 恢复对话列表折叠状态(进入对话页时生效)
|
||||||
function initConversationSidebarState() {
|
function initConversationSidebarState() {
|
||||||
@@ -463,10 +467,6 @@ function initConversationSidebarState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出函数供其他脚本使用
|
// 导出函数供其他脚本使用(与上方尽早绑定保持一致,便于外部脚本探测)
|
||||||
window.switchPage = switchPage;
|
|
||||||
window.toggleSubmenu = toggleSubmenu;
|
|
||||||
window.toggleSidebar = toggleSidebar;
|
|
||||||
window.toggleConversationSidebar = toggleConversationSidebar;
|
|
||||||
window.currentPage = function() { return currentPage; };
|
window.currentPage = function() { return currentPage; };
|
||||||
|
|
||||||
|
|||||||
+131
-23
@@ -501,26 +501,32 @@ function renderToolsList() {
|
|||||||
external_mcp: tool.external_mcp || ''
|
external_mcp: tool.external_mcp || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
// 外部工具标签,显示来源信息
|
// 外部工具标签,显示来源信息(可点击跳转到对应 MCP 卡片)
|
||||||
let externalBadge = '';
|
let externalBadge = '';
|
||||||
if (toolState.is_external || tool.is_external) {
|
if (toolState.is_external || tool.is_external) {
|
||||||
const externalMcpName = toolState.external_mcp || tool.external_mcp || '';
|
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 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工具');
|
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>`;
|
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,使用工具唯一标识符
|
// 生成唯一的checkbox id,使用工具唯一标识符
|
||||||
const checkboxId = `tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
|
const checkboxId = `tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
|
||||||
|
|
||||||
toolItem.innerHTML = `
|
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)" />
|
<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-info" onclick="toggleToolDetail(this, '${escapeHtml(toolKey)}', ${tool.is_external ? 'true' : 'false'}, '${escapeHtml(tool.external_mcp || '')}', event)">
|
||||||
<div class="tool-item-name">
|
<div class="tool-item-name">
|
||||||
${escapeHtml(tool.name)}
|
${escapeHtml(tool.name)}
|
||||||
${externalBadge}
|
${externalBadge}
|
||||||
|
<span class="tool-expand-icon">▶</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-item-desc">${escapeHtml(tool.description || (typeof window.t === 'function' ? window.t('mcp.noDescription') : '无描述'))}</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
listContainer.appendChild(toolItem);
|
listContainer.appendChild(toolItem);
|
||||||
@@ -534,6 +540,103 @@ function renderToolsList() {
|
|||||||
updateToolsStats();
|
updateToolsStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 展开/折叠工具详情面板(按需从后端加载 schema)
|
||||||
|
function toggleToolDetail(infoEl, toolKey, isExternal, externalMcp, event) {
|
||||||
|
// 点击 checkbox 或外部工具徽章时不展开
|
||||||
|
if (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;
|
||||||
|
|
||||||
|
const isOpen = detail.style.display !== 'none';
|
||||||
|
detail.style.display = isOpen ? 'none' : 'block';
|
||||||
|
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() {
|
function renderToolsPagination() {
|
||||||
const toolsList = document.getElementById('tools-list');
|
const toolsList = document.getElementById('tools-list');
|
||||||
@@ -1382,7 +1485,7 @@ function renderExternalMCPList(servers) {
|
|||||||
status === 'connecting' ? statusT('mcp.connecting') :
|
status === 'connecting' ? statusT('mcp.connecting') :
|
||||||
status === 'error' ? statusT('mcp.connectionFailed') :
|
status === 'error' ? statusT('mcp.connectionFailed') :
|
||||||
status === 'disabled' ? statusT('mcp.disabled') : statusT('mcp.disconnected');
|
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' ? '⚙️' : '🌐';
|
const transportIcon = transport === 'stdio' ? '⚙️' : '🌐';
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
@@ -1393,11 +1496,11 @@ function renderExternalMCPList(servers) {
|
|||||||
<span class="external-mcp-status ${statusClass}">${statusText}</span>
|
<span class="external-mcp-status ${statusClass}">${statusText}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="external-mcp-item-actions">
|
<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')}">
|
`<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')}
|
${status === 'connected' ? '⏸ ' + statusT('mcp.stop') : '▶ ' + statusT('mcp.start')}
|
||||||
</button>` :
|
</button>` :
|
||||||
status === 'connecting' ?
|
status === 'connecting' ?
|
||||||
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" disabled style="opacity: 0.6; cursor: not-allowed;">
|
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" disabled style="opacity: 0.6; cursor: not-allowed;">
|
||||||
⏳ ${statusT('mcp.connecting')}
|
⏳ ${statusT('mcp.connecting')}
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
@@ -1552,24 +1655,29 @@ function formatExternalMCPJSON() {
|
|||||||
|
|
||||||
// 加载示例
|
// 加载示例
|
||||||
function loadExternalMCPExample() {
|
function loadExternalMCPExample() {
|
||||||
const desc = (typeof window.t === 'function' ? window.t('externalMcpModal.exampleDescription') : '示例描述');
|
|
||||||
const example = {
|
const example = {
|
||||||
"hexstrike-ai": {
|
"my-stdio-server": {
|
||||||
command: "python3",
|
command: "python3",
|
||||||
args: [
|
args: [
|
||||||
"/path/to/script.py",
|
"${HOME}/mcp-servers/main.py",
|
||||||
"--server",
|
"--port",
|
||||||
"http://example.com"
|
"${MCP_PORT:-3000}"
|
||||||
],
|
],
|
||||||
description: desc,
|
env: {
|
||||||
|
"API_KEY": "${API_KEY}",
|
||||||
|
"LOG_LEVEL": "${LOG_LEVEL:-INFO}"
|
||||||
|
},
|
||||||
timeout: 300
|
timeout: 300
|
||||||
},
|
},
|
||||||
"cyberstrike-ai-http": {
|
"my-http-server": {
|
||||||
transport: "http",
|
type: "http",
|
||||||
url: "http://127.0.0.1:8081/mcp"
|
url: "https://mcp.example.com/mcp",
|
||||||
|
headers: {
|
||||||
|
"Authorization": "Bearer ${MCP_TOKEN}"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cyberstrike-ai-sse": {
|
"my-sse-server": {
|
||||||
transport: "sse",
|
type: "sse",
|
||||||
url: "http://127.0.0.1:8081/mcp/sse"
|
url: "http://127.0.0.1:8081/mcp/sse"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1642,8 +1750,8 @@ async function saveExternalMCP() {
|
|||||||
// 移除 external_mcp_enable 字段(由按钮控制,但保留 enabled/disabled 用于向后兼容)
|
// 移除 external_mcp_enable 字段(由按钮控制,但保留 enabled/disabled 用于向后兼容)
|
||||||
delete config.external_mcp_enable;
|
delete config.external_mcp_enable;
|
||||||
|
|
||||||
// 验证配置内容
|
// 验证配置内容(同时支持官方 type 字段和旧版 transport 字段)
|
||||||
const transport = config.transport || (config.command ? 'stdio' : config.url ? 'http' : '');
|
const transport = config.type || config.transport || (config.command ? 'stdio' : config.url ? 'http' : '');
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
errorDiv.textContent = t('mcp.configNeedCommand', { name: name });
|
errorDiv.textContent = t('mcp.configNeedCommand', { name: name });
|
||||||
errorDiv.style.display = 'block';
|
errorDiv.style.display = 'block';
|
||||||
|
|||||||
+503
-84
@@ -28,6 +28,8 @@ let webshellClearInProgress = false;
|
|||||||
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话
|
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话
|
||||||
let webshellAiConvMap = {};
|
let webshellAiConvMap = {};
|
||||||
let webshellAiSending = false;
|
let webshellAiSending = false;
|
||||||
|
let webshellAiAbortController = null; // AbortController for current AI stream
|
||||||
|
let webshellAiStreamReader = null; // Current ReadableStreamDefaultReader
|
||||||
let webshellDbConfigByConn = {};
|
let webshellDbConfigByConn = {};
|
||||||
let webshellDirTreeByConn = {};
|
let webshellDirTreeByConn = {};
|
||||||
let webshellDirExpandedByConn = {};
|
let webshellDirExpandedByConn = {};
|
||||||
@@ -70,6 +72,237 @@ function resolveWebshellAiStreamRequest() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── WebShell AI 助手:角色 + 对话模式选择器(与主「对话」页对齐) ───
|
||||||
|
|
||||||
|
let wsRolesCache = null; // 缓存 /api/roles 结果
|
||||||
|
|
||||||
|
function wsLoadRoles() {
|
||||||
|
if (typeof apiFetch === 'undefined') return;
|
||||||
|
apiFetch('/api/roles').then(function (r) { return r.json(); }).then(function (data) {
|
||||||
|
wsRolesCache = (data && Array.isArray(data.roles)) ? data.roles : [];
|
||||||
|
wsRenderRoleList();
|
||||||
|
wsUpdateRoleSelectorDisplay();
|
||||||
|
}).catch(function () { /* ignore */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsUpdateRoleSelectorDisplay() {
|
||||||
|
var iconEl = document.getElementById('ws-role-selector-icon');
|
||||||
|
var textEl = document.getElementById('ws-role-selector-text');
|
||||||
|
if (!iconEl || !textEl) return;
|
||||||
|
var cur = (typeof getCurrentRole === 'function') ? getCurrentRole() : (localStorage.getItem('currentRole') || '');
|
||||||
|
if (!cur) {
|
||||||
|
iconEl.textContent = '\ud83d\udd35';
|
||||||
|
textEl.textContent = (typeof window.t === 'function' ? window.t('chat.defaultRole') : '') || '默认';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wsRolesCache) {
|
||||||
|
for (var i = 0; i < wsRolesCache.length; i++) {
|
||||||
|
if (wsRolesCache[i].name === cur) {
|
||||||
|
iconEl.textContent = wsRolesCache[i].icon || '\ud83d\udd35';
|
||||||
|
textEl.textContent = cur;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
iconEl.textContent = '\ud83d\udd35';
|
||||||
|
textEl.textContent = cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsRenderRoleList() {
|
||||||
|
var listEl = document.getElementById('ws-role-selection-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
var cur = (typeof getCurrentRole === 'function') ? getCurrentRole() : (localStorage.getItem('currentRole') || '');
|
||||||
|
var html = '';
|
||||||
|
// 默认角色
|
||||||
|
var defSelected = !cur ? ' selected' : '';
|
||||||
|
html += '<button type="button" class="role-selection-item-main' + defSelected + '" onclick="wsSelectRole(\'\')">' +
|
||||||
|
'<div class="role-selection-item-icon-main">\ud83d\udd35</div>' +
|
||||||
|
'<div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' +
|
||||||
|
(wsTOr('chat.defaultRole', '默认')) +
|
||||||
|
'</div><div class="role-selection-item-description-main">' +
|
||||||
|
(wsTOr('roles.defaultRoleDescription', '默认角色,不额外携带用户提示词,使用所有工具')) +
|
||||||
|
'</div></div>' +
|
||||||
|
(defSelected ? '<div class="role-selection-checkmark-main">\u2713</div>' : '') +
|
||||||
|
'</button>';
|
||||||
|
if (wsRolesCache) {
|
||||||
|
for (var i = 0; i < wsRolesCache.length; i++) {
|
||||||
|
var r = wsRolesCache[i];
|
||||||
|
if (!r.enabled) continue;
|
||||||
|
if (r.name === '默认') continue; // 已在上方硬编码默认角色,跳过 API 返回的默认项
|
||||||
|
var sel = (r.name === cur) ? ' selected' : '';
|
||||||
|
html += '<button type="button" class="role-selection-item-main' + sel + '" onclick="wsSelectRole(\'' + r.name.replace(/'/g, "\\'") + '\')">' +
|
||||||
|
'<div class="role-selection-item-icon-main">' + (r.icon || '\ud83d\udd35') + '</div>' +
|
||||||
|
'<div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + r.name + '</div>' +
|
||||||
|
'<div class="role-selection-item-description-main">' + (r.description || '').substring(0, 60) + '</div></div>' +
|
||||||
|
(sel ? '<div class="role-selection-checkmark-main">\u2713</div>' : '') +
|
||||||
|
'</button>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsSelectRole(name) {
|
||||||
|
var roleName = name || '';
|
||||||
|
// 使用主页的 handleRoleChange 来同步 roles.js 内部状态和 localStorage
|
||||||
|
if (typeof handleRoleChange === 'function') {
|
||||||
|
try { handleRoleChange(roleName); } catch (e) { /* */ }
|
||||||
|
} else {
|
||||||
|
try { localStorage.setItem('currentRole', roleName); } catch (e) { /* */ }
|
||||||
|
}
|
||||||
|
if (typeof window.currentSelectedRole !== 'undefined') window.currentSelectedRole = roleName;
|
||||||
|
wsUpdateRoleSelectorDisplay();
|
||||||
|
wsRenderRoleList();
|
||||||
|
wsCloseRolePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsToggleRolePanel() {
|
||||||
|
var panel = document.getElementById('ws-role-selection-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
var isOpen = panel.style.display === 'flex';
|
||||||
|
if (isOpen) { wsCloseRolePanel(); return; }
|
||||||
|
wsCloseAgentModePanel();
|
||||||
|
panel.style.display = 'flex';
|
||||||
|
}
|
||||||
|
function wsCloseRolePanel() {
|
||||||
|
var panel = document.getElementById('ws-role-selection-panel');
|
||||||
|
if (panel) panel.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 对话模式选择器 ───
|
||||||
|
|
||||||
|
function wsInitAgentMode() {
|
||||||
|
if (typeof apiFetch === 'undefined') return;
|
||||||
|
apiFetch('/api/config').then(function (r) { return r.ok ? r.json() : null; }).then(function (cfg) {
|
||||||
|
var wrapper = document.getElementById('ws-agent-mode-wrapper');
|
||||||
|
if (!wrapper) return;
|
||||||
|
wrapper.style.display = '';
|
||||||
|
// 是否启用多代理
|
||||||
|
var multiOn = cfg && cfg.multi_agent && cfg.multi_agent.enabled;
|
||||||
|
// 隐藏/显示多代理选项
|
||||||
|
var opts = wrapper.querySelectorAll('.ws-agent-mode-option');
|
||||||
|
opts.forEach(function (el) {
|
||||||
|
var v = el.getAttribute('data-value');
|
||||||
|
if (v === 'deep' || v === 'plan_execute' || v === 'supervisor') {
|
||||||
|
el.style.display = multiOn ? '' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 标准化当前值
|
||||||
|
var stored = localStorage.getItem('cyberstrike-chat-agent-mode');
|
||||||
|
var norm;
|
||||||
|
if (typeof window.csaiChatAgentMode === 'object' && typeof window.csaiChatAgentMode.normalizeStored === 'function') {
|
||||||
|
norm = window.csaiChatAgentMode.normalizeStored(stored, cfg);
|
||||||
|
} else {
|
||||||
|
norm = stored || 'react';
|
||||||
|
if (norm === 'single') norm = 'react';
|
||||||
|
if (norm === 'multi') norm = 'deep';
|
||||||
|
}
|
||||||
|
wsSyncAgentMode(norm);
|
||||||
|
}).catch(function () {
|
||||||
|
var wrapper = document.getElementById('ws-agent-mode-wrapper');
|
||||||
|
if (wrapper) wrapper.style.display = '';
|
||||||
|
wsSyncAgentMode('react');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsSyncAgentMode(value) {
|
||||||
|
var hid = document.getElementById('ws-agent-mode-select');
|
||||||
|
var label = document.getElementById('ws-agent-mode-text');
|
||||||
|
var icon = document.getElementById('ws-agent-mode-icon');
|
||||||
|
if (hid) hid.value = value;
|
||||||
|
if (label) label.textContent = (typeof getAgentModeLabelForValue === 'function') ? getAgentModeLabelForValue(value) : value;
|
||||||
|
if (icon) icon.textContent = (typeof getAgentModeIconForValue === 'function') ? getAgentModeIconForValue(value) : '\ud83e\udd16';
|
||||||
|
var wrapper = document.getElementById('ws-agent-mode-wrapper');
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.querySelectorAll('.ws-agent-mode-option').forEach(function (el) {
|
||||||
|
el.classList.toggle('selected', el.getAttribute('data-value') === value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsSelectAgentMode(mode) {
|
||||||
|
try { localStorage.setItem('cyberstrike-chat-agent-mode', mode); } catch (e) { /* */ }
|
||||||
|
wsSyncAgentMode(mode);
|
||||||
|
wsCloseAgentModePanel();
|
||||||
|
// 同步主页模式选择器
|
||||||
|
if (typeof syncAgentModeFromValue === 'function') try { syncAgentModeFromValue(mode); } catch (e) { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsToggleAgentModePanel() {
|
||||||
|
var panel = document.getElementById('ws-agent-mode-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
var isOpen = panel.style.display === 'flex';
|
||||||
|
if (isOpen) { wsCloseAgentModePanel(); return; }
|
||||||
|
wsCloseRolePanel();
|
||||||
|
panel.style.display = 'flex';
|
||||||
|
}
|
||||||
|
function wsCloseAgentModePanel() {
|
||||||
|
var panel = document.getElementById('ws-agent-mode-panel');
|
||||||
|
if (panel) panel.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当 WebShell AI Tab 可见时刷新选择器显示(同步主页可能的更改) */
|
||||||
|
function wsRefreshSelectors() {
|
||||||
|
wsUpdateRoleSelectorDisplay();
|
||||||
|
wsRenderRoleList();
|
||||||
|
var stored = localStorage.getItem('cyberstrike-chat-agent-mode') || 'react';
|
||||||
|
wsSyncAgentMode(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击面板外部关闭
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var rolePanel = document.getElementById('ws-role-selection-panel');
|
||||||
|
var roleBtn = document.getElementById('ws-role-selector-btn');
|
||||||
|
if (rolePanel && rolePanel.style.display !== 'none' && roleBtn && !rolePanel.contains(e.target) && !roleBtn.contains(e.target)) {
|
||||||
|
wsCloseRolePanel();
|
||||||
|
}
|
||||||
|
var modePanel = document.getElementById('ws-agent-mode-panel');
|
||||||
|
var modeBtn = document.getElementById('ws-agent-mode-btn');
|
||||||
|
if (modePanel && modePanel.style.display !== 'none' && modeBtn && !modePanel.contains(e.target) && !modeBtn.contains(e.target)) {
|
||||||
|
wsCloseAgentModePanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── end WebShell AI 选择器 ───
|
||||||
|
|
||||||
|
/** 停止当前 WebShell AI 流式请求 */
|
||||||
|
function wsStopAiStream(conn) {
|
||||||
|
// 1. Abort the fetch
|
||||||
|
if (webshellAiAbortController) {
|
||||||
|
try { webshellAiAbortController.abort(); } catch (e) { /* */ }
|
||||||
|
webshellAiAbortController = null;
|
||||||
|
}
|
||||||
|
// 2. Cancel the reader
|
||||||
|
if (webshellAiStreamReader) {
|
||||||
|
try { webshellAiStreamReader.cancel(); } catch (e) { /* */ }
|
||||||
|
webshellAiStreamReader = null;
|
||||||
|
}
|
||||||
|
// 3. Call backend cancel API if we have a conversation
|
||||||
|
var convId = conn && conn.id ? (webshellAiConvMap[conn.id] || '') : '';
|
||||||
|
if (convId && typeof apiFetch === 'function') {
|
||||||
|
apiFetch('/api/agent-loop/cancel', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ conversationId: convId })
|
||||||
|
}).catch(function () { /* ignore */ });
|
||||||
|
}
|
||||||
|
// 4. Reset UI state
|
||||||
|
wsSetAiSendingState(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换发送/停止按钮状态 */
|
||||||
|
function wsSetAiSendingState(sending) {
|
||||||
|
webshellAiSending = sending;
|
||||||
|
var sendBtn = document.getElementById('webshell-ai-send');
|
||||||
|
var stopBtn = document.getElementById('webshell-ai-stop');
|
||||||
|
if (sendBtn) {
|
||||||
|
sendBtn.disabled = sending;
|
||||||
|
sendBtn.style.display = sending ? 'none' : '';
|
||||||
|
}
|
||||||
|
if (stopBtn) {
|
||||||
|
stopBtn.style.display = sending ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 从服务端(SQLite)拉取连接列表
|
// 从服务端(SQLite)拉取连接列表
|
||||||
function getWebshellConnections() {
|
function getWebshellConnections() {
|
||||||
if (typeof apiFetch === 'undefined') {
|
if (typeof apiFetch === 'undefined') {
|
||||||
@@ -1441,7 +1674,7 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
|
|||||||
el.classList.toggle('active', el.dataset.convId === convId);
|
el.classList.toggle('active', el.dataset.convId === convId);
|
||||||
});
|
});
|
||||||
if (typeof apiFetch !== 'function') return;
|
if (typeof apiFetch !== 'function') return;
|
||||||
apiFetch('/api/conversations/' + encodeURIComponent(convId), { method: 'GET' })
|
apiFetch('/api/conversations/' + encodeURIComponent(convId) + '?include_process_details=1', { method: 'GET' })
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
messagesContainer.innerHTML = '';
|
messagesContainer.innerHTML = '';
|
||||||
@@ -1572,9 +1805,45 @@ function selectWebshell(id, stateReady) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="webshell-ai-main">' +
|
'<div class="webshell-ai-main">' +
|
||||||
'<div id="webshell-ai-messages" class="webshell-ai-messages"></div>' +
|
'<div id="webshell-ai-messages" class="webshell-ai-messages"></div>' +
|
||||||
|
'<div class="webshell-ai-input-area">' +
|
||||||
|
'<div class="webshell-ai-selectors-row">' +
|
||||||
|
'<div class="ws-role-selector-wrapper">' +
|
||||||
|
'<button type="button" class="role-selector-btn ws-role-selector-btn" id="ws-role-selector-btn" onclick="wsToggleRolePanel()">' +
|
||||||
|
'<span id="ws-role-selector-icon" class="role-selector-icon">\ud83d\udd35</span>' +
|
||||||
|
'<span id="ws-role-selector-text" class="role-selector-text">' + (wsT('chat.defaultRole') || '默认') + '</span>' +
|
||||||
|
'<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
|
||||||
|
'</button>' +
|
||||||
|
'<div id="ws-role-selection-panel" class="role-selection-panel" style="display:none;">' +
|
||||||
|
'<div class="role-selection-panel-header"><h3 class="role-selection-panel-title">' + (wsT('chatGroup.rolePanelTitle') || '选择角色') + '</h3>' +
|
||||||
|
'<button type="button" class="role-selection-panel-close" onclick="wsCloseRolePanel()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
||||||
|
'</div><div id="ws-role-selection-list" class="role-selection-list-main"></div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ws-agent-mode-wrapper" id="ws-agent-mode-wrapper" style="display:none;">' +
|
||||||
|
'<div class="agent-mode-inner">' +
|
||||||
|
'<button type="button" class="role-selector-btn agent-mode-btn" id="ws-agent-mode-btn" onclick="wsToggleAgentModePanel()">' +
|
||||||
|
'<span id="ws-agent-mode-icon" class="role-selector-icon">\ud83e\udd16</span>' +
|
||||||
|
'<span id="ws-agent-mode-text" class="role-selector-text">' + (wsT('chat.agentModeReactNative') || '原生 ReAct') + '</span>' +
|
||||||
|
'<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
|
||||||
|
'</button>' +
|
||||||
|
'<div id="ws-agent-mode-panel" class="agent-mode-panel" style="display:none;" role="listbox">' +
|
||||||
|
'<div class="role-selection-panel-header agent-mode-panel-header"><h3 class="role-selection-panel-title">' + (wsT('chat.agentModePanelTitle') || '对话模式') + '</h3>' +
|
||||||
|
'<button type="button" class="role-selection-panel-close" onclick="wsCloseAgentModePanel()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="agent-mode-options">' +
|
||||||
|
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="react" role="option" onclick="wsSelectAgentMode(\'react\')"><div class="role-selection-item-icon-main">\ud83e\udd16</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeReactNative') || '原生 ReAct 模式') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeReactNativeHint') || '经典单代理 ReAct 与 MCP 工具') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="react">\u2713</div></button>' +
|
||||||
|
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="eino_single" role="option" onclick="wsSelectAgentMode(\'eino_single\')"><div class="role-selection-item-icon-main">\u26a1</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeEinoSingle') || 'Eino 单代理(ADK)') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeEinoSingleHint') || 'Eino ChatModelAgent + Runner') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single">\u2713</div></button>' +
|
||||||
|
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="deep" role="option" onclick="wsSelectAgentMode(\'deep\')"><div class="role-selection-item-icon-main">\ud83e\udde9</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeDeep') || 'Deep(DeepAgent)') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeDeepHint') || 'Eino DeepAgent,task 调度子代理') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep">\u2713</div></button>' +
|
||||||
|
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="plan_execute" role="option" onclick="wsSelectAgentMode(\'plan_execute\')"><div class="role-selection-item-icon-main">\ud83d\udccb</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModePlanExecuteLabel') || 'Plan-Execute') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModePlanExecuteHint') || '规划 → 执行 → 重规划') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute">\u2713</div></button>' +
|
||||||
|
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="supervisor" role="option" onclick="wsSelectAgentMode(\'supervisor\')"><div class="role-selection-item-icon-main">\ud83c\udfaf</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeSupervisorLabel') || 'Supervisor') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeSupervisorHint') || '监督者协调,transfer 委派子代理') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="supervisor">\u2713</div></button>' +
|
||||||
|
'</div></div></div>' +
|
||||||
|
'<input type="hidden" id="ws-agent-mode-select" value="react" autocomplete="off" />' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
'<div class="webshell-ai-input-row">' +
|
'<div class="webshell-ai-input-row">' +
|
||||||
'<textarea id="webshell-ai-input" class="webshell-ai-input form-control" rows="2" placeholder="' + (wsT('webshell.aiPlaceholder') || '例如:列出当前目录下的文件') + '"></textarea>' +
|
'<textarea id="webshell-ai-input" class="webshell-ai-input form-control" rows="2" placeholder="' + (wsT('webshell.aiPlaceholder') || '例如:列出当前目录下的文件') + '"></textarea>' +
|
||||||
'<button type="button" class="btn-primary" id="webshell-ai-send">' + (wsT('webshell.aiSend') || '发送') + '</button>' +
|
'<button type="button" class="btn-primary" id="webshell-ai-send">' + (wsT('webshell.aiSend') || '发送') + '</button>' +
|
||||||
|
'<button type="button" class="btn-danger webshell-ai-stop-btn" id="webshell-ai-stop" style="display:none;">' + wsTOr('webshell.aiStop', '停止') + '</button>' +
|
||||||
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -1635,6 +1904,9 @@ function selectWebshell(id, stateReady) {
|
|||||||
if (tab === 'terminal' && webshellTerminalInstance && webshellTerminalFitAddon) {
|
if (tab === 'terminal' && webshellTerminalInstance && webshellTerminalFitAddon) {
|
||||||
try { webshellTerminalFitAddon.fit(); } catch (e) {}
|
try { webshellTerminalFitAddon.fit(); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
if (tab === 'ai') {
|
||||||
|
try { wsRefreshSelectors(); } catch (e) {}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1710,6 +1982,10 @@ function selectWebshell(id, stateReady) {
|
|||||||
var aiMessages = document.getElementById('webshell-ai-messages');
|
var aiMessages = document.getElementById('webshell-ai-messages');
|
||||||
var aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
|
var aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
|
||||||
var aiConvListEl = document.getElementById('webshell-ai-conv-list');
|
var aiConvListEl = document.getElementById('webshell-ai-conv-list');
|
||||||
|
|
||||||
|
// 初始化角色 + 模式选择器
|
||||||
|
wsLoadRoles();
|
||||||
|
wsInitAgentMode();
|
||||||
var aiMemoInput = document.getElementById('webshell-ai-memo-input');
|
var aiMemoInput = document.getElementById('webshell-ai-memo-input');
|
||||||
var aiMemoStatus = document.getElementById('webshell-ai-memo-status');
|
var aiMemoStatus = document.getElementById('webshell-ai-memo-status');
|
||||||
var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear');
|
var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear');
|
||||||
@@ -1770,7 +2046,11 @@ function selectWebshell(id, stateReady) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (aiSendBtn && aiInput && aiMessages) {
|
if (aiSendBtn && aiInput && aiMessages) {
|
||||||
|
var aiStopBtn = document.getElementById('webshell-ai-stop');
|
||||||
aiSendBtn.addEventListener('click', function () { runWebshellAiSend(conn, aiInput, aiSendBtn, aiMessages); });
|
aiSendBtn.addEventListener('click', function () { runWebshellAiSend(conn, aiInput, aiSendBtn, aiMessages); });
|
||||||
|
if (aiStopBtn) {
|
||||||
|
aiStopBtn.addEventListener('click', function () { wsStopAiStream(conn); });
|
||||||
|
}
|
||||||
aiInput.addEventListener('keydown', function (e) {
|
aiInput.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2347,8 +2627,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
webshellAiSending = true;
|
webshellAiAbortController = new AbortController();
|
||||||
if (sendBtn) sendBtn.disabled = true;
|
wsSetAiSendingState(true);
|
||||||
|
|
||||||
var userDiv = document.createElement('div');
|
var userDiv = document.createElement('div');
|
||||||
userDiv.className = 'webshell-ai-msg user';
|
userDiv.className = 'webshell-ai-msg user';
|
||||||
@@ -2427,14 +2707,18 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var einoSubReplyStreams = new Map();
|
var einoSubReplyStreams = new Map();
|
||||||
|
var wsThinkingStreams = new Map(); // streamId → { el, buf }
|
||||||
|
var wsToolResultStreams = new Map(); // toolCallId → { el, buf }
|
||||||
|
|
||||||
if (inputEl) inputEl.value = '';
|
if (inputEl) inputEl.value = '';
|
||||||
|
|
||||||
var convId = webshellAiConvMap[conn.id] || '';
|
var convId = webshellAiConvMap[conn.id] || '';
|
||||||
|
var wsRole = (typeof getCurrentRole === 'function') ? getCurrentRole() : (localStorage.getItem('currentRole') || '');
|
||||||
var body = {
|
var body = {
|
||||||
message: message,
|
message: message,
|
||||||
webshellConnectionId: conn.id,
|
webshellConnectionId: conn.id,
|
||||||
conversationId: convId
|
conversationId: convId,
|
||||||
|
role: wsRole
|
||||||
};
|
};
|
||||||
|
|
||||||
// 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加
|
// 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加
|
||||||
@@ -2448,7 +2732,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
return apiFetch(info.path, {
|
return apiFetch(info.path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body),
|
||||||
|
signal: webshellAiAbortController ? webshellAiAbortController.signal : undefined
|
||||||
});
|
});
|
||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -2458,6 +2743,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
return response.body.getReader();
|
return response.body.getReader();
|
||||||
}).then(function (reader) {
|
}).then(function (reader) {
|
||||||
if (!reader) return;
|
if (!reader) return;
|
||||||
|
webshellAiStreamReader = reader;
|
||||||
var decoder = new TextDecoder();
|
var decoder = new TextDecoder();
|
||||||
var buffer = '';
|
var buffer = '';
|
||||||
return reader.read().then(function processChunk(result) {
|
return reader.read().then(function processChunk(result) {
|
||||||
@@ -2470,9 +2756,12 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
if (line.indexOf('data: ') !== 0) continue;
|
if (line.indexOf('data: ') !== 0) continue;
|
||||||
try {
|
try {
|
||||||
var eventData = JSON.parse(line.slice(6));
|
var eventData = JSON.parse(line.slice(6));
|
||||||
if (eventData.type === 'conversation' && eventData.data && eventData.data.conversationId) {
|
var _et = eventData.type;
|
||||||
// 先把 conversationId 拿出来,避免后续异步回调里 eventData 被后续事件覆盖导致 undefined 报错
|
var _ed = eventData.data || {};
|
||||||
var convId = eventData.data.conversationId;
|
var _em = eventData.message || '';
|
||||||
|
|
||||||
|
if (_et === 'conversation' && _ed.conversationId) {
|
||||||
|
var convId = _ed.conversationId;
|
||||||
webshellAiConvMap[conn.id] = convId;
|
webshellAiConvMap[conn.id] = convId;
|
||||||
var listEl = document.getElementById('webshell-ai-conv-list');
|
var listEl = document.getElementById('webshell-ai-conv-list');
|
||||||
if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () {
|
if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () {
|
||||||
@@ -2480,100 +2769,219 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
el.classList.toggle('active', el.dataset.convId === convId);
|
el.classList.toggle('active', el.dataset.convId === convId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else if (eventData.type === 'response_start') {
|
|
||||||
|
// ─── Response streaming ───
|
||||||
|
} else if (_et === 'response_start') {
|
||||||
streamingTarget = '';
|
streamingTarget = '';
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
streamingTypingId = webshellStreamingTypingId;
|
streamingTypingId = webshellStreamingTypingId;
|
||||||
assistantDiv.textContent = '…';
|
assistantDiv.textContent = '…';
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
} else if (eventData.type === 'response_delta') {
|
} else if (_et === 'response_delta') {
|
||||||
var deltaText = (eventData.message != null && eventData.message !== '') ? String(eventData.message) : '';
|
var deltaText = (_em != null && _em !== '') ? String(_em) : '';
|
||||||
if (deltaText) {
|
if (deltaText) {
|
||||||
streamingTarget += deltaText;
|
streamingTarget += deltaText;
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
streamingTypingId = webshellStreamingTypingId;
|
streamingTypingId = webshellStreamingTypingId;
|
||||||
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
||||||
}
|
}
|
||||||
} else if (eventData.type === 'response') {
|
} else if (_et === 'response') {
|
||||||
var text = (eventData.message != null && eventData.message !== '') ? eventData.message : (eventData.data && typeof eventData.data === 'string' ? eventData.data : '');
|
var text = (_em != null && _em !== '') ? _em : (typeof _ed === 'string' ? _ed : '');
|
||||||
if (text) {
|
if (text) {
|
||||||
// response 为最终完整内容:避免与增量重复拼接
|
|
||||||
streamingTarget = String(text);
|
streamingTarget = String(text);
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
streamingTypingId = webshellStreamingTypingId;
|
streamingTypingId = webshellStreamingTypingId;
|
||||||
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
||||||
}
|
}
|
||||||
} else if (eventData.type === 'error' && eventData.message) {
|
|
||||||
|
// ─── Terminal events ───
|
||||||
|
} else if (_et === 'error' && _em) {
|
||||||
streamingTypingId += 1;
|
streamingTypingId += 1;
|
||||||
var errLabel = (typeof window.t === 'function') ? window.t('chat.error') : '错误';
|
var errLabel = wsTOr('chat.error', '错误');
|
||||||
appendTimelineItem('error', '❌ ' + errLabel, eventData.message, eventData.data);
|
appendTimelineItem('error', '❌ ' + errLabel, _em, _ed);
|
||||||
renderWebshellAiErrorMessage(assistantDiv, errLabel + ': ' + eventData.message);
|
renderWebshellAiErrorMessage(assistantDiv, errLabel + ': ' + _em);
|
||||||
} else if (eventData.type === 'progress' && eventData.message) {
|
} else if (_et === 'cancelled') {
|
||||||
|
streamingTypingId += 1;
|
||||||
|
var cancelLabel = wsTOr('chat.taskCancelled', '任务已取消');
|
||||||
|
appendTimelineItem('cancelled', '⛔ ' + cancelLabel, _em, _ed);
|
||||||
|
if (!streamingTarget && !assistantDiv.dataset.hasContent) {
|
||||||
|
assistantDiv.textContent = cancelLabel;
|
||||||
|
}
|
||||||
|
} else if (_et === 'done') {
|
||||||
|
// 清理流式状态
|
||||||
|
wsThinkingStreams.clear();
|
||||||
|
wsToolResultStreams.clear();
|
||||||
|
einoSubReplyStreams.clear();
|
||||||
|
|
||||||
|
// ─── Iteration / Progress ───
|
||||||
|
} else if (_et === 'progress' && _em) {
|
||||||
var progressMsg = (typeof window.translateProgressMessage === 'function')
|
var progressMsg = (typeof window.translateProgressMessage === 'function')
|
||||||
? window.translateProgressMessage(eventData.message)
|
? window.translateProgressMessage(_em) : _em;
|
||||||
: eventData.message;
|
appendTimelineItem('progress', '🔍 ' + progressMsg, '', _ed);
|
||||||
appendTimelineItem('progress', '🔍 ' + progressMsg, '', eventData.data);
|
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (eventData.type === 'iteration') {
|
} else if (_et === 'iteration') {
|
||||||
var iterN = (eventData.data && eventData.data.iteration) || 0;
|
var iterN = _ed.iteration || 0;
|
||||||
var iterTitle = (typeof window.t === 'function')
|
var iterTitle = wsTOr('chat.iterationRound', '') || (iterN ? ('第 ' + iterN + ' 轮迭代') : (_em || '迭代'));
|
||||||
? window.t('chat.iterationRound', { n: iterN || 1 })
|
if (typeof window.t === 'function' && iterN) {
|
||||||
: (iterN ? ('第 ' + iterN + ' 轮迭代') : (eventData.message || '迭代'));
|
iterTitle = window.t('chat.iterationRound', { n: iterN });
|
||||||
var iterMessage = eventData.message || '';
|
}
|
||||||
|
var iterMessage = _em || '';
|
||||||
if (iterMessage && typeof window.translateProgressMessage === 'function') {
|
if (iterMessage && typeof window.translateProgressMessage === 'function') {
|
||||||
iterMessage = window.translateProgressMessage(iterMessage);
|
iterMessage = window.translateProgressMessage(iterMessage);
|
||||||
}
|
}
|
||||||
appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, eventData.data);
|
appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, _ed);
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (eventData.type === 'thinking' && eventData.message) {
|
|
||||||
var thinkLabel = (typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考';
|
// ─── Thinking (non-stream + stream) ───
|
||||||
var thinkD = eventData.data || {};
|
} else if (_et === 'thinking_stream_start' && _ed.streamId) {
|
||||||
appendTimelineItem('thinking', webshellAgentPx(thinkD) + '🤔 ' + thinkLabel, eventData.message, thinkD);
|
var thinkSLabel = wsTOr('chat.aiThinking', 'AI 思考');
|
||||||
|
var thinkSItem = document.createElement('div');
|
||||||
|
thinkSItem.className = 'webshell-ai-timeline-item webshell-ai-timeline-thinking';
|
||||||
|
thinkSItem.innerHTML = '<span class="webshell-ai-timeline-title">' + escapeHtml(webshellAgentPx(_ed) + '🤔 ' + thinkSLabel) + '</span>';
|
||||||
|
var thinkSPre = document.createElement('div');
|
||||||
|
thinkSPre.className = 'webshell-ai-timeline-msg webshell-thinking-stream-body';
|
||||||
|
thinkSItem.appendChild(thinkSPre);
|
||||||
|
timelineContainer.appendChild(thinkSItem);
|
||||||
|
timelineContainer.classList.add('has-items');
|
||||||
|
wsThinkingStreams.set(_ed.streamId, { el: thinkSItem, body: thinkSPre, buf: '' });
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (eventData.type === 'tool_calls_detected' && eventData.data) {
|
} else if (_et === 'thinking_stream_delta' && _ed.streamId) {
|
||||||
var count = eventData.data.count || 0;
|
var tsD = wsThinkingStreams.get(_ed.streamId);
|
||||||
var detectedLabel = (typeof window.t === 'function')
|
if (tsD) {
|
||||||
? window.t('chat.toolCallsDetected', { count: count })
|
tsD.buf += (_em || '');
|
||||||
: ('检测到 ' + count + ' 个工具调用');
|
if (typeof formatMarkdown === 'function') {
|
||||||
appendTimelineItem('tool_calls_detected', webshellAgentPx(eventData.data) + '🔧 ' + detectedLabel, eventData.message || '', eventData.data);
|
tsD.body.innerHTML = formatMarkdown(tsD.buf);
|
||||||
|
} else {
|
||||||
|
tsD.body.textContent = tsD.buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (eventData.type === 'tool_call' && eventData.data) {
|
} else if (_et === 'thinking_stream_end' && _ed.streamId) {
|
||||||
var d = eventData.data;
|
var tsE = wsThinkingStreams.get(_ed.streamId);
|
||||||
var tn = d.toolName || '未知工具';
|
if (tsE) {
|
||||||
var idx = d.index || 0;
|
var fullThink = (_em != null && _em !== '') ? String(_em) : tsE.buf;
|
||||||
var total = d.total || 0;
|
if (typeof formatMarkdown === 'function') {
|
||||||
var callTitle = (typeof window.t === 'function')
|
tsE.body.innerHTML = formatMarkdown(fullThink);
|
||||||
? window.t('chat.callTool', { name: tn, index: idx, total: total })
|
} else {
|
||||||
: ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : ''));
|
tsE.body.textContent = fullThink;
|
||||||
var title = webshellAgentPx(d) + '🔧 ' + callTitle;
|
}
|
||||||
appendTimelineItem('tool_call', title, eventData.message || '', eventData.data);
|
wsThinkingStreams.delete(_ed.streamId);
|
||||||
|
}
|
||||||
|
} else if (_et === 'thinking' && _em) {
|
||||||
|
// 如果有 streamId 且已存在流式条目,跳过避免重复
|
||||||
|
if (_ed.streamId && wsThinkingStreams.has(_ed.streamId)) {
|
||||||
|
// 已由 thinking_stream_* 处理
|
||||||
|
} else {
|
||||||
|
var thinkLabel = wsTOr('chat.aiThinking', 'AI 思考');
|
||||||
|
appendTimelineItem('thinking', webshellAgentPx(_ed) + '🤔 ' + thinkLabel, _em, _ed);
|
||||||
|
}
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (eventData.type === 'tool_result' && eventData.data) {
|
|
||||||
var dr = eventData.data;
|
// ─── Warning ───
|
||||||
var success = dr.success !== false;
|
} else if (_et === 'warning') {
|
||||||
var tname = dr.toolName || '工具';
|
appendTimelineItem('warning', '⚠️ ' + (_em || ''), '', _ed);
|
||||||
var titleText = (typeof window.t === 'function')
|
|
||||||
? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname }))
|
// ─── Eino recovery ───
|
||||||
: (tname + (success ? ' 执行完成' : ' 执行失败'));
|
} else if (_et === 'eino_recovery') {
|
||||||
var title = webshellAgentPx(dr) + (success ? '✅ ' : '❌ ') + titleText;
|
var runIdx = _ed.runIndex != null ? _ed.runIndex : (_ed.einoRetry != null ? _ed.einoRetry + 1 : 1);
|
||||||
var sub = eventData.message || (dr.result ? String(dr.result).slice(0, 300) : '');
|
var maxRuns = _ed.maxRuns != null ? _ed.maxRuns : 3;
|
||||||
appendTimelineItem('tool_result', title, sub, eventData.data);
|
var recTitle = wsTOr('chat.einoRecoveryTitle', '') ||
|
||||||
|
('🔄 工具参数无效 · 第 ' + runIdx + '/' + maxRuns + ' 轮(已追加提示)');
|
||||||
|
if (typeof window.t === 'function') {
|
||||||
|
try { recTitle = window.t('chat.einoRecoveryTitle', { n: runIdx, max: maxRuns }); } catch (e) { /* */ }
|
||||||
|
}
|
||||||
|
appendTimelineItem('eino_recovery', recTitle, _em, _ed);
|
||||||
|
|
||||||
|
// ─── Tool calls ───
|
||||||
|
} else if (_et === 'tool_calls_detected' && _ed) {
|
||||||
|
var count = _ed.count || 0;
|
||||||
|
var detectedLabel = wsTOr('chat.toolCallsDetected', '') || ('检测到 ' + count + ' 个工具调用');
|
||||||
|
if (typeof window.t === 'function') {
|
||||||
|
try { detectedLabel = window.t('chat.toolCallsDetected', { count: count }); } catch (e) { /* */ }
|
||||||
|
}
|
||||||
|
appendTimelineItem('tool_calls_detected', webshellAgentPx(_ed) + '🔧 ' + detectedLabel, _em || '', _ed);
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (eventData.type === 'eino_agent_reply_stream_start' && eventData.data && eventData.data.streamId) {
|
} else if (_et === 'tool_call' && _ed) {
|
||||||
var rdS = eventData.data;
|
var tn = _ed.toolName || '未知工具';
|
||||||
var repTS = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
|
var idx = _ed.index || 0;
|
||||||
var runTS = (typeof window.t === 'function') ? window.t('timeline.running') : '执行中...';
|
var total = _ed.total || 0;
|
||||||
|
var callTitle = wsTOr('chat.callTool', '') || ('调用工具: ' + tn + (total ? ' (' + idx + '/' + total + ')' : ''));
|
||||||
|
if (typeof window.t === 'function') {
|
||||||
|
try { callTitle = window.t('chat.callTool', { name: tn, index: idx, total: total }); } catch (e) { /* */ }
|
||||||
|
}
|
||||||
|
appendTimelineItem('tool_call', webshellAgentPx(_ed) + '🔧 ' + callTitle, _em || '', _ed);
|
||||||
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
|
|
||||||
|
// ─── Tool result delta (streaming output) ───
|
||||||
|
} else if (_et === 'tool_result_delta' && _ed.toolCallId) {
|
||||||
|
var trdKey = _ed.toolCallId;
|
||||||
|
var trdDelta = _em || '';
|
||||||
|
if (trdDelta) {
|
||||||
|
var trdState = wsToolResultStreams.get(trdKey);
|
||||||
|
if (!trdState) {
|
||||||
|
var trdName = _ed.toolName || '工具';
|
||||||
|
var runLabel = wsTOr('timeline.running', '执行中...');
|
||||||
|
var trdItem = document.createElement('div');
|
||||||
|
trdItem.className = 'webshell-ai-timeline-item webshell-ai-timeline-tool_result';
|
||||||
|
trdItem.innerHTML = '<span class="webshell-ai-timeline-title">' +
|
||||||
|
escapeHtml(webshellAgentPx(_ed) + '⏳ ' + runLabel + ' ' + trdName) +
|
||||||
|
'</span><div class="webshell-ai-timeline-msg"><div class="tool-result-section success">' +
|
||||||
|
'<pre class="tool-result"></pre></div></div>';
|
||||||
|
timelineContainer.appendChild(trdItem);
|
||||||
|
timelineContainer.classList.add('has-items');
|
||||||
|
trdState = { el: trdItem, buf: '' };
|
||||||
|
wsToolResultStreams.set(trdKey, trdState);
|
||||||
|
}
|
||||||
|
trdState.buf += trdDelta;
|
||||||
|
var trdPre = trdState.el.querySelector('pre.tool-result');
|
||||||
|
if (trdPre) trdPre.textContent = trdState.buf;
|
||||||
|
}
|
||||||
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
|
|
||||||
|
// ─── Tool result (final) ───
|
||||||
|
} else if (_et === 'tool_result' && _ed) {
|
||||||
|
var success = _ed.success !== false;
|
||||||
|
var tname = _ed.toolName || '工具';
|
||||||
|
var titleText = wsTOr(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', '') ||
|
||||||
|
(tname + (success ? ' 执行完成' : ' 执行失败'));
|
||||||
|
if (typeof window.t === 'function') {
|
||||||
|
try { titleText = window.t(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', { name: tname }); } catch (e) { /* */ }
|
||||||
|
}
|
||||||
|
// 如果有流式占位条目,更新标题
|
||||||
|
var trdExist = _ed.toolCallId ? wsToolResultStreams.get(_ed.toolCallId) : null;
|
||||||
|
if (trdExist) {
|
||||||
|
var trdTitleEl = trdExist.el.querySelector('.webshell-ai-timeline-title');
|
||||||
|
if (trdTitleEl) trdTitleEl.textContent = webshellAgentPx(_ed) + (success ? '✅ ' : '❌ ') + titleText;
|
||||||
|
// 更新结果内容
|
||||||
|
var resultText = _ed.result ? String(_ed.result) : (_em || '');
|
||||||
|
var trdPreEl = trdExist.el.querySelector('pre.tool-result');
|
||||||
|
if (trdPreEl && resultText) trdPreEl.textContent = resultText;
|
||||||
|
// 更新 section class
|
||||||
|
var trdSection = trdExist.el.querySelector('.tool-result-section');
|
||||||
|
if (trdSection) { trdSection.className = 'tool-result-section ' + (success ? 'success' : 'error'); }
|
||||||
|
wsToolResultStreams.delete(_ed.toolCallId);
|
||||||
|
} else {
|
||||||
|
var title = webshellAgentPx(_ed) + (success ? '✅ ' : '❌ ') + titleText;
|
||||||
|
var sub = _em || (_ed.result ? String(_ed.result).slice(0, 300) : '');
|
||||||
|
appendTimelineItem('tool_result', title, sub, _ed);
|
||||||
|
}
|
||||||
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
|
|
||||||
|
// ─── Eino sub-agent reply streaming ───
|
||||||
|
} else if (_et === 'eino_agent_reply_stream_start' && _ed.streamId) {
|
||||||
|
var repTS = wsTOr('chat.einoAgentReplyTitle', '子代理回复');
|
||||||
|
var runTS = wsTOr('timeline.running', '执行中...');
|
||||||
var itemS = document.createElement('div');
|
var itemS = document.createElement('div');
|
||||||
itemS.className = 'webshell-ai-timeline-item webshell-ai-timeline-eino_agent_reply';
|
itemS.className = 'webshell-ai-timeline-item webshell-ai-timeline-eino_agent_reply';
|
||||||
itemS.innerHTML = '<span class="webshell-ai-timeline-title">' + escapeHtml(webshellAgentPx(rdS) + '💬 ' + repTS + ' · ' + runTS) + '</span>';
|
itemS.innerHTML = '<span class="webshell-ai-timeline-title">' + escapeHtml(webshellAgentPx(_ed) + '💬 ' + repTS + ' · ' + runTS) + '</span>';
|
||||||
timelineContainer.appendChild(itemS);
|
timelineContainer.appendChild(itemS);
|
||||||
timelineContainer.classList.add('has-items');
|
timelineContainer.classList.add('has-items');
|
||||||
einoSubReplyStreams.set(rdS.streamId, { el: itemS, buf: '' });
|
einoSubReplyStreams.set(_ed.streamId, { el: itemS, buf: '' });
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (eventData.type === 'eino_agent_reply_stream_delta' && eventData.data && eventData.data.streamId) {
|
} else if (_et === 'eino_agent_reply_stream_delta' && _ed.streamId) {
|
||||||
var stD = einoSubReplyStreams.get(eventData.data.streamId);
|
var stD = einoSubReplyStreams.get(_ed.streamId);
|
||||||
if (stD) {
|
if (stD) {
|
||||||
stD.buf += (eventData.message || '');
|
stD.buf += (_em || '');
|
||||||
var preD = stD.el.querySelector('.webshell-eino-reply-stream-body');
|
var preD = stD.el.querySelector('.webshell-eino-reply-stream-body');
|
||||||
if (!preD) {
|
if (!preD) {
|
||||||
preD = document.createElement('pre');
|
preD = document.createElement('pre');
|
||||||
@@ -2581,17 +2989,20 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
preD.style.whiteSpace = 'pre-wrap';
|
preD.style.whiteSpace = 'pre-wrap';
|
||||||
stD.el.appendChild(preD);
|
stD.el.appendChild(preD);
|
||||||
}
|
}
|
||||||
preD.textContent = stD.buf;
|
if (typeof formatMarkdown === 'function') {
|
||||||
|
preD.innerHTML = formatMarkdown(stD.buf);
|
||||||
|
} else {
|
||||||
|
preD.textContent = stD.buf;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (eventData.type === 'eino_agent_reply_stream_end' && eventData.data && eventData.data.streamId) {
|
} else if (_et === 'eino_agent_reply_stream_end' && _ed.streamId) {
|
||||||
var stE = einoSubReplyStreams.get(eventData.data.streamId);
|
var stE = einoSubReplyStreams.get(_ed.streamId);
|
||||||
if (stE) {
|
if (stE) {
|
||||||
var fullE = (eventData.message != null && eventData.message !== '') ? String(eventData.message) : stE.buf;
|
var fullE = (_em != null && _em !== '') ? String(_em) : stE.buf;
|
||||||
stE.buf = fullE;
|
var repTE = wsTOr('chat.einoAgentReplyTitle', '子代理回复');
|
||||||
var repTE = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
|
|
||||||
var titE = stE.el.querySelector('.webshell-ai-timeline-title');
|
var titE = stE.el.querySelector('.webshell-ai-timeline-title');
|
||||||
if (titE) titE.textContent = webshellAgentPx(eventData.data) + '💬 ' + repTE;
|
if (titE) titE.textContent = webshellAgentPx(_ed) + '💬 ' + repTE;
|
||||||
var preE = stE.el.querySelector('.webshell-eino-reply-stream-body');
|
var preE = stE.el.querySelector('.webshell-eino-reply-stream-body');
|
||||||
if (!preE) {
|
if (!preE) {
|
||||||
preE = document.createElement('pre');
|
preE = document.createElement('pre');
|
||||||
@@ -2599,14 +3010,17 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
preE.style.whiteSpace = 'pre-wrap';
|
preE.style.whiteSpace = 'pre-wrap';
|
||||||
stE.el.appendChild(preE);
|
stE.el.appendChild(preE);
|
||||||
}
|
}
|
||||||
preE.textContent = fullE;
|
if (typeof formatMarkdown === 'function') {
|
||||||
einoSubReplyStreams.delete(eventData.data.streamId);
|
preE.innerHTML = formatMarkdown(fullE);
|
||||||
|
} else {
|
||||||
|
preE.textContent = fullE;
|
||||||
|
}
|
||||||
|
einoSubReplyStreams.delete(_ed.streamId);
|
||||||
}
|
}
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
} else if (eventData.type === 'eino_agent_reply' && eventData.message) {
|
} else if (_et === 'eino_agent_reply' && _em) {
|
||||||
var rd = eventData.data || {};
|
var replyT = wsTOr('chat.einoAgentReplyTitle', '子代理回复');
|
||||||
var replyT = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
|
appendTimelineItem('eino_agent_reply', webshellAgentPx(_ed) + '💬 ' + replyT, _em, _ed);
|
||||||
appendTimelineItem('eino_agent_reply', webshellAgentPx(rd) + '💬 ' + replyT, eventData.message, rd);
|
|
||||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||||
}
|
}
|
||||||
} catch (e) { /* ignore parse error */ }
|
} catch (e) { /* ignore parse error */ }
|
||||||
@@ -2615,10 +3029,15 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
return reader.read().then(processChunk);
|
return reader.read().then(processChunk);
|
||||||
});
|
});
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
renderWebshellAiErrorMessage(assistantDiv, '请求异常: ' + (err && err.message ? err.message : String(err)));
|
var msg = err && err.message ? err.message : String(err);
|
||||||
|
var isAbort = /abort/i.test(msg);
|
||||||
|
if (!isAbort) {
|
||||||
|
renderWebshellAiErrorMessage(assistantDiv, '请求异常: ' + msg);
|
||||||
|
}
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
webshellAiSending = false;
|
webshellAiAbortController = null;
|
||||||
if (sendBtn) sendBtn.disabled = false;
|
webshellAiStreamReader = null;
|
||||||
|
wsSetAiSendingState(false);
|
||||||
if (assistantDiv.textContent === '…' && !streamingTarget) {
|
if (assistantDiv.textContent === '…' && !streamingTarget) {
|
||||||
// 没有任何 response 内容,保持纯文本提示
|
// 没有任何 response 内容,保持纯文本提示
|
||||||
assistantDiv.textContent = '无回复内容';
|
assistantDiv.textContent = '无回复内容';
|
||||||
|
|||||||
+10
-31
@@ -159,7 +159,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item nav-item-has-submenu" data-page="mcp">
|
<div class="nav-item nav-item-has-submenu" data-page="mcp">
|
||||||
<div class="nav-item-content" data-title="MCP" onclick="toggleSubmenu('mcp')" data-i18n="nav.mcp" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
<div class="nav-item-content" data-title="MCP" onclick="window.toggleSubmenu('mcp')" data-i18n="nav.mcp" 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">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item nav-item-has-submenu" data-page="knowledge">
|
<div class="nav-item nav-item-has-submenu" data-page="knowledge">
|
||||||
<div class="nav-item-content" data-title="知识" onclick="toggleSubmenu('knowledge')" data-i18n="nav.knowledge" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
<div class="nav-item-content" data-title="知识" onclick="window.toggleSubmenu('knowledge')" data-i18n="nav.knowledge" 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">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
|
||||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item nav-item-has-submenu" data-page="skills">
|
<div class="nav-item nav-item-has-submenu" data-page="skills">
|
||||||
<div class="nav-item-content" data-title="Skills" onclick="toggleSubmenu('skills')" data-i18n="nav.skills" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
<div class="nav-item-content" data-title="Skills" onclick="window.toggleSubmenu('skills')" data-i18n="nav.skills" 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">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
|
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
|
||||||
<polyline points="14 2 14 8 20 8"></polyline>
|
<polyline points="14 2 14 8 20 8"></polyline>
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item nav-item-has-submenu" data-page="agents">
|
<div class="nav-item nav-item-has-submenu" data-page="agents">
|
||||||
<div class="nav-item-content" data-title="Agents" onclick="toggleSubmenu('agents')" data-i18n="nav.agents" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
<div class="nav-item-content" data-title="Agents" onclick="window.toggleSubmenu('agents')" data-i18n="nav.agents" 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">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
|
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
|
||||||
<polyline points="2 17 12 22 22 17"></polyline>
|
<polyline points="2 17 12 22 22 17"></polyline>
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item nav-item-has-submenu" data-page="roles">
|
<div class="nav-item nav-item-has-submenu" data-page="roles">
|
||||||
<div class="nav-item-content" data-title="角色" onclick="toggleSubmenu('roles')" data-i18n="nav.roles" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
<div class="nav-item-content" data-title="角色" onclick="window.toggleSubmenu('roles')" data-i18n="nav.roles" 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">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
<circle cx="9" cy="7" r="4"></circle>
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
@@ -2699,6 +2699,11 @@
|
|||||||
<div class="role-tools-actions">
|
<div class="role-tools-actions">
|
||||||
<button type="button" class="btn-secondary" onclick="selectAllRoleTools()" data-i18n="roleModal.selectAll">全选</button>
|
<button type="button" class="btn-secondary" onclick="selectAllRoleTools()" data-i18n="roleModal.selectAll">全选</button>
|
||||||
<button type="button" class="btn-secondary" onclick="deselectAllRoleTools()" data-i18n="roleModal.deselectAll">全不选</button>
|
<button type="button" class="btn-secondary" onclick="deselectAllRoleTools()" data-i18n="roleModal.deselectAll">全不选</button>
|
||||||
|
<div id="role-tools-status-filter" class="tools-status-filter">
|
||||||
|
<button type="button" class="btn-filter active" data-filter="" onclick="filterRoleToolsByStatus('')" data-i18n="roleModal.filterRoleAll">全部</button>
|
||||||
|
<button type="button" class="btn-filter" data-filter="role_on" onclick="filterRoleToolsByStatus('role_on')" data-i18n="roleModal.filterRoleOn">本角色已开</button>
|
||||||
|
<button type="button" class="btn-filter" data-filter="role_off" onclick="filterRoleToolsByStatus('role_off')" data-i18n="roleModal.filterRoleOff">本角色已关</button>
|
||||||
|
</div>
|
||||||
<div class="role-tools-search-box">
|
<div class="role-tools-search-box">
|
||||||
<input type="text" id="role-tools-search" data-i18n="roleModal.searchToolsPlaceholder" data-i18n-attr="placeholder" placeholder="搜索工具..."
|
<input type="text" id="role-tools-search" data-i18n="roleModal.searchToolsPlaceholder" data-i18n-attr="placeholder" placeholder="搜索工具..."
|
||||||
oninput="searchRoleTools(this.value)"
|
oninput="searchRoleTools(this.value)"
|
||||||
@@ -2719,32 +2724,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<small class="form-hint" data-i18n="roleModal.relatedToolsHint">勾选要关联的工具,留空则使用MCP管理中的全部工具配置。</small>
|
<small class="form-hint" data-i18n="roleModal.relatedToolsHint">勾选要关联的工具,留空则使用MCP管理中的全部工具配置。</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="role-skills-section">
|
|
||||||
<label data-i18n="roleModal.relatedSkills">关联的Skills(可选)</label>
|
|
||||||
<div class="role-skills-controls">
|
|
||||||
<div class="role-skills-actions">
|
|
||||||
<button type="button" class="btn-secondary" onclick="selectAllRoleSkills()" data-i18n="roleModal.selectAll">全选</button>
|
|
||||||
<button type="button" class="btn-secondary" onclick="deselectAllRoleSkills()" data-i18n="roleModal.deselectAll">全不选</button>
|
|
||||||
<div class="role-skills-search-box">
|
|
||||||
<input type="text" id="role-skills-search" data-i18n="roleModal.searchSkillsPlaceholder" data-i18n-attr="placeholder" placeholder="搜索skill..."
|
|
||||||
oninput="searchRoleSkills(this.value)"
|
|
||||||
onkeypress="if(event.key === 'Enter') searchRoleSkills(this.value)" />
|
|
||||||
<button class="role-skills-search-clear" id="role-skills-search-clear"
|
|
||||||
onclick="clearRoleSkillsSearch()" style="display: none;" data-i18n="common.clearSearch" data-i18n-attr="title" title="清除搜索">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="role-skills-stats" class="role-skills-stats"></div>
|
|
||||||
</div>
|
|
||||||
<div id="role-skills-list" class="role-skills-list">
|
|
||||||
<div class="skills-loading" data-i18n="roleModal.loadingSkills">正在加载skills列表...</div>
|
|
||||||
</div>
|
|
||||||
<small class="form-hint" data-i18n="roleModal.relatedSkillsHint">勾选要关联的skills,这些skills的内容会在执行任务前注入到系统提示词中,帮助AI更好地理解相关专业知识。</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" id="role-enabled" class="modern-checkbox" checked />
|
<input type="checkbox" id="role-enabled" class="modern-checkbox" checked />
|
||||||
|
|||||||
Reference in New Issue
Block a user