Compare commits

..

60 Commits

Author SHA1 Message Date
公明 8d999792b8 Update config.yaml 2026-06-16 16:22:14 +08:00
公明 afae8970d1 Add files via upload 2026-06-16 16:21:24 +08:00
公明 4d7330c5c3 Add files via upload 2026-06-16 15:48:11 +08:00
公明 8884bfb0b4 Add files via upload 2026-06-16 13:07:04 +08:00
公明 fb351c80b6 Add files via upload 2026-06-15 22:06:46 +08:00
公明 664834e338 Add files via upload 2026-06-15 22:03:29 +08:00
公明 95bf62db88 Add files via upload 2026-06-15 21:56:42 +08:00
公明 656242614d Add files via upload 2026-06-15 21:41:02 +08:00
公明 a9d6d8c00e Add files via upload 2026-06-15 21:30:39 +08:00
公明 0d6a43c0a8 Add files via upload 2026-06-15 20:43:51 +08:00
公明 702f286eb1 Add files via upload 2026-06-15 20:24:17 +08:00
公明 f4906543a8 Update config.yaml 2026-06-15 11:55:49 +08:00
公明 b073421637 Add files via upload 2026-06-15 11:55:04 +08:00
公明 08436c27aa Add files via upload 2026-06-15 11:49:53 +08:00
公明 25ce0b221f Add files via upload 2026-06-14 21:07:51 +08:00
公明 87e629f270 Add files via upload 2026-06-14 20:19:52 +08:00
公明 04f8d73b0e Add files via upload 2026-06-14 19:58:04 +08:00
公明 33e4f023b5 Add files via upload 2026-06-14 19:48:07 +08:00
公明 fc2e822448 Add files via upload 2026-06-14 19:46:13 +08:00
公明 7487c45799 Add files via upload 2026-06-14 19:43:59 +08:00
公明 6c4b3bf131 Add files via upload 2026-06-14 19:42:14 +08:00
公明 54cea1b172 Add files via upload 2026-06-13 19:56:09 +08:00
公明 b8775997e4 Add files via upload 2026-06-13 12:32:30 +08:00
公明 4223ec47f9 Add files via upload 2026-06-13 12:27:21 +08:00
公明 9887589d99 Add files via upload 2026-06-13 12:15:55 +08:00
公明 b7c01f41c7 Add files via upload 2026-06-13 12:08:04 +08:00
公明 1d3b4c44e1 Update config.yaml 2026-06-12 22:11:49 +08:00
公明 cbd64173b8 Add files via upload 2026-06-12 22:10:10 +08:00
公明 af71c6aa24 Add files via upload 2026-06-12 22:08:15 +08:00
公明 97a73a1cb6 Add files via upload 2026-06-12 22:06:41 +08:00
公明 83e1c707ca Add files via upload 2026-06-12 22:04:57 +08:00
公明 96ccbff77c Add files via upload 2026-06-12 21:28:51 +08:00
公明 c4bd8b93f6 Delete install-tools.sh 2026-06-12 21:26:22 +08:00
公明 d005268d28 Add files via upload 2026-06-12 19:43:38 +08:00
公明 7f4e8d2ad2 Add files via upload 2026-06-12 19:41:47 +08:00
公明 f3be355820 Add files via upload 2026-06-12 19:39:01 +08:00
公明 bf0ce33e3f Add files via upload 2026-06-12 19:36:45 +08:00
公明 4661862a1a Add files via upload 2026-06-11 18:03:09 +08:00
公明 f319a0f243 Add files via upload 2026-06-11 18:01:38 +08:00
公明 15c4802319 Add files via upload 2026-06-11 17:18:58 +08:00
公明 6ffde48b0c Add files via upload 2026-06-11 16:54:36 +08:00
公明 c5e2f0d95d Add files via upload 2026-06-11 16:02:48 +08:00
公明 28a826d5b7 Add files via upload 2026-06-11 15:56:25 +08:00
公明 6365de7018 Add files via upload 2026-06-11 11:50:31 +08:00
公明 2e4bf7197b Add files via upload 2026-06-11 11:48:17 +08:00
公明 ed4ba08163 Add files via upload 2026-06-11 11:46:23 +08:00
公明 8b5e55a673 Add files via upload 2026-06-11 11:44:20 +08:00
公明 e8a75e5105 Update config.yaml 2026-06-11 02:03:03 +08:00
公明 48976ed650 Add files via upload 2026-06-11 01:48:42 +08:00
公明 dc9ecae7fd Add files via upload 2026-06-11 01:43:35 +08:00
公明 a9d0a59f7a Add files via upload 2026-06-11 01:41:57 +08:00
公明 5ec4729b83 Add files via upload 2026-06-11 01:40:00 +08:00
公明 9857003018 Add files via upload 2026-06-11 01:38:25 +08:00
公明 a6e7885fed Add files via upload 2026-06-11 01:31:18 +08:00
公明 e69375451c Add files via upload 2026-06-11 01:29:07 +08:00
公明 07e7f104ad Add files via upload 2026-06-11 01:27:50 +08:00
公明 ffce9185bb Add files via upload 2026-06-11 01:16:20 +08:00
公明 612f16455d Add files via upload 2026-06-11 01:14:52 +08:00
公明 ecd5b40bc2 Add files via upload 2026-06-11 01:13:11 +08:00
公明 5aa7306c9b Update config.yaml 2026-06-11 00:53:39 +08:00
78 changed files with 11289 additions and 2113 deletions
+19 -13
View File
@@ -29,7 +29,6 @@ If CyberStrikeAI helps you, you can support the project via **WeChat Pay** or **
CyberStrikeAI is an **AI-native security testing platform** built in Go. It integrates 100+ security tools, an intelligent orchestration engine, role-based testing with predefined security roles, a skills system with specialized testing skills, comprehensive lifecycle management capabilities, and a **built-in lightweight C2 (Command & Control) framework** for **authorized** engagements (listeners, encrypted implants, sessions, tasks, real-time events, REST and MCP). Through native MCP protocol and AI agents, it enables end-to-end automation from conversational commands to vulnerability discovery, attack-chain analysis, knowledge retrieval, and result visualization—delivering an auditable, traceable, and collaborative testing environment for security teams.
## Interface & Integration Preview
<div align="center">
@@ -117,9 +116,9 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
- 📋 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
- 🧩 **Agent orchestration (CloudWeGo Eino)**: **single-agent** via **`/api/eino-agent/stream`** (Eino ADK `ChatModelAgent`); **multi-agent** via **`/api/multi-agent/stream`** with **`deep`** (coordinator + `task` sub-agents), **`plan_execute`**, or **`supervisor`** (`orchestration` in the request body). Markdown under `agents/`: `orchestrator.md`, `orchestrator-plan-execute.md`, `orchestrator-supervisor.md`, plus sub-agent `*.md` (see [Multi-agent doc](docs/MULTI_AGENT_EINO.md))
- 🧩 **Agent orchestration (CloudWeGo Eino)**: **single-agent** via **`/api/eino-agent/stream`** (Eino ADK `ChatModelAgent`); **multi-agent** via **`/api/multi-agent/stream`** with **`deep`** (coordinator + `task` sub-agents), **`plan_execute`**, or **`supervisor`** (`orchestration` in the request body). ADK **summarization** compresses long contexts; pre-compaction **transcripts** land at `data/conversation_artifacts/<conversation-id>/summarization/transcript.txt` (full user/assistant/tool turns; static system omitted). Markdown under `agents/`: `orchestrator.md`, `orchestrator-plan-execute.md`, `orchestrator-supervisor.md`, plus sub-agent `*.md` (see [Multi-agent doc](docs/MULTI_AGENT_EINO.md))
- 🖼️ **Vision analysis (`analyze_image`)**: separate VL model (e.g. `qwen-vl-max`) via MCP for local screenshots, captchas, and UI; image bytes stay out of agent history (text summaries only). Configure `vision` in `config.yaml`; see [docs/VISION.md](docs/VISION.md)
- 🎯 **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/`
- 🎯 **Skills (refactored for Eino)**: packs under `skills_dir` follow **Agent Skills** layout (`SKILL.md` + optional files); **multi-agent** sessions use the official Eino ADK **`skill`** tool for **progressive disclosure** (load by name), with optional **host filesystem / shell** via `multi_agent.eino_skills`; optional **`eino_middleware`** adds patchtoolcalls, tool_search, **plantask** (`TaskCreate` / `TaskList` boards under `skills_dir/.eino/plantask/`), reduction, file **checkpoints** (`checkpoint_dir`), ChatModel **retries**, session **output key**, and Deep tuning—20+ sample domains (SQLi, XSS, API security, …) ship under `skills/`
- 📱 **Chatbot**: DingTalk and Lark (Feishu) long-lived connections so you can talk to CyberStrikeAI from mobile (see [Robot / Chatbot guide](docs/robot_en.md) for setup and commands)
- 🧑‍⚖️ **Human-in-the-loop (HITL)**: Chat sidebar to set approval mode and tool allowlists (listed tools skip approval); global list in `config.yaml` under `hitl.tool_whitelist`; **Apply** can merge new tools into the file and update the running server without restart; dedicated **HITL** page for pending approvals
- 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter.
@@ -190,15 +189,21 @@ The `run.sh` script will automatically:
```
- Or edit `config.yaml` directly before launching
2. **Login** - Use the auto-generated password shown in the console (or set `auth.password` in `config.yaml`)
3. **Install security tools (optional)** - Install all tools declared under `tools/`:
3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
**macOS (Homebrew):**
```bash
./install-tools.sh # install missing tools (best on Kali/Debian/Ubuntu)
./install-tools.sh --check # check only, no install
./install-tools.sh --list # show per-tool status
./install-tools.sh --only nmap,gau # install selected tools only
brew install nmap masscan sqlmap nikto gobuster ffuf hydra hashcat nuclei subfinder
```
On macOS, install bash 4+ via Homebrew first; without apt, the script falls back to pip/go/GitHub.
AI automatically falls back to alternatives when a tool is missing.
**Linux (Kali / Debian / Ubuntu):**
```bash
sudo apt update
sudo apt install -y nmap masscan sqlmap nikto gobuster hydra hashcat john binwalk
# On some distros, install ffuf/nuclei/subfinder via go install or upstream docs
```
See the `tools/` directory for the full list; refer to each tool's official docs for install details.
**Alternative Launch Methods:**
```bash
@@ -261,7 +266,7 @@ 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** Skill packs live under `skills_dir` and load via the Eino ADK **`skill`** tool (**progressive disclosure**) in both **single- and multi-agent** sessions when **`multi_agent.eino_skills`** is enabled. Optional host **read_file / glob / grep / write / edit / execute** and **`eino_middleware`** (tool_search, reduction, checkpoints, etc.) apply per mode—see docs.
- **Skills** Skill packs live under `skills_dir` and load via the Eino ADK **`skill`** tool (**progressive disclosure**) in both **single- and multi-agent** sessions when **`multi_agent.eino_skills`** is enabled. Optional host **read_file / glob / grep / write / edit / execute** and **`eino_middleware`** (tool_search, plantask, reduction, checkpoints, summarization transcripts, etc.) apply per mode—see docs.
- **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.
@@ -289,6 +294,7 @@ Requirements / tips:
- **Sub-agents** (for **deep** / **supervisor**): other `*.md` files (YAML front matter + body). Not used as **`task`** targets if marked orchestrator-only.
- **Management** Web UI: **Agents → Agent management**; API `/api/multi-agent/markdown-agents`.
- **Config** `multi_agent` in `config.yaml`: `enabled`, `robot_default_agent_mode`, `batch_use_multi_agent`, `max_iteration`, `plan_execute_loop_max_iterations`, per-mode orchestrator instruction fields, optional YAML `sub_agents` merged with disk (`id` clash → Markdown wins), **`eino_skills`**, **`eino_middleware`** (optional ADK middleware and Deep/Supervisor tuning).
- **Resilience & long runs** `checkpoint_dir` enables ADK **resume** after process crashes (distinct from trace-based “interrupt & continue”). `deep_model_retry_max_retries` retries transient LLM API failures within a single call. **Summarization** writes a filtered **transcript** when compression fires; the summary message includes the path so the model can `read_file` for scan output and other pre-compaction details.
- **Details** **[docs/MULTI_AGENT_EINO.md](docs/MULTI_AGENT_EINO.md)** (streaming, robots, batch, middleware caveats).
### Skills System (Agent Skills + Eino)
@@ -296,7 +302,7 @@ Requirements / tips:
- **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).
- **HTTP API** `/api/skills` listing and `depth` (`summary` | `full`), `section`, and `resource_path` remain for the web UI and ops; **model-side** skill loading in multi-agent uses the **`skill`** tool, not MCP.
- **Optional `eino_middleware`** e.g. `tool_search` (dynamic MCP tool list), `patch_tool_calls`, `plantask` (structured tasks; persistence defaults under a subdirectory of `skills_dir`), `reduction`, `checkpoint_dir`, Deep output key / model retries / task-tool description prefix—see `config.yaml` and `internal/config/config.go`.
- **Optional `eino_middleware`** e.g. `tool_search` (dynamic MCP tool list), `patch_tool_calls`, **`plantask`** (Eino `TaskCreate` / `TaskGet` / `TaskUpdate` / `TaskList`; JSON under `skills_dir/.eino/plantask/<conversation-id>/`; Eino clears task files when **all** tasks are marked completed), `reduction`, **`checkpoint_dir`** (`data/eino-checkpoints/`), **`deep_model_retry_max_retries`**, **`deep_output_key`**, 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:**
@@ -544,7 +550,7 @@ multi_agent:
orchestrator_instruction: "" # Deep; used when orchestrator.md body is empty
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
# eino_middleware: optional patch_tool_calls, tool_search, plantask, reduction, checkpoint_dir, ...
# eino_middleware: plantask_enable, checkpoint_dir, deep_model_retry_max_retries, deep_output_key, ...
```
### Tool Definition Example (`tools/nmap.yaml`)
+19 -13
View File
@@ -28,7 +28,6 @@
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能、完整的测试生命周期管理能力,以及面向 **授权场景****内置轻量 C2Command & Control,指挥与控制)** 能力(监听器、加密通信、会话与任务、实时事件、REST 与 MCP 协同)。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
## 界面与集成预览
<div align="center">
@@ -116,9 +115,9 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
- 🧩 **Agent 编排(CloudWeGo Eino****单代理** `POST /api/eino-agent/stream`Eino ADK);**多代理** `POST /api/multi-agent/stream``orchestration`**`deep`** / **`plan_execute`** / **`supervisor`**。`agents/` 下主代理与子代理 Markdown 见 [多代理说明](docs/MULTI_AGENT_EINO.md)
- 🧩 **Agent 编排(CloudWeGo Eino****单代理** `POST /api/eino-agent/stream`Eino ADK);**多代理** `POST /api/multi-agent/stream``orchestration`**`deep`** / **`plan_execute`** / **`supervisor`**。ADK **Summarization** 在上下文过长时压缩历史;压缩前将可恢复 **转录** 写入 `data/conversation_artifacts/<会话ID>/summarization/transcript.txt`(保留完整 user/assistant/tool 轮次,省略静态 system)。`agents/` 下主代理与子代理 Markdown 见 [多代理说明](docs/MULTI_AGENT_EINO.md)
- 🖼️ **视觉分析(`analyze_image`**:独立 Vision 模型(如 `qwen-vl-max`),MCP 工具分析本地截图/验证码/UI;图片仅在单次 VL 调用中出现,对话上下文只保留文字摘要。配置见 `config.yaml``vision` 与 [视觉分析说明](docs/VISION.md)
- 🎯 **Skills(面向 Eino 重构)**:技能包放在 **`skills_dir`**,遵循 **Agent Skills** 目录规范(`SKILL.md` + 可选文件);**多代理** 下通过 Eino 官方 **`skill`** 工具 **渐进式披露**(按 name 加载)。**`multi_agent.eino_skills`** 控制是否启用、本机文件/Shell 工具、工具名覆盖;**`eino_middleware`** 可选 patch、tool_search、plantask、reduction、断点目录及 Deep 调参。20+ 领域示例仍可绑定角色
- 🎯 **Skills(面向 Eino 重构)**:技能包放在 **`skills_dir`**,遵循 **Agent Skills** 目录规范(`SKILL.md` + 可选文件);**多代理** 下通过 Eino 官方 **`skill`** 工具 **渐进式披露**(按 name 加载)。**`multi_agent.eino_skills`** 控制是否启用、本机文件/Shell 工具、工具名覆盖;**`eino_middleware`** 可选 patch、tool_search、**plantask**`TaskCreate` / `TaskList` 任务板,落在 `skills_dir/.eino/plantask/`)、reduction、文件型 **checkpoint**`checkpoint_dir`)、ChatModel **重试**、会话 **输出键** 及 Deep 调参。20+ 领域示例仍可绑定角色
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md)
- 🧑‍⚖️ **人机协同(HITL**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml``hitl.tool_whitelist`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
@@ -189,15 +188,21 @@ chmod +x run.sh && ./run.sh
```
- 或启动前直接编辑 `config.yaml` 文件
2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password`
3. **安装安全工具(可选)** - 一键安装 `tools/` 目录声明的全部工具
3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例
**macOSHomebrew):**
```bash
./install-tools.sh # 安装缺失工具 (Kali/Debian/Ubuntu 推荐)
./install-tools.sh --check # 仅检查, 不安装
./install-tools.sh --list # 列出各工具安装状态
./install-tools.sh --only nmap,gau # 只装指定工具
brew install nmap masscan sqlmap nikto gobuster ffuf hydra hashcat nuclei subfinder
```
macOS 自带 bash 3.2, 请用 `./install-tools.sh --install-bash --list` 自动安装 bash 4+; apt 不可用时会降级到 pip/go/GitHub。
未安装的工具在执行时会自动跳过或改用替代方案。
**LinuxKali / Debian / Ubuntu):**
```bash
sudo apt update
sudo apt install -y nmap masscan sqlmap nikto gobuster hydra hashcat john binwalk
# 部分发行版需自行安装:ffuf、nuclei、subfinder 等可用 go install 或见各工具官网
```
完整工具列表见 `tools/` 目录;各工具安装方式以官方文档为准。
**其他启动方式:**
```bash
@@ -259,7 +264,7 @@ go build -o cyberstrike-ai cmd/server/main.go
- **预设角色**:系统内置 12+ 个预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试、二进制分析、云安全审计等),位于 `roles/` 目录。
- **自定义提示词**:每个角色可定义 `user_prompt`,会在用户消息前自动添加,引导 AI 采用特定的测试方法和关注重点。
- **工具限制**:角色可指定 `tools` 列表,限制可用工具,实现聚焦的测试流程(如 CTF 角色限制为 CTF 专用工具)。
- **Skills**:技能包位于 `skills_dir`;启用 **`multi_agent.eino_skills`** 后,**单代理与多代理**均可通过 Eino **`skill`** 工具按需加载。中间件与本机 read_file/glob/grep 等见文档。
- **Skills**:技能包位于 `skills_dir`;启用 **`multi_agent.eino_skills`** 后,**单代理与多代理**均可通过 Eino **`skill`** 工具按需加载。可选 **`eino_middleware`**tool_search、plantask、reduction、checkpoint、Summarization 转录等)与本机 read_file/glob/grep 等见文档。
- **轻松创建角色**:通过在 `roles/` 目录添加 YAML 文件即可创建自定义角色。每个角色定义 `name`、`description`、`user_prompt`、`icon`、`tools`、`enabled` 字段。
- **Web 界面集成**:在聊天界面通过下拉菜单选择角色。角色选择会影响 AI 行为和可用工具建议。
@@ -287,6 +292,7 @@ go build -o cyberstrike-ai cmd/server/main.go
- **子代理****deep** / **supervisor**):其余 `*.md`;标成 orchestrator 的不会进入 `task` 列表。
- **界面管理****Agents → Agent 管理**API `/api/multi-agent/markdown-agents`。
- **配置项**`multi_agent``enabled`、`robot_default_agent_mode`、`batch_use_multi_agent`、`max_iteration`、`plan_execute_loop_max_iterations`、各模式 orchestrator 指令字段、可选 YAML `sub_agents` 与目录合并(同 `id` → Markdown 优先)、**`eino_skills`**、**`eino_middleware`**。
- **长任务与恢复**`checkpoint_dir` 支持进程崩溃后 ADK **断点续跑**(与基于 trace 的「中断继续」不同)。`deep_model_retry_max_retries` 在同一次 LLM 调用内重试瞬时 API 失败。**Summarization** 触发压缩时会写入过滤后的 **transcript**,摘要消息中带路径,模型可用 `read_file` 找回扫描输出等压缩前细节。
- **更多细节**[docs/MULTI_AGENT_EINO.md](docs/MULTI_AGENT_EINO.md)(流式、机器人、批量、中间件差异)。
### Skills 技能系统(Agent Skills + Eino
@@ -294,7 +300,7 @@ go build -o cyberstrike-ai cmd/server/main.go
- **运行侧重构****`skills_dir`** 为技能包唯一根目录;**多代理** 通过 Eino 官方 **`skill`** 中间件做 **渐进式披露**(模型按 **name** 调用 `skill`,而非一次性注入全文)。由 **`multi_agent.eino_skills`** 控制:`disable`、`filesystem_tools`(本机读写与 Shell)、`skill_tool_name`。
- **Eino / 知识流水线**:技能包可切分为 `schema.Document`,供 `FilesystemSkillsRetriever``skills.AsEinoRetriever()`)在 **compose** 图(如索引/编排)中使用。
- **HTTP 管理**`/api/skills` 列表与 `depth=summary|full`、`section`、`resource_path` 等仍用于 Web 与运维;**模型侧** 多代理走 **`skill`** 工具,而非 MCP。
- **可选 `eino_middleware`**:如 `tool_search`(动态工具列表)、`patch_tool_calls`、`plantask`(结构化任务;默认落在 `skills_dir` 下子目录)、`reduction`、`checkpoint_dir`、Deep 输出键 / 模型重试 / task 描述前缀等,见 `config.yaml` 与 `internal/config/config.go`。
- **可选 `eino_middleware`**:如 `tool_search`(动态工具列表)、`patch_tool_calls`、**`plantask`**Eino `TaskCreate` / `TaskGet` / `TaskUpdate` / `TaskList`JSON 存于 `skills_dir/.eino/plantask/<会话ID>/`**全部**任务标为 completed 后 Eino 会清理任务文件)、`reduction`、**`checkpoint_dir`**(如 `data/eino-checkpoints/`)、**`deep_model_retry_max_retries`**、**`deep_output_key`**、task 描述前缀等,见 `config.yaml` 与 `internal/config/config.go`。
- **自带示例**`skills/cyberstrike-eino-demo/`;说明见 `skills/README.md`。
**新建技能:**
@@ -542,7 +548,7 @@ multi_agent:
orchestrator_instruction: "" # Deeporchestrator.md 正文为空时使用
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
# eino_middleware: 可选 patch_tool_calls、tool_search、plantask、reduction、checkpoint_dir
# eino_middleware: plantask_enable、checkpoint_dir、deep_model_retry_max_retries、deep_output_key
```
### 工具模版示例(`tools/nmap.yaml`
+7 -7
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.34"
version: "v1.6.38"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -129,8 +129,8 @@ multi_agent:
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
tool_search_always_visible: 12 # 始终直接暴露给模型的工具个数(顺序与角色工具列表一致);其余工具进入动态池,需 tool_search 解锁
tool_search_always_visible_tools: [read_file, glob, grep, analyze_image, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_vulnerabilities, get_vulnerability, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test] # 后端内置常驻工具白名单(优先于 always_visible 数量策略)
plantask_enable: false # true:主代理(Deep / Supervisor 主)挂载 TaskCreate/Get/Update/List;需 eino_skills 可用且 skills_dir 存在,否则仅打日志并跳过
plantask_rel_dir: .eino/plantask # 结构化任务文件相对 skills_dir 的子目录,其下再按会话 ID 分子目录存放
plantask_enable: true # P0:主代理挂载 TaskCreate/Get/Update/List 结构化任务板;需 eino_skills 可用且 skills_dir 存在
plantask_rel_dir: .eino/plantask # 任务文件相对 skills_dir,按会话分子目录:skills/.eino/plantask/<conversationId>/
reduction_enable: true # true:大工具输出截断/落盘以控上下文;依赖与 plantask 相同的 eino local 写盘后端,无后端时不挂载
reduction_max_length_for_trunc: 50000 # 单条工具结果超过该字符数(bytes)时截断并落盘(由 reduction 中间件处理)
reduction_max_tokens_for_clear: 160000 # 历史工具结果清理阈值(tokens),超阈值时在模型调用前清理旧结果
@@ -143,11 +143,11 @@ multi_agent:
plan_execute_executed_steps_budget_ratio: 0.2 # plan_execute 中 executed_steps 预算比例
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
checkpoint_dir: "" # 非空:为 adk.NewRunner 启用按会话子目录的文件型 CheckPointStore,便于中断恢复持久化;Resume 的 HTTP/前端流程需另行对接
run_retry_max_attempts: 0 # >0429/5xx/网络抖动时 ADK 运行循环指数退避续跑次数0=默认 10
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
run_retry_max_attempts: 0 # 429/5xx/网络抖动时整轮 Run 指数退避续跑;0=默认 10(与 deep_model_retry 互补,建议保持默认)
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
deep_output_key: "" # 非空:将最终助手输出写入 adk session 的键名(DeepSupervisor 主代理);空表示不写入
deep_model_retry_max_retries: 0 # >0ChatModel 调用失败时框架级最大重试次数(Deep 与 Supervisor 主);0:不重试
deep_output_key: final_answer # P0Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single
deep_model_retry_max_retries: 3 # P0单次 ChatModel API 失败时框架自动重试(超时/502 等);子代理模型不受此项影响
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
# Eino callbacks + OpenTelemetry:框架级 span(与 Zap 对齐);默认不向终端用户 UI 推 eino_trace_*(见 sse_trace_to_client
eino_callbacks:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 179 KiB

-1064
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -315,6 +315,14 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath)
log.Logger.Info("Skills 目录(Eino ADK skill 中间件 + Web 管理 API", zap.String("skillsDir", skillsDir))
configDir := filepath.Dir(configPath)
plantaskRel := strings.TrimSpace(cfg.MultiAgent.EinoMiddleware.PlantaskRelDir)
if plantaskRel == "" {
plantaskRel = ".eino/plantask"
}
plantaskBase := filepath.Join(skillsDir, plantaskRel)
// Match eino_adk_run_loop: checkpoint_dir is used as configured (relative to process CWD when not absolute).
checkpointBase := strings.TrimSpace(cfg.MultiAgent.EinoMiddleware.CheckpointDir)
db.SetEinoConversationDirs(plantaskBase, checkpointBase)
agent.SetPromptBaseDir(configDir)
agentsDir := cfg.AgentsDir
+19 -2
View File
@@ -47,6 +47,24 @@ func (l *oneConnListener) Accept() (net.Conn, error) {
func (l *oneConnListener) Close() error { return nil }
func (l *oneConnListener) Addr() net.Addr { return l.addr }
// httpServerForTLSConn 从已有 Server 复制可服务字段,用于已握手 TLS 连接上的 HTTP 服务。
// 不能复制整个 http.Server(内含 atomic/noCopy 字段)。
func httpServerForTLSConn(src *http.Server) *http.Server {
return &http.Server{
Handler: src.Handler,
DisableGeneralOptionsHandler: src.DisableGeneralOptionsHandler,
ReadTimeout: src.ReadTimeout,
ReadHeaderTimeout: src.ReadHeaderTimeout,
WriteTimeout: src.WriteTimeout,
IdleTimeout: src.IdleTimeout,
MaxHeaderBytes: src.MaxHeaderBytes,
ConnState: src.ConnState,
ErrorLog: src.ErrorLog,
BaseContext: src.BaseContext,
ConnContext: src.ConnContext,
}
}
func isTLSHandshakeRecord(b byte) bool {
return b == 0x16
}
@@ -172,8 +190,7 @@ func (m *mainServerMux) serveHTTPS(pc *peekedConn, localAddr net.Addr) {
}
}
plain := *srv
plain.TLSConfig = nil
plain := httpServerForTLSConn(srv)
ocl := &oneConnListener{conn: tlsConn, addr: localAddr}
if err := plain.Serve(ocl); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
m.logger.Debug("HTTPS 连接处理结束", zap.Error(err))
+2 -2
View File
@@ -293,8 +293,8 @@ func registerListVulnerabilitiesTool(mcpServer *mcp.Server, db *database.DB, log
},
"status": map[string]interface{}{
"type": "string",
"description": "按状态筛选:open、confirmed、fixed、false_positive",
"enum": []string{"open", "confirmed", "fixed", "false_positive"},
"description": "按状态筛选:open、confirmed、fixed、false_positive、ignored",
"enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
},
"q": map[string]interface{}{
"type": "string",
+18 -5
View File
@@ -160,6 +160,18 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro
}
f.Close()
// 平台相关辅助源文件(如无窗口子进程)
for _, name := range []string{"proc_hide_windows.go", "proc_hide_unix.go"} {
helperSrc := filepath.Join(b.tmplDir, name+".tmpl")
helperData, readErr := os.ReadFile(helperSrc)
if readErr != nil {
return nil, fmt.Errorf("read helper %s: %w", name, readErr)
}
if writeErr := os.WriteFile(filepath.Join(workDir, name), helperData, 0644); writeErr != nil {
return nil, fmt.Errorf("write helper %s: %w", name, writeErr)
}
}
// 交叉编译
binName := strings.TrimSpace(in.OutputName)
if binName == "" {
@@ -174,15 +186,16 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro
return nil, fmt.Errorf("mkdir output: %w", err)
}
absSrcPath, err := filepath.Abs(srcPath)
if err != nil {
return nil, fmt.Errorf("abs source path: %w", err)
}
absBinPath, err := filepath.Abs(binPath)
if err != nil {
return nil, fmt.Errorf("abs output path: %w", err)
}
cmd := exec.Command("go", "build", "-ldflags", "-s -w -buildid=", "-trimpath", "-o", absBinPath, absSrcPath)
ldflags := "-s -w -buildid="
if goos == "windows" {
// 无控制台窗口运行 beacon 本体
ldflags += " -H windowsgui"
}
cmd := exec.Command("go", "build", "-ldflags", ldflags, "-trimpath", "-o", absBinPath, ".")
cmd.Env = append(os.Environ(),
"GOOS="+goos,
"GOARCH="+goarch,
+3 -1
View File
@@ -729,6 +729,7 @@ func runWithTimeout(cmdStr string, timeoutSec int) (string, error) {
timeoutSec = 60
}
cmd := exec.Command(shellByOS(), shellFlag(), cmdStr)
prepareHiddenCmd(cmd)
cwdMu.Lock()
cmd.Dir = currentCwd
cwdMu.Unlock()
@@ -959,7 +960,7 @@ func taskScreenshot() (string, string, string, string) {
b64Out, err = runWithTimeout("import -window root /tmp/.cs_ss.png 2>/dev/null && base64 /tmp/.cs_ss.png && rm -f /tmp/.cs_ss.png", 30)
case "windows":
ps := `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $b=New-Object System.Drawing.Bitmap([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width,[System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height); $g=[System.Drawing.Graphics]::FromImage($b); $g.CopyFromScreen([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Location,[System.Drawing.Point]::Empty,$b.Size); $m=New-Object IO.MemoryStream; $b.Save($m,[System.Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($m.ToArray())`
b64Out, err = runWithTimeout(fmt.Sprintf("powershell -NoProfile -NonInteractive -Command \"%s\"", ps), 30)
b64Out, err = runWithTimeout(fmt.Sprintf("powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command \"%s\"", ps), 30)
default:
return "", "", "", "screenshot not supported on " + runtime.GOOS
}
@@ -1200,6 +1201,7 @@ func taskLoadAssembly(payload map[string]interface{}) (string, string, string, s
cmdArgs = strings.Fields(args)
}
cmd := exec.Command(tmpFile, cmdArgs...)
prepareHiddenCmd(cmd)
cwdMu.Lock()
cmd.Dir = currentCwd
cwdMu.Unlock()
@@ -0,0 +1,9 @@
//go:build !windows
package main
import "os/exec"
func prepareHiddenCmd(cmd *exec.Cmd) {
_ = cmd
}
@@ -0,0 +1,18 @@
//go:build windows
package main
import (
"os/exec"
"syscall"
)
// prepareHiddenCmd 避免子进程弹出控制台窗口(cmd / powershell / 临时 exe 等)。
func prepareHiddenCmd(cmd *exec.Cmd) {
if cmd == nil {
return
}
// 仅用 HideWindow:等价于 CREATE_NO_WINDOW,且 macOS/Linux 交叉编译 Windows 时
// syscall.CREATE_NO_WINDOW 常量不可用。
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
}
+1 -1
View File
@@ -239,7 +239,7 @@ func (db *DB) CountBatchQueues(status, keyword string) (int, error) {
// GetBatchTasks 获取批量任务队列的所有任务
func (db *DB) GetBatchTasks(queueID string) ([]*BatchTaskRow, error) {
rows, err := db.Query(
"SELECT id, queue_id, message, conversation_id, status, started_at, completed_at, error, result FROM batch_tasks WHERE queue_id = ? ORDER BY id",
"SELECT id, queue_id, message, conversation_id, status, started_at, completed_at, error, result FROM batch_tasks WHERE queue_id = ? ORDER BY rowid ASC",
queueID,
)
if err != nil {
+56 -12
View File
@@ -543,18 +543,28 @@ func (db *DB) UpdateConversationTime(id string) error {
return nil
}
// DeleteConversation 删除对话及其所有相关数据
// DeleteConversation 删除对话及其会话相关数据
// 由于数据库外键约束设置了 ON DELETE CASCADE,删除对话时会自动删除:
// - messages(消息)
// - process_details(过程详情)
// - attack_chain_nodes(攻击链节点)
// - attack_chain_edges(攻击链边)
// - vulnerabilities(漏洞)
// - conversation_group_mappings(分组映射)
// 注意:knowledge_retrieval_logs 使用 ON DELETE SET NULL,记录会保留但 conversation_id 会被设为 NULL
// 漏洞记录会保留:vulnerabilities.conversation_id 使用 ON DELETE SET NULL,仅解除与会话的关联。
// 注意:knowledge_retrieval_logs 在删除前会被显式清理。
func (db *DB) DeleteConversation(id string) error {
// 删除对话前补全漏洞来源标签,便于在漏洞库中追溯已删除会话的发现。
_, err := db.Exec(`
UPDATE vulnerabilities
SET conversation_tag = COALESCE(NULLIF(TRIM(conversation_tag), ''), (SELECT title FROM conversations WHERE id = ?))
WHERE conversation_id = ?
`, id, id)
if err != nil {
db.logger.Warn("更新漏洞来源标签失败", zap.String("conversationId", id), zap.Error(err))
}
// 显式删除知识检索日志(虽然外键是SET NULL,但为了彻底清理,我们手动删除)
_, err := db.Exec("DELETE FROM knowledge_retrieval_logs WHERE conversation_id = ?", id)
_, err = db.Exec("DELETE FROM knowledge_retrieval_logs WHERE conversation_id = ?", id)
if err != nil {
db.logger.Warn("删除知识检索日志失败", zap.String("conversationId", id), zap.Error(err))
// 不返回错误,继续删除对话
@@ -565,17 +575,51 @@ func (db *DB) DeleteConversation(id string) error {
if err != nil {
return fmt.Errorf("删除对话失败: %w", err)
}
// Best-effort cleanup for conversation-scoped filesystem artifacts
// (e.g., summarization transcript, reduction/checkpoint files under conversation_artifacts/<id>).
if base := strings.TrimSpace(db.conversationArtifactsDir); base != "" {
artDir := filepath.Join(base, id)
if rmErr := os.RemoveAll(artDir); rmErr != nil {
db.logger.Warn("删除会话 artifacts 目录失败", zap.String("conversationId", id), zap.String("dir", artDir), zap.Error(rmErr))
db.removeConversationScopedDirs(id)
db.logger.Info("对话已删除(漏洞记录已保留)", zap.String("conversationId", id))
return nil
}
func sanitizeConversationPathSegment(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "default"
}
s = strings.ReplaceAll(s, string(filepath.Separator), "-")
s = strings.ReplaceAll(s, "/", "-")
s = strings.ReplaceAll(s, "\\", "-")
s = strings.ReplaceAll(s, "..", "__")
if len(s) > 180 {
s = s[:180]
}
return s
}
func (db *DB) removeConversationScopedDir(base, conversationID, label string) {
base = strings.TrimSpace(base)
if base == "" {
return
}
dir := filepath.Join(base, sanitizeConversationPathSegment(conversationID))
if rmErr := os.RemoveAll(dir); rmErr != nil {
if db.logger != nil {
db.logger.Warn("删除会话目录失败",
zap.String("conversationId", conversationID),
zap.String("kind", label),
zap.String("dir", dir),
zap.Error(rmErr))
}
}
}
db.logger.Info("对话及其所有相关数据已删除", zap.String("conversationId", id))
return nil
func (db *DB) removeConversationScopedDirs(conversationID string) {
// summarization transcript, reduction files, etc.
db.removeConversationScopedDir(db.conversationArtifactsDir, conversationID, "conversation_artifacts")
// Eino plantask JSON boards (skills_dir/.eino/plantask/<id>/).
db.removeConversationScopedDir(db.einoPlantaskBaseDir, conversationID, "plantask")
// Eino ADK runner checkpoints (checkpoint_dir/<id>/).
db.removeConversationScopedDir(db.einoCheckpointBaseDir, conversationID, "eino_checkpoint")
}
// SaveAgentTrace 保存最后一轮代理消息轨迹与助手输出摘要。
@@ -0,0 +1,57 @@
package database
import (
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestDeleteConversationRemovesEinoScopedDirs(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "conversations.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
plantaskBase := filepath.Join(tmp, "skills", ".eino", "plantask")
checkpointBase := filepath.Join(tmp, "eino-checkpoints")
db.SetEinoConversationDirs(plantaskBase, checkpointBase)
conv, err := db.CreateConversation("cleanup test", ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
convID := conv.ID
seg := sanitizeConversationPathSegment(convID)
for _, base := range []struct {
root string
file string
}{
{db.conversationArtifactsDir, "transcript.txt"},
{plantaskBase, "task-1.json"},
{checkpointBase, "runner-deep.ckpt"},
} {
dir := filepath.Join(base.root, seg)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
if err := os.WriteFile(filepath.Join(dir, base.file), []byte("x"), 0o644); err != nil {
t.Fatalf("write %s: %v", base.file, err)
}
}
if err := db.DeleteConversation(convID); err != nil {
t.Fatalf("DeleteConversation: %v", err)
}
for _, base := range []string{db.conversationArtifactsDir, plantaskBase, checkpointBase} {
dir := filepath.Join(base, seg)
if _, statErr := os.Stat(dir); !os.IsNotExist(statErr) {
t.Fatalf("expected removed dir %s, stat err=%v", dir, statErr)
}
}
}
@@ -0,0 +1,69 @@
package database
import (
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestDeleteConversationPreservesVulnerabilities(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "vuln-preserve.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
conv, err := db.CreateConversation("vuln source chat", ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
vuln, err := db.CreateVulnerability(&Vulnerability{
ConversationID: conv.ID,
Title: "SQL Injection",
Severity: "high",
Status: "open",
})
if err != nil {
t.Fatalf("CreateVulnerability: %v", err)
}
if err := db.DeleteConversation(conv.ID); err != nil {
t.Fatalf("DeleteConversation: %v", err)
}
got, err := db.GetVulnerability(vuln.ID)
if err != nil {
t.Fatalf("GetVulnerability after delete: %v", err)
}
if got.Title != "SQL Injection" {
t.Fatalf("title = %q, want SQL Injection", got.Title)
}
if got.ConversationID != "" {
t.Fatalf("conversation_id = %q, want empty after conversation delete", got.ConversationID)
}
if got.ConversationTag != "vuln source chat" {
t.Fatalf("conversation_tag = %q, want vuln source chat", got.ConversationTag)
}
}
func TestMigrateVulnerabilitiesConversationFK(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "vuln-fk-migrate.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
ok, err := vulnerabilitiesConversationFKOnDeleteSetNull(db.DB)
if err != nil {
t.Fatalf("vulnerabilitiesConversationFKOnDeleteSetNull: %v", err)
}
if !ok {
t.Fatal("expected vulnerabilities.conversation_id FK to use ON DELETE SET NULL")
}
}
+128 -2
View File
@@ -49,6 +49,8 @@ type DB struct {
*sql.DB
logger *zap.Logger
conversationArtifactsDir string
einoPlantaskBaseDir string // skills_dir + plantask_rel_dir (per-conversation subdirs)
einoCheckpointBaseDir string // checkpoint_dir root (per-conversation subdirs)
checkpointLoopName string
checkpointStop chan struct{}
checkpointDone chan struct{}
@@ -155,6 +157,16 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
return database, nil
}
// SetEinoConversationDirs configures best-effort filesystem cleanup on DeleteConversation.
// plantaskBase is skills_root/plantask_rel (no conversation id); checkpointBase is checkpoint_dir root.
func (db *DB) SetEinoConversationDirs(plantaskBase, checkpointBase string) {
if db == nil {
return
}
db.einoPlantaskBaseDir = strings.TrimSpace(plantaskBase)
db.einoCheckpointBaseDir = strings.TrimSpace(checkpointBase)
}
// initTables 初始化数据库表
func (db *DB) initTables() error {
// 创建对话表(last_react_input / last_react_output 存「代理消息轨迹」JSON 与助手摘要,列名保留以兼容已有库)
@@ -345,7 +357,7 @@ func (db *DB) initTables() error {
createVulnerabilitiesTable := `
CREATE TABLE IF NOT EXISTS vulnerabilities (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
conversation_id TEXT,
conversation_tag TEXT,
task_tag TEXT,
title TEXT NOT NULL,
@@ -359,7 +371,8 @@ func (db *DB) initTables() error {
recommendation TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
project_id TEXT,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
);`
// 创建批量任务队列表
@@ -725,6 +738,9 @@ func (db *DB) initTables() error {
db.logger.Warn("迁移vulnerabilities表失败", zap.Error(err))
// 不返回错误,允许继续运行
}
if err := db.migrateVulnerabilitiesConversationFK(); err != nil {
db.logger.Warn("迁移vulnerabilities会话外键失败", zap.Error(err))
}
if err := db.migrateProjectsTable(); err != nil {
db.logger.Warn("迁移projects相关表失败", zap.Error(err))
@@ -1134,6 +1150,116 @@ func (db *DB) dropProjectFactVersionsTable() error {
return err
}
// migrateVulnerabilitiesConversationFK 将 vulnerabilities.conversation_id 外键改为 ON DELETE SET NULL,删除对话时保留漏洞记录。
func (db *DB) migrateVulnerabilitiesConversationFK() error {
ok, err := vulnerabilitiesConversationFKOnDeleteSetNull(db.DB)
if err != nil {
return err
}
if ok {
return nil
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开启事务失败: %w", err)
}
defer func() { _ = tx.Rollback() }()
const createNew = `
CREATE TABLE vulnerabilities_new (
id TEXT PRIMARY KEY,
conversation_id TEXT,
conversation_tag TEXT,
task_tag TEXT,
title TEXT NOT NULL,
description TEXT,
severity TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
vulnerability_type TEXT,
target TEXT,
proof TEXT,
impact TEXT,
recommendation TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
project_id TEXT,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
);`
if _, err := tx.Exec(createNew); err != nil {
return fmt.Errorf("创建 vulnerabilities_new 失败: %w", err)
}
const copyRows = `
INSERT INTO vulnerabilities_new (
id, conversation_id, conversation_tag, task_tag, title, description,
severity, status, vulnerability_type, target, proof, impact, recommendation,
created_at, updated_at, project_id
)
SELECT
id, conversation_id, conversation_tag, task_tag, title, description,
severity, status, vulnerability_type, target, proof, impact, recommendation,
created_at, updated_at, project_id
FROM vulnerabilities;`
if _, err := tx.Exec(copyRows); err != nil {
return fmt.Errorf("复制 vulnerabilities 数据失败: %w", err)
}
if _, err := tx.Exec(`DROP TABLE vulnerabilities`); err != nil {
return fmt.Errorf("删除旧 vulnerabilities 表失败: %w", err)
}
if _, err := tx.Exec(`ALTER TABLE vulnerabilities_new RENAME TO vulnerabilities`); err != nil {
return fmt.Errorf("重命名 vulnerabilities 表失败: %w", err)
}
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_id ON vulnerabilities(conversation_id)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_tag ON vulnerabilities(conversation_tag)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_task_tag ON vulnerabilities(task_tag)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_severity ON vulnerabilities(severity)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_status ON vulnerabilities(status)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_created_at ON vulnerabilities(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id)`,
}
for _, stmt := range indexes {
if _, err := tx.Exec(stmt); err != nil {
return fmt.Errorf("重建 vulnerabilities 索引失败: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交 vulnerabilities 外键迁移失败: %w", err)
}
db.logger.Info("vulnerabilities 表已迁移:删除对话时保留漏洞记录")
return nil
}
func vulnerabilitiesConversationFKOnDeleteSetNull(db *sql.DB) (bool, error) {
rows, err := db.Query(`PRAGMA foreign_key_list(vulnerabilities)`)
if err != nil {
return false, err
}
defer rows.Close()
found := false
for rows.Next() {
var id, seq int
var table, from, to, onUpdate, onDelete, match string
if err := rows.Scan(&id, &seq, &table, &from, &to, &onUpdate, &onDelete, &match); err != nil {
return false, err
}
if from == "conversation_id" {
found = true
if !strings.EqualFold(onDelete, "SET NULL") {
return false, nil
}
}
}
if err := rows.Err(); err != nil {
return false, err
}
return found, nil
}
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
func (db *DB) migrateVulnerabilitiesTable() error {
columns := []struct {
+5 -5
View File
@@ -98,7 +98,7 @@ type Vulnerability struct {
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"` // critical, high, medium, low, info
Status string `json:"status"` // open, confirmed, fixed, false_positive
Status string `json:"status"` // open, confirmed, fixed, false_positive, ignored
Type string `json:"type"`
Target string `json:"target"`
Proof string `json:"proof"`
@@ -138,7 +138,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
_, err := db.Exec(
query,
vuln.ID, vuln.ConversationID, nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description,
vuln.ID, nullIfEmpty(vuln.ConversationID), nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description,
vuln.Severity, vuln.Status, vuln.Type, vuln.Target,
vuln.Proof, vuln.Impact, vuln.Recommendation,
vuln.CreatedAt, vuln.UpdatedAt,
@@ -154,7 +154,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
var vuln Vulnerability
query := `
SELECT id, conversation_id, COALESCE(project_id,''), title, description, severity, status,
SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status,
conversation_tag, task_tag, vulnerability_type, target, proof, impact, recommendation,
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
@@ -183,7 +183,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
// ListVulnerabilities 列出漏洞
func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) {
query := `
SELECT id, conversation_id, COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag,
SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag,
vulnerability_type, target, proof, impact, recommendation,
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
@@ -403,7 +403,7 @@ func (db *DB) GetVulnerabilityFilterOptions() (map[string][]string, error) {
if err != nil {
return nil, fmt.Errorf("查询漏洞ID建议失败: %w", err)
}
conversationIDs, err := collect(`SELECT DISTINCT conversation_id FROM vulnerabilities WHERE conversation_id <> '' ORDER BY created_at DESC LIMIT 500`)
conversationIDs, err := collect(`SELECT DISTINCT conversation_id FROM vulnerabilities WHERE conversation_id IS NOT NULL AND conversation_id <> '' ORDER BY created_at DESC LIMIT 500`)
if err != nil {
return nil, fmt.Errorf("查询会话ID建议失败: %w", err)
}
+41
View File
@@ -637,13 +637,26 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
var resultMA *multiagent.RunResult
var errMA error
var transientRunAttempts int
var emptyResponseAttempts int
for {
resultMA, errMA = multiagent.RunEinoSingleChatModelAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
taskCtx, conversationID, resultMA, errMA, &emptyResponseAttempts,
&curHist, &curMsg, segmentUserMessage, progressCallback, nil,
)
if exhaustedEmpty {
errMA = nil
break
}
if handledEmpty {
continue
}
if errMA == nil {
transientRunAttempts = 0
emptyResponseAttempts = 0
break
}
if handled, _ := h.handleEinoTransientRetryContinue(
@@ -673,14 +686,27 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
var resultMA *multiagent.RunResult
var errMA error
var transientRunAttempts int
var emptyResponseAttempts int
for {
resultMA, errMA = multiagent.RunDeepAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, curMsg, curHist, roleTools, progressCallback,
h.agentsMarkdownDir, orchestration, nil, h.projectBlackboardBlock(conversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
taskCtx, conversationID, resultMA, errMA, &emptyResponseAttempts,
&curHist, &curMsg, segmentUserMessage, progressCallback, nil,
)
if exhaustedEmpty {
errMA = nil
break
}
if handledEmpty {
continue
}
if errMA == nil {
transientRunAttempts = 0
emptyResponseAttempts = 0
break
}
if handled, _ := h.handleEinoTransientRetryContinue(
@@ -1159,6 +1185,8 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
}
}
flushResponsePlan()
// 助手正文开始前,推理流通常已结束;落库以便刷新后「渗透测试详情」可回放
flushThinkingStreams()
respPlan.meta = nil
if dataMap, ok := data.(map[string]interface{}); ok {
respPlan.meta = make(map[string]interface{}, len(dataMap))
@@ -1194,6 +1222,19 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
}
if eventType == "response" {
flushResponsePlan()
flushThinkingStreams()
return
}
if eventType == "done" {
flushResponsePlan()
flushThinkingStreams()
return
}
// 流式思考/推理结束:聚合落库(与 eino_agent_reply_stream_end 同理)
if eventType == "thinking_stream_end" || eventType == "reasoning_chain_stream_end" {
flushResponsePlan()
flushThinkingStreams()
return
}
@@ -3,10 +3,14 @@ package handler
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/openai"
"go.uber.org/zap"
)
@@ -46,3 +50,50 @@ func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
}
wg.Wait()
}
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
tmp := t.TempDir()
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer os.RemoveAll(tmp)
conv, err := db.CreateConversation("test", database.ConversationCreateMeta{})
if err != nil {
t.Fatalf("CreateConversation: %v", err)
}
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
if err != nil {
t.Fatalf("AddMessage: %v", err)
}
h := &AgentHandler{logger: zap.NewNop(), db: db}
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
streamID := "eino-reasoning-test-1"
cb("reasoning_chain_stream_start", " ", map[string]interface{}{
"streamId": streamID,
"source": "eino",
})
cb("reasoning_chain_stream_delta", "step one", openai.WithSSEAccumulated(map[string]interface{}{
"streamId": streamID,
}, "step one"))
cb("done", "", map[string]interface{}{"conversationId": conv.ID})
details, err := db.GetProcessDetails(asst.ID)
if err != nil {
t.Fatalf("GetProcessDetails: %v", err)
}
found := false
for _, d := range details {
if d.EventType == "reasoning_chain" && d.Message == "step one" {
found = true
break
}
}
if !found {
t.Fatalf("expected reasoning_chain persisted on done, got %+v", details)
}
}
+105 -58
View File
@@ -298,7 +298,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
}
}
// 获取外部MCP工具
// 获取外部MCP工具(走缓存,持锁期间通常不阻塞)
if h.externalMCPMgr != nil {
ctx := context.Background()
externalTools := h.getExternalMCPTools(ctx)
@@ -359,9 +359,6 @@ type GetToolsResponse struct {
// GetTools 获取工具列表(支持分页和搜索)
func (h *ConfigHandler) GetTools(c *gin.Context) {
h.mu.RLock()
defer h.mu.RUnlock()
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
// 解析分页参数
@@ -407,12 +404,37 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
}
}
includeExternal := true
if v := strings.TrimSpace(strings.ToLower(c.Query("include_external"))); v == "0" || v == "false" || v == "no" {
includeExternal = false
}
refreshExternal := false
if v := strings.TrimSpace(strings.ToLower(c.Query("refresh_external"))); v == "1" || v == "true" || v == "yes" {
refreshExternal = true
}
// 按外部 MCP 名称筛选(MCP 管理页左侧卡片 → 右侧工具列表联动)
externalMCPFilter := strings.TrimSpace(c.Query("external_mcp"))
// 快照配置后立即释放锁,避免外部 MCP 网络 IO 阻塞整个配置子系统
h.mu.RLock()
securityTools := append([]config.ToolConfig(nil), h.config.Security.Tools...)
roles := h.config.Roles
toolDescriptionMode := h.config.Security.ToolDescriptionMode
mcpServer := h.mcpServer
externalMCPMgr := h.externalMCPMgr
h.mu.RUnlock()
pickDesc := func(shortDesc, fullDesc string) string {
return pickToolDescriptionWithMode(toolDescriptionMode, shortDesc, fullDesc)
}
// 解析角色参数,用于过滤工具并标注启用状态
roleName := c.Query("role")
var roleToolsSet map[string]bool // 角色配置的工具集合
var roleUsesAllTools bool = true // 角色是否使用所有工具(默认角色)
if roleName != "" && roleName != "默认" && h.config.Roles != nil {
if role, exists := h.config.Roles[roleName]; exists && role.Enabled {
if roleName != "" && roleName != "默认" && roles != nil {
if role, exists := roles[roleName]; exists && role.Enabled {
if len(role.Tools) > 0 {
// 角色配置了工具列表,只使用这些工具
roleToolsSet = make(map[string]bool)
@@ -426,12 +448,12 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
// 获取所有内部工具并应用搜索过滤
configToolMap := make(map[string]bool)
allTools := make([]ToolConfigInfo, 0, len(h.config.Security.Tools))
for _, tool := range h.config.Security.Tools {
allTools := make([]ToolConfigInfo, 0, len(securityTools))
for _, tool := range securityTools {
configToolMap[tool.Name] = true
toolInfo := ToolConfigInfo{
Name: tool.Name,
Description: h.pickToolDescription(tool.ShortDescription, tool.Description),
Description: pickDesc(tool.ShortDescription, tool.Description),
Enabled: tool.Enabled,
IsExternal: false,
}
@@ -479,15 +501,15 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
}
// 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具)
if h.mcpServer != nil {
mcpTools := h.mcpServer.GetAllTools()
if mcpServer != nil {
mcpTools := mcpServer.GetAllTools()
for _, mcpTool := range mcpTools {
// 跳过已经在配置文件中的工具(避免重复)
if configToolMap[mcpTool.Name] {
continue
}
description := h.pickToolDescription(mcpTool.ShortDescription, mcpTool.Description)
description := pickDesc(mcpTool.ShortDescription, mcpTool.Description)
toolInfo := ToolConfigInfo{
Name: mcpTool.Name,
@@ -534,11 +556,13 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
}
}
// 获取外部MCP工具
if h.externalMCPMgr != nil {
// 创建context用于获取外部工具
// 获取外部MCP工具(可走缓存,不持有 config 锁)
if includeExternal && externalMCPMgr != nil {
if refreshExternal {
externalMCPMgr.InvalidateAllToolCaches()
}
ctx := context.Background()
externalTools := h.getExternalMCPTools(ctx)
externalTools := h.getExternalMCPToolsWithManager(ctx, externalMCPMgr, pickDesc)
// 应用搜索过滤和角色配置
for _, toolInfo := range externalTools {
@@ -585,6 +609,16 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
// 注意:这里我们不直接过滤掉工具,而是保留所有工具,但通过 role_enabled 字段标注状态
// 这样前端可以显示所有工具,并标注哪些工具在当前角色中可用
if externalMCPFilter != "" {
filtered := make([]ToolConfigInfo, 0)
for _, tool := range allTools {
if tool.IsExternal && tool.ExternalMCP == externalMCPFilter {
filtered = append(filtered, tool)
}
}
allTools = filtered
}
// 统一按名称排序后再分页,避免配置文件中顺序导致「全部」与「仅已启用」前几页看起来完全一致
sort.SliceStable(allTools, func(i, j int) bool {
key := func(t ToolConfigInfo) string {
@@ -1906,50 +1940,52 @@ func setFloatInMap(mapNode *yaml.Node, key string, value float64) {
}
// getExternalMCPTools 获取外部MCP工具列表(公共方法)
// 返回 ToolConfigInfo 列表,已处理启用状态和描述信息
func (h *ConfigHandler) getExternalMCPTools(ctx context.Context) []ToolConfigInfo {
var result []ToolConfigInfo
if h.externalMCPMgr == nil {
return nil
}
return h.getExternalMCPToolsWithManager(ctx, h.externalMCPMgr, h.pickToolDescription)
}
// getExternalMCPToolsWithManager 获取外部 MCP 工具(不持有 config 锁,供 GetTools 等热路径使用)
func (h *ConfigHandler) getExternalMCPToolsWithManager(
ctx context.Context,
mgr *mcp.ExternalMCPManager,
pickDesc func(shortDesc, fullDesc string) string,
) []ToolConfigInfo {
var result []ToolConfigInfo
if mgr == nil {
return result
}
// 使用较短的超时时间(5秒)进行快速失败,避免阻塞页面加载
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
externalTools, err := h.externalMCPMgr.GetAllTools(timeoutCtx)
externalTools, err := mgr.GetAllTools(timeoutCtx)
if err != nil {
// 记录警告但不阻塞,继续返回已缓存的工具(如果有)
h.logger.Warn("获取外部MCP工具失败(可能连接断开),尝试返回缓存的工具",
zap.Error(err),
zap.String("hint", "如果外部MCP工具未显示,请检查连接状态或点击刷新按钮"),
)
}
// 如果获取到了工具(即使有错误),继续处理
if len(externalTools) == 0 {
return result
}
externalMCPConfigs := h.externalMCPMgr.GetConfigs()
externalMCPConfigs := mgr.GetConfigs()
for _, externalTool := range externalTools {
// 解析工具名称:mcpName::toolName
mcpName, actualToolName := h.parseExternalToolName(externalTool.Name)
if mcpName == "" || actualToolName == "" {
continue // 跳过格式不正确的工具
continue
}
// 计算启用状态
enabled := h.calculateExternalToolEnabled(mcpName, actualToolName, externalMCPConfigs)
// 处理描述信息
description := h.pickToolDescription(externalTool.ShortDescription, externalTool.Description)
enabled := h.calculateExternalToolEnabledWithManager(mcpName, actualToolName, externalMCPConfigs, mgr)
result = append(result, ToolConfigInfo{
Name: actualToolName,
Description: description,
Description: pickDesc(externalTool.ShortDescription, externalTool.Description),
Enabled: enabled,
IsExternal: true,
ExternalMCP: mcpName,
@@ -1970,40 +2006,48 @@ func (h *ConfigHandler) parseExternalToolName(fullName string) (mcpName, toolNam
// calculateExternalToolEnabled 计算外部工具的启用状态
func (h *ConfigHandler) calculateExternalToolEnabled(mcpName, toolName string, configs map[string]config.ExternalMCPServerConfig) bool {
return h.calculateExternalToolEnabledWithManager(mcpName, toolName, configs, h.externalMCPMgr)
}
func (h *ConfigHandler) calculateExternalToolEnabledWithManager(
mcpName, toolName string,
configs map[string]config.ExternalMCPServerConfig,
mgr *mcp.ExternalMCPManager,
) bool {
cfg, exists := configs[mcpName]
if !exists {
return false
}
// 首先检查外部MCP是否启用
if !cfg.ExternalMCPEnable {
return false // MCP未启用,所有工具都禁用
return false
}
// MCP已启用,检查单个工具的启用状态
// 如果ToolEnabled为空或未设置该工具,默认为启用(向后兼容)
if cfg.ToolEnabled == nil {
// 未设置工具状态,默认为启用
} else if toolEnabled, exists := cfg.ToolEnabled[toolName]; exists {
// 使用配置的工具状态
if !toolEnabled {
if cfg.ToolEnabled != nil {
if toolEnabled, exists := cfg.ToolEnabled[toolName]; exists && !toolEnabled {
return false
}
}
// 工具未在配置中,默认为启用
// 最后检查外部MCP是否已连接
client, exists := h.externalMCPMgr.GetClient(mcpName)
if mgr == nil {
return false
}
client, exists := mgr.GetClient(mcpName)
if !exists || !client.IsConnected() {
return false // 未连接时视为禁用
return false
}
return true
}
// pickToolDescription 根据 security.tool_description_mode 选择 short 或 full 描述并限制长度
// pickToolDescription 根据 security.tool_description_mode 选择 short 或 full 描述并限制长度
// 调用方若已持有 h.mu 读锁,须直接读 mode 并调用 pickToolDescriptionWithMode,避免嵌套 RLock 死锁。
func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
useFull := strings.TrimSpace(strings.ToLower(h.config.Security.ToolDescriptionMode)) == "full"
return pickToolDescriptionWithMode(h.config.Security.ToolDescriptionMode, shortDesc, fullDesc)
}
func pickToolDescriptionWithMode(mode, shortDesc, fullDesc string) string {
useFull := strings.TrimSpace(strings.ToLower(mode)) == "full"
description := shortDesc
if useFull {
description = fullDesc
@@ -2018,23 +2062,22 @@ func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
// 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 {
h.mu.RLock()
externalMCPMgr := h.externalMCPMgr
h.mu.RUnlock()
if externalMCPMgr != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
externalTools, _ := h.externalMCPMgr.GetAllTools(ctx)
externalTools, _ := externalMCPMgr.GetAllTools(ctx)
fullName := externalMCP + "::" + toolName
for _, t := range externalTools {
if t.Name == fullName {
@@ -2047,8 +2090,12 @@ func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
return
}
// 内部工具:从 YAML 配置的 Parameters 构建
for _, tool := range h.config.Security.Tools {
h.mu.RLock()
securityTools := append([]config.ToolConfig(nil), h.config.Security.Tools...)
mcpServer := h.mcpServer
h.mu.RUnlock()
for _, tool := range securityTools {
if tool.Name == toolName {
c.JSON(http.StatusOK, gin.H{"input_schema": buildInputSchemaFromParams(tool.Parameters)})
return
@@ -2056,8 +2103,8 @@ func (h *ConfigHandler) GetToolSchema(c *gin.Context) {
}
// MCP 注册工具(如知识检索)
if h.mcpServer != nil {
for _, mt := range h.mcpServer.GetAllTools() {
if mcpServer != nil {
for _, mt := range mcpServer.GetAllTools() {
if mt.Name == toolName {
c.JSON(http.StatusOK, gin.H{"input_schema": mt.InputSchema})
return
+58
View File
@@ -9,6 +9,8 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/multiagent"
"go.uber.org/zap"
)
func (h *AgentHandler) einoRunRetryMaxAttempts() int {
@@ -120,3 +122,59 @@ func (h *AgentHandler) handleEinoTransientRetryContinue(
}
return true, nil
}
// handleEinoEmptyResponseContinue 在 SSE 任务循环内处理「正常结束但无助手正文」;返回 exhausted=true 时由外层按成功结束(保留占位文案)。
// 与临时错误重试一致:仅恢复轨迹并保留本请求原始 user 文案,不向模型注入续跑说明。
func (h *AgentHandler) handleEinoEmptyResponseContinue(
baseCtx context.Context,
conversationID string,
result *multiagent.RunResult,
runErr error,
emptyResponseAttempts *int,
curHistory *[]agent.ChatMessage,
curFinalMessage *string,
segmentUserMessage string,
progressCallback func(eventType, message string, data interface{}),
sendProgress func(msg string, extra map[string]interface{}),
) (handled bool, exhausted bool) {
if !errors.Is(runErr, multiagent.ErrEmptyResponseContinue) {
return false, false
}
maxAttempts := h.einoRunRetryMaxAttempts()
*emptyResponseAttempts++
if *emptyResponseAttempts > maxAttempts {
if h.logger != nil {
h.logger.Warn("eino empty response auto resume exhausted",
zap.String("conversationId", conversationID),
zap.Int("maxAttempts", maxAttempts))
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(conversationID, result)
}
return false, true
}
attemptNo := *emptyResponseAttempts
if h.logger != nil {
h.logger.Info("eino empty response, auto resume from trace",
zap.String("conversationId", conversationID),
zap.Int("attempt", attemptNo),
zap.Int("maxAttempts", maxAttempts))
}
if progressCallback != nil {
progressCallback("eino_empty_response_continue", fmt.Sprintf("未捕获到助手正文,正在基于轨迹自动续跑(%d/%d)…", attemptNo, maxAttempts), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"attempt": attemptNo,
"maxAttempts": maxAttempts,
"resumeKind": "trace_segment",
})
}
h.applyEinoTransientRetrySegment(conversationID, result, curHistory, curFinalMessage, segmentUserMessage)
if sendProgress != nil {
sendProgress("已恢复上下文,正在继续推理…", map[string]interface{}{
"conversationId": conversationID,
"source": "empty_response_continue",
})
}
return true, false
}
+67 -15
View File
@@ -178,6 +178,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
var cumulativeMCPExecutionIDs []string
var transientRunAttempts int
var emptyResponseAttempts int
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int
@@ -237,9 +238,32 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
}
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
baseCtx, conversationID, result, runErr, &emptyResponseAttempts,
&curHistory, &curFinalMessage, segmentUserMessage, progressCallback,
func(msg string, extra map[string]interface{}) { sendEvent("progress", msg, extra) },
)
if exhaustedEmpty {
runErr = nil
transientRunAttempts = 0
timeoutCancel()
break
}
if handledEmpty {
mainIterationOffset += segmentMainIterationMax
transientRunAttempts = 0
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
continue
}
if runErr == nil {
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
transientRunAttempts = 0
emptyResponseAttempts = 0
timeoutCancel()
break
}
@@ -418,21 +442,49 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
return
}
result, runErr := multiagent.RunEinoSingleChatModelAgent(
taskCtx,
h.config,
&h.config.MultiAgent,
h.agent,
h.logger,
prep.ConversationID,
prep.FinalMessage,
prep.History,
prep.RoleTools,
progressCallback,
chatReasoningToClientIntent(req.Reasoning),
h.projectBlackboardBlock(prep.ConversationID),
)
if runErr != nil {
curHist := prep.History
curMsg := prep.FinalMessage
var result *multiagent.RunResult
var runErr error
var transientRunAttempts int
var emptyResponseAttempts int
for {
result, runErr = multiagent.RunEinoSingleChatModelAgent(
taskCtx,
h.config,
&h.config.MultiAgent,
h.agent,
h.logger,
prep.ConversationID,
curMsg,
curHist,
prep.RoleTools,
progressCallback,
chatReasoningToClientIntent(req.Reasoning),
h.projectBlackboardBlock(prep.ConversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
baseCtx, prep.ConversationID, result, runErr, &emptyResponseAttempts,
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
)
if exhaustedEmpty {
runErr = nil
break
}
if handledEmpty {
continue
}
if runErr == nil {
break
}
if handled, fatalErr := h.handleEinoTransientRetryContinue(
baseCtx, prep.ConversationID, result, runErr, &transientRunAttempts,
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
); handled {
continue
} else if fatalErr != nil {
runErr = fatalErr
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
}
+10 -11
View File
@@ -64,10 +64,7 @@ func (h *ExternalMCPHandler) GetExternalMCPs(c *gin.Context) {
}
toolCount := toolCounts[name]
errorMsg := ""
if status == "error" {
errorMsg = h.manager.GetError(name)
}
errorMsg := externalMCPStatusError(h.manager, name, status)
result[name] = ExternalMCPResponse{
Config: cfg,
@@ -115,20 +112,22 @@ func (h *ExternalMCPHandler) GetExternalMCP(c *gin.Context) {
}
}
// 获取错误信息
errorMsg := ""
if status == "error" {
errorMsg = h.manager.GetError(name)
}
c.JSON(http.StatusOK, ExternalMCPResponse{
Config: cfg,
Status: status,
ToolCount: toolCount,
Error: errorMsg,
Error: externalMCPStatusError(h.manager, name, status),
})
}
// externalMCPStatusError 在 error/disconnected 状态下返回最近错误(含断连原因)。
func externalMCPStatusError(manager *mcp.ExternalMCPManager, name, status string) string {
if status != "error" && status != "disconnected" {
return ""
}
return manager.GetError(name)
}
// AddOrUpdateExternalMCP 添加或更新外部MCP配置
func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
var req AddOrUpdateExternalMCPRequest
+10
View File
@@ -271,6 +271,16 @@ func TestExternalMCPHandler_DeleteExternalMCP(t *testing.T) {
}
}
func TestExternalMCPStatusError(t *testing.T) {
manager := mcp.NewExternalMCPManager(zap.NewNop())
if got := externalMCPStatusError(manager, "x", "connected"); got != "" {
t.Fatalf("connected status should not return error, got %q", got)
}
if got := externalMCPStatusError(manager, "x", "connecting"); got != "" {
t.Fatalf("connecting status should not return error, got %q", got)
}
}
func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
router, handler, _ := setupTestRouter()
+36 -4
View File
@@ -77,8 +77,8 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
// 解析状态筛选参数
status := c.Query("status")
// 解析工具筛选参数
toolName := c.Query("tool")
// 解析工具筛选参数(兼容 mcp__tool 与内部 mcp::tool
toolName := normalizeToolNameFilter(c.Query("tool"))
executions, total := h.loadExecutionsWithPagination(page, pageSize, status, toolName)
stats := h.loadStats()
@@ -113,7 +113,7 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
for _, exec := range allExecutions {
matchStatus := status == "" || exec.Status == status
// 支持部分匹配(模糊搜索)
matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName))
matchTool := toolNameFilterMatches(exec.ToolName, toolName)
if matchStatus && matchTool {
filtered = append(filtered, exec)
}
@@ -143,7 +143,7 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
for _, exec := range allExecutions {
matchStatus := status == "" || exec.Status == status
// 支持部分匹配(模糊搜索)
matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName))
matchTool := toolNameFilterMatches(exec.ToolName, toolName)
if matchStatus && matchTool {
filtered = append(filtered, exec)
}
@@ -584,3 +584,35 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
h.logger.Info("尝试批量删除内存中的执行记录", zap.Int("count", len(request.IDs)))
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"})
}
// normalizeToolNameFilter 将模型侧 mcp__tool 转为内部存储用的 mcp::tool。
func normalizeToolNameFilter(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return name
}
if strings.Contains(name, "::") {
return name
}
if idx := strings.Index(name, "__"); idx > 0 {
return name[:idx] + "::" + name[idx+2:]
}
return name
}
func toolNameFilterMatches(storedName, filter string) bool {
filter = strings.TrimSpace(filter)
if filter == "" {
return true
}
storedLower := strings.ToLower(storedName)
filterLower := strings.ToLower(filter)
if strings.Contains(storedLower, filterLower) {
return true
}
normFilter := strings.ToLower(normalizeToolNameFilter(filter))
if normFilter != filterLower && strings.Contains(storedLower, normFilter) {
return true
}
return strings.Contains(strings.ReplaceAll(storedLower, "::", "__"), filterLower)
}
+69 -17
View File
@@ -188,6 +188,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
// 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表
var cumulativeMCPExecutionIDs []string
var transientRunAttempts int
var emptyResponseAttempts int
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int
@@ -249,9 +250,32 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
}
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
baseCtx, conversationID, result, runErr, &emptyResponseAttempts,
&curHistory, &curFinalMessage, segmentUserMessage, progressCallback,
func(msg string, extra map[string]interface{}) { sendEvent("progress", msg, extra) },
)
if exhaustedEmpty {
runErr = nil
transientRunAttempts = 0
timeoutCancel()
break
}
if handledEmpty {
mainIterationOffset += segmentMainIterationMax
transientRunAttempts = 0
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
continue
}
if runErr == nil {
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
transientRunAttempts = 0
emptyResponseAttempts = 0
timeoutCancel()
break
}
@@ -430,23 +454,51 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
})
result, runErr := multiagent.RunDeepAgent(
taskCtx,
h.config,
&h.config.MultiAgent,
h.agent,
h.logger,
prep.ConversationID,
prep.FinalMessage,
prep.History,
prep.RoleTools,
progressCallback,
h.agentsMarkdownDir,
strings.TrimSpace(req.Orchestration),
chatReasoningToClientIntent(req.Reasoning),
h.projectBlackboardBlock(prep.ConversationID),
)
if runErr != nil {
curHist := prep.History
curMsg := prep.FinalMessage
var result *multiagent.RunResult
var runErr error
var transientRunAttempts int
var emptyResponseAttempts int
for {
result, runErr = multiagent.RunDeepAgent(
taskCtx,
h.config,
&h.config.MultiAgent,
h.agent,
h.logger,
prep.ConversationID,
curMsg,
curHist,
prep.RoleTools,
progressCallback,
h.agentsMarkdownDir,
strings.TrimSpace(req.Orchestration),
chatReasoningToClientIntent(req.Reasoning),
h.projectBlackboardBlock(prep.ConversationID),
)
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
baseCtx, prep.ConversationID, result, runErr, &emptyResponseAttempts,
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
)
if exhaustedEmpty {
runErr = nil
break
}
if handledEmpty {
continue
}
if runErr == nil {
break
}
if handled, fatalErr := h.handleEinoTransientRetryContinue(
baseCtx, prep.ConversationID, result, runErr, &transientRunAttempts,
&curHist, &curMsg, prep.FinalMessage, progressCallback, nil,
); handled {
continue
} else if fatalErr != nil {
runErr = fatalErr
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
}
+3 -3
View File
@@ -237,7 +237,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"status": map[string]interface{}{
"type": "string",
"description": "状态",
"enum": []string{"open", "closed", "fixed"},
"enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
},
"target": map[string]interface{}{
"type": "string",
@@ -575,7 +575,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"status": map[string]interface{}{
"type": "string",
"description": "状态",
"enum": []string{"open", "closed", "fixed"},
"enum": []string{"open", "confirmed", "fixed", "false_positive", "ignored"},
},
"type": map[string]interface{}{
"type": "string",
@@ -1344,7 +1344,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"delete": map[string]interface{}{
"tags": []string{"对话管理"},
"summary": "删除对话",
"description": "删除指定的对话及其所有相关数据(消息、漏洞等)。**此操作不可恢复**。",
"description": "删除指定的对话及其会话数据(消息、攻击链等)。**漏洞记录会保留**,仅解除与会话的关联。**此操作不可恢复**。",
"operationId": "deleteConversation",
"parameters": []map[string]interface{}{
{
+12 -2
View File
@@ -12,6 +12,16 @@ import (
"go.uber.org/zap"
)
const maxProjectDescriptionRunes = 4000
func clampProjectDescription(s string) string {
r := []rune(s)
if len(r) <= maxProjectDescriptionRunes {
return s
}
return string(r[:maxProjectDescriptionRunes])
}
// ProjectHandler 项目管理处理器。
type ProjectHandler struct {
db *database.DB
@@ -48,7 +58,7 @@ func (h *ProjectHandler) CreateProject(c *gin.Context) {
}
p := &database.Project{
Name: strings.TrimSpace(req.Name),
Description: req.Description,
Description: clampProjectDescription(req.Description),
ScopeJSON: req.ScopeJSON,
Status: strings.TrimSpace(req.Status),
}
@@ -184,7 +194,7 @@ func (h *ProjectHandler) UpdateProject(c *gin.Context) {
}
}
if req.Description != nil {
p.Description = *req.Description
p.Description = clampProjectDescription(*req.Description)
}
if req.ScopeJSON != nil {
p.ScopeJSON = *req.ScopeJSON
+17
View File
@@ -190,6 +190,23 @@ func (c *lazySDKClient) Close() error {
return nil
}
// markDisconnected 在检测到传输层断连时关闭底层 session,避免 IsConnected 仍返回 true。
func (c *lazySDKClient) markDisconnected() {
c.mu.Lock()
inner := c.inner
sessionCancel := c.sessionCancel
c.inner = nil
c.sessionCancel = nil
c.mu.Unlock()
if sessionCancel != nil {
sessionCancel()
}
if inner != nil {
_ = inner.Close()
}
c.setStatus("disconnected")
}
func (c *sdkClient) setStatus(s string) {
c.mu.Lock()
defer c.mu.Unlock()
+192
View File
@@ -0,0 +1,192 @@
package mcp
import (
"context"
"errors"
"io"
"strings"
"time"
"go.uber.org/zap"
)
const (
// externalReconnectMinInterval 两次自动重连之间的最短间隔
externalReconnectMinInterval = 30 * time.Second
// externalReconnectMaxBackoff 指数退避上限
externalReconnectMaxBackoff = 5 * time.Minute
)
// isConnectionDeadError 判断错误是否表示底层传输已断开(而非调用方主动取消或超时)。
func isConnectionDeadError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
if errors.Is(err, io.EOF) {
return true
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "eof") ||
strings.Contains(s, "client is closing") ||
strings.Contains(s, "connection closed") ||
strings.Contains(s, "connection reset") ||
strings.Contains(s, "broken pipe")
}
// handleConnectionDead 在 ListTools/CallTool 等操作失败且判定为断连时,标记客户端并调度重连。
func (m *ExternalMCPManager) handleConnectionDead(name string, client ExternalMCPClient, err error) {
if !isConnectionDeadError(err) {
return
}
m.logger.Warn("检测到外部MCP连接已断开,将尝试自动重连",
zap.String("name", name),
zap.Error(err),
)
m.markClientDisconnected(name, client, err)
m.scheduleReconnect(name)
}
func (m *ExternalMCPManager) markClientDisconnected(name string, client ExternalMCPClient, err error) {
if lazy, ok := client.(*lazySDKClient); ok {
lazy.markDisconnected()
}
m.mu.Lock()
if err != nil {
m.errors[name] = "连接已断开: " + err.Error()
}
m.mu.Unlock()
m.toolCountsMu.Lock()
m.toolCounts[name] = 0
m.toolCountsMu.Unlock()
}
func (m *ExternalMCPManager) onClientConnected(name string) {
m.clearReconnectState(name)
}
func (m *ExternalMCPManager) clearReconnectState(name string) {
m.reconnectMu.Lock()
delete(m.reconnectAttempts, name)
delete(m.reconnectLastTry, name)
delete(m.reconnecting, name)
m.reconnectMu.Unlock()
}
func (m *ExternalMCPManager) reconnectBackoff(attempts int) time.Duration {
if attempts <= 0 {
return 0
}
d := externalReconnectMinInterval
for i := 1; i < attempts && d < externalReconnectMaxBackoff; i++ {
d *= 2
}
if d > externalReconnectMaxBackoff {
return externalReconnectMaxBackoff
}
return d
}
func (m *ExternalMCPManager) scheduleReconnect(name string) {
m.mu.RLock()
cfg, exists := m.configs[name]
enabled := exists && m.isEnabled(cfg)
m.mu.RUnlock()
if !enabled {
return
}
go m.tryReconnect(name)
}
func (m *ExternalMCPManager) tryReconnect(name string) {
m.reconnectMu.Lock()
if m.reconnecting[name] {
m.reconnectMu.Unlock()
return
}
attempts := m.reconnectAttempts[name]
if wait := m.reconnectBackoff(attempts); wait > 0 {
if last, ok := m.reconnectLastTry[name]; ok {
if elapsed := time.Since(last); elapsed < wait {
remaining := wait - elapsed
m.reconnectMu.Unlock()
m.scheduleReconnectAfter(name, remaining)
return
}
}
}
m.reconnecting[name] = true
m.reconnectMu.Unlock()
defer func() {
m.reconnectMu.Lock()
delete(m.reconnecting, name)
m.reconnectMu.Unlock()
}()
m.mu.RLock()
cfg, exists := m.configs[name]
enabled := exists && m.isEnabled(cfg)
client, hasClient := m.clients[name]
connecting := hasClient && client.GetStatus() == "connecting"
m.mu.RUnlock()
if !enabled {
m.logger.Debug("跳过自动重连(外部MCP已停用)", zap.String("name", name))
return
}
if connecting {
m.logger.Debug("跳过自动重连(连接正在进行中)", zap.String("name", name))
return
}
m.reconnectMu.Lock()
m.reconnectLastTry[name] = time.Now()
m.reconnectAttempts[name] = attempts + 1
attemptNum := m.reconnectAttempts[name]
m.reconnectMu.Unlock()
m.logger.Info("正在自动重连外部MCP",
zap.String("name", name),
zap.Int("attempt", attemptNum),
)
if err := m.startClient(name, true); err != nil {
m.logger.Warn("自动重连外部MCP失败",
zap.String("name", name),
zap.Error(err),
)
}
}
// scheduleReconnectAfterFailure 在自动重连失败后,按当前退避间隔预约下一次重试。
func (m *ExternalMCPManager) scheduleReconnectAfterFailure(name string) {
m.mu.RLock()
cfg, exists := m.configs[name]
enabled := exists && m.isEnabled(cfg)
m.mu.RUnlock()
if !enabled {
return
}
m.reconnectMu.Lock()
wait := m.reconnectBackoff(m.reconnectAttempts[name])
m.reconnectMu.Unlock()
m.logger.Info("自动重连失败,将按退避间隔再次尝试",
zap.String("name", name),
zap.Duration("after", wait),
)
m.scheduleReconnectAfter(name, wait)
}
// scheduleReconnectAfter 在 delay 后触发 tryReconnectdelay<=0 时立即执行)。
func (m *ExternalMCPManager) scheduleReconnectAfter(name string, delay time.Duration) {
if delay <= 0 {
go m.tryReconnect(name)
return
}
time.AfterFunc(delay, func() {
m.tryReconnect(name)
})
}
+215
View File
@@ -0,0 +1,215 @@
package mcp
import (
"context"
"errors"
"fmt"
"io"
"testing"
"time"
"cyberstrike-ai/internal/config"
"go.uber.org/zap"
)
func TestIsConnectionDeadError(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"eof", io.EOF, true},
{"wrapped eof", fmt.Errorf("connection closed: %w", io.EOF), true},
{"client closing", errors.New(`calling "tools/list": client is closing: EOF`), true},
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
{"canceled", context.Canceled, false},
{"deadline", context.DeadlineExceeded, false},
{"other", errors.New("invalid params"), false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isConnectionDeadError(tc.err); got != tc.want {
t.Fatalf("isConnectionDeadError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
func TestLazySDKClient_MarkDisconnected(t *testing.T) {
c := &lazySDKClient{status: "connected"}
c.inner = &sdkClient{status: "connected"}
c.markDisconnected()
if c.IsConnected() {
t.Fatal("expected disconnected after markDisconnected")
}
if c.GetStatus() != "disconnected" {
t.Fatalf("expected status disconnected, got %s", c.GetStatus())
}
}
func TestHandleConnectionDead_MarksLazyClientDisconnected(t *testing.T) {
logger := zap.NewNop()
m := NewExternalMCPManager(logger)
name := "dead-mcp"
cfg := config.ExternalMCPServerConfig{
Type: "http",
URL: "http://example.com/mcp",
ExternalMCPEnable: true,
}
m.mu.Lock()
m.configs[name] = cfg
client := newLazySDKClient(cfg, logger)
client.inner = &sdkClient{status: "connected"}
client.status = "connected"
m.clients[name] = client
m.mu.Unlock()
deadErr := errors.New(`connection closed: calling "tools/list": client is closing: EOF`)
m.handleConnectionDead(name, client, deadErr)
if client.IsConnected() {
t.Fatal("expected disconnected after handleConnectionDead")
}
if m.GetError(name) == "" {
t.Fatal("expected error message to be recorded")
}
counts := m.GetToolCounts()
if counts[name] != 0 {
t.Fatalf("expected tool count 0 after disconnect, got %d", counts[name])
}
}
func TestReconnectBackoff(t *testing.T) {
t.Parallel()
if d := (&ExternalMCPManager{}).reconnectBackoff(0); d != 0 {
t.Fatalf("attempt 0: got %v", d)
}
if d := (&ExternalMCPManager{}).reconnectBackoff(1); d != externalReconnectMinInterval {
t.Fatalf("attempt 1: got %v", d)
}
if d := (&ExternalMCPManager{}).reconnectBackoff(10); d != externalReconnectMaxBackoff {
t.Fatalf("attempt 10: got %v, want cap %v", d, externalReconnectMaxBackoff)
}
}
func TestTryReconnect_RateLimited(t *testing.T) {
logger := zap.NewNop()
m := NewExternalMCPManager(logger)
name := "rate-limited"
m.reconnectMu.Lock()
m.reconnectLastTry[name] = time.Now()
m.reconnectAttempts[name] = 2
m.reconnectMu.Unlock()
m.tryReconnect(name)
m.reconnectMu.Lock()
attempts := m.reconnectAttempts[name]
m.reconnectMu.Unlock()
if attempts != 2 {
t.Fatalf("rate limited reconnect should not increment attempts, got %d", attempts)
}
}
func TestTryReconnect_SkipsWhenDisabled(t *testing.T) {
logger := zap.NewNop()
m := NewExternalMCPManager(logger)
name := "disabled-mcp"
m.mu.Lock()
m.configs[name] = config.ExternalMCPServerConfig{
Type: "http",
URL: "http://example.com/mcp",
ExternalMCPEnable: false,
}
m.mu.Unlock()
m.tryReconnect(name)
m.reconnectMu.Lock()
attempts := m.reconnectAttempts[name]
m.reconnectMu.Unlock()
if attempts != 0 {
t.Fatalf("disabled MCP should not increment reconnect attempts, got %d", attempts)
}
}
func TestTryReconnect_SkipsWhenConnecting(t *testing.T) {
logger := zap.NewNop()
m := NewExternalMCPManager(logger)
name := "connecting-mcp"
cfg := config.ExternalMCPServerConfig{
Type: "http",
URL: "http://example.com/mcp",
ExternalMCPEnable: true,
}
client := newLazySDKClient(cfg, logger)
client.setStatus("connecting")
m.mu.Lock()
m.configs[name] = cfg
m.clients[name] = client
m.mu.Unlock()
m.tryReconnect(name)
m.reconnectMu.Lock()
attempts := m.reconnectAttempts[name]
m.reconnectMu.Unlock()
if attempts != 0 {
t.Fatalf("connecting MCP should not increment reconnect attempts, got %d", attempts)
}
}
func TestStartClientAutoReconnect_SkipsWhenDisabled(t *testing.T) {
logger := zap.NewNop()
m := NewExternalMCPManager(logger)
m.stopRefresh = make(chan struct{})
name := "stopped"
m.mu.Lock()
m.configs[name] = config.ExternalMCPServerConfig{
Type: "http",
URL: "http://example.com/mcp",
ExternalMCPEnable: false,
}
m.mu.Unlock()
if err := m.startClient(name, true); err != nil {
t.Fatalf("startClient: %v", err)
}
m.mu.RLock()
cfg := m.configs[name]
_, hasClient := m.clients[name]
m.mu.RUnlock()
if cfg.ExternalMCPEnable {
t.Fatal("auto reconnect should not enable stopped MCP")
}
if hasClient {
t.Fatal("auto reconnect should not create client when disabled")
}
}
func TestOnClientConnected_ClearsReconnectState(t *testing.T) {
m := &ExternalMCPManager{
reconnectAttempts: map[string]int{"x": 3},
reconnectLastTry: map[string]time.Time{"x": time.Now()},
reconnecting: map[string]bool{"x": true},
}
m.onClientConnected("x")
m.reconnectMu.Lock()
defer m.reconnectMu.Unlock()
if len(m.reconnectAttempts) != 0 || len(m.reconnectLastTry) != 0 || len(m.reconnecting) != 0 {
t.Fatal("expected reconnect state cleared")
}
}
+217 -76
View File
@@ -15,6 +15,26 @@ import (
"go.uber.org/zap"
)
const (
// externalToolListCacheTTL 已连接外部 MCP 的工具列表缓存有效期,避免每次 API 请求都打远程 ListTools。
externalToolListCacheTTL = 60 * time.Second
// externalToolCountRefreshInterval 后台刷新工具数量的间隔(仅刷新缓存过期或缺失的客户端)。
externalToolCountRefreshInterval = 60 * time.Second
)
// toolListCacheEntry 外部 MCP 工具列表缓存条目
type toolListCacheEntry struct {
tools []Tool
updatedAt time.Time
}
// listToolsInflight 合并同一 MCP 上并发的 ListTools 请求
type listToolsInflight struct {
done chan struct{}
tools []Tool
err error
}
// ExternalMCPManager 外部MCP管理器
type ExternalMCPManager struct {
clients map[string]ExternalMCPClient
@@ -26,14 +46,20 @@ type ExternalMCPManager struct {
errors map[string]string // 错误信息
toolCounts map[string]int // 工具数量缓存
toolCountsMu sync.RWMutex // 工具数量缓存的锁
toolCache map[string][]Tool // 工具列表缓存:MCP名称 -> 工具列表
toolCache map[string]toolListCacheEntry // 工具列表缓存:MCP名称 -> 工具列表
toolCacheMu sync.RWMutex // 工具列表缓存的锁
listToolsMu sync.Mutex
listToolsInflight map[string]*listToolsInflight
stopRefresh chan struct{} // 停止后台刷新的信号
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
mu sync.RWMutex
runningCancels map[string]context.CancelFunc
abortUserNotes map[string]string
runningCancels map[string]context.CancelFunc
abortUserNotes map[string]string
reconnectMu sync.Mutex
reconnecting map[string]bool
reconnectLastTry map[string]time.Time
reconnectAttempts map[string]int
}
// NewExternalMCPManager 创建外部MCP管理器
@@ -51,11 +77,15 @@ func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage
executions: make(map[string]*ToolExecution),
stats: make(map[string]*ToolStats),
errors: make(map[string]string),
toolCounts: make(map[string]int),
toolCache: make(map[string][]Tool),
stopRefresh: make(chan struct{}),
runningCancels: make(map[string]context.CancelFunc),
abortUserNotes: make(map[string]string),
toolCounts: make(map[string]int),
toolCache: make(map[string]toolListCacheEntry),
listToolsInflight: make(map[string]*listToolsInflight),
stopRefresh: make(chan struct{}),
runningCancels: make(map[string]context.CancelFunc),
abortUserNotes: make(map[string]string),
reconnecting: make(map[string]bool),
reconnectLastTry: make(map[string]time.Time),
reconnectAttempts: make(map[string]int),
}
// 启动后台刷新工具数量的goroutine
manager.startToolCountRefresh()
@@ -122,6 +152,7 @@ func (m *ExternalMCPManager) RemoveConfig(name string) error {
}
delete(m.configs, name)
m.clearReconnectState(name)
// 清理工具数量缓存
m.toolCountsMu.Lock()
@@ -136,8 +167,13 @@ func (m *ExternalMCPManager) RemoveConfig(name string) error {
return nil
}
// StartClient 启动客户端
// StartClient 启动客户端(用户手动启动;连接失败不自动重试)
func (m *ExternalMCPManager) StartClient(name string) error {
return m.startClient(name, false)
}
// startClient 启动客户端。autoReconnect 为 true 时用于断连自愈:尊重停用状态,失败后按退避继续重试。
func (m *ExternalMCPManager) startClient(name string, autoReconnect bool) error {
m.mu.Lock()
serverCfg, exists := m.configs[name]
m.mu.Unlock()
@@ -146,6 +182,10 @@ func (m *ExternalMCPManager) StartClient(name string) error {
return fmt.Errorf("配置不存在: %s", name)
}
if autoReconnect && !m.isEnabled(serverCfg) {
return nil
}
// 检查是否已经有连接的客户端
m.mu.RLock()
existingClient, hasClient := m.clients[name]
@@ -155,11 +195,12 @@ func (m *ExternalMCPManager) StartClient(name string) error {
// 检查客户端是否已连接
if existingClient.IsConnected() {
// 客户端已连接,直接返回成功(目标状态已达成)
// 更新配置为启用(确保配置一致)
m.mu.Lock()
serverCfg.ExternalMCPEnable = true
m.configs[name] = serverCfg
m.mu.Unlock()
if !autoReconnect {
m.mu.Lock()
serverCfg.ExternalMCPEnable = true
m.configs[name] = serverCfg
m.mu.Unlock()
}
return nil
}
// 如果有客户端但未连接,先关闭
@@ -169,6 +210,16 @@ func (m *ExternalMCPManager) StartClient(name string) error {
m.mu.Unlock()
}
if autoReconnect {
m.mu.RLock()
serverCfg, exists = m.configs[name]
enabled := exists && m.isEnabled(serverCfg)
m.mu.RUnlock()
if !enabled {
return nil
}
}
// 更新配置为启用
m.mu.Lock()
serverCfg.ExternalMCPEnable = true
@@ -192,10 +243,11 @@ func (m *ExternalMCPManager) StartClient(name string) error {
m.mu.Unlock()
// 在后台异步进行实际连接
go func() {
go func(reconnect bool) {
if err := m.doConnect(name, serverCfg, client); err != nil {
m.logger.Error("连接外部MCP客户端失败",
zap.String("name", name),
zap.Bool("auto_reconnect", reconnect),
zap.Error(err),
)
// 连接失败,设置状态为error并保存错误信息
@@ -205,22 +257,19 @@ func (m *ExternalMCPManager) StartClient(name string) error {
m.mu.Unlock()
// 触发工具数量刷新(连接失败,工具数量应为0)
m.triggerToolCountRefresh()
if reconnect {
m.scheduleReconnectAfterFailure(name)
}
} else {
// 连接成功,清除错误信息
m.mu.Lock()
delete(m.errors, name)
m.mu.Unlock()
// 立即刷新工具数量和工具列表缓存
m.triggerToolCountRefresh()
m.refreshToolCache(name, client)
// 2 秒后再刷新一次,覆盖 SSE/Streamable 等需稍等就绪的远端
go func() {
time.Sleep(2 * time.Second)
m.triggerToolCountRefresh()
m.refreshToolCache(name, client)
}()
m.onClientConnected(name)
// 异步拉取工具列表(singleflight 去重,结果同时写入 toolCache 与 toolCounts
go m.refreshToolCache(name, client)
}
}()
}(autoReconnect)
return nil
}
@@ -249,10 +298,16 @@ func (m *ExternalMCPManager) StopClient(name string) error {
m.toolCounts[name] = 0
m.toolCountsMu.Unlock()
m.toolCacheMu.Lock()
delete(m.toolCache, name)
m.toolCacheMu.Unlock()
// 更新配置为禁用
serverCfg.ExternalMCPEnable = false
m.configs[name] = serverCfg
m.clearReconnectState(name)
return nil
}
@@ -335,16 +390,19 @@ func (m *ExternalMCPManager) getToolsForClient(name string, client ExternalMCPCl
return nil, fmt.Errorf("外部MCP连接失败: %s", name)
}
// 已连接:尝试获取最新工具列表
// 已连接:缓存优先,仅在缺失或过期时打远程 ListTools
if client.IsConnected() {
tools, err := client.ListTools(ctx)
if tools, ok := m.getFreshCachedTools(name); ok {
return tools, nil
}
if tools, ok := m.getAnyCachedTools(name); ok {
m.triggerToolListRefresh(name, client)
return tools, nil
}
tools, err := m.listToolsDeduped(ctx, name, client)
if err != nil {
// 获取失败,尝试使用缓存
return m.getCachedTools(name, "连接正常但获取失败", err)
}
// 获取成功,更新缓存
m.updateToolCache(name, tools)
return tools, nil
}
@@ -361,37 +419,127 @@ func (m *ExternalMCPManager) getToolsForClient(name string, client ExternalMCPCl
return nil, fmt.Errorf("外部MCP状态未知: %s (状态: %s)", name, status)
}
// getCachedTools 获取缓存的工具列表
// getCachedTools 获取缓存的工具列表(含空列表缓存)
func (m *ExternalMCPManager) getCachedTools(name, reason string, originalErr error) ([]Tool, error) {
m.toolCacheMu.RLock()
cachedTools, hasCache := m.toolCache[name]
m.toolCacheMu.RUnlock()
if hasCache && len(cachedTools) > 0 {
if tools, ok := m.getAnyCachedTools(name); ok {
m.logger.Debug("使用缓存的工具列表",
zap.String("name", name),
zap.String("reason", reason),
zap.Int("count", len(cachedTools)),
zap.Int("count", len(tools)),
zap.Error(originalErr),
)
return cachedTools, nil
return tools, nil
}
// 无缓存,返回错误
if originalErr != nil {
return nil, fmt.Errorf("获取外部MCP工具失败且无缓存: %w", originalErr)
}
return nil, fmt.Errorf("外部MCP无缓存工具: %s", name)
}
// updateToolCache 更新工具列表缓存
func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
func (m *ExternalMCPManager) isToolCacheFresh(updatedAt time.Time) bool {
return !updatedAt.IsZero() && time.Since(updatedAt) < externalToolListCacheTTL
}
func cloneTools(tools []Tool) []Tool {
if len(tools) == 0 {
return nil
}
out := make([]Tool, len(tools))
copy(out, tools)
return out
}
func (m *ExternalMCPManager) getFreshCachedTools(name string) ([]Tool, bool) {
m.toolCacheMu.RLock()
entry, ok := m.toolCache[name]
m.toolCacheMu.RUnlock()
if !ok || !m.isToolCacheFresh(entry.updatedAt) {
return nil, false
}
return cloneTools(entry.tools), true
}
func (m *ExternalMCPManager) getAnyCachedTools(name string) ([]Tool, bool) {
m.toolCacheMu.RLock()
entry, ok := m.toolCache[name]
m.toolCacheMu.RUnlock()
if !ok {
return nil, false
}
return cloneTools(entry.tools), true
}
// listToolsDeduped 对同一 MCP 合并并发 ListTools,并更新 toolCache / toolCounts。
func (m *ExternalMCPManager) listToolsDeduped(ctx context.Context, name string, client ExternalMCPClient) ([]Tool, error) {
m.listToolsMu.Lock()
if inflight, exists := m.listToolsInflight[name]; exists {
m.listToolsMu.Unlock()
select {
case <-inflight.done:
if inflight.err != nil {
return nil, inflight.err
}
return cloneTools(inflight.tools), nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
inflight := &listToolsInflight{done: make(chan struct{})}
m.listToolsInflight[name] = inflight
m.listToolsMu.Unlock()
inflight.tools, inflight.err = client.ListTools(ctx)
if inflight.err == nil {
m.updateToolCache(name, inflight.tools)
}
m.listToolsMu.Lock()
delete(m.listToolsInflight, name)
close(inflight.done)
m.listToolsMu.Unlock()
if inflight.err != nil {
m.handleConnectionDead(name, client, inflight.err)
return nil, inflight.err
}
return cloneTools(inflight.tools), nil
}
// InvalidateToolCache 清除指定外部 MCP 的工具列表缓存(手动刷新时使用)
func (m *ExternalMCPManager) InvalidateToolCache(name string) {
m.toolCacheMu.Lock()
m.toolCache[name] = tools
delete(m.toolCache, name)
m.toolCacheMu.Unlock()
}
// InvalidateAllToolCaches 清除所有外部 MCP 工具列表缓存
func (m *ExternalMCPManager) InvalidateAllToolCaches() {
m.toolCacheMu.Lock()
m.toolCache = make(map[string]toolListCacheEntry)
m.toolCacheMu.Unlock()
}
func (m *ExternalMCPManager) triggerToolListRefresh(name string, client ExternalMCPClient) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, _ = m.listToolsDeduped(ctx, name, client)
}()
}
// updateToolCache 更新工具列表缓存与工具数量
func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
stored := cloneTools(tools)
m.toolCacheMu.Lock()
m.toolCache[name] = toolListCacheEntry{tools: stored, updatedAt: time.Now()}
m.toolCacheMu.Unlock()
// 如果返回空列表,记录警告
if len(tools) == 0 {
m.toolCountsMu.Lock()
m.toolCounts[name] = len(stored)
m.toolCountsMu.Unlock()
if len(stored) == 0 {
m.logger.Warn("外部MCP返回空工具列表",
zap.String("name", name),
zap.String("hint", "服务可能暂时不可用,工具列表为空"),
@@ -399,7 +547,7 @@ func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
} else {
m.logger.Debug("工具列表缓存已更新",
zap.String("name", name),
zap.Int("count", len(tools)),
zap.Int("count", len(stored)),
)
}
}
@@ -467,6 +615,9 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
// 调用工具
result, err := client.CallTool(execCtx, actualToolName, args)
if err != nil {
m.handleConnectionDead(mcpName, client, err)
}
cancelledWithUserNote := m.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
// 更新执行记录
@@ -854,28 +1005,27 @@ func (m *ExternalMCPManager) refreshToolCounts() {
return
}
// 使用合理的超时时间(15秒),既能应对网络延迟,又不会过长阻塞
// 由于这是后台异步刷新,超时不会影响前端响应
// 缓存仍新鲜时直接复用,避免与 GetAllTools 重复打远程
if _, fresh := m.getFreshCachedTools(n); fresh {
m.toolCountsMu.RLock()
count := m.toolCounts[n]
m.toolCountsMu.RUnlock()
resultChan <- countResult{name: n, count: count}
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
tools, err := c.ListTools(ctx)
tools, err := m.listToolsDeduped(ctx, n, c)
cancel()
if err != nil {
errStr := err.Error()
// SSE 连接 EOF:远端可能关闭了流或未按规范在流上推送响应,仅首次用 Warn 提示
if strings.Contains(errStr, "EOF") || strings.Contains(errStr, "client is closing") {
m.logger.Warn("获取外部MCP工具数量失败(SSE 流已关闭或服务端未在流上返回 tools/list 响应)",
zap.String("name", n),
zap.String("hint", "若为 SSE 连接,请确认服务端保持 GET 流打开并按 MCP 规范以 event: message 推送 JSON-RPC 响应"),
zap.Error(err),
)
} else {
if !isConnectionDeadError(err) {
m.logger.Warn("获取外部MCP工具数量失败,请检查连接或服务端 tools/list",
zap.String("name", n),
zap.Error(err),
)
}
resultChan <- countResult{name: n, count: -1} // -1 表示使用旧值
resultChan <- countResult{name: n, count: -1}
return
}
@@ -925,33 +1075,21 @@ func (m *ExternalMCPManager) refreshToolCache(name string, client ExternalMCPCli
if !client.IsConnected() {
return
}
// 检查状态,如果是error状态,不更新缓存
status := client.GetStatus()
if status == "error" {
if client.GetStatus() == "error" {
m.logger.Debug("跳过刷新工具列表缓存(连接失败)",
zap.String("name", name),
zap.String("status", status),
)
return
}
// 使用较短的超时时间(5秒)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
tools, err := client.ListTools(ctx)
if err != nil {
if _, err := m.listToolsDeduped(ctx, name, client); err != nil {
m.logger.Debug("刷新工具列表缓存失败",
zap.String("name", name),
zap.Error(err),
)
// 刷新失败时不更新缓存,保留旧缓存(如果有)
return
}
// 使用统一的缓存更新方法
m.updateToolCache(name, tools)
}
// startToolCountRefresh 启动后台刷新工具数量的goroutine
@@ -959,7 +1097,7 @@ func (m *ExternalMCPManager) startToolCountRefresh() {
m.refreshWg.Add(1)
go func() {
defer m.refreshWg.Done()
ticker := time.NewTicker(10 * time.Second) // 每10秒刷新一次
ticker := time.NewTicker(externalToolCountRefreshInterval)
defer ticker.Stop()
// 立即执行一次刷新
@@ -1075,6 +1213,8 @@ func (m *ExternalMCPManager) connectClient(name string, serverCfg config.Externa
zap.String("name", name),
)
m.onClientConnected(name)
// 连接成功,触发工具数量刷新和工具列表缓存刷新
m.triggerToolCountRefresh()
m.mu.RLock()
@@ -1159,6 +1299,7 @@ func (m *ExternalMCPManager) StopAll() {
for name, client := range m.clients {
client.Close()
delete(m.clients, name)
m.clearReconnectState(name)
}
// 清理所有工具数量缓存
@@ -1168,7 +1309,7 @@ func (m *ExternalMCPManager) StopAll() {
// 清理所有工具列表缓存
m.toolCacheMu.Lock()
m.toolCache = make(map[string][]Tool)
m.toolCache = make(map[string]toolListCacheEntry)
m.toolCacheMu.Unlock()
// 停止后台刷新(使用 select 避免重复关闭 channel
+33
View File
@@ -785,6 +785,16 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
}
}
}
if progress != nil && reasoningStreamID != "" && strings.TrimSpace(reasoningBuf) != "" {
progress("reasoning_chain_stream_end", openai.DisplayReasoningContent(strings.TrimSpace(reasoningBuf)), map[string]interface{}{
"streamId": reasoningStreamID,
"conversationId": conversationID,
"source": "eino",
"einoAgent": ev.AgentName,
"einoRole": einoRoleTag(ev.AgentName),
"orchestration": orchMode,
})
}
if streamsMainAssistant(ev.AgentName) {
s := strings.TrimSpace(mainAssistantBuf)
if mainAssistDupTarget != "" {
@@ -1027,9 +1037,32 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
orchMode, runAccumulatedMsgs, persistTraceSource(args, runAccumulatedMsgs),
lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, false,
)
if shouldEinoEmptyResponseContinue(out, emptyHint, len(runAccumulatedMsgs), baseAccumulatedCount) {
if logger != nil {
logger.Info("eino empty response, ending run segment for handler resume",
zap.String("conversationId", conversationID),
zap.String("orchestration", orchMode),
zap.Int("traceMessages", len(runAccumulatedMsgs)))
}
if progress != nil {
progress("eino_empty_response_continue", "会话已结束但未产生助手正文,正在基于轨迹自动续跑…", map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"resumeKind": "trace_segment",
})
}
return out, ErrEmptyResponseContinue
}
return out, nil
}
func shouldEinoEmptyResponseContinue(out *RunResult, emptyHint string, accumulatedLen, baseCount int) bool {
if out == nil || accumulatedLen <= baseCount {
return false
}
return strings.TrimSpace(out.Response) == strings.TrimSpace(emptyHint)
}
func persistTraceSource(args *einoADKRunLoopArgs, fallback []adk.Message) []adk.Message {
if args != nil && args.ModelFacingTrace != nil {
if snap := args.ModelFacingTrace.Snapshot(); len(snap) > 0 {
@@ -0,0 +1,21 @@
package multiagent
import "testing"
func TestShouldEinoEmptyResponseContinue(t *testing.T) {
t.Parallel()
hint := "(empty hint)"
out := &RunResult{Response: hint}
if !shouldEinoEmptyResponseContinue(out, hint, 3, 1) {
t.Fatal("expected continue when response is empty hint and trace grew")
}
if shouldEinoEmptyResponseContinue(out, hint, 1, 1) {
t.Fatal("expected no continue when trace did not grow")
}
if shouldEinoEmptyResponseContinue(&RunResult{Response: "hello"}, hint, 3, 1) {
t.Fatal("expected no continue when response has content")
}
if shouldEinoEmptyResponseContinue(nil, hint, 3, 1) {
t.Fatal("expected no continue for nil result")
}
}
+4 -27
View File
@@ -43,22 +43,6 @@ func sanitizeEinoPathSegment(s string) string {
return s
}
// localPlantaskBackend wraps the eino-ext local backend with plantask.Delete (Local has no Delete).
type localPlantaskBackend struct {
*localbk.Local
}
func (l *localPlantaskBackend) Delete(ctx context.Context, req *plantask.DeleteRequest) error {
if l == nil || l.Local == nil || req == nil {
return nil
}
p := strings.TrimSpace(req.FilePath)
if p == "" {
return nil
}
return os.Remove(p)
}
func splitToolsForToolSearch(all []tool.BaseTool, alwaysVisible int) (static []tool.BaseTool, dynamic []tool.BaseTool, ok bool) {
if alwaysVisible <= 0 || len(all) <= alwaysVisible+1 {
return all, nil, false
@@ -67,14 +51,7 @@ func splitToolsForToolSearch(all []tool.BaseTool, alwaysVisible int) (static []t
}
func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbackAlwaysVisible int) (static []tool.BaseTool, dynamic []tool.BaseTool, ok bool) {
nameSet := make(map[string]struct{}, len(names))
for _, n := range names {
n = strings.TrimSpace(strings.ToLower(n))
if n == "" {
continue
}
nameSet[n] = struct{}{}
}
nameSet := expandAlwaysVisibleNameSet(names)
if len(nameSet) == 0 {
return splitToolsForToolSearch(all, fallbackAlwaysVisible)
}
@@ -87,9 +64,9 @@ func splitToolsForToolSearchByNames(all []tool.BaseTool, names []string, fallbac
info, err := t.Info(context.Background())
name := ""
if err == nil && info != nil {
name = strings.TrimSpace(strings.ToLower(info.Name))
name = info.Name
}
if _, keep := nameSet[name]; keep {
if toolMatchesAlwaysVisible(name, nameSet) {
static = append(static, t)
continue
}
@@ -238,7 +215,7 @@ func prependEinoMiddlewares(
if mk := os.MkdirAll(baseDir, 0o755); mk != nil {
return nil, nil, toolSearchActive, fmt.Errorf("plantask mkdir: %w", mk)
}
ptBE := &localPlantaskBackend{Local: einoLoc}
ptBE := newLocalPlantaskBackend(einoLoc)
pt, perr := plantask.New(ctx, &plantask.Config{Backend: ptBE, BaseDir: baseDir})
if perr != nil {
return nil, nil, toolSearchActive, fmt.Errorf("plantask: %w", perr)
+37 -13
View File
@@ -146,20 +146,27 @@ func newEinoSummarizationMiddleware(
return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
},
Callback: func(ctx context.Context, before, after adk.ChatModelAgentState) error {
if logger == nil {
return nil
if transcriptPath != "" && len(before.Messages) > 0 {
if werr := writeSummarizationTranscript(transcriptPath, before.Messages); werr != nil && logger != nil {
logger.Warn("eino summarization transcript 写入失败",
zap.String("path", transcriptPath),
zap.Error(werr),
)
}
}
if logger != nil {
beforeTokens, _ := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: before.Messages})
afterTokens, _ := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: after.Messages})
logger.Info("eino summarization 已压缩上下文",
zap.Int("messages_before", len(before.Messages)),
zap.Int("messages_after", len(after.Messages)),
zap.Int("tokens_before_estimated", beforeTokens),
zap.Int("tokens_after_estimated", afterTokens),
zap.Int("max_total_tokens", maxTotal),
zap.Int("trigger_context_tokens", trigger),
zap.String("transcript_file", transcriptPath),
)
}
beforeTokens, _ := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: before.Messages})
afterTokens, _ := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: after.Messages})
logger.Info("eino summarization 已压缩上下文",
zap.Int("messages_before", len(before.Messages)),
zap.Int("messages_after", len(after.Messages)),
zap.Int("tokens_before_estimated", beforeTokens),
zap.Int("tokens_after_estimated", afterTokens),
zap.Int("max_total_tokens", maxTotal),
zap.Int("trigger_context_tokens", trigger),
zap.String("transcript_file", transcriptPath),
)
return nil
},
})
@@ -335,6 +342,23 @@ func splitMessagesIntoRounds(msgs []adk.Message) []messageRound {
return rounds
}
// writeSummarizationTranscript persists pre-compaction history for read_file after summarization.
// Eino TranscriptFilePath only embeds the path in summary text; the file must be written by the host app.
func writeSummarizationTranscript(path string, msgs []adk.Message) error {
path = strings.TrimSpace(path)
if path == "" {
return nil
}
body := formatSummarizationTranscript(msgs)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("mkdir transcript dir: %w", err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
return fmt.Errorf("write transcript: %w", err)
}
return nil
}
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
tc := agent.NewTikTokenCounter()
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
@@ -2,6 +2,9 @@ package multiagent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/cloudwego/eino/adk"
@@ -343,3 +346,91 @@ func assertNoOrphanTool(t *testing.T, msgs []adk.Message) {
}
}
}
func TestWriteSummarizationTranscript(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "summarization", "transcript.txt")
msgs := []adk.Message{
schema.UserMessage("scan target"),
assistantToolCallsMsg("", "tc1"),
schema.ToolMessage("nmap output", "tc1"),
}
if err := writeSummarizationTranscript(path, msgs); err != nil {
t.Fatalf("writeSummarizationTranscript: %v", err)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read transcript: %v", err)
}
text := string(body)
if !strings.Contains(text, "Pre-compaction session record") {
t.Fatalf("missing transcript header: %q", text)
}
if !strings.Contains(text, "[user]") || !strings.Contains(text, "scan target") {
t.Fatalf("missing user section: %q", text)
}
if !strings.Contains(text, "tool_calls:") || !strings.Contains(text, "nmap output") {
t.Fatalf("missing tool round: %q", text)
}
}
func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
t.Parallel()
system := strings.Join([]string{
"以下是当前会话绑定的工具名称索引(仅名称,无参数 JSON Schema)。",
"- nmap",
"- nuclei",
"",
"使用规则:",
"1) 上表仅为名称索引",
"5) 不要臆造不存在的工具名。",
"",
"你是CyberStrikeAI,是一个专业的网络安全渗透测试专家。",
"高强度扫描要求:全力出击",
"",
"## 项目黑板索引(project: 123, id: abc",
"(暂无事实)",
"需要写入请使用 upsert_project_fact。",
"",
"# Skills System",
"**How to Use Skills**",
"Remember: Skills make you more capable",
}, "\n")
out := sanitizeSystemContentForTranscript(system)
if strings.Contains(out, "以下是当前会话绑定的工具名称索引") {
t.Fatalf("tool index should be stripped: %q", out)
}
if strings.Contains(out, "- nmap") || strings.Contains(out, "高强度扫描要求") {
t.Fatalf("static persona should be stripped: %q", out)
}
if strings.Contains(out, "# Skills System") || strings.Contains(out, "How to Use Skills") {
t.Fatalf("skills boilerplate should be stripped: %q", out)
}
if !strings.Contains(out, transcriptStaticSystemOmitNote) {
t.Fatalf("missing omission note: %q", out)
}
if !strings.Contains(out, "## 项目黑板索引(project: 123, id: abc") {
t.Fatalf("project blackboard should be kept: %q", out)
}
}
func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) {
t.Parallel()
msgs := []adk.Message{
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n## 项目黑板索引(project: p1, id: x\n(暂无事实)\n# Skills System\nboiler"),
schema.UserMessage("hello"),
schema.AssistantMessage("reply", nil),
}
out := formatSummarizationTranscript(msgs)
if strings.Contains(out, "- nmap") {
t.Fatalf("tool list leaked into transcript: %q", out)
}
if !strings.Contains(out, "hello") || !strings.Contains(out, "reply") {
t.Fatalf("conversation turns missing: %q", out)
}
if !strings.Contains(out, "## 项目黑板索引(project: p1, id: x") {
t.Fatalf("dynamic blackboard missing: %q", out)
}
}
@@ -0,0 +1,145 @@
package multiagent
import (
"strings"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"github.com/bytedance/sonic"
)
const (
transcriptFileHeader = `# CyberStrikeAI summarization transcript
# Pre-compaction session record for read_file after context compression.
# Omits static system/tool-index/skills boilerplate; full user/assistant/tool turns below.
`
transcriptStaticSystemOmitNote = "[static system prompt omitted — unchanged in live context after compaction]"
transcriptToolIndexStartMarker = "以下是当前会话绑定的工具名称索引"
transcriptPersonaStartMarker = "你是CyberStrikeAI"
transcriptSkillsSystemMarker = "# Skills System"
transcriptProjectBlackboardMarker = "## 项目黑板索引"
)
// formatSummarizationTranscript renders pre-compaction messages for transcript.txt.
// Best practice: keep full user/assistant/tool turns; slim system to dynamic blocks only.
func formatSummarizationTranscript(msgs []adk.Message) string {
var sb strings.Builder
sb.WriteString(transcriptFileHeader)
wrote := false
for _, msg := range msgs {
if msg == nil {
continue
}
switch msg.Role {
case schema.System:
body := sanitizeSystemContentForTranscript(msg.Content)
if strings.TrimSpace(body) == "" {
continue
}
if wrote {
sb.WriteString("\n")
}
appendTranscriptSection(&sb, schema.System, body)
wrote = true
default:
if wrote {
sb.WriteString("\n")
}
appendTranscriptMessage(&sb, msg)
wrote = true
}
}
return sb.String()
}
func sanitizeSystemContentForTranscript(content string) string {
content = stripToolNamesIndexFromSystem(content)
content = stripSkillsSystemBoilerplate(content)
blackboard := extractProjectBlackboardSection(content)
var sb strings.Builder
sb.WriteString(transcriptStaticSystemOmitNote)
if bb := strings.TrimSpace(blackboard); bb != "" {
sb.WriteString("\n\n")
sb.WriteString(bb)
}
return sb.String()
}
func stripToolNamesIndexFromSystem(s string) string {
if !strings.Contains(s, transcriptToolIndexStartMarker) {
return s
}
idx := strings.Index(s, transcriptPersonaStartMarker)
if idx < 0 {
return s
}
return strings.TrimSpace(s[idx:])
}
func stripSkillsSystemBoilerplate(s string) string {
idx := strings.Index(s, transcriptSkillsSystemMarker)
if idx < 0 {
return strings.TrimSpace(s)
}
return strings.TrimSpace(s[:idx])
}
func extractProjectBlackboardSection(s string) string {
idx := strings.Index(s, transcriptProjectBlackboardMarker)
if idx < 0 {
return ""
}
return strings.TrimSpace(s[idx:])
}
func appendTranscriptSection(sb *strings.Builder, role schema.RoleType, body string) {
sb.WriteString("--- [")
sb.WriteString(string(role))
sb.WriteString("] ---\n")
sb.WriteString(body)
if !strings.HasSuffix(body, "\n") {
sb.WriteByte('\n')
}
}
func appendTranscriptMessage(sb *strings.Builder, msg adk.Message) {
sb.WriteString("--- [")
sb.WriteString(string(msg.Role))
sb.WriteString("] ---\n")
if msg.Content != "" {
sb.WriteString(msg.Content)
if !strings.HasSuffix(msg.Content, "\n") {
sb.WriteByte('\n')
}
}
if msg.ReasoningContent != "" {
sb.WriteString("[reasoning]\n")
sb.WriteString(msg.ReasoningContent)
if !strings.HasSuffix(msg.ReasoningContent, "\n") {
sb.WriteByte('\n')
}
}
for _, part := range msg.UserInputMultiContent {
if part.Type == schema.ChatMessagePartTypeText && strings.TrimSpace(part.Text) != "" {
sb.WriteString(part.Text)
if !strings.HasSuffix(part.Text, "\n") {
sb.WriteByte('\n')
}
}
}
if len(msg.ToolCalls) > 0 {
if b, err := sonic.Marshal(msg.ToolCalls); err == nil {
sb.WriteString("tool_calls: ")
sb.Write(b)
sb.WriteByte('\n')
}
}
if msg.ToolCallID != "" {
sb.WriteString("tool_call_id: ")
sb.WriteString(msg.ToolCallID)
sb.WriteByte('\n')
}
}
+4
View File
@@ -9,3 +9,7 @@ var ErrInterruptContinue = errors.New("agent interrupt: continue with user-suppl
// ErrTransientRetryContinue 表示 Run 因 429/网络等临时错误结束,应由 handler 落库轨迹后
// loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue 同级的「分段续跑」语义)。
var ErrTransientRetryContinue = errors.New("agent transient: retry after persisting trace")
// ErrEmptyResponseContinue 表示 Eino ADK 会话正常结束但未捕获到助手正文,应由 handler 落库轨迹后
// loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue / ErrTransientRetryContinue 同级)。
var ErrEmptyResponseContinue = errors.New("agent empty response: continue after persisting trace")
@@ -0,0 +1,71 @@
package multiagent
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
localbk "github.com/cloudwego/eino-ext/adk/backend/local"
"github.com/cloudwego/eino/adk/middlewares/plantask"
)
// localPlantaskBackend adapts eino-ext local filesystem backend for Eino plantask.
//
// plantask TaskCreate/TaskList list a directory via LsInfo, then Read using each entry's Path.
// local.LsInfo returns basenames only (e.g. ".highwatermark"), while local.Read expects a
// resolvable path — causing "file not found: .highwatermark" on the second TaskCreate.
type localPlantaskBackend struct {
*localbk.Local
}
func newLocalPlantaskBackend(loc *localbk.Local) *localPlantaskBackend {
if loc == nil {
return nil
}
return &localPlantaskBackend{Local: loc}
}
// LsInfo lists files under req.Path and returns absolute paths suitable for subsequent Read calls.
func (l *localPlantaskBackend) LsInfo(ctx context.Context, req *plantask.LsInfoRequest) ([]plantask.FileInfo, error) {
if l == nil || l.Local == nil {
return nil, fmt.Errorf("plantask backend: local nil")
}
if req == nil || strings.TrimSpace(req.Path) == "" {
return nil, fmt.Errorf("plantask backend: list path empty")
}
files, err := l.Local.LsInfo(ctx, req)
if err != nil {
return nil, err
}
if len(files) == 0 {
return files, nil
}
base := filepath.Clean(req.Path)
out := make([]plantask.FileInfo, len(files))
for i, f := range files {
out[i] = f
name := strings.TrimSpace(f.Path)
if name == "" {
continue
}
if filepath.IsAbs(name) {
out[i].Path = filepath.Clean(name)
continue
}
out[i].Path = filepath.Join(base, name)
}
return out, nil
}
func (l *localPlantaskBackend) Delete(ctx context.Context, req *plantask.DeleteRequest) error {
if l == nil || l.Local == nil || req == nil {
return nil
}
p := strings.TrimSpace(req.FilePath)
if p == "" {
return nil
}
return os.Remove(p)
}
@@ -0,0 +1,83 @@
package multiagent
import (
"context"
"os"
"path/filepath"
"testing"
localbk "github.com/cloudwego/eino-ext/adk/backend/local"
"github.com/cloudwego/eino/adk/filesystem"
"github.com/cloudwego/eino/adk/middlewares/plantask"
)
func TestLocalPlantaskBackendLsInfoReturnsFullPaths(t *testing.T) {
t.Parallel()
ctx := context.Background()
baseDir := t.TempDir()
loc, err := localbk.NewBackend(ctx, &localbk.Config{})
if err != nil {
t.Fatalf("NewBackend: %v", err)
}
be := newLocalPlantaskBackend(loc)
hwPath := filepath.Join(baseDir, ".highwatermark")
if err := os.WriteFile(hwPath, []byte("1"), 0o600); err != nil {
t.Fatalf("write highwatermark: %v", err)
}
files, err := be.LsInfo(ctx, &plantask.LsInfoRequest{Path: baseDir})
if err != nil {
t.Fatalf("LsInfo: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if files[0].Path != hwPath {
t.Fatalf("expected full path %q, got %q", hwPath, files[0].Path)
}
content, err := be.Read(ctx, &plantask.ReadRequest{FilePath: files[0].Path})
if err != nil {
t.Fatalf("Read via LsInfo path: %v", err)
}
if content.Content != "1" {
t.Fatalf("unexpected content: %q", content.Content)
}
}
func TestLocalPlantaskBackendSecondTaskCreateScenario(t *testing.T) {
t.Parallel()
ctx := context.Background()
baseDir := t.TempDir()
loc, err := localbk.NewBackend(ctx, &localbk.Config{})
if err != nil {
t.Fatalf("NewBackend: %v", err)
}
be := newLocalPlantaskBackend(loc)
hwPath := filepath.Join(baseDir, ".highwatermark")
if err := loc.Write(ctx, &filesystem.WriteRequest{FilePath: hwPath, Content: "1"}); err != nil {
t.Fatalf("seed highwatermark: %v", err)
}
files, err := be.LsInfo(ctx, &plantask.LsInfoRequest{Path: baseDir})
if err != nil {
t.Fatalf("LsInfo: %v", err)
}
var hwFile string
for _, f := range files {
if filepath.Base(f.Path) == ".highwatermark" {
hwFile = f.Path
break
}
}
if hwFile == "" {
t.Fatal("highwatermark not listed")
}
if _, err := be.Read(ctx, &plantask.ReadRequest{FilePath: hwFile}); err != nil {
t.Fatalf("Read highwatermark (second TaskCreate path): %v", err)
}
}
@@ -0,0 +1,72 @@
package multiagent
import (
"strings"
)
// expandAlwaysVisibleNameSet 将配置中的常驻工具名展开为可匹配运行时工具名的集合。
// 支持:内置短名 read_file;外部 mcp::tool;运行时 mcp__toolOpenAI/Eino 命名)。
func expandAlwaysVisibleNameSet(names []string) map[string]struct{} {
set := make(map[string]struct{}, len(names)*3)
add := func(name string) {
n := strings.TrimSpace(strings.ToLower(name))
if n == "" {
return
}
set[n] = struct{}{}
}
for _, raw := range names {
n := strings.TrimSpace(strings.ToLower(raw))
if n == "" {
continue
}
add(n)
if mcp, tool, ok := strings.Cut(n, "::"); ok && mcp != "" && tool != "" {
// 外部工具用 mcp::tool 配置时只展开运行时 mcp__tool,避免短名误伤其它 MCP 同名工具。
add(mcp + "__" + tool)
continue
}
if idx := strings.LastIndex(n, "__"); idx > 0 {
mcp, tool := n[:idx], n[idx+2:]
if mcp != "" && tool != "" {
add(mcp + "::" + tool)
}
continue
}
}
return set
}
// toolMatchesAlwaysVisible 判断运行时工具名是否命中常驻白名单(含别名)。
func toolMatchesAlwaysVisible(runtimeName string, nameSet map[string]struct{}) bool {
if len(nameSet) == 0 {
return false
}
name := strings.TrimSpace(strings.ToLower(runtimeName))
if name == "" {
return false
}
if _, ok := nameSet[name]; ok {
return true
}
if mcp, tool, ok := strings.Cut(name, "::"); ok && mcp != "" && tool != "" {
if _, ok := nameSet[mcp+"__"+tool]; ok {
return true
}
if _, ok := nameSet[tool]; ok {
return true
}
}
if idx := strings.LastIndex(name, "__"); idx > 0 {
mcp, tool := name[:idx], name[idx+2:]
if mcp != "" && tool != "" {
if _, ok := nameSet[mcp+"::"+tool]; ok {
return true
}
if _, ok := nameSet[tool]; ok {
return true
}
}
}
return false
}
@@ -0,0 +1,32 @@
package multiagent
import "testing"
func TestToolMatchesAlwaysVisible_ExternalAliases(t *testing.T) {
t.Parallel()
set := expandAlwaysVisibleNameSet([]string{"zhidemai::discount_search", "read_file"})
cases := []struct {
runtime string
want bool
}{
{"zhidemai__discount_search", true},
{"zhidemai::discount_search", true},
{"read_file", true},
{"zhidemai__product_search_pro", false},
{"github__discount_search", false},
}
for _, tc := range cases {
if got := toolMatchesAlwaysVisible(tc.runtime, set); got != tc.want {
t.Fatalf("toolMatchesAlwaysVisible(%q) = %v, want %v", tc.runtime, got, tc.want)
}
}
}
func TestExpandAlwaysVisibleNameSet_LegacyShortName(t *testing.T) {
t.Parallel()
set := expandAlwaysVisibleNameSet([]string{"discount_search"})
if !toolMatchesAlwaysVisible("zhidemai__discount_search", set) {
t.Fatal("legacy short name should match external runtime tool")
}
}
+107 -107
View File
@@ -2,11 +2,11 @@
set -euo pipefail
# CyberStrikeAI 一键部署启动脚本
# CyberStrikeAI one-click deploy and start script
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT_DIR"
# 颜色定义
# Color definitions
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -14,31 +14,31 @@ BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 打印带颜色的消息
# Print colored messages
info() { echo -e "${BLUE}$1${NC}"; }
success() { echo -e "${GREEN}$1${NC}"; }
warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
error() { echo -e "${RED}$1${NC}"; }
note() { echo -e "${CYAN}$1${NC}"; }
# 临时源配置(仅在此脚本中生效)
# Temporary mirror/proxy settings (only effective in this script)
PIP_INDEX_URL="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}"
GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
# 保存原始环境变量(用于恢复)
# Save original env vars (for restoration)
ORIGINAL_PIP_INDEX_URL="${PIP_INDEX_URL:-}"
ORIGINAL_GOPROXY="${GOPROXY:-}"
# 进度显示函数
# Progress display helper
show_progress() {
local pid=$1
local message=$2
local i=0
local dots=""
# 检查进程是否存在
# Check if the process exists
if ! kill -0 "$pid" 2>/dev/null; then
# 进程已经结束,立即返回
# Process already finished; return immediately
return 0
fi
@@ -53,7 +53,7 @@ show_progress() {
printf "\r${BLUE}⏳ %s%s${NC}" "$message" "$dots"
sleep 0.5
# 再次检查进程是否还存在
# Re-check whether the process is still running
if ! kill -0 "$pid" 2>/dev/null; then
break
fi
@@ -63,21 +63,21 @@ show_progress() {
echo ""
echo "=========================================="
echo " CyberStrikeAI 一键部署启动脚本"
echo " (默认 HTTPS 自签证书;纯 HTTP 请用: $0 --http"
echo " CyberStrikeAI Deploy & Start Script"
echo " (HTTPS with self-signed cert by default; plain HTTP: $0 --http)"
echo "=========================================="
echo ""
# 显示临时源配置信息
# Show temporary mirror/proxy info
echo ""
warning "⚠️ 注意:此脚本将使用临时镜像源加速下载"
warning "Note: this script uses temporary mirrors to speed up downloads"
echo ""
info "Python pip 临时镜像源:"
info "Python pip temporary mirror:"
echo " ${PIP_INDEX_URL}"
info "Go Proxy 临时镜像源:"
info "Go temporary proxy:"
echo " ${GOPROXY}"
echo ""
note "这些设置仅在脚本运行期间生效,不会修改系统配置"
note "These settings apply only while this script runs and do not change system config"
echo ""
sleep 1
@@ -86,19 +86,19 @@ VENV_DIR="$ROOT_DIR/venv"
REQUIREMENTS_FILE="$ROOT_DIR/requirements.txt"
BINARY_NAME="cyberstrike-ai"
# 检查配置文件
# Check config file
if [ ! -f "$CONFIG_FILE" ]; then
error "配置文件 config.yaml 不存在"
info "请确保在项目根目录运行此脚本"
error "Config file config.yaml not found"
info "Make sure you run this script from the project root"
exit 1
fi
# 检查并安装 Python 环境
# Check Python environment
check_python() {
if ! command -v python3 >/dev/null 2>&1; then
error "未找到 python3"
error "python3 not found"
echo ""
info "请先安装 Python 3.10 或更高版本:"
info "Install Python 3.10 or later first:"
echo " macOS: brew install python3"
echo " Ubuntu: sudo apt-get install python3 python3-venv"
echo " CentOS: sudo yum install python3 python3-pip"
@@ -110,23 +110,23 @@ check_python() {
PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2)
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 10 ]); then
error "Python 版本过低: $PYTHON_VERSION (需要 3.10+)"
error "Python version too old: $PYTHON_VERSION (requires 3.10+)"
exit 1
fi
success "Python 环境检查通过: $PYTHON_VERSION"
success "Python check passed: $PYTHON_VERSION"
}
# 检查并安装 Go 环境
# Check Go environment
check_go() {
if ! command -v go >/dev/null 2>&1; then
error "未找到 Go"
error "Go not found"
echo ""
info "请先安装 Go 1.21 或更高版本:"
info "Install Go 1.21 or later first:"
echo " macOS: brew install go"
echo " Ubuntu: sudo apt-get install golang-go"
echo " CentOS: sudo yum install golang"
echo " 或访问: https://go.dev/dl/"
echo " Or visit: https://go.dev/dl/"
exit 1
fi
@@ -135,63 +135,63 @@ check_go() {
GO_MINOR=$(echo "$GO_VERSION" | cut -d. -f2)
if [ "$GO_MAJOR" -lt 1 ] || ([ "$GO_MAJOR" -eq 1 ] && [ "$GO_MINOR" -lt 21 ]); then
error "Go 版本过低: $GO_VERSION (需要 1.21+)"
error "Go version too old: $GO_VERSION (requires 1.21+)"
exit 1
fi
success "Go 环境检查通过: $(go version)"
success "Go check passed: $(go version)"
}
# 设置 Python 虚拟环境
# Set up Python virtual environment
setup_python_env() {
if [ ! -d "$VENV_DIR" ]; then
info "创建 Python 虚拟环境..."
info "Creating Python virtual environment..."
python3 -m venv "$VENV_DIR"
success "虚拟环境创建完成"
success "Virtual environment created"
else
info "Python 虚拟环境已存在"
info "Python virtual environment already exists"
fi
info "激活虚拟环境..."
info "Activating virtual environment..."
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
if [ -f "$REQUIREMENTS_FILE" ]; then
echo ""
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
note "⚠️ 使用临时 pip 镜像源(仅本次脚本运行有效)"
note " 镜像地址: ${PIP_INDEX_URL}"
note " 如需永久配置,请设置环境变量 PIP_INDEX_URL"
note "Using temporary pip mirror (this script run only)"
note " Mirror URL: ${PIP_INDEX_URL}"
note " For a permanent setting, set the PIP_INDEX_URL env var"
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
info "升级 pip..."
info "Upgrading pip..."
pip install --index-url "$PIP_INDEX_URL" --upgrade pip >/dev/null 2>&1 || true
info "安装 Python 依赖包..."
info "Installing Python dependencies..."
echo ""
# 尝试安装依赖,捕获错误输出并显示进度
# Install deps in background; capture errors and show progress
PIP_LOG=$(mktemp)
(
set +e # 在子shell中禁用错误退出
set +e # disable errexit in subshell
pip install --index-url "$PIP_INDEX_URL" -r "$REQUIREMENTS_FILE" >"$PIP_LOG" 2>&1
echo $? > "${PIP_LOG}.exit"
) &
PIP_PID=$!
# 等待一小段时间,确保进程启动
# Brief pause so the process can start
sleep 0.1
# 显示进度(如果进程还在运行)
# Show progress while still running
if kill -0 "$PIP_PID" 2>/dev/null; then
show_progress "$PIP_PID" "正在安装依赖包"
show_progress "$PIP_PID" "Installing dependencies"
else
# 进程已经结束,等待一下确保退出码文件已写入
# Process already finished; wait for exit code file
sleep 0.2
fi
# 等待进程完成,忽略 wait 的退出码
# Wait for completion; ignore wait exit code
wait "$PIP_PID" 2>/dev/null || true
PIP_EXIT_CODE=0
@@ -199,74 +199,74 @@ setup_python_env() {
PIP_EXIT_CODE=$(cat "${PIP_LOG}.exit" 2>/dev/null || echo "1")
rm -f "${PIP_LOG}.exit" 2>/dev/null || true
else
# 如果没有退出码文件,检查日志中是否有错误
# No exit code file; check log for errors
if [ -f "$PIP_LOG" ] && grep -q -i "error\|failed\|exception" "$PIP_LOG" 2>/dev/null; then
PIP_EXIT_CODE=1
fi
fi
if [ $PIP_EXIT_CODE -eq 0 ]; then
success "Python 依赖安装完成"
success "Python dependencies installed"
else
# 检查是否是 angr 安装失败(需要 Rust
# Check for angr install failure (needs Rust)
if grep -q "angr" "$PIP_LOG" && grep -q "Rust compiler\|can't find Rust" "$PIP_LOG"; then
warning "angr 安装失败(需要 Rust 编译器)"
warning "angr install failed (Rust compiler required)"
echo ""
info "angr 是可选依赖,主要用于二进制分析工具"
info "如果需要使用 angr,请先安装 Rust:"
info "angr is optional and mainly used for binary analysis tools"
info "To use angr, install Rust first:"
echo " macOS: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
echo " Ubuntu: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
echo " 或访问: https://rustup.rs/"
echo " Or visit: https://rustup.rs/"
echo ""
info "其他依赖已安装,可以继续使用(部分工具可能不可用)"
info "Other dependencies are installed; you can continue (some tools may be unavailable)"
else
warning "部分 Python 依赖安装失败,但可以继续尝试运行"
warning "如果遇到问题,请检查错误信息并手动安装缺失的依赖"
# 显示最后几行错误信息
warning "Some Python dependencies failed to install, but continuing"
warning "If you hit issues, check the errors and install missing packages manually"
# Show last lines of error output
echo ""
info "错误详情(最后 10 行):"
info "Error details (last 10 lines):"
tail -n 10 "$PIP_LOG" | sed 's/^/ /'
echo ""
fi
fi
rm -f "$PIP_LOG"
else
warning "未找到 requirements.txt,跳过 Python 依赖安装"
warning "requirements.txt not found; skipping Python dependency install"
fi
}
# 构建 Go 项目
# Build Go project
build_go_project() {
echo ""
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
note "⚠️ 使用临时 Go Proxy(仅本次脚本运行有效)"
note " Proxy 地址: ${GOPROXY}"
note " 如需永久配置,请设置环境变量 GOPROXY"
note "Using temporary Go proxy (this script run only)"
note " Proxy URL: ${GOPROXY}"
note " For a permanent setting, set the GOPROXY env var"
note "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
info "下载 Go 依赖..."
info "Downloading Go dependencies..."
GO_DOWNLOAD_LOG=$(mktemp)
(
set +e # 在子shell中禁用错误退出
set +e # disable errexit in subshell
export GOPROXY="$GOPROXY"
go mod download >"$GO_DOWNLOAD_LOG" 2>&1
echo $? > "${GO_DOWNLOAD_LOG}.exit"
) &
GO_DOWNLOAD_PID=$!
# 等待一小段时间,确保进程启动
# Brief pause so the process can start
sleep 0.1
# 显示进度(如果进程还在运行)
# Show progress while still running
if kill -0 "$GO_DOWNLOAD_PID" 2>/dev/null; then
show_progress "$GO_DOWNLOAD_PID" "正在下载 Go 依赖"
show_progress "$GO_DOWNLOAD_PID" "Downloading Go dependencies"
else
# 进程已经结束,等待一下确保退出码文件已写入
# Process already finished; wait for exit code file
sleep 0.2
fi
# 等待进程完成,忽略 wait 的退出码
# Wait for completion; ignore wait exit code
wait "$GO_DOWNLOAD_PID" 2>/dev/null || true
GO_DOWNLOAD_EXIT_CODE=0
@@ -274,7 +274,7 @@ build_go_project() {
GO_DOWNLOAD_EXIT_CODE=$(cat "${GO_DOWNLOAD_LOG}.exit" 2>/dev/null || echo "1")
rm -f "${GO_DOWNLOAD_LOG}.exit" 2>/dev/null || true
else
# 如果没有退出码文件,检查日志中是否有错误
# No exit code file; check log for errors
if [ -f "$GO_DOWNLOAD_LOG" ] && grep -q -i "error\|failed" "$GO_DOWNLOAD_LOG" 2>/dev/null; then
GO_DOWNLOAD_EXIT_CODE=1
fi
@@ -282,33 +282,33 @@ build_go_project() {
rm -f "$GO_DOWNLOAD_LOG" 2>/dev/null || true
if [ $GO_DOWNLOAD_EXIT_CODE -ne 0 ]; then
error "Go 依赖下载失败"
error "Go dependency download failed"
exit 1
fi
success "Go 依赖下载完成"
success "Go dependencies downloaded"
info "构建项目..."
info "Building project..."
GO_BUILD_LOG=$(mktemp)
(
set +e # 在子shell中禁用错误退出
set +e # disable errexit in subshell
export GOPROXY="$GOPROXY"
go build -o "$BINARY_NAME" cmd/server/main.go >"$GO_BUILD_LOG" 2>&1
echo $? > "${GO_BUILD_LOG}.exit"
) &
GO_BUILD_PID=$!
# 等待一小段时间,确保进程启动
# Brief pause so the process can start
sleep 0.1
# 显示进度(如果进程还在运行)
# Show progress while still running
if kill -0 "$GO_BUILD_PID" 2>/dev/null; then
show_progress "$GO_BUILD_PID" "正在构建项目"
show_progress "$GO_BUILD_PID" "Building project"
else
# 进程已经结束,等待一下确保退出码文件已写入
# Process already finished; wait for exit code file
sleep 0.2
fi
# 等待进程完成,忽略 wait 的退出码
# Wait for completion; ignore wait exit code
wait "$GO_BUILD_PID" 2>/dev/null || true
GO_BUILD_EXIT_CODE=0
@@ -316,20 +316,20 @@ build_go_project() {
GO_BUILD_EXIT_CODE=$(cat "${GO_BUILD_LOG}.exit" 2>/dev/null || echo "1")
rm -f "${GO_BUILD_LOG}.exit" 2>/dev/null || true
else
# 如果没有退出码文件,检查日志中是否有错误
# No exit code file; check log for errors
if [ -f "$GO_BUILD_LOG" ] && grep -q -i "error\|failed" "$GO_BUILD_LOG" 2>/dev/null; then
GO_BUILD_EXIT_CODE=1
fi
fi
if [ $GO_BUILD_EXIT_CODE -eq 0 ]; then
success "项目构建完成: $BINARY_NAME"
success "Build complete: $BINARY_NAME"
rm -f "$GO_BUILD_LOG"
else
error "项目构建失败"
# 显示构建错误
error "Build failed"
# Show build errors
echo ""
info "构建错误详情:"
info "Build error details:"
cat "$GO_BUILD_LOG" | sed 's/^/ /'
echo ""
rm -f "$GO_BUILD_LOG"
@@ -337,24 +337,24 @@ build_go_project() {
fi
}
# 检查是否需要重新构建
# Check whether a rebuild is needed
need_rebuild() {
if [ ! -f "$BINARY_NAME" ]; then
return 0 # 需要构建
return 0 # needs build
fi
# 检查源代码是否有更新
# Check if source changed since last build
if [ "$BINARY_NAME" -ot cmd/server/main.go ] || \
[ "$BINARY_NAME" -ot go.mod ] || \
find internal cmd -name "*.go" -newer "$BINARY_NAME" 2>/dev/null | grep -q .; then
return 0 # 需要重新构建
return 0 # needs rebuild
fi
return 1 # 不需要构建
return 1 # no rebuild needed
}
# 主流程
# 默认启动主站 HTTPS--https 传给二进制);传 --http 则走明文 HTTP
# Main flow
# Default: HTTPS (--https passed to binary); --http uses plain HTTP.
main() {
USE_HTTPS=1
FORWARD_ARGS=()
@@ -366,39 +366,39 @@ main() {
FORWARD_ARGS+=("$arg")
done
# 环境检查
info "检查运行环境..."
# Environment checks
info "Checking runtime environment..."
check_python
check_go
echo ""
# 设置 Python 环境
info "设置 Python 环境..."
# Python setup
info "Setting up Python environment..."
setup_python_env
echo ""
# 构建 Go 项目
# Go build
if need_rebuild; then
info "准备构建项目..."
info "Preparing to build project..."
build_go_project
else
success "可执行文件已是最新,跳过构建"
success "Binary is up to date; skipping build"
fi
echo ""
# 启动服务器
success "所有准备工作完成!"
# Start server
success "All setup complete!"
echo ""
if [ "$USE_HTTPS" -eq 1 ]; then
info "启动 CyberStrikeAI 服务器(HTTPS + HTTP/2,自签证书)..."
note "纯 HTTP 启动请使用: $0 --http"
info "Starting CyberStrikeAI server (HTTPS + HTTP/2, self-signed cert)..."
note "For plain HTTP, use: $0 --http"
else
info "启动 CyberStrikeAI 服务器(HTTP..."
info "Starting CyberStrikeAI server (HTTP)..."
fi
echo "=========================================="
echo ""
# 始终传入项目根目录下的 config.yaml,避免 cwd 不在项目根时找不到配置;额外参数仍可追加(如再次 -config 覆盖,以 Go flag 后写为准)。
# Always pass config.yaml from project root so cwd does not matter; extra args still apply (e.g. -config override; last Go flag wins).
if [ "$USE_HTTPS" -eq 1 ]; then
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https "${FORWARD_ARGS[@]}"
@@ -414,5 +414,5 @@ main() {
fi
}
# 执行主流程(支持参数,如: ./run.sh --http
# Run main (supports args, e.g. ./run.sh --http)
main "$@"
+3 -5
View File
@@ -1371,7 +1371,6 @@
Modal
============================================================================ */
/* Toast 须高于模态遮罩 (10050),避免被 backdrop-filter 模糊 */
#c2-toast-container {
z-index: 10100 !important;
}
@@ -1379,9 +1378,7 @@
.c2-modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(15, 23, 42, 0.52);
display: flex;
align-items: center;
justify-content: center;
@@ -1404,7 +1401,8 @@
overflow-y: auto;
box-shadow: var(--c2-shadow-lg);
border: 1px solid var(--c2-border);
animation: c2-slide-up 0.2s ease-out;
animation: c2-slide-up 0.18s ease-out;
contain: layout style paint;
}
@keyframes c2-slide-up {
+353 -26
View File
@@ -2419,6 +2419,9 @@ header {
padding-top: 12px;
border-top: 1px solid var(--border-color);
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.mcp-call-label {
@@ -2473,10 +2476,15 @@ header {
padding-top: 12px;
border-top: 1px solid var(--border-color);
width: 100%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.process-details-content {
width: 100%;
min-width: 0;
max-width: 100%;
}
.process-details-content .progress-timeline {
@@ -2488,6 +2496,7 @@ header {
.process-details-content .progress-timeline.expanded {
max-height: 2000px;
overflow-x: hidden;
overflow-y: auto;
opacity: 1;
margin-top: 12px;
@@ -3326,9 +3335,9 @@ header {
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
background-color: rgba(15, 23, 42, 0.52);
overflow: auto;
animation: fadeIn 0.2s ease-in;
animation: fadeIn 0.15s ease-out;
}
.modal-content {
@@ -3343,8 +3352,9 @@ header {
flex-direction: column;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-color);
animation: slideDown 0.3s ease-out;
animation: slideDown 0.18s ease-out;
overflow: hidden;
contain: layout style paint;
}
@keyframes slideDown {
@@ -3912,6 +3922,7 @@ header {
.progress-timeline.expanded {
max-height: 2000px;
overflow-x: hidden;
overflow-y: auto;
}
@@ -3919,7 +3930,8 @@ header {
.progress-container.is-streaming .progress-timeline.expanded,
.process-details-container.is-streaming .process-details-content .progress-timeline.expanded {
max-height: none;
overflow: visible;
overflow-x: hidden;
overflow-y: visible;
}
.timeline-item {
@@ -3930,6 +3942,10 @@ header {
background: var(--bg-secondary);
border-radius: 4px;
transition: all 0.2s;
min-width: 0;
max-width: 100%;
overflow: hidden;
box-sizing: border-box;
}
.timeline-item:hover {
@@ -4087,6 +4103,12 @@ header {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.6;
min-width: 0;
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
overflow-x: auto;
box-sizing: border-box;
}
/* 流式增量阶段纯文本展示(避免半段 Markdown 反复解析) */
@@ -4095,6 +4117,43 @@ header {
word-break: break-word;
}
/* 过程详情 Markdown:避免长 URL/代码/表格撑破紫/蓝时间线条目 */
.timeline-item-content p,
.timeline-item-content li,
.timeline-item-content td,
.timeline-item-content th {
overflow-wrap: break-word;
word-break: break-word;
}
.timeline-item-content pre {
max-width: 100%;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
box-sizing: border-box;
}
.timeline-item-content code {
overflow-wrap: break-word;
word-break: break-word;
}
.timeline-item-content .table-wrapper {
max-width: 100%;
overflow-x: auto;
}
.timeline-item-content table {
max-width: 100%;
}
/* 长过程详情:跳过视口外时间线条目的布局/绘制,减轻大段工具输出时的主线程压力 */
.progress-timeline .timeline-item {
content-visibility: auto;
contain-intrinsic-size: auto 72px;
}
.tool-details {
display: flex;
flex-direction: column;
@@ -5590,6 +5649,66 @@ header {
animation: mcpHighlight 2s ease-out;
}
.external-mcp-item.clickable {
cursor: pointer;
}
.external-mcp-item.selected {
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
.tool-item.highlight {
animation: mcpHighlight 2s ease-out;
}
.tools-source-filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px 4px 10px;
border-radius: 999px;
background: rgba(var(--accent-rgb, 59, 130, 246), 0.12);
border: 1px solid var(--accent-color);
color: var(--text-primary);
font-size: 0.8125rem;
line-height: 1.2;
}
.tools-source-filter-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
min-width: 0;
height: auto;
padding: 0;
margin: 0;
border: none !important;
border-radius: 0;
background: transparent !important;
box-shadow: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
}
.tools-actions .tools-source-filter-clear {
padding: 0;
border: none;
background: transparent;
}
.tools-source-filter-clear:hover,
.tools-actions .tools-source-filter-clear:hover {
background: transparent !important;
border: none !important;
box-shadow: none;
color: var(--text-primary);
}
@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); }
@@ -7130,17 +7249,68 @@ header {
stroke-width: 2;
}
.mcp-stats-timeline-empty,
.mcp-stats-timeline-error {
margin: 0;
padding: 20px 8px;
padding: 16px 8px;
text-align: center;
font-size: 0.75rem;
color: var(--text-muted);
color: #b91c1c;
}
.mcp-stats-timeline-error {
color: #b91c1c;
.mcp-stats-timeline-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1;
min-height: 88px;
padding: 20px 16px;
text-align: center;
border-radius: 8px;
background: rgba(148, 163, 184, 0.06);
border: 1px dashed rgba(148, 163, 184, 0.28);
}
.mcp-stats-timeline-empty-state--compact {
min-height: 72px;
padding: 14px 10px;
gap: 4px;
}
.mcp-stats-timeline-empty-state__icon {
color: rgba(148, 163, 184, 0.75);
flex-shrink: 0;
}
.mcp-stats-timeline-empty-state--compact .mcp-stats-timeline-empty-state__icon {
width: 28px;
height: 28px;
}
.mcp-stats-timeline-empty-state__title {
margin: 0;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
line-height: 1.4;
}
.mcp-stats-timeline-empty-state--compact .mcp-stats-timeline-empty-state__title {
font-size: 0.75rem;
}
.mcp-stats-timeline-empty-state__hint {
margin: 0;
max-width: 28em;
font-size: 0.6875rem;
color: var(--text-muted);
line-height: 1.45;
}
.mcp-stats-timeline-empty-state--compact .mcp-stats-timeline-empty-state__hint {
font-size: 0.625rem;
max-width: 100%;
}
.mcp-stats-timeline-tooltip {
@@ -14531,6 +14701,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
}
.webshell-ai-process-block .process-details-content .progress-timeline.expanded {
max-height: 2000px;
overflow-x: hidden;
overflow-y: auto;
}
@@ -14762,6 +14933,10 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
position: relative;
flex-shrink: 0;
}
.ws-project-selector-wrapper {
position: relative;
flex-shrink: 0;
}
.ws-agent-mode-wrapper {
flex-shrink: 0;
}
@@ -16630,6 +16805,12 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
border-color: rgba(148, 163, 184, 0.25);
}
.dashboard-recent-vuln-status.st-ignored {
background: rgba(108, 117, 125, 0.12);
color: #868e96;
border-color: rgba(108, 117, 125, 0.22);
}
@media (max-width: 720px) {
.dashboard-recent-vuln-item {
grid-template-columns: 56px minmax(0, 1fr) auto 8.25rem;
@@ -18704,6 +18885,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
color: #dc3545;
}
.status-badge.status-ignored {
background: rgba(108, 117, 125, 0.12);
color: #868e96;
}
.vulnerability-date {
font-size: 0.75rem;
color: var(--text-muted);
@@ -19126,6 +19312,12 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
overflow-y: auto;
}
.agent-mode-options > .role-selection-item-main {
flex-shrink: 0;
}
/* 选项为 <button>,浏览器默认 text-align:center 会继承到文案,强制左对齐与角色列表一致 */
@@ -19157,7 +19349,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
left: 0;
width: 340px;
max-width: calc(100vw - 32px);
max-height: 60vh;
max-height: min(580px, 60vh);
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 16px;
@@ -19167,6 +19359,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
animation: slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -19209,11 +19402,17 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
gap: 6px;
/* 限制显示8个角色:每个角色约70px高度 + gap8个角色约580px */
max-height: 580px;
min-height: 0;
overflow-y: auto;
padding-right: 6px;
flex: 1;
}
/* 防止 flex 列容器在高度受限时把列表项纵向压扁(应滚动而非压缩) */
.role-selection-list-main > .role-selection-item-main {
flex-shrink: 0;
}
.role-selection-list-main::-webkit-scrollbar {
width: 8px;
}
@@ -19246,6 +19445,9 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
min-width: 0;
flex-shrink: 0;
box-sizing: border-box;
}
.role-selection-item-main:hover {
@@ -19308,6 +19510,10 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
margin: 0;
transition: color 0.2s cubic-bezier(0.16, 1, 0.3, 1);
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.role-selection-item-main.selected .role-selection-item-name-main {
@@ -19315,6 +19521,10 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
font-weight: 600;
}
.role-selection-item-main.selected .role-selection-item-content-main {
padding-right: 24px;
}
.role-selection-item-description-main {
font-size: 0.75rem;
color: #666666;
@@ -22386,7 +22596,10 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
}
.projects-list-item {
position: relative;
padding: 10px 12px 10px 14px;
display: flex;
align-items: center;
gap: 4px;
padding: 10px 8px 10px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
@@ -22419,8 +22632,43 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
color: #94a3b8;
}
.projects-list-item-body {
flex: 1;
min-width: 0;
}
.projects-list-item-menu {
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
color: var(--text-muted, #94a3b8);
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 16px;
font-weight: 600;
line-height: 1;
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.projects-list-item:hover .projects-list-item-menu,
.projects-list-item.is-active .projects-list-item-menu {
opacity: 0.75;
}
.projects-list-item-menu:hover,
.projects-list-item-menu:focus-visible {
opacity: 1;
background: #e2e8f0;
color: var(--text-primary, #0f172a);
outline: none;
}
.projects-list-item.is-active .projects-list-item-menu:hover,
.projects-list-item.is-active .projects-list-item-menu:focus-visible {
background: #dbeafe;
}
.projects-list-item-name {
font-weight: 600;
color: var(--text-primary, #0f172a);
@@ -22515,21 +22763,38 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
.projects-detail-header-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.projects-detail-title-row {
.projects-detail-headline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 12px;
min-width: 0;
}
.projects-detail-title-group {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
max-width: min(560px, 100%);
}
.projects-detail-title {
margin: 0;
min-width: 0;
font-size: 1.375rem;
font-weight: 600;
color: #0f172a;
letter-spacing: -0.02em;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.projects-status-pill {
flex-shrink: 0;
display: inline-flex;
align-items: center;
font-size: 0.6875rem;
@@ -22537,41 +22802,76 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
padding: 3px 10px;
border-radius: 999px;
line-height: 1.2;
border: 1px solid transparent;
}
.projects-status-pill--active {
background: #dcfce7;
color: #166534;
border-color: #86efac;
}
.projects-status-pill--archived {
background: #f1f5f9;
color: #64748b;
border-color: #e2e8f0;
}
.projects-detail-meta {
margin: 6px 0 0;
margin: 0;
font-size: 0.8125rem;
color: #94a3b8;
line-height: 1.4;
}
.projects-detail-desc {
margin: 10px 0 0;
margin: 0;
max-width: min(640px, 100%);
font-size: 0.875rem;
color: #475569;
line-height: 1.55;
max-width: 640px;
word-break: break-word;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
}
.projects-description-textarea {
max-height: 200px;
resize: vertical;
overflow-y: auto;
}
.projects-detail-stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
align-items: center;
gap: 6px;
flex-shrink: 0;
padding-left: 12px;
border-left: 1px solid #e2e8f0;
}
.projects-stat-chip {
font-size: 0.75rem;
font-size: 0.6875rem;
font-weight: 500;
color: #475569;
background: #f1f5f9;
border: 1px solid #e2e8f0;
padding: 4px 10px;
padding: 3px 8px;
border-radius: 999px;
white-space: nowrap;
}
.projects-stat-chip--facts {
color: #1d4ed8;
background: #dbeafe;
border-color: #93c5fd;
}
.projects-stat-chip--vulns {
color: #c2410c;
background: #ffedd5;
border-color: #fdba74;
}
.projects-stat-chip--conversations {
color: #6d28d9;
background: #ede9fe;
border-color: #c4b5fd;
}
.projects-stat-chip--warn {
color: #92400e;
@@ -22583,7 +22883,23 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
align-items: center;
align-self: flex-start;
margin-top: 2px;
}
@media (max-width: 860px) {
.projects-detail-header {
flex-direction: column;
align-items: stretch;
}
.projects-detail-header-actions {
align-self: stretch;
margin-top: 0;
}
.projects-detail-stats {
padding-left: 0;
border-left: none;
}
}
.projects-tabs {
display: flex;
@@ -23544,10 +23860,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
justify-content: center;
padding: 24px 16px;
box-sizing: border-box;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: projectsOverlayIn 0.2s ease-out;
background: rgba(15, 23, 42, 0.52);
animation: projectsOverlayIn 0.15s ease-out;
}
@keyframes projectsOverlayIn {
from { opacity: 0; }
@@ -23565,7 +23879,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
0 24px 48px rgba(15, 23, 42, 0.18),
0 0 0 1px rgba(15, 23, 42, 0.06);
overflow: hidden;
animation: projectsDialogIn 0.25s cubic-bezier(0.22, 1, 0.36, 1);
animation: projectsDialogIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
contain: layout style paint;
}
.projects-modal-dialog--wide {
max-width: 640px;
@@ -23626,6 +23941,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
padding: 10px 12px;
font-size: 0.875rem;
transition: border-color 0.15s, box-shadow 0.15s;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
#project-modal-name {
overflow: hidden;
text-overflow: ellipsis;
}
.projects-modal-body .form-input:focus {
outline: none;
@@ -23649,7 +23971,8 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
.projects-modal-footer .btn-primary {
min-width: 100px;
}
body.projects-modal-open {
body.projects-modal-open,
body.app-modal-open {
overflow: hidden;
}
.fact-detail-prev-wrap {
@@ -23797,8 +24120,11 @@ body.projects-modal-open {
/* 对话区项目选择器(与角色/代理模式共用 role-selector-* */
.project-selector-wrapper .role-selector-text {
max-width: 108px;
min-width: 0;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-project-panel {
width: 280px;
@@ -23818,6 +24144,7 @@ body.projects-modal-open {
padding-right: 0;
margin: 0;
width: 100%;
overflow-x: hidden;
}
.chat-project-panel .role-selection-item-main {
width: 100%;
+20 -2
View File
@@ -205,6 +205,7 @@
"statusConfirmed": "Confirmed",
"statusFixed": "Fixed",
"statusFalsePositive": "False positive",
"statusIgnored": "Ignored",
"fixRate": "Fix rate",
"dataStale": "Data may be stale — please refresh",
"recommendedActions": "Recommended Actions",
@@ -285,6 +286,8 @@
"status": "Status",
"modalNewTitle": "New project",
"modalNewSubtitle": "After creation, bind conversations to share fact board across chats",
"modalEditTitle": "Edit project",
"modalEditSubtitle": "Update project name and description",
"projectName": "Project name",
"projectNamePlaceholder": "e.g. Client A Web pentest",
"projectDescription": "Project description",
@@ -323,6 +326,9 @@
"statsSparse": "{{count}} incomplete",
"projectNotFound": "Project not found",
"updatedPrefix": "Updated {{time}}",
"descExpand": "Show all",
"descCollapse": "Show less",
"descriptionLengthHint": "Keep it brief (max 4000 chars). Put long logs/POCs in fact board body instead.",
"noMatchingFacts": "No matching facts, try adjusting filters",
"noFacts": "No facts yet. Click Add fact or let Agent write facts automatically",
"relatedVulnIdTitle": "Related vulnerability ID",
@@ -406,6 +412,10 @@
"dangerZoneTitle": "Danger zone",
"dangerZoneHint": "Archived projects are hidden unless 'Show archived' is enabled; deletion removes all facts permanently.",
"archiveRestore": "Archive / Restore",
"archiveProject": "Archive",
"editProject": "Edit",
"restoreProjectActive": "Restore to active",
"projectActions": "Project actions",
"deleteProject": "Delete project",
"saveChangesHint": "Click save to sync changes to server",
"saveSettings": "Save changes",
@@ -464,7 +474,7 @@
"noHistoryConversations": "No conversation history yet",
"renameGroupPrompt": "Please enter new name:",
"deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.",
"deleteConversationConfirm": "Are you sure you want to delete this conversation?",
"deleteConversationConfirm": "Delete this conversation? Chat messages cannot be recovered, but recorded vulnerabilities will remain in the vulnerability library.",
"renameFailed": "Rename failed",
"downloadConversationFailed": "Failed to download conversation",
"viewAttackChainSelectConv": "Please select a conversation to view attack chain",
@@ -501,6 +511,8 @@
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
"einoStreamErrorMessage": "Streaming read failed; the system will retry or terminate according to policy.",
"einoRunRetryTitle": "🔁 Transient error retry",
"einoEmptyResponseContinueTitle": "🔁 Auto resume (no assistant text)",
"einoEmptyResponseContinueMessage": "Session ended without captured assistant text; resuming from trace…",
"einoRunRetryErrorDetail": "Error detail",
"iterationLimitReachedTitle": "⛔ Iteration limit reached",
"iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.",
@@ -958,6 +970,9 @@
"externalBadge": "External",
"externalFrom": "External ({{name}})",
"externalToolFrom": "External MCP - Source: {{name}}",
"clickToViewTools": "Click to view tools from {{name}}",
"filterBySource": "Source: {{name}}",
"clearSourceFilter": "Clear source filter",
"noDescription": "No description",
"paginationInfo": "{{start}}-{{end}} of {{total}} tools",
"perPage": "Per page:",
@@ -1577,6 +1592,7 @@
"timelineSummary": "{{total}} calls in range · peak {{peak}}",
"timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}",
"timelineNoData": "No calls in this period",
"timelineEmptyHint": "Switch the time range or invoke MCP tools in chat or tasks",
"timelineLoadError": "Failed to load call trend",
"timelineTotalLegend": "Total calls",
"timelineFailedLegend": "Failed",
@@ -1803,6 +1819,7 @@
"statusConfirmed": "Confirmed",
"statusFixed": "Fixed",
"statusFalsePositive": "False positive",
"statusIgnored": "Ignored",
"searchVulnId": "Search vuln ID",
"searchKeyword": "Search title, description, type, target…",
"searchKeywordShort": "Keyword",
@@ -2319,7 +2336,7 @@
"selectAll": "Select all",
"deleteSelected": "Delete selected",
"confirmDeleteNone": "Please select at least one conversation to delete",
"confirmDeleteN": "Delete {{count}} selected conversation(s)?",
"confirmDeleteN": "Delete {{count}} selected conversation(s)? Chat messages cannot be recovered, but recorded vulnerabilities will remain in the vulnerability library.",
"deleteFailed": "Delete failed",
"unnamedConversation": "Unnamed conversation"
},
@@ -2465,6 +2482,7 @@
"statusConfirmed": "Confirmed",
"statusFixed": "Fixed",
"statusFalsePositive": "False positive",
"statusIgnored": "Ignored",
"type": "Vulnerability type",
"typePlaceholder": "e.g. SQL injection, XSS, CSRF",
"target": "Target",
+20 -2
View File
@@ -198,6 +198,7 @@
"statusConfirmed": "已确认",
"statusFixed": "已修复",
"statusFalsePositive": "误报",
"statusIgnored": "已忽略",
"fixRate": "修复率",
"dataStale": "数据可能已过期,请手动刷新",
"recommendedActions": "推荐操作",
@@ -273,6 +274,8 @@
"status": "状态",
"modalNewTitle": "新建项目",
"modalNewSubtitle": "创建后可绑定对话,跨会话共享事实黑板",
"modalEditTitle": "编辑项目",
"modalEditSubtitle": "修改项目名称与描述",
"projectName": "项目名称",
"projectNamePlaceholder": "例如:某客户 Web 渗透",
"projectDescription": "项目描述",
@@ -311,6 +314,9 @@
"statsSparse": "{{count}} 待补全",
"projectNotFound": "项目不存在",
"updatedPrefix": "更新于 {{time}}",
"descExpand": "展开全部",
"descCollapse": "收起",
"descriptionLengthHint": "简要说明即可(最多 4000 字);大段日志/POC 请写入事实黑板 body",
"noMatchingFacts": "无匹配事实,请调整筛选条件",
"noFacts": "暂无事实,点击「添加事实」或由 Agent 自动写入",
"relatedVulnIdTitle": "关联漏洞 ID",
@@ -394,6 +400,10 @@
"dangerZoneTitle": "危险操作",
"dangerZoneHint": "归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。",
"archiveRestore": "归档 / 恢复",
"archiveProject": "归档",
"editProject": "编辑",
"restoreProjectActive": "恢复为进行中",
"projectActions": "项目操作",
"deleteProject": "删除项目",
"saveChangesHint": "修改后请点击保存以同步到服务器",
"saveSettings": "保存更改",
@@ -452,7 +462,7 @@
"noHistoryConversations": "暂无历史对话",
"renameGroupPrompt": "请输入新名称:",
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
"deleteConversationConfirm": "确定要删除此对话吗?",
"deleteConversationConfirm": "确定要删除此对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
"renameFailed": "重命名失败",
"downloadConversationFailed": "下载对话失败",
"viewAttackChainSelectConv": "请选择一个对话以查看攻击链",
@@ -489,6 +499,8 @@
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}}",
"einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。",
"einoRunRetryTitle": "🔁 临时错误重试",
"einoEmptyResponseContinueTitle": "🔁 自动续跑(无助手正文)",
"einoEmptyResponseContinueMessage": "会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…",
"einoRunRetryErrorDetail": "具体报错",
"iterationLimitReachedTitle": "⛔ 达到迭代上限",
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
@@ -946,6 +958,9 @@
"externalBadge": "外部",
"externalFrom": "外部 ({{name}})",
"externalToolFrom": "外部MCP工具 - 来源:{{name}}",
"clickToViewTools": "点击查看 {{name}} 的工具",
"filterBySource": "来源: {{name}}",
"clearSourceFilter": "清除来源筛选",
"noDescription": "无描述",
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 个工具",
"perPage": "每页:",
@@ -1565,6 +1580,7 @@
"timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}",
"timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}",
"timelineNoData": "该时段暂无调用",
"timelineEmptyHint": "切换时间范围查看其他时段,或在对话/任务中调用 MCP 工具",
"timelineLoadError": "无法加载调用趋势",
"timelineTotalLegend": "总调用",
"timelineFailedLegend": "失败",
@@ -1791,6 +1807,7 @@
"statusConfirmed": "已确认",
"statusFixed": "已修复",
"statusFalsePositive": "误报",
"statusIgnored": "已忽略",
"searchVulnId": "搜索漏洞 ID",
"searchKeyword": "搜索标题、描述、类型、目标…",
"searchKeywordShort": "关键词",
@@ -2307,7 +2324,7 @@
"selectAll": "全选",
"deleteSelected": "删除所选",
"confirmDeleteNone": "请先选择要删除的对话",
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?",
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
"deleteFailed": "删除失败",
"unnamedConversation": "未命名对话"
},
@@ -2453,6 +2470,7 @@
"statusConfirmed": "已确认",
"statusFixed": "已修复",
"statusFalsePositive": "误报",
"statusIgnored": "已忽略",
"type": "漏洞类型",
"typePlaceholder": "如:SQL注入、XSS、CSRF等",
"target": "目标",
+20 -17
View File
@@ -105,45 +105,48 @@ function showAddMarkdownAgentModal() {
document.getElementById('agent-md-bind-role').value = '';
document.getElementById('agent-md-max-iter').value = '0';
document.getElementById('agent-md-instruction').value = '';
if (modal) modal.style.display = 'flex';
openAppModal('agent-md-modal');
}
async function editMarkdownAgent(filename) {
if (!filename) return;
const modal = document.getElementById('agent-md-modal');
const title = document.getElementById('agent-md-modal-title');
const row = document.getElementById('agent-md-filename-row');
markdownAgentsEditingFilename = null;
markdownAgentsEditingIsOrchestrator = false;
if (title) title.textContent = _agentsT('agentsPage.editTitle');
if (row) row.style.display = 'none';
document.getElementById('agent-md-instruction').value = '';
openAppModal('agent-md-modal', { focus: false });
try {
const r = await apiFetch('/api/multi-agent/markdown-agents/' + encodeURIComponent(filename));
const data = await r.json();
if (!r.ok) throw new Error(data.error || r.statusText);
markdownAgentsEditingFilename = data.filename || filename;
markdownAgentsEditingIsOrchestrator = !!data.is_orchestrator;
document.getElementById('agent-md-filename-current').value = data.filename || filename;
document.getElementById('agent-md-filename').value = data.filename || filename;
document.getElementById('agent-md-filename').disabled = true;
var roleEl2 = document.getElementById('agent-md-role');
if (roleEl2) roleEl2.value = data.is_orchestrator ? 'orchestrator' : 'sub';
document.getElementById('agent-md-id').value = data.id || '';
document.getElementById('agent-md-name').value = data.name || '';
document.getElementById('agent-md-description').value = data.description || '';
document.getElementById('agent-md-tools').value = Array.isArray(data.tools) ? data.tools.join(', ') : '';
document.getElementById('agent-md-bind-role').value = data.bind_role || '';
document.getElementById('agent-md-max-iter').value = String(data.max_iterations != null ? data.max_iterations : 0);
document.getElementById('agent-md-instruction').value = data.instruction || '';
if (modal) modal.style.display = 'flex';
deferModalContent(function () {
document.getElementById('agent-md-filename-current').value = data.filename || filename;
document.getElementById('agent-md-filename').value = data.filename || filename;
document.getElementById('agent-md-filename').disabled = true;
var roleEl2 = document.getElementById('agent-md-role');
if (roleEl2) roleEl2.value = data.is_orchestrator ? 'orchestrator' : 'sub';
document.getElementById('agent-md-id').value = data.id || '';
document.getElementById('agent-md-name').value = data.name || '';
document.getElementById('agent-md-description').value = data.description || '';
document.getElementById('agent-md-tools').value = Array.isArray(data.tools) ? data.tools.join(', ') : '';
document.getElementById('agent-md-bind-role').value = data.bind_role || '';
document.getElementById('agent-md-max-iter').value = String(data.max_iterations != null ? data.max_iterations : 0);
document.getElementById('agent-md-instruction').value = data.instruction || '';
document.getElementById('agent-md-name')?.focus();
});
} catch (e) {
closeMarkdownAgentModal();
showNotification(_agentsT('agentsPage.loadOneFailed') + ': ' + e.message, 'error');
}
}
function closeMarkdownAgentModal() {
const modal = document.getElementById('agent-md-modal');
if (modal) modal.style.display = 'none';
closeAppModal('agent-md-modal');
markdownAgentsEditingFilename = null;
markdownAgentsEditingIsOrchestrator = false;
}
+38 -33
View File
@@ -533,56 +533,61 @@ async function exportAuditLogsCsv() {
}
function closeAuditDetailModal() {
closeAppModal('audit-detail-modal');
const el = document.getElementById('audit-detail-modal');
if (el) el.remove();
syncAppModalBodyLock();
}
async function showAuditLogDetail(id) {
if (!id || typeof apiFetch !== 'function') return;
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
try {
closeAuditDetailModal();
const overlay = document.createElement('div');
overlay.id = 'audit-detail-modal';
overlay.className = 'modal';
document.body.appendChild(overlay);
openAppModal(overlay, { focus: false });
const r = await apiFetch('/api/audit/logs/' + encodeURIComponent(id));
if (!r.ok) throw new Error('not found');
const data = await r.json();
const log = data.log || {};
const detail = log.detail ? JSON.stringify(log.detail, null, 2) : '';
closeAuditDetailModal();
const overlay = document.createElement('div');
overlay.id = 'audit-detail-modal';
overlay.className = 'modal';
overlay.style.display = 'block';
const catAction = esc(auditCategoryLabel(log.category || '')) + ' / ' + esc(auditActionLabel(log.action || ''));
overlay.innerHTML =
'<div class="modal-content" style="max-width: 720px;">' +
'<div class="modal-header">' +
'<h2>' + esc(auditT('settingsAudit.detailTitle', null, '审计详情')) + '</h2>' +
'<span class="modal-close" onclick="closeAuditDetailModal()">&times;</span>' +
'</div>' +
'<div class="modal-body audit-detail-body">' +
'<p><strong>' + esc(auditT('settingsAudit.detailTime', null, '时间')) + ':</strong> ' + esc(formatAuditTime(log.createdAt)) + '</p>' +
'<p><strong>' + esc(auditT('settingsAudit.detailCategory', null, '类别')) + ':</strong> ' + catAction + '</p>' +
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(log.result || '') + '</p>' +
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(log.message || '') + '</p>' +
(log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') +
(log.sessionHint ? '<p><strong>' + esc(auditT('settingsAudit.detailSession', null, '会话')) + ':</strong> ' + esc(log.sessionHint) + '</p>' : '') +
(log.userAgent ? '<p><strong>UA:</strong> ' + esc(log.userAgent) + '</p>' : '') +
auditResourceMeta(log) +
(detail ? '<pre class="audit-detail-pre">' + esc(detail) + '</pre>' : '') +
'</div>' +
'<div class="modal-footer"><button type="button" class="btn-secondary" onclick="closeAuditDetailModal()">' +
esc(auditT('common.close', null, '关闭')) + '</button></div>' +
'</div>';
document.body.appendChild(overlay);
const chatBtn = overlay.querySelector('.audit-open-chat-btn');
if (chatBtn) {
chatBtn.addEventListener('click', function () {
auditOpenConversationChat(chatBtn.getAttribute('data-conversation-id'));
deferModalContent(function () {
overlay.innerHTML =
'<div class="modal-content" style="max-width: 720px;">' +
'<div class="modal-header">' +
'<h2>' + esc(auditT('settingsAudit.detailTitle', null, '审计详情')) + '</h2>' +
'<span class="modal-close" onclick="closeAuditDetailModal()">&times;</span>' +
'</div>' +
'<div class="modal-body audit-detail-body">' +
'<p><strong>' + esc(auditT('settingsAudit.detailTime', null, '时间')) + ':</strong> ' + esc(formatAuditTime(log.createdAt)) + '</p>' +
'<p><strong>' + esc(auditT('settingsAudit.detailCategory', null, '类别')) + ':</strong> ' + catAction + '</p>' +
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(log.result || '') + '</p>' +
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(log.message || '') + '</p>' +
(log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') +
(log.sessionHint ? '<p><strong>' + esc(auditT('settingsAudit.detailSession', null, '会话')) + ':</strong> ' + esc(log.sessionHint) + '</p>' : '') +
(log.userAgent ? '<p><strong>UA:</strong> ' + esc(log.userAgent) + '</p>' : '') +
auditResourceMeta(log) +
(detail ? '<pre class="audit-detail-pre">' + esc(detail) + '</pre>' : '') +
'</div>' +
'<div class="modal-footer"><button type="button" class="btn-secondary" onclick="closeAuditDetailModal()">' +
esc(auditT('common.close', null, '关闭')) + '</button></div>' +
'</div>';
const chatBtn = overlay.querySelector('.audit-open-chat-btn');
if (chatBtn) {
chatBtn.addEventListener('click', function () {
auditOpenConversationChat(chatBtn.getAttribute('data-conversation-id'));
});
}
overlay.addEventListener('click', function (ev) {
if (ev.target === overlay) closeAuditDetailModal();
});
}
overlay.addEventListener('click', function (ev) {
if (ev.target === overlay) closeAuditDetailModal();
});
} catch (e) {
closeAuditDetailModal();
if (typeof showToast === 'function') {
showToast(e.message || String(e), 'error');
}
+3 -5
View File
@@ -72,7 +72,7 @@ function showLoginOverlay(message = '') {
if (!overlay) {
return;
}
overlay.style.display = 'flex';
openAppModal('login-overlay', { focus: false });
if (errorBox) {
if (message) {
errorBox.textContent = message;
@@ -82,7 +82,7 @@ function showLoginOverlay(message = '') {
errorBox.style.display = 'none';
}
}
setTimeout(() => {
setTimeout(function () {
if (passwordInput) {
passwordInput.focus();
}
@@ -93,9 +93,7 @@ function hideLoginOverlay() {
const overlay = document.getElementById('login-overlay');
const errorBox = document.getElementById('login-error');
const passwordInput = document.getElementById('login-password');
if (overlay) {
overlay.style.display = 'none';
}
closeAppModal('login-overlay');
if (errorBox) {
errorBox.textContent = '';
errorBox.style.display = 'none';
+5 -5
View File
@@ -478,7 +478,7 @@
const content = document.getElementById('c2-modal-content');
if (!content || !modal) return;
modal.style.display = 'flex';
openAppModal(modal);
content.innerHTML = `
<div class="c2-modal-header">
<h3>${escapeHtml(c2t('c2.listeners.modalCreateTitle'))}</h3>
@@ -635,7 +635,7 @@
const content = document.getElementById('c2-modal-content');
if (!content || !modal) return;
modal.style.display = 'flex';
openAppModal(modal);
content.innerHTML = `
<div class="c2-modal-header">
<h3>${escapeHtml(c2t('c2.listeners.editTitle'))}</h3>
@@ -2376,7 +2376,7 @@
<button class="btn-secondary" onclick="C2.closeModal()">${escapeHtml(c2t('common.close'))}</button>
</div>
`;
modal.style.display = 'flex';
openAppModal(modal);
};
const local = C2.tasks.find(x => x.id === id);
@@ -2920,7 +2920,7 @@
<button class="btn-primary" onclick="C2.createProfile()">${escapeHtml(c2t('c2.profiles.submitCreate'))}</button>
</div>
`;
modal.style.display = 'flex';
openAppModal(modal);
};
C2.createProfile = function() {
@@ -2981,10 +2981,10 @@
C2.closeModal = function() {
const modal = document.getElementById('c2-modal');
if (modal) {
modal.style.display = 'none';
const modalBox = modal.querySelector('.c2-modal');
if (modalBox) modalBox.classList.remove('c2-modal--wide');
}
closeAppModal('c2-modal');
};
// ============================================================================
+12 -11
View File
@@ -1002,7 +1002,7 @@ async function openChatFilesEdit(relativePath) {
const modal = document.getElementById('chat-files-edit-modal');
if (pathEl) pathEl.textContent = relativePath;
if (ta) ta.value = '';
if (modal) modal.style.display = 'block';
openAppModal('chat-files-edit-modal', { focus: false });
try {
const res = await apiFetch('/api/chat-uploads/content?path=' + encodeURIComponent(relativePath));
@@ -1017,16 +1017,19 @@ async function openChatFilesEdit(relativePath) {
throw new Error(errText || res.status);
}
const data = await res.json();
if (ta) ta.value = data.content != null ? String(data.content) : '';
const content = data.content != null ? String(data.content) : '';
deferModalContent(() => {
if (ta) ta.value = content;
ta?.focus();
});
} catch (e) {
if (modal) modal.style.display = 'none';
closeAppModal('chat-files-edit-modal');
alert(chatFilesAlertMessage(e && e.message));
}
}
function closeChatFilesEditModal() {
const modal = document.getElementById('chat-files-edit-modal');
if (modal) modal.style.display = 'none';
closeAppModal('chat-files-edit-modal');
chatFilesEditRelativePath = '';
}
@@ -1060,7 +1063,7 @@ function openChatFilesRename(relativePath, currentName) {
input.value = currentName || '';
input.select();
}
if (modal) modal.style.display = 'flex';
if (modal) openAppModal(modal);
if (modal && typeof window.applyTranslations === 'function') {
window.applyTranslations(modal);
}
@@ -1068,8 +1071,7 @@ function openChatFilesRename(relativePath, currentName) {
}
function closeChatFilesRenameModal() {
const modal = document.getElementById('chat-files-rename-modal');
if (modal) modal.style.display = 'none';
closeAppModal('chat-files-rename-modal');
const hint = document.getElementById('chat-files-rename-path-hint');
if (hint) hint.textContent = '';
chatFilesRenameRelativePath = '';
@@ -1106,7 +1108,7 @@ function openChatFilesMkdirModal() {
const p = chatFilesBrowsePath.join('/');
if (hint) hint.textContent = p ? ('chat_uploads/' + p) : 'chat_uploads';
if (input) input.value = '';
if (modal) modal.style.display = 'flex';
if (modal) openAppModal(modal);
if (modal && typeof window.applyTranslations === 'function') {
window.applyTranslations(modal);
}
@@ -1116,8 +1118,7 @@ function openChatFilesMkdirModal() {
}
function closeChatFilesMkdirModal() {
const modal = document.getElementById('chat-files-mkdir-modal');
if (modal) modal.style.display = 'none';
closeAppModal('chat-files-mkdir-modal');
const input = document.getElementById('chat-files-mkdir-input');
if (input) input.value = '';
}
+170 -81
View File
@@ -982,6 +982,24 @@ async function sendMessage() {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const dispatchStreamEvent = function (eventData) {
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
};
const processSseLines = typeof processSseDataLinesYielding === 'function'
? processSseDataLinesYielding
: async function (lines, onEvent) {
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
onEvent(JSON.parse(line.slice(6)));
} catch (e) {
console.error('解析事件数据失败:', e, line);
}
}
}
};
while (true) {
const { done, value } = await reader.read();
@@ -991,18 +1009,7 @@ async function sendMessage() {
const lines = buffer.split('\n');
buffer = lines.pop(); // 保留最后一个不完整的行
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
} catch (e) {
console.error('解析事件数据失败:', e, line);
}
}
}
await processSseLines(lines, dispatchStreamEvent);
}
// Flush decoder internal buffer to avoid losing the final partial UTF-8 code point.
buffer += decoder.decode();
@@ -1010,18 +1017,7 @@ async function sendMessage() {
// 处理剩余的buffer
if (buffer.trim()) {
const lines = buffer.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
} catch (e) {
console.error('解析事件数据失败:', e, line);
}
}
}
await processSseLines(lines, dispatchStreamEvent);
}
} finally {
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
@@ -2168,6 +2164,97 @@ function showCopySuccess(button) {
}
}
/** Claude extended thinking 内部尾缀(与后端 DisplayReasoningContent 一致,UI 不展示) */
const CLAUDE_REASONING_UI_SUFFIX = '\n---CSAI_CLAUDE_THINKING_BLOCKS---\n';
function normalizeReasoningContentForDisplay(text) {
if (text == null) return '';
let s = String(text).trim();
if (!s) return '';
const idx = s.lastIndexOf(CLAUDE_REASONING_UI_SUFFIX);
if (idx >= 0) {
s = s.slice(0, idx).trim();
}
return s;
}
function setMessageReasoningContent(messageIdOrEl, reasoningContent) {
const el = typeof messageIdOrEl === 'string' ? document.getElementById(messageIdOrEl) : messageIdOrEl;
if (!el || !el.dataset) return;
const rc = normalizeReasoningContentForDisplay(reasoningContent);
if (rc) {
el.dataset.reasoningContent = rc;
} else {
delete el.dataset.reasoningContent;
}
}
function getMessageReasoningContent(messageIdOrEl) {
const el = typeof messageIdOrEl === 'string' ? document.getElementById(messageIdOrEl) : messageIdOrEl;
if (!el || !el.dataset) return '';
return normalizeReasoningContentForDisplay(el.dataset.reasoningContent || '');
}
function reasoningTextAlreadyInProcessDetails(processDetails, rc) {
if (!rc) return true;
const list = Array.isArray(processDetails) ? processDetails : [];
for (let i = 0; i < list.length; i++) {
const d = list[i];
if (!d) continue;
const et = d.eventType || '';
if (et !== 'reasoning_chain' && et !== 'thinking') continue;
const msg = normalizeReasoningContentForDisplay(d.message || '');
if (!msg) continue;
if (msg === rc || msg.includes(rc) || rc.includes(msg)) {
return true;
}
}
return false;
}
/** 合并 messages.reasoningContent 与 process_details 中的 reasoning_chain,两者都读、都展示(去重后) */
function mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningContent) {
const rc = normalizeReasoningContentForDisplay(reasoningContent);
const details = Array.isArray(processDetails) ? processDetails.slice() : [];
if (!rc || reasoningTextAlreadyInProcessDetails(details, rc)) {
return details;
}
details.push({
eventType: 'reasoning_chain',
message: rc,
data: { source: 'message.reasoningContent' }
});
return details;
}
async function syncAssistantReasoningContentFromServer(backendMessageId, domAssistantId) {
if (!backendMessageId || !domAssistantId || !currentConversationId || typeof apiFetch !== 'function') {
return;
}
try {
const convRes = await apiFetch(`/api/conversations/${encodeURIComponent(currentConversationId)}?include_process_details=0`);
const conv = await convRes.json().catch(() => ({}));
if (!convRes.ok || !Array.isArray(conv.messages)) return;
const msg = conv.messages.find((m) => m && String(m.id) === String(backendMessageId));
if (!msg || !msg.reasoningContent) return;
setMessageReasoningContent(domAssistantId, msg.reasoningContent);
const pdRes = await apiFetch(`/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details`);
const pdJson = await pdRes.json().catch(() => ({}));
const details = pdRes.ok && Array.isArray(pdJson.processDetails) ? pdJson.processDetails : [];
if (typeof renderProcessDetails === 'function') {
renderProcessDetails(domAssistantId, details);
}
} catch (e) {
console.warn('syncAssistantReasoningContentFromServer failed', e);
}
}
window.normalizeReasoningContentForDisplay = normalizeReasoningContentForDisplay;
window.setMessageReasoningContent = setMessageReasoningContent;
window.getMessageReasoningContent = getMessageReasoningContent;
window.mergeMessageReasoningContentIntoProcessDetails = mergeMessageReasoningContentIntoProcessDetails;
window.syncAssistantReasoningContentFromServer = syncAssistantReasoningContentFromServer;
/** 相邻且类型/正文/data 完全一致的过程详情只保留一条(与后端去重一致,避免时间线叠多条相同块) */
function dedupeConsecutiveProcessDetailRows(details) {
if (!Array.isArray(details) || details.length < 2) {
@@ -2286,20 +2373,27 @@ function renderProcessDetails(messageId, processDetails) {
detailsContainer.appendChild(contentDiv);
}
// processDetails === null 表示“尚未加载(懒加载)”
// processDetails === null 表示“尚未加载(懒加载)”messages.reasoningContent 可先展示
const isLazyNotLoaded = (processDetails === null);
if (isLazyNotLoaded) {
const reasoningFromMessage = getMessageReasoningContent(messageElement);
if (isLazyNotLoaded && !reasoningFromMessage) {
detailsContainer.dataset.lazyNotLoaded = '1';
detailsContainer.dataset.loaded = '0';
timeline.innerHTML = '<div class="progress-timeline-empty">' +
(typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
'(点击后加载)</div>';
// 默认折叠
timeline.classList.remove('expanded');
return;
}
detailsContainer.dataset.lazyNotLoaded = '0';
detailsContainer.dataset.loaded = '1';
if (isLazyNotLoaded) {
detailsContainer.dataset.lazyNotLoaded = '1';
detailsContainer.dataset.loaded = '0';
processDetails = [];
} else {
detailsContainer.dataset.lazyNotLoaded = '0';
detailsContainer.dataset.loaded = '1';
}
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
@@ -2384,6 +2478,10 @@ function renderProcessDetails(messageId, processDetails) {
itemTitle = agPx + execLine;
} else if (eventType === 'eino_agent_reply') {
itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
} else if (eventType === 'eino_empty_response_continue') {
itemTitle = typeof window.t === 'function'
? window.t('chat.einoEmptyResponseContinueTitle')
: '🔁 自动续跑(无助手正文)';
} else if (eventType === 'eino_run_retry') {
itemTitle = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle')
@@ -2426,6 +2524,14 @@ function renderProcessDetails(messageId, processDetails) {
}
addTimelineItem(timeline, eventType, timelineOpts);
});
if (isLazyNotLoaded && reasoningFromMessage) {
const lazyHint = document.createElement('div');
lazyHint.className = 'progress-timeline-empty progress-timeline-lazy-hint';
lazyHint.textContent = (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
'(点击后加载完整过程详情)';
timeline.appendChild(lazyHint);
}
// 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理)
const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt');
@@ -2535,10 +2641,17 @@ async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
// 显示MCP调用详情
async function showMCPDetail(executionId) {
try {
openAppModal('mcp-detail-modal', { focus: false });
const response = await apiFetch(`/api/monitor/execution/${executionId}`);
const exec = await response.json();
if (response.ok) {
if (!response.ok) {
closeMCPDetail();
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + (exec.error || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : '未知错误')));
return;
}
deferModalContent(function () {
// 填充模态框内容
document.getElementById('detail-tool-name').textContent = exec.toolName || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : 'Unknown');
document.getElementById('detail-execution-id').textContent = exec.id || 'N/A';
@@ -2645,20 +2758,16 @@ async function showMCPDetail(executionId) {
delete abortBtn.dataset.execId;
}
}
// 显示模态框
document.getElementById('mcp-detail-modal').style.display = 'block';
} else {
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + (exec.error || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : '未知错误')));
}
});
} catch (error) {
closeMCPDetail();
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + error.message);
}
}
// 关闭MCP详情模态框
function closeMCPDetail() {
document.getElementById('mcp-detail-modal').style.display = 'none';
closeAppModal('mcp-detail-modal');
}
/** 从详情模态框触发:取消当前进行中的 MCP 工具调用 */
@@ -2682,18 +2791,12 @@ function openMcpToolAbortModal(executionId, options = {}) {
if (ta) {
ta.value = '';
}
const m = document.getElementById('mcp-tool-abort-modal');
if (m) {
m.style.display = 'block';
}
openAppModal('mcp-tool-abort-modal');
}
function closeMcpToolAbortModal() {
window.__mcpToolAbortContext = null;
const m = document.getElementById('mcp-tool-abort-modal');
if (m) {
m.style.display = 'none';
}
closeAppModal('mcp-tool-abort-modal');
}
async function submitMcpToolAbortModal() {
@@ -2846,10 +2949,12 @@ async function startNewConversation() {
} catch (e) { /* ignore */ }
currentConversationGroupId = null; // 新对话不属于任何分组
if (typeof ensureDefaultActiveProjectForNewChat === 'function') {
ensureDefaultActiveProjectForNewChat().catch(() => {});
try {
await ensureDefaultActiveProjectForNewChat();
} catch (e) { /* ignore */ }
}
if (typeof refreshChatProjectSelector === 'function') {
refreshChatProjectSelector();
await refreshChatProjectSelector();
}
document.getElementById('chat-messages').innerHTML = '';
const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
@@ -3125,7 +3230,7 @@ async function loadConversation(conversationId) {
// 如果攻击链模态框打开且显示的不是当前对话,关闭它
const attackChainModal = document.getElementById('attack-chain-modal');
if (attackChainModal && attackChainModal.style.display === 'block') {
if (attackChainModal && isAppModalOpen('attack-chain-modal')) {
if (currentAttackChainConversationId !== conversationId) {
closeAttackChainModal();
}
@@ -3194,6 +3299,9 @@ async function loadConversation(conversationId) {
attachDeleteTurnButton(messageEl);
}
if (msg.role === 'assistant') {
if (messageEl && msg.reasoningContent) {
setMessageReasoningContent(messageEl, msg.reasoningContent);
}
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
if (msg.processDetails && msg.processDetails.length > 0) {
@@ -3369,7 +3477,7 @@ async function deleteConversationTurnFromUI(anchorBackendMessageId) {
async function deleteConversation(conversationId, skipConfirm = false) {
// 确认删除(如果调用者没有跳过确认)
if (!skipConfirm) {
if (!confirm('确定要删除这个对话吗?此操作不可恢复。')) {
if (!confirm('确定要删除这个对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。')) {
return;
}
}
@@ -3415,7 +3523,7 @@ async function deleteConversation(conversationId, skipConfirm = false) {
// 批量管理弹窗打开时,同步刷新弹窗内列表
const batchModal = document.getElementById('batch-manage-modal');
if (batchModal && batchModal.style.display === 'flex') {
if (batchModal && isAppModalOpen('batch-manage-modal')) {
allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId);
updateBatchManageTitle(allConversationsForBatch.length);
const searchInput = document.getElementById('batch-search-input');
@@ -3522,7 +3630,7 @@ async function showAttackChain(conversationId) {
if (isAttackChainLoading(conversationId) && currentAttackChainConversationId === conversationId) {
// 如果模态框已经打开且显示的是同一个对话,不重复打开
const modal = document.getElementById('attack-chain-modal');
if (modal && modal.style.display === 'block') {
if (modal && isAppModalOpen('attack-chain-modal')) {
console.log('攻击链正在加载中,模态框已打开');
return;
}
@@ -3535,8 +3643,7 @@ async function showAttackChain(conversationId) {
return;
}
modal.style.display = 'block';
// 打开时立即按当前语言刷新统计(避免红框内仍显示硬编码中文)
openAppModal('attack-chain-modal', { focus: false });
updateAttackChainStats({ nodes: [], edges: [] });
// 清空容器
@@ -4668,10 +4775,7 @@ function closeNodeDetails() {
// 关闭攻击链模态框
function closeAttackChainModal() {
const modal = document.getElementById('attack-chain-modal');
if (modal) {
modal.style.display = 'none';
}
closeAppModal('attack-chain-modal');
// 关闭节点详情
closeNodeDetails();
@@ -7214,19 +7318,14 @@ async function showBatchManageModal() {
updateBatchManageTitle(allConversationsForBatch.length);
renderBatchConversations();
if (modal) {
modal.style.display = 'flex';
}
openAppModal('batch-manage-modal');
} catch (error) {
console.error('加载对话列表失败:', error);
// 错误时使用空数组,不显示错误提示(更友好的用户体验)
allConversationsForBatch = [];
const modal = document.getElementById('batch-manage-modal');
updateBatchManageTitle(0);
if (modal) {
renderBatchConversations();
modal.style.display = 'flex';
}
renderBatchConversations();
openAppModal('batch-manage-modal');
}
}
@@ -7381,10 +7480,7 @@ async function deleteSelectedConversations() {
// 关闭批量管理模态框
function closeBatchManageModal() {
const modal = document.getElementById('batch-manage-modal');
if (modal) {
modal.style.display = 'none';
}
closeAppModal('batch-manage-modal');
const selectAll = document.getElementById('batch-select-all');
if (selectAll) {
selectAll.checked = false;
@@ -7424,8 +7520,7 @@ function refreshChatPanelI18n() {
});
}
const mcpModal = document.getElementById('mcp-detail-modal');
if (mcpModal && mcpModal.style.display === 'block') {
if (isAppModalOpen('mcp-detail-modal')) {
const detailTimeEl = document.getElementById('detail-time');
if (detailTimeEl && detailTimeEl.dataset.detailTimeIso) {
try {
@@ -7447,7 +7542,7 @@ document.addEventListener('languagechange', function () {
refreshSystemReadyMessageBubbles();
refreshChatPanelI18n();
const modal = document.getElementById('batch-manage-modal');
if (modal && modal.style.display === 'flex') {
if (isAppModalOpen('batch-manage-modal')) {
updateBatchManageTitle(allConversationsForBatch.length);
}
// 侧边栏最近对话等列表的时间戳会随语言变化(24h/12h 等),重新拉列表以统一格式
@@ -7482,20 +7577,14 @@ function showCreateGroupModal(andMoveConversation = false) {
iconPicker.style.display = 'none';
}
if (modal) {
modal.style.display = 'flex';
openAppModal('create-group-modal', { focusEl: input });
modal.dataset.moveConversation = andMoveConversation ? 'true' : 'false';
if (input) {
setTimeout(() => input.focus(), 100);
}
}
}
// 关闭创建分组模态框
function closeCreateGroupModal() {
const modal = document.getElementById('create-group-modal');
if (modal) {
modal.style.display = 'none';
}
closeAppModal('create-group-modal');
const input = document.getElementById('create-group-name-input');
if (input) {
input.value = '';
+3 -1
View File
@@ -131,7 +131,7 @@ async function refreshDashboard() {
openVulnQuery('low'),
// 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。
// 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。
fetchJson('/api/config/tools?page=1&page_size=1'),
fetchJson('/api/config/tools?page=1&page_size=1&include_external=false'),
// HITL 待审批:用于「需要立即处理」告警条 + 推荐操作
fetchJson('/api/hitl/pending'),
// 通知摘要:since=0 拿最新一批,limit 控制大小;用于「最近事件」内联展示
@@ -1459,6 +1459,7 @@ function statusKey(s) {
if (s === 'fixed' || s === 'closed' || s === 'resolved') return 'fixed';
if (s === 'confirmed') return 'confirmed';
if (s === 'false_positive' || s === 'false-positive' || s === 'fp') return 'fp';
if (s === 'ignored') return 'ignored';
return 'open';
}
@@ -1467,6 +1468,7 @@ function statusShortLabel(s) {
if (k === 'fixed') return dt('dashboard.statusFixed', null, '已修复');
if (k === 'confirmed') return dt('dashboard.statusConfirmed', null, '已确认');
if (k === 'fp') return dt('dashboard.statusFalsePositive', null, '误报');
if (k === 'ignored') return dt('dashboard.statusIgnored', null, '已忽略');
return dt('dashboard.statusOpen', null, '待处理');
}
+18 -10
View File
@@ -344,7 +344,9 @@ function showFofaParseModal(nlText, parsed) {
const modal = document.createElement('div');
modal.id = 'fofa-parse-modal';
modal.className = 'modal';
modal.style.display = 'block';
document.body.appendChild(modal);
openAppModal(modal, { focus: false });
deferModalContent(function () {
modal.innerHTML = `
<div class="modal-content" style="max-width: 900px;">
<div class="modal-header">
@@ -384,24 +386,24 @@ function showFofaParseModal(nlText, parsed) {
</div>
`;
document.body.appendChild(modal);
const queryTextarea = document.getElementById('fofa-parse-query');
if (queryTextarea) {
queryTextarea.value = (parsed?.query || '').trim();
setTimeout(() => {
try { queryTextarea.focus(); } catch (e) { /* ignore */ }
}, 0);
queryTextarea.focus();
}
const close = () => modal.remove();
modal.addEventListener('click', (e) => {
const close = function () {
closeAppModal(modal);
modal.remove();
syncAppModalBodyLock();
};
modal.addEventListener('click', function (e) {
if (e.target === modal) close();
});
document.getElementById('fofa-parse-modal-close')?.addEventListener('click', close);
document.getElementById('fofa-parse-cancel')?.addEventListener('click', close);
const applyToQuery = (run) => {
const applyToQuery = function (run) {
const els = getFofaFormElements();
const q = (queryTextarea?.value || '').trim();
if (!q) {
@@ -435,6 +437,7 @@ function showFofaParseModal(nlText, parsed) {
}
};
document.addEventListener('keydown', onKey);
});
}
function setFofaMeta(text) {
@@ -1091,8 +1094,13 @@ function showCellDetailModal(field, fullText) {
`;
document.body.appendChild(modal);
openAppModal(modal);
const close = () => modal.remove();
const close = function () {
closeAppModal(modal);
modal.remove();
syncAppModalBodyLock();
};
modal.addEventListener('click', (e) => {
if (e.target === modal) close();
});
+24 -20
View File
@@ -905,25 +905,32 @@ function showAddKnowledgeItemModal() {
document.getElementById('knowledge-item-category').value = '';
document.getElementById('knowledge-item-title').value = '';
document.getElementById('knowledge-item-content').value = '';
document.getElementById('knowledge-item-modal').style.display = 'block';
openAppModal('knowledge-item-modal');
}
// 编辑知识项
async function editKnowledgeItem(id) {
try {
currentEditingItemId = id;
document.getElementById('knowledge-item-modal-title').textContent = '编辑知识';
document.getElementById('knowledge-item-category').value = '';
document.getElementById('knowledge-item-title').value = '';
document.getElementById('knowledge-item-content').value = '';
openAppModal('knowledge-item-modal', { focus: false });
const response = await apiFetch(`/api/knowledge/items/${id}`);
if (!response.ok) {
throw new Error('获取知识项失败');
}
const item = await response.json();
currentEditingItemId = id;
document.getElementById('knowledge-item-modal-title').textContent = '编辑知识';
document.getElementById('knowledge-item-category').value = item.category;
document.getElementById('knowledge-item-title').value = item.title;
document.getElementById('knowledge-item-content').value = item.content;
document.getElementById('knowledge-item-modal').style.display = 'block';
deferModalContent(() => {
document.getElementById('knowledge-item-category').value = item.category;
document.getElementById('knowledge-item-title').value = item.title;
document.getElementById('knowledge-item-content').value = item.content;
document.getElementById('knowledge-item-title')?.focus();
});
} catch (error) {
closeAppModal('knowledge-item-modal');
currentEditingItemId = null;
console.error('编辑知识项失败:', error);
showNotification('编辑知识项失败: ' + error.message, 'error');
}
@@ -1232,10 +1239,7 @@ function updateKnowledgeStatsAfterDelete() {
// 关闭知识项模态框
function closeKnowledgeItemModal() {
const modal = document.getElementById('knowledge-item-modal');
if (modal) {
modal.style.display = 'none';
}
closeAppModal('knowledge-item-modal');
// 重置编辑状态
currentEditingItemId = null;
@@ -1786,8 +1790,11 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
document.body.appendChild(modal);
}
// 填充内容
const content = document.getElementById('retrieval-log-details-content');
if (content) content.innerHTML = '<p style="color:#64748b;margin:0;">…</p>';
openAppModal(modal, { focus: false });
deferModalContent(() => {
const timeAgo = getTimeAgo(log.createdAt);
const fullTime = formatTime(log.createdAt);
@@ -1880,16 +1887,12 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
</div>
</div>
`;
modal.style.display = 'block';
});
}
// 关闭检索日志详情模态框
function closeRetrievalLogDetailsModal() {
const modal = document.getElementById('retrieval-log-details-modal');
if (modal) {
modal.style.display = 'none';
}
closeAppModal('retrieval-log-details-modal');
}
// 点击模态框外部关闭
@@ -2118,7 +2121,8 @@ function showToastNotification(message, type = 'info') {
font-size: 0.875rem;
line-height: 1.45;
word-wrap: break-word;
backdrop-filter: blur(8px);
backdrop-filter: none;
-webkit-backdrop-filter: none;
`;
toast.innerHTML = `
+92
View File
@@ -0,0 +1,92 @@
/**
* 统一弹窗先显示遮罩下一帧再填大段内容避免与 backdrop 绘制抢主线程
*/
(function () {
const BODY_LOCK = 'app-modal-open';
const LEGACY_BODY_LOCK = 'projects-modal-open';
const OVERLAY_SELECTOR =
'.projects-modal-overlay, .c2-modal-overlay, .modal, .info-collect-cell-modal, #login-overlay';
const FLEX_MODAL_IDS = new Set([
'role-modal',
'skill-modal',
'agent-md-modal',
'batch-manage-modal',
'create-group-modal',
'login-overlay',
]);
function resolveEl(idOrEl) {
if (!idOrEl) return null;
return typeof idOrEl === 'string' ? document.getElementById(idOrEl) : idOrEl;
}
function isElVisible(el) {
if (!el) return false;
const s = window.getComputedStyle(el);
return s.display !== 'none' && s.visibility !== 'hidden';
}
function defaultDisplay(el) {
if (el.classList.contains('projects-modal-overlay') || el.classList.contains('c2-modal-overlay')) {
return 'flex';
}
if (el.classList.contains('info-collect-cell-modal')) {
return 'flex';
}
if (FLEX_MODAL_IDS.has(el.id)) {
return 'flex';
}
return 'block';
}
function syncBodyLock() {
const anyOpen = Array.from(document.querySelectorAll(OVERLAY_SELECTOR)).some(isElVisible);
document.body.classList.toggle(BODY_LOCK, anyOpen);
const projectsOpen = Array.from(document.querySelectorAll('.projects-modal-overlay')).some(isElVisible);
document.body.classList.toggle(LEGACY_BODY_LOCK, projectsOpen);
}
function openAppModal(idOrEl, opts) {
opts = opts || {};
const el = resolveEl(idOrEl);
if (!el) return null;
el.style.display = opts.display || defaultDisplay(el);
syncBodyLock();
if (opts.focus === false) return el;
const sel =
opts.focusSelector ||
'input.form-input, textarea.form-input, select.form-input, input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])';
const focusTarget = opts.focusEl || el.querySelector(sel);
if (focusTarget) {
requestAnimationFrame(function () {
focusTarget.focus();
});
}
return el;
}
function closeAppModal(idOrEl) {
const el = resolveEl(idOrEl);
if (el) el.style.display = 'none';
syncBodyLock();
return el;
}
function isAppModalOpen(idOrEl) {
return isElVisible(resolveEl(idOrEl));
}
/** 双 rAF:等遮罩绘制完成后再写入大段 DOM / 表单 */
function deferModalContent(fn) {
requestAnimationFrame(function () {
requestAnimationFrame(fn);
});
}
window.openAppModal = openAppModal;
window.closeAppModal = closeAppModal;
window.isAppModalOpen = isAppModalOpen;
window.deferModalContent = deferModalContent;
window.syncAppModalBodyLock = syncBodyLock;
})();
+279 -69
View File
@@ -31,6 +31,25 @@ function shouldSkipTaskEventReplayAttach(conversationId) {
return false;
}
}
/** 监控页展示:内部 mcp::tool → 模型侧 mcp__tool */
function formatMonitorToolName(name) {
if (!name || typeof name !== 'string') return name || '';
return name.includes('::') ? name.replace('::', '__') : name;
}
/** 筛选/APImcp__tool → 内部 mcp::tool(与库存一致) */
function canonicalMonitorToolName(name) {
if (!name || typeof name !== 'string') return name || '';
if (name.includes('::')) return name;
const idx = name.indexOf('__');
if (idx > 0) return `${name.slice(0, idx)}::${name.slice(idx + 2)}`;
return name;
}
function monitorToolNamesEqual(a, b) {
return canonicalMonitorToolName(a) === canonicalMonitorToolName(b);
}
if (typeof window !== 'undefined') {
window.shouldSkipTaskEventReplayAttach = shouldSkipTaskEventReplayAttach;
}
@@ -153,6 +172,59 @@ function einoMainStreamPlanningTitle(responseData) {
return prefix + '📝 ' + plan;
}
/**
* 主通道 response 结束时将流式占位条目固化为 planning与后端 flushResponsePlan 落库类型一致
* 避免 integrateProgressToMCPSection 快照前删除占位导致助手输出仅刷新后才出现
*/
function finalizeMainResponseStreamItem(streamState, finalMessage, responseData) {
if (!streamState || !streamState.itemId) return false;
const item = document.getElementById(streamState.itemId);
if (!item || !item.parentNode) return false;
const fullText = (finalMessage != null && String(finalMessage).trim() !== '')
? String(finalMessage)
: (streamState.buffer || '');
if (!String(fullText).trim()) {
item.parentNode.removeChild(item);
return false;
}
const meta = Object.assign({}, streamState.streamMeta || {}, responseData || {});
item.classList.remove('timeline-item-thinking');
item.classList.add('timeline-item-planning');
item.dataset.timelineType = 'planning';
delete item.dataset.responseStreamPlaceholder;
if (meta.orchestration != null && String(meta.orchestration).trim() !== '') {
item.dataset.orchestration = String(meta.orchestration).trim();
}
if (meta.einoAgent != null && String(meta.einoAgent).trim() !== '') {
item.dataset.einoAgent = String(meta.einoAgent).trim();
}
const titleEl = item.querySelector('.timeline-item-title');
if (titleEl && typeof einoMainStreamPlanningTitle === 'function') {
titleEl.textContent = einoMainStreamPlanningTitle(meta);
}
let contentEl = item.querySelector('.timeline-item-content');
if (!contentEl) {
contentEl = document.createElement('div');
contentEl.className = 'timeline-item-content';
item.appendChild(contentEl);
}
flushStreamPlainTextUpdate(contentEl);
const body = typeof formatTimelineStreamBody === 'function'
? formatTimelineStreamBody(fullText, meta)
: fullText;
if (typeof formatMarkdown === 'function') {
setTimelineItemContentStreamRich(contentEl, formatMarkdown(body, timelineMarkdownOpts));
} else {
setTimelineItemContentStreamPlain(contentEl, body);
}
return true;
}
function translateProgressMessage(message, data) {
if (!message || typeof message !== 'string') return message;
if (typeof window.t !== 'function') return message;
@@ -205,6 +277,7 @@ if (typeof window !== 'undefined') {
window.translateProgressMessage = translateProgressMessage;
window.translatePlanExecuteAgentName = translatePlanExecuteAgentName;
window.einoMainStreamPlanningTitle = einoMainStreamPlanningTitle;
window.finalizeMainResponseStreamItem = finalizeMainResponseStreamItem;
window.formatTimelineStreamBody = formatTimelineStreamBody;
}
@@ -638,18 +711,126 @@ function mergeStreamBuffer(current, delta, data) {
if (typeof window !== 'undefined') {
window.streamBufferFromAccumulated = streamBufferFromAccumulated;
window.mergeStreamBuffer = mergeStreamBuffer;
window.processSseDataLinesYielding = processSseDataLinesYielding;
window.flushStreamPlainTextUpdate = flushStreamPlainTextUpdate;
window.scheduleStreamPlainTextUpdate = scheduleStreamPlainTextUpdate;
}
/** 流式纯文本 DOM:按帧合并更新,尽量增量 appendData,避免每条 SSE 全量 textContent 阻塞主线程 */
const streamPlainDomState = new WeakMap();
/** 跟踪仍有待刷新的流式节点,便于快照时间线前一次性 flush */
const streamPlainDomPendingElements = new Set();
function applyStreamPlainTextNow(contentEl, text, state) {
if (!contentEl) return;
const full = text == null ? '' : String(text);
const prevLen = state && state.renderedLen ? state.renderedLen : 0;
contentEl.classList.add('timeline-stream-plain');
if (full.length > prevLen && contentEl.childNodes.length === 1 &&
contentEl.firstChild && contentEl.firstChild.nodeType === Node.TEXT_NODE) {
const existing = contentEl.firstChild.nodeValue || '';
if (existing.length === prevLen && full.startsWith(existing)) {
const delta = full.slice(prevLen);
if (delta) {
contentEl.firstChild.appendData(delta);
if (state) {
state.renderedLen = full.length;
state.pendingText = full;
}
return;
}
}
}
contentEl.textContent = full;
if (state) {
state.renderedLen = full.length;
state.pendingText = full;
}
}
function flushStreamPlainTextUpdate(contentEl) {
if (!contentEl) return;
const state = streamPlainDomState.get(contentEl);
if (!state) return;
if (state.rafId) {
cancelAnimationFrame(state.rafId);
state.rafId = 0;
}
applyStreamPlainTextNow(contentEl, state.pendingText, state);
}
function scheduleStreamPlainTextUpdate(contentEl, text) {
if (!contentEl) return;
const full = text == null ? '' : String(text);
let state = streamPlainDomState.get(contentEl);
if (!state) {
state = { pendingText: full, rafId: 0, renderedLen: 0 };
streamPlainDomState.set(contentEl, state);
} else {
state.pendingText = full;
}
streamPlainDomPendingElements.add(contentEl);
if (state.rafId) return;
state.rafId = requestAnimationFrame(function () {
state.rafId = 0;
applyStreamPlainTextNow(contentEl, state.pendingText, state);
});
}
function resetStreamPlainTextState(contentEl) {
if (!contentEl) return;
const state = streamPlainDomState.get(contentEl);
if (state && state.rafId) {
cancelAnimationFrame(state.rafId);
}
streamPlainDomState.delete(contentEl);
streamPlainDomPendingElements.delete(contentEl);
}
function flushAllPendingStreamPlainUpdates() {
streamPlainDomPendingElements.forEach(function (el) {
if (el && el.isConnected) {
flushStreamPlainTextUpdate(el);
}
});
}
/** 流式 delta:纯文本,避免每条全量 marked + DOMPurify */
function setTimelineItemContentStreamPlain(contentEl, text) {
if (!contentEl) return;
contentEl.classList.add('timeline-stream-plain');
contentEl.textContent = text == null ? '' : String(text);
resetStreamPlainTextState(contentEl);
applyStreamPlainTextNow(contentEl, text, null);
}
/**
* 分批处理 SSE data 行并在批间让出主线程避免单次 read() 内数百条事件连续阻塞 UI
* @param {string[]} lines
* @param {(event: object) => void} onEvent
* @param {{ yieldEvery?: number }} [options]
*/
async function processSseDataLinesYielding(lines, onEvent, options) {
const yieldEvery = (options && options.yieldEvery) || 32;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('data: ')) {
try {
onEvent(JSON.parse(line.slice(6)));
} catch (e) {
console.error('解析事件数据失败:', e, line);
}
}
if ((i + 1) % yieldEvery === 0 && i + 1 < lines.length) {
await new Promise(function (resolve) { requestAnimationFrame(resolve); });
}
}
}
/** 流结束或非流式:富文本(已消毒的 HTML 字符串) */
function setTimelineItemContentStreamRich(contentEl, html) {
if (!contentEl) return;
resetStreamPlainTextState(contentEl);
contentEl.classList.remove('timeline-stream-plain');
contentEl.innerHTML = html;
}
@@ -817,18 +998,12 @@ function openUserInterruptModal(progressId, conversationId) {
if (ta) {
ta.value = '';
}
const m = document.getElementById('user-interrupt-modal');
if (m) {
m.style.display = 'block';
}
openAppModal('user-interrupt-modal');
}
function closeUserInterruptModal() {
userInterruptModalPending = null;
const m = document.getElementById('user-interrupt-modal');
if (m) {
m.style.display = 'none';
}
closeAppModal('user-interrupt-modal');
}
async function submitUserInterruptContinue() {
@@ -1054,6 +1229,9 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
const progressElement = document.getElementById(progressId);
if (!progressElement) return;
// 快照 innerHTML 前刷掉尚未执行的 rAF 流式更新,避免过程详情少最后几帧
flushAllPendingStreamPlainUpdates();
// Ensure any "running" tool_call badges are closed before we snapshot timeline HTML.
// Otherwise, once the progress element is removed, later 'done' events may not be able
// to update the original timeline DOM and the copied HTML would stay "执行中".
@@ -1668,7 +1846,7 @@ function handleStreamEvent(event, progressElement, progressId,
if (item) {
const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) {
setTimelineItemContentStreamPlain(contentEl, s.buffer);
scheduleStreamPlainTextUpdate(contentEl, s.buffer);
}
}
break;
@@ -1688,6 +1866,7 @@ function handleStreamEvent(event, progressElement, progressId,
if (item) {
const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) {
flushStreamPlainTextUpdate(contentEl);
if (typeof formatMarkdown === 'function') {
setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer, timelineMarkdownOpts));
} else {
@@ -1784,6 +1963,21 @@ function handleStreamEvent(event, progressElement, progressId,
break;
}
case 'eino_empty_response_continue': {
const d = event.data || {};
const title = typeof window.t === 'function'
? window.t('chat.einoEmptyResponseContinueTitle')
: '🔁 自动续跑(无助手正文)';
addTimelineItem(timeline, 'warning', {
title: title,
message: event.message || (typeof window.t === 'function'
? window.t('chat.einoEmptyResponseContinueMessage')
: '会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…'),
data: d
});
break;
}
case 'eino_run_retry': {
const d = event.data || {};
const title = typeof window.t === 'function'
@@ -1899,7 +2093,7 @@ function handleStreamEvent(event, progressElement, progressId,
const pre = item.querySelector('pre.tool-result');
if (pre) {
pre.classList.remove('tool-result-pending');
pre.textContent = state.buffer;
scheduleStreamPlainTextUpdate(pre, state.buffer);
}
}
break;
@@ -2006,7 +2200,7 @@ function handleStreamEvent(event, progressElement, progressId,
}
}
if (contentEl) {
setTimelineItemContentStreamPlain(contentEl, s.buffer);
scheduleStreamPlainTextUpdate(contentEl, s.buffer);
}
}
break;
@@ -2033,6 +2227,7 @@ function handleStreamEvent(event, progressElement, progressId,
contentEl.className = 'timeline-item-content';
item.appendChild(contentEl);
}
flushStreamPlainTextUpdate(contentEl);
if (typeof formatMarkdown === 'function') {
setTimelineItemContentStreamRich(contentEl, formatMarkdown(full, timelineMarkdownOpts));
} else {
@@ -2209,15 +2404,13 @@ function handleStreamEvent(event, progressElement, progressId,
if (!deltaContent && streamBufferFromAccumulated(responseData) === null) break;
state.buffer = mergeStreamBuffer(state.buffer, deltaContent, responseData);
// 更新时间线条目内容
// 流式阶段仅追加纯文本;formatTimelineStreamBody 在终态 response 时一次性处理
if (state.itemId) {
const item = document.getElementById(state.itemId);
if (item) {
const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) {
const meta = state.streamMeta || responseData;
const body = formatTimelineStreamBody(state.buffer, meta);
setTimelineItemContentStreamPlain(contentEl, body);
scheduleStreamPlainTextUpdate(contentEl, state.buffer);
}
}
}
@@ -2262,14 +2455,18 @@ function handleStreamEvent(event, progressElement, progressId,
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
}
// 移除 response_start/response_delta 阶段创建的「规划中」占位条目。
// 该条目属于 UI-only 的流式展示,不应被拷贝到最终的过程详情里;
// 否则会出现“不刷新页面仍显示规划中,刷新后消失”的不一致。
// response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
if (streamState && streamState.itemId) {
const planningItem = document.getElementById(streamState.itemId);
if (planningItem && planningItem.parentNode) {
planningItem.parentNode.removeChild(planningItem);
}
finalizeMainResponseStreamItem(streamState, event.message, responseData);
} else if (event.message && String(event.message).trim()) {
addTimelineItem(timeline, 'planning', {
title: typeof einoMainStreamPlanningTitle === 'function'
? einoMainStreamPlanningTitle(responseData)
: ('📝 ' + (typeof window.t === 'function' ? window.t('chat.planning') : '规划中')),
message: event.message,
data: responseData,
expanded: false
});
}
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
@@ -2290,6 +2487,11 @@ function handleStreamEvent(event, progressElement, progressId,
const respMid = responseData.messageId;
if (respMid) {
applyBackendMessageIdToAssistantDom(assistantIdFinal, respMid);
if (typeof window.syncAssistantReasoningContentFromServer === 'function') {
setTimeout(function () {
window.syncAssistantReasoningContentFromServer(respMid, assistantIdFinal);
}, 400);
}
}
setTimeout(() => {
@@ -2757,39 +2959,22 @@ async function attachRunningTaskEventStream(conversationId) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const dispatchTaskEvent = function (eventData) {
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
};
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
if (line.indexOf('data: ') === 0) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
} catch (e) {
console.error('task-events parse', e);
}
}
}
await processSseDataLinesYielding(lines, dispatchTaskEvent);
}
// Flush decoder internal buffer to avoid dropping trailing partial UTF-8 bytes.
buffer += decoder.decode();
if (buffer.trim()) {
const lines = buffer.split('\n');
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
if (line.indexOf('data: ') === 0) {
try {
const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
} catch (e) {
console.error('task-events parse', e);
}
}
}
await processSseDataLinesYielding(lines, dispatchTaskEvent);
}
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
clearCsTaskReplay();
@@ -2921,7 +3106,9 @@ function mergeToolResultIntoCallItem(item, data, options) {
const pre = section.querySelector('pre.tool-result');
if (pre) {
pre.classList.remove('tool-result-pending');
flushStreamPlainTextUpdate(pre);
pre.textContent = text;
resetStreamPlainTextState(pre);
}
if (data.executionId) {
@@ -3521,9 +3708,10 @@ async function applyMonitorFilters() {
const statusFilter = document.getElementById('monitor-status-filter');
const toolFilter = document.getElementById('monitor-tool-filter');
const status = statusFilter ? statusFilter.value : 'all';
const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
const toolRaw = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
const tool = toolRaw === 'all' ? 'all' : canonicalMonitorToolName(toolRaw);
if (toolFilter) {
toolFilter.classList.toggle('is-filter-active', tool !== 'all');
toolFilter.classList.toggle('is-filter-active', toolRaw !== 'all');
}
// 当筛选条件改变时,从后端重新获取数据
await refreshMonitorPanelWithFilter(status, tool);
@@ -3855,7 +4043,9 @@ async function setMcpMonitorTimelineRange(range) {
monitorState.timeline = timelineJson;
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
if (timelineInner) {
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError);
const combined = timelineInner.closest('.mcp-stats-combined');
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError, compactEmpty);
bindMcpStatsTimelineEvents();
syncMcpMonitorTimelineRangeUI(range);
} else if (monitorState.stats && Object.keys(monitorState.stats).length > 0) {
@@ -3865,7 +4055,9 @@ async function setMcpMonitorTimelineRange(range) {
monitorState.timelineError = err.message || 'error';
const timelineInner = document.querySelector('#monitor-stats .mcp-stats-combined__timeline-inner');
if (timelineInner) {
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError);
const combined = timelineInner.closest('.mcp-stats-combined');
const compactEmpty = combined && !!combined.querySelector('.mcp-stats-combined__main');
timelineInner.innerHTML = renderMcpStatsTimelineBody(monitorState.timeline, monitorState.timelineError, compactEmpty);
bindMcpStatsTimelineEvents();
syncMcpMonitorTimelineRangeUI(range);
}
@@ -3883,7 +4075,21 @@ function renderMcpStatsTimelineRangeButtons() {
}).join('');
}
function renderMcpStatsTimelineBody(timeline, timelineError) {
const MCP_TIMELINE_EMPTY_ICON = '<svg class="mcp-stats-timeline-empty-state__icon" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
function renderMcpStatsTimelineEmptyState(compact) {
const noData = mcpMonitorT('timelineNoData') || monitorFallback('该时段暂无调用', 'No calls in this period');
const emptyHint = mcpMonitorT('timelineEmptyHint')
|| monitorFallback('切换时间范围查看其他时段,或在对话/任务中调用 MCP 工具', 'Switch the time range or invoke MCP tools in chat or tasks');
const compactClass = compact ? ' mcp-stats-timeline-empty-state--compact' : '';
return `<div class="mcp-stats-timeline-empty-state${compactClass}">
${MCP_TIMELINE_EMPTY_ICON}
<p class="mcp-stats-timeline-empty-state__title">${escapeHtml(noData)}</p>
<p class="mcp-stats-timeline-empty-state__hint">${escapeHtml(emptyHint)}</p>
</div>`;
}
function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty) {
const hint = mcpMonitorT('timelineHint') || monitorFallback('全部工具合计', 'All tools combined');
if (timelineError) {
@@ -3898,8 +4104,7 @@ function renderMcpStatsTimelineBody(timeline, timelineError) {
|| `区间内 ${summaryTotal} 次 · 峰值 ${peak}`;
if (points.length === 0 || summaryTotal === 0) {
const noData = mcpMonitorT('timelineNoData') || monitorFallback('该时段暂无调用', 'No calls in this period');
return `<p class="mcp-stats-timeline-empty">${escapeHtml(noData)}</p>`;
return renderMcpStatsTimelineEmptyState(!!compactEmpty);
}
const rangeKey = timeline.range || getMcpMonitorTimelineRange();
@@ -3930,9 +4135,10 @@ function renderMcpStatsCombinedSection(topTools, totals, activeToolFilter, timel
if (!hasTools && !showTimeline) return '';
const filterChipLabel = activeToolFilter ? formatMonitorToolName(activeToolFilter) : '';
const filterChip = activeToolFilter
? `<span class="mcp-stats-filter-chip" title="${escapeHtml(mcpMonitorT('filterByToolTitle', { tool: activeToolFilter }) || activeToolFilter)}">
<span class="mcp-stats-filter-chip__label">${escapeHtml(mcpMonitorT('filterActive', { tool: activeToolFilter }) || `已筛选:${activeToolFilter}`)}</span>
? `<span class="mcp-stats-filter-chip" title="${escapeHtml(mcpMonitorT('filterByToolTitle', { tool: filterChipLabel }) || filterChipLabel)}">
<span class="mcp-stats-filter-chip__label">${escapeHtml(mcpMonitorT('filterActive', { tool: filterChipLabel }) || `已筛选:${filterChipLabel}`)}</span>
<button type="button" class="mcp-stats-filter-chip__clear mcp-stats-clear-filter" aria-label="${escapeHtml(mcpMonitorT('clearToolFilter') || '清除工具筛选')}">×</button>
</span>`
: '';
@@ -3951,7 +4157,7 @@ function renderMcpStatsCombinedSection(topTools, totals, activeToolFilter, timel
const timelineCol = showTimeline
? `<div class="mcp-stats-combined__timeline">
<p class="mcp-stats-combined__col-label">${escapeHtml(timelineTitle)}</p>
<div class="mcp-stats-combined__timeline-inner">${renderMcpStatsTimelineBody(timeline, timelineError)}</div>
<div class="mcp-stats-combined__timeline-inner">${renderMcpStatsTimelineBody(timeline, timelineError, hasTools)}</div>
</div>`
: '';
@@ -4372,7 +4578,7 @@ function updateMonitorStatsSubtitle(lastFetchedAt, toolCount) {
function filterMonitorByTool(toolName) {
const toolFilter = document.getElementById('monitor-tool-filter');
if (!toolFilter || !toolName) return;
toolFilter.value = toolName;
toolFilter.value = formatMonitorToolName(toolName);
toolFilter.classList.add('is-filter-active');
applyMonitorFilters();
const execSection = document.querySelector('.monitor-executions');
@@ -4504,7 +4710,8 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
let rowsHtml = '';
topTools.forEach((tool, index) => {
const name = tool.toolName || unknownToolLabel;
const rawName = tool.toolName || unknownToolLabel;
const name = formatMonitorToolName(rawName);
const total = tool.totalCalls || 0;
const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0;
@@ -4512,14 +4719,14 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
const toolRate = toolRateNum.toFixed(1);
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const dotColor = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
const isActive = activeToolFilter && activeToolFilter === name;
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
const rateClass = getMcpToolRateClass(toolRateNum);
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|| `${name}${total} 次调用,成功率 ${toolRate}%`;
rowsHtml += `
<tr class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(name)}"
data-tool-name="${escapeHtml(rawName)}"
tabindex="0"
role="button"
aria-label="${escapeHtml(rowAria)}"
@@ -4568,14 +4775,15 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const distAria = mcpMonitorT('distTitle') || '调用分布';
const stackedHtml = segments.map((s) => {
const isActive = !s.isOthers && activeToolFilter && activeToolFilter === s.name;
const title = `${s.name} · ${s.pct}% · ${s.calls}`;
const isActive = !s.isOthers && activeToolFilter && monitorToolNamesEqual(activeToolFilter, s.name);
const displayName = s.isOthers ? s.name : formatMonitorToolName(s.name);
const title = `${displayName} · ${s.pct}% · ${s.calls}`;
if (s.isOthers) {
return `<span class="mcp-stats-proportion-seg is-others" data-is-others="1" role="presentation"
style="flex:${s.pctNum} 1 0;background:${s.color}" title="${escapeHtml(title)}"></span>`;
}
const segAria = mcpMonitorT('distSegmentAria', { name: s.name, pct: s.pct, calls: s.calls })
|| `${s.name},占 ${s.pct}%${s.calls}`;
const segAria = mcpMonitorT('distSegmentAria', { name: displayName, pct: s.pct, calls: s.calls })
|| `${displayName},占 ${s.pct}%${s.calls}`;
return `<span class="mcp-stats-proportion-seg${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(s.name)}" data-pct="${s.pct}" data-calls="${s.calls}" data-is-others="0"
role="button" tabindex="0" aria-label="${escapeHtml(segAria)}"
@@ -4584,7 +4792,8 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const maxCalls = Math.max(1, ...topTools.map((t) => t.totalCalls || 0));
const listHtml = topTools.map((tool, index) => {
const name = tool.toolName || unknownToolLabel;
const rawName = tool.toolName || unknownToolLabel;
const name = formatMonitorToolName(rawName);
const total = tool.totalCalls || 0;
const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0;
@@ -4593,7 +4802,7 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
const barPct = maxCalls > 0 ? ((total / maxCalls) * 100).toFixed(1) : '0';
const isActive = activeToolFilter && activeToolFilter === name;
const isActive = activeToolFilter && monitorToolNamesEqual(activeToolFilter, rawName);
const rateClass = getMcpToolRateClass(toolRateNum);
const rankClass = index === 0 ? ' rank-1' : index === 1 ? ' rank-2' : index === 2 ? ' rank-3' : '';
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
@@ -4602,7 +4811,7 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>`
: '';
return `<li class="mcp-stats-tool-item${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(name)}" tabindex="0" role="button"
data-tool-name="${escapeHtml(rawName)}" tabindex="0" role="button"
aria-label="${escapeHtml(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}">
<span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span>
<span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span>
@@ -4762,7 +4971,8 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
.slice(0, MCP_STATS_TOP_N);
const showCombined = showTimeline || topTools.length > 0;
const hasAnyCalls = totals.total > 0;
const showCombined = hasAnyCalls && (topTools.length > 0 || showTimeline);
const html = `
<div class="mcp-exec-stats">
${renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, lastCallText, hasCalls)}
@@ -4836,7 +5046,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status);
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
const duration = formatExecutionDuration(exec.startTime, exec.endTime);
const toolName = escapeHtml(exec.toolName || unknownToolLabel);
const toolName = escapeHtml(formatMonitorToolName(exec.toolName) || unknownToolLabel);
const rawExecId = exec.id || '';
const executionId = escapeHtml(rawExecId);
const terminateBtn = status === 'running'
+304 -111
View File
@@ -5,12 +5,15 @@ let projectsCache = [];
let projectsCacheAll = [];
const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size';
let currentProjectId = null;
let currentProjectUpdatedAt = null;
let currentProjectTab = 'facts';
const projectNameById = {};
let _projectsListReady = false;
let _projectsFetchPromise = null;
const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId';
const PROJECT_DESCRIPTION_MAX_LENGTH = 4000;
const PROJECT_NAME_MAX_LENGTH = 200;
function tp(key, opts) {
if (typeof window.t === 'function') return window.t(key, opts);
@@ -304,23 +307,9 @@ function prefetchProjectsForChat() {
ensureProjectsLoaded().catch(() => {});
}
/** 新对话时:保留有效 activeProjectId,否则默认选中第一个进行中的项目 */
/** 新对话时默认不绑定项目;用户需主动选择后才写入共享黑板 */
async function ensureDefaultActiveProjectForNewChat() {
try {
await ensureProjectsLoaded();
const cur = getActiveProjectId();
if (cur && isActiveChatProjectId(cur)) return cur;
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
const first =
source.find((p) => p.pinned && p.status !== 'archived') ||
source.find((p) => p.status !== 'archived');
if (first) {
setActiveProjectId(first.id);
return first.id;
}
} catch (e) {
console.warn(e);
}
setActiveProjectId('');
return '';
}
@@ -343,7 +332,9 @@ async function initProjectsPage() {
const page = document.getElementById('page-projects');
if (!page || page.style.display === 'none') return;
initProjectsModalEscape();
syncProjectsModalBodyLock();
if (typeof syncAppModalBodyLock === 'function') {
syncAppModalBodyLock();
}
updateProjectsDetailVisibility();
projectsListPagination.pageSize = getProjectsListPageSize();
renderProjectsPagination();
@@ -463,9 +454,10 @@ function formatVulnStatusBadge(status) {
confirmed: 'vulnerabilityPage.statusConfirmed',
fixed: 'vulnerabilityPage.statusFixed',
false_positive: 'vulnerabilityPage.statusFalsePositive',
ignored: 'vulnerabilityPage.statusIgnored',
};
const label = labelMap[s] ? tp(labelMap[s]) : status || '—';
const cls = ['open', 'confirmed', 'fixed', 'false_positive'].includes(s) ? s : 'open';
const cls = ['open', 'confirmed', 'fixed', 'false_positive', 'ignored'].includes(s) ? s : 'open';
return `<span class="status-badge status-${escapeHtml(cls)}">${escapeHtml(label)}</span>`;
}
@@ -610,11 +602,41 @@ function renderProjectsSidebar() {
<div class="projects-list-item-name">${escapeHtml(p.name)}${badges}</div>
<div class="projects-list-item-meta">${formatProjectTime(p.updated_at)}</div>
</div>
<button type="button" class="projects-list-item-menu" title="${escapeHtml(tp('projects.projectActions'))}" aria-label="${escapeHtml(tp('projects.projectActions'))}" onclick="showProjectListActionMenu(event, '${escapeHtml(p.id)}')"></button>
</div>`;
}).join('');
updateProjectsDetailVisibility();
}
function clampProjectDescription(text) {
const s = (text || '').trim();
if (s.length <= PROJECT_DESCRIPTION_MAX_LENGTH) return s;
return s.slice(0, PROJECT_DESCRIPTION_MAX_LENGTH);
}
function renderProjectDetailTitle(name) {
const titleEl = document.getElementById('projects-detail-title');
if (!titleEl) return;
const text = (name || '').trim() || tp('projects.defaultProjectName');
titleEl.textContent = text;
titleEl.title = text;
}
function renderProjectDetailDesc(desc) {
const descEl = document.getElementById('projects-detail-desc');
if (!descEl) return;
const text = (desc || '').trim();
if (!text) {
descEl.hidden = true;
descEl.textContent = '';
descEl.removeAttribute('title');
return;
}
descEl.textContent = text;
descEl.title = text;
descEl.hidden = false;
}
function updateProjectStatusPill(status) {
const el = document.getElementById('projects-detail-status');
if (!el) return;
@@ -623,6 +645,24 @@ function updateProjectStatusPill(status) {
el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active');
}
function renderProjectDetailMeta(updatedAt) {
const metaEl = document.getElementById('projects-detail-meta');
if (!metaEl) return;
const time = formatProjectTime(updatedAt);
metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
}
function refreshProjectDetailMetaI18n() {
if (!currentProjectId) return;
let updatedAt = currentProjectUpdatedAt;
if (updatedAt == null) {
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
const p = source.find((x) => x.id === currentProjectId);
updatedAt = p?.updated_at;
}
renderProjectDetailMeta(updatedAt);
}
function updateProjectStats(stats) {
const s = stats || {};
const f = document.getElementById('project-stat-facts');
@@ -668,8 +708,7 @@ async function selectProject(id) {
const res = await apiFetch(`/api/projects/${id}`);
if (!res.ok) throw new Error(tp('projects.projectNotFound'));
const p = await res.json();
const titleEl = document.getElementById('projects-detail-title');
if (titleEl) titleEl.textContent = p.name || tp('projects.defaultProjectName');
renderProjectDetailTitle(p.name);
document.getElementById('project-edit-name').value = p.name || '';
document.getElementById('project-edit-description').value = p.description || '';
document.getElementById('project-edit-scope').value = p.scope_json || '';
@@ -678,19 +717,9 @@ async function selectProject(id) {
const pinEl = document.getElementById('project-edit-pinned');
if (pinEl) pinEl.checked = !!p.pinned;
updateProjectStatusPill(p.status || 'active');
const metaEl = document.getElementById('projects-detail-meta');
if (metaEl) metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${formatProjectTime(p.updated_at)}`, { time: formatProjectTime(p.updated_at) });
const descEl = document.getElementById('projects-detail-desc');
if (descEl) {
const desc = (p.description || '').trim();
if (desc) {
descEl.textContent = desc;
descEl.hidden = false;
} else {
descEl.textContent = '';
descEl.hidden = true;
}
}
currentProjectUpdatedAt = p.updated_at;
renderProjectDetailMeta(currentProjectUpdatedAt);
renderProjectDetailDesc(p.description);
projectNameById[p.id] = p.name || p.id;
} catch (e) {
console.warn(e);
@@ -857,38 +886,52 @@ let _factDetailFact = null;
let _projectFactsFilterDebounce = null;
async function viewProjectFactBody(factKey) {
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`);
if (!res.ok) return alert(tp('common.loadFailed'));
const f = await res.json();
_factDetailKey = f.fact_key;
_factDetailFact = f;
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
const metaParts = [
tpFmt('projects.factMetaCategory', `Category: ${f.category}`, { value: f.category }),
tpFmt('projects.factMetaConfidence', `Confidence: ${f.confidence}`, { value: f.confidence }),
tpFmt('projects.factMetaUpdated', `Updated: ${formatProjectTime(f.updated_at, f.created_at)}`, {
time: formatProjectTime(f.updated_at, f.created_at),
}),
];
if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id }));
if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id }));
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
document.getElementById('fact-detail-title').textContent = factKey;
document.getElementById('fact-detail-meta').textContent = '…';
document.getElementById('fact-detail-body').textContent = '';
const warnEl = document.getElementById('fact-detail-sparse-warn');
if (warnEl) {
if (isSparseFactBody(f.category, f.fact_key, f.body)) {
warnEl.hidden = false;
warnEl.textContent = tp('projects.factSparseWarn');
} else {
warnEl.hidden = true;
warnEl.textContent = '';
}
warnEl.hidden = true;
warnEl.textContent = '';
}
const linkBtn = document.getElementById('fact-detail-link-vuln-btn');
const createBtn = document.getElementById('fact-detail-create-vuln-btn');
if (linkBtn) linkBtn.hidden = false;
if (createBtn) createBtn.hidden = false;
openProjectsOverlay('fact-detail-modal');
if (linkBtn) linkBtn.hidden = true;
if (createBtn) createBtn.hidden = true;
openProjectsOverlay('fact-detail-modal', { focus: false });
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`);
if (!res.ok) {
closeFactDetailModal();
return alert(tp('common.loadFailed'));
}
const f = await res.json();
_factDetailKey = f.fact_key;
_factDetailFact = f;
deferModalContent(() => {
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
const metaParts = [
tpFmt('projects.factMetaCategory', `Category: ${f.category}`, { value: f.category }),
tpFmt('projects.factMetaConfidence', `Confidence: ${f.confidence}`, { value: f.confidence }),
tpFmt('projects.factMetaUpdated', `Updated: ${formatProjectTime(f.updated_at, f.created_at)}`, {
time: formatProjectTime(f.updated_at, f.created_at),
}),
];
if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id }));
if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id }));
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
if (warnEl) {
if (isSparseFactBody(f.category, f.fact_key, f.body)) {
warnEl.hidden = false;
warnEl.textContent = tp('projects.factSparseWarn');
} else {
warnEl.hidden = true;
warnEl.textContent = '';
}
}
if (linkBtn) linkBtn.hidden = false;
if (createBtn) createBtn.hidden = false;
});
}
function editFactFromDetail() {
@@ -1143,41 +1186,16 @@ async function viewFactsForVulnerability(vulnId) {
else loadProjectFacts();
}
function openProjectsOverlay(id) {
const el = document.getElementById(id);
if (!el) return;
el.style.display = 'flex';
syncProjectsModalBodyLock();
const focusTarget = el.querySelector('input.form-input, textarea.form-input, select.form-input');
if (focusTarget) {
setTimeout(() => focusTarget.focus(), 80);
}
function openProjectsOverlay(id, opts) {
openAppModal(id, opts);
}
function isProjectsOverlayVisible(id) {
const el = document.getElementById(id);
if (!el) return false;
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
}
function hasVisibleProjectsOverlay() {
const overlays = document.querySelectorAll('.projects-modal-overlay');
return Array.from(overlays).some((el) => {
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden';
});
}
function syncProjectsModalBodyLock() {
if (hasVisibleProjectsOverlay()) document.body.classList.add('projects-modal-open');
else document.body.classList.remove('projects-modal-open');
return isAppModalOpen(id);
}
function closeProjectsOverlay(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
syncProjectsModalBodyLock();
closeAppModal(id);
}
function showNewProjectModal() {
@@ -1192,6 +1210,42 @@ function showNewProjectModal() {
openProjectsOverlay('project-modal');
}
async function showEditProjectModal(projectId) {
if (!projectId) return;
window._projectModalFromChat = false;
window._projectModalEditId = projectId;
document.getElementById('project-modal-title').textContent = tp('projects.modalEditTitle');
const sub = document.getElementById('project-modal-subtitle');
if (sub) sub.textContent = tp('projects.modalEditSubtitle');
const submitBtn = document.getElementById('project-modal-submit-btn');
if (submitBtn) submitBtn.textContent = tp('projects.saveChanges');
const nameEl = document.getElementById('project-modal-name');
const descEl = document.getElementById('project-modal-description');
if (nameEl) nameEl.value = '';
if (descEl) descEl.value = '';
openProjectsOverlay('project-modal', { focus: false });
let p = findProjectById(projectId);
if (!p) {
try {
const res = await apiFetch(`/api/projects/${encodeURIComponent(projectId)}`);
if (!res.ok) throw new Error(tp('projects.projectNotFound'));
p = await res.json();
} catch (e) {
closeProjectModal();
alert(e.message || tp('projects.projectNotFound'));
window._projectModalEditId = null;
return;
}
}
const name = (p.name || '').slice(0, PROJECT_NAME_MAX_LENGTH);
const description = clampProjectDescription(p.description || '');
deferModalContent(() => {
if (nameEl) nameEl.value = name;
if (descEl) descEl.value = description;
nameEl?.focus();
});
}
/** 从对话区「选择项目」面板打开新建项目,创建成功后自动绑定当前对话 */
function showNewProjectModalFromChat() {
closeChatProjectPanel();
@@ -1200,11 +1254,11 @@ function showNewProjectModalFromChat() {
}
async function saveProjectModal() {
const name = document.getElementById('project-modal-name').value.trim();
const name = document.getElementById('project-modal-name').value.trim().slice(0, PROJECT_NAME_MAX_LENGTH);
if (!name) return alert(tp('projects.enterProjectName'));
const body = {
name,
description: document.getElementById('project-modal-description').value.trim(),
description: clampProjectDescription(document.getElementById('project-modal-description').value),
};
const editId = window._projectModalEditId;
const res = editId
@@ -1216,12 +1270,18 @@ async function saveProjectModal() {
return;
}
const fromChat = !!window._projectModalFromChat;
const fromWebshellConnId = window._projectModalFromWebshellConnId || '';
window._projectModalFromChat = false;
window._projectModalFromWebshellConnId = '';
closeProjectModal();
const saved = await res.json();
await loadProjectsList();
if (saved.id) {
if (fromChat && !editId) {
if (fromWebshellConnId && !editId) {
if (typeof applyWebshellAiProjectSelection === 'function') {
await applyWebshellAiProjectSelection(saved.id);
}
} else if (fromChat && !editId) {
await applyChatProjectSelection(saved.id);
} else {
await selectProject(saved.id);
@@ -1231,6 +1291,7 @@ async function saveProjectModal() {
function closeProjectModal() {
window._projectModalFromChat = false;
window._projectModalEditId = null;
closeProjectsOverlay('project-modal');
}
@@ -1271,7 +1332,7 @@ async function saveProjectSettings() {
}
const body = {
name: document.getElementById('project-edit-name').value.trim(),
description: document.getElementById('project-edit-description').value.trim(),
description: clampProjectDescription(document.getElementById('project-edit-description').value),
scope_json: scopeRaw,
status: document.getElementById('project-edit-status')?.value || 'active',
pinned: !!document.getElementById('project-edit-pinned')?.checked,
@@ -1287,30 +1348,112 @@ async function saveProjectSettings() {
alert(tp('projects.saved'));
}
async function archiveCurrentProject() {
if (!currentProjectId) return;
const statusEl = document.getElementById('project-edit-status');
const cur = statusEl?.value || 'active';
function findProjectById(projectId) {
return projectsCache.find((p) => p.id === projectId) || projectsCacheAll.find((p) => p.id === projectId);
}
let _projectListMenuTargetId = null;
let _projectListMenuDocClickBound = false;
function closeProjectListActionMenu() {
const menu = document.getElementById('projects-list-action-menu');
if (!menu) return;
menu.style.display = 'none';
_projectListMenuTargetId = null;
}
function positionProjectListActionMenu(event) {
const menu = document.getElementById('projects-list-action-menu');
if (!menu) return;
menu.style.display = 'block';
menu.style.visibility = 'visible';
menu.style.opacity = '1';
void menu.offsetHeight;
const menuRect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = event.clientX;
let top = event.clientY;
if (left + menuRect.width > viewportWidth) {
left = Math.max(8, event.clientX - menuRect.width);
}
if (top + menuRect.height > viewportHeight) {
top = Math.max(8, event.clientY - menuRect.height);
}
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
}
function showProjectListActionMenu(event, projectId) {
event.stopPropagation();
event.preventDefault();
const menu = document.getElementById('projects-list-action-menu');
if (!menu) return;
if (_projectListMenuTargetId === projectId && menu.style.display === 'block') {
closeProjectListActionMenu();
return;
}
closeProjectListActionMenu();
const p = findProjectById(projectId);
if (!p) return;
_projectListMenuTargetId = projectId;
const editText = document.getElementById('projects-list-menu-edit-text');
const archiveText = document.getElementById('projects-list-menu-archive-text');
const deleteText = document.getElementById('projects-list-menu-delete-text');
if (editText) editText.textContent = tp('projects.editProject');
if (archiveText) {
archiveText.textContent = p.status === 'archived'
? tp('projects.restoreProjectActive')
: tp('projects.archiveProject');
}
if (deleteText) deleteText.textContent = tp('projects.deleteProject');
positionProjectListActionMenu(event);
}
function initProjectListActionMenu() {
if (_projectListMenuDocClickBound) return;
_projectListMenuDocClickBound = true;
document.addEventListener('click', (event) => {
const menu = document.getElementById('projects-list-action-menu');
if (!menu || menu.style.display === 'none') return;
if (menu.contains(event.target)) return;
if (event.target.closest('.projects-list-item-menu')) return;
closeProjectListActionMenu();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeProjectListActionMenu();
});
}
async function toggleProjectArchiveById(projectId) {
const p = findProjectById(projectId);
if (!p) return;
const cur = p.status || 'active';
const next = cur === 'archived' ? 'active' : 'archived';
if (!confirm(next === 'archived' ? tp('projects.confirmArchiveProject') : tp('projects.confirmRestoreProjectActive'))) return;
const res = await apiFetch(`/api/projects/${currentProjectId}`, {
const res = await apiFetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: next }),
});
if (!res.ok) return alert(tp('projects.operationFailed'));
await loadProjectsList();
await selectProject(currentProjectId);
if (currentProjectId === projectId && projectsCache.some((item) => item.id === projectId)) {
await selectProject(projectId);
} else if (currentProjectId === projectId) {
currentProjectId = null;
updateProjectsDetailVisibility();
if (projectsCache.length) await selectProject(projectsCache[0].id);
}
}
async function deleteCurrentProject() {
if (!currentProjectId || !confirm(tp('projects.confirmDeleteProject'))) return;
const deletedId = currentProjectId;
const deletedIndex = projectsCache.findIndex((p) => p.id === deletedId);
const res = await apiFetch(`/api/projects/${deletedId}`, { method: 'DELETE' });
async function deleteProjectById(projectId) {
if (!projectId || !confirm(tp('projects.confirmDeleteProject'))) return;
const deletedIndex = projectsCache.findIndex((p) => p.id === projectId);
const res = await apiFetch(`/api/projects/${projectId}`, { method: 'DELETE' });
if (!res.ok) return alert(tp('projects.deleteFailed'));
if (getActiveProjectId() === deletedId) setActiveProjectId('');
currentProjectId = null;
if (getActiveProjectId() === projectId) setActiveProjectId('');
if (currentProjectId === projectId) currentProjectId = null;
await loadProjectsList();
if (projectsCache.length) {
const nextIndex = Math.min(deletedIndex >= 0 ? deletedIndex : 0, projectsCache.length - 1);
@@ -1320,6 +1463,37 @@ async function deleteCurrentProject() {
}
}
async function toggleProjectArchiveFromListMenu() {
const projectId = _projectListMenuTargetId;
closeProjectListActionMenu();
if (!projectId) return;
await toggleProjectArchiveById(projectId);
}
function editProjectFromListMenu() {
const projectId = _projectListMenuTargetId;
closeProjectListActionMenu();
if (!projectId) return;
showEditProjectModal(projectId);
}
async function deleteProjectFromListMenu() {
const projectId = _projectListMenuTargetId;
closeProjectListActionMenu();
if (!projectId) return;
await deleteProjectById(projectId);
}
async function archiveCurrentProject() {
if (!currentProjectId) return;
await toggleProjectArchiveById(currentProjectId);
}
async function deleteCurrentProject() {
if (!currentProjectId) return;
await deleteProjectById(currentProjectId);
}
function resetFactModalForm() {
window._factModalEditId = null;
const keyEl = document.getElementById('fact-modal-key');
@@ -1379,14 +1553,20 @@ function showAddFactModal() {
async function showEditFactModal(factKey) {
if (!currentProjectId) return alert(tp('projects.selectProjectFirst'));
resetFactModalForm();
openProjectsOverlay('fact-modal', { focus: false });
const res = await apiFetch(
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`,
);
if (!res.ok) return alert(tp('projects.loadFactFailed'));
if (!res.ok) {
closeFactModal();
return alert(tp('projects.loadFactFailed'));
}
const f = await res.json();
resetFactModalForm();
fillFactModalForm(f);
openProjectsOverlay('fact-modal');
deferModalContent(() => {
fillFactModalForm(f);
document.getElementById('fact-modal-key')?.focus();
});
}
function closeFactModal() {
@@ -1713,6 +1893,10 @@ function initChatProjectSelector() {
const panel = document.getElementById('chat-project-panel');
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
if (currentProjectId) {
refreshProjectDetailMetaI18n();
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
const p = source.find((x) => x.id === currentProjectId);
if (p) updateProjectStatusPill(p.status || 'active');
refreshProjectHeaderStats().catch(() => {});
switchProjectTab(currentProjectTab || 'facts');
}
@@ -1730,13 +1914,18 @@ function initChatProjectSelector() {
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initChatProjectSelector);
document.addEventListener('DOMContentLoaded', () => {
initChatProjectSelector();
initProjectListActionMenu();
});
} else {
initChatProjectSelector();
initProjectListActionMenu();
}
window.initProjectsPage = initProjectsPage;
window.showNewProjectModal = showNewProjectModal;
window.showEditProjectModal = showEditProjectModal;
window.showNewProjectModalFromChat = showNewProjectModalFromChat;
window.saveProjectModal = saveProjectModal;
window.closeProjectModal = closeProjectModal;
@@ -1751,6 +1940,10 @@ window.closeFactDetailModal = closeFactDetailModal;
window.saveProjectSettings = saveProjectSettings;
window.archiveCurrentProject = archiveCurrentProject;
window.deleteCurrentProject = deleteCurrentProject;
window.showProjectListActionMenu = showProjectListActionMenu;
window.editProjectFromListMenu = editProjectFromListMenu;
window.toggleProjectArchiveFromListMenu = toggleProjectArchiveFromListMenu;
window.deleteProjectFromListMenu = deleteProjectFromListMenu;
window.refreshChatProjectSelector = refreshChatProjectSelector;
window.onChatProjectChange = onChatProjectChange;
window.toggleChatProjectPanel = toggleChatProjectPanel;
+8 -6
View File
@@ -1112,7 +1112,7 @@ async function showAddRoleModal() {
// 确保统计信息正确更新(显示0/108)
updateRoleToolsStats();
modal.style.display = 'flex';
openAppModal('role-modal');
}
// 编辑角色
@@ -1274,15 +1274,16 @@ async function editRole(roleName) {
}
}
modal.style.display = 'flex';
openAppModal('role-modal');
}
// 关闭角色模态框
function closeRoleModal() {
const modal = document.getElementById('role-modal');
if (modal) {
modal.style.display = 'none';
}
closeAppModal('role-modal');
}
function closeRoleSelectModal() {
closeAppModal('role-select-modal');
}
// 获取所有选中的工具(包括未在MCP管理中启用的工具)
@@ -1634,6 +1635,7 @@ if (typeof window !== 'undefined') {
window.getCurrentRole = getCurrentRole;
window.toggleRoleSelectionPanel = toggleRoleSelectionPanel;
window.closeRoleSelectionPanel = closeRoleSelectionPanel;
window.closeRoleSelectModal = closeRoleSelectModal;
window.filterRoleToolsByStatus = filterRoleToolsByStatus;
window.currentSelectedRole = getCurrentRole();
+19 -11
View File
@@ -315,6 +315,9 @@ function showSubmenuPopup(navItem, menuId) {
async function initPage(pageId) {
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致页面显示原始占位符 key
if (window.i18nReady) await window.i18nReady;
if (typeof stopExternalMcpPoll === 'function') {
stopExternalMcpPoll();
}
switch(pageId) {
case 'dashboard':
if (typeof refreshDashboard === 'function') {
@@ -372,21 +375,26 @@ async function initPage(pageId) {
}, 100);
}
};
// 先拉取全局配置,确保 tool_search 常驻状态按后端生效集合展示
const afterMcpConfigReady = () => {
startLoadMcpTools();
if (typeof loadExternalMCPs === 'function') {
loadExternalMCPs().catch(err => {
console.warn('加载外部MCP列表失败:', err);
});
}
if (typeof startExternalMcpPoll === 'function') {
startExternalMcpPoll();
}
};
// 先拉取配置(含 tool_search 常驻列表),再加载工具与外部 MCP
if (typeof loadConfig === 'function') {
loadConfig(false)
.catch(err => {
console.warn('加载配置失败(将继续加载工具列表):', err);
console.warn('加载配置失败(将继续加载 MCP 列表):', err);
})
.finally(startLoadMcpTools);
.finally(afterMcpConfigReady);
} else {
startLoadMcpTools();
}
// 先加载外部MCP列表(快速),然后加载工具列表
if (typeof loadExternalMCPs === 'function') {
loadExternalMCPs().catch(err => {
console.warn('加载外部MCP列表失败:', err);
});
afterMcpConfigReady();
}
break;
case 'projects':
@@ -497,7 +505,7 @@ document.addEventListener('DOMContentLoaded', function() {
let pageId = hashParts[0];
if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(200);
+293 -94
View File
@@ -16,6 +16,96 @@ function getToolKey(tool) {
}
return tool.name;
}
// 常驻工具配置存储键(外部工具用 mcp::tool,与后端 tool_search 白名单一致)
function getAlwaysVisibleStorageKey(tool) {
return getToolKey(tool);
}
function addAlwaysVisibleAliases(name) {
const n = (name || '').trim();
if (!n) return;
alwaysVisibleToolNames.add(n);
if (n.includes('::')) {
const sep = n.indexOf('::');
const mcp = n.slice(0, sep);
const tool = n.slice(sep + 2);
if (mcp && tool) {
alwaysVisibleToolNames.add(`${mcp}__${tool}`);
}
return;
}
if (n.includes('__')) {
const sep = n.lastIndexOf('__');
const mcp = n.slice(0, sep);
const tool = n.slice(sep + 2);
if (mcp && tool) {
alwaysVisibleToolNames.add(`${mcp}::${tool}`);
}
}
}
function removeAlwaysVisibleAliases(name) {
const n = (name || '').trim();
if (!n) return;
alwaysVisibleToolNames.delete(n);
if (n.includes('::')) {
const sep = n.indexOf('::');
const mcp = n.slice(0, sep);
const tool = n.slice(sep + 2);
if (mcp && tool) {
alwaysVisibleToolNames.delete(`${mcp}__${tool}`);
}
return;
}
if (n.includes('__')) {
const sep = n.lastIndexOf('__');
const mcp = n.slice(0, sep);
const tool = n.slice(sep + 2);
if (mcp && tool) {
alwaysVisibleToolNames.delete(`${mcp}::${tool}`);
}
}
}
function isToolAlwaysVisible(tool) {
const key = getAlwaysVisibleStorageKey(tool);
if (alwaysVisibleToolNames.has(key)) return true;
if (alwaysVisibleToolNames.has(tool.name)) return true;
if (tool.is_external && tool.external_mcp) {
if (alwaysVisibleToolNames.has(`${tool.external_mcp}__${tool.name}`)) return true;
}
return false;
}
function isToolAlwaysVisibleBuiltin(tool) {
if (alwaysVisibleBuiltinToolNames.has(tool.name)) return true;
return alwaysVisibleBuiltinToolNames.has(getAlwaysVisibleStorageKey(tool));
}
function getAlwaysVisibleForSave() {
const out = new Set();
for (const name of alwaysVisibleToolNames) {
if (alwaysVisibleBuiltinToolNames.has(name)) continue;
if (name.includes('::')) {
out.add(name);
continue;
}
if (name.includes('__')) {
const sep = name.lastIndexOf('__');
const mcp = name.slice(0, sep);
const tool = name.slice(sep + 2);
if (mcp && tool) out.add(`${mcp}::${tool}`);
continue;
}
out.add(name);
}
return Array.from(out);
}
function countUserAlwaysVisibleTools() {
return getAlwaysVisibleForSave().length;
}
// 从localStorage读取每页显示数量,默认为20
const getToolsPageSize = () => {
const saved = localStorage.getItem('toolsPageSize');
@@ -158,14 +248,21 @@ async function loadConfig(loadTools = true) {
}
currentConfig = await response.json();
const alwaysVisibleList = currentConfig?.multi_agent?.tool_search_always_visible_effective_tools;
const alwaysVisibleConfigured = currentConfig?.multi_agent?.tool_search_always_visible_tools;
alwaysVisibleToolNames = new Set(Array.isArray(alwaysVisibleList) ? alwaysVisibleList.filter(Boolean) : []);
alwaysVisibleBuiltinToolNames = new Set(
alwaysVisibleToolNames.size > 0 && Array.isArray(alwaysVisibleConfigured)
? Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleConfigured.includes(name))
: []
);
const alwaysVisibleEffective = currentConfig?.multi_agent?.tool_search_always_visible_effective_tools;
alwaysVisibleToolNames = new Set();
if (Array.isArray(alwaysVisibleConfigured)) {
alwaysVisibleConfigured.filter(Boolean).forEach(addAlwaysVisibleAliases);
}
alwaysVisibleBuiltinToolNames = new Set();
if (Array.isArray(alwaysVisibleEffective)) {
const configuredSet = new Set(Array.isArray(alwaysVisibleConfigured) ? alwaysVisibleConfigured : []);
alwaysVisibleEffective.filter(Boolean).forEach(name => {
if (!configuredSet.has(name)) {
alwaysVisibleBuiltinToolNames.add(name);
}
});
}
// 填充OpenAI配置
const providerEl = document.getElementById('openai-provider');
@@ -442,8 +539,11 @@ let toolsSearchKeyword = '';
// 工具状态筛选: '' = 全部, 'true' = 已启用, 'false' = 已停用
let toolsStatusFilter = '';
// 按外部 MCP 来源筛选(点击左侧卡片时设置)
let toolsExternalMcpFilter = '';
// 加载工具列表(分页)
async function loadToolsList(page = 1, searchKeyword = '') {
async function loadToolsList(page = 1, searchKeyword = '', options = {}) {
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
if (window.i18nReady) await window.i18nReady;
const toolsList = document.getElementById('tools-list');
@@ -466,6 +566,12 @@ async function loadToolsList(page = 1, searchKeyword = '') {
if (toolsStatusFilter !== '') {
url += `&enabled=${toolsStatusFilter}`;
}
if (options.refreshExternal) {
url += '&refresh_external=true';
}
if (toolsExternalMcpFilter) {
url += `&external_mcp=${encodeURIComponent(toolsExternalMcpFilter)}`;
}
// 使用较短的超时时间(10秒),避免长时间等待
const controller = new AbortController();
@@ -486,6 +592,7 @@ async function loadToolsList(page = 1, searchKeyword = '') {
page: result.page || page,
pageSize: result.page_size || pageSize,
total: result.total || 0,
totalEnabled: result.total_enabled ?? 0,
totalPages: result.total_pages || 1
};
@@ -504,6 +611,8 @@ async function loadToolsList(page = 1, searchKeyword = '') {
renderToolsList();
renderToolsPagination();
renderExternalMcpFilterChip();
updateExternalMcpCardSelection();
} catch (error) {
console.error('加载工具列表失败:', error);
if (toolsList) {
@@ -622,8 +731,8 @@ function renderToolsList() {
is_external: tool.is_external || false,
external_mcp: tool.external_mcp || ''
};
const alwaysVisibleChecked = alwaysVisibleToolNames.has(tool.name);
const alwaysVisibleLocked = alwaysVisibleBuiltinToolNames.has(tool.name);
const alwaysVisibleChecked = isToolAlwaysVisible(tool);
const alwaysVisibleLocked = isToolAlwaysVisibleBuiltin(tool);
// 外部工具标签,显示来源信息(可点击跳转到对应 MCP 卡片)
let externalBadge = '';
@@ -648,7 +757,7 @@ function renderToolsList() {
${escapeHtml(tool.name)}
${externalBadge}
<label class="tool-resident-toggle" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleHint') : '始终常驻在 Tool Search 可见列表'}" onclick="event.stopPropagation()">
<input type="checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(tool.name)}', this.checked)" />
<input type="checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(toolKey)}', this.checked)" />
<span>${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleLabel') : '常驻'}</span>
</label>
${alwaysVisibleLocked ? `<span class="external-tool-badge" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinHint') : '后端内置工具默认常驻,不可关闭'}">${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}</span>` : ''}
@@ -763,8 +872,7 @@ 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)) {
if (item.dataset.mcpName === mcpName) {
item.scrollIntoView({ behavior: 'smooth', block: 'center' });
item.classList.add('highlight');
setTimeout(() => item.classList.remove('highlight'), 2000);
@@ -773,6 +881,94 @@ function scrollToExternalMCP(mcpName, event) {
}
}
// 点击左侧外部 MCP 卡片,筛选并定位右侧工具列表
async function scrollToExternalMCPTools(mcpName, event) {
if (event) {
if (event.target.closest('.external-mcp-item-actions, button, a, input, label')) {
return;
}
event.stopPropagation();
}
if (toolsExternalMcpFilter === mcpName) {
await clearExternalMcpFilter();
return;
}
toolsExternalMcpFilter = mcpName;
updateExternalMcpCardSelection();
renderExternalMcpFilterChip();
await loadToolsList(1, toolsSearchKeyword);
requestAnimationFrame(() => {
highlightExternalMcpTools(mcpName);
});
}
function highlightExternalMcpTools(mcpName) {
const toolsList = document.querySelector('.mcp-tools-panel .tools-list');
if (toolsList) {
toolsList.scrollTop = 0;
}
document.querySelectorAll('#tools-list .tool-item.highlight').forEach(el => {
el.classList.remove('highlight');
});
const selector = `#tools-list .tool-item[data-external-mcp="${CSS.escape(mcpName)}"]`;
const matchingTools = document.querySelectorAll(selector);
if (matchingTools.length === 0) {
return;
}
matchingTools[0].scrollIntoView({ behavior: 'smooth', block: 'start' });
matchingTools.forEach(el => {
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 2000);
});
}
async function clearExternalMcpFilter() {
toolsExternalMcpFilter = '';
updateExternalMcpCardSelection();
renderExternalMcpFilterChip();
await loadToolsList(1, toolsSearchKeyword);
}
function updateExternalMcpCardSelection() {
document.querySelectorAll('.external-mcp-item').forEach(item => {
item.classList.toggle('selected', item.dataset.mcpName === toolsExternalMcpFilter);
});
}
function renderExternalMcpFilterChip() {
let chip = document.getElementById('tools-source-filter-chip');
const toolsActions = document.querySelector('.mcp-tools-panel .tools-actions');
if (!toolsActions) {
return;
}
if (!chip) {
chip = document.createElement('div');
chip.id = 'tools-source-filter-chip';
chip.className = 'tools-source-filter-chip';
toolsActions.appendChild(chip);
}
if (!toolsExternalMcpFilter) {
chip.style.display = 'none';
chip.innerHTML = '';
return;
}
const t = typeof window.t === 'function' ? window.t : (k) => k;
chip.style.display = 'inline-flex';
chip.innerHTML = `
<span>${t('mcp.filterBySource', { name: escapeHtml(toolsExternalMcpFilter) })}</span>
<button type="button" class="tools-source-filter-clear" onclick="clearExternalMcpFilter()" title="${escapeHtml(t('mcp.clearSourceFilter'))}">×</button>
`;
}
// 渲染工具列表分页控件
function renderToolsPagination() {
const toolsList = document.getElementById('tools-list');
@@ -847,14 +1043,15 @@ function handleToolCheckboxChange(toolKey, enabled) {
updateToolsStats();
}
function handleToolAlwaysVisibleChange(toolName, alwaysVisible) {
const name = (toolName || '').trim();
if (!name) return;
function handleToolAlwaysVisibleChange(toolKey, alwaysVisible) {
const key = (toolKey || '').trim();
if (!key) return;
if (alwaysVisible) {
alwaysVisibleToolNames.add(name);
addAlwaysVisibleAliases(key);
} else {
alwaysVisibleToolNames.delete(name);
removeAlwaysVisibleAliases(key);
}
updateToolsStats();
}
// 全选工具
@@ -964,60 +1161,22 @@ async function updateToolsStats() {
return checkbox ? checkbox.checked : tool.enabled;
}).length;
} else {
// 没有搜索时,需要获取所有工具的状态
// 先使用全局状态映射和当前页的checkbox状态
const localStateMap = new Map();
// 从当前页的checkbox获取状态(如果全局映射中没有)
allTools.forEach(tool => {
const toolKey = getToolKey(tool);
const savedState = toolStateMap.get(toolKey);
if (savedState !== undefined) {
localStateMap.set(toolKey, savedState.enabled);
} else {
const checkboxId = `tool-${toolKey.replace(/::/g, '--')}`;
const checkbox = document.getElementById(checkboxId);
if (checkbox) {
localStateMap.set(toolKey, checkbox.checked);
} else {
// 如果checkbox不存在(不在当前页),使用工具原始状态
localStateMap.set(toolKey, tool.enabled);
// 使用服务端统计,避免为统计翻页触发多次外部 MCP ListTools
totalEnabled = toolsPagination.totalEnabled ?? 0;
if (toolStateMap.size > 0) {
let delta = 0;
allTools.forEach(tool => {
const toolKey = getToolKey(tool);
const savedState = toolStateMap.get(toolKey);
if (savedState === undefined) {
return;
}
}
});
// 如果总工具数大于当前页,需要获取所有工具的状态
if (totalTools > allTools.length) {
// 遍历所有页面获取完整状态
let page = 1;
let hasMore = true;
const pageSize = 100; // 使用较大的页面大小以减少请求次数
while (hasMore && page <= 10) { // 限制最多10页,避免无限循环
const url = `/api/config/tools?page=${page}&page_size=${pageSize}`;
const pageResponse = await apiFetch(url);
if (!pageResponse.ok) break;
const pageResult = await pageResponse.json();
pageResult.tools.forEach(tool => {
// 优先使用全局状态映射,否则使用服务器返回的状态
const toolKey = getToolKey(tool);
if (!localStateMap.has(toolKey)) {
const savedState = toolStateMap.get(toolKey);
localStateMap.set(toolKey, savedState ? savedState.enabled : tool.enabled);
}
});
if (page >= pageResult.total_pages) {
hasMore = false;
} else {
page++;
if (savedState.enabled !== tool.enabled) {
delta += savedState.enabled ? 1 : -1;
}
}
});
totalEnabled = Math.max(0, totalEnabled + delta);
}
// 计算启用的工具数
totalEnabled = Array.from(localStateMap.values()).filter(enabled => enabled).length;
}
} catch (error) {
console.warn('获取工具统计失败,使用当前页数据', error);
@@ -1027,7 +1186,7 @@ async function updateToolsStats() {
}
const tStats = typeof window.t === 'function' ? window.t : (k) => k;
const pinnedCount = alwaysVisibleToolNames.size;
const pinnedCount = countUserAlwaysVisibleTools();
statsEl.innerHTML = `
<span title="${tStats('mcp.currentPageEnabled')}"> ${tStats('mcp.currentPageEnabled')}: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
<span title="${tStats('mcp.totalEnabled')}">📊 ${tStats('mcp.totalEnabled')}: <strong>${totalEnabled}</strong> / ${totalTools}</span>
@@ -1535,7 +1694,7 @@ async function saveToolsConfig() {
robot_default_agent_mode: currentConfig?.multi_agent?.robot_default_agent_mode || 'eino_single',
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
plan_execute_loop_max_iterations: Number(currentConfig?.multi_agent?.plan_execute_loop_max_iterations || 0),
tool_search_always_visible_tools: Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleBuiltinToolNames.has(name))
tool_search_always_visible_tools: getAlwaysVisibleForSave()
},
tools: []
};
@@ -1732,6 +1891,32 @@ async function fetchExternalMCPs() {
return response.json();
}
// MCP 管理页定时刷新外部 MCP 状态(感知后台断连/自动重连)
let externalMcpPollTimer = null;
const EXTERNAL_MCP_POLL_INTERVAL_MS = 8000;
function startExternalMcpPoll() {
stopExternalMcpPoll();
externalMcpPollTimer = setInterval(function () {
const mcpPage = document.getElementById('page-mcp-management');
if (!mcpPage || !mcpPage.classList.contains('active')) {
stopExternalMcpPoll();
return;
}
if (document.hidden) {
return;
}
loadExternalMCPs().catch(function () { /* ignore */ });
}, EXTERNAL_MCP_POLL_INTERVAL_MS);
}
function stopExternalMcpPoll() {
if (externalMcpPollTimer) {
clearInterval(externalMcpPollTimer);
externalMcpPollTimer = null;
}
}
// 加载外部MCP列表并渲染
async function loadExternalMCPs() {
try {
@@ -1750,6 +1935,13 @@ async function loadExternalMCPs() {
}
}
async function reloadMcpToolsAfterExternalChange(refreshExternal = false) {
if (typeof loadToolsList === 'function') {
const page = (toolsPagination && toolsPagination.page) ? toolsPagination.page : 1;
await loadToolsList(page, toolsSearchKeyword, { refreshExternal });
}
}
// 轮询列表直到指定 MCP 的工具数量已更新(每秒拉一次,拿到即停,无固定延迟)
// name 为 null 时仅按 maxAttempts 次数轮询,不判断 tool_count
async function pollExternalMCPToolCount(name, maxAttempts = 10) {
@@ -1768,6 +1960,7 @@ async function pollExternalMCPToolCount(name, maxAttempts = 10) {
console.warn('轮询工具数量失败:', e);
}
}
await reloadMcpToolsAfterExternalChange(true);
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
window.refreshMentionTools();
}
@@ -1802,8 +1995,15 @@ function renderExternalMCPList(servers) {
const transport = server.config.type || server.config.transport || (server.config.command ? 'stdio' : 'http');
const transportIcon = transport === 'stdio' ? '⚙️' : '🌐';
const hasTools = server.tool_count !== undefined && server.tool_count > 0;
const cardClickTitle = hasTools
? escapeHtml(statusT('mcp.clickToViewTools', { name }))
: '';
const cardClass = hasTools ? 'external-mcp-item clickable' : 'external-mcp-item';
const selectedClass = toolsExternalMcpFilter === name ? ' selected' : '';
html += `
<div class="external-mcp-item">
<div class="${cardClass}${selectedClass}" data-mcp-name="${escapeHtml(name)}"${hasTools ? ` onclick="scrollToExternalMCPTools('${escapeHtml(name)}', event)" title="${cardClickTitle}"` : ''}>
<div class="external-mcp-item-header">
<div class="external-mcp-item-info">
<h4>${transportIcon} ${escapeHtml(name)}${server.tool_count !== undefined && server.tool_count > 0 ? `<span class="tool-count-badge" title="${escapeHtml(statusT('mcp.toolCount'))}">🔧 ${server.tool_count}</span>` : ''}</h4>
@@ -1822,9 +2022,9 @@ function renderExternalMCPList(servers) {
<button class="btn-small btn-danger" onclick="deleteExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.deleteConfig')}" ${status === 'connecting' ? 'disabled' : ''}>🗑 ${statusT('common.delete')}</button>
</div>
</div>
${status === 'error' && server.error ? `
<div class="external-mcp-error" style="margin: 12px 0; padding: 12px; background: #fee; border-left: 3px solid #f44; border-radius: 4px; color: #c33; font-size: 0.875rem;">
<strong> ${statusT('mcp.connectionErrorLabel')}</strong>${escapeHtml(server.error)}
${(status === 'error' || status === 'disconnected') && server.error ? `
<div class="external-mcp-error" style="margin: 12px 0; padding: 12px; background: ${status === 'error' ? '#fee' : '#fff8e6'}; border-left: 3px solid ${status === 'error' ? '#f44' : '#e6a700'}; border-radius: 4px; color: ${status === 'error' ? '#c33' : '#8a6d00'}; font-size: 0.875rem;">
<strong>${status === 'error' ? '❌' : '⚠️'} ${statusT('mcp.connectionErrorLabel')}</strong>${escapeHtml(server.error)}
</div>` : ''}
<div class="external-mcp-item-details">
<div>
@@ -1866,6 +2066,7 @@ function renderExternalMCPList(servers) {
}
html += '</div>';
list.innerHTML = html;
updateExternalMcpCardSelection();
}
// 渲染外部MCP统计信息
@@ -1895,47 +2096,42 @@ function showAddExternalMCPModal() {
document.getElementById('external-mcp-json-error').style.display = 'none';
document.getElementById('external-mcp-json-error').textContent = '';
document.getElementById('external-mcp-json').classList.remove('error');
document.getElementById('external-mcp-modal').style.display = 'block';
openAppModal('external-mcp-modal');
}
// 关闭外部MCP模态框
function closeExternalMCPModal() {
document.getElementById('external-mcp-modal').style.display = 'none';
closeAppModal('external-mcp-modal');
currentEditingMCPName = null;
}
// 编辑外部MCP
async function editExternalMCP(name) {
try {
currentEditingMCPName = name;
document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.editExternalMCP') : '编辑外部MCP');
document.getElementById('external-mcp-json').value = '';
document.getElementById('external-mcp-json-error').style.display = 'none';
document.getElementById('external-mcp-json-error').textContent = '';
document.getElementById('external-mcp-json').classList.remove('error');
openAppModal('external-mcp-modal', { focus: false });
const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`);
if (!response.ok) {
throw new Error(typeof window.t === 'function' ? window.t('mcp.getConfigFailed') : '获取外部MCP配置失败');
}
const server = await response.json();
currentEditingMCPName = name;
document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.editExternalMCP') : '编辑外部MCP');
// 将配置转换为对象格式(key为名称)
const config = { ...server.config };
// 移除tool_count、external_mcp_enable等前端字段,但保留enabled/disabled用于向后兼容
delete config.tool_count;
delete config.external_mcp_enable;
// 包装成对象格式:{ "name": { config } }
const configObj = {};
configObj[name] = config;
// 格式化JSON
const jsonStr = JSON.stringify(configObj, null, 2);
document.getElementById('external-mcp-json').value = jsonStr;
document.getElementById('external-mcp-json-error').style.display = 'none';
document.getElementById('external-mcp-json-error').textContent = '';
document.getElementById('external-mcp-json').classList.remove('error');
document.getElementById('external-mcp-modal').style.display = 'block';
deferModalContent(() => {
document.getElementById('external-mcp-json').value = jsonStr;
document.getElementById('external-mcp-json')?.focus();
});
} catch (error) {
closeExternalMCPModal();
console.error('编辑外部MCP失败:', error);
alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '编辑失败') + ': ' + error.message);
}
@@ -2226,6 +2422,7 @@ async function toggleExternalMCP(name, currentStatus) {
}
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
pollExternalMCPToolCount(name, 10);
await reloadMcpToolsAfterExternalChange(true);
return;
}
}
@@ -2238,6 +2435,7 @@ async function toggleExternalMCP(name, currentStatus) {
} else {
// 停止操作,直接刷新
await loadExternalMCPs();
await reloadMcpToolsAfterExternalChange(false);
// 刷新对话界面的工具列表
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
window.refreshMentionTools();
@@ -2289,6 +2487,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
}
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
pollExternalMCPToolCount(name, 10);
await reloadMcpToolsAfterExternalChange(true);
return;
} else if (status === 'error' || status === 'disconnected') {
// 连接失败,刷新列表并显示错误
+40 -40
View File
@@ -40,7 +40,7 @@ function shouldSkipSkillsAutoRefresh() {
}
const modal = document.getElementById('skill-modal');
if (modal && modal.style.display === 'flex') {
if (modal && isAppModalOpen('skill-modal')) {
return true;
}
@@ -465,7 +465,7 @@ function showAddSkillModal() {
const addTa = document.getElementById('skill-content-add');
if (addTa) addTa.value = '';
modal.style.display = 'flex';
openAppModal('skill-modal');
}
function skillPackagePathDepth(path) {
@@ -555,6 +555,22 @@ async function selectSkillPackageFile(skillId, path, opts) {
// 编辑skill
async function editSkill(skillId) {
wireSkillModalOnce();
const modal = document.getElementById('skill-modal');
if (!modal) return;
skillModalAddMode = false;
skillFileDirty = false;
skillActivePath = 'SKILL.md';
const pkg = document.getElementById('skill-package-editor');
const addEd = document.getElementById('skill-add-editor');
if (pkg) pkg.style.display = 'block';
if (addEd) addEd.style.display = 'none';
document.getElementById('skill-modal-title').textContent = _t('skills.editSkill');
document.getElementById('skill-name').value = '';
document.getElementById('skill-name').disabled = true;
document.getElementById('skill-description').value = '';
const ta = document.getElementById('skill-content');
if (ta) ta.value = '';
openAppModal('skill-modal', { focus: false });
try {
const [detailRes, filesRes] = await Promise.all([
apiFetch(`/api/skills/${encodeURIComponent(skillId)}?depth=full`),
@@ -565,39 +581,24 @@ async function editSkill(skillId) {
}
const data = await detailRes.json();
const skill = data.skill;
const modal = document.getElementById('skill-modal');
if (!modal) return;
skillModalAddMode = false;
skillFileDirty = false;
skillActivePath = 'SKILL.md';
const pkg = document.getElementById('skill-package-editor');
const addEd = document.getElementById('skill-add-editor');
if (pkg) pkg.style.display = 'block';
if (addEd) addEd.style.display = 'none';
document.getElementById('skill-modal-title').textContent = _t('skills.editSkill');
document.getElementById('skill-name').value = skill.id || skillId;
document.getElementById('skill-name').disabled = true;
document.getElementById('skill-description').value = skill.description || '';
let files = [];
if (filesRes.ok) {
const fd = await filesRes.json();
skillPackageFiles = fd.files || [];
} else {
skillPackageFiles = [];
files = fd.files || [];
}
renderSkillPackageTree();
const ta = document.getElementById('skill-content');
if (ta) ta.value = skill.content || '';
const hint = document.getElementById('skill-body-hint-edit');
if (hint) hint.style.display = 'block';
currentEditingSkillName = skillId;
modal.style.display = 'flex';
deferModalContent(() => {
document.getElementById('skill-name').value = skill.id || skillId;
document.getElementById('skill-description').value = skill.description || '';
skillPackageFiles = files;
renderSkillPackageTree();
if (ta) ta.value = skill.content || '';
const hint = document.getElementById('skill-body-hint-edit');
if (hint) hint.style.display = 'block';
document.getElementById('skill-name')?.focus();
});
} catch (error) {
closeSkillModal();
console.error('加载skill详情失败:', error);
showNotification(_t('skills.loadDetailFailed') + ': ' + error.message, 'error');
}
@@ -659,7 +660,7 @@ async function viewSkill(skillId) {
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'flex';
openAppModal(modal);
const close = () => closeSkillViewModal();
modal.querySelectorAll('[data-skill-view-close]').forEach(el => el.addEventListener('click', close));
@@ -691,23 +692,22 @@ async function viewSkill(skillId) {
// 关闭查看模态框
function closeSkillViewModal() {
closeAppModal('skill-view-modal');
const modal = document.getElementById('skill-view-modal');
if (modal) {
modal.remove();
syncAppModalBodyLock();
}
}
// 关闭skill模态框
function closeSkillModal() {
const modal = document.getElementById('skill-modal');
if (modal) {
modal.style.display = 'none';
currentEditingSkillName = null;
skillModalAddMode = true;
skillFileDirty = false;
skillPackageFiles = [];
skillActivePath = 'SKILL.md';
}
closeAppModal('skill-modal');
currentEditingSkillName = null;
skillModalAddMode = true;
skillFileDirty = false;
skillPackageFiles = [];
skillActivePath = 'SKILL.md';
}
// 保存skill
+18 -25
View File
@@ -914,18 +914,14 @@ async function showBatchImportModal() {
}
}
await refreshBatchProjectSelectOptions();
modal.style.display = 'block';
input.focus();
openAppModal('batch-import-modal', { focusEl: input });
}
}
// 关闭新建任务模态框
function closeBatchImportModal() {
const modal = document.getElementById('batch-import-modal');
if (modal) {
modal.style.display = 'none';
}
closeAppModal('batch-import-modal');
}
function handleBatchScheduleModeChange() {
@@ -1350,7 +1346,13 @@ async function showBatchQueueDetail(queueId) {
const addTaskBtn = document.getElementById('batch-queue-add-task-btn');
if (!modal || !content) return;
const alreadyOpen = isAppModalOpen('batch-queue-detail-modal');
if (!alreadyOpen) {
if (content) content.innerHTML = '<p style="color:#64748b;margin:0;">…</p>';
openAppModal('batch-queue-detail-modal', { focus: false });
}
try {
// 加载角色列表(如果还未加载)
let loadedRoles = [];
@@ -1459,6 +1461,7 @@ async function showBatchQueueDetail(queueId) {
const sameQueueAsBefore = prevDetailFor === queue.id;
const savedTechDetailsOpen = sameQueueAsBefore && !!(prevTechDetails && prevTechDetails.open);
deferModalContent(function () {
content.innerHTML = `
<div class="batch-queue-detail-layout" data-bq-detail-for="${escapeHtml(queue.id)}">
<section class="batch-queue-detail-hero">
@@ -1529,8 +1532,7 @@ async function showBatchQueueDetail(queueId) {
if (newTechDetails && savedTechDetailsOpen) {
newTechDetails.open = true;
}
modal.style.display = 'block';
});
// 仅运行中定时拉取详情;其它状态应停止,避免 innerHTML 重绘把 <details> 等 UI 打回默认态
if (queue.status === 'running') {
@@ -1540,6 +1542,7 @@ async function showBatchQueueDetail(queueId) {
}
} catch (error) {
console.error('获取队列详情失败:', error);
closeBatchQueueDetailModal();
alert(_t('tasks.getQueueDetailFailed') + ': ' + error.message);
}
}
@@ -1708,10 +1711,7 @@ async function deleteBatchQueueFromList(queueId) {
// 关闭批量任务队列详情模态框
function closeBatchQueueDetailModal() {
const modal = document.getElementById('batch-queue-detail-modal');
if (modal) {
modal.style.display = 'none';
}
closeAppModal('batch-queue-detail-modal');
batchQueuesState.currentQueueId = null;
stopBatchQueueRefresh();
}
@@ -1730,7 +1730,7 @@ function startBatchQueueRefresh(queueId) {
content.querySelector('.bq-inline-edit-controls') ||
content.querySelector('.batch-task-inline-edit')
);
if ((addModal && addModal.style.display === 'block') || hasInlineEdit) {
if ((addModal && isAppModalOpen('add-batch-task-modal')) || hasInlineEdit) {
return;
}
if (batchQueuesState._bqDetailRefreshing) {
@@ -1891,12 +1891,7 @@ function showAddBatchTaskModal() {
}
messageInput.value = '';
modal.style.display = 'block';
// 聚焦到输入框
setTimeout(() => {
messageInput.focus();
}, 100);
openAppModal('add-batch-task-modal', { focusEl: messageInput });
// 清理旧的事件监听器
if (showAddBatchTaskModal._escHandler) {
@@ -1940,9 +1935,7 @@ function closeAddBatchTaskModal() {
}
const modal = document.getElementById('add-batch-task-modal');
const messageInput = document.getElementById('add-task-message');
if (modal) {
modal.style.display = 'none';
}
closeAppModal('add-batch-task-modal');
if (messageInput) {
messageInput.value = '';
}
@@ -2462,7 +2455,7 @@ document.addEventListener('languagechange', function () {
const detailModal = document.getElementById('batch-queue-detail-modal');
if (
detailModal &&
detailModal.style.display === 'block' &&
isAppModalOpen('batch-queue-detail-modal') &&
batchQueuesState.currentQueueId
) {
showBatchQueueDetail(batchQueuesState.currentQueueId);
+26 -26
View File
@@ -33,7 +33,8 @@ function vulnStatusLabel(code) {
open: 'vulnerabilityPage.statusOpen',
confirmed: 'vulnerabilityPage.statusConfirmed',
fixed: 'vulnerabilityPage.statusFixed',
false_positive: 'vulnerabilityPage.statusFalsePositive'
false_positive: 'vulnerabilityPage.statusFalsePositive',
ignored: 'vulnerabilityPage.statusIgnored'
};
return m[code] ? vulnT(m[code]) : code;
}
@@ -1089,37 +1090,36 @@ async function showAddVulnerabilityModal() {
document.getElementById('vulnerability-impact').value = '';
document.getElementById('vulnerability-recommendation').value = '';
document.getElementById('vulnerability-modal').style.display = 'block';
openAppModal('vulnerability-modal');
}
// 编辑漏洞
async function editVulnerability(id) {
try {
const response = await apiFetch(`/api/vulnerabilities/${id}`);
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
const vuln = await response.json();
currentVulnerabilityId = id;
document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.editVuln');
// 填充表单
document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || '';
document.getElementById('vulnerability-conversation-tag').value = vuln.conversation_tag || '';
document.getElementById('vulnerability-task-tag').value = vuln.task_tag || '';
document.getElementById('vulnerability-title').value = vuln.title || '';
document.getElementById('vulnerability-description').value = vuln.description || '';
document.getElementById('vulnerability-severity').value = vuln.severity || '';
document.getElementById('vulnerability-status').value = vuln.status || 'open';
document.getElementById('vulnerability-type').value = vuln.type || '';
document.getElementById('vulnerability-target').value = vuln.target || '';
document.getElementById('vulnerability-proof').value = vuln.proof || '';
document.getElementById('vulnerability-impact').value = vuln.impact || '';
document.getElementById('vulnerability-recommendation').value = vuln.recommendation || '';
await populateVulnerabilityModalProjectSelect(vuln.project_id || '');
document.getElementById('vulnerability-modal').style.display = 'block';
openAppModal('vulnerability-modal', { focus: false });
const response = await apiFetch(`/api/vulnerabilities/${id}`);
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
const vuln = await response.json();
deferModalContent(async () => {
document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || '';
document.getElementById('vulnerability-conversation-tag').value = vuln.conversation_tag || '';
document.getElementById('vulnerability-task-tag').value = vuln.task_tag || '';
document.getElementById('vulnerability-title').value = vuln.title || '';
document.getElementById('vulnerability-description').value = vuln.description || '';
document.getElementById('vulnerability-severity').value = vuln.severity || '';
document.getElementById('vulnerability-status').value = vuln.status || 'open';
document.getElementById('vulnerability-type').value = vuln.type || '';
document.getElementById('vulnerability-target').value = vuln.target || '';
document.getElementById('vulnerability-proof').value = vuln.proof || '';
document.getElementById('vulnerability-impact').value = vuln.impact || '';
document.getElementById('vulnerability-recommendation').value = vuln.recommendation || '';
await populateVulnerabilityModalProjectSelect(vuln.project_id || '');
document.getElementById('vulnerability-title')?.focus();
});
} catch (error) {
closeVulnerabilityModal();
console.error('加载漏洞失败:', error);
alert(vulnT('vulnerability.loadFailed') + ': ' + error.message);
}
@@ -1232,7 +1232,7 @@ async function deleteVulnerability(id) {
// 关闭漏洞模态框
function closeVulnerabilityModal() {
document.getElementById('vulnerability-modal').style.display = 'none';
closeAppModal('vulnerability-modal');
currentVulnerabilityId = null;
}
@@ -1748,7 +1748,7 @@ async function refreshVulnerabilityProjectFilter() {
sel.innerHTML = html;
if (cur) sel.value = cur;
const modalSel = document.getElementById('vulnerability-project-id');
if (modalSel && document.getElementById('vulnerability-modal')?.style.display === 'block') {
if (modalSel && isAppModalOpen('vulnerability-modal')) {
const modalCur = modalSel.value || '';
modalSel.innerHTML = buildVulnerabilityProjectOptionsHtml(modalCur);
modalSel.value = modalCur;
+290 -30
View File
@@ -27,6 +27,9 @@ const WEBSHELL_HISTORY_MAX = 100;
let webshellClearInProgress = false;
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话
let webshellAiConvMap = {};
// AI 助手:项目绑定(已有对话按 convId,新对话按 connId 草稿)
let webshellAiProjectByConvId = {};
let webshellAiDraftProjectByConn = {};
let webshellAiSending = false;
let webshellAiAbortController = null; // AbortController for current AI stream
let webshellAiStreamReader = null; // Current ReadableStreamDefaultReader
@@ -266,6 +269,7 @@ function wsToggleRolePanel() {
var isOpen = panel.style.display === 'flex';
if (isOpen) { wsCloseRolePanel(); return; }
wsCloseAgentModePanel();
wsCloseProjectPanel();
panel.style.display = 'flex';
}
function wsCloseRolePanel() {
@@ -340,6 +344,7 @@ function wsToggleAgentModePanel() {
var isOpen = panel.style.display === 'flex';
if (isOpen) { wsCloseAgentModePanel(); return; }
wsCloseRolePanel();
wsCloseProjectPanel();
panel.style.display = 'flex';
}
function wsCloseAgentModePanel() {
@@ -347,10 +352,204 @@ function wsCloseAgentModePanel() {
if (panel) panel.style.display = 'none';
}
// ─── WebShell AI 项目选择器(与主「对话」页对齐) ───
function wsProjectT(key, fallback) {
if (typeof window.t === 'function') {
var v = window.t(key);
if (v && v !== key) return v;
}
return fallback;
}
function getWebshellAiConvId(conn) {
if (!conn || !conn.id) return '';
return webshellAiConvMap[conn.id] || '';
}
function getWebshellAiProjectSelection(conn) {
if (!conn || !conn.id) return '';
var convId = getWebshellAiConvId(conn);
if (convId) return webshellAiProjectByConvId[convId] || '';
return webshellAiDraftProjectByConn[conn.id] || '';
}
function wsSetWebshellAiProject(conn, projectId) {
if (!conn || !conn.id) return;
var pid = projectId || '';
var convId = getWebshellAiConvId(conn);
if (convId) {
if (pid) webshellAiProjectByConvId[convId] = pid;
else delete webshellAiProjectByConvId[convId];
} else if (pid) {
webshellAiDraftProjectByConn[conn.id] = pid;
} else {
delete webshellAiDraftProjectByConn[conn.id];
}
wsUpdateProjectButtonLabel();
}
function wsIsActiveProjectId(id) {
if (!id) return false;
var map = window.projectNameById || {};
return !!map[id];
}
function wsResolveWebshellAiProjectSelection(conn) {
var raw = getWebshellAiProjectSelection(conn);
if (!raw) return '';
return wsIsActiveProjectId(raw) ? raw : '';
}
function wsUpdateProjectButtonLabel() {
var textEl = document.getElementById('ws-project-text');
if (!textEl || !webshellCurrentConn) return;
var id = wsResolveWebshellAiProjectSelection(webshellCurrentConn);
var nameMap = window.projectNameById || {};
textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目');
}
async function wsRenderProjectPanelList() {
var list = document.getElementById('ws-project-list');
if (!list || !webshellCurrentConn) return;
var conn = webshellCurrentConn;
var selected = wsResolveWebshellAiProjectSelection(conn);
var projects = [];
try {
if (typeof window.fetchAllProjects === 'function') {
projects = await window.fetchAllProjects(false);
}
} catch (e) {
list.innerHTML = '<div class="chat-project-panel-empty">' + escapeHtml(wsProjectT('projects.loadFailedRetry', '加载失败,请重试')) + '</div>';
return;
}
if (typeof window.rebuildProjectNameMap === 'function') {
window.rebuildProjectNameMap(projects);
}
var activeProjects = projects.filter(function (p) { return p.status !== 'archived'; });
var items = [{ id: '', name: wsProjectT('projects.noProject', '无项目'), description: wsProjectT('projects.noProjectDescription', '不绑定项目') }].concat(activeProjects);
list.innerHTML = '';
items.forEach(function (p) {
var isNone = !p.id;
var isSelected = isNone ? !selected : selected === p.id;
var desc = isNone
? (p.description || '')
: ((p.description || '').trim().slice(0, 80) || wsProjectT('projects.sharedFactBoard', '共享事实黑板'));
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
btn.setAttribute('role', 'option');
btn.onclick = function () { wsSelectProject(p.id || ''); };
btn.innerHTML = '<div class="role-selection-item-icon-main">' + (isNone ? '—' : '📁') + '</div>' +
'<div class="role-selection-item-content-main">' +
'<div class="role-selection-item-name-main">' + escapeHtml(p.name || '未命名') + '</div>' +
'<div class="role-selection-item-description-main">' + escapeHtml(desc) + '</div></div>' +
(isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : '');
list.appendChild(btn);
});
}
async function wsRenderProjectPanel() {
var list = document.getElementById('ws-project-list');
if (!list) return;
list.innerHTML = '<div class="chat-project-panel-loading">' + escapeHtml(wsProjectT('common.loading', '加载中...')) + '</div>';
await wsRenderProjectPanelList();
}
function wsCloseProjectPanel() {
var panel = document.getElementById('ws-project-panel');
var btn = document.getElementById('ws-project-btn');
if (panel) panel.style.display = 'none';
if (btn) {
btn.classList.remove('active');
btn.setAttribute('aria-expanded', 'false');
}
}
async function wsToggleProjectPanel() {
var panel = document.getElementById('ws-project-panel');
var btn = document.getElementById('ws-project-btn');
if (!panel) return;
var isHidden = panel.style.display === 'none' || !panel.style.display;
if (!isHidden) {
wsCloseProjectPanel();
return;
}
wsCloseRolePanel();
wsCloseAgentModePanel();
panel.style.display = 'flex';
if (btn) {
btn.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
}
await wsRenderProjectPanel();
}
async function wsSelectProject(projectId) {
wsCloseProjectPanel();
await applyWebshellAiProjectSelection(projectId || '');
}
async function applyWebshellAiProjectSelection(projectId) {
var conn = webshellCurrentConn;
if (!conn || !conn.id) return;
var prev = getWebshellAiProjectSelection(conn);
if (projectId === prev) {
wsUpdateProjectButtonLabel();
return;
}
var convId = getWebshellAiConvId(conn);
if (convId) {
try {
var res = await apiFetch('/api/conversations/' + encodeURIComponent(convId) + '/project', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: projectId }),
});
if (!res.ok) {
var err = await res.json().catch(function () { return {}; });
throw new Error(err.error || res.statusText);
}
wsSetWebshellAiProject(conn, projectId);
if (typeof showNotification === 'function') {
showNotification(
projectId ? wsProjectT('projects.projectBound', '已绑定项目') : wsProjectT('projects.projectUnbound', '已解除项目绑定'),
'success'
);
}
} catch (e) {
console.error(e);
alert(wsProjectT('projects.updateProjectBindingFailed', '更新项目绑定失败') + ': ' + (e.message || e));
wsUpdateProjectButtonLabel();
return;
}
} else {
wsSetWebshellAiProject(conn, projectId);
}
wsUpdateProjectButtonLabel();
}
function showNewProjectModalFromWebshellAi() {
wsCloseProjectPanel();
if (webshellCurrentConn && webshellCurrentConn.id) {
window._projectModalFromWebshellConnId = webshellCurrentConn.id;
}
window._projectModalFromChat = false;
if (typeof showNewProjectModal === 'function') showNewProjectModal();
}
window.applyWebshellAiProjectSelection = applyWebshellAiProjectSelection;
window.showNewProjectModalFromWebshellAi = showNewProjectModalFromWebshellAi;
window.wsToggleProjectPanel = wsToggleProjectPanel;
window.wsCloseProjectPanel = wsCloseProjectPanel;
// ─── end WebShell AI 项目选择器 ───
/** 当 WebShell AI Tab 可见时刷新选择器显示(同步主页可能的更改) */
function wsRefreshSelectors() {
wsUpdateRoleSelectorDisplay();
wsRenderRoleList();
wsUpdateProjectButtonLabel();
var stored = localStorage.getItem('cyberstrike-chat-agent-mode') || 'eino_single';
if (stored !== 'eino_single' && stored !== 'deep' && stored !== 'plan_execute' && stored !== 'supervisor') {
stored = 'eino_single';
@@ -370,6 +569,11 @@ document.addEventListener('click', function (e) {
if (modePanel && modePanel.style.display !== 'none' && modeBtn && !modePanel.contains(e.target) && !modeBtn.contains(e.target)) {
wsCloseAgentModePanel();
}
var projectPanel = document.getElementById('ws-project-panel');
var projectBtn = document.getElementById('ws-project-btn');
if (projectPanel && projectPanel.style.display !== 'none' && projectBtn && !projectPanel.contains(e.target) && !projectBtn.contains(e.target)) {
wsCloseProjectPanel();
}
});
// ─── end WebShell AI 选择器 ───
@@ -1873,6 +2077,7 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
apiFetch('/api/conversations/' + encodeURIComponent(convId) + '?include_process_details=1', { method: 'GET' })
.then(function (r) { return r.json(); })
.then(function (data) {
wsSetWebshellAiProject(conn, data.projectId || data.project_id || '');
messagesContainer.innerHTML = '';
var list = data.messages || [];
list.forEach(function (msg) {
@@ -1893,9 +2098,14 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
}
}
messagesContainer.appendChild(div);
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
if (block) messagesContainer.appendChild(block);
if (role === 'assistant') {
var wsMergedDetails = (typeof window.mergeMessageReasoningContentIntoProcessDetails === 'function')
? window.mergeMessageReasoningContentIntoProcessDetails(msg.processDetails || [], msg.reasoningContent)
: (msg.processDetails || []);
if (wsMergedDetails.length > 0) {
var block = renderWebshellProcessDetailsBlock(wsMergedDetails, true);
if (block) messagesContainer.appendChild(block);
}
}
});
if (list.length === 0) {
@@ -2003,6 +2213,25 @@ function selectWebshell(id, stateReady) {
'<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-project-selector-wrapper project-selector-wrapper">' +
'<button type="button" id="ws-project-btn" class="role-selector-btn" onclick="wsToggleProjectPanel()" aria-label="' + escapeHtml(wsProjectT('projects.chatSelectorButton', '选择项目')) + '" aria-haspopup="listbox" aria-expanded="false" title="' + escapeHtml(wsProjectT('projects.chatSelectorButton', '绑定项目后共享事实黑板(跨对话)')) + '">' +
'<span class="role-selector-icon" aria-hidden="true">📁</span>' +
'<span id="ws-project-text" class="role-selector-text">' + escapeHtml(wsProjectT('projects.noProject', '无项目')) + '</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-project-panel" class="role-selection-panel chat-project-panel" style="display:none;" role="listbox">' +
'<div class="role-selection-panel-header">' +
'<h3 class="role-selection-panel-title">' + escapeHtml(wsProjectT('projects.selectProject', '选择项目')) + '</h3>' +
'<button type="button" class="role-selection-panel-close" onclick="wsCloseProjectPanel()" title="' + escapeHtml(wsProjectT('common.close', '关闭')) + '" aria-label="' + escapeHtml(wsProjectT('common.close', '关闭')) + '">' +
'<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="chat-project-panel-body">' +
'<div id="ws-project-list" class="role-selection-list-main"></div>' +
'<div class="chat-project-panel-footer">' +
'<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromWebshellAi()">' +
'<span class="chat-project-panel-create-icon" aria-hidden="true">+</span>' +
'<span class="chat-project-panel-create-label">' + escapeHtml(wsProjectT('projects.newProject', '新建项目')) + '</span>' +
'</button></div></div></div></div>' +
'<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>' +
@@ -2174,9 +2403,11 @@ function selectWebshell(id, stateReady) {
var aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
var aiConvListEl = document.getElementById('webshell-ai-conv-list');
// 初始化角色 + 模式选择器
// 初始化角色 + 模式 + 项目选择器
wsLoadRoles();
wsInitAgentMode();
if (typeof prefetchProjectsForChat === 'function') prefetchProjectsForChat();
wsUpdateProjectButtonLabel();
var aiMemoInput = document.getElementById('webshell-ai-memo-input');
var aiMemoStatus = document.getElementById('webshell-ai-memo-status');
var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear');
@@ -2225,6 +2456,8 @@ function selectWebshell(id, stateReady) {
if (aiNewConvBtn) {
aiNewConvBtn.addEventListener('click', function () {
delete webshellAiConvMap[conn.id];
delete webshellAiDraftProjectByConn[conn.id];
wsUpdateProjectButtonLabel();
if (aiMessages) {
aiMessages.innerHTML = '';
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
@@ -2301,10 +2534,14 @@ function selectWebshell(id, stateReady) {
function setDbProfileModalVisible(visible, mode) {
if (!dbProfileModalEl) return;
dbProfileModalEl.style.display = visible ? 'block' : 'none';
if (dbProfileModalTitleEl) {
if (mode === 'add') dbProfileModalTitleEl.textContent = wsT('webshell.dbAddProfile') || '新增连接';
else dbProfileModalTitleEl.textContent = wsT('webshell.editConnectionTitle') || '编辑连接';
if (visible) {
if (dbProfileModalTitleEl) {
if (mode === 'add') dbProfileModalTitleEl.textContent = wsT('webshell.dbAddProfile') || '新增连接';
else dbProfileModalTitleEl.textContent = wsT('webshell.editConnectionTitle') || '编辑连接';
}
openAppModal(dbProfileModalEl);
} else {
closeAppModal(dbProfileModalEl);
}
}
@@ -2763,7 +3000,15 @@ function loadWebshellAiHistory(conn, messagesContainer) {
return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-history', { method: 'GET' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.conversationId) webshellAiConvMap[conn.id] = data.conversationId;
if (data.conversationId) {
webshellAiConvMap[conn.id] = data.conversationId;
apiFetch('/api/conversations/' + encodeURIComponent(data.conversationId), { method: 'GET' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (conv) {
if (conv) wsSetWebshellAiProject(conn, conv.projectId || conv.project_id || '');
})
.catch(function () { /* ignore */ });
}
var list = Array.isArray(data.messages) ? data.messages : [];
list.forEach(function (msg) {
var role = (msg.role || '').toLowerCase();
@@ -2783,9 +3028,14 @@ function loadWebshellAiHistory(conn, messagesContainer) {
}
}
messagesContainer.appendChild(div);
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
if (block) messagesContainer.appendChild(block);
if (role === 'assistant') {
var wsHistMerged = (typeof window.mergeMessageReasoningContentIntoProcessDetails === 'function')
? window.mergeMessageReasoningContentIntoProcessDetails(msg.processDetails || [], msg.reasoningContent)
: (msg.processDetails || []);
if (wsHistMerged.length > 0) {
var block = renderWebshellProcessDetailsBlock(wsHistMerged, true);
if (block) messagesContainer.appendChild(block);
}
}
});
if (list.length === 0) {
@@ -2918,6 +3168,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
conversationId: convId,
role: wsRole
};
if (!convId) {
var wsPid = getWebshellAiProjectSelection(conn);
if (wsPid) body.projectId = wsPid;
}
// 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加
var streamingTarget = ''; // 当前要打字显示的目标全文(用于打字机效果)
@@ -2966,6 +3220,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
if (_et === 'conversation' && _ed.conversationId) {
var convId = _ed.conversationId;
var prevDraft = webshellAiDraftProjectByConn[conn.id];
if (prevDraft) {
webshellAiProjectByConvId[convId] = prevDraft;
delete webshellAiDraftProjectByConn[conn.id];
}
webshellAiConvMap[conn.id] = convId;
var listEl = document.getElementById('webshell-ai-conv-list');
if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () {
@@ -4369,37 +4628,38 @@ function showAddWebshellModal() {
var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
var modal = document.getElementById('webshell-modal');
if (modal) modal.style.display = 'block';
if (modal) openAppModal(modal);
}
// 打开编辑连接弹窗(预填当前连接信息)
function showEditWebshellModal(connId) {
var conn = webshellConnections.find(function (c) { return c.id === connId; });
if (!conn) return;
var editIdEl = document.getElementById('webshell-edit-id');
if (editIdEl) editIdEl.value = conn.id;
document.getElementById('webshell-url').value = conn.url || '';
document.getElementById('webshell-password').value = conn.password || '';
document.getElementById('webshell-type').value = conn.type || 'php';
document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase();
document.getElementById('webshell-cmd-param').value = conn.cmdParam || '';
var osEditEl = document.getElementById('webshell-os');
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
var encEditEl = document.getElementById('webshell-encoding');
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
document.getElementById('webshell-remark').value = conn.remark || '';
var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle');
var modal = document.getElementById('webshell-modal');
if (modal) modal.style.display = 'block';
openAppModal('webshell-modal', { focus: false });
deferModalContent(function () {
var editIdEl = document.getElementById('webshell-edit-id');
if (editIdEl) editIdEl.value = conn.id;
document.getElementById('webshell-url').value = conn.url || '';
document.getElementById('webshell-password').value = conn.password || '';
document.getElementById('webshell-type').value = conn.type || 'php';
document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase();
document.getElementById('webshell-cmd-param').value = conn.cmdParam || '';
var osEditEl = document.getElementById('webshell-os');
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
var encEditEl = document.getElementById('webshell-encoding');
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
document.getElementById('webshell-remark').value = conn.remark || '';
document.getElementById('webshell-url')?.focus();
});
}
// 关闭弹窗
function closeWebshellModal() {
var editIdEl = document.getElementById('webshell-edit-id');
if (editIdEl) editIdEl.value = '';
var modal = document.getElementById('webshell-modal');
if (modal) modal.style.display = 'none';
closeAppModal('webshell-modal');
}
// 语言切换时刷新 WebShell 页面内所有由 JS 生成的文案(不重建终端)
@@ -4571,7 +4831,7 @@ function refreshWebshellUIOnLanguageChange() {
}
var modal = document.getElementById('webshell-modal');
if (modal && modal.style.display === 'block') {
if (modal && isAppModalOpen('webshell-modal')) {
var titleEl = document.getElementById('webshell-modal-title');
var editIdEl = document.getElementById('webshell-edit-id');
if (titleEl) {
File diff suppressed because one or more lines are too long
+6696
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(function(){return(()=>{"use strict";var e={775:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0;var r=function(){function e(){}return e.prototype.activate=function(e){this._terminal=e},e.prototype.dispose=function(){},e.prototype.fit=function(){var e=this.proposeDimensions();if(e&&this._terminal){var t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}},e.prototype.proposeDimensions=function(){if(this._terminal&&this._terminal.element&&this._terminal.element.parentElement){var e=this._terminal._core;if(0!==e._renderService.dimensions.actualCellWidth&&0!==e._renderService.dimensions.actualCellHeight){var t=window.getComputedStyle(this._terminal.element.parentElement),r=parseInt(t.getPropertyValue("height")),i=Math.max(0,parseInt(t.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),o=r-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=i-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-e.viewport.scrollBarWidth;return{cols:Math.max(2,Math.floor(a/e._renderService.dimensions.actualCellWidth)),rows:Math.max(1,Math.floor(o/e._renderService.dimensions.actualCellHeight))}}}},e}();t.FitAddon=r}},t={};return function r(i){if(t[i])return t[i].exports;var n=t[i]={exports:{}};return e[i](n,n.exports,r),n.exports}(775)})()}));
//# sourceMappingURL=xterm-addon-fit.js.map
+190
View File
@@ -0,0 +1,190 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
opacity: 0.5;
}
.xterm-underline {
text-decoration: underline;
}
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-decoration-overview-ruler {
z-index: 7;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
+2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -934,7 +934,7 @@ Content-Type: application/json
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/i18next@23.11.5/i18next.min.js"></script>
<script src="/static/vendor/i18next.min.js"></script>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/api-docs.js"></script>
</body>
+44 -22
View File
@@ -8,7 +8,8 @@
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<script src="/static/js/router.js"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -1471,18 +1472,20 @@
<div class="projects-detail-inner" id="projects-detail-inner" hidden>
<header class="projects-detail-header">
<div class="projects-detail-header-main">
<div class="projects-detail-title-row">
<h3 id="projects-detail-title" class="projects-detail-title" data-i18n="projects.defaultProjectName">项目</h3>
<span id="projects-detail-status" class="projects-status-pill projects-status-pill--active" data-i18n="projects.statusActive">进行中</span>
<div class="projects-detail-headline">
<div class="projects-detail-title-group">
<h3 id="projects-detail-title" class="projects-detail-title" data-i18n="projects.defaultProjectName">项目</h3>
<span id="projects-detail-status" class="projects-status-pill projects-status-pill--active" data-i18n="projects.statusActive">进行中</span>
</div>
<div class="projects-detail-stats" id="projects-detail-stats">
<span class="projects-stat-chip projects-stat-chip--facts" id="project-stat-facts">0 条事实</span>
<span class="projects-stat-chip projects-stat-chip--vulns" id="project-stat-vulns">0 个漏洞</span>
<span class="projects-stat-chip projects-stat-chip--conversations" id="project-stat-conversations">0 个对话</span>
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
</div>
</div>
<p id="projects-detail-meta" class="projects-detail-meta"></p>
<p id="projects-detail-desc" class="projects-detail-desc"></p>
<div class="projects-detail-stats" id="projects-detail-stats">
<span class="projects-stat-chip" id="project-stat-facts">0 条事实</span>
<span class="projects-stat-chip" id="project-stat-vulns">0 个漏洞</span>
<span class="projects-stat-chip" id="project-stat-conversations">0 个对话</span>
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
</div>
<p id="projects-detail-desc" class="projects-detail-desc" hidden></p>
</div>
<div class="projects-detail-header-actions">
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button>
@@ -1613,6 +1616,7 @@
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
<option value="ignored" data-i18n="vulnerabilityPage.statusIgnored">已忽略</option>
</select>
</label>
</div>
@@ -1668,7 +1672,8 @@
</div>
<div class="projects-form-field">
<label for="project-edit-description" data-i18n="projects.projectDescription">描述</label>
<textarea id="project-edit-description" class="form-input" rows="3" placeholder="测试目标、授权范围、联系人、注意事项…" data-i18n="projects.editDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
<textarea id="project-edit-description" class="form-input projects-description-textarea" rows="3" maxlength="4000" placeholder="测试目标、授权范围、联系人、注意事项…" data-i18n="projects.editDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
<small class="form-hint" data-i18n="projects.descriptionLengthHint">简要说明即可(最多 4000 字);大段日志/POC 请写入事实黑板 body</small>
</div>
</div>
</section>
@@ -1811,6 +1816,7 @@
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
<option value="ignored" data-i18n="vulnerabilityPage.statusIgnored">已忽略</option>
</select>
</label>
<div class="vulnerability-filter-actions">
@@ -3478,16 +3484,16 @@
</div>
</div>
<!-- Marked.js + DOMPurify:本地 vendor避免 CDN 不可用导致 Markdown 降级为纯文本 -->
<!-- Marked.js + DOMPurify + 其他前端依赖:本地 vendor内网/离线部署不依赖 CDN -->
<script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.min.js"></script>
<script src="/static/js/sanitize-markdown.js"></script>
<!-- Cytoscape.js for attack chain visualization -->
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.27.0/dist/cytoscape.min.js"></script>
<script src="/static/vendor/cytoscape.min.js"></script>
<!-- ELK.js for high-quality DAG layout (reduces edge crossings) -->
<script src="https://cdn.jsdelivr.net/npm/elkjs@0.9.2/lib/elk.bundled.js"></script>
<script src="/static/vendor/elk.bundled.js"></script>
<!-- SheetJS for XLSX export (info-collect) -->
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<script src="/static/vendor/xlsx.full.min.js"></script>
<script>
// 确保ELK对象全局可用
if (typeof ELK === 'undefined' && typeof elk !== 'undefined') {
@@ -3775,6 +3781,20 @@
</div>
</div>
<!-- 项目列表操作菜单 -->
<div id="projects-list-action-menu" class="context-menu" style="display: none;" role="menu">
<div id="projects-list-menu-edit" class="context-menu-item" onclick="editProjectFromListMenu()">
<span id="projects-list-menu-edit-text"></span>
</div>
<div id="projects-list-menu-archive" class="context-menu-item" onclick="toggleProjectArchiveFromListMenu()">
<span id="projects-list-menu-archive-text"></span>
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item context-menu-item-danger" onclick="deleteProjectFromListMenu()">
<span id="projects-list-menu-delete-text"></span>
</div>
</div>
<!-- 新建任务模态框 -->
<div id="batch-import-modal" class="modal">
<div class="modal-content" style="max-width: 800px;">
@@ -3943,6 +3963,7 @@
<option value="confirmed" data-i18n="vulnerabilityModal.statusConfirmed">已确认</option>
<option value="fixed" data-i18n="vulnerabilityModal.statusFixed">已修复</option>
<option value="false_positive" data-i18n="vulnerabilityModal.statusFalsePositive">误报</option>
<option value="ignored" data-i18n="vulnerabilityModal.statusIgnored">已忽略</option>
</select>
</div>
<div class="form-group">
@@ -4152,11 +4173,12 @@
<div class="projects-modal-body">
<div class="projects-form-field">
<label for="project-modal-name" data-i18n="projects.projectName">项目名称 <span class="required">*</span></label>
<input type="text" id="project-modal-name" class="form-input" placeholder="例如:某客户 Web 渗透" autocomplete="off" data-i18n="projects.projectNamePlaceholder" data-i18n-attr="placeholder">
<input type="text" id="project-modal-name" class="form-input" maxlength="200" placeholder="例如:某客户 Web 渗透" autocomplete="off" data-i18n="projects.projectNamePlaceholder" data-i18n-attr="placeholder">
</div>
<div class="projects-form-field">
<label for="project-modal-description" data-i18n="projects.projectDescription">项目描述</label>
<textarea id="project-modal-description" class="form-input" rows="4" placeholder="测试范围、授权边界、注意事项…" data-i18n="projects.projectDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
<textarea id="project-modal-description" class="form-input projects-description-textarea" rows="4" maxlength="4000" placeholder="测试范围、授权边界、注意事项…" data-i18n="projects.projectDescriptionPlaceholder" data-i18n-attr="placeholder"></textarea>
<small class="form-hint" data-i18n="projects.descriptionLengthHint">简要说明即可(最多 4000 字);大段日志/POC 请写入事实黑板 body</small>
</div>
</div>
<div class="projects-modal-footer">
@@ -4265,13 +4287,13 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/i18next@23.11.5/i18next.min.js"></script>
<script src="/static/vendor/i18next.min.js"></script>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/builtin-tools.js"></script>
<script src="/static/js/auth.js"></script>
<script src="/static/js/modal.js"></script>
<script src="/static/js/notifications.js"></script>
<script src="/static/js/info-collect.js"></script>
<script src="/static/js/router.js"></script>
<script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js"></script>
@@ -4281,8 +4303,8 @@
<script src="/static/js/settings.js"></script>
<script src="/static/js/audit.js"></script>
<script src="/static/js/wechat-robot.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
<script src="/static/vendor/xterm.js"></script>
<script src="/static/vendor/xterm-addon-fit.js"></script>
<script src="/static/js/terminal.js"></script>
<script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script>