Compare commits

...

22 Commits

Author SHA1 Message Date
公明 5b3f4e3556 Update config.yaml 2026-04-21 20:50:37 +08:00
公明 adef2c143b Delete tools/mimikatz.yaml 2026-04-21 20:48:32 +08:00
公明 7ac3c06c34 Delete tools/http-intruder.yaml 2026-04-21 20:47:42 +08:00
公明 d3a05fcd92 Delete tools/modify-file.yaml 2026-04-21 20:46:06 +08:00
公明 1d692e9f52 Delete tools/cat.yaml 2026-04-21 20:45:34 +08:00
公明 7e4032858e Delete tools/delete-file.yaml 2026-04-21 20:45:04 +08:00
公明 f77af18694 Delete tools/create-file.yaml 2026-04-21 20:44:30 +08:00
公明 8e31f10837 Delete tools/api-fuzzer.yaml 2026-04-21 20:43:40 +08:00
公明 b3e29f6e8f Add files via upload 2026-04-21 19:37:52 +08:00
公明 32b655f526 Add files via upload 2026-04-21 19:28:14 +08:00
公明 a8b608135e Add files via upload 2026-04-21 19:25:45 +08:00
公明 964c520215 Add files via upload 2026-04-21 19:17:46 +08:00
公明 26116b0822 Add files via upload 2026-04-21 19:16:09 +08:00
公明 d037647c21 Add files via upload 2026-04-21 19:13:08 +08:00
公明 f2a701a846 Update config.yaml 2026-04-21 01:27:46 +08:00
公明 0ce79c6ef4 Add files via upload 2026-04-21 01:26:49 +08:00
公明 0d4f608c14 Add files via upload 2026-04-21 01:25:40 +08:00
公明 c801a97add Add files via upload 2026-04-21 01:24:01 +08:00
公明 68978b82e9 Add files via upload 2026-04-20 20:01:02 +08:00
公明 c43fde2612 Add files via upload 2026-04-20 19:46:40 +08:00
公明 fbd1ede8cb Add files via upload 2026-04-20 19:45:04 +08:00
公明 2d8ef3a1b0 Add files via upload 2026-04-20 19:42:11 +08:00
54 changed files with 4075 additions and 2171 deletions
+4 -7
View File
@@ -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
- 🎭 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))
- 🎯 **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)
- 🐚 **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.
- **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).
- **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.
- **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.
- **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`, 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.
**Creating a custom role (example):**
@@ -265,8 +265,6 @@ Requirements / tips:
- api-fuzzer
- arjun
- graphql-scanner
skills:
- cyberstrike-eino-demo
enabled: true
```
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.
- **Runtime refactor** **`skills_dir`** is the single root for packs. **Multi-agent** loads them through Einos 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).
- **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.
- **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`.
**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.
2. Reference `<skill-id>` in a roles `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
- **YAML recipes** in `tools/*.yaml` describe commands, arguments, prompts, and metadata.
+3 -6
View File
@@ -248,8 +248,8 @@ go build -o cyberstrike-ai cmd/server/main.go
- **预设角色**:系统内置 12+ 个预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试、二进制分析、云安全审计等),位于 `roles/` 目录。
- **自定义提示词**:每个角色可定义 `user_prompt`,会在用户消息前自动添加,引导 AI 采用特定的测试方法和关注重点。
- **工具限制**:角色可指定 `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 链。
- **轻松创建角色**:通过在 `roles/` 目录添加 YAML 文件即可创建自定义角色。每个角色定义 `name`、`description`、`user_prompt`、`icon`、`tools`、`skills`、`enabled` 字段。
- **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`、`enabled` 字段。
- **Web 界面集成**:在聊天界面通过下拉菜单选择角色。角色选择会影响 AI 行为和可用工具建议。
**创建自定义角色示例:**
@@ -263,8 +263,6 @@ go build -o cyberstrike-ai cmd/server/main.go
- api-fuzzer
- arjun
- graphql-scanner
skills:
- cyberstrike-eino-demo
enabled: true
```
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/` 列表、渐进式行为由运行时从正文与磁盘 **自动推导**。
- **运行侧重构****`skills_dir`** 为技能包唯一根目录;**多代理** 通过 Eino 官方 **`skill`** 中间件做 **渐进式披露**(模型按 **name** 调用 `skill`,而非一次性注入全文)。由 **`multi_agent.eino_skills`** 控制:`disable`、`filesystem_tools`(本机读写与 Shell)、`skill_tool_name`。
- **Eino / 知识流水线**:技能包可切分为 `schema.Document`,供 `FilesystemSkillsRetriever``skills.AsEinoRetriever()`)在 **compose** 图(如索引/编排)中使用。
- **提示词**:角色绑定的技能 **id**(文件夹名)会作为推荐写入系统提示;正文默认不整包注入。
- **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`。
- **自带示例**`skills/cyberstrike-eino-demo/`;说明见 `skills/README.md`。
**新建技能:**
1. 在 `skills/` 下创建 `<skill-id>/`,放入标准 `SKILL.md`(及任意可选文件),或直接解压开源技能包到该目录。
2. 在 `roles/*.yaml` 的 `skills` 列表中引用该 `<skill-id>`。
2. 启用 **`multi_agent.eino_skills`** 并使用 **多代理** 会话,由模型通过 **`skill`** 工具按包 **name** 加载
### 工具编排与扩展
- `tools/*.yaml` 定义命令、参数、提示词与元数据,可热加载。
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.5.2"
version: "v1.5.4"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+1
View File
@@ -58,3 +58,4 @@
| 2026-03-22 | `agents/*.md` 子代理定义、`agents_dir`、合并进 `RunDeepAgent`、前端 Agents 菜单与 CRUD API。 |
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
| 2026-04-19 | 主聊天「对话模式」:原生 ReAct 与 Deep / Plan-Execute / Supervisor`POST /api/multi-agent*` 请求体 `orchestration` 与界面一致;`config.yaml` / 设置页不再维护预置编排字段(机器人/批量默认 `deep`)。 |
| 2026-04-21 | 移除角色 `skills``/api/roles/skills/list``bind_role` 仅继承 toolsSkills 仅通过 Eino `skill` 工具按需加载。 |
+6 -45
View File
@@ -316,16 +316,16 @@ type ProgressCallback func(eventType, message string, data interface{})
// AgentLoop 执行Agent循环
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
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 提示)。
func (a *Agent) EinoSingleAgentSystemInstruction(roleSkills []string) string {
// EinoSingleAgentSystemInstruction 供 Eino adk.ChatModelAgent.Instruction 使用,与 AgentLoopWithProgress 首条 system 对齐(含 system_prompt_path)。
func (a *Agent) EinoSingleAgentSystemInstruction() string {
systemPrompt := DefaultSingleAgentSystemPrompt()
if a.agentConfig != nil {
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
}
// AgentLoopWithProgress 执行Agent循环(带进度回调和对话ID)
// roleSkills: 角色配置的skills列表(用于在系统提示词中提示AI,但不硬编码内容)
func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, historyMessages []ChatMessage, conversationID string, callback ProgressCallback, roleTools []string, roleSkills []string) (*AgentLoopResult, error) {
func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, historyMessages []ChatMessage, conversationID string, callback ProgressCallback, roleTools []string) (*AgentLoopResult, error) {
// 设置当前对话ID
a.mu.Lock()
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{
{
Role: "system",
@@ -988,7 +949,7 @@ func (a *Agent) getAvailableTools(roleTools []string) []Tool {
enabled := false
if cfg, exists := externalMCPConfigs[mcpName]; exists {
// 首先检查外部MCP是否启用
if !cfg.ExternalMCPEnable && !(cfg.Enabled && !cfg.Disabled) {
if !cfg.ExternalMCPEnable {
enabled = false // MCP未启用,所有工具都禁用
} else {
// MCP已启用,检查单个工具的启用状态
+58 -16
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"crypto/subtle"
"database/sql"
"fmt"
"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)
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
roleHandler.SetSkillsManager(skillpackage.DirLister{SkillsRoot: skillsDir})
skillsHandler := handler.NewSkillsHandler(cfg, configPath, log.Logger)
fofaHandler := handler.NewFofaHandler(cfg, 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) {
cfg := a.config.MCP
if cfg.AuthHeader != "" {
if r.Header.Get(cfg.AuthHeader) != cfg.AuthHeaderValue {
actual := []byte(r.Header.Get(cfg.AuthHeader))
expected := []byte(cfg.AuthHeaderValue)
if subtle.ConstantTimeCompare(actual, expected) != 1 {
a.logger.Logger.Debug("MCP 鉴权失败:header 缺失或值不匹配", zap.String("header", cfg.AuthHeader))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
@@ -471,18 +473,25 @@ func (a *App) mcpHandlerWithAuth(w http.ResponseWriter, r *http.Request) {
a.mcpServer.HandleHTTP(w, r)
}
// Run 启动应用
// Run 启动应用(向后兼容,不支持优雅关闭)
func (a *App) Run() error {
return a.RunWithContext(context.Background())
}
// RunWithContext 启动应用,支持通过 context 取消来优雅关闭
func (a *App) RunWithContext(ctx context.Context) error {
// 启动MCP服务器(如果启用)
var mcpServer *http.Server
if a.config.MCP.Enabled {
mcpAddr := fmt.Sprintf("%s:%d", a.config.MCP.Host, a.config.MCP.Port)
a.logger.Info("启动MCP服务器", zap.String("address", mcpAddr))
mux := http.NewServeMux()
mux.HandleFunc("/mcp", a.mcpHandlerWithAuth)
mcpServer = &http.Server{Addr: mcpAddr, Handler: mux}
go func() {
mcpAddr := fmt.Sprintf("%s:%d", a.config.MCP.Host, a.config.MCP.Port)
a.logger.Info("启动MCP服务器", zap.String("address", mcpAddr))
mux := http.NewServeMux()
mux.HandleFunc("/mcp", a.mcpHandlerWithAuth)
if err := http.ListenAndServe(mcpAddr, mux); err != nil {
if err := mcpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
a.logger.Error("MCP服务器启动失败", zap.Error(err))
}
}()
@@ -492,7 +501,27 @@ func (a *App) Run() error {
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
a.logger.Info("启动HTTP服务器", zap.String("address", addr))
return a.router.Run(addr)
srv := &http.Server{Addr: addr, Handler: a.router}
// 监听 context 取消,优雅关闭 HTTP 服务器
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
a.logger.Error("HTTP服务器关闭失败", zap.Error(err))
}
if mcpServer != nil {
if err := mcpServer.Shutdown(shutdownCtx); err != nil {
a.logger.Error("MCP服务器关闭失败", zap.Error(err))
}
}
}()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// Shutdown 关闭应用
@@ -520,6 +549,13 @@ func (a *App) Shutdown() {
a.logger.Logger.Warn("关闭知识库数据库连接失败", zap.Error(err))
}
}
// 关闭主数据库连接
if a.db != nil {
if err := a.db.Close(); err != nil {
a.logger.Logger.Warn("关闭主数据库连接失败", zap.Error(err))
}
}
}
// startRobotConnections 根据当前配置启动钉钉/飞书长连接(不先关闭已有连接,仅用于首次启动)
@@ -594,10 +630,16 @@ func setupRoutes(
}
// 机器人回调(无需登录,供企业微信/钉钉/飞书服务器调用)
api.GET("/robot/wecom", robotHandler.HandleWecomGET)
api.POST("/robot/wecom", robotHandler.HandleWecomPOST)
api.POST("/robot/dingtalk", robotHandler.HandleDingtalkPOST)
api.POST("/robot/lark", robotHandler.HandleLarkPOST)
// 添加速率限制:每个 IP 每分钟最多 60 次请求,防止滥用
robotRL := security.NewRateLimiter(60, 1*time.Minute)
robotGroup := api.Group("/robot")
robotGroup.Use(security.RateLimitMiddleware(robotRL))
{
robotGroup.GET("/wecom", robotHandler.HandleWecomGET)
robotGroup.POST("/wecom", robotHandler.HandleWecomPOST)
robotGroup.POST("/dingtalk", robotHandler.HandleDingtalkPOST)
robotGroup.POST("/lark", robotHandler.HandleLarkPOST)
}
protected := api.Group("")
protected.Use(security.AuthMiddleware(authManager))
@@ -681,6 +723,7 @@ func setupRoutes(
// 配置管理
protected.GET("/config", configHandler.GetConfig)
protected.GET("/config/tools", configHandler.GetTools)
protected.GET("/config/tools/:name/schema", configHandler.GetToolSchema)
protected.PUT("/config", configHandler.UpdateConfig)
protected.POST("/config/apply", configHandler.ApplyConfig)
protected.POST("/config/test-openai", configHandler.TestOpenAI)
@@ -881,7 +924,6 @@ func setupRoutes(
// 角色管理
protected.GET("/roles", roleHandler.GetRoles)
protected.GET("/roles/:name", roleHandler.GetRole)
protected.GET("/roles/skills/list", roleHandler.GetSkills)
protected.POST("/roles", roleHandler.CreateRole)
protected.PUT("/roles/:name", roleHandler.UpdateRole)
protected.DELETE("/roles/:name", roleHandler.DeleteRole)
+50 -30
View File
@@ -120,7 +120,7 @@ type MultiAgentSubConfig struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
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
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` // 仅 Markdownkind=orchestrator 表示 Deep 主代理(与 orchestrator.md 二选一约定)
@@ -257,28 +257,52 @@ type ExternalMCPConfig struct {
Servers map[string]ExternalMCPServerConfig `yaml:"servers,omitempty" json:"servers,omitempty"`
}
// ExternalMCPServerConfig 外部MCP服务器配置
// ExternalMCPServerConfig 外部MCP服务器配置(遵循官方 MCP 配置格式,兼容 Claude Desktop / Cursor / VS Code)。
// 所有字符串字段均支持 ${VAR} 和 ${VAR:-default} 环境变量展开语法。
type ExternalMCPServerConfig struct {
// stdio模式配置
// 传输类型: "stdio" | "sse" | "http"Streamable HTTP)。
// stdio 模式可省略,有 command 字段时自动推断。
Type string `yaml:"type,omitempty" json:"type,omitempty"`
// stdio 模式配置
Command string `yaml:"command,omitempty" json:"command,omitempty"`
Args []string `yaml:"args,omitempty" json:"args,omitempty"`
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` // 环境变量(用于stdio模式)
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
// HTTP模式配置
Transport string `yaml:"transport,omitempty" json:"transport,omitempty"` // "stdio" | "sse" | "http"(Streamable) | "simple_http"(自建/简单POST端点,如本机 http://127.0.0.1:8081/mcp)
URL string `yaml:"url,omitempty" json:"url,omitempty"`
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` // HTTP/SSE 请求头(如 x-api-key
// HTTP/SSE 模式配置
URL string `yaml:"url,omitempty" json:"url,omitempty"`
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
// 官方标准字段
Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` // 禁用服务器(官方字段)
AutoApprove []string `yaml:"autoApprove,omitempty" json:"autoApprove,omitempty"` // 自动批准的工具列表(官方字段)
// SDK 高级配置(对应 MCP Go SDK 传输层参数)
MaxRetries int `yaml:"max_retries,omitempty" json:"max_retries,omitempty"` // Streamable HTTP 断线重连次数(默认 5)
TerminateDuration int `yaml:"terminate_duration,omitempty" json:"terminate_duration,omitempty"` // stdio 进程优雅关闭等待秒数(默认 5)
KeepAlive int `yaml:"keep_alive,omitempty" json:"keep_alive,omitempty"` // 客户端心跳间隔秒数(0 = 禁用)
// 通用配置
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` // 超时时间(秒)
ExternalMCPEnable bool `yaml:"external_mcp_enable,omitempty" json:"external_mcp_enable,omitempty"` // 是否启用外部MCP
ToolEnabled map[string]bool `yaml:"tool_enabled,omitempty" json:"tool_enabled,omitempty"` // 每个工具的启用状态(工具名称 -> 是否启用)
// 向后兼容字段(已废弃,保留用于读取旧配置)
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` // 已废弃,使用 external_mcp_enable
Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` // 已废弃,使用 external_mcp_enable
Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` // 连接超时(秒)
ExternalMCPEnable bool `yaml:"external_mcp_enable,omitempty" json:"external_mcp_enable,omitempty"` // 是否启用
ToolEnabled map[string]bool `yaml:"tool_enabled,omitempty" json:"tool_enabled,omitempty"` // 每个工具的启用状态
}
// GetTransportType 返回实际传输类型。优先读 Type,否则根据 Command/URL 自动推断。
func (c ExternalMCPServerConfig) GetTransportType() string {
if c.Type != "" {
return c.Type
}
if c.Command != "" {
return "stdio"
}
if c.URL != "" {
return "http"
}
return ""
}
type ToolConfig struct {
Name string `yaml:"name"`
Command string `yaml:"command"`
@@ -369,23 +393,20 @@ func Load(path string) (*Config, error) {
cfg.Security.Tools = tools
}
// 迁移外部MCP配置:将旧的 enabled/disabled 字段迁移到 external_mcp_enable
// 外部 MCP:迁移 + 环境变量展开
if cfg.ExternalMCP.Servers != nil {
for name, serverCfg := range cfg.ExternalMCP.Servers {
// 如果已经设置了 external_mcp_enable,跳过迁移
// 否则从 enabled/disabled 字段迁移
// 注意:由于 ExternalMCPEnable 是 bool 类型,零值为 false,所以需要检查是否真的设置了
// 这里我们通过检查旧的 enabled/disabled 字段来判断是否需要迁移
// 官方 disabled 字段 → ExternalMCPEnable
if serverCfg.Disabled {
// 旧配置使用 disabled,迁移到 external_mcp_enable
serverCfg.ExternalMCPEnable = false
} else if serverCfg.Enabled {
// 旧配置使用 enabled,迁移到 external_mcp_enable
serverCfg.ExternalMCPEnable = true
} else {
// 都没有设置,默认为启用
} else if !serverCfg.ExternalMCPEnable {
// 默认启用
serverCfg.ExternalMCPEnable = true
}
// 展开所有 ${VAR} / ${VAR:-default} 环境变量引用
ExpandConfigEnv(&serverCfg)
cfg.ExternalMCP.Servers[name] = serverCfg
}
}
@@ -933,8 +954,7 @@ type RoleConfig struct {
Description string `yaml:"description" json:"description"` // 角色描述
UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前)
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName"
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
Skills []string `yaml:"skills,omitempty" json:"skills,omitempty"` // 关联的skills列表(skill名称列表,在执行任务前会读取这些skills的内容)
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName"
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用
}
+66
View File
@@ -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)
}
}
+81
View File
@@ -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")
}
}
+15 -2
View File
@@ -4,11 +4,20 @@ import (
"database/sql"
"fmt"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
"go.uber.org/zap"
)
// configureDBPool 设置 SQLite 连接池参数,提升并发稳定性
func configureDBPool(db *sql.DB) {
// SQLite 同一时间只允许一个写入者,限制连接数避免 "database is locked" 错误
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
}
// DB 数据库连接
type DB struct {
*sql.DB
@@ -17,11 +26,13 @@ type DB struct {
// NewDB 创建数据库连接
func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1")
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
if err != nil {
return nil, fmt.Errorf("打开数据库失败: %w", err)
}
configureDBPool(db)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
@@ -674,11 +685,13 @@ func (db *DB) migrateBatchTaskQueuesTable() error {
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1")
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
if err != nil {
return nil, fmt.Errorf("打开知识库数据库失败: %w", err)
}
configureDBPool(sqlDB)
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("连接知识库数据库失败: %w", err)
}
+39 -37
View File
@@ -494,8 +494,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
// 应用角色用户提示词和工具配置
finalMessage := req.Message
var roleTools []string // 角色配置的工具列表
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
var roleTools []string // 角色配置的工具列表
// WebShell AI 助手模式:绑定当前连接,仅开放 webshell_* 工具并注入 connection_id
if req.WebShellConnectionID != "" {
@@ -509,8 +508,19 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
if remark == "" {
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)
// 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{
builtin.ToolWebshellExec,
builtin.ToolWebshellFileList,
@@ -520,7 +530,6 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
builtin.ToolListKnowledgeRiskTypes,
builtin.ToolSearchKnowledgeBase,
}
roleSkills = nil
} else if req.Role != "" && req.Role != "默认" {
if h.config.Roles != nil {
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
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和角色工具列表)
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
result, err := h.agent.AgentLoopWithProgress(c.Request.Context(), finalMessage, agentHistoryMessages, conversationID, nil, roleTools, roleSkills)
result, err := h.agent.AgentLoopWithProgress(c.Request.Context(), finalMessage, agentHistoryMessages, conversationID, nil, roleTools)
if err != nil {
h.logger.Error("Agent Loop执行失败", zap.Error(err))
@@ -635,14 +638,13 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
}
finalMessage := message
var roleTools, roleSkills []string
var roleTools []string
if role != "" && role != "默认" && h.config.Roles != nil {
if r, exists := h.config.Roles[role]; exists && r.Enabled {
if r.UserPrompt != "" {
finalMessage = r.UserPrompt + "\n\n" + message
}
roleTools = r.Tools
roleSkills = r.Skills
}
}
@@ -709,7 +711,7 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
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 {
errMsg := "执行失败: " + err.Error()
if assistantMessageID != "" {
@@ -1252,7 +1254,6 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 应用角色用户提示词和工具配置
finalMessage := req.Message
var roleTools []string // 角色配置的工具列表
var roleSkills []string
if req.WebShellConnectionID != "" {
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
if errConn != nil || conn == nil {
@@ -1264,8 +1265,19 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
if remark == "" {
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)
// 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{
builtin.ToolWebshellExec,
builtin.ToolWebshellFileList,
@@ -1292,11 +1304,6 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 因为mcps是MCP服务器名称,不是工具列表
h.logger.Info("角色配置使用旧的mcps字段,将使用所有工具", zap.String("role", req.Role))
}
// 注意:角色 skills 仅在系统提示词中提示;运行时加载请使用 Eino 多代理内置 `skill` 工具
if len(role.Skills) > 0 {
roleSkills = role.Skills
h.logger.Info("角色配置了skillsAI可通过工具按需调用", 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和角色工具列表)
sendEvent("progress", "正在分析您的请求...", nil)
// 注意:roleSkills 已在上方根据 req.Role 或 WebShell 模式设置
stopKeepalive := make(chan struct{})
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
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 {
h.logger.Error("Agent Loop执行失败", zap.Error(err))
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) {
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists {
return false, nil
}
// 先获取执行互斥门,再读取队列状态,避免基于过时快照做判断
if !h.markBatchQueueRunning(queueID) {
return true, nil
}
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists {
h.unmarkBatchQueueRunning(queueID)
return false, nil
}
if scheduled {
if queue.ScheduleMode != "cron" {
h.unmarkBatchQueueRunning(queueID)
@@ -2220,8 +2229,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
// 应用角色用户提示词和工具配置
finalMessage := task.Message
var roleTools []string // 角色配置的工具列表
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
var roleTools []string // 角色配置的工具列表
if queue.Role != "" && queue.Role != "默认" {
if h.config.Roles != nil {
if role, exists := h.config.Roles[queue.Role]; exists && role.Enabled {
@@ -2235,11 +2243,6 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
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)))
}
// 获取角色配置的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)
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
useBatchMulti := false
useEinoSingle := false
batchOrch := "deep"
@@ -2304,10 +2306,10 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
if h.config == nil {
runErr = fmt.Errorf("服务器配置未加载")
} 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:
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)
+63 -68
View File
@@ -543,16 +543,23 @@ func (m *BatchTaskManager) UpdateTaskStatus(queueID, taskID, status string, resu
// UpdateTaskStatusWithConversationID 更新任务状态(包含conversationId
func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, status string, result, errorMsg, conversationID string) {
var needDBUpdate bool
// 在锁内只更新内存状态
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
m.mu.Unlock()
return
}
// DB 优先:先持久化,成功后再更新内存,避免重启后状态不一致
if m.db != nil {
if err := m.db.UpdateBatchTaskStatus(queueID, taskID, status, conversationID, result, errorMsg); err != nil {
m.logger.Warn("batch task DB status update failed, skipping memory update",
zap.String("queueId", queueID), zap.String("taskId", taskID), zap.Error(err))
return
}
}
for _, task := range queue.Tasks {
if task.ID == taskID {
task.Status = status
@@ -575,30 +582,27 @@ func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, s
break
}
}
needDBUpdate = m.db != nil
m.mu.Unlock()
// 释放锁后写 DB
if needDBUpdate {
if err := m.db.UpdateBatchTaskStatus(queueID, taskID, status, conversationID, result, errorMsg); err != nil {
m.logger.Warn("batch task DB status update failed", zap.String("queueId", queueID), zap.String("taskId", taskID), zap.Error(err))
}
}
}
// UpdateQueueStatus 更新队列状态
func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
var needDBUpdate bool
// 在锁内只更新内存状态
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
m.mu.Unlock()
return
}
// DB 优先:先持久化,成功后再更新内存
if m.db != nil {
if err := m.db.UpdateBatchQueueStatus(queueID, status); err != nil {
m.logger.Warn("batch queue DB status update failed, skipping memory update",
zap.String("queueId", queueID), zap.Error(err))
return
}
}
queue.Status = status
now := time.Now()
if status == BatchQueueStatusRunning && queue.StartedAt == nil {
@@ -607,16 +611,6 @@ func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
if status == BatchQueueStatusCompleted || status == BatchQueueStatusCancelled {
queue.CompletedAt = &now
}
needDBUpdate = m.db != nil
m.mu.Unlock()
// 释放锁后写 DB
if needDBUpdate {
if err := m.db.UpdateBatchQueueStatus(queueID, status); err != nil {
m.logger.Warn("batch queue DB status update failed", zap.String("queueId", queueID), zap.Error(err))
}
}
}
// UpdateQueueSchedule 更新队列调度配置
@@ -756,6 +750,16 @@ func (m *BatchTaskManager) ResetQueueForRerun(queueID string) bool {
if !exists {
return false
}
// DB 优先:先持久化重置,成功后再更新内存,避免 DB 失败导致内存脏状态
if m.db != nil {
if err := m.db.ResetBatchQueueForRerun(queueID); err != nil {
m.logger.Warn("batch queue DB reset for rerun failed, skipping memory update",
zap.String("queueId", queueID), zap.Error(err))
return false
}
}
queue.Status = BatchQueueStatusPending
queue.CurrentIndex = 0
queue.StartedAt = nil
@@ -771,12 +775,6 @@ func (m *BatchTaskManager) ResetQueueForRerun(queueID string) bool {
task.Error = ""
task.Result = ""
}
if m.db != nil {
if err := m.db.ResetBatchQueueForRerun(queueID); err != nil {
return false
}
}
return true
}
@@ -870,7 +868,7 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
return fmt.Errorf("队列正在执行或未就绪,无法删除任务")
}
// 查找并删除任务
// 查找任务
taskIndex := -1
for i, task := range queue.Tasks {
if task.ID == taskID {
@@ -886,18 +884,14 @@ func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
return fmt.Errorf("任务不存在")
}
// 从内存队列中删
queue.Tasks = append(queue.Tasks[:taskIndex], queue.Tasks[taskIndex+1:]...)
// 同步到数据库
// DB 优先:先从数据库删除,成功后再从内存移
if m.db != nil {
if err := m.db.DeleteBatchTask(queueID, taskID); err != nil {
// 如果数据库删除失败,恢复内存中的任务
// 这里需要重新插入,但为了简化,我们只记录错误
return fmt.Errorf("删除任务失败: %w", err)
}
}
queue.Tasks = append(queue.Tasks[:taskIndex], queue.Tasks[taskIndex+1:]...)
return nil
}
@@ -987,9 +981,7 @@ func (m *BatchTaskManager) SetTaskCancel(queueID string, cancel context.CancelFu
// PauseQueue 暂停队列
func (m *BatchTaskManager) PauseQueue(queueID string) bool {
var cancelFunc context.CancelFunc
var needDBUpdate bool
// 在锁内只更新内存状态
m.mu.Lock()
queue, exists := m.queues[queueID]
if !exists {
@@ -1002,6 +994,16 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
return false
}
// DB 优先:先持久化,成功后再更新内存
if m.db != nil {
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusPaused); err != nil {
m.logger.Warn("batch queue DB pause update failed, skipping memory update",
zap.String("queueId", queueID), zap.Error(err))
m.mu.Unlock()
return false
}
}
queue.Status = BatchQueueStatusPaused
// 取消当前正在执行的任务(通过取消context)
@@ -1009,22 +1011,13 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
cancelFunc = cancel
delete(m.taskCancels, queueID)
}
needDBUpdate = m.db != nil
m.mu.Unlock()
// 释放锁后执行取消回调
// 释放锁后执行取消回调cancel 可能阻塞,不应持锁)
if cancelFunc != nil {
cancelFunc()
}
// 释放锁后写 DB
if needDBUpdate {
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusPaused); err != nil {
m.logger.Warn("batch queue DB pause update failed", zap.String("queueId", queueID), zap.Error(err))
}
}
return true
}
@@ -1032,9 +1025,7 @@ func (m *BatchTaskManager) PauseQueue(queueID string) bool {
func (m *BatchTaskManager) CancelQueue(queueID string) bool {
now := time.Now()
var cancelFunc context.CancelFunc
var needDBUpdate bool
// 在锁内只更新内存状态,不做 DB 操作
m.mu.Lock()
queue, exists := m.queues[queueID]
if !exists {
@@ -1047,6 +1038,22 @@ func (m *BatchTaskManager) CancelQueue(queueID string) bool {
return false
}
// DB 优先:先持久化,成功后再更新内存
if m.db != nil {
if err := m.db.CancelPendingBatchTasks(queueID, now); err != nil {
m.logger.Warn("batch task DB batch cancel failed, skipping memory update",
zap.String("queueId", queueID), zap.Error(err))
m.mu.Unlock()
return false
}
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusCancelled); err != nil {
m.logger.Warn("batch queue DB cancel update failed, skipping memory update",
zap.String("queueId", queueID), zap.Error(err))
m.mu.Unlock()
return false
}
}
queue.Status = BatchQueueStatusCancelled
queue.CompletedAt = &now
@@ -1063,25 +1070,13 @@ func (m *BatchTaskManager) CancelQueue(queueID string) bool {
cancelFunc = cancel
delete(m.taskCancels, queueID)
}
needDBUpdate = m.db != nil
m.mu.Unlock()
// 释放锁后执行取消回调
// 释放锁后执行取消回调cancel 可能阻塞,不应持锁)
if cancelFunc != nil {
cancelFunc()
}
// 释放锁后批量写 DB(单条 SQL 取消所有 pending 任务)
if needDBUpdate {
if err := m.db.CancelPendingBatchTasks(queueID, now); err != nil {
m.logger.Warn("batch task DB batch cancel failed", zap.String("queueId", queueID), zap.Error(err))
}
if err := m.db.UpdateBatchQueueStatus(queueID, BatchQueueStatusCancelled); err != nil {
m.logger.Warn("batch queue DB cancel update failed", zap.String("queueId", queueID), zap.Error(err))
}
}
return true
}
+23 -15
View File
@@ -27,7 +27,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
// --- list ---
reg(mcp.Tool{
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: "列出批量任务队列",
InputSchema: map[string]interface{}{
"type": "object",
@@ -101,7 +101,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
// --- get ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskGet,
Description: "根据 queue_id 获取单个批量任务队列详情(含子任务列表、Cron、调度开关与最近错误信息)。",
Description: "根据 queue_id 获取单个批量任务队列详情(含子任务列表、Cron、调度开关与最近错误信息)。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确提及查看/管理批量任务、任务队列时才可调用。不要在用户未要求时自行调用。",
ShortDescription: "获取批量任务队列详情",
InputSchema: map[string]interface{}{
"type": "object",
@@ -128,11 +128,13 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
// --- create ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskCreate,
Description: `用途应用内任务管理 / 批量任务队列把多条彼此独立的用户指令登记成一条队列便于在界面里查看进度暂停/继续定时重跑等这是队列数据与调度入口不是再开一个子代理会话替你探索当前问题
Description: ` 调用约束本工具属于任务管理模块仅当用户明确要求创建批量任务任务队列时才可调用禁止在用户未提及批量任务任务队列定时任务等关键词时自行调用如果用户只是让你做某件事请在当前对话中直接完成不要自作主张创建任务队列
何时用户明确要批量排队执行Cron 周期跑同一批指令或需要与任务管理页面对齐时调用需要即时追问强依赖当前对话上下文的分析/编码应在本对话内直接完成要为了委派而创建队列
应用内任务管理 / 批量任务队列把多条彼此独立的用户指令登记成一条队列便于在界面里查看进度暂停/继续定时重跑等这是队列数据与调度入口是再开一个子代理会话替你探索当前问题
参数tasks字符串数组 tasks_text多行每行一条二选一每项是一条将来由系统按队列顺序执行的指令文案agent_modesingle原生 ReAct默认eino_singleEino ADK 单代理deep / plan_execute / supervisor需系统启用多代理兼容旧值 multi视为 deep把主对话拆给子代理schedule_modemanual默认 croncron 须填 cron_expr5 "0 */6 * * *"
何时用用户明确要批量排队执行Cron 周期跑同一批指令或需要与任务管理页面对齐时调用需要即时追问强依赖当前对话上下文的分析/编码应在本对话内直接完成不要为了委派而创建队列
参数tasks字符串数组 tasks_text多行每行一条二选一每项是一条将来由系统按队列顺序执行的指令文案agent_modesingle原生 ReAct默认eino_singleEino ADK 单代理deep / plan_execute / supervisor需系统启用多代理兼容旧值 multi视为 deep把主对话拆给子代理schedule_modemanual默认 croncron 须填 cron_expr5 0 */6 * * *
执行默认创建后为 pending不自动跑execute_now=true 可创建后立即跑否则之后调用 batch_task_startCron 自动下一轮需 schedule_enabled true可用 batch_task_schedule_enabled`,
ShortDescription: "任务管理:创建批量任务队列(登记多条指令,可选立即或 Cron)",
@@ -239,7 +241,9 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
reg(mcp.Tool{
Name: builtin.ToolBatchTaskStart,
Description: `启动或继续执行批量任务队列pending / paused
batch_task_create 配合使用仅创建队列不会自动执行需调用本工具才会开始跑子任务`,
batch_task_create 配合使用仅创建队列不会自动执行需调用本工具才会开始跑子任务
调用约束本工具属于任务管理模块仅当用户明确要求启动/继续批量任务时才可调用不要在用户未要求时自行调用`,
ShortDescription: "启动/继续批量任务队列(创建后需调用才会执行)",
InputSchema: map[string]interface{}{
"type": "object",
@@ -270,7 +274,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
// --- rerun (reset + start for completed/cancelled queues) ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskRerun,
Description: "重跑已完成或已取消的批量任务队列。会重置所有子任务状态后重新执行一轮。",
Description: "重跑已完成或已取消的批量任务队列。会重置所有子任务状态后重新执行一轮。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求重跑批量任务时才可调用。不要在用户未要求时自行调用。",
ShortDescription: "重跑批量任务队列",
InputSchema: map[string]interface{}{
"type": "object",
@@ -311,7 +315,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
// --- pause ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskPause,
Description: "暂停正在运行的批量任务队列(当前子任务会被取消)。",
Description: "暂停正在运行的批量任务队列(当前子任务会被取消)。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求暂停批量任务时才可调用。不要在用户未要求时自行调用。",
ShortDescription: "暂停批量任务队列",
InputSchema: map[string]interface{}{
"type": "object",
@@ -338,7 +342,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
// --- delete queue ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskDelete,
Description: "删除批量任务队列及其子任务记录。",
Description: "删除批量任务队列及其子任务记录。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求删除批量任务队列时才可调用。不要在用户未要求时自行调用。",
ShortDescription: "删除批量任务队列",
InputSchema: map[string]interface{}{
"type": "object",
@@ -365,7 +369,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
// --- update metadata (title/role/agentMode) ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskUpdateMetadata,
Description: "修改批量任务队列的标题、角色和代理模式。仅在队列非 running 状态下可修改。",
Description: "修改批量任务队列的标题、角色和代理模式。仅在队列非 running 状态下可修改。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求修改批量任务队列属性时才可调用。不要在用户未要求时自行调用。",
ShortDescription: "修改批量任务队列标题/角色/代理模式",
InputSchema: map[string]interface{}{
"type": "object",
@@ -410,7 +414,9 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
reg(mcp.Tool{
Name: builtin.ToolBatchTaskUpdateSchedule,
Description: `修改批量任务队列的调度方式和 Cron 表达式仅在队列非 running 状态下可修改
schedule_mode cron 时必须提供有效 cron_expr manual 时会清除 Cron 配置`,
schedule_mode cron 时必须提供有效 cron_expr manual 时会清除 Cron 配置
调用约束本工具属于任务管理模块仅当用户明确要求修改批量任务调度配置时才可调用不要在用户未要求时自行调用`,
ShortDescription: "修改批量任务调度配置(Cron 表达式)",
InputSchema: map[string]interface{}{
"type": "object",
@@ -467,7 +473,9 @@ schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除
reg(mcp.Tool{
Name: builtin.ToolBatchTaskScheduleEnabled,
Description: `设置是否允许 Cron 自动触发该队列关闭后仍保留 Cron 表达式仅停止定时自动跑可用手工启动执行
仅对 schedule_mode cron 的队列有意义`,
仅对 schedule_mode cron 的队列有意义
调用约束本工具属于任务管理模块仅当用户明确要求开关批量任务自动调度时才可调用不要在用户未要求时自行调用`,
ShortDescription: "开关批量任务 Cron 自动调度",
InputSchema: map[string]interface{}{
"type": "object",
@@ -506,7 +514,7 @@ schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除
// --- add task ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskAdd,
Description: "向处于 pending 状态的队列追加一条子任务。",
Description: "向处于 pending 状态的队列追加一条子任务。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求向批量任务队列添加子任务时才可调用。不要在用户未要求时自行调用。",
ShortDescription: "批量队列添加子任务",
InputSchema: map[string]interface{}{
"type": "object",
@@ -540,7 +548,7 @@ schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除
// --- update task ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskUpdate,
Description: "修改 pending 队列中仍为 pending 的子任务文案。",
Description: "修改 pending 队列中仍为 pending 的子任务文案。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求修改批量子任务内容时才可调用。不要在用户未要求时自行调用。",
ShortDescription: "更新批量子任务内容",
InputSchema: map[string]interface{}{
"type": "object",
@@ -578,7 +586,7 @@ schedule_mode 为 cron 时必须提供有效 cron_expr;为 manual 时会清除
// --- remove task ---
reg(mcp.Tool{
Name: builtin.ToolBatchTaskRemove,
Description: "从 pending 队列中删除仍为 pending 的子任务。",
Description: "从 pending 队列中删除仍为 pending 的子任务。\n\n⚠️ 调用约束:本工具属于「任务管理」模块,仅当用户明确要求删除批量子任务时才可调用。不要在用户未要求时自行调用。",
ShortDescription: "删除批量子任务",
InputSchema: map[string]interface{}{
"type": "object",
+150 -43
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
@@ -193,12 +194,13 @@ type GetConfigResponse struct {
// ToolConfigInfo 工具配置信息
type ToolConfigInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
IsExternal bool `json:"is_external,omitempty"` // 是否为外部MCP工具
ExternalMCP string `json:"external_mcp,omitempty"` // 外部MCP名称(如果是外部工具)
RoleEnabled *bool `json:"role_enabled,omitempty"` // 该工具在当前角色中是否启用(nil表示未指定角色或使用所有工具)
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
IsExternal bool `json:"is_external,omitempty"` // 是否为外部MCP工具
ExternalMCP string `json:"external_mcp,omitempty"` // 外部MCP名称(如果是外部工具)
RoleEnabled *bool `json:"role_enabled,omitempty"` // 该工具在当前角色中是否启用(nil表示未指定角色或使用所有工具)
InputSchema map[string]interface{} `json:"input_schema,omitempty"` // 工具参数 JSON Schema(用于前端展示详情)
}
// GetConfig 获取当前配置
@@ -210,25 +212,25 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
// 首先从配置文件获取工具
configToolMap := make(map[string]bool)
tools := make([]ToolConfigInfo, 0, len(h.config.Security.Tools))
for _, tool := range h.config.Security.Tools {
configToolMap[tool.Name] = true
tools = append(tools, ToolConfigInfo{
info := ToolConfigInfo{
Name: tool.Name,
Description: h.pickToolDescription(tool.ShortDescription, tool.Description),
Enabled: tool.Enabled,
IsExternal: false,
})
}
tools = append(tools, info)
}
// 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具)
if h.mcpServer != nil {
mcpTools := h.mcpServer.GetAllTools()
for _, mcpTool := range mcpTools {
// 跳过已经在配置文件中的工具(避免重复)
if configToolMap[mcpTool.Name] {
continue
}
// 添加直接注册到MCP服务器的工具(如知识检索工具)
description := mcpTool.ShortDescription
if description == "" {
description = mcpTool.Description
@@ -239,7 +241,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
tools = append(tools, ToolConfigInfo{
Name: mcpTool.Name,
Description: description,
Enabled: true, // 直接注册的工具默认启用
Enabled: true,
IsExternal: false,
})
}
@@ -305,6 +307,8 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
h.mu.RLock()
defer h.mu.RUnlock()
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
// 解析分页参数
page := 1
pageSize := 20
@@ -326,15 +330,26 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
searchTermLower = strings.ToLower(searchTerm)
}
// 解析状态筛选参数: "true" = 仅已启用, "false" = 仅已停用, "" = 全部
enabledFilter := c.Query("enabled")
// 解析状态筛选: tool_filter=on|off(角色弹窗等优先,避免与网关/代理对 enabled 的特殊处理冲突)
// 兼容旧参数 enabled=true|false
var filterEnabled *bool
if enabledFilter == "true" {
toolFilter := strings.TrimSpace(strings.ToLower(c.Query("tool_filter")))
switch toolFilter {
case "on", "1", "true", "enabled":
v := true
filterEnabled = &v
} else if enabledFilter == "false" {
case "off", "0", "false", "disabled":
v := false
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{
Name: mcpTool.Name,
Description: description,
Enabled: true, // 直接注册的工具默认启用
Enabled: true,
IsExternal: false,
}
@@ -521,6 +536,17 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
// 注意:这里我们不直接过滤掉工具,而是保留所有工具,但通过 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)
// 统计已启用的工具数(在角色中的启用工具数)
totalEnabled := 0
@@ -1117,32 +1143,7 @@ func (h *ConfigHandler) saveConfig() error {
updateRobotsConfig(root, h.config.Robots)
updateMultiAgentConfig(root, h.config.MultiAgent)
// 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用)
// 读取原始配置以保持向后兼容
originalConfigs := make(map[string]map[string]bool)
externalMCPNode := findMapValue(root, "external_mcp")
if externalMCPNode != nil && externalMCPNode.Kind == yaml.MappingNode {
serversNode := findMapValue(externalMCPNode, "servers")
if serversNode != nil && serversNode.Kind == yaml.MappingNode {
for i := 0; i < len(serversNode.Content); i += 2 {
if i+1 >= len(serversNode.Content) {
break
}
nameNode := serversNode.Content[i]
serverNode := serversNode.Content[i+1]
if nameNode.Kind == yaml.ScalarNode && serverNode.Kind == yaml.MappingNode {
serverName := nameNode.Value
originalConfigs[serverName] = make(map[string]bool)
if enabledVal := findBoolInMap(serverNode, "enabled"); enabledVal != nil {
originalConfigs[serverName]["enabled"] = *enabledVal
}
if disabledVal := findBoolInMap(serverNode, "disabled"); disabledVal != nil {
originalConfigs[serverName]["disabled"] = *disabledVal
}
}
}
}
}
updateExternalMCPConfig(root, h.config.ExternalMCP, originalConfigs)
updateExternalMCPConfig(root, h.config.ExternalMCP)
if err := writeYAMLDocument(h.configPath, root); err != nil {
return fmt.Errorf("保存配置文件失败: %w", err)
@@ -1560,7 +1561,7 @@ func (h *ConfigHandler) calculateExternalToolEnabled(mcpName, toolName string, c
}
// 首先检查外部MCP是否启用
if !cfg.ExternalMCPEnable && !(cfg.Enabled && !cfg.Disabled) {
if !cfg.ExternalMCPEnable {
return false // MCP未启用,所有工具都禁用
}
@@ -1599,3 +1600,109 @@ func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
}
return description
}
// GetToolSchema 获取单个工具的 inputSchema(按需加载,避免列表接口返回大量 schema 数据)
func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
h.mu.RLock()
defer h.mu.RUnlock()
toolName := c.Param("name")
if toolName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称不能为空"})
return
}
// 检查是否为外部工具(格式:mcpName::toolName
externalMCP := c.Query("external_mcp")
if externalMCP != "" {
// 外部 MCP 工具
if h.externalMCPMgr != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
externalTools, _ := h.externalMCPMgr.GetAllTools(ctx)
fullName := externalMCP + "::" + toolName
for _, t := range externalTools {
if t.Name == fullName {
c.JSON(http.StatusOK, gin.H{"input_schema": t.InputSchema})
return
}
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "外部工具未找到"})
return
}
// 内部工具:从 YAML 配置的 Parameters 构建
for _, tool := range h.config.Security.Tools {
if tool.Name == toolName {
c.JSON(http.StatusOK, gin.H{"input_schema": buildInputSchemaFromParams(tool.Parameters)})
return
}
}
// MCP 注册工具(如知识检索)
if h.mcpServer != nil {
for _, mt := range h.mcpServer.GetAllTools() {
if mt.Name == toolName {
c.JSON(http.StatusOK, gin.H{"input_schema": mt.InputSchema})
return
}
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "工具未找到"})
}
// buildInputSchemaFromParams 从 YAML 工具的 ParameterConfig 构建 JSON Schema(用于前端展示)。
// 不依赖 MCP 服务器注册状态,所有工具(包括未启用的)都能返回参数定义。
func buildInputSchemaFromParams(params []config.ParameterConfig) map[string]interface{} {
if len(params) == 0 {
return nil
}
properties := make(map[string]interface{})
required := make([]string, 0)
for _, p := range params {
name := strings.TrimSpace(p.Name)
if name == "" {
continue
}
prop := map[string]interface{}{
"type": convertParamType(p.Type),
"description": p.Description,
}
if p.Default != nil {
prop["default"] = p.Default
}
if len(p.Options) > 0 {
prop["enum"] = p.Options
}
properties[name] = prop
if p.Required {
required = append(required, name)
}
}
schema := map[string]interface{}{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
func convertParamType(t string) string {
switch strings.TrimSpace(strings.ToLower(t)) {
case "int", "integer", "number":
return "number"
case "bool", "boolean":
return "boolean"
case "array", "list":
return "array"
default:
return "string"
}
}
-2
View File
@@ -151,7 +151,6 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
prep.FinalMessage,
prep.History,
prep.RoleTools,
prep.RoleSkills,
progressCallback,
)
@@ -255,7 +254,6 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
prep.FinalMessage,
prep.History,
prep.RoleTools,
prep.RoleSkills,
progressCallback,
)
if runErr != nil {
+41 -124
View File
@@ -157,36 +157,19 @@ func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
h.config.ExternalMCP.Servers = make(map[string]config.ExternalMCPServerConfig)
}
// 如果用户提供了 disabled 或 enabled 字段,保留它们以保持向后兼容
// 同时将值迁移到 external_mcp_enable
cfg := req.Config
if req.Config.Disabled {
// 用户设置了 disabled: true
// 官方 disabled 字段 → ExternalMCPEnable 取反
if cfg.Disabled {
cfg.ExternalMCPEnable = false
cfg.Disabled = true
cfg.Enabled = false
} else if req.Config.Enabled {
// 用户设置了 enabled: true
} else if !cfg.ExternalMCPEnable {
// 用户未显式设置 external_mcp_enable,官方配置默认就是启用的
cfg.ExternalMCPEnable = true
cfg.Enabled = true
cfg.Disabled = false
} else if !req.Config.ExternalMCPEnable {
// 用户没有设置任何字段,且 external_mcp_enable 为 false
// 检查现有配置是否有旧字段
if existingCfg, exists := h.config.ExternalMCP.Servers[name]; exists {
// 保留现有的旧字段
cfg.Enabled = existingCfg.Enabled
cfg.Disabled = existingCfg.Disabled
}
} else {
// 用户通过新字段启用了(external_mcp_enable: true),但没有设置旧字段
// 为了向后兼容,我们设置 enabled: true
// 这样即使原始配置中有 disabled: false,也会被转换为 enabled: true
cfg.Enabled = true
cfg.Disabled = false
}
// 展开 ${VAR} 环境变量
config.ExpandConfigEnv(&cfg)
h.config.ExternalMCP.Servers[name] = cfg
// 保存到配置文件
@@ -315,32 +298,25 @@ func (h *ExternalMCPHandler) GetExternalMCPStats(c *gin.Context) {
c.JSON(http.StatusOK, stats)
}
// validateConfig 验证配置
// validateConfig 验证配置(同时支持官方 type 字段和旧版 transport 字段)
func (h *ExternalMCPHandler) validateConfig(cfg config.ExternalMCPServerConfig) error {
transport := cfg.Transport
transport := cfg.GetTransportType()
if transport == "" {
// 如果没有指定transport,根据是否有command或url判断
if cfg.Command != "" {
transport = "stdio"
} else if cfg.URL != "" {
transport = "http"
} else {
return fmt.Errorf("需要指定commandstdio模式)或urlhttp/sse模式)")
}
return fmt.Errorf("需要指定 commandstdio模式)或 url + typehttp/sse模式)")
}
switch transport {
case "http":
if cfg.URL == "" {
return fmt.Errorf("HTTP模式需要URL")
return fmt.Errorf("HTTP模式需要 url")
}
case "stdio":
if cfg.Command == "" {
return fmt.Errorf("stdio模式需要command")
return fmt.Errorf("stdio模式需要 command")
}
case "sse":
if cfg.URL == "" {
return fmt.Errorf("SSE模式需要URL")
return fmt.Errorf("SSE模式需要 url")
}
default:
return fmt.Errorf("不支持的传输模式: %s,支持的模式: http, stdio, sse", transport)
@@ -351,25 +327,11 @@ func (h *ExternalMCPHandler) validateConfig(cfg config.ExternalMCPServerConfig)
// isEnabled 检查是否启用
func (h *ExternalMCPHandler) isEnabled(cfg config.ExternalMCPServerConfig) bool {
// 优先使用 ExternalMCPEnable 字段
// 如果没有设置,检查旧的 enabled/disabled 字段(向后兼容)
if cfg.ExternalMCPEnable {
return true
}
// 向后兼容:检查旧字段
if cfg.Disabled {
return false
}
if cfg.Enabled {
return true
}
// 都没有设置,默认为启用
return true
return cfg.ExternalMCPEnable
}
// saveConfig 保存配置到文件
func (h *ExternalMCPHandler) saveConfig() error {
// 读取现有配置文件并创建备份
data, err := os.ReadFile(h.configPath)
if err != nil {
return fmt.Errorf("读取配置文件失败: %w", err)
@@ -384,37 +346,7 @@ func (h *ExternalMCPHandler) saveConfig() error {
return fmt.Errorf("解析配置文件失败: %w", err)
}
// 在更新前,读取原始配置中的 enabled/disabled 字段,以便保持向后兼容
originalConfigs := make(map[string]map[string]bool)
externalMCPNode := findMapValue(root.Content[0], "external_mcp")
if externalMCPNode != nil && externalMCPNode.Kind == yaml.MappingNode {
serversNode := findMapValue(externalMCPNode, "servers")
if serversNode != nil && serversNode.Kind == yaml.MappingNode {
// 遍历现有的服务器配置,保存 enabled/disabled 字段
for i := 0; i < len(serversNode.Content); i += 2 {
if i+1 >= len(serversNode.Content) {
break
}
nameNode := serversNode.Content[i]
serverNode := serversNode.Content[i+1]
if nameNode.Kind == yaml.ScalarNode && serverNode.Kind == yaml.MappingNode {
serverName := nameNode.Value
originalConfigs[serverName] = make(map[string]bool)
// 检查是否有 enabled 字段
if enabledVal := findBoolInMap(serverNode, "enabled"); enabledVal != nil {
originalConfigs[serverName]["enabled"] = *enabledVal
}
// 检查是否有 disabled 字段
if disabledVal := findBoolInMap(serverNode, "disabled"); disabledVal != nil {
originalConfigs[serverName]["disabled"] = *disabledVal
}
}
}
}
}
// 更新外部MCP配置
updateExternalMCPConfig(root, h.config.ExternalMCP, originalConfigs)
updateExternalMCPConfig(root, h.config.ExternalMCP)
if err := writeYAMLDocument(h.configPath, root); err != nil {
return fmt.Errorf("保存配置文件失败: %w", err)
@@ -425,7 +357,7 @@ func (h *ExternalMCPHandler) saveConfig() error {
}
// updateExternalMCPConfig 更新外部MCP配置
func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, originalConfigs map[string]map[string]bool) {
func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig) {
root := doc.Content[0]
externalMCPNode := ensureMap(root, "external_mcp")
serversNode := ensureMap(externalMCPNode, "servers")
@@ -435,32 +367,31 @@ func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, origi
// 添加新的服务器配置
for name, serverCfg := range cfg.Servers {
// 添加服务器名称键
nameNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name}
serverNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
serversNode.Content = append(serversNode.Content, nameNode, serverNode)
// 设置服务器配置字段
// type(官方 MCP 传输类型)
effectiveType := serverCfg.GetTransportType()
if effectiveType != "" && effectiveType != "stdio" {
// stdio 可省略(有 command 时自动推断)
setStringInMap(serverNode, "type", effectiveType)
}
if serverCfg.Command != "" {
setStringInMap(serverNode, "command", serverCfg.Command)
}
if len(serverCfg.Args) > 0 {
setStringArrayInMap(serverNode, "args", serverCfg.Args)
}
// 保存 env 字段(环境变量)
if serverCfg.Env != nil && len(serverCfg.Env) > 0 {
envNode := ensureMap(serverNode, "env")
for envKey, envValue := range serverCfg.Env {
setStringInMap(envNode, envKey, envValue)
}
}
if serverCfg.Transport != "" {
setStringInMap(serverNode, "transport", serverCfg.Transport)
}
if serverCfg.URL != "" {
setStringInMap(serverNode, "url", serverCfg.URL)
}
// 保存 headers 字段(HTTP/SSE 请求头)
if serverCfg.Headers != nil && len(serverCfg.Headers) > 0 {
headersNode := ensureMap(serverNode, "headers")
for k, v := range serverCfg.Headers {
@@ -473,46 +404,32 @@ func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, origi
if serverCfg.Timeout > 0 {
setIntInMap(serverNode, "timeout", serverCfg.Timeout)
}
// 保存 external_mcp_enable 字段(新字段
// 官方标准字段
if serverCfg.Disabled {
setBoolInMap(serverNode, "disabled", true)
}
if len(serverCfg.AutoApprove) > 0 {
setStringArrayInMap(serverNode, "autoApprove", serverCfg.AutoApprove)
}
// SDK 高级配置
if serverCfg.MaxRetries > 0 {
setIntInMap(serverNode, "max_retries", serverCfg.MaxRetries)
}
if serverCfg.TerminateDuration > 0 {
setIntInMap(serverNode, "terminate_duration", serverCfg.TerminateDuration)
}
if serverCfg.KeepAlive > 0 {
setIntInMap(serverNode, "keep_alive", serverCfg.KeepAlive)
}
setBoolInMap(serverNode, "external_mcp_enable", serverCfg.ExternalMCPEnable)
// 保存 tool_enabled 字段(每个工具的启用状态)
if serverCfg.ToolEnabled != nil && len(serverCfg.ToolEnabled) > 0 {
toolEnabledNode := ensureMap(serverNode, "tool_enabled")
for toolName, enabled := range serverCfg.ToolEnabled {
setBoolInMap(toolEnabledNode, toolName, enabled)
}
}
// 保留旧的 enabled/disabled 字段以保持向后兼容
originalFields, hasOriginal := originalConfigs[name]
// 如果原始配置中有 enabled 字段,保留它
if hasOriginal {
if enabledVal, hasEnabled := originalFields["enabled"]; hasEnabled {
setBoolInMap(serverNode, "enabled", enabledVal)
}
// 如果原始配置中有 disabled 字段,保留它
// 注意:由于 omitemptydisabled: 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)
}
}
}
+22 -32
View File
@@ -60,13 +60,13 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_Stdio(t *testing.T) {
router, _, configPath := setupTestRouter()
defer cleanupTestConfig(configPath)
// 测试添加stdio模式的配置
// 测试添加stdio模式的配置(官方格式:有 command 时 type 可省略)
configJSON := `{
"command": "python3",
"args": ["/path/to/script.py", "--server", "http://example.com"],
"description": "Test stdio MCP",
"timeout": 300,
"enabled": true
"external_mcp_enable": true
}`
var configObj config.ExternalMCPServerConfig
@@ -115,20 +115,17 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_Stdio(t *testing.T) {
if response.Config.Timeout != 300 {
t.Errorf("期望timeout为300,实际%d", response.Config.Timeout)
}
if !response.Config.Enabled {
t.Error("期望enabled为true")
}
}
func TestExternalMCPHandler_AddOrUpdateExternalMCP_HTTP(t *testing.T) {
router, _, configPath := setupTestRouter()
defer cleanupTestConfig(configPath)
// 测试添加HTTP模式的配置
// 测试添加HTTP模式的配置(使用官方 type 字段)
configJSON := `{
"transport": "http",
"type": "http",
"url": "http://127.0.0.1:8081/mcp",
"enabled": true
"external_mcp_enable": true
}`
var configObj config.ExternalMCPServerConfig
@@ -165,15 +162,12 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_HTTP(t *testing.T) {
t.Fatalf("解析响应失败: %v", err)
}
if response.Config.Transport != "http" {
t.Errorf("期望transport为http,实际%s", response.Config.Transport)
if response.Config.Type != "http" {
t.Errorf("期望type为http,实际%s", response.Config.Type)
}
if response.Config.URL != "http://127.0.0.1:8081/mcp" {
t.Errorf("期望url为'http://127.0.0.1:8081/mcp',实际%s", response.Config.URL)
}
if !response.Config.Enabled {
t.Error("期望enabled为true")
}
}
func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) {
@@ -187,22 +181,22 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) {
}{
{
name: "缺少command和url",
configJSON: `{"enabled": true}`,
expectedErr: "需要指定commandstdio模式)或urlhttp/sse模式)",
configJSON: `{"external_mcp_enable": true}`,
expectedErr: "需要指定 commandstdio模式)或 url + typehttp/sse模式)",
},
{
name: "stdio模式缺少command",
configJSON: `{"args": ["test"], "enabled": true}`,
configJSON: `{"args": ["test"], "external_mcp_enable": true}`,
expectedErr: "stdio模式需要command",
},
{
name: "http模式缺少url",
configJSON: `{"transport": "http", "enabled": true}`,
expectedErr: "HTTP模式需要URL",
configJSON: `{"type": "http", "external_mcp_enable": true}`,
expectedErr: "HTTP模式需要 url",
},
{
name: "无效的transport",
configJSON: `{"transport": "invalid", "enabled": true}`,
name: "无效的type",
configJSON: `{"type": "invalid", "external_mcp_enable": true}`,
expectedErr: "不支持的传输模式",
},
}
@@ -254,7 +248,7 @@ func TestExternalMCPHandler_DeleteExternalMCP(t *testing.T) {
// 先添加一个配置
configObj := config.ExternalMCPServerConfig{
Command: "python3",
Enabled: true,
ExternalMCPEnable: true,
}
handler.manager.AddOrUpdateConfig("test-delete", configObj)
@@ -283,11 +277,11 @@ func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
// 添加多个配置
handler.manager.AddOrUpdateConfig("test1", config.ExternalMCPServerConfig{
Command: "python3",
Enabled: true,
ExternalMCPEnable: true,
})
handler.manager.AddOrUpdateConfig("test2", config.ExternalMCPServerConfig{
URL: "http://127.0.0.1:8081/mcp",
Enabled: false,
ExternalMCPEnable: false,
})
req := httptest.NewRequest("GET", "/api/external-mcp", nil)
@@ -326,16 +320,14 @@ func TestExternalMCPHandler_GetExternalMCPStats(t *testing.T) {
// 添加配置
handler.manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
Command: "python3",
Enabled: true,
ExternalMCPEnable: true,
})
handler.manager.AddOrUpdateConfig("enabled2", config.ExternalMCPServerConfig{
URL: "http://127.0.0.1:8081/mcp",
Enabled: true,
ExternalMCPEnable: true,
})
handler.manager.AddOrUpdateConfig("disabled1", config.ExternalMCPServerConfig{
Command: "python3",
Enabled: false,
Disabled: true,
})
req := httptest.NewRequest("GET", "/api/external-mcp/stats", nil)
@@ -369,8 +361,6 @@ func TestExternalMCPHandler_StartStopExternalMCP(t *testing.T) {
// 添加一个禁用的配置
handler.manager.AddOrUpdateConfig("test-start-stop", config.ExternalMCPServerConfig{
Command: "python3",
Enabled: false,
Disabled: true,
})
// 测试启动(可能会失败,因为没有真实的服务器)
@@ -427,7 +417,7 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_EmptyName(t *testing.T) {
configObj := config.ExternalMCPServerConfig{
Command: "python3",
Enabled: true,
ExternalMCPEnable: true,
}
reqBody := AddOrUpdateExternalMCPRequest{
@@ -470,14 +460,14 @@ func TestExternalMCPHandler_UpdateExistingConfig(t *testing.T) {
// 先添加配置
config1 := config.ExternalMCPServerConfig{
Command: "python3",
Enabled: true,
ExternalMCPEnable: true,
}
handler.manager.AddOrUpdateConfig("test-update", config1)
// 更新配置
config2 := config.ExternalMCPServerConfig{
URL: "http://127.0.0.1:8081/mcp",
Enabled: true,
ExternalMCPEnable: true,
}
reqBody := AddOrUpdateExternalMCPRequest{
+12 -5
View File
@@ -18,7 +18,6 @@ type multiAgentPrepared struct {
History []agent.ChatMessage
FinalMessage string
RoleTools []string
RoleSkills []string
AssistantMessageID string
UserMessageID string
}
@@ -68,7 +67,6 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
finalMessage := req.Message
var roleTools []string
var roleSkills []string
if req.WebShellConnectionID != "" {
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
if errConn != nil || conn == nil {
@@ -79,8 +77,19 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
if remark == "" {
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)
// 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{
builtin.ToolWebshellExec,
builtin.ToolWebshellFileList,
@@ -96,7 +105,6 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
finalMessage = role.UserPrompt + "\n\n" + req.Message
}
roleTools = role.Tools
roleSkills = role.Skills
}
}
@@ -135,7 +143,6 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
History: agentHistoryMessages,
FinalMessage: finalMessage,
RoleTools: roleTools,
RoleSkills: roleSkills,
AssistantMessageID: assistantMessageID,
UserMessageID: userMessageID,
}, nil
File diff suppressed because it is too large Load Diff
+35
View File
@@ -9,6 +9,8 @@ var apiDocI18nTagToKey = map[string]string{
"角色管理": "roleManagement", "Skills管理": "skillsManagement", "监控": "monitoring",
"配置管理": "configManagement", "外部MCP管理": "externalMCPManagement", "攻击链": "attackChain",
"知识库": "knowledgeBase", "MCP": "mcp",
"FOFA信息收集": "fofaRecon", "终端": "terminal", "WebShell管理": "webshellManagement",
"对话附件": "chatUploads", "机器人集成": "robotIntegration", "多代理Markdown": "markdownAgents",
}
var apiDocI18nSummaryToKey = map[string]string{
@@ -45,6 +47,29 @@ var apiDocI18nSummaryToKey = map[string]string{
"获取检索日志": "getRetrievalLogs", "删除检索日志": "deleteRetrievalLog",
"MCP端点": "mcpEndpoint", "列出所有工具": "listAllTools", "调用工具": "invokeTool", "初始化连接": "initConnection",
"成功响应": "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{
@@ -62,6 +87,16 @@ var apiDocI18nResponseDescToKey = map[string]string{
"任务不存在": "taskNotFound", "对话或分组不存在": "conversationOrGroupNotFound",
"取消请求已提交": "cancelSubmitted", "未找到正在执行的任务": "noRunningTask",
"消息发送成功,返回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
+3 -37
View File
@@ -18,15 +18,9 @@ import (
// RoleHandler 角色处理器
type RoleHandler struct {
config *config.Config
configPath string
logger *zap.Logger
skillsManager SkillsManager // Skills管理器接口(可选)
}
// SkillsManager Skills管理器接口
type SkillsManager interface {
ListSkills() ([]string, error)
config *config.Config
configPath string
logger *zap.Logger
}
// 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 获取所有角色
func (h *RoleHandler) GetRoles(c *gin.Context) {
if h.config.Roles == nil {
+6 -72
View File
@@ -308,31 +308,10 @@ func (h *SkillsHandler) GetSkillBoundRoles(c *gin.Context) {
})
}
// getRolesBoundToSkill 获取绑定指定skill的角色列表(不修改配置)
// getRolesBoundToSkill 预留:角色不再配置 skill 绑定,始终返回空列表。
func (h *SkillsHandler) getRolesBoundToSkill(skillName string) []string {
if h.config.Roles == nil {
return []string{}
}
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
_ = skillName
return nil
}
// 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 {
if h.config.Roles == nil {
return []string{}
}
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
_ = skillName
return nil
}
// saveRolesConfig 保存角色配置到文件(从SkillsHandler调用)
+16 -4
View File
@@ -411,7 +411,10 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
out, readErr := io.ReadAll(resp.Body)
if readErr != nil {
h.logger.Warn("webshell exec read body", zap.Error(readErr))
}
output := string(out)
httpCode := resp.StatusCode
@@ -578,7 +581,10 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
out, readErr := io.ReadAll(resp.Body)
if readErr != nil {
h.logger.Warn("webshell fileop read body", zap.Error(readErr))
}
output := string(out)
c.JSON(http.StatusOK, FileOpResponse{
@@ -633,7 +639,10 @@ func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection,
return "", false, err.Error()
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
out, readErr := io.ReadAll(resp.Body)
if readErr != nil {
h.logger.Warn("webshell ExecWithConnection read body", zap.Error(readErr))
}
return string(out), resp.StatusCode == http.StatusOK, ""
}
@@ -701,6 +710,9 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
return "", false, err.Error()
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
out, readErr := io.ReadAll(resp.Body)
if readErr != nil {
h.logger.Warn("webshell FileOpWithConnection read body", zap.Error(readErr))
}
return string(out), resp.StatusCode == http.StatusOK, ""
}
+40 -186
View File
@@ -2,11 +2,9 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
@@ -16,7 +14,6 @@ import (
"cyberstrike-ai/internal/config"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.uber.org/zap"
)
@@ -268,172 +265,6 @@ func mustJSON(v interface{}) []byte {
return b
}
// simpleHTTPClient 简单 JSON-RPC over HTTP:每次请求一次 POST、响应在 body。实现 ExternalMCPClient。
// 用于自建 MCP(如 http://127.0.0.1:8081/mcp)或其它仅支持简单 POST 的端点。
type simpleHTTPClient struct {
url string
client *http.Client
logger *zap.Logger
mu sync.RWMutex
status string
}
func newSimpleHTTPClient(ctx context.Context, url string, timeout time.Duration, headers map[string]string, logger *zap.Logger) (ExternalMCPClient, error) {
c := &simpleHTTPClient{
url: url,
client: httpClientWithTimeoutAndHeaders(timeout, headers),
logger: logger,
status: "connecting",
}
if err := c.initialize(ctx); err != nil {
return nil, err
}
c.mu.Lock()
c.status = "connected"
c.mu.Unlock()
return c, nil
}
func (c *simpleHTTPClient) setStatus(s string) {
c.mu.Lock()
defer c.mu.Unlock()
c.status = s
}
func (c *simpleHTTPClient) GetStatus() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.status
}
func (c *simpleHTTPClient) IsConnected() bool {
return c.GetStatus() == "connected"
}
func (c *simpleHTTPClient) Initialize(context.Context) error {
return nil // 已在 newSimpleHTTPClient 中完成
}
func (c *simpleHTTPClient) initialize(ctx context.Context) error {
params := InitializeRequest{
ProtocolVersion: ProtocolVersion,
Capabilities: make(map[string]interface{}),
ClientInfo: ClientInfo{Name: clientName, Version: clientVersion},
}
paramsJSON, _ := json.Marshal(params)
req := &Message{
ID: MessageID{value: "1"},
Method: "initialize",
Version: "2.0",
Params: paramsJSON,
}
resp, err := c.sendRequest(ctx, req)
if err != nil {
return fmt.Errorf("initialize: %w", err)
}
if resp.Error != nil {
return fmt.Errorf("initialize: %s (code %d)", resp.Error.Message, resp.Error.Code)
}
// 发送 notifications/initialized(协议要求)
notify := &Message{
ID: MessageID{value: nil},
Method: "notifications/initialized",
Version: "2.0",
Params: json.RawMessage("{}"),
}
_ = c.sendNotification(notify)
return nil
}
func (c *simpleHTTPClient) sendRequest(ctx context.Context, msg *Message) (*Message, error) {
body, err := json.Marshal(msg)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b))
}
var out Message
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
func (c *simpleHTTPClient) sendNotification(msg *Message) error {
body, _ := json.Marshal(msg)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(httpReq)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
func (c *simpleHTTPClient) ListTools(ctx context.Context) ([]Tool, error) {
req := &Message{
ID: MessageID{value: uuid.New().String()},
Method: "tools/list",
Version: "2.0",
Params: json.RawMessage("{}"),
}
resp, err := c.sendRequest(ctx, req)
if err != nil {
return nil, err
}
if resp.Error != nil {
return nil, fmt.Errorf("tools/list: %s (code %d)", resp.Error.Message, resp.Error.Code)
}
var listResp ListToolsResponse
if err := json.Unmarshal(resp.Result, &listResp); err != nil {
return nil, err
}
return listResp.Tools, nil
}
func (c *simpleHTTPClient) CallTool(ctx context.Context, name string, args map[string]interface{}) (*ToolResult, error) {
params := CallToolRequest{Name: name, Arguments: args}
paramsJSON, _ := json.Marshal(params)
req := &Message{
ID: MessageID{value: uuid.New().String()},
Method: "tools/call",
Version: "2.0",
Params: paramsJSON,
}
resp, err := c.sendRequest(ctx, req)
if err != nil {
return nil, err
}
if resp.Error != nil {
return nil, fmt.Errorf("tools/call: %s (code %d)", resp.Error.Message, resp.Error.Code)
}
var callResp CallToolResponse
if err := json.Unmarshal(resp.Result, &callResp); err != nil {
return nil, err
}
return &ToolResult{Content: callResp.Content, IsError: callResp.IsError}, nil
}
func (c *simpleHTTPClient) Close() error {
c.setStatus("disconnected")
return nil
}
// createSDKClient 根据配置创建并连接外部 MCP 客户端(使用官方 SDK),返回实现 ExternalMCPClient 的 *sdkClient
// 若连接失败返回 (nil, error)。ctx 用于连接超时与取消。
func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) (ExternalMCPClient, error) {
@@ -442,21 +273,23 @@ func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConf
timeout = 30 * time.Second
}
transport := serverCfg.Transport
transport := serverCfg.GetTransportType()
if transport == "" {
if serverCfg.Command != "" {
transport = "stdio"
} else if serverCfg.URL != "" {
transport = "http"
} else {
return nil, fmt.Errorf("配置缺少 command 或 url")
return nil, fmt.Errorf("配置缺少 command 或 url,且未指定 type/transport")
}
// 构造 ClientOptionsKeepAlive 心跳
var clientOpts *mcp.ClientOptions
if serverCfg.KeepAlive > 0 {
clientOpts = &mcp.ClientOptions{
KeepAlive: time.Duration(serverCfg.KeepAlive) * time.Second,
}
}
client := mcp.NewClient(&mcp.Implementation{
Name: clientName,
Version: clientVersion,
}, nil)
}, clientOpts)
var t mcp.Transport
switch transport {
@@ -470,12 +303,18 @@ func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConf
if len(serverCfg.Env) > 0 {
cmd.Env = append(cmd.Env, envMapToSlice(serverCfg.Env)...)
}
t = &mcp.CommandTransport{Command: cmd}
ct := &mcp.CommandTransport{Command: cmd}
if serverCfg.TerminateDuration > 0 {
ct.TerminateDuration = time.Duration(serverCfg.TerminateDuration) * time.Second
}
t = ct
case "sse":
if serverCfg.URL == "" {
return nil, fmt.Errorf("sse 模式需要配置 url")
}
httpClient := httpClientWithTimeoutAndHeaders(timeout, serverCfg.Headers)
// SSE 是长连接(GET 流持续打开),不能设置 http.Client.Timeout(会在超时后杀掉整个连接导致 EOF)。
// 超时由每次 ListTools/CallTool 的 context 单独控制。
httpClient := httpClientForLongLived(serverCfg.Headers)
t = &mcp.SSEClientTransport{
Endpoint: serverCfg.URL,
HTTPClient: httpClient,
@@ -485,18 +324,16 @@ func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConf
return nil, fmt.Errorf("http 模式需要配置 url")
}
httpClient := httpClientWithTimeoutAndHeaders(timeout, serverCfg.Headers)
t = &mcp.StreamableClientTransport{
st := &mcp.StreamableClientTransport{
Endpoint: serverCfg.URL,
HTTPClient: httpClient,
}
case "simple_http":
// 简单 JSON-RPC HTTP:每次请求一次 POST、响应在 body。用于自建 MCP 或兼容旧端点(如 http://127.0.0.1:8081/mcp
if serverCfg.URL == "" {
return nil, fmt.Errorf("simple_http 模式需要配置 url")
if serverCfg.MaxRetries > 0 {
st.MaxRetries = serverCfg.MaxRetries
}
return newSimpleHTTPClient(ctx, serverCfg.URL, timeout, serverCfg.Headers, logger)
t = st
default:
return nil, fmt.Errorf("不支持的传输模式: %s", transport)
return nil, fmt.Errorf("不支持的传输模式: %s(支持: stdio, sse, http", transport)
}
session, err := client.Connect(ctx, t, nil)
@@ -538,6 +375,23 @@ func httpClientWithTimeoutAndHeaders(timeout time.Duration, headers map[string]s
}
}
// httpClientForLongLived 创建不设超时的 HTTP 客户端,用于 SSE 等长连接传输。
// SSE 的 GET 流会持续打开,http.Client.Timeout 会在超时后强制关闭连接导致 EOF。
// 超时由调用方通过 context 控制。
func httpClientForLongLived(headers map[string]string) *http.Client {
transport := http.DefaultTransport
if len(headers) > 0 {
transport = &headerRoundTripper{
headers: headers,
base: http.DefaultTransport,
}
}
return &http.Client{
Transport: transport,
// 不设 TimeoutSSE 长连接的超时由 per-request context 控制
}
}
type headerRoundTripper struct {
headers map[string]string
base http.RoundTripper
+16 -40
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"cyberstrike-ai/internal/config"
@@ -29,6 +30,7 @@ type ExternalMCPManager struct {
toolCacheMu sync.RWMutex // 工具列表缓存的锁
stopRefresh chan struct{} // 停止后台刷新的信号
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
mu sync.RWMutex
}
@@ -721,7 +723,13 @@ func (m *ExternalMCPManager) GetToolCounts() map[string]int {
}
// refreshToolCounts 刷新工具数量缓存(后台异步执行)
// 使用 atomic flag 防止并发堆积:如果上一次刷新尚未完成,本次触发直接跳过。
func (m *ExternalMCPManager) refreshToolCounts() {
if !m.refreshing.CompareAndSwap(false, true) {
return // 上一次刷新尚未完成,跳过
}
defer m.refreshing.Store(false)
m.mu.RLock()
clients := make(map[string]ExternalMCPClient)
for k, v := range m.clients {
@@ -874,16 +882,7 @@ func (m *ExternalMCPManager) triggerToolCountRefresh() {
// createClient 创建客户端(不连接)。统一使用官方 MCP Go SDK 的 lazy 客户端,连接在 Initialize 时完成。
func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConfig) ExternalMCPClient {
transport := serverCfg.Transport
if transport == "" {
if serverCfg.Command != "" {
transport = "stdio"
} else if serverCfg.URL != "" {
transport = "http"
} else {
return nil
}
}
transport := serverCfg.GetTransportType()
switch transport {
case "http":
@@ -891,12 +890,6 @@ func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConf
return nil
}
return newLazySDKClient(serverCfg, m.logger)
case "simple_http":
// 简单 HTTP(一次 POST 一次响应),用于自建 MCP 等
if serverCfg.URL == "" {
return nil
}
return newLazySDKClient(serverCfg, m.logger)
case "stdio":
if serverCfg.Command == "" {
return nil
@@ -908,7 +901,11 @@ func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConf
}
return newLazySDKClient(serverCfg, m.logger)
default:
return nil
if transport == "" {
return nil
}
// 未知传输类型也尝试使用 lazy client
return newLazySDKClient(serverCfg, m.logger)
}
}
@@ -990,20 +987,7 @@ func (m *ExternalMCPManager) connectClient(name string, serverCfg config.Externa
// isEnabled 检查是否启用
func (m *ExternalMCPManager) isEnabled(cfg config.ExternalMCPServerConfig) bool {
// 优先使用 ExternalMCPEnable 字段
// 如果没有设置,检查旧的 enabled/disabled 字段(向后兼容)
if cfg.ExternalMCPEnable {
return true
}
// 向后兼容:检查旧字段
if cfg.Disabled {
return false
}
if cfg.Enabled {
return true
}
// 都没有设置,默认为启用
return true
return cfg.ExternalMCPEnable
}
// findSubstring 查找子字符串(简单实现)
@@ -1044,15 +1028,7 @@ func (m *ExternalMCPManager) StartAllEnabled() {
zap.Error(err),
}
// 根据传输模式添加相应的信息
transport := c.Transport
if transport == "" {
if c.Command != "" {
transport = "stdio"
} else if c.URL != "" {
transport = "http"
}
}
transport := c.GetTransportType()
if transport == "http" && c.URL != "" {
fields = append(fields, zap.String("url", c.URL))
+19 -23
View File
@@ -16,12 +16,11 @@ func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
// 测试添加stdio配置
stdioCfg := config.ExternalMCPServerConfig{
Command: "python3",
Args: []string{"/path/to/script.py"},
Transport: "stdio",
Description: "Test stdio MCP",
Timeout: 30,
Enabled: true,
Command: "python3",
Args: []string{"/path/to/script.py"},
Description: "Test stdio MCP",
Timeout: 30,
ExternalMCPEnable: true,
}
err := manager.AddOrUpdateConfig("test-stdio", stdioCfg)
@@ -31,11 +30,11 @@ func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
// 测试添加HTTP配置
httpCfg := config.ExternalMCPServerConfig{
Transport: "http",
URL: "http://127.0.0.1:8081/mcp",
Description: "Test HTTP MCP",
Timeout: 30,
Enabled: false,
Type: "http",
URL: "http://127.0.0.1:8081/mcp",
Description: "Test HTTP MCP",
Timeout: 30,
ExternalMCPEnable: false,
}
err = manager.AddOrUpdateConfig("test-http", httpCfg)
@@ -64,8 +63,7 @@ func TestExternalMCPManager_RemoveConfig(t *testing.T) {
cfg := config.ExternalMCPServerConfig{
Command: "python3",
Transport: "stdio",
Enabled: false,
ExternalMCPEnable: false,
}
manager.AddOrUpdateConfig("test-remove", cfg)
@@ -89,18 +87,17 @@ func TestExternalMCPManager_GetStats(t *testing.T) {
// 添加多个配置
manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
Command: "python3",
Enabled: true,
ExternalMCPEnable: true,
})
manager.AddOrUpdateConfig("enabled2", config.ExternalMCPServerConfig{
URL: "http://127.0.0.1:8081/mcp",
Enabled: true,
ExternalMCPEnable: true,
})
manager.AddOrUpdateConfig("disabled1", config.ExternalMCPServerConfig{
Command: "python3",
Enabled: false,
Disabled: true, // 明确设置为禁用
ExternalMCPEnable: false,
})
stats := manager.GetStats()
@@ -126,11 +123,11 @@ func TestExternalMCPManager_LoadConfigs(t *testing.T) {
Servers: map[string]config.ExternalMCPServerConfig{
"loaded1": {
Command: "python3",
Enabled: true,
ExternalMCPEnable: true,
},
"loaded2": {
URL: "http://127.0.0.1:8081/mcp",
Enabled: false,
ExternalMCPEnable: false,
},
},
}
@@ -156,7 +153,7 @@ func TestLazySDKClient_InitializeFails(t *testing.T) {
logger := zap.NewNop()
// 使用不存在的 HTTP 地址,Initialize 应失败
cfg := config.ExternalMCPServerConfig{
Transport: "http",
Type: "http",
URL: "http://127.0.0.1:19999/nonexistent",
Timeout: 2,
}
@@ -180,8 +177,7 @@ func TestExternalMCPManager_StartStopClient(t *testing.T) {
// 添加一个禁用的配置
cfg := config.ExternalMCPServerConfig{
Command: "python3",
Transport: "stdio",
Enabled: false,
ExternalMCPEnable: false,
}
manager.AddOrUpdateConfig("test-start-stop", cfg)
@@ -200,7 +196,7 @@ func TestExternalMCPManager_StartStopClient(t *testing.T) {
// 验证配置已更新为禁用
configs := manager.GetConfigs()
if configs["test-start-stop"].Enabled {
if configs["test-start-stop"].ExternalMCPEnable {
t.Error("配置应该已被禁用")
}
}
+81 -36
View File
@@ -79,6 +79,21 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
mcpIDsMu := args.McpIDsMu
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 lastAssistant string
var lastPlanExecuteExecutor string
@@ -86,7 +101,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
emptyHint := strings.TrimSpace(args.EmptyResponseMessage)
if emptyHint == "" {
emptyHint = "Eino 会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)"
emptyHint = "(Eino session completed but no assistant text was captured. Check process details or logs.) " +
"(Eino 会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)"
}
attemptLoop:
@@ -191,6 +207,20 @@ attemptLoop:
iter := runner.Run(ctx, msgs)
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()
if !ok {
lastRunMsgs = msgs
@@ -200,54 +230,61 @@ attemptLoop:
continue
}
if ev.Err != nil {
canRetry := attempt+1 < maxToolCallRecoveryAttempts
if canRetry && isRecoverableToolCallArgumentsJSONError(ev.Err) {
if logger != nil {
logger.Warn("eino: recoverable tool-call JSON error from model/API", zap.Error(ev.Err), zap.Int("attempt", attempt))
}
retryHints = append(retryHints, toolCallArgumentsJSONRetryHint())
if progress != nil {
progress("eino_recovery", toolCallArgumentsJSONRecoveryTimelineMessage(attempt), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1,
"maxRuns": maxToolCallRecoveryAttempts,
"reason": "invalid_tool_arguments_json",
})
}
continue attemptLoop
}
if canRetry && isRecoverableToolExecutionError(ev.Err) {
if logger != nil {
logger.Warn("eino: recoverable tool execution error, will retry with corrective hint",
zap.Error(ev.Err), zap.Int("attempt", attempt))
}
// context.Canceled 是唯一应当直接终止编排的错误(用户关闭页面、主动停止等)。
if errors.Is(ev.Err, context.Canceled) {
flushAllPendingAsFailed(ev.Err)
retryHints = append(retryHints, toolExecutionRetryHint())
if progress != nil {
progress("eino_recovery", toolExecutionRecoveryTimelineMessage(attempt), map[string]interface{}{
progress("error", ev.Err.Error(), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1,
"maxRuns": maxToolCallRecoveryAttempts,
"reason": "tool_execution_error",
})
}
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)
retryHints = append(retryHints, hint)
if progress != nil {
progress("error", ev.Err.Error(), map[string]interface{}{
progress("eino_recovery", timelineMsg, map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1,
"maxRuns": maxToolCallRecoveryAttempts,
"reason": reason,
})
}
return nil, ev.Err
continue attemptLoop
}
if ev.AgentName != "" && progress != nil {
iterEinoAgent := orchestratorName
@@ -308,7 +345,10 @@ attemptLoop:
break
}
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
}
@@ -531,6 +571,11 @@ attemptLoop:
}
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
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{
Response: cleaned,
MCPExecutionIDs: ids,
+67 -15
View File
@@ -26,6 +26,13 @@ type PlanExecuteRootArgs struct {
// AppCfg / Logger 非空时为 Executor 挂载与 Deep/Supervisor 一致的 Eino summarization 中间件。
AppCfg *config.Config
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 并列)。
@@ -40,20 +47,39 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
if !ok {
return nil, fmt.Errorf("plan_execute: 主模型需实现 ToolCallingChatModel")
}
planner, err := planexecute.NewPlanner(ctx, &planexecute.PlannerConfig{
plannerCfg := &planexecute.PlannerConfig{
ToolCallingChatModel: tcm,
})
}
if fn := planExecutePlannerGenInput(a.OrchInstruction); fn != nil {
plannerCfg.GenInputFn = fn
}
planner, err := planexecute.NewPlanner(ctx, plannerCfg)
if err != nil {
return nil, fmt.Errorf("plan_execute planner: %w", err)
}
replanner, err := planexecute.NewReplanner(ctx, &planexecute.ReplannerConfig{
ChatModel: tcm,
GenInputFn: planExecuteReplannerGenInput,
GenInputFn: planExecuteReplannerGenInput(a.OrchInstruction),
})
if err != nil {
return nil, fmt.Errorf("plan_execute replanner: %w", err)
}
// 组装 executor handler 栈,顺序与 Deep/Supervisor 主代理一致(outermost first)。
var execHandlers []adk.ChatModelAgentMiddleware
// 1. patchtoolcalls, reduction, toolsearch, plantask(来自 prependEinoMiddlewares
if len(a.ExecPreMiddlewares) > 0 {
execHandlers = append(execHandlers, a.ExecPreMiddlewares...)
}
// 2. filesystem 中间件(可选)
if a.FilesystemMiddleware != nil {
execHandlers = append(execHandlers, a.FilesystemMiddleware)
}
// 3. skill 中间件(可选)
if a.SkillMiddleware != nil {
execHandlers = append(execHandlers, a.SkillMiddleware)
}
// 4. summarization(最后,与 Deep/Supervisor 一致)
if a.AppCfg != nil {
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.Logger)
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 {
oi := strings.TrimSpace(orchInstruction)
return func(ctx context.Context, in *planexecute.ExecutionContext) ([]adk.Message, error) {
@@ -123,19 +164,30 @@ func planExecuteFormatExecutedSteps(results []planexecute.ExecutedStep) string {
return sb.String()
}
// planExecuteReplannerGenInput 与 Eino 默认 Replanner 输入一致,但 executed_steps 经 cap 后再写入 prompt
func planExecuteReplannerGenInput(ctx context.Context, in *planexecute.ExecutionContext) ([]adk.Message, error) {
planContent, err := in.Plan.MarshalJSON()
if err != nil {
return nil, err
// planExecuteReplannerGenInput 与 Eino 默认 Replanner 输入一致,但 executed_steps 经 cap 后再写入 prompt
// 且在 orchInstruction 非空时 prepend SystemMessage 使 replanner 也能接收全局指令。
func planExecuteReplannerGenInput(orchInstruction string) planexecute.GenModelInputFn {
oi := strings.TrimSpace(orchInstruction)
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 将规划/执行/重规划各阶段助手流式输出映射到主对话区。
+3 -3
View File
@@ -36,7 +36,6 @@ func RunEinoSingleChatModelAgent(
userMessage string,
history []agent.ChatMessage,
roleTools []string,
roleSkills []string,
progress func(eventType, message string, data interface{}),
) (*RunResult, error) {
if appCfg == nil || ag == nil {
@@ -169,7 +168,7 @@ func RunEinoSingleChatModelAgent(
chatCfg := &adk.ChatModelAgentConfig{
Name: einoSingleAgentName,
Description: "Eino ADK ChatModelAgent with MCP tools for authorized security testing.",
Instruction: ag.EinoSingleAgentSystemInstruction(roleSkills),
Instruction: ag.EinoSingleAgentSystemInstruction(),
Model: mainModel,
ToolsConfig: mainToolsCfg,
MaxIterations: maxIter,
@@ -212,6 +211,7 @@ func RunEinoSingleChatModelAgent(
McpIDsMu: &mcpIDsMu,
McpIDs: &mcpIDs,
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)
}
+2 -2
View File
@@ -23,13 +23,13 @@ func newPlanExecuteExecutor(ctx context.Context, cfg *planexecute.ExecutorConfig
genInput := func(ctx context.Context, instruction string, _ *adk.AgentInput) ([]adk.Message, error) {
plan, ok := adk.GetSessionValue(ctx, planexecute.PlanSessionKey)
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)
userInput, ok := adk.GetSessionValue(ctx, planexecute.UserInputSessionKey)
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)
+19 -16
View File
@@ -213,19 +213,6 @@ func RunDeepAgent(
if len(roleTools) == 0 && len(r.Tools) > 0 {
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 名称)。专家列表:")
for _, sa := range subAgents {
if sa == nil {
continue
}
sb.WriteString("\n- ")
sb.WriteString(sa.Name(ctx))
}
@@ -349,14 +339,15 @@ func RunDeepAgent(
deepShell = einoLoc
}
deepHandlers := []adk.ChatModelAgentMiddleware{}
// noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。
deepHandlers := []adk.ChatModelAgentMiddleware{newNoNestedTaskMiddleware()}
if len(mainOrchestratorPre) > 0 {
deepHandlers = append(deepHandlers, mainOrchestratorPre...)
}
if einoSkillMW != nil {
deepHandlers = append(deepHandlers, einoSkillMW)
}
deepHandlers = append(deepHandlers, newNoNestedTaskMiddleware(), mainSumMw)
deepHandlers = append(deepHandlers, mainSumMw)
supHandlers := []adk.ChatModelAgentMiddleware{}
if len(mainOrchestratorPre) > 0 {
@@ -387,6 +378,14 @@ func RunDeepAgent(
if perr != nil {
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{
MainToolCallingModel: mainModel,
ExecModel: execModel,
@@ -396,6 +395,9 @@ func RunDeepAgent(
LoopMaxIter: ma.PlanExecuteLoopMaxIterations,
AppCfg: appCfg,
Logger: logger,
ExecPreMiddlewares: mainOrchestratorPre,
SkillMiddleware: einoSkillMW,
FilesystemMiddleware: peFsMw,
})
if perr != nil {
return nil, perr
@@ -493,7 +495,8 @@ func RunDeepAgent(
McpIDsMu: &mcpIDsMu,
McpIDs: &mcpIDs,
DA: da,
EmptyResponseMessage: "Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
EmptyResponseMessage: "(Eino multi-agent orchestration completed but no assistant text was captured. Check process details or logs.) " +
"(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
}, baseMsgs)
}
+15 -38
View File
@@ -3,6 +3,7 @@ package multiagent
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -40,51 +41,27 @@ func softRecoveryToolCallMiddleware() compose.InvokableToolMiddleware {
// isSoftRecoverableToolError determines whether a tool execution error should be
// silently converted to a tool-result message rather than crashing the graph.
//
// Design: default-soft (blacklist). Almost every tool execution error should be
// fed back to the LLM so it can self-correct or choose an alternative tool.
// Only a small set of "truly fatal" conditions (user cancellation) should
// propagate as hard errors that terminate the orchestration graph.
// This avoids the fragile whitelist approach where every new error pattern
// would need to be explicitly enumerated.
func isSoftRecoverableToolError(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
// JSON unmarshal/parse failures — the model generated truncated or malformed arguments.
if isJSONRelatedError(s) {
return true
}
// Sub-agent type not found (from deep/task_tool.go)
if strings.Contains(s, "subagent type") && strings.Contains(s, "not found") {
return true
}
// Tool not found in ToolsNode indexes
if strings.Contains(s, "tool") && strings.Contains(s, "not found") {
return true
}
return false
}
// isJSONRelatedError checks whether an error string indicates a JSON parsing problem.
func isJSONRelatedError(lower string) bool {
if !strings.Contains(lower, "json") {
// 用户主动取消 — 唯一应当终止编排的情况,不应重试。
if errors.Is(err, context.Canceled) {
return false
}
jsonIndicators := []string{
"unexpected end of json",
"unmarshal",
"invalid character",
"cannot unmarshal",
"invalid tool arguments",
"failed to unmarshal",
"must be in json format",
"unexpected eof",
}
for _, ind := range jsonIndicators {
if strings.Contains(lower, ind) {
return true
}
}
return false
// 其他所有工具执行错误(超时、命令不存在、JSON 解析失败、工具未找到、
// 权限不足、网络不可达……)一律转为 soft error,让 LLM 看到错误信息
// 后自行决策:换工具、调整参数、或向用户说明。
return true
}
// buildSoftRecoveryMessage creates a bilingual error message that the LLM can act on.
@@ -53,7 +53,12 @@ func TestIsSoftRecoverableToolError(t *testing.T) {
{
name: "unrelated network error",
err: errors.New("connection refused"),
expected: false,
expected: true, // default-soft: non-cancel errors are recoverable
},
{
name: "tool binary not installed",
err: errors.New("[LocalFunc] failed to invoke tool, toolName=grep, err=ripgrep (rg) is not installed or not in PATH"),
expected: true,
},
{
name: "context cancelled",
@@ -131,15 +136,16 @@ func TestSoftRecoveryToolCallMiddleware_PropagatesNonRecoverable(t *testing.T) {
return nil, origErr
}
wrapped := mw(next)
_, err := wrapped(context.Background(), &compose.ToolInput{
out, err := wrapped(context.Background(), &compose.ToolInput{
Name: "test_tool",
Arguments: `{}`,
})
if err == nil {
t.Fatal("expected error to propagate for non-recoverable errors")
// Default-soft: non-cancel errors are converted to tool-result messages.
if err != nil {
t.Fatalf("expected nil error (soft recovery), got: %v", err)
}
if err != origErr {
t.Fatalf("expected original error, got: %v", err)
if out == nil || out.Result == "" {
t.Fatal("expected non-empty recovery message")
}
}
+18 -50
View File
@@ -2,74 +2,42 @@ package multiagent
import (
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
)
// isRecoverableToolExecutionError detects tool-level execution errors that can be
// recovered by retrying with a corrective hint. These errors originate from eino
// framework internals (e.g. task_tool.go, tool_node.go) when the LLM produces
// invalid tool calls such as non-existent sub-agent types, malformed JSON arguments,
// or unregistered tool names.
func isRecoverableToolExecutionError(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
// Sub-agent type not found (from deep/task_tool.go)
if strings.Contains(s, "subagent type") && strings.Contains(s, "not found") {
return true
}
// Tool not found in toolsNode indexes (from compose/tool_node.go, when UnknownToolsHandler is nil)
if strings.Contains(s, "tool") && strings.Contains(s, "not found") {
return true
}
// Invalid tool arguments JSON (from einomcp/mcp_tools.go or eino internals)
if strings.Contains(s, "invalid tool arguments json") {
return true
}
// Failed to unmarshal task tool input json (from deep/task_tool.go)
if strings.Contains(s, "failed to unmarshal") && strings.Contains(s, "json") {
return true
}
// Generic tool call stream/invoke failure wrapping the above
if (strings.Contains(s, "failed to stream tool call") || strings.Contains(s, "failed to invoke tool")) &&
(strings.Contains(s, "not found") || strings.Contains(s, "json") || strings.Contains(s, "unmarshal")) {
return true
}
return false
}
// toolExecutionRetryHint returns a user message appended to the conversation to prompt
// the LLM to correct its tool call after a tool execution error.
// the LLM to adjust after a tool execution error (tool not found, binary missing,
// runtime failure, network error, etc.).
func toolExecutionRetryHint() *schema.Message {
return schema.UserMessage(`[System] Your previous tool call failed because:
- The tool or sub-agent name you used does not exist, OR
return schema.UserMessage(`[System] Your previous tool call failed. Possible causes:
- The tool or sub-agent name does not exist (typo or unregistered name).
- The tool call arguments were not valid JSON.
- The tool's underlying binary is not installed or not in PATH.
- The tool encountered a runtime error (timeout, network failure, permission denied, etc.).
Please carefully review the available tools and sub-agents listed in your context, use only exact registered names (case-sensitive), and ensure all arguments are well-formed JSON objects. Then retry your action.
Please review the error message above, check available tools, and either:
1. Retry with corrected arguments or a different tool, OR
2. Inform the user about the limitation and proceed with an alternative approach.
[系统提示] 上一次工具调用失败可能原因
- 你使用的工具名或子代理名称不存在
- 工具调用参数不是合法 JSON
- 工具名或子代理名称不存在拼写错误或未注册
- 工具调用参数不是合法 JSON
- 工具依赖的底层二进制程序未安装或不在 PATH
- 工具运行时遇到错误超时网络故障权限不足等
仔细检查上下文中列出的可用工具和子代理名称须完全匹配区分大小写确保所有参数均为合法的 JSON 对象然后重新执行`)
根据上述错误信息检查可用工具然后
1. 修正参数或改用其他工具重试或者
2. 告知用户当前限制并采用替代方案继续`)
}
// toolExecutionRecoveryTimelineMessage returns a message for the eino_recovery event
// displayed in the UI timeline when a tool execution error triggers a retry.
func toolExecutionRecoveryTimelineMessage(attempt int) string {
return fmt.Sprintf(
"工具调用执行失败(工具/子代理名称不存在或参数 JSON 无效)。已向对话追加纠错提示并要求模型重新生成。"+
"工具调用执行失败。已向对话追加纠错提示并要求模型调整策略。"+
"当前为第 %d/%d 轮完整运行。\n\n"+
"Tool call execution failed (unknown tool/sub-agent name or invalid JSON arguments). "+
"Tool call execution failed. "+
"A corrective hint was appended. This is full run %d of %d.",
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
)
+18 -4
View File
@@ -487,7 +487,10 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return "", fmt.Errorf("claude bridge: read error response: %w", readErr)
}
return "", &APIError{
StatusCode: resp.StatusCode,
Body: string(respBody),
@@ -588,7 +591,10 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return "", nil, "", fmt.Errorf("claude bridge: read error response: %w", readErr)
}
return "", nil, "", &APIError{
StatusCode: resp.StatusCode,
Body: string(respBody),
@@ -824,7 +830,11 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
// 非 200:尝试把 Claude 错误格式转成 OpenAI 错误格式,便于 Eino 解析
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
resp.Body.Close()
return nil, fmt.Errorf("claude bridge: read error response: %w", readErr)
}
resp.Body.Close()
converted := rt.tryConvertClaudeErrorToOpenAI(bodyBytes)
return &http.Response{
@@ -838,7 +848,11 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
// 非流式:一次性转换响应体
if !claudeReq.Stream {
respBody, _ := io.ReadAll(resp.Body)
respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
resp.Body.Close()
return nil, fmt.Errorf("claude bridge: read response: %w", readErr)
}
resp.Body.Close()
oaiJSON, err := claudeToOpenAIResponseJSON(respBody)
if err != nil {
+8 -2
View File
@@ -189,7 +189,10 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
// 非200:读完 body 返回
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
c.logger.Warn("failed to read OpenAI error response body", zap.Error(readErr))
}
return "", &APIError{
StatusCode: resp.StatusCode,
Body: string(respBody),
@@ -329,7 +332,10 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
c.logger.Warn("failed to read OpenAI error response body", zap.Error(readErr))
}
return "", nil, "", &APIError{
StatusCode: resp.StatusCode,
Body: string(respBody),
+81
View File
@@ -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()
}
}
+2 -2
View File
@@ -33,12 +33,12 @@ func SkillsRootFromConfig(skillsDir string, configPath string) string {
return skillsDir
}
// DirLister satisfies handler.SkillsManager for role UI (lists package directory names).
// DirLister lists skill package directory names under SkillsRoot.
type DirLister struct {
SkillsRoot string
}
// ListSkills implements the role handler dependency.
// ListSkills returns skill package directory names that contain SKILL.md.
func (d DirLister) ListSkills() ([]string, error) {
return ListSkillDirNames(d.SkillsRoot)
}
+2 -3
View File
@@ -1,6 +1,6 @@
# 角色配置文件说明
本目录包含所有角色配置文件,每个角色定义了AI的行为模式可用工具和技能
本目录包含所有角色配置文件,每个角色定义了AI的行为模式可用工具。
## 创建新角色
@@ -41,7 +41,7 @@ enabled: true
按需还可加入 WebShell、批量任务等其它内置或外部工具(以 MCP 管理中已启用的为准)。
**Skills(技能包)**不由 MCP 工具列表提供。角色 `skills` 字段绑定技能 id 后,**多代理Eino DeepAgent** 会话中由 ADK **`skill`** 工具渐进加载;单代理路径不含该能力
**Skills(技能包)**:在 **多代理 / Eino** 会话中由内置 **`skill`** 工具按需加载 `skills_dir` 下的包,与角色 YAML 无绑定关系
**注意**:如果不设置 `tools` 字段,系统会默认使用所有 MCP 管理中已开启的工具。为明确控制角色可用工具,建议显式设置 `tools` 字段。
@@ -54,7 +54,6 @@ enabled: true
- **tools**: 工具列表,指定该角色可用的工具(可选)
- **如果不设置 `tools` 字段**:默认会选中**全部MCP管理中已开启的工具**
- **如果设置了 `tools` 字段**:只使用列表中指定的工具(建议至少包含上述核心内置工具)
- **skills**: 技能列表,指定该角色关联的技能(可选)
- **enabled**: 是否启用该角色(必填,true/false)
## 示例
-105
View File
@@ -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"
-22
View File
@@ -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
-78
View File
@@ -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"
-44
View File
@@ -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"
-157
View File
@@ -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: "简单的Intrudersniper)模糊测试工具"
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"
-46
View File
@@ -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"
-76
View File
@@ -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
View File
@@ -3965,7 +3965,7 @@ header {
.tool-item {
display: flex;
align-items: center;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border-radius: 6px;
@@ -3980,8 +3980,10 @@ header {
.tool-item input[type="checkbox"] {
width: 18px;
height: 18px;
margin-top: 2px;
cursor: pointer;
accent-color: var(--accent-color);
flex-shrink: 0;
}
.tool-item-info {
@@ -4021,6 +4023,93 @@ header {
white-space: nowrap;
}
/* 展开图标 */
.tool-expand-icon {
font-size: 0.625rem;
color: var(--text-tertiary);
transition: transform 0.2s;
user-select: none;
flex-shrink: 0;
}
/* 展开后的详情面板 */
.tool-item-detail {
margin-top: 8px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--border-color);
font-size: 0.8125rem;
}
.tool-detail-desc {
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 8px;
white-space: pre-wrap;
word-break: break-word;
}
.tool-detail-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
/* 参数表格 */
.tool-schema-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.tool-schema-table th {
text-align: left;
padding: 6px 10px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
}
.tool-schema-table td {
padding: 6px 10px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
vertical-align: top;
}
.tool-schema-table code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.8125rem;
color: var(--accent-color);
}
/* 可点击的外部工具徽章 */
.external-tool-badge.clickable {
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.external-tool-badge.clickable:hover {
background: rgba(255, 152, 0, 0.25);
border-color: rgba(255, 152, 0, 0.6);
}
/* 外部 MCP 卡片高亮动画 */
.external-mcp-item.highlight {
animation: mcpHighlight 2s ease-out;
}
@keyframes mcpHighlight {
0% { box-shadow: 0 0 0 3px var(--accent-color); border-color: var(--accent-color); }
100% { box-shadow: none; border-color: var(--border-color); }
}
.tool-item.hidden {
display: none;
}
@@ -11353,12 +11442,53 @@ header {
.webshell-ai-msg ol {
padding-left: 20px;
}
.webshell-ai-input-row {
/* WebShell AI 输入区域:选择器 + 输入框同行 */
.webshell-ai-input-area {
flex-shrink: 0;
display: flex;
gap: 10px;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 8px 14px;
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;
}
.webshell-ai-input {
@@ -11391,7 +11521,8 @@ header {
.webshell-ai-input::-webkit-scrollbar-thumb:hover {
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;
height: 36px;
min-width: 72px;
@@ -11400,6 +11531,18 @@ header {
align-items: 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-pane-db {
@@ -13465,6 +13608,7 @@ header {
min-width: 0;
flex: 1;
padding-top: 2px;
text-align: left;
}
.role-selection-item-name-main {
@@ -14133,7 +14277,9 @@ header {
.role-tools-stats {
display: flex;
gap: 16px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
@@ -14142,6 +14288,60 @@ header {
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 {
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-controls {
margin-bottom: 8px;
+103 -22
View File
@@ -845,7 +845,13 @@
"externalMCPManagement": "External MCP Management",
"attackChain": "Attack Chain",
"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": {
"login": "User login",
@@ -945,7 +951,53 @@
"invokeTool": "Invoke tool",
"initConnection": "Initialize connection",
"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": {
"getSuccess": "Success",
@@ -980,7 +1032,29 @@
"cancelSubmitted": "Cancel request submitted",
"noRunningTask": "No running task found",
"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": {
@@ -1490,15 +1564,15 @@
"externalMcpModal": {
"configJson": "Config JSON",
"formatLabel": "Format:",
"formatDesc": "JSON object; key = config name, value = config. Use Start/Stop buttons to control state.",
"formatDesc": "JSON object; key = config name, value = config. Use Start/Stop buttons to control state. Supports ${VAR} and ${VAR:-default} env variable syntax.",
"configExample": "Configuration example:",
"stdioMode": "stdio mode:",
"httpMode": "HTTP mode:",
"sseMode": "SSE mode:",
"placeholder": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\"],\n \"description\": \"Description\",\n \"timeout\": 300\n }\n}",
"exampleStdio": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\", \"--server\", \"http://example.com\"],\n \"description\": \"Description\",\n \"timeout\": 300\n }\n}",
"exampleHttp": "{\n \"cyberstrike-ai-http\": {\n \"transport\": \"http\",\n \"url\": \"http://127.0.0.1:8081/mcp\"\n }\n}",
"exampleSse": "{\n \"cyberstrike-ai-sse\": {\n \"transport\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
"placeholder": "{\n \"my-server\": {\n \"command\": \"python3\",\n \"args\": [\"${HOME}/mcp/server.py\"],\n \"env\": { \"API_KEY\": \"${API_KEY}\" },\n \"timeout\": 300\n }\n}",
"exampleStdio": "{\n \"my-server\": {\n \"command\": \"python3\",\n \"args\": [\"${HOME}/mcp/server.py\"],\n \"env\": { \"API_KEY\": \"${API_KEY}\", \"LOG_LEVEL\": \"${LOG_LEVEL:-INFO}\" },\n \"timeout\": 300\n }\n}",
"exampleHttp": "{\n \"remote-mcp\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.example.com/mcp\",\n \"headers\": { \"Authorization\": \"Bearer ${MCP_TOKEN}\" }\n }\n}",
"exampleSse": "{\n \"sse-mcp\": {\n \"type\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
"exampleDescription": "Example description",
"formatJson": "Format JSON",
"loadExample": "Load example"
@@ -1710,28 +1784,30 @@
"defaultRoleToolsDesc": "Default role uses all tools enabled in MCP Management.",
"searchToolsPlaceholder": "Search tools...",
"loadingTools": "Loading tools...",
"relatedToolsHint": "Select tools to link; empty = use all from MCP Management.",
"relatedSkills": "Related Skills (optional)",
"searchSkillsPlaceholder": "Search skill...",
"loadingSkills": "Loading skills...",
"relatedSkillsHint": "Selected skills are injected into system prompt before task execution.",
"relatedToolsHint": "Use “Linked / Not linked” above to filter by this roles checkboxes. MCP-wide on/off is in MCP Management.",
"enableRole": "Enable this role",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"roleNameRequired": "Role name is required",
"roleNotFound": "Role not found",
"firstRoleNoToolsHint": "First role with no tools selected will use all tools by default.",
"currentPageSelected": "Current page: {{current}} / {{total}}",
"totalSelected": "Total selected: {{current}} / {{total}}",
"filterRoleAll": "All",
"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)",
"currentPageSelectedTitle": "Selected on current page (enabled tools only)",
"totalSelectedTitle": "Total tools linked to this role",
"skillsSelectedCount": "Selected {{count}} / {{total}}",
"loadToolsFailed": "Failed to load tools",
"loadSkillsFailed": "Failed to load skills",
"cannotDeleteDefaultRole": "Cannot delete default role",
"noMatchingSkills": "No matching skills",
"noSkillsAvailable": "No skills available",
"usingAllTools": "Use all tools",
"andNMore": " and {{count}} more",
"toolsLabel": "Tools:",
@@ -1742,6 +1818,11 @@
"prevPage": "Previous",
"pageOf": "Page {{page}} / {{total}}",
"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
View File
@@ -845,7 +845,13 @@
"externalMCPManagement": "外部MCP管理",
"attackChain": "攻击链",
"knowledgeBase": "知识库",
"mcp": "MCP"
"mcp": "MCP",
"fofaRecon": "FOFA信息收集",
"terminal": "终端",
"webshellManagement": "WebShell管理",
"chatUploads": "对话附件",
"robotIntegration": "机器人集成",
"markdownAgents": "多代理Markdown"
},
"summary": {
"login": "用户登录",
@@ -945,7 +951,53 @@
"invokeTool": "调用工具",
"initConnection": "初始化连接",
"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": {
"getSuccess": "获取成功",
@@ -980,7 +1032,29 @@
"cancelSubmitted": "取消请求已提交",
"noRunningTask": "未找到正在执行的任务",
"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": {
@@ -1490,15 +1564,15 @@
"externalMcpModal": {
"configJson": "配置JSON",
"formatLabel": "配置格式:",
"formatDesc": "JSON对象,key为配置名称,value为配置内容。状态通过\"启动/停止\"按钮控制,无需在JSON中配置。",
"formatDesc": "JSON对象,key为配置名称,value为配置内容。状态通过\"启动/停止\"按钮控制,无需在JSON中配置。支持 ${VAR} 和 ${VAR:-默认值} 环境变量语法。",
"configExample": "配置示例:",
"stdioMode": "stdio模式:",
"httpMode": "HTTP模式:",
"sseMode": "SSE模式:",
"placeholder": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\"],\n \"description\": \"描述\",\n \"timeout\": 300\n }\n}",
"exampleStdio": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\", \"--server\", \"http://example.com\"],\n \"description\": \"描述\",\n \"timeout\": 300\n }\n}",
"exampleHttp": "{\n \"cyberstrike-ai-http\": {\n \"transport\": \"http\",\n \"url\": \"http://127.0.0.1:8081/mcp\"\n }\n}",
"exampleSse": "{\n \"cyberstrike-ai-sse\": {\n \"transport\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
"placeholder": "{\n \"my-server\": {\n \"command\": \"python3\",\n \"args\": [\"${HOME}/mcp/server.py\"],\n \"env\": { \"API_KEY\": \"${API_KEY}\" },\n \"timeout\": 300\n }\n}",
"exampleStdio": "{\n \"my-server\": {\n \"command\": \"python3\",\n \"args\": [\"${HOME}/mcp/server.py\"],\n \"env\": { \"API_KEY\": \"${API_KEY}\", \"LOG_LEVEL\": \"${LOG_LEVEL:-INFO}\" },\n \"timeout\": 300\n }\n}",
"exampleHttp": "{\n \"remote-mcp\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.example.com/mcp\",\n \"headers\": { \"Authorization\": \"Bearer ${MCP_TOKEN}\" }\n }\n}",
"exampleSse": "{\n \"sse-mcp\": {\n \"type\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
"exampleDescription": "示例描述",
"formatJson": "格式化JSON",
"loadExample": "加载示例"
@@ -1710,28 +1784,30 @@
"defaultRoleToolsDesc": "默认角色会自动使用MCP管理中启用的所有工具,无需单独配置。",
"searchToolsPlaceholder": "搜索工具...",
"loadingTools": "正在加载工具列表...",
"relatedToolsHint": "勾选要关联的工具,留空则使用MCP管理中的全部工具配置。",
"relatedSkills": "关联的Skills(可选)",
"searchSkillsPlaceholder": "搜索skill...",
"loadingSkills": "正在加载skills列表...",
"relatedSkillsHint": "勾选要关联的skills,这些skills的内容会在执行任务前注入到系统提示词中,帮助AI更好地理解相关专业知识。",
"relatedToolsHint": "上方「本角色已开/已关」按复选框筛选;留空工具清单表示不限制。MCP 全局开关请在 MCP 管理中操作。",
"enableRole": "启用此角色",
"selectAll": "全选",
"deselectAll": "全不选",
"roleNameRequired": "角色名称不能为空",
"roleNotFound": "角色不存在",
"firstRoleNoToolsHint": "检测到这是首次添加角色且未选择工具,将默认使用全部工具",
"currentPageSelected": "当前页已选中: {{current}} / {{total}}",
"totalSelected": "总计已选中: {{current}} / {{total}}",
"filterRoleAll": "全部",
"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": "(使用所有已启用工具)",
"currentPageSelectedTitle": "当前页选中的工具数(只统计已启用的工具)",
"totalSelectedTitle": "角色已关联的工具总数(基于角色实际配置)",
"skillsSelectedCount": "已选择 {{count}} / {{total}}",
"loadToolsFailed": "加载工具列表失败",
"loadSkillsFailed": "加载skills列表失败",
"cannotDeleteDefaultRole": "不能删除默认角色",
"noMatchingSkills": "没有找到匹配的skills",
"noSkillsAvailable": "暂无可用skills",
"usingAllTools": "使用所有工具",
"andNMore": " 等 {{count}} 个",
"toolsLabel": "工具:",
@@ -1742,6 +1818,11 @@
"prevPage": "上一页",
"pageOf": "第 {{page}} / {{total}} 页",
"nextPage": "下一页",
"lastPage": "末页"
"lastPage": "末页",
"mcpDisabledBadge": "MCP已关",
"mcpDisabledBadgeTitle": "MCP 管理里该工具为关闭;勾选只表示想关联到本角色,实际调用需先在 MCP 中打开",
"roleFilterOnBanner": "以下为「已勾选、关联到本角色」的工具(与 MCP 管理里全局开/关无关)。",
"roleFilterOffBanner": "以下为「未勾选、未关联到本角色」的工具。",
"checkboxLinkTitle": "勾选表示本角色关联使用该工具"
}
}
+306 -328
View File
@@ -7,9 +7,22 @@ let roles = [];
let rolesSearchKeyword = ''; // 角色搜索关键词
let rolesSearchTimeout = null; // 搜索防抖定时器
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 = {
page: 1,
pageSize: 20,
pageSize: getRoleToolsPageSize(),
total: 0,
totalPages: 1
};
@@ -17,13 +30,11 @@ let roleToolsSearchKeyword = ''; // 工具搜索关键词
let roleToolStateMap = new Map(); // 工具状态映射:toolKey -> { enabled: boolean, ... }
let roleUsesAllTools = false; // 标记角色是否使用所有工具(当没有配置tools时)
let totalEnabledToolsInMCP = 0; // 已启用的工具总数(从MCP管理中获取,从API响应中获取)
// 仅在「无状态筛选、无搜索」的请求结果上更新,供统计条分母使用(避免筛选后 total 变小导致 25/9 这类错误)
let roleToolsStatsGrandTotal = 0; // 工具总条数(与 MCP 列表「全部」一致)
let roleToolsStatsMcpEnabledTotal = 0; // MCP 全局已启用工具数
let roleConfiguredTools = new Set(); // 角色配置的工具列表(用于确定哪些工具应该被选中)
// Skills相关
let allRoleSkills = []; // 存储所有skills列表
let roleSkillsSearchKeyword = ''; // Skills搜索关键词
let roleSelectedSkills = new Set(); // 选中的skills集合
// 对角色列表进行排序:默认角色排在第一个,其他按名称排序
function sortRoles(rolesArray) {
const sortedRoles = [...rolesArray];
@@ -418,6 +429,91 @@ function getToolKey(tool) {
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() {
document.querySelectorAll('#role-tools-list .role-tool-item').forEach(item => {
@@ -444,72 +540,70 @@ async function loadRoleTools(page = 1, searchKeyword = '') {
try {
// 在加载新页面之前,先保存当前页的状态到全局映射
saveCurrentRolePageToolStates();
const pageSize = roleToolsPagination.pageSize;
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();
allRoleTools = result.tools || [];
roleToolsPagination = {
page: result.page || page,
pageSize: result.page_size || pageSize,
total: result.total || 0,
totalPages: result.total_pages || 1
};
// 更新已启用的工具总数(从API响应中获取)
if (result.total_enabled !== undefined) {
totalEnabledToolsInMCP = result.total_enabled;
}
// 初始化工具状态映射(如果工具不在映射中,使用服务器返回的状态)
// 但要注意:如果工具已经在映射中(比如编辑角色时预先设置的选中工具),则保留映射中的状态
allRoleTools.forEach(tool => {
const toolKey = getToolKey(tool);
if (!roleToolStateMap.has(toolKey)) {
// 工具不在映射中
let enabled = false;
if (roleUsesAllTools) {
// 如果使用所有工具,且工具在MCP管理中已启用,则标记为选中
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 // 保存MCP管理中的原始启用状态
});
} else {
// 工具已在映射中(可能是预先设置的选中工具或用户手动选择的),保留映射中的状态
// 注意:即使使用所有工具,也不要强制覆盖用户已取消的工具选择
const state = roleToolStateMap.get(toolKey);
// 如果使用所有工具,且工具在MCP管理中已启用,确保标记为选中
if (roleUsesAllTools && tool.enabled) {
// 使用所有工具时,确保所有已启用的工具都被选中
state.enabled = true;
}
// 如果不使用所有工具,保留映射中的状态(不要覆盖,因为状态已经在初始化时正确设置了)
state.is_external = tool.is_external || false;
state.external_mcp = tool.external_mcp || '';
state.mcpEnabled = tool.enabled; // 更新MCP管理中的原始启用状态
if (!state.name || state.name === toolKey.split('::').pop()) {
state.name = tool.name; // 更新工具名称
const needRoleLinkFilter =
roleToolsStatusFilter === 'role_on' || roleToolsStatusFilter === 'role_off';
if (needRoleLinkFilter) {
roleToolsClientMode = true;
const searchChanged = searchKeyword !== roleToolsListCacheSearch;
if (searchChanged || roleToolsListCacheFull.length === 0) {
await fetchAllRoleToolsIntoCache(searchKeyword);
roleToolsListCacheSearch = searchKeyword;
}
const filtered = computeRoleLinkFilteredTools();
const total = filtered.length;
let totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
let p = page;
if (p > totalPages) {
p = totalPages;
}
if (p < 1) {
p = 1;
}
roleToolsPagination = {
page: p,
pageSize,
total,
totalPages
};
allRoleTools = filtered.slice((p - 1) * pageSize, p * pageSize);
} else {
roleToolsClientMode = false;
roleToolsListCacheFull = [];
roleToolsListCacheSearch = '';
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();
allRoleTools = result.tools || [];
roleToolsPagination = {
page: result.page || page,
pageSize: result.page_size || pageSize,
total: result.total || 0,
totalPages: result.total_pages || 1
};
if (roleToolsStatusFilter === '' && !searchKeyword) {
roleToolsStatsGrandTotal = result.total || 0;
if (result.total_enabled !== undefined) {
roleToolsStatsMcpEnabledTotal = result.total_enabled;
totalEnabledToolsInMCP = result.total_enabled;
}
}
});
allRoleTools.forEach(tool => mergeToolIntoRoleStateMap(tool));
}
renderRoleToolsList();
renderRoleToolsPagination();
updateRoleToolsStats();
@@ -529,6 +623,20 @@ function renderRoleToolsList() {
// 清除加载提示和旧内容
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');
listContainer.className = 'role-tools-list-items';
@@ -539,6 +647,8 @@ function renderRoleToolsList() {
toolsList.appendChild(listContainer);
return;
}
const chkTitle = escapeHtml(_t('roleModal.checkboxLinkTitle'));
allRoleTools.forEach(tool => {
const toolKey = getToolKey(tool);
@@ -564,17 +674,22 @@ function renderRoleToolsList() {
const badgeTitle = externalMcpName ? `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}` : '外部MCP工具';
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
const checkboxId = `role-tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
toolItem.innerHTML = `
<input type="checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''}
title="${chkTitle}" aria-label="${chkTitle}"
onchange="handleRoleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
<div class="role-tool-item-info">
<div class="role-tool-item-name">
${escapeHtml(tool.name)}
${externalBadge}
${mcpDisabledBadge}
</div>
<div class="role-tool-item-desc">${escapeHtml(tool.description || '无描述')}</div>
</div>
@@ -585,7 +700,7 @@ function renderRoleToolsList() {
toolsList.appendChild(listContainer);
}
// 渲染工具列表分页控件
// 渲染工具列表分页控件(始终展示范围与每页条数,便于在仅一页时仍可调整 page size)
function renderRoleToolsPagination() {
const toolsList = document.getElementById('role-tools-list');
if (!toolsList) return;
@@ -596,34 +711,78 @@ function renderRoleToolsPagination() {
oldPagination.remove();
}
// 如果只有一页或没有数据,不显示分页
if (roleToolsPagination.totalPages <= 1) {
return;
}
const pagination = document.createElement('div');
pagination.className = 'role-tools-pagination';
const { page, totalPages, total } = roleToolsPagination;
const startItem = (page - 1) * roleToolsPagination.pageSize + 1;
const endItem = Math.min(page * roleToolsPagination.pageSize, total);
const { page, totalPages, total, pageSize } = roleToolsPagination;
const startItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
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 }) +
(roleToolsSearchKeyword ? _t('roleModal.paginationSearch', { keyword: roleToolsSearchKeyword }) : '');
const navDisabled = total === 0 || totalPages <= 1;
pagination.innerHTML = `
<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">
<button class="btn-secondary" onclick="loadRoleTools(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? '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(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.firstPage')}</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>
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? '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(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.nextPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.lastPage')}</button>
</div>
`;
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状态变化
function handleRoleToolCheckboxChange(toolKey, enabled) {
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启用状态
});
}
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('');
}
// 更新工具统计信息
// 更新工具统计信息(口径:分母「可关联上限」= 全库 MCP 已开工具数,与 MCP 管理页筛选「MCP已开」条数一致;勾选=关联本角色)
function updateRoleToolsStats() {
const statsEl = document.getElementById('role-tools-stats');
if (!statsEl) return;
// 统计当前页已选中的工具数
const currentPageEnabled = Array.from(document.querySelectorAll('#role-tools-list input[type="checkbox"]:checked')).length;
// 统计当前页已启用的工具数(在MCP管理中已启用的工具)
// 优先从状态映射中获取,如果没有则从工具数据中获取
let currentPageEnabledInMCP = 0;
allRoleTools.forEach(tool => {
const toolKey = getToolKey(tool);
const state = roleToolStateMap.get(toolKey);
// 如果工具在MCP管理中已启用(从状态映射或工具数据中获取),计入当前页已启用工具数
const mcpEnabled = state ? (state.mcpEnabled !== false) : (tool.enabled !== false);
if (mcpEnabled) {
currentPageEnabledInMCP++;
}
});
// 如果使用所有工具,使用从API获取的已启用工具总数
const pageChecked = 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 =
(roleToolsStatsMcpEnabledTotal > 0 ? roleToolsStatsMcpEnabledTotal : totalEnabledToolsInMCP) || 0;
const grandAll =
(roleToolsStatsGrandTotal > 0 ? roleToolsStatsGrandTotal : roleToolsPagination.total) || 0;
const scopeLine = roleToolsListScopeLine();
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 = `
<span title="${_t('roleModal.currentPageSelectedTitle')}"> ${_t('roleModal.currentPageSelected', { current: currentPageEnabled, total: currentPageTotal })}</span>
<span title="${_t('roleModal.totalSelectedTitle')}">📊 ${_t('roleModal.totalSelected', { current: totalEnabled, total: totalTools })} <em>${_t('roleModal.usingAllEnabledTools')}</em></span>
<div class="role-tools-stats-row">
<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;
}
// 统计角色实际选中的工具数(只统计在MCP管理中已启用的工具)
let totalSelected = 0;
let roleLinked = 0;
roleToolStateMap.forEach(state => {
// 只统计在MCP管理中已启用且被角色选中的工具
if (state.enabled && state.mcpEnabled !== false) {
totalSelected++;
roleLinked++;
}
});
// 如果当前页有未保存的状态,需要合并计算
document.querySelectorAll('#role-tools-list input[type="checkbox"]').forEach(checkbox => {
const toolItem = checkbox.closest('.role-tool-item');
if (toolItem) {
const toolKey = toolItem.dataset.toolKey;
const savedState = roleToolStateMap.get(toolKey);
if (savedState && savedState.enabled !== checkbox.checked && savedState.mcpEnabled !== false) {
// 状态不一致,使用checkbox状态(但只统计MCP管理中已启用的工具)
if (checkbox.checked && !savedState.enabled) {
totalSelected++;
roleLinked++;
} else if (!checkbox.checked && savedState.enabled) {
totalSelected--;
roleLinked--;
}
}
}
});
// 角色可选择的所有已启用工具总数(应该基于MCP管理中的总数,而不是状态映射)
// 因为角色可以选择任意已启用的工具,所以总数应该是所有已启用工具的总数
let totalEnabledForRole = totalEnabledToolsInMCP || 0;
// 如果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;
const roleRow =
mcpOnMax > 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>`;
statsEl.innerHTML = `
<span title="${_t('roleModal.currentPageSelectedTitle')}"> ${_t('roleModal.currentPageSelected', { current: currentPageEnabled, total: currentPageTotal })}</span>
<span title="${_t('roleModal.totalSelectedTitle')}">📊 ${_t('roleModal.totalSelected', { current: totalSelected, total: totalTools })}</span>
<div class="role-tools-stats-row">
<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) {
clearBtn.style.display = 'none';
}
roleToolsStatusFilter = '';
syncRoleToolsFilterButtons();
roleToolsPagination.pageSize = getRoleToolsPageSize();
// 清空工具列表 DOM,避免 loadRoleTools 中的 saveCurrentRolePageToolStates 读取旧状态
if (toolsList) {
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, '');
@@ -922,9 +1067,6 @@ async function showAddRoleModal() {
// 确保统计信息正确更新(显示0/108)
updateRoleToolsStats();
// 加载并渲染skills列表
await loadRoleSkills();
modal.style.display = 'flex';
}
@@ -1007,6 +1149,9 @@ async function editRole(roleName) {
if (clearBtn) {
clearBtn.style.display = 'none';
}
roleToolsStatusFilter = '';
syncRoleToolsFilterButtons();
roleToolsPagination.pageSize = getRoleToolsPageSize();
// 优先使用tools字段,如果没有则使用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';
}
@@ -1317,16 +1452,12 @@ async function saveRole() {
}
}
// 获取选中的skills
const skills = Array.from(roleSelectedSkills);
const roleData = {
name: name,
description: description,
icon: icon || undefined, // 如果为空字符串,则不发送该字段
user_prompt: userPrompt,
tools: tools, // 默认角色为空数组,表示使用所有工具
skills: skills, // Skills列表
enabled: enabled
};
const url = isEdit ? `/api/roles/${encodeURIComponent(name)}` : '/api/roles';
@@ -1459,6 +1590,7 @@ if (typeof window !== 'undefined') {
window.getCurrentRole = getCurrentRole;
window.toggleRoleSelectionPanel = toggleRoleSelectionPanel;
window.closeRoleSelectionPanel = closeRoleSelectionPanel;
window.filterRoleToolsByStatus = filterRoleToolsByStatus;
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;
}
+5 -5
View File
@@ -59,6 +59,7 @@ function switchPage(pageId) {
initPage(pageId);
}
}
window.switchPage = switchPage;
// 更新导航状态
function updateNavState(pageId) {
@@ -159,6 +160,7 @@ function toggleSubmenu(menuId) {
navItem.classList.toggle('expanded');
}
}
window.toggleSubmenu = toggleSubmenu;
// 显示子菜单弹出框
function showSubmenuPopup(navItem, menuId) {
@@ -427,6 +429,7 @@ function toggleSidebar() {
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
}
}
window.toggleSidebar = toggleSidebar;
// 初始化侧边栏状态
function initSidebarState() {
@@ -449,6 +452,7 @@ function toggleConversationSidebar() {
localStorage.setItem('conversationSidebarCollapsed', isCollapsed ? 'true' : 'false');
}
}
window.toggleConversationSidebar = toggleConversationSidebar;
// 恢复对话列表折叠状态(进入对话页时生效)
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; };
+131 -23
View File
@@ -501,26 +501,32 @@ function renderToolsList() {
external_mcp: tool.external_mcp || ''
};
// 外部工具标签,显示来源信息
// 外部工具标签,显示来源信息(可点击跳转到对应 MCP 卡片)
let externalBadge = '';
if (toolState.is_external || tool.is_external) {
const externalMcpName = toolState.external_mcp || tool.external_mcp || '';
const badgeText = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalFrom', { name: escapeHtml(externalMcpName) }) : `外部 (${escapeHtml(externalMcpName)})`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部');
const badgeTitle = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalToolFrom', { name: escapeHtml(externalMcpName) }) : `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部MCP工具');
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
const badgeTitle = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalToolFrom', { name: escapeHtml(externalMcpName) }) + ' — 点击跳转' : `外部MCP工具 - 来源:${escapeHtml(externalMcpName)} — 点击跳转`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部MCP工具');
if (externalMcpName) {
externalBadge = `<span class="external-tool-badge clickable" onclick="scrollToExternalMCP('${escapeHtml(externalMcpName)}', event)" title="${badgeTitle}">${badgeText}</span>`;
} else {
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
}
}
// 生成唯一的checkbox id,使用工具唯一标识符
const checkboxId = `tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
toolItem.innerHTML = `
<input type="checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''} ${toolState.is_external || tool.is_external ? 'data-external="true"' : ''} onchange="handleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
<div class="tool-item-info">
<div class="tool-item-info" onclick="toggleToolDetail(this, '${escapeHtml(toolKey)}', ${tool.is_external ? 'true' : 'false'}, '${escapeHtml(tool.external_mcp || '')}', event)">
<div class="tool-item-name">
${escapeHtml(tool.name)}
${externalBadge}
<span class="tool-expand-icon"></span>
</div>
<div class="tool-item-desc">${escapeHtml(tool.description || (typeof window.t === 'function' ? window.t('mcp.noDescription') : '无描述'))}</div>
<div class="tool-item-detail" style="display:none"></div>
</div>
`;
listContainer.appendChild(toolItem);
@@ -534,6 +540,103 @@ function renderToolsList() {
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() {
const toolsList = document.getElementById('tools-list');
@@ -1382,7 +1485,7 @@ function renderExternalMCPList(servers) {
status === 'connecting' ? statusT('mcp.connecting') :
status === 'error' ? statusT('mcp.connectionFailed') :
status === 'disabled' ? statusT('mcp.disabled') : statusT('mcp.disconnected');
const transport = server.config.transport || (server.config.command ? 'stdio' : 'http');
const transport = server.config.type || server.config.transport || (server.config.command ? 'stdio' : 'http');
const transportIcon = transport === 'stdio' ? '⚙️' : '🌐';
html += `
@@ -1393,11 +1496,11 @@ function renderExternalMCPList(servers) {
<span class="external-mcp-status ${statusClass}">${statusText}</span>
</div>
<div class="external-mcp-item-actions">
${status === 'connected' || status === 'disconnected' || status === 'error' ?
${status === 'connected' || status === 'disconnected' || status === 'error' || status === 'disabled' ?
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" onclick="toggleExternalMCP('${escapeHtml(name)}', '${status}')" title="${status === 'connected' ? statusT('mcp.stopConnection') : statusT('mcp.startConnection')}">
${status === 'connected' ? '⏸ ' + statusT('mcp.stop') : '▶ ' + statusT('mcp.start')}
</button>` :
status === 'connecting' ?
</button>` :
status === 'connecting' ?
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" disabled style="opacity: 0.6; cursor: not-allowed;">
${statusT('mcp.connecting')}
</button>` : ''}
@@ -1552,24 +1655,29 @@ function formatExternalMCPJSON() {
// 加载示例
function loadExternalMCPExample() {
const desc = (typeof window.t === 'function' ? window.t('externalMcpModal.exampleDescription') : '示例描述');
const example = {
"hexstrike-ai": {
"my-stdio-server": {
command: "python3",
args: [
"/path/to/script.py",
"--server",
"http://example.com"
"${HOME}/mcp-servers/main.py",
"--port",
"${MCP_PORT:-3000}"
],
description: desc,
env: {
"API_KEY": "${API_KEY}",
"LOG_LEVEL": "${LOG_LEVEL:-INFO}"
},
timeout: 300
},
"cyberstrike-ai-http": {
transport: "http",
url: "http://127.0.0.1:8081/mcp"
"my-http-server": {
type: "http",
url: "https://mcp.example.com/mcp",
headers: {
"Authorization": "Bearer ${MCP_TOKEN}"
}
},
"cyberstrike-ai-sse": {
transport: "sse",
"my-sse-server": {
type: "sse",
url: "http://127.0.0.1:8081/mcp/sse"
}
};
@@ -1642,8 +1750,8 @@ async function saveExternalMCP() {
// 移除 external_mcp_enable 字段(由按钮控制,但保留 enabled/disabled 用于向后兼容)
delete config.external_mcp_enable;
// 验证配置内容
const transport = config.transport || (config.command ? 'stdio' : config.url ? 'http' : '');
// 验证配置内容(同时支持官方 type 字段和旧版 transport 字段)
const transport = config.type || config.transport || (config.command ? 'stdio' : config.url ? 'http' : '');
if (!transport) {
errorDiv.textContent = t('mcp.configNeedCommand', { name: name });
errorDiv.style.display = 'block';
+503 -84
View File
@@ -28,6 +28,8 @@ let webshellClearInProgress = false;
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话
let webshellAiConvMap = {};
let webshellAiSending = false;
let webshellAiAbortController = null; // AbortController for current AI stream
let webshellAiStreamReader = null; // Current ReadableStreamDefaultReader
let webshellDbConfigByConn = {};
let webshellDirTreeByConn = {};
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)拉取连接列表
function getWebshellConnections() {
if (typeof apiFetch === 'undefined') {
@@ -1441,7 +1674,7 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
el.classList.toggle('active', el.dataset.convId === convId);
});
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 (data) {
messagesContainer.innerHTML = '';
@@ -1572,9 +1805,45 @@ function selectWebshell(id, stateReady) {
'</div>' +
'<div class="webshell-ai-main">' +
'<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') || 'DeepDeepAgent') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeDeepHint') || 'Eino DeepAgenttask 调度子代理') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep">\u2713</div></button>' +
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="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">' +
'<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-danger webshell-ai-stop-btn" id="webshell-ai-stop" style="display:none;">' + wsTOr('webshell.aiStop', '停止') + '</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
@@ -1635,6 +1904,9 @@ function selectWebshell(id, stateReady) {
if (tab === 'terminal' && webshellTerminalInstance && webshellTerminalFitAddon) {
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 aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
var aiConvListEl = document.getElementById('webshell-ai-conv-list');
// 初始化角色 + 模式选择器
wsLoadRoles();
wsInitAgentMode();
var aiMemoInput = document.getElementById('webshell-ai-memo-input');
var aiMemoStatus = document.getElementById('webshell-ai-memo-status');
var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear');
@@ -1770,7 +2046,11 @@ function selectWebshell(id, stateReady) {
});
}
if (aiSendBtn && aiInput && aiMessages) {
var aiStopBtn = document.getElementById('webshell-ai-stop');
aiSendBtn.addEventListener('click', function () { runWebshellAiSend(conn, aiInput, aiSendBtn, aiMessages); });
if (aiStopBtn) {
aiStopBtn.addEventListener('click', function () { wsStopAiStream(conn); });
}
aiInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -2347,8 +2627,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
return;
}
webshellAiSending = true;
if (sendBtn) sendBtn.disabled = true;
webshellAiAbortController = new AbortController();
wsSetAiSendingState(true);
var userDiv = document.createElement('div');
userDiv.className = 'webshell-ai-msg user';
@@ -2427,14 +2707,18 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
}
var einoSubReplyStreams = new Map();
var wsThinkingStreams = new Map(); // streamId → { el, buf }
var wsToolResultStreams = new Map(); // toolCallId → { el, buf }
if (inputEl) inputEl.value = '';
var convId = webshellAiConvMap[conn.id] || '';
var wsRole = (typeof getCurrentRole === 'function') ? getCurrentRole() : (localStorage.getItem('currentRole') || '');
var body = {
message: message,
webshellConnectionId: conn.id,
conversationId: convId
conversationId: convId,
role: wsRole
};
// 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加
@@ -2448,7 +2732,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
return apiFetch(info.path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
body: JSON.stringify(body),
signal: webshellAiAbortController ? webshellAiAbortController.signal : undefined
});
}).then(function (response) {
if (!response.ok) {
@@ -2458,6 +2743,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
return response.body.getReader();
}).then(function (reader) {
if (!reader) return;
webshellAiStreamReader = reader;
var decoder = new TextDecoder();
var buffer = '';
return reader.read().then(function processChunk(result) {
@@ -2470,9 +2756,12 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
if (line.indexOf('data: ') !== 0) continue;
try {
var eventData = JSON.parse(line.slice(6));
if (eventData.type === 'conversation' && eventData.data && eventData.data.conversationId) {
// 先把 conversationId 拿出来,避免后续异步回调里 eventData 被后续事件覆盖导致 undefined 报错
var convId = eventData.data.conversationId;
var _et = eventData.type;
var _ed = eventData.data || {};
var _em = eventData.message || '';
if (_et === 'conversation' && _ed.conversationId) {
var convId = _ed.conversationId;
webshellAiConvMap[conn.id] = convId;
var listEl = document.getElementById('webshell-ai-conv-list');
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);
});
});
} else if (eventData.type === 'response_start') {
// ─── Response streaming ───
} else if (_et === 'response_start') {
streamingTarget = '';
webshellStreamingTypingId += 1;
streamingTypingId = webshellStreamingTypingId;
assistantDiv.textContent = '…';
messagesContainer.scrollTop = messagesContainer.scrollHeight;
} else if (eventData.type === 'response_delta') {
var deltaText = (eventData.message != null && eventData.message !== '') ? String(eventData.message) : '';
} else if (_et === 'response_delta') {
var deltaText = (_em != null && _em !== '') ? String(_em) : '';
if (deltaText) {
streamingTarget += deltaText;
webshellStreamingTypingId += 1;
streamingTypingId = webshellStreamingTypingId;
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
}
} else if (eventData.type === 'response') {
var text = (eventData.message != null && eventData.message !== '') ? eventData.message : (eventData.data && typeof eventData.data === 'string' ? eventData.data : '');
} else if (_et === 'response') {
var text = (_em != null && _em !== '') ? _em : (typeof _ed === 'string' ? _ed : '');
if (text) {
// response 为最终完整内容:避免与增量重复拼接
streamingTarget = String(text);
webshellStreamingTypingId += 1;
streamingTypingId = webshellStreamingTypingId;
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
}
} else if (eventData.type === 'error' && eventData.message) {
// ─── Terminal events ───
} else if (_et === 'error' && _em) {
streamingTypingId += 1;
var errLabel = (typeof window.t === 'function') ? window.t('chat.error') : '错误';
appendTimelineItem('error', '❌ ' + errLabel, eventData.message, eventData.data);
renderWebshellAiErrorMessage(assistantDiv, errLabel + ': ' + eventData.message);
} else if (eventData.type === 'progress' && eventData.message) {
var errLabel = wsTOr('chat.error', '错误');
appendTimelineItem('error', '❌ ' + errLabel, _em, _ed);
renderWebshellAiErrorMessage(assistantDiv, errLabel + ': ' + _em);
} 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')
? window.translateProgressMessage(eventData.message)
: eventData.message;
appendTimelineItem('progress', '🔍 ' + progressMsg, '', eventData.data);
? window.translateProgressMessage(_em) : _em;
appendTimelineItem('progress', '🔍 ' + progressMsg, '', _ed);
if (!streamingTarget) assistantDiv.textContent = '…';
} else if (eventData.type === 'iteration') {
var iterN = (eventData.data && eventData.data.iteration) || 0;
var iterTitle = (typeof window.t === 'function')
? window.t('chat.iterationRound', { n: iterN || 1 })
: (iterN ? ('第 ' + iterN + ' 轮迭代') : (eventData.message || '迭代'));
var iterMessage = eventData.message || '';
} else if (_et === 'iteration') {
var iterN = _ed.iteration || 0;
var iterTitle = wsTOr('chat.iterationRound', '') || (iterN ? ('第 ' + iterN + ' 轮迭代') : (_em || '迭代'));
if (typeof window.t === 'function' && iterN) {
iterTitle = window.t('chat.iterationRound', { n: iterN });
}
var iterMessage = _em || '';
if (iterMessage && typeof window.translateProgressMessage === 'function') {
iterMessage = window.translateProgressMessage(iterMessage);
}
appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, eventData.data);
appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, _ed);
if (!streamingTarget) assistantDiv.textContent = '…';
} else if (eventData.type === 'thinking' && eventData.message) {
var thinkLabel = (typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考';
var thinkD = eventData.data || {};
appendTimelineItem('thinking', webshellAgentPx(thinkD) + '🤔 ' + thinkLabel, eventData.message, thinkD);
// ─── Thinking (non-stream + stream) ───
} else if (_et === 'thinking_stream_start' && _ed.streamId) {
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 = '…';
} else if (eventData.type === 'tool_calls_detected' && eventData.data) {
var count = eventData.data.count || 0;
var detectedLabel = (typeof window.t === 'function')
? window.t('chat.toolCallsDetected', { count: count })
: ('检测到 ' + count + ' 个工具调用');
appendTimelineItem('tool_calls_detected', webshellAgentPx(eventData.data) + '🔧 ' + detectedLabel, eventData.message || '', eventData.data);
} else if (_et === 'thinking_stream_delta' && _ed.streamId) {
var tsD = wsThinkingStreams.get(_ed.streamId);
if (tsD) {
tsD.buf += (_em || '');
if (typeof formatMarkdown === 'function') {
tsD.body.innerHTML = formatMarkdown(tsD.buf);
} else {
tsD.body.textContent = tsD.buf;
}
}
if (!streamingTarget) assistantDiv.textContent = '…';
} else if (eventData.type === 'tool_call' && eventData.data) {
var d = eventData.data;
var tn = d.toolName || '未知工具';
var idx = d.index || 0;
var total = d.total || 0;
var callTitle = (typeof window.t === 'function')
? window.t('chat.callTool', { name: tn, index: idx, total: total })
: ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : ''));
var title = webshellAgentPx(d) + '🔧 ' + callTitle;
appendTimelineItem('tool_call', title, eventData.message || '', eventData.data);
} else if (_et === 'thinking_stream_end' && _ed.streamId) {
var tsE = wsThinkingStreams.get(_ed.streamId);
if (tsE) {
var fullThink = (_em != null && _em !== '') ? String(_em) : tsE.buf;
if (typeof formatMarkdown === 'function') {
tsE.body.innerHTML = formatMarkdown(fullThink);
} else {
tsE.body.textContent = fullThink;
}
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 = '…';
} else if (eventData.type === 'tool_result' && eventData.data) {
var dr = eventData.data;
var success = dr.success !== false;
var tname = dr.toolName || '工具';
var titleText = (typeof window.t === 'function')
? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname }))
: (tname + (success ? ' 执行完成' : ' 执行失败'));
var title = webshellAgentPx(dr) + (success ? '✅ ' : '❌ ') + titleText;
var sub = eventData.message || (dr.result ? String(dr.result).slice(0, 300) : '');
appendTimelineItem('tool_result', title, sub, eventData.data);
// ─── Warning ───
} else if (_et === 'warning') {
appendTimelineItem('warning', '⚠️ ' + (_em || ''), '', _ed);
// ─── Eino recovery ───
} else if (_et === 'eino_recovery') {
var runIdx = _ed.runIndex != null ? _ed.runIndex : (_ed.einoRetry != null ? _ed.einoRetry + 1 : 1);
var maxRuns = _ed.maxRuns != null ? _ed.maxRuns : 3;
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 = '…';
} else if (eventData.type === 'eino_agent_reply_stream_start' && eventData.data && eventData.data.streamId) {
var rdS = eventData.data;
var repTS = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
var runTS = (typeof window.t === 'function') ? window.t('timeline.running') : '执行中...';
} else if (_et === 'tool_call' && _ed) {
var tn = _ed.toolName || '未知工具';
var idx = _ed.index || 0;
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');
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.classList.add('has-items');
einoSubReplyStreams.set(rdS.streamId, { el: itemS, buf: '' });
einoSubReplyStreams.set(_ed.streamId, { el: itemS, buf: '' });
if (!streamingTarget) assistantDiv.textContent = '…';
} else if (eventData.type === 'eino_agent_reply_stream_delta' && eventData.data && eventData.data.streamId) {
var stD = einoSubReplyStreams.get(eventData.data.streamId);
} else if (_et === 'eino_agent_reply_stream_delta' && _ed.streamId) {
var stD = einoSubReplyStreams.get(_ed.streamId);
if (stD) {
stD.buf += (eventData.message || '');
stD.buf += (_em || '');
var preD = stD.el.querySelector('.webshell-eino-reply-stream-body');
if (!preD) {
preD = document.createElement('pre');
@@ -2581,17 +2989,20 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
preD.style.whiteSpace = 'pre-wrap';
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 = '…';
} else if (eventData.type === 'eino_agent_reply_stream_end' && eventData.data && eventData.data.streamId) {
var stE = einoSubReplyStreams.get(eventData.data.streamId);
} else if (_et === 'eino_agent_reply_stream_end' && _ed.streamId) {
var stE = einoSubReplyStreams.get(_ed.streamId);
if (stE) {
var fullE = (eventData.message != null && eventData.message !== '') ? String(eventData.message) : stE.buf;
stE.buf = fullE;
var repTE = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
var fullE = (_em != null && _em !== '') ? String(_em) : stE.buf;
var repTE = wsTOr('chat.einoAgentReplyTitle', '子代理回复');
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');
if (!preE) {
preE = document.createElement('pre');
@@ -2599,14 +3010,17 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
preE.style.whiteSpace = 'pre-wrap';
stE.el.appendChild(preE);
}
preE.textContent = fullE;
einoSubReplyStreams.delete(eventData.data.streamId);
if (typeof formatMarkdown === 'function') {
preE.innerHTML = formatMarkdown(fullE);
} else {
preE.textContent = fullE;
}
einoSubReplyStreams.delete(_ed.streamId);
}
if (!streamingTarget) assistantDiv.textContent = '…';
} else if (eventData.type === 'eino_agent_reply' && eventData.message) {
var rd = eventData.data || {};
var replyT = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
appendTimelineItem('eino_agent_reply', webshellAgentPx(rd) + '💬 ' + replyT, eventData.message, rd);
} else if (_et === 'eino_agent_reply' && _em) {
var replyT = wsTOr('chat.einoAgentReplyTitle', '子代理回复');
appendTimelineItem('eino_agent_reply', webshellAgentPx(_ed) + '💬 ' + replyT, _em, _ed);
if (!streamingTarget) assistantDiv.textContent = '…';
}
} catch (e) { /* ignore parse error */ }
@@ -2615,10 +3029,15 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
return reader.read().then(processChunk);
});
}).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 () {
webshellAiSending = false;
if (sendBtn) sendBtn.disabled = false;
webshellAiAbortController = null;
webshellAiStreamReader = null;
wsSetAiSendingState(false);
if (assistantDiv.textContent === '…' && !streamingTarget) {
// 没有任何 response 内容,保持纯文本提示
assistantDiv.textContent = '无回复内容';
+10 -31
View File
@@ -159,7 +159,7 @@
</div>
</div>
<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">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
@@ -178,7 +178,7 @@
</div>
</div>
<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">
<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>
@@ -198,7 +198,7 @@
</div>
</div>
<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">
<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>
@@ -221,7 +221,7 @@
</div>
</div>
<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">
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
<polyline points="2 17 12 22 22 17"></polyline>
@@ -239,7 +239,7 @@
</div>
</div>
<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">
<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>
@@ -2699,6 +2699,11 @@
<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="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">
<input type="text" id="role-tools-search" data-i18n="roleModal.searchToolsPlaceholder" data-i18n-attr="placeholder" placeholder="搜索工具..."
oninput="searchRoleTools(this.value)"
@@ -2719,32 +2724,6 @@
</div>
<small class="form-hint" data-i18n="roleModal.relatedToolsHint">勾选要关联的工具,留空则使用MCP管理中的全部工具配置。</small>
</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">
<label class="checkbox-label">
<input type="checkbox" id="role-enabled" class="modern-checkbox" checked />