mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-06 06:13:58 +02:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -117,7 +117,8 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
||||
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
|
||||
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
|
||||
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
|
||||
- 🧩 **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). Markdown under `agents/`: `orchestrator.md`, `orchestrator-plan-execute.md`, `orchestrator-supervisor.md`, plus sub-agent `*.md` (see [Multi-agent doc](docs/MULTI_AGENT_EINO.md))
|
||||
- 🖼️ **Vision analysis (`analyze_image`)**: separate VL model (e.g. `qwen-vl-max`) via MCP for local screenshots, captchas, and UI; image bytes stay out of agent history (text summaries only). Configure `vision` in `config.yaml`; see [docs/VISION.md](docs/VISION.md)
|
||||
- 🎯 **Skills (refactored for Eino)**: packs under `skills_dir` follow **Agent Skills** layout (`SKILL.md` + optional files); **multi-agent** sessions use the official Eino ADK **`skill`** tool for **progressive disclosure** (load by name), with optional **host filesystem / shell** via `multi_agent.eino_skills`; optional **`eino_middleware`** adds patchtoolcalls, tool_search, plantask, reduction, checkpoints, and Deep tuning—20+ sample domains (SQLi, XSS, API security, …) ship under `skills/`
|
||||
- 📱 **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
|
||||
@@ -235,7 +236,7 @@ Requirements / tips:
|
||||
|
||||
### Core Workflows
|
||||
- **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.
|
||||
- **Tool monitor** – Inspect running jobs, execution logs, and large-result attachments.
|
||||
- **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.
|
||||
- **Custom prompts** – Each role can define a `user_prompt` that prepends to user messages, guiding the AI to adopt specialized testing methodologies and focus areas.
|
||||
- **Tool restrictions** – Roles can specify a `tools` list to limit available tools, ensuring focused testing workflows (e.g., CTF role restricts to CTF-specific utilities).
|
||||
- **Skills** – Skill packs live under `skills_dir` and 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, reduction, checkpoints, etc.) apply per mode—see docs.
|
||||
- **Easy role creation** – Create custom roles by adding YAML files to the `roles/` directory. Each role defines `name`, `description`, `user_prompt`, `icon`, `tools`, and `enabled` fields.
|
||||
- **Web UI integration** – Select roles from a dropdown in the chat interface. Role selection affects both AI behavior and available tool suggestions.
|
||||
|
||||
@@ -279,7 +280,7 @@ Requirements / tips:
|
||||
2. Restart the server or reload configuration; the role appears in the role selector dropdown.
|
||||
|
||||
### 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/`):
|
||||
- **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).
|
||||
@@ -536,8 +537,8 @@ skills_dir: "skills" # Skills directory (relative to config file)
|
||||
agents_dir: "agents" # Multi-agent Markdown definitions (orchestrator + sub-agents)
|
||||
multi_agent:
|
||||
enabled: false
|
||||
default_mode: "single" # single | multi (UI default when multi-agent is enabled)
|
||||
robot_default_agent_mode: react
|
||||
default_mode: "eino_single" # eino_single | multi (UI default when multi-agent is enabled)
|
||||
robot_default_agent_mode: eino_single
|
||||
batch_use_multi_agent: false
|
||||
orchestrator_instruction: "" # Deep; used when orchestrator.md body is empty
|
||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
|
||||
|
||||
+7
-6
@@ -116,7 +116,8 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
||||
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
||||
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
|
||||
- 🎭 角色化测试:预设安全测试角色(渗透测试、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`**。`agents/` 下主代理与子代理 Markdown 见 [多代理说明](docs/MULTI_AGENT_EINO.md)
|
||||
- 🖼️ **视觉分析(`analyze_image`)**:独立 Vision 模型(如 `qwen-vl-max`),MCP 工具分析本地截图/验证码/UI;图片仅在单次 VL 调用中出现,对话上下文只保留文字摘要。配置见 `config.yaml` → `vision` 与 [视觉分析说明](docs/VISION.md)
|
||||
- 🎯 **Skills(面向 Eino 重构)**:技能包放在 **`skills_dir`**,遵循 **Agent Skills** 目录规范(`SKILL.md` + 可选文件);**多代理** 下通过 Eino 官方 **`skill`** 工具 **渐进式披露**(按 name 加载)。**`multi_agent.eino_skills`** 控制是否启用、本机文件/Shell 工具、工具名覆盖;**`eino_middleware`** 可选 patch、tool_search、plantask、reduction、断点目录及 Deep 调参。20+ 领域示例仍可绑定角色
|
||||
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md))
|
||||
- 🧑⚖️ **人机协同(HITL)**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml` 的 `hitl.tool_whitelist`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用
|
||||
@@ -233,7 +234,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
|
||||
### 常用流程
|
||||
- **对话测试**:自然语言触发多步工具编排,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 行为和可用工具。每个角色可应用自定义系统提示词,并可限制可用工具列表,实现聚焦的测试场景。
|
||||
- **工具监控**:查看任务队列、执行日志、大文件附件。
|
||||
- **会话历史**:所有对话与工具调用保存在 SQLite,可随时重放。
|
||||
@@ -257,7 +258,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
- **预设角色**:系统内置 12+ 个预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试、二进制分析、云安全审计等),位于 `roles/` 目录。
|
||||
- **自定义提示词**:每个角色可定义 `user_prompt`,会在用户消息前自动添加,引导 AI 采用特定的测试方法和关注重点。
|
||||
- **工具限制**:角色可指定 `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`** 工具按需加载。中间件与本机 read_file/glob/grep 等见文档。
|
||||
- **轻松创建角色**:通过在 `roles/` 目录添加 YAML 文件即可创建自定义角色。每个角色定义 `name`、`description`、`user_prompt`、`icon`、`tools`、`enabled` 字段。
|
||||
- **Web 界面集成**:在聊天界面通过下拉菜单选择角色。角色选择会影响 AI 行为和可用工具建议。
|
||||
|
||||
@@ -277,7 +278,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。
|
||||
|
||||
### 多代理模式(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/`):
|
||||
- **Deep 主代理**:`orchestrator.md` 或唯一 `kind: orchestrator` 的 `.md`;正文或 `multi_agent.orchestrator_instruction`,再回退 Eino 默认。
|
||||
- **Plan-Execute 主代理**:固定 **`orchestrator-plan-execute.md`**(另可配 `orchestrator_instruction_plan_execute`)。
|
||||
@@ -534,8 +535,8 @@ skills_dir: "skills" # Skills 目录(相对于配置文件所在目录)
|
||||
agents_dir: "agents" # 多代理 Markdown(主代理 orchestrator.md + 子代理 *.md)
|
||||
multi_agent:
|
||||
enabled: false
|
||||
default_mode: "single" # single | multi(开启多代理时的界面默认模式)
|
||||
robot_default_agent_mode: react
|
||||
default_mode: "eino_single" # eino_single | multi(开启多代理时的界面默认模式)
|
||||
robot_default_agent_mode: eino_single
|
||||
batch_use_multi_agent: false
|
||||
orchestrator_instruction: "" # Deep;orchestrator.md 正文为空时使用
|
||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
|
||||
|
||||
@@ -97,6 +97,7 @@ description: supervisor 模式下的协调者:通过 transfer 委派专家子
|
||||
- **`transfer` 交接包(强制,避免专家重复侦察)**:**把专家当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 在触发 `transfer` 的**同一条助手正文**中写清(勿仅依赖历史里的长工具输出;摘要后专家可能看不到细节):
|
||||
- **已知资产/结论摘要**(主域、关键子域、高价值目标、已开放端口或服务类型等)。
|
||||
- **本轮唯一任务**与 **禁止项**(例如:「不得再做全量子域枚举;仅对下列主机做 MQTT 验证」)。
|
||||
- **图片/验证码(若有)**:本地绝对路径 + 期望输出格式(如验证码「只输出字符」);专家默认看不到父对话识图结果,须在交接正文中写明。
|
||||
- **专家类型**:验证/利用/协议分析派对应专家,**避免**把「仅差验证」的工作交给 `recon` 导致其按习惯从侦察阶段重来。
|
||||
- **transfer 前目标完整性校验(强制)**:在 `transfer` 前必须具备并显式写入:
|
||||
- 目标标识:`URL` 或 `IP:Port` 或 `域名 + 具体路径/API 基址`
|
||||
|
||||
@@ -33,6 +33,7 @@ description: 多代理模式下的 Deep 编排者:在已授权安全场景中
|
||||
- **`task` 上下文交接(强制,避免重复劳动)**:**把子代理当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 框架下子代理默认**只看到**你传入的 `description` 文本,**看不到**你在父对话里已跑过的工具输出全文。因此每次 `task` 的 `description` 必须自带**交接包**(可精简,但不可省略关键事实):
|
||||
- **已完成**:已枚举的主域/子域要点、已扫端口或服务结论、已确认 IP/URL、协调者已知的漏洞假设等(用列表或短段落即可)。
|
||||
- **本轮只做**:明确写「本轮禁止重复全量子域爆破 / 禁止重复相同 subfinder 参数集」等(若确实需要增量,写清增量范围)。
|
||||
- **图片/验证码(若有)**:本地绝对路径 + 期望输出格式(如验证码「只输出字符」、登录页 UI 要素列表);子代理默认看不到父对话里的识图结果,须在 description 中写明路径与格式。
|
||||
- **专家匹配**:验证、利用、协议深挖(如 MQTT)等应委派给**对应专项子代理**;不要把此类子目标交给纯侦察(`recon`)角色除非任务仅为补充攻击面。
|
||||
- **派单前目标完整性校验(强制)**:在调用 `task` 前,你必须检查并写入最小必需字段;任一缺失时**禁止委派**,先向用户澄清或先自行补充证据:
|
||||
- **目标标识**:`URL` 或 `IP:Port` 或 `域名 + 具体路径/API 基址`
|
||||
|
||||
+22
-8
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.26"
|
||||
version: "v1.6.30"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -65,6 +65,21 @@ openai:
|
||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||
# 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
|
||||
# allowed_roots: [] # 额外允许的绝对路径根目录
|
||||
# ============================================
|
||||
# 信息收集(FOFA)配置(可选)
|
||||
# ============================================
|
||||
@@ -77,23 +92,23 @@ fofa:
|
||||
# Agent 配置
|
||||
# 达到最大迭代次数时,AI 会自动总结测试结果
|
||||
agent:
|
||||
max_iterations: 1200 # 最大迭代次数,AI 代理最多执行多少轮工具调用
|
||||
max_iterations: 12000 # 最大迭代次数,AI 代理最多执行多少轮工具调用
|
||||
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
|
||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
||||
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||
# system_prompt_path: prompts/single-react.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
||||
# system_prompt_path: prompts/single-agent.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
||||
|
||||
system_prompt_path: ""
|
||||
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
|
||||
hitl:
|
||||
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
||||
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
|
||||
# 启用后需重启服务才会注册 /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:
|
||||
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 多代理(成本更高)
|
||||
max_iteration: 0 # 主代理 / plan_execute 执行器最大轮次,0 表示沿用 agent.max_iterations
|
||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。当前实现下 Executor 会挂载 patch/reduction/tool_search 等前置中间件。
|
||||
@@ -116,7 +131,7 @@ multi_agent:
|
||||
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
|
||||
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
|
||||
tool_search_always_visible: 12 # 始终直接暴露给模型的工具个数(顺序与角色工具列表一致);其余工具进入动态池,需 tool_search 解锁
|
||||
tool_search_always_visible_tools: [read_file, glob, grep, 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_rel_dir: .eino/plantask # 结构化任务文件相对 skills_dir 的子目录,其下再按会话 ID 分子目录存放
|
||||
reduction_enable: true # true:大工具输出截断/落盘以控上下文;依赖与 plantask 相同的 eino local 写盘后端,无后端时不挂载
|
||||
@@ -127,7 +142,6 @@ multi_agent:
|
||||
reduction_sub_agents: true # true:子代理也挂 reduction;false:仅编排主代理使用 reduction
|
||||
summarization_trigger_ratio: 0.8 # summarization 触发比例(max_total_tokens * ratio),建议 0.75~0.85
|
||||
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_executed_steps_budget_ratio: 0.2 # plan_execute 中 executed_steps 预算比例
|
||||
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Eino 多代理改造说明(DeepAgent)
|
||||
|
||||
本文档记录 **单 Agent(原有 ReAct)** 与 **多 Agent(CloudWeGo Eino `adk/prebuilt/deep`)** 并存的改造范围、进度与后续事项。
|
||||
本文档记录 **Eino 单代理(ADK)** 与 **多 Agent(CloudWeGo Eino `adk/prebuilt`)** 的改造范围、进度与后续事项。原生 ReAct 执行路径已移除。
|
||||
|
||||
## 总体结论
|
||||
|
||||
- **改造已可用于生产试验**:流式对话、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`。
|
||||
|
||||
## 已完成项
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
| 编排 | `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`)。 |
|
||||
| 会话准备 | `internal/handler/multi_agent_prepare.go`:`prepareMultiAgentSession`(含 **WebShell** `CreateConversationWithWebshell`、工具白名单与单代理一致)。 |
|
||||
| 单 Agent | `internal/agent` 增加 `ToolsForRole`、`ExecuteMCPToolForConversation`;原 `/api/agent-loop` 未删改语义。 |
|
||||
| 前端 | 主聊天 / WebShell:`multi_agent.enabled` 时可选 **原生 ReAct** 与三种 Eino 命名,多代理路径在 JSON 中带 `orchestration`。设置页不再配置预置编排项;`plan_execute` 外层循环上限等仍可在设置中保存。 |
|
||||
| 流式兼容 | 与 `/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 已对齐。 |
|
||||
| 单 Agent | `internal/agent` 为 MCP/工具层(`ToolsForRole`、`ExecuteMCPToolForConversation`);单代理编排走 `RunEinoSingleChatModelAgent`(`/api/eino-agent*`)。 |
|
||||
| 前端 | 主聊天 / WebShell:**Eino 单代理**(`/api/eino-agent/stream`)与 **Deep / Plan-Execute / Supervisor**(`/api/multi-agent/stream` + `orchestration`);`multi_agent.enabled` 控制多代理选项是否展示。 |
|
||||
| 流式兼容 | 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`。 |
|
||||
| 配置 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 错误事件)。 |
|
||||
| 机器人 | `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` 至少需一个子代理。 |
|
||||
| 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-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-06-02 | **移除原生 ReAct**:删除 `/api/agent-loop*` 执行入口与 `AgentLoopWithProgress`;统一 Eino ADK(单代理 `/api/eino-agent*`,多代理 `/api/multi-agent*`);任务 cancel/tasks API 保留。 |
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 视觉分析(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
|
||||
# allowed_roots: [] # 额外绝对路径根
|
||||
```
|
||||
|
||||
`enabled: false` 时不注册工具。
|
||||
|
||||
## Web 设置
|
||||
|
||||
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用 OpenAI)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
||||
|
||||
## 路径白名单
|
||||
|
||||
默认可读:
|
||||
|
||||
- 进程工作目录(`cwd`)及其子路径
|
||||
- `chat_uploads/`
|
||||
- `agent.result_storage_dir`(默认 `tmp/`)
|
||||
- `vision.allowed_roots` 中配置的绝对路径
|
||||
|
||||
## 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 端「对话」列表中看到,反之亦然。
|
||||
- 机器人执行逻辑与 **`/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.
|
||||
- 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`.
|
||||
|
||||
@@ -49,6 +49,7 @@ require (
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
@@ -90,6 +91,7 @@ require (
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/crypto v0.39.0 // 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/sys v0.33.0 // 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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
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/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/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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
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: 178 KiB |
+3
-1035
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import (
|
||||
"cyberstrike-ai/internal/project"
|
||||
)
|
||||
|
||||
// DefaultSingleAgentSystemPrompt 单代理(ReAct / MCP)内置系统提示;可通过 agent.system_prompt_path 覆盖为文件。
|
||||
// DefaultSingleAgentSystemPrompt 单代理(Eino ADK / MCP)内置系统提示;可通过 agent.system_prompt_path 覆盖为文件。
|
||||
func DefaultSingleAgentSystemPrompt() string {
|
||||
return `你是CyberStrikeAI,是一个专业的网络安全渗透测试专家。你可以使用各种安全工具进行自主渗透测试。分析目标并选择最佳测试策略。
|
||||
|
||||
@@ -112,6 +112,6 @@ func DefaultSingleAgentSystemPrompt() string {
|
||||
## 技能库(Skills)与知识库
|
||||
|
||||
- 技能包位于服务器 skills/ 目录(各子目录 SKILL.md,遵循 agentskills.io);知识库用于向量检索片段,Skills 为可执行工作流指令。
|
||||
- 单代理本会话通过 MCP 使用知识库与漏洞记录等;Skills 的渐进式加载在「多代理 / Eino DeepAgent」中由内置 skill 工具完成(需在配置中启用 multi_agent.eino_skills)。
|
||||
- 若当前无 skill 工具,需要完整 Skill 工作流时请使用多代理模式或切换为 Eino 编排会话(亦可选 Eino ADK 单代理路径 /api/eino-agent)。`
|
||||
- 本会话通过 MCP 使用知识库与漏洞记录等。Skills 由 Eino ADK skill 工具按需加载(配置 multi_agent.eino_skills;单代理与多代理均可,未启用时无 skill 工具)。
|
||||
- 需要完整 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
|
||||
}
|
||||
+3
-4
@@ -113,6 +113,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
// 注册漏洞记录工具
|
||||
registerVulnerabilityTools(mcpServer, db, log.Logger)
|
||||
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
|
||||
registerVisionTools(mcpServer, cfg, log.Logger)
|
||||
|
||||
if cfg.Auth.GeneratedPassword != "" {
|
||||
config.PrintGeneratedPasswordWarning(cfg.Auth.GeneratedPassword, cfg.Auth.GeneratedPasswordPersisted, cfg.Auth.GeneratedPasswordPersistErr)
|
||||
@@ -418,6 +419,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
vulnerabilityRegistrar := func() error {
|
||||
registerVulnerabilityTools(mcpServer, db, log.Logger)
|
||||
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
|
||||
registerVisionTools(mcpServer, cfg, log.Logger)
|
||||
return nil
|
||||
}
|
||||
configHandler.SetVulnerabilityToolRegistrar(vulnerabilityRegistrar)
|
||||
@@ -801,10 +803,6 @@ func setupRoutes(
|
||||
protected.POST("/robot/wechat/qrcode/verify", wechatRobotHandler.HandleWechatVerifyCode)
|
||||
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)
|
||||
protected.POST("/eino-agent", agentHandler.EinoSingleAgentLoop)
|
||||
protected.POST("/eino-agent/stream", agentHandler.EinoSingleAgentLoopStream)
|
||||
@@ -892,6 +890,7 @@ func setupRoutes(
|
||||
protected.PUT("/config", configHandler.UpdateConfig)
|
||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
||||
protected.POST("/config/test-vision", configHandler.TestVision)
|
||||
|
||||
// 系统设置 - 终端(执行命令,提高运维效率)
|
||||
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+22
-28
@@ -37,6 +37,7 @@ type Config struct {
|
||||
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"`
|
||||
Project ProjectConfig `yaml:"project,omitempty" json:"project,omitempty"`
|
||||
Vision VisionConfig `yaml:"vision,omitempty" json:"vision,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectConfig 项目黑板(跨对话共享事实)配置。
|
||||
@@ -64,10 +65,10 @@ func (c ProjectConfig) FactSummaryMaxRunesEffective() int {
|
||||
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 {
|
||||
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 多代理
|
||||
// Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。
|
||||
Orchestration string `yaml:"orchestration,omitempty" json:"orchestration,omitempty"`
|
||||
@@ -237,9 +238,6 @@ type MultiAgentEinoMiddlewareConfig struct {
|
||||
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
||||
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
||||
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
|
||||
// HistoryInputBudgetRatio 已不影响 Eino:从 last_react 轨迹转 ADK 消息时**不再**按 token 比例裁剪(完整注入)。
|
||||
// 字段仍保留,便于旧版 config 不报错;新部署可省略。
|
||||
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 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).
|
||||
@@ -283,20 +281,6 @@ func (c MultiAgentEinoMiddlewareConfig) SummarizationEmitInternalEventsEffective
|
||||
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 {
|
||||
v := c.PlanExecuteUserInputBudgetRatio
|
||||
if v <= 0 {
|
||||
@@ -403,16 +387,26 @@ type MultiAgentPublic struct {
|
||||
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
|
||||
}
|
||||
|
||||
// NormalizeRobotAgentMode 解析机器人默认对话模式(react | eino_single | deep | plan_execute | supervisor);空值视为 react。
|
||||
func NormalizeRobotAgentMode(ma MultiAgentConfig) string {
|
||||
s := strings.TrimSpace(strings.ToLower(ma.RobotDefaultAgentMode))
|
||||
if s == "" || s == "single" || s == "react" {
|
||||
return "react"
|
||||
}
|
||||
if s == "eino_single" {
|
||||
// NormalizeAgentMode 解析代理模式(eino_single | deep | plan_execute | supervisor);空值默认 eino_single。
|
||||
func NormalizeAgentMode(mode string) string {
|
||||
s := strings.TrimSpace(strings.ToLower(mode))
|
||||
switch s {
|
||||
case "", "eino_single":
|
||||
return "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 NormalizeMultiAgentOrchestration(s)
|
||||
}
|
||||
|
||||
// NormalizeRobotAgentMode 解析机器人默认对话模式。
|
||||
func NormalizeRobotAgentMode(ma MultiAgentConfig) string {
|
||||
return NormalizeAgentMode(ma.RobotDefaultAgentMode)
|
||||
}
|
||||
|
||||
// NormalizeMultiAgentOrchestration 返回 deep、plan_execute 或 supervisor。
|
||||
@@ -532,7 +526,7 @@ type OpenAIConfig struct {
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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
|
||||
AllowedRoots []string `yaml:"allowed_roots,omitempty" json:"allowed_roots,omitempty"`
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,19 +13,106 @@ import (
|
||||
"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 连接池参数,提升并发稳定性
|
||||
func configureDBPool(db *sql.DB) {
|
||||
// SQLite 同一时间只允许一个写入者,限制连接数避免 "database is locked" 错误
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
// SQLite 同一时间只允许一个写入者;过高连接数会放大锁竞争和 WAL 回收延迟。
|
||||
db.SetMaxOpenConns(sqliteMaxOpenConns)
|
||||
db.SetMaxIdleConns(sqliteMaxIdleConns)
|
||||
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 数据库连接
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
logger *zap.Logger
|
||||
conversationArtifactsDir string
|
||||
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 创建数据库连接
|
||||
@@ -37,8 +125,13 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
configureDBPool(db)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
if err := configureSQLitePragmas(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("配置数据库 PRAGMA 失败: %w", err)
|
||||
}
|
||||
|
||||
database := &DB{
|
||||
DB: db,
|
||||
@@ -54,8 +147,10 @@ func NewDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
|
||||
// 初始化表
|
||||
if err := database.initTables(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("初始化表失败: %w", err)
|
||||
}
|
||||
database.startPassiveCheckpointLoop("conversations")
|
||||
|
||||
return database, nil
|
||||
}
|
||||
@@ -293,7 +388,7 @@ func (db *DB) initTables() error {
|
||||
id TEXT PRIMARY KEY,
|
||||
title 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',
|
||||
cron_expr TEXT,
|
||||
next_run_at DATETIME,
|
||||
@@ -889,14 +984,14 @@ func (db *DB) migrateBatchTaskQueuesTable() error {
|
||||
var agentModeCount int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='agent_mode'").Scan(&agentModeCount)
|
||||
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())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} 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))
|
||||
}
|
||||
}
|
||||
@@ -1159,8 +1254,13 @@ func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
configureDBPool(sqlDB)
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("连接知识库数据库失败: %w", err)
|
||||
}
|
||||
if err := configureSQLitePragmas(sqlDB); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("配置知识库数据库 PRAGMA 失败: %w", err)
|
||||
}
|
||||
|
||||
database := &DB{
|
||||
DB: sqlDB,
|
||||
@@ -1169,8 +1269,10 @@ func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
|
||||
// 初始化知识库表
|
||||
if err := database.initKnowledgeTables(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("初始化知识库表失败: %w", err)
|
||||
}
|
||||
database.startPassiveCheckpointLoop("knowledge")
|
||||
|
||||
return database, nil
|
||||
}
|
||||
@@ -1284,5 +1386,19 @@ func (db *DB) migrateKnowledgeEmbeddingsColumns() error {
|
||||
|
||||
// Close 关闭数据库连接
|
||||
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
|
||||
}
|
||||
|
||||
+102
-835
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -128,7 +129,7 @@ func (m *BatchTaskManager) CreateBatchQueue(
|
||||
Title: title,
|
||||
Role: role,
|
||||
ProjectID: strings.TrimSpace(projectID),
|
||||
AgentMode: normalizeBatchQueueAgentMode(agentMode),
|
||||
AgentMode: config.NormalizeAgentMode(agentMode),
|
||||
ScheduleMode: normalizeBatchQueueScheduleMode(scheduleMode),
|
||||
CronExpr: strings.TrimSpace(cronExpr),
|
||||
NextRunAt: nextRunAt,
|
||||
@@ -225,7 +226,7 @@ func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
|
||||
|
||||
queue := &BatchTaskQueue{
|
||||
ID: queueRow.ID,
|
||||
AgentMode: "single",
|
||||
AgentMode: "eino_single",
|
||||
ScheduleMode: "manual",
|
||||
Status: queueRow.Status,
|
||||
CreatedAt: queueRow.CreatedAt,
|
||||
@@ -240,7 +241,7 @@ func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
|
||||
queue.Role = queueRow.Role.String
|
||||
}
|
||||
if queueRow.AgentMode.Valid {
|
||||
queue.AgentMode = normalizeBatchQueueAgentMode(queueRow.AgentMode.String)
|
||||
queue.AgentMode = config.NormalizeAgentMode(queueRow.AgentMode.String)
|
||||
}
|
||||
if queueRow.ScheduleMode.Valid {
|
||||
queue.ScheduleMode = normalizeBatchQueueScheduleMode(queueRow.ScheduleMode.String)
|
||||
@@ -464,7 +465,7 @@ func (m *BatchTaskManager) LoadFromDB() error {
|
||||
|
||||
queue := &BatchTaskQueue{
|
||||
ID: queueRow.ID,
|
||||
AgentMode: "single",
|
||||
AgentMode: "eino_single",
|
||||
ScheduleMode: "manual",
|
||||
Status: queueRow.Status,
|
||||
CreatedAt: queueRow.CreatedAt,
|
||||
@@ -479,7 +480,7 @@ func (m *BatchTaskManager) LoadFromDB() error {
|
||||
queue.Role = queueRow.Role.String
|
||||
}
|
||||
if queueRow.AgentMode.Valid {
|
||||
queue.AgentMode = normalizeBatchQueueAgentMode(queueRow.AgentMode.String)
|
||||
queue.AgentMode = config.NormalizeAgentMode(queueRow.AgentMode.String)
|
||||
}
|
||||
if queueRow.ScheduleMode.Valid {
|
||||
queue.ScheduleMode = normalizeBatchQueueScheduleMode(queueRow.ScheduleMode.String)
|
||||
@@ -669,7 +670,7 @@ func (m *BatchTaskManager) UpdateQueueMetadata(queueID, title, role, agentMode s
|
||||
|
||||
// 如果未传 agentMode,保留原值
|
||||
if strings.TrimSpace(agentMode) != "" {
|
||||
agentMode = normalizeBatchQueueAgentMode(agentMode)
|
||||
agentMode = config.NormalizeAgentMode(agentMode)
|
||||
} else {
|
||||
agentMode = queue.AgentMode
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
|
||||
@@ -134,7 +135,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
||||
|
||||
【何时用】用户明确要批量排队执行、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)。`,
|
||||
ShortDescription: "任务管理:创建批量任务队列(登记多条指令,可选立即或 Cron)",
|
||||
@@ -160,8 +161,8 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
||||
},
|
||||
"agent_mode": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "执行模式:single(原生 ReAct)、eino_single(Eino ADK)、deep/plan_execute/supervisor(Eino 编排,需启用多代理);multi 兼容为 deep",
|
||||
"enum": []string{"single", "eino_single", "deep", "plan_execute", "supervisor", "multi"},
|
||||
"description": "执行模式:eino_single(Eino ADK,默认)、deep/plan_execute/supervisor(Eino 编排,需启用多代理)",
|
||||
"enum": []string{"eino_single", "deep", "plan_execute", "supervisor"},
|
||||
},
|
||||
"schedule_mode": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -189,7 +190,7 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
||||
}
|
||||
title := mcpArgString(args, "title")
|
||||
role := mcpArgString(args, "role")
|
||||
agentMode := normalizeBatchQueueAgentMode(mcpArgString(args, "agent_mode"))
|
||||
agentMode := config.NormalizeAgentMode(mcpArgString(args, "agent_mode"))
|
||||
scheduleMode := normalizeBatchQueueScheduleMode(mcpArgString(args, "schedule_mode"))
|
||||
cronExpr := strings.TrimSpace(mcpArgString(args, "cron_expr"))
|
||||
var nextRunAt *time.Time
|
||||
@@ -393,8 +394,8 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
|
||||
},
|
||||
"agent_mode": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "代理模式:single、eino_single、deep、plan_execute、supervisor;multi 视为 deep",
|
||||
"enum": []string{"single", "eino_single", "deep", "plan_execute", "supervisor", "multi"},
|
||||
"description": "代理模式:eino_single、deep、plan_execute、supervisor",
|
||||
"enum": []string{"eino_single", "deep", "plan_execute", "supervisor"},
|
||||
},
|
||||
},
|
||||
"required": []string{"queue_id"},
|
||||
|
||||
+148
-1
@@ -237,6 +237,7 @@ func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) err
|
||||
// GetConfigResponse 获取配置响应
|
||||
type GetConfigResponse struct {
|
||||
OpenAI config.OpenAIConfig `json:"openai"`
|
||||
Vision config.VisionConfig `json:"vision"`
|
||||
FOFA config.FofaConfig `json:"fofa"`
|
||||
MCP config.MCPConfig `json:"mcp"`
|
||||
Tools []ToolConfigInfo `json:"tools"`
|
||||
@@ -333,6 +334,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, GetConfigResponse{
|
||||
OpenAI: h.config.OpenAI,
|
||||
Vision: h.config.Vision,
|
||||
FOFA: h.config.FOFA,
|
||||
MCP: h.config.MCP,
|
||||
Tools: tools,
|
||||
@@ -638,6 +640,7 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
// UpdateConfigRequest 更新配置请求
|
||||
type UpdateConfigRequest struct {
|
||||
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
||||
Vision *config.VisionConfig `json:"vision,omitempty"`
|
||||
FOFA *config.FofaConfig `json:"fofa,omitempty"`
|
||||
MCP *config.MCPConfig `json:"mcp,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配置
|
||||
if req.FOFA != nil {
|
||||
h.config.FOFA = *req.FOFA
|
||||
@@ -783,7 +794,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
if mode := strings.TrimSpace(req.MultiAgent.RobotDefaultAgentMode); mode != "" {
|
||||
h.config.MultiAgent.RobotDefaultAgentMode = mode
|
||||
} else {
|
||||
h.config.MultiAgent.RobotDefaultAgentMode = "react"
|
||||
h.config.MultiAgent.RobotDefaultAgentMode = "eino_single"
|
||||
}
|
||||
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
|
||||
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 应用配置(重新加载并重启相关服务)
|
||||
func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
// 先检查是否需要动态初始化知识库(在锁外执行,避免阻塞其他请求)
|
||||
@@ -1286,6 +1390,7 @@ func (h *ConfigHandler) saveConfig() error {
|
||||
updateAgentConfig(root, h.config.Agent)
|
||||
updateMCPConfig(root, h.config.MCP)
|
||||
updateOpenAIConfig(root, h.config.OpenAI)
|
||||
updateVisionConfig(root, h.config.Vision)
|
||||
updateFOFAConfig(root, h.config.FOFA)
|
||||
updateKnowledgeConfig(root, h.config.Knowledge)
|
||||
updateC2Config(root, h.config.C2)
|
||||
@@ -1406,6 +1511,48 @@ func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) {
|
||||
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)
|
||||
}
|
||||
if len(cfg.AllowedRoots) > 0 {
|
||||
setStringSliceInMap(visionNode, "allowed_roots", cfg.AllowedRoots)
|
||||
}
|
||||
}
|
||||
|
||||
func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
||||
root := doc.Content[0]
|
||||
openaiNode := ensureMap(root, "openai")
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
// EinoSingleAgentLoopStream Eino ADK 单代理(ChatModelAgent + Runner)流式对话;不依赖 multi_agent.enabled。
|
||||
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("Connection", "keep-alive")
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
@@ -691,35 +690,6 @@ func (h *AgentHandler) interceptHITLForEinoTool(runCtx context.Context, cancelRu
|
||||
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 {
|
||||
ConversationID string `json:"conversationId" binding:"required"`
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
// MultiAgentLoopStream Eino DeepAgent 流式对话(需 config.multi_agent.enabled)。
|
||||
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("Connection", "keep-alive")
|
||||
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})
|
||||
}
|
||||
|
||||
// MultiAgentLoop Eino DeepAgent 非流式对话(与 POST /api/agent-loop 对齐,需 multi_agent.enabled)。
|
||||
// MultiAgentLoop Eino DeepAgent 非流式对话(需 multi_agent.enabled)。
|
||||
func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
if h.config == nil || !h.config.MultiAgent.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "多代理未启用,请在 config.yaml 中设置 multi_agent.enabled: true"})
|
||||
|
||||
+102
-149
@@ -423,8 +423,8 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"agentMode": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "代理模式:single(原生 ReAct)| eino_single(Eino ADK 单代理)| deep | plan_execute | supervisor;react 同 single;旧值 multi 按 deep",
|
||||
"enum": []string{"single", "eino_single", "deep", "plan_execute", "supervisor", "multi", "react"},
|
||||
"description": "代理模式:eino_single(Eino ADK 单代理,默认)| deep | plan_execute | supervisor",
|
||||
"enum": []string{"eino_single", "deep", "plan_execute", "supervisor"},
|
||||
},
|
||||
"scheduleMode": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -778,11 +778,55 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"ConfigResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "配置信息",
|
||||
"description": "配置信息(含 openai、vision、multi_agent 等)",
|
||||
"properties": map[string]interface{}{
|
||||
"vision": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/VisionConfig",
|
||||
},
|
||||
},
|
||||
},
|
||||
"UpdateConfigRequest": map[string]interface{}{
|
||||
"type": "object",
|
||||
"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"},
|
||||
"allowed_roots": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "额外允许读取的绝对路径根"},
|
||||
},
|
||||
},
|
||||
"AnalyzeImageToolCall": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "内置 MCP 工具 analyze_image:分析服务器本地图片,返回纯文本(验证码/UI/报错等)",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "图片路径(cwd、chat_uploads、result_storage_dir 或 allowed_roots 下)",
|
||||
},
|
||||
"question": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选:重点问题;验证码建议「只输出验证码字符」",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
"ExternalMCPConfig": map[string]interface{}{
|
||||
"type": "object",
|
||||
@@ -1121,7 +1165,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话管理"},
|
||||
"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",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
@@ -1412,148 +1456,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{}{
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
"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",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
@@ -1573,7 +1480,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
"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": "参数错误"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
"500": map[string]interface{}{"description": "执行失败"},
|
||||
@@ -1584,7 +1491,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
"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",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
@@ -1623,7 +1530,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
"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",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
@@ -1646,7 +1553,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"webshellConnectionId": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "WebShell 连接 ID(可选,与 agent-loop 行为一致)",
|
||||
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
||||
},
|
||||
"orchestration": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -1661,7 +1568,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "成功,响应格式同 /api/agent-loop",
|
||||
"description": "成功,响应格式同 /api/eino-agent",
|
||||
},
|
||||
"400": map[string]interface{}{"description": "参数错误"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
@@ -1674,7 +1581,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
"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",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
@@ -4790,7 +4697,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"properties": map[string]interface{}{
|
||||
"title": 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 +4944,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{}{
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"配置管理"},
|
||||
|
||||
+203
-18
@@ -40,8 +40,13 @@ const (
|
||||
robotCmdRoles = "角色"
|
||||
robotCmdRolesList = "角色列表"
|
||||
robotCmdSwitchRole = "切换角色"
|
||||
robotCmdDelete = "删除"
|
||||
robotCmdVersion = "版本"
|
||||
robotCmdDelete = "删除"
|
||||
robotCmdVersion = "版本"
|
||||
robotCmdProjects = "项目"
|
||||
robotCmdProjectsList = "项目列表"
|
||||
robotCmdBindProject = "绑定项目"
|
||||
robotCmdNewProject = "新建项目"
|
||||
robotCmdUnbindProject = "解除项目"
|
||||
)
|
||||
|
||||
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理
|
||||
@@ -269,21 +274,176 @@ func (h *RobotHandler) robotMessageTimeout() time.Duration {
|
||||
}
|
||||
|
||||
func (h *RobotHandler) cmdHelp() string {
|
||||
return "**【CyberStrikeAI 机器人命令】**\n\n" +
|
||||
"- `帮助` `help` — 显示本帮助 | Show this help\n" +
|
||||
"- `列表` `list` — 列出所有对话标题与 ID | List conversations\n" +
|
||||
"- `切换 <ID>` `switch <ID>` — 指定对话继续 | Switch to conversation\n" +
|
||||
"- `新对话` `new` — 开启新对话 | Start new conversation\n" +
|
||||
"- `清空` `clear` — 清空当前上下文 | Clear context\n" +
|
||||
"- `当前` `current` — 显示当前对话 ID 与标题 | Show current conversation\n" +
|
||||
"- `停止` `stop` — 中断当前任务 | Stop running task\n" +
|
||||
"- `角色` `roles` — 列出所有可用角色 | List roles\n" +
|
||||
"- `角色 <名>` `role <name>` — 切换当前角色 | Switch role\n" +
|
||||
"- `删除 <ID>` `delete <ID>` — 删除指定对话 | Delete conversation\n" +
|
||||
"- `版本` `version` — 显示当前版本号 | Show version\n\n" +
|
||||
"---\n" +
|
||||
"除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。\n" +
|
||||
"Otherwise, send any text for AI penetration testing / security analysis."
|
||||
var b strings.Builder
|
||||
b.WriteString("【CyberStrikeAI 机器人命令】\n\n")
|
||||
b.WriteString("【通用 General】\n")
|
||||
b.WriteString("· 帮助 / help — 显示本帮助\n")
|
||||
b.WriteString("· 版本 / version — 显示当前版本号\n")
|
||||
b.WriteString("\n【对话 Conversation】\n")
|
||||
b.WriteString("· 列表 / list — 列出所有对话标题与 ID\n")
|
||||
b.WriteString("· 切换 <ID> / switch <ID> — 指定对话继续\n")
|
||||
b.WriteString("· 新对话 / new — 开启新对话\n")
|
||||
b.WriteString("· 清空 / clear — 清空当前上下文\n")
|
||||
b.WriteString("· 当前 / current — 显示当前对话、角色与项目\n")
|
||||
b.WriteString("· 停止 / stop — 中断当前任务\n")
|
||||
b.WriteString("· 删除 <ID> / delete <ID> — 删除指定对话\n")
|
||||
b.WriteString("\n【角色 Role】\n")
|
||||
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 {
|
||||
@@ -357,7 +517,12 @@ func (h *RobotHandler) cmdCurrent(platform, userID string) string {
|
||||
return "当前对话 ID: " + convID + "(获取标题失败)"
|
||||
}
|
||||
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 {
|
||||
@@ -494,6 +659,26 @@ func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string
|
||||
return h.cmdDelete(platform, userID, convID), true
|
||||
case text == robotCmdVersion || text == "version":
|
||||
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:
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ const (
|
||||
ToolListKnowledgeRiskTypes = "list_knowledge_risk_types"
|
||||
ToolSearchKnowledgeBase = "search_knowledge_base"
|
||||
|
||||
// 视觉分析(本地图片 → VL 模型 → 文本摘要)
|
||||
ToolAnalyzeImage = "analyze_image"
|
||||
|
||||
// WebShell 助手工具(AI 在 WebShell 管理 - AI 助手 中使用)
|
||||
ToolWebshellExec = "webshell_exec"
|
||||
ToolWebshellFileList = "webshell_file_list"
|
||||
@@ -73,6 +76,7 @@ func IsBuiltinTool(toolName string) bool {
|
||||
ToolRestoreProjectFact,
|
||||
ToolListKnowledgeRiskTypes,
|
||||
ToolSearchKnowledgeBase,
|
||||
ToolAnalyzeImage,
|
||||
ToolWebshellExec,
|
||||
ToolWebshellFileList,
|
||||
ToolWebshellFileRead,
|
||||
@@ -124,6 +128,7 @@ func GetAllBuiltinTools() []string {
|
||||
ToolRestoreProjectFact,
|
||||
ToolListKnowledgeRiskTypes,
|
||||
ToolSearchKnowledgeBase,
|
||||
ToolAnalyzeImage,
|
||||
ToolWebshellExec,
|
||||
ToolWebshellFileList,
|
||||
ToolWebshellFileRead,
|
||||
|
||||
@@ -44,11 +44,12 @@ func newSDKClientFromSession(session *mcp.ClientSession, client *mcp.Client, log
|
||||
|
||||
// lazySDKClient 延迟连接:Initialize() 时才调用官方 SDK 建立连接,对外实现 ExternalMCPClient
|
||||
type lazySDKClient struct {
|
||||
serverCfg config.ExternalMCPServerConfig
|
||||
logger *zap.Logger
|
||||
inner ExternalMCPClient // 连接成功后为 *sdkClient
|
||||
mu sync.RWMutex
|
||||
status string
|
||||
serverCfg config.ExternalMCPServerConfig
|
||||
logger *zap.Logger
|
||||
sessionCancel context.CancelFunc
|
||||
inner ExternalMCPClient // connected SDK client
|
||||
mu sync.RWMutex
|
||||
status string
|
||||
}
|
||||
|
||||
func newLazySDKClient(serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) *lazySDKClient {
|
||||
@@ -92,14 +93,61 @@ func (c *lazySDKClient) Initialize(ctx context.Context) error {
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
inner, err := createSDKClient(ctx, c.serverCfg, c.logger)
|
||||
if err != nil {
|
||||
sessionCtx, sessionCancel := context.WithCancel(context.Background())
|
||||
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")
|
||||
return err
|
||||
}
|
||||
|
||||
if result.err != nil {
|
||||
sessionCancel()
|
||||
c.setStatus("error")
|
||||
return result.err
|
||||
}
|
||||
|
||||
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.setStatus("connected")
|
||||
return nil
|
||||
@@ -128,9 +176,14 @@ func (c *lazySDKClient) CallTool(ctx context.Context, name string, args map[stri
|
||||
func (c *lazySDKClient) Close() error {
|
||||
c.mu.Lock()
|
||||
inner := c.inner
|
||||
sessionCancel := c.sessionCancel
|
||||
c.inner = nil
|
||||
c.sessionCancel = nil
|
||||
c.mu.Unlock()
|
||||
c.setStatus("disconnected")
|
||||
if sessionCancel != nil {
|
||||
sessionCancel()
|
||||
}
|
||||
if inner != nil {
|
||||
return inner.Close()
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
const einoSingleAgentName = "cyberstrike-eino-single"
|
||||
|
||||
// RunEinoSingleChatModelAgent 使用 Eino adk.NewChatModelAgent + adk.NewRunner.Run(官方 Quick Start 的 Query 同属 Runner API;此处用历史 + 用户消息切片等价于多轮 Query)。
|
||||
// 不替代既有原生 ReAct;与 RunDeepAgent 共享 runEinoADKAgentLoop 的 SSE 映射与 MCP 桥。
|
||||
// 与 RunDeepAgent 共享 runEinoADKAgentLoop 的 SSE 映射与 MCP 桥。
|
||||
func RunEinoSingleChatModelAgent(
|
||||
ctx context.Context,
|
||||
appCfg *config.Config,
|
||||
@@ -180,6 +180,7 @@ func RunEinoSingleChatModelAgent(
|
||||
EmitInternalEvents: true,
|
||||
}
|
||||
ins := project.AppendSystemPromptBlock(ag.EinoSingleAgentSystemInstruction(), systemPromptExtra)
|
||||
ins = project.AppendVisionImageAnalysisIfReady(ins, appCfg.Vision.Ready())
|
||||
ins = injectToolNamesOnlyInstruction(ctx, ins, mainTools, singleToolSearchActive)
|
||||
if logger != nil {
|
||||
names := collectToolNames(ctx, mainTools)
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// einoSummarizeUserInstruction 与单 Agent MemoryCompressor 目标一致:压缩时保留渗透关键信息。
|
||||
// einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。
|
||||
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史。
|
||||
|
||||
必须保留:已确认漏洞与攻击路径、工具输出中的核心发现、凭证与认证细节、架构与薄弱点、当前进度、失败尝试与死路、策略决策。
|
||||
@@ -29,7 +29,7 @@ const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完
|
||||
输出须使后续代理能无缝继续同一授权测试任务。`
|
||||
|
||||
// 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(
|
||||
ctx context.Context,
|
||||
summaryModel model.BaseChatModel,
|
||||
|
||||
@@ -3,7 +3,6 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -24,10 +23,6 @@ func isEinoTransientRunError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// io.EOF 常见于流式正常收尾,不应触发分段重试。
|
||||
if errors.Is(err, io.EOF) {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
@@ -66,6 +61,7 @@ func isEinoTransientRunError(err error) bool {
|
||||
"tls handshake timeout",
|
||||
"stream error",
|
||||
"unexpected eof",
|
||||
`": eof`, // net/http: Post "url": EOF (often wraps io.EOF)
|
||||
"unexpected end of json",
|
||||
"status code: 406",
|
||||
"status code: 502",
|
||||
|
||||
@@ -3,6 +3,7 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -21,6 +22,8 @@ func TestIsEinoTransientRunError(t *testing.T) {
|
||||
{"nil", nil, false},
|
||||
{"io eof", io.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},
|
||||
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
|
||||
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
||||
|
||||
@@ -262,7 +262,8 @@ func RunDeepAgent(
|
||||
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 {
|
||||
subNames := collectToolNames(ctx, subTools)
|
||||
mountedNames := collectToolNames(ctx, subToolsForCfg)
|
||||
@@ -342,6 +343,7 @@ func RunDeepAgent(
|
||||
}
|
||||
|
||||
orchInstruction = project.AppendSystemPromptBlock(orchInstruction, systemPromptExtra)
|
||||
orchInstruction = project.AppendVisionImageAnalysisIfReady(orchInstruction, appCfg.Vision.Ready())
|
||||
orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools, mainToolSearchActive)
|
||||
if logger != nil {
|
||||
mainNames := collectToolNames(ctx, mainTools)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,142 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const chatUploadsDirName = "chat_uploads"
|
||||
|
||||
var allowedImageExt = map[string]struct{}{
|
||||
".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {}, ".gif": {},
|
||||
".bmp": {}, ".tif": {}, ".tiff": {},
|
||||
}
|
||||
|
||||
// PathOptions 图片路径白名单根目录。
|
||||
type PathOptions struct {
|
||||
CWD string
|
||||
ResultStorageDir string // 相对 CWD,如 tmp
|
||||
ExtraRoots []string // vision.allowed_roots 绝对路径
|
||||
}
|
||||
|
||||
// ResolveImagePath 解析并校验可读图片路径(防穿越、symlink 逃逸)。
|
||||
func ResolveImagePath(path string, opt PathOptions) (string, error) {
|
||||
p := strings.TrimSpace(path)
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("path is empty")
|
||||
}
|
||||
cwd := strings.TrimSpace(opt.CWD)
|
||||
if cwd == "" {
|
||||
var err error
|
||||
cwd, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getwd: %w", err)
|
||||
}
|
||||
}
|
||||
cwdAbs, err := filepath.Abs(filepath.Clean(cwd))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var candidate string
|
||||
if filepath.IsAbs(p) {
|
||||
candidate = filepath.Clean(p)
|
||||
} else {
|
||||
candidate = filepath.Clean(filepath.Join(cwdAbs, p))
|
||||
}
|
||||
candidate = normalizeAbsPath(candidate)
|
||||
if candidate == "" {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(candidate))
|
||||
if _, ok := allowedImageExt[ext]; !ok {
|
||||
return "", fmt.Errorf("unsupported image extension %q", ext)
|
||||
}
|
||||
|
||||
roots := buildAllowedRoots(cwdAbs, opt)
|
||||
resolved, err := evalUnderAllowedRoots(candidate, roots)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func buildAllowedRoots(cwdAbs string, opt PathOptions) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var roots []string
|
||||
add := func(r string) {
|
||||
r = strings.TrimSpace(r)
|
||||
if r == "" {
|
||||
return
|
||||
}
|
||||
abs := normalizeAbsPath(r)
|
||||
if abs == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[abs]; ok {
|
||||
return
|
||||
}
|
||||
seen[abs] = struct{}{}
|
||||
roots = append(roots, abs)
|
||||
}
|
||||
add(cwdAbs)
|
||||
add(filepath.Join(cwdAbs, chatUploadsDirName))
|
||||
rs := strings.TrimSpace(opt.ResultStorageDir)
|
||||
if rs == "" {
|
||||
rs = "tmp"
|
||||
}
|
||||
if filepath.IsAbs(rs) {
|
||||
add(rs)
|
||||
} else {
|
||||
add(filepath.Join(cwdAbs, rs))
|
||||
}
|
||||
for _, r := range opt.ExtraRoots {
|
||||
add(r)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func evalUnderAllowedRoots(candidate string, roots []string) (string, error) {
|
||||
check := normalizeAbsPath(candidate)
|
||||
for _, root := range roots {
|
||||
if isUnderRoot(check, root) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("path %q is outside allowed directories", candidate)
|
||||
}
|
||||
|
||||
func isUnderRoot(path, root string) bool {
|
||||
path = filepath.Clean(path)
|
||||
root = filepath.Clean(root)
|
||||
if path == root {
|
||||
return true
|
||||
}
|
||||
sep := string(filepath.Separator)
|
||||
return strings.HasPrefix(path, root+sep)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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, PathOptions{CWD: dir, ResultStorageDir: "tmp"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := normalizeAbsPath(img)
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveImagePath_rejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := ResolveImagePath("../../../etc/passwd", PathOptions{CWD: dir})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path outside roots")
|
||||
}
|
||||
}
|
||||
|
||||
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, PathOptions{CWD: 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,130 @@
|
||||
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
|
||||
}
|
||||
|
||||
pathOpt := PathOptions{
|
||||
CWD: cwd,
|
||||
ResultStorageDir: cfg.Agent.ResultStorageDir,
|
||||
ExtraRoots: cfg.Vision.AllowedRoots,
|
||||
}
|
||||
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, pathOpt)
|
||||
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
|
||||
- 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
|
||||
- Output is split into **collapsible Progress** + **Final Response** (Markdown rendering supported)
|
||||
- View captured **Request / Response** for each run
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
- 右键任意 HTTP 请求包 → **Send to CyberStrikeAI (stream test)**:
|
||||
- 将该 HTTP 请求(含 headers/body;若存在响应则附带截断片段)发送到 CyberStrikeAI
|
||||
- 以 **SSE 流式**接收返回内容,并在标签页中实时展示
|
||||
- 单 Agent:`POST /api/agent-loop/stream`
|
||||
- 多 Agent:`POST /api/multi-agent/stream`(需要服务端启用 `multi_agent.enabled: true`)
|
||||
- 单 Agent:`POST /api/eino-agent/stream`
|
||||
- 多 Agent:`POST /api/multi-agent/stream`(需 `multi_agent.enabled: true`,请求体 `orchestration`)
|
||||
- **测试历史侧边栏(可搜索)**:每次发送都会新增一条记录,方便回看与对比
|
||||
- **Output 分区**:`Progress`(可折叠)+ `Final Response`(主区域)
|
||||
- **Markdown 渲染**:最终输出可在 Output 主区域渲染为富文本(可开关)
|
||||
|
||||
-1
@@ -38,7 +38,6 @@ final class CyberStrikeAIClient {
|
||||
}
|
||||
|
||||
enum AgentMode {
|
||||
NATIVE_REACT("Native ReAct", "/api/agent-loop/stream", null),
|
||||
EINO_SINGLE("Eino Single (ADK)", "/api/eino-agent/stream", null),
|
||||
DEEP("Deep (DeepAgent)", "/api/multi-agent/stream", "deep"),
|
||||
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 JCheckBox useHttpsBox = new JCheckBox("HTTPS", true);
|
||||
private final JPasswordField passwordField = new JPasswordField();
|
||||
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{
|
||||
"Native ReAct", "Eino Single (ADK)", "Deep (DeepAgent)", "Plan-Execute", "Supervisor"
|
||||
});
|
||||
private final JComboBox<String> agentModeBox = new JComboBox<>(agentModeLabels());
|
||||
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 clearButton = new JButton("Clear Output");
|
||||
private final JButton stopButton = new JButton("Stop");
|
||||
@@ -554,7 +561,7 @@ final class CyberStrikeAITab implements ITab {
|
||||
int idx = agentModeBox.getSelectedIndex();
|
||||
CyberStrikeAIClient.AgentMode mode = (idx >= 0 && idx < AGENT_MODES.length)
|
||||
? AGENT_MODES[idx]
|
||||
: CyberStrikeAIClient.AgentMode.NATIVE_REACT;
|
||||
: CyberStrikeAIClient.AgentMode.EINO_SINGLE;
|
||||
return new CyberStrikeAIClient.Config(baseUrl, password, mode);
|
||||
}
|
||||
|
||||
|
||||
+300
-28
@@ -9,6 +9,10 @@
|
||||
--secondary-color: #2d2d2d;
|
||||
--accent-color: #0066ff;
|
||||
--accent-hover: #0052cc;
|
||||
--brand-core: #141824;
|
||||
--brand-ai-start: #0066ff;
|
||||
--brand-ai-end: #7c3aed;
|
||||
--brand-gradient: linear-gradient(135deg, var(--brand-ai-start) 0%, var(--brand-ai-end) 100%);
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--bg-tertiary: #f1f3f5;
|
||||
@@ -125,11 +129,19 @@ body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.main-sidebar-header .logo span {
|
||||
.main-sidebar-header .logo span,
|
||||
.main-sidebar-header .brand-wordmark {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--brand-core);
|
||||
}
|
||||
|
||||
.main-sidebar-header .brand-wordmark__ai {
|
||||
background: var(--brand-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.main-sidebar-nav {
|
||||
@@ -592,37 +604,89 @@ header {
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
/* 品牌字标:CyberStrike + AI 渐变 */
|
||||
.brand-wordmark {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-wordmark--lg {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.brand-wordmark--sm {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.brand-wordmark__core {
|
||||
color: var(--brand-core);
|
||||
}
|
||||
|
||||
.brand-wordmark__ai {
|
||||
background: var(--brand-gradient);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0, 102, 255, 0.18);
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.logo h1,
|
||||
.logo .brand-wordmark {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.header-logo-link {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.header-logo-link:hover {
|
||||
opacity: 0.85;
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.header-logo-link:hover .brand-logo {
|
||||
box-shadow: 0 4px 14px rgba(0, 102, 255, 0.28);
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
vertical-align: 0.35em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
padding: 3px 9px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
color: var(--brand-ai-start);
|
||||
background: linear-gradient(135deg, rgba(0, 102, 255, 0.08) 0%, rgba(124, 58, 237, 0.08) 100%);
|
||||
border: 1px solid rgba(0, 102, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.04em;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -3091,10 +3155,36 @@ header {
|
||||
.login-brand {
|
||||
padding: 32px 28px 24px;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, rgba(0, 102, 255, 0.06) 0%, transparent 100%);
|
||||
background: linear-gradient(180deg, rgba(0, 102, 255, 0.07) 0%, rgba(124, 58, 237, 0.04) 50%, transparent 100%);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.login-brand-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 20px rgba(0, 102, 255, 0.2);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 0.35em;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.login-title .brand-wordmark {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.login-brand h2 {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
@@ -3530,8 +3620,19 @@ header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 1.25rem;
|
||||
.logo h1,
|
||||
.logo .brand-wordmark {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
padding: 2px 7px;
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
@@ -4298,6 +4399,31 @@ header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.robot-cmd-category {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 16px 0 6px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.robot-cmd-category:first-of-type {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.robot-cmd-list {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
margin: 0 0 4px 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.robot-cmd-footer {
|
||||
margin-top: 12px !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
@@ -6249,6 +6375,7 @@ header {
|
||||
.mcp-stats-dist-panel .mcp-stats-tools-legend {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -18160,7 +18287,6 @@ header {
|
||||
border-color: rgba(138, 43, 226, 0.3);
|
||||
box-shadow: 0 2px 6px rgba(138, 43, 226, 0.2);
|
||||
}
|
||||
|
||||
.role-selection-item-content-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -21080,13 +21206,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
#page-projects .page-content.projects-page-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
min-height: calc(100vh - 128px);
|
||||
padding: 16px 20px 24px;
|
||||
padding: 16px clamp(12px, 1.4vw, 20px) 24px;
|
||||
background: transparent;
|
||||
}
|
||||
.projects-sidebar-card {
|
||||
width: 260px;
|
||||
width: clamp(200px, 15vw, 236px);
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
@@ -21258,10 +21384,11 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
min-height: 420px;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
.projects-detail-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
@@ -21349,6 +21476,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
padding: 12px 24px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.projects-tab {
|
||||
padding: 8px 16px;
|
||||
@@ -21594,9 +21722,94 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
#project-panel-facts,
|
||||
#project-panel-conversations,
|
||||
#project-panel-vulns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
#project-panel-facts .projects-fact-toolbar,
|
||||
#project-panel-vulns .projects-fact-toolbar,
|
||||
#project-panel-conversations .projects-panel-toolbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#project-panel-facts .projects-table-wrap,
|
||||
#project-panel-conversations .projects-table-wrap,
|
||||
#project-panel-vulns .projects-table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
#project-panel-facts .projects-table-wrap .data-table--projects thead th,
|
||||
#project-panel-conversations .projects-table-wrap .data-table--projects thead th,
|
||||
#project-panel-vulns .projects-table-wrap .data-table--projects thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
box-shadow: 0 1px 0 var(--border-color, #e2e8f0);
|
||||
}
|
||||
.projects-panel-toolbar--hint {
|
||||
margin-bottom: 14px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.projects-panel-toolbar--hint .projects-fact-toolbar-hint {
|
||||
margin: 0;
|
||||
}
|
||||
#project-panel-conversations .data-table--projects th:nth-child(1),
|
||||
#project-panel-conversations .data-table--projects td:nth-child(1) {
|
||||
width: 48%;
|
||||
}
|
||||
#project-panel-conversations .data-table--projects th:nth-child(2),
|
||||
#project-panel-conversations .data-table--projects td:nth-child(2) {
|
||||
width: 22%;
|
||||
}
|
||||
#project-panel-conversations .data-table--projects th:nth-child(3),
|
||||
#project-panel-conversations .data-table--projects td:nth-child(3) {
|
||||
width: 30%;
|
||||
}
|
||||
.projects-vuln-toolbar-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.projects-vuln-toolbar-top .projects-fact-toolbar-hint {
|
||||
flex: 1 1 240px;
|
||||
margin: 0;
|
||||
}
|
||||
#project-panel-vulns .data-table--projects th:nth-child(1),
|
||||
#project-panel-vulns .data-table--projects td:nth-child(1) {
|
||||
width: 46%;
|
||||
}
|
||||
#project-panel-vulns .data-table--projects th:nth-child(2),
|
||||
#project-panel-vulns .data-table--projects td:nth-child(2) {
|
||||
width: 14%;
|
||||
}
|
||||
#project-panel-vulns .data-table--projects th:nth-child(3),
|
||||
#project-panel-vulns .data-table--projects td:nth-child(3) {
|
||||
width: 14%;
|
||||
}
|
||||
#project-panel-vulns .data-table--projects th:nth-child(4),
|
||||
#project-panel-vulns .data-table--projects td:nth-child(4) {
|
||||
width: 26%;
|
||||
}
|
||||
.projects-table-wrap .data-table--projects {
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.data-table--projects .col-actions {
|
||||
width: auto;
|
||||
min-width: 240px;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
.data-table--projects thead th.col-actions {
|
||||
@@ -21606,13 +21819,13 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
.projects-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 11px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
@@ -21657,6 +21870,65 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
#project-panel-facts .data-table--projects th:nth-child(1),
|
||||
#project-panel-facts .data-table--projects td:nth-child(1) {
|
||||
width: 19%;
|
||||
}
|
||||
#project-panel-facts .data-table--projects th:nth-child(2),
|
||||
#project-panel-facts .data-table--projects td:nth-child(2) {
|
||||
width: 9%;
|
||||
}
|
||||
#project-panel-facts .data-table--projects th:nth-child(3),
|
||||
#project-panel-facts .data-table--projects td:nth-child(3) {
|
||||
width: 28%;
|
||||
}
|
||||
#project-panel-facts .data-table--projects th:nth-child(4),
|
||||
#project-panel-facts .data-table--projects td:nth-child(4) {
|
||||
width: 8%;
|
||||
}
|
||||
#project-panel-facts .data-table--projects th:nth-child(5),
|
||||
#project-panel-facts .data-table--projects td:nth-child(5) {
|
||||
width: 9%;
|
||||
}
|
||||
#project-panel-facts .data-table--projects th:nth-child(6),
|
||||
#project-panel-facts .data-table--projects td:nth-child(6) {
|
||||
width: 8%;
|
||||
}
|
||||
#project-panel-facts .data-table--projects th:nth-child(7),
|
||||
#project-panel-facts .data-table--projects td:nth-child(7) {
|
||||
width: 19%;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.projects-detail-header {
|
||||
padding: 16px 18px 14px;
|
||||
gap: 14px;
|
||||
}
|
||||
.projects-tabs {
|
||||
padding: 10px 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.projects-panel {
|
||||
padding: 14px 18px 18px;
|
||||
}
|
||||
.projects-action-btn {
|
||||
padding: 4px 9px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1680px) {
|
||||
#project-panel-facts .data-table--projects th,
|
||||
#project-panel-facts .data-table--projects td {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
#project-panel-facts .data-table--projects th:nth-child(3),
|
||||
#project-panel-facts .data-table--projects td:nth-child(3) {
|
||||
width: 24%;
|
||||
}
|
||||
#project-panel-facts .data-table--projects th:nth-child(7),
|
||||
#project-panel-facts .data-table--projects td:nth-child(7) {
|
||||
width: 23%;
|
||||
}
|
||||
}
|
||||
/* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */
|
||||
.projects-settings-layout {
|
||||
width: 100%;
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in to CyberStrikeAI",
|
||||
"titlePrefix": "Sign in to",
|
||||
"subtitle": "Enter the access password from config",
|
||||
"passwordLabel": "Password",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
@@ -258,6 +259,9 @@
|
||||
"boundConversationsHint": "Conversations bound to this project; click to open",
|
||||
"titleLabel": "Title",
|
||||
"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",
|
||||
"severity": "Severity",
|
||||
"status": "Status",
|
||||
@@ -497,8 +501,6 @@
|
||||
"historyGroupEarlier": "Older",
|
||||
"agentModeSelectAria": "Choose conversation execution mode",
|
||||
"agentModePanelTitle": "Conversation mode",
|
||||
"agentModeReactNative": "Native ReAct",
|
||||
"agentModeReactNativeHint": "Classic single-agent ReAct with MCP tools",
|
||||
"agentModeEinoSingle": "Eino single (ADK)",
|
||||
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner with MCP tools (/api/eino-agent)",
|
||||
"agentModeDeep": "Deep (DeepAgent)",
|
||||
@@ -509,7 +511,7 @@
|
||||
"agentModeSupervisorHint": "Supervisor coordinates via transfer to sub-agents",
|
||||
"agentModeSingle": "Single-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",
|
||||
"reasoningModeLabel": "Model reasoning",
|
||||
"reasoningEffortLabel": "Reasoning effort",
|
||||
@@ -1955,6 +1957,26 @@
|
||||
"retryDelay": "Retry delay (ms)",
|
||||
"retryDelayPlaceholder": "1000",
|
||||
"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)",
|
||||
"visionAllowedRoots": "Extra allowed path roots",
|
||||
"visionAllowedRootsPlaceholder": "One absolute path per line, optional",
|
||||
"visionTestFillRequired": "Enter vision model and ensure API Key is available (or reuse OpenAI)",
|
||||
"testConnection": "Test Connection",
|
||||
"testFillRequired": "Please fill in API Key and Model first",
|
||||
"testing": "Testing connection...",
|
||||
@@ -2091,17 +2113,25 @@
|
||||
"settingsRobotsExtra": {
|
||||
"botCommandsTitle": "Bot command instructions",
|
||||
"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",
|
||||
"botCmdList": "List conversations",
|
||||
"botCmdSwitch": "Switch to conversation",
|
||||
"botCmdNew": "Start new conversation",
|
||||
"botCmdClear": "Clear context",
|
||||
"botCmdCurrent": "Show current conversation",
|
||||
"botCmdCurrent": "Show current conversation, role and project",
|
||||
"botCmdStop": "Stop running task",
|
||||
"botCmdRoles": "List roles",
|
||||
"botCmdRole": "Switch role",
|
||||
"botCmdDelete": "Delete conversation",
|
||||
"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."
|
||||
},
|
||||
"mcpDetailModal": {
|
||||
@@ -2277,9 +2307,9 @@
|
||||
"projectNone": "(Unbound)",
|
||||
"projectHint": "Optionally bind this queue to a project; leave empty to keep it unbound.",
|
||||
"agentMode": "Agent mode",
|
||||
"agentModeSingle": "Single-agent (ReAct)",
|
||||
"agentModeSingle": "Single-agent (Eino ADK)",
|
||||
"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",
|
||||
"scheduleModeManual": "Manual",
|
||||
"scheduleModeCron": "Cron expression",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
},
|
||||
"login": {
|
||||
"title": "登录 CyberStrikeAI",
|
||||
"titlePrefix": "登录",
|
||||
"subtitle": "请输入配置中的访问密码",
|
||||
"passwordLabel": "密码",
|
||||
"passwordPlaceholder": "输入登录密码",
|
||||
@@ -247,6 +248,9 @@
|
||||
"boundConversationsHint": "绑定到本项目的对话;点击可打开会话",
|
||||
"titleLabel": "标题",
|
||||
"projectVulnSummaryHint": "本项目下记录的漏洞汇总",
|
||||
"searchVulnsSr": "搜索漏洞",
|
||||
"searchVulnsPlaceholder": "搜索标题、描述、类型、目标…",
|
||||
"noMatchingVulns": "无匹配漏洞,请调整筛选条件",
|
||||
"viewInVulnerabilityManagement": "在漏洞管理中查看",
|
||||
"severity": "严重度",
|
||||
"status": "状态",
|
||||
@@ -486,8 +490,6 @@
|
||||
"historyGroupEarlier": "更早",
|
||||
"agentModeSelectAria": "选择对话执行模式",
|
||||
"agentModePanelTitle": "对话模式",
|
||||
"agentModeReactNative": "原生 ReAct 模式",
|
||||
"agentModeReactNativeHint": "经典单代理 ReAct 与 MCP 工具",
|
||||
"agentModeEinoSingle": "Eino 单代理(ADK)",
|
||||
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)",
|
||||
"agentModeDeep": "Deep(DeepAgent)",
|
||||
@@ -498,7 +500,7 @@
|
||||
"agentModeSupervisorHint": "监督者协调,transfer 委派子代理",
|
||||
"agentModeSingle": "单代理",
|
||||
"agentModeMulti": "多代理",
|
||||
"agentModeSingleHint": "单模型 ReAct 循环,适合常规对话与工具调用",
|
||||
"agentModeSingleHint": "Eino ADK 单代理,适合常规对话与工具调用",
|
||||
"agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务",
|
||||
"reasoningModeLabel": "模型推理",
|
||||
"reasoningEffortLabel": "推理强度",
|
||||
@@ -1944,6 +1946,26 @@
|
||||
"retryDelay": "重试间隔(毫秒)",
|
||||
"retryDelayPlaceholder": "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": "超时(秒)",
|
||||
"visionAllowedRoots": "额外允许路径根目录",
|
||||
"visionAllowedRootsPlaceholder": "每行一个绝对路径,可选",
|
||||
"visionTestFillRequired": "请填写视觉模型,并确保 API Key 可用(可复用 OpenAI)",
|
||||
"testConnection": "测试连接",
|
||||
"testFillRequired": "请先填写 API Key 和模型",
|
||||
"testing": "测试中...",
|
||||
@@ -2080,17 +2102,25 @@
|
||||
"settingsRobotsExtra": {
|
||||
"botCommandsTitle": "机器人命令说明",
|
||||
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
|
||||
"botCmdCategoryGeneral": "通用",
|
||||
"botCmdCategoryConversation": "对话",
|
||||
"botCmdCategoryRole": "角色",
|
||||
"botCmdCategoryProject": "项目",
|
||||
"botCmdHelp": "显示本帮助 | Show this help",
|
||||
"botCmdList": "列出所有对话标题与 ID | List conversations",
|
||||
"botCmdSwitch": "指定对话继续 | Switch to conversation",
|
||||
"botCmdNew": "开启新对话 | Start new conversation",
|
||||
"botCmdClear": "清空当前上下文 | Clear context",
|
||||
"botCmdCurrent": "显示当前对话 ID 与标题 | Show current conversation",
|
||||
"botCmdCurrent": "显示当前对话、角色与项目 | Show current conversation",
|
||||
"botCmdStop": "中断当前任务 | Stop running task",
|
||||
"botCmdRoles": "列出所有可用角色 | List roles",
|
||||
"botCmdRole": "切换当前角色 | Switch role",
|
||||
"botCmdDelete": "删除指定对话 | Delete conversation",
|
||||
"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."
|
||||
},
|
||||
"mcpDetailModal": {
|
||||
@@ -2266,9 +2296,9 @@
|
||||
"projectNone": "(未绑定)",
|
||||
"projectHint": "可为队列绑定项目;留空则不绑定项目上下文。",
|
||||
"agentMode": "代理模式",
|
||||
"agentModeSingle": "单代理(ReAct)",
|
||||
"agentModeSingle": "单代理(Eino ADK)",
|
||||
"agentModeMulti": "多代理(Eino)",
|
||||
"agentModeHint": "与对话页一致:原生 ReAct、Eino 单代理(ADK),或 Deep / Plan-Execute / Supervisor(后三种需已启用多代理)。",
|
||||
"agentModeHint": "与对话页一致:Eino 单代理(ADK),或 Deep / Plan-Execute / Supervisor(后三种需已启用多代理)。",
|
||||
"scheduleMode": "调度方式",
|
||||
"scheduleModeManual": "手工执行",
|
||||
"scheduleModeCron": "调度表达式(Cron)",
|
||||
|
||||
+14
-22
@@ -38,11 +38,10 @@ function isInterruptContinueInjectChatMessage(content) {
|
||||
let chatAttachments = [];
|
||||
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 REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
||||
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_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
|
||||
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) {
|
||||
const pub = cfg && cfg.multi_agent ? cfg.multi_agent : null;
|
||||
const multiOn = !!(pub && pub.enabled);
|
||||
const defOrch = 'deep';
|
||||
let s = stored;
|
||||
if (s === 'single') s = CHAT_AGENT_MODE_REACT;
|
||||
if (s === 'multi') s = defOrch;
|
||||
if (s === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(s)) return s;
|
||||
const s = stored;
|
||||
if (chatAgentModeIsEinoSingle(s)) return 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') {
|
||||
@@ -411,7 +407,6 @@ if (typeof window !== 'undefined') {
|
||||
window.csaiChatAgentMode = {
|
||||
EINO_MODES: CHAT_AGENT_EINO_MODES,
|
||||
EINO_SINGLE: CHAT_AGENT_MODE_EINO_SINGLE,
|
||||
REACT: CHAT_AGENT_MODE_REACT,
|
||||
isEino: chatAgentModeIsEino,
|
||||
isEinoSingle: chatAgentModeIsEinoSingle,
|
||||
normalizeStored: chatAgentModeNormalizeStored,
|
||||
@@ -448,8 +443,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
function getAgentModeLabelForValue(mode) {
|
||||
if (typeof window.t === 'function') {
|
||||
switch (mode) {
|
||||
case CHAT_AGENT_MODE_REACT:
|
||||
return window.t('chat.agentModeReactNative');
|
||||
case 'deep':
|
||||
return window.t('chat.agentModeDeep');
|
||||
case 'plan_execute':
|
||||
@@ -463,7 +456,6 @@ function getAgentModeLabelForValue(mode) {
|
||||
}
|
||||
}
|
||||
switch (mode) {
|
||||
case CHAT_AGENT_MODE_REACT: return '原生 ReAct';
|
||||
case CHAT_AGENT_MODE_EINO_SINGLE: return 'Eino 单代理';
|
||||
case 'deep': return 'Deep';
|
||||
case 'plan_execute': return 'Plan-Execute';
|
||||
@@ -474,7 +466,6 @@ function getAgentModeLabelForValue(mode) {
|
||||
|
||||
function getAgentModeIconForValue(mode) {
|
||||
switch (mode) {
|
||||
case CHAT_AGENT_MODE_REACT: return '🤖';
|
||||
case CHAT_AGENT_MODE_EINO_SINGLE: return '⚡';
|
||||
case 'deep': return '🧩';
|
||||
case 'plan_execute': return '📋';
|
||||
@@ -655,7 +646,7 @@ function toggleAgentModePanel() {
|
||||
}
|
||||
|
||||
function selectAgentMode(mode) {
|
||||
const ok = mode === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
||||
const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
||||
if (!ok) return;
|
||||
try {
|
||||
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
|
||||
@@ -672,8 +663,8 @@ async function initChatAgentModeFromConfig() {
|
||||
// 先展示基础模式,避免首次登录时配置接口短暂失败导致入口被隐藏。
|
||||
wrap.style.display = '';
|
||||
let stored = localStorage.getItem(AGENT_MODE_STORAGE_KEY);
|
||||
if (!(stored === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(stored) || chatAgentModeIsEino(stored))) {
|
||||
stored = CHAT_AGENT_MODE_REACT;
|
||||
if (!(chatAgentModeIsEinoSingle(stored) || chatAgentModeIsEino(stored))) {
|
||||
stored = CHAT_AGENT_MODE_EINO_SINGLE;
|
||||
}
|
||||
sel.value = stored;
|
||||
syncAgentModeFromValue(stored);
|
||||
@@ -725,7 +716,7 @@ document.addEventListener('languagechange', function () {
|
||||
const hid = document.getElementById('agent-mode-select');
|
||||
if (!hid) return;
|
||||
const v = hid.value;
|
||||
if (v === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(v) || chatAgentModeIsEino(v)) {
|
||||
if (chatAgentModeIsEinoSingle(v) || chatAgentModeIsEino(v)) {
|
||||
syncAgentModeFromValue(v);
|
||||
}
|
||||
if (typeof updateChatReasoningSummary === 'function') {
|
||||
@@ -945,10 +936,9 @@ async function sendMessage() {
|
||||
|
||||
try {
|
||||
const modeSel = document.getElementById('agent-mode-select');
|
||||
const modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_REACT;
|
||||
const useEinoSingle = chatAgentModeIsEinoSingle(modeVal);
|
||||
let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE;
|
||||
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) {
|
||||
body.orchestration = modeVal;
|
||||
}
|
||||
@@ -995,6 +985,8 @@ async function sendMessage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flush decoder internal buffer to avoid losing the final partial UTF-8 code point.
|
||||
buffer += decoder.decode();
|
||||
|
||||
// 处理剩余的buffer
|
||||
if (buffer.trim()) {
|
||||
|
||||
+192
-8
@@ -238,6 +238,8 @@ function finalizeOutstandingToolCallsForProgress(progressId, finalStatus) {
|
||||
|
||||
// 模型流式输出缓存:progressId -> { assistantId, buffer }
|
||||
const responseStreamStateByProgressId = new Map();
|
||||
// 主通道当前迭代轮次缓存:progressId -> { iteration, orchestration }
|
||||
const mainIterationStateByProgressId = new Map();
|
||||
|
||||
/** 同一段主通道流式输出(Eino 可能重复 response_start) */
|
||||
function sameMainResponseStreamMeta(a, b) {
|
||||
@@ -250,6 +252,40 @@ function sameMainResponseStreamMeta(a, b) {
|
||||
return orchA === orchB;
|
||||
}
|
||||
|
||||
function resolveMainIterationTag(progressId, responseData) {
|
||||
const d = responseData || {};
|
||||
if (d.iteration != null) {
|
||||
return String(d.iteration);
|
||||
}
|
||||
const cached = mainIterationStateByProgressId.get(String(progressId));
|
||||
if (!cached || cached.iteration == null) {
|
||||
return '';
|
||||
}
|
||||
const cachedOrch = String(cached.orchestration != null ? cached.orchestration : '').trim();
|
||||
const streamOrch = String(d.orchestration != null ? d.orchestration : '').trim();
|
||||
if (cachedOrch && streamOrch && cachedOrch !== streamOrch) {
|
||||
return '';
|
||||
}
|
||||
return String(cached.iteration);
|
||||
}
|
||||
|
||||
function buildMainResponseStreamIdentity(progressId, responseData) {
|
||||
const d = responseData || {};
|
||||
const agent = String(d.einoAgent != null ? d.einoAgent : '').trim();
|
||||
const orch = String(d.orchestration != null ? d.orchestration : '').trim();
|
||||
const iterTag = resolveMainIterationTag(progressId, d);
|
||||
return agent + '|' + orch + '|iter=' + iterTag;
|
||||
}
|
||||
|
||||
function extractIterationTagFromStreamIdentity(identity) {
|
||||
const s = String(identity || '');
|
||||
const idx = s.lastIndexOf('|iter=');
|
||||
if (idx < 0) {
|
||||
return '';
|
||||
}
|
||||
return s.slice(idx + 6);
|
||||
}
|
||||
|
||||
// AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer })
|
||||
const thinkingStreamStateByProgressId = new Map();
|
||||
|
||||
@@ -380,6 +416,118 @@ function _normalizeUnicodeBulletMarkersToMdDash(segment) {
|
||||
.replace(/^\s*\u00b7\s+/gm, '- ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 修正模型常见的强调语法偏差:
|
||||
* 1) 把 `\*\*文本\*\*` 还原为 `**文本**`(常见于多层转义输出)
|
||||
* 2) 把 `** 文本 **` 收敛为 `**文本**`(避免分隔符内空格导致不生效)
|
||||
* 仅处理单行内容,避免跨段落误匹配。
|
||||
*/
|
||||
function _normalizeEmphasisMarkersForMarkdown(segment) {
|
||||
const raw = String(segment);
|
||||
const maskInlineCode = (input) => {
|
||||
const blocks = [];
|
||||
const masked = input.replace(/`[^`\n]*`/g, (m) => {
|
||||
const token = '__CS_INLINE_CODE_' + blocks.length + '__';
|
||||
blocks.push(m);
|
||||
return token;
|
||||
});
|
||||
return { masked, blocks };
|
||||
};
|
||||
const unmaskInlineCode = (input, blocks) => {
|
||||
let out = input;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
out = out.replace('__CS_INLINE_CODE_' + i + '__', blocks[i]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const isWordLike = (ch) => /[\u4e00-\u9fffA-Za-z0-9]/.test(ch || '');
|
||||
const countUnescapedStrongMarkers = (text) => {
|
||||
let count = 0;
|
||||
for (let i = 0; i < text.length - 1; i++) {
|
||||
if (text.charAt(i) === '*' && text.charAt(i + 1) === '*') {
|
||||
if (i > 0 && text.charAt(i - 1) === '\\') {
|
||||
continue;
|
||||
}
|
||||
count++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const normalizeLine = (line) => {
|
||||
let lineWork = line;
|
||||
// 奇数个 `**` 往往意味着有一个孤立标记;仅清理「空白夹着的 **」这类高置信噪声。
|
||||
while (countUnescapedStrongMarkers(lineWork) % 2 === 1) {
|
||||
const next = lineWork.replace(/\s\*\*\s/g, ' ');
|
||||
if (next === lineWork) break;
|
||||
lineWork = next;
|
||||
}
|
||||
let out = '';
|
||||
let cursor = 0;
|
||||
while (cursor < lineWork.length) {
|
||||
const open = lineWork.indexOf('**', cursor);
|
||||
if (open < 0) {
|
||||
out += lineWork.slice(cursor);
|
||||
break;
|
||||
}
|
||||
// 允许 `\*\*text\*\*` 先还原,escaped 星号本身不作为强调标记。
|
||||
if (open > 0 && lineWork.charAt(open - 1) === '\\') {
|
||||
out += lineWork.slice(cursor, open + 2);
|
||||
cursor = open + 2;
|
||||
continue;
|
||||
}
|
||||
let close = open + 2;
|
||||
while (true) {
|
||||
close = lineWork.indexOf('**', close);
|
||||
if (close < 0) break;
|
||||
if (close > 0 && lineWork.charAt(close - 1) === '\\') {
|
||||
close += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (close < 0) {
|
||||
out += lineWork.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
let prefix = lineWork.slice(cursor, open);
|
||||
const innerRaw = lineWork.slice(open + 2, close);
|
||||
const inner = innerRaw.trim();
|
||||
const next = lineWork.charAt(close + 2);
|
||||
const prevTail = prefix.charAt(prefix.length - 1);
|
||||
|
||||
// 内部为空时不改写,避免把 `****` 等异常输入改坏。
|
||||
if (!inner) {
|
||||
out += lineWork.slice(cursor, close + 2);
|
||||
cursor = close + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// CJK/字母数字与强调标记紧邻时补边界空格,提升解析稳定性。
|
||||
if (isWordLike(prevTail) && !/\s$/.test(prefix)) {
|
||||
prefix += ' ';
|
||||
}
|
||||
out += prefix + '**' + inner + '**';
|
||||
if (isWordLike(next)) {
|
||||
out += ' ';
|
||||
}
|
||||
cursor = close + 2;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// 先还原常见 escaped strong,再做成对规范化。
|
||||
let s = raw.replace(/\\\*\*([^\n*][^\n]*?[^\n*])\\\*\*/g, '**$1**');
|
||||
const masked = maskInlineCode(s);
|
||||
s = masked.masked
|
||||
.split('\n')
|
||||
.map(normalizeLine)
|
||||
.join('\n');
|
||||
s = unmaskInlineCode(s, masked.blocks);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析前归一化助手 Markdown:去掉零宽字符,NFKC 将全角 * ` _ 等转为 ASCII,
|
||||
* 避免 marked 无法识别强调/行内代码而原样显示 **、反引号;
|
||||
@@ -396,6 +544,7 @@ function normalizeAssistantMarkdownSource(text) {
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
s = _normalizeEmphasisMarkersForMarkdown(s);
|
||||
s = _stripXmlReasoningWrappersForMarkdown(s);
|
||||
const fb = _maskFencedCodeBlocksForMdPreprocess(s);
|
||||
s = _unwrapHtmlBlockWrappersForMarkdown(fb.masked);
|
||||
@@ -1372,6 +1521,13 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
case 'iteration': {
|
||||
const d = event.data || {};
|
||||
const n = d.iteration != null ? d.iteration : 1;
|
||||
const scope = d.einoScope != null ? String(d.einoScope).trim() : '';
|
||||
if (scope !== 'sub') {
|
||||
mainIterationStateByProgressId.set(String(progressId), {
|
||||
iteration: n,
|
||||
orchestration: d.orchestration != null ? d.orchestration : ''
|
||||
});
|
||||
}
|
||||
let iterTitle;
|
||||
if (d.orchestration === 'plan_execute' && d.einoScope === 'main') {
|
||||
const phase = translatePlanExecuteAgentName(d.einoAgent != null ? d.einoAgent : '');
|
||||
@@ -1939,6 +2095,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const responseOriginalConversationId = responseTaskState?.conversationId;
|
||||
|
||||
const responseData = event.data || {};
|
||||
const streamIdentity = buildMainResponseStreamIdentity(progressId, responseData);
|
||||
const streamIterTag = extractIterationTagFromStreamIdentity(streamIdentity);
|
||||
const mcpIds = responseData.mcpExecutionIds || [];
|
||||
setMcpIds(mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], mcpIds));
|
||||
|
||||
@@ -1958,25 +2116,33 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
|
||||
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
|
||||
const prevStream = responseStreamStateByProgressId.get(progressId);
|
||||
if (prevStream && prevStream.itemId && sameMainResponseStreamMeta(prevStream.streamMeta, responseData)) {
|
||||
const prevIterTag = extractIterationTagFromStreamIdentity(prevStream && prevStream.streamIdentity ? prevStream.streamIdentity : '');
|
||||
const compatibleIterTag = !prevIterTag || !streamIterTag || prevIterTag === streamIterTag;
|
||||
if (
|
||||
prevStream &&
|
||||
prevStream.itemId &&
|
||||
sameMainResponseStreamMeta(prevStream.streamMeta, responseData) &&
|
||||
compatibleIterTag
|
||||
) {
|
||||
// Eino 可能对同一段流重复发 response_start;复用已有条目与 buffer,避免多条「助手输出」
|
||||
prevStream.streamMeta = Object.assign({}, prevStream.streamMeta || {}, responseData);
|
||||
// 若此前轮次未知(空),在后续事件带来轮次后升级 identity,避免跨轮误复用。
|
||||
prevStream.streamIdentity = streamIdentity;
|
||||
responseStreamStateByProgressId.set(progressId, prevStream);
|
||||
break;
|
||||
}
|
||||
if (prevStream && prevStream.itemId) {
|
||||
const oldItem = document.getElementById(prevStream.itemId);
|
||||
if (oldItem && oldItem.parentNode) {
|
||||
oldItem.parentNode.removeChild(oldItem);
|
||||
}
|
||||
}
|
||||
const title = einoMainStreamPlanningTitle(responseData);
|
||||
const itemId = addTimelineItem(timeline, 'thinking', {
|
||||
title: title,
|
||||
message: ' ',
|
||||
data: Object.assign({}, responseData, { responseStreamPlaceholder: true })
|
||||
});
|
||||
responseStreamStateByProgressId.set(progressId, { itemId: itemId, buffer: '', streamMeta: responseData });
|
||||
responseStreamStateByProgressId.set(progressId, {
|
||||
itemId: itemId,
|
||||
buffer: '',
|
||||
streamMeta: responseData,
|
||||
streamIdentity: streamIdentity
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2145,11 +2311,13 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
loadActiveTasks();
|
||||
// Close any remaining running tool calls for this progress.
|
||||
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||
mainIterationStateByProgressId.delete(String(progressId));
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
// 清理流式输出状态
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
mainIterationStateByProgressId.delete(String(progressId));
|
||||
thinkingStreamStateByProgressId.delete(progressId);
|
||||
einoAgentReplyStreamStateByProgressId.delete(progressId);
|
||||
// 清理工具流式输出占位
|
||||
@@ -2570,6 +2738,22 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flush decoder internal buffer to avoid dropping trailing partial UTF-8 bytes.
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) {
|
||||
const lines = buffer.split('\n');
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const line = lines[li];
|
||||
if (line.indexOf('data: ') === 0) {
|
||||
try {
|
||||
const eventData = JSON.parse(line.slice(6));
|
||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
|
||||
} catch (e) {
|
||||
console.error('task-events parse', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
|
||||
clearCsTaskReplay();
|
||||
}
|
||||
|
||||
+85
-10
@@ -226,9 +226,9 @@ function initProjectsModalEscape() {
|
||||
window._projectsModalEscapeBound = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (document.getElementById('project-modal')?.style.display === 'flex') closeProjectModal();
|
||||
else if (document.getElementById('fact-modal')?.style.display === 'flex') closeFactModal();
|
||||
else if (document.getElementById('fact-detail-modal')?.style.display === 'flex') closeFactDetailModal();
|
||||
if (isProjectsOverlayVisible('project-modal')) closeProjectModal();
|
||||
else if (isProjectsOverlayVisible('fact-modal')) closeFactModal();
|
||||
else if (isProjectsOverlayVisible('fact-detail-modal')) closeFactDetailModal();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,6 +236,7 @@ async function initProjectsPage() {
|
||||
const page = document.getElementById('page-projects');
|
||||
if (!page || page.style.display === 'none') return;
|
||||
initProjectsModalEscape();
|
||||
syncProjectsModalBodyLock();
|
||||
updateProjectsDetailVisibility();
|
||||
await loadProjectsList();
|
||||
if (!currentProjectId && projectsCache.length) {
|
||||
@@ -335,6 +336,50 @@ function formatSeverityBadge(severity) {
|
||||
return `<span class="projects-severity ${cls}">${escapeHtml(severity || '—')}</span>`;
|
||||
}
|
||||
|
||||
function formatVulnStatusBadge(status) {
|
||||
const s = (status || 'open').toLowerCase();
|
||||
const labelMap = {
|
||||
open: 'vulnerabilityPage.statusOpen',
|
||||
confirmed: 'vulnerabilityPage.statusConfirmed',
|
||||
fixed: 'vulnerabilityPage.statusFixed',
|
||||
false_positive: 'vulnerabilityPage.statusFalsePositive',
|
||||
};
|
||||
const label = labelMap[s] ? tp(labelMap[s]) : status || '—';
|
||||
const cls = ['open', 'confirmed', 'fixed', 'false_positive'].includes(s) ? s : 'open';
|
||||
return `<span class="status-badge status-${escapeHtml(cls)}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
let _projectVulnsFilterDebounce = null;
|
||||
|
||||
function buildProjectVulnsQueryParams() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('project_id', currentProjectId);
|
||||
params.set('limit', '200');
|
||||
const search = document.getElementById('project-vulns-search')?.value?.trim();
|
||||
const severity = document.getElementById('project-vulns-filter-severity')?.value?.trim();
|
||||
const status = document.getElementById('project-vulns-filter-status')?.value?.trim();
|
||||
if (search) params.set('q', search);
|
||||
if (severity) params.set('severity', severity);
|
||||
if (status) params.set('status', status);
|
||||
return params;
|
||||
}
|
||||
|
||||
function projectVulnsHasActiveFilter() {
|
||||
return !!(
|
||||
document.getElementById('project-vulns-search')?.value?.trim() ||
|
||||
document.getElementById('project-vulns-filter-severity')?.value ||
|
||||
document.getElementById('project-vulns-filter-status')?.value
|
||||
);
|
||||
}
|
||||
|
||||
function debouncedLoadProjectVulnerabilities() {
|
||||
if (_projectVulnsFilterDebounce) clearTimeout(_projectVulnsFilterDebounce);
|
||||
_projectVulnsFilterDebounce = setTimeout(() => {
|
||||
_projectVulnsFilterDebounce = null;
|
||||
loadProjectVulnerabilities();
|
||||
}, 280);
|
||||
}
|
||||
|
||||
function getProjectsListFilter() {
|
||||
return (document.getElementById('projects-list-search')?.value || '').trim().toLowerCase();
|
||||
}
|
||||
@@ -416,10 +461,16 @@ async function selectProject(id) {
|
||||
const catEl = document.getElementById('project-facts-filter-category');
|
||||
const confEl = document.getElementById('project-facts-filter-confidence');
|
||||
const sparseEl = document.getElementById('project-facts-filter-sparse');
|
||||
const vulnSearchEl = document.getElementById('project-vulns-search');
|
||||
const vulnSevEl = document.getElementById('project-vulns-filter-severity');
|
||||
const vulnStatusEl = document.getElementById('project-vulns-filter-status');
|
||||
if (searchEl) searchEl.value = '';
|
||||
if (catEl) catEl.value = '';
|
||||
if (confEl) confEl.value = '';
|
||||
if (sparseEl) sparseEl.checked = false;
|
||||
if (vulnSearchEl) vulnSearchEl.value = '';
|
||||
if (vulnSevEl) vulnSevEl.value = '';
|
||||
if (vulnStatusEl) vulnStatusEl.value = '';
|
||||
renderProjectsSidebar();
|
||||
updateProjectsDetailVisibility();
|
||||
try {
|
||||
@@ -845,15 +896,18 @@ async function loadProjectVulnerabilities() {
|
||||
const tbody = document.getElementById('project-vulns-tbody');
|
||||
if (!tbody || !currentProjectId) return;
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${escapeHtml(tp('common.loading'))}</td></tr>`;
|
||||
const res = await apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=100`);
|
||||
const qs = buildProjectVulnsQueryParams().toString();
|
||||
const res = await apiFetch(`/api/vulnerabilities?${qs}`);
|
||||
if (!res.ok) {
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const items = data.Vulnerabilities || data.vulnerabilities || data.items || [];
|
||||
const items = data.Vulnerabilities || data.vulnerabilities || data.items || (Array.isArray(data) ? data : []);
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${escapeHtml(tp('projects.noVulnerabilityRecords'))}</td></tr>`;
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="4">${
|
||||
projectVulnsHasActiveFilter() ? tp('projects.noMatchingVulns') : tp('projects.noVulnerabilityRecords')
|
||||
}</td></tr>`;
|
||||
refreshProjectHeaderStats();
|
||||
return;
|
||||
}
|
||||
@@ -862,7 +916,7 @@ async function loadProjectVulnerabilities() {
|
||||
return `<tr>
|
||||
<td class="cell-summary" title="${escapeHtml(v.title)}">${escapeHtml(v.title)}</td>
|
||||
<td>${formatSeverityBadge(v.severity)}</td>
|
||||
<td>${escapeHtml(v.status)}</td>
|
||||
<td>${formatVulnStatusBadge(v.status)}</td>
|
||||
<td class="col-actions">
|
||||
<div class="projects-table-actions">
|
||||
<button type="button" class="projects-action-btn projects-action-btn--view" data-vuln-id="${idEsc}" onclick="openVulnerabilityDetail(this.dataset.vulnId)">${escapeHtml(tp('common.view'))}</button>
|
||||
@@ -927,18 +981,37 @@ function openProjectsOverlay(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.style.display = 'flex';
|
||||
document.body.classList.add('projects-modal-open');
|
||||
syncProjectsModalBodyLock();
|
||||
const focusTarget = el.querySelector('input.form-input, textarea.form-input, select.form-input');
|
||||
if (focusTarget) {
|
||||
setTimeout(() => focusTarget.focus(), 80);
|
||||
}
|
||||
}
|
||||
|
||||
function isProjectsOverlayVisible(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
function hasVisibleProjectsOverlay() {
|
||||
const overlays = document.querySelectorAll('.projects-modal-overlay');
|
||||
return Array.from(overlays).some((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
});
|
||||
}
|
||||
|
||||
function syncProjectsModalBodyLock() {
|
||||
if (hasVisibleProjectsOverlay()) document.body.classList.add('projects-modal-open');
|
||||
else document.body.classList.remove('projects-modal-open');
|
||||
}
|
||||
|
||||
function closeProjectsOverlay(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
const anyOpen = document.querySelector('.projects-modal-overlay[style*="flex"]');
|
||||
if (!anyOpen) document.body.classList.remove('projects-modal-open');
|
||||
syncProjectsModalBodyLock();
|
||||
}
|
||||
|
||||
function showNewProjectModal() {
|
||||
@@ -1522,6 +1595,8 @@ window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
|
||||
window.openVulnerabilityDetail = openVulnerabilityDetail;
|
||||
window.filterProjectsList = filterProjectsList;
|
||||
window.debouncedLoadProjectFacts = debouncedLoadProjectFacts;
|
||||
window.debouncedLoadProjectVulnerabilities = debouncedLoadProjectVulnerabilities;
|
||||
window.loadProjectVulnerabilities = loadProjectVulnerabilities;
|
||||
window.linkFactToExistingVulnerability = linkFactToExistingVulnerability;
|
||||
window.createVulnerabilityFromCurrentFact = createVulnerabilityFromCurrentFact;
|
||||
window.viewFactsForVulnerability = viewFactsForVulnerability;
|
||||
|
||||
+127
-6
@@ -40,7 +40,7 @@ function syncRobotAgentModeSelectOptions(multiEnabled) {
|
||||
if (opt) opt.disabled = !multiEnabled;
|
||||
});
|
||||
if (!multiEnabled && ['deep', 'plan_execute', 'supervisor'].indexOf(sel.value) >= 0) {
|
||||
sel.value = 'react';
|
||||
sel.value = 'eino_single';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +197,8 @@ async function loadConfig(loadTools = true) {
|
||||
orAllowEl.checked = orm.allow_client_reasoning !== false;
|
||||
}
|
||||
|
||||
fillVisionConfigFromCurrent(currentConfig.vision || {});
|
||||
|
||||
// 填充FOFA配置
|
||||
const fofa = currentConfig.fofa || {};
|
||||
const fofaEmailEl = document.getElementById('fofa-email');
|
||||
@@ -227,8 +229,7 @@ async function loadConfig(loadTools = true) {
|
||||
}
|
||||
const maRobotMode = document.getElementById('multi-agent-robot-mode');
|
||||
if (maRobotMode) {
|
||||
let mode = (ma.robot_default_agent_mode || 'react').trim().toLowerCase();
|
||||
if (mode === 'single') mode = 'react';
|
||||
let mode = (ma.robot_default_agent_mode || 'eino_single').trim().toLowerCase();
|
||||
maRobotMode.value = mode;
|
||||
syncRobotAgentModeSelectOptions(ma.enabled === true);
|
||||
}
|
||||
@@ -1075,6 +1076,14 @@ async function applySettings() {
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const visionPayload = collectVisionConfigFromForm();
|
||||
if (visionPayload.enabled && !visionPayload.model) {
|
||||
const vm = document.getElementById('vision-model');
|
||||
if (vm) vm.classList.add('error');
|
||||
alert((typeof window.t === 'function') ? window.t('settingsBasic.visionModelRequired') : '启用视觉分析时请填写视觉模型名称');
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集配置
|
||||
const knowledgeEnabledCheckbox = document.getElementById('knowledge-enabled');
|
||||
@@ -1147,6 +1156,7 @@ async function applySettings() {
|
||||
allow_client_reasoning: document.getElementById('openai-reasoning-allow-client')?.checked !== false
|
||||
}
|
||||
},
|
||||
vision: visionPayload,
|
||||
fofa: {
|
||||
email: document.getElementById('fofa-email')?.value.trim() || '',
|
||||
api_key: document.getElementById('fofa-api-key')?.value.trim() || '',
|
||||
@@ -1160,9 +1170,9 @@ async function applySettings() {
|
||||
const peParsed = parseInt(peRaw, 10);
|
||||
const peLoop = Number.isNaN(peParsed) ? 0 : Math.max(0, peParsed);
|
||||
const maEnabled = document.getElementById('multi-agent-enabled')?.checked === true;
|
||||
let robotMode = document.getElementById('multi-agent-robot-mode')?.value || 'react';
|
||||
let robotMode = document.getElementById('multi-agent-robot-mode')?.value || 'eino_single';
|
||||
if (!maEnabled && ['deep', 'plan_execute', 'supervisor'].indexOf(robotMode) >= 0) {
|
||||
robotMode = 'react';
|
||||
robotMode = 'eino_single';
|
||||
}
|
||||
return {
|
||||
enabled: maEnabled,
|
||||
@@ -1342,6 +1352,117 @@ async function applySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function fillVisionConfigFromCurrent(v) {
|
||||
const en = document.getElementById('vision-enabled');
|
||||
if (en) en.checked = v.enabled === true;
|
||||
const prov = document.getElementById('vision-provider');
|
||||
if (prov) prov.value = (v.provider || '').trim();
|
||||
const setVal = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = val != null && val !== '' ? String(val) : '';
|
||||
};
|
||||
setVal('vision-api-key', v.api_key || '');
|
||||
setVal('vision-base-url', v.base_url || '');
|
||||
setVal('vision-model', v.model || '');
|
||||
setVal('vision-max-image-bytes', v.max_image_bytes || 5242880);
|
||||
setVal('vision-max-dimension', v.max_dimension || 2048);
|
||||
setVal('vision-jpeg-quality', v.jpeg_quality || 82);
|
||||
setVal('vision-max-payload-bytes', v.max_payload_bytes || 524288);
|
||||
setVal('vision-skip-preprocess-bytes', v.skip_preprocess_below_bytes != null ? v.skip_preprocess_below_bytes : 2097152);
|
||||
setVal('vision-timeout-seconds', v.timeout_seconds || 60);
|
||||
const det = document.getElementById('vision-detail');
|
||||
if (det) {
|
||||
const d = (v.detail || 'low').toString().toLowerCase();
|
||||
det.value = ['low', 'auto', 'high'].includes(d) ? d : 'low';
|
||||
}
|
||||
const rootsEl = document.getElementById('vision-allowed-roots');
|
||||
if (rootsEl) {
|
||||
const roots = Array.isArray(v.allowed_roots) ? v.allowed_roots : [];
|
||||
rootsEl.value = roots.join('\n');
|
||||
}
|
||||
syncVisionFormEnabled();
|
||||
}
|
||||
|
||||
function collectVisionConfigFromForm() {
|
||||
const parseIntOr = (id, fallback) => {
|
||||
const n = parseInt(document.getElementById(id)?.value, 10);
|
||||
return Number.isNaN(n) ? fallback : n;
|
||||
};
|
||||
const rootsRaw = document.getElementById('vision-allowed-roots')?.value || '';
|
||||
const allowed_roots = rootsRaw.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
||||
const provider = document.getElementById('vision-provider')?.value.trim() || '';
|
||||
return {
|
||||
enabled: document.getElementById('vision-enabled')?.checked === true,
|
||||
api_key: document.getElementById('vision-api-key')?.value.trim() || '',
|
||||
base_url: document.getElementById('vision-base-url')?.value.trim() || '',
|
||||
model: document.getElementById('vision-model')?.value.trim() || '',
|
||||
provider: provider,
|
||||
timeout_seconds: parseIntOr('vision-timeout-seconds', 60),
|
||||
max_image_bytes: parseIntOr('vision-max-image-bytes', 5242880),
|
||||
max_dimension: parseIntOr('vision-max-dimension', 2048),
|
||||
jpeg_quality: parseIntOr('vision-jpeg-quality', 82),
|
||||
max_payload_bytes: parseIntOr('vision-max-payload-bytes', 524288),
|
||||
skip_preprocess_below_bytes: parseIntOr('vision-skip-preprocess-bytes', 2097152),
|
||||
detail: document.getElementById('vision-detail')?.value || 'low',
|
||||
allowed_roots: allowed_roots
|
||||
};
|
||||
}
|
||||
|
||||
function syncVisionFormEnabled() {
|
||||
const enabled = document.getElementById('vision-enabled')?.checked === true;
|
||||
const panel = document.getElementById('vision-fields-panel');
|
||||
if (panel) {
|
||||
panel.style.opacity = enabled ? '1' : '0.55';
|
||||
panel.querySelectorAll('input, select, textarea, a').forEach(el => {
|
||||
if (el.id === 'test-vision-btn') return;
|
||||
el.disabled = !enabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function testVisionConnection() {
|
||||
const resultEl = document.getElementById('test-vision-result');
|
||||
const vision = collectVisionConfigFromForm();
|
||||
const openai = {
|
||||
provider: document.getElementById('openai-provider')?.value || 'openai',
|
||||
api_key: document.getElementById('openai-api-key')?.value.trim() || '',
|
||||
base_url: document.getElementById('openai-base-url')?.value.trim() || '',
|
||||
model: document.getElementById('openai-model')?.value.trim() || ''
|
||||
};
|
||||
const apiKey = vision.api_key || openai.api_key;
|
||||
const model = vision.model;
|
||||
if (!apiKey || !model) {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.visionTestFillRequired') : '请填写视觉模型,并确保 API Key 可用';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (resultEl) {
|
||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testing') : '测试中...';
|
||||
resultEl.style.color = '';
|
||||
}
|
||||
try {
|
||||
const response = await apiFetch('/api/config/test-vision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vision: vision, openai: openai })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const latency = result.latency_ms != null ? ` (${result.latency_ms}ms)` : '';
|
||||
const modelInfo = result.model ? ` [${result.model}]` : '';
|
||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency;
|
||||
resultEl.style.color = 'var(--success-color, #38a169)';
|
||||
} else {
|
||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ' + (result.error || '未知错误');
|
||||
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||
}
|
||||
} catch (error) {
|
||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testError') : '测试出错') + ': ' + error.message;
|
||||
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||
}
|
||||
}
|
||||
|
||||
// 测试OpenAI连接
|
||||
async function testOpenAIConnection() {
|
||||
const btn = document.getElementById('test-openai-btn');
|
||||
@@ -1415,7 +1536,7 @@ async function saveToolsConfig() {
|
||||
agent: currentConfig.agent || {},
|
||||
multi_agent: {
|
||||
enabled: currentConfig?.multi_agent?.enabled === true,
|
||||
robot_default_agent_mode: currentConfig?.multi_agent?.robot_default_agent_mode || 'react',
|
||||
robot_default_agent_mode: currentConfig?.multi_agent?.robot_default_agent_mode || 'eino_single',
|
||||
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
|
||||
plan_execute_loop_max_iterations: Number(currentConfig?.multi_agent?.plan_execute_loop_max_iterations || 0),
|
||||
tool_search_always_visible_tools: Array.from(alwaysVisibleToolNames).filter(name => !alwaysVisibleBuiltinToolNames.has(name))
|
||||
|
||||
+11
-14
@@ -15,7 +15,7 @@ function _tPlain(key, opts) {
|
||||
}
|
||||
|
||||
/** 与创建队列 / API 一致的合法 agentMode */
|
||||
const BATCH_QUEUE_AGENT_MODES = ['single', 'eino_single', 'deep', 'plan_execute', 'supervisor'];
|
||||
const BATCH_QUEUE_AGENT_MODES = ['eino_single', 'deep', 'plan_execute', 'supervisor'];
|
||||
|
||||
function isBatchQueueAgentMode(mode) {
|
||||
return BATCH_QUEUE_AGENT_MODES.indexOf(String(mode || '').toLowerCase()) >= 0;
|
||||
@@ -23,13 +23,12 @@ function isBatchQueueAgentMode(mode) {
|
||||
|
||||
/** 批量队列 agentMode 展示文案(与对话模式命名一致) */
|
||||
function batchQueueAgentModeLabel(mode) {
|
||||
const m = String(mode || 'single').toLowerCase();
|
||||
if (m === 'single') return _t('chat.agentModeReactNative');
|
||||
const m = String(mode || 'eino_single').toLowerCase();
|
||||
if (m === 'eino_single') return _t('chat.agentModeEinoSingle');
|
||||
if (m === 'multi' || m === 'deep') return _t('chat.agentModeDeep');
|
||||
if (m === 'deep') return _t('chat.agentModeDeep');
|
||||
if (m === 'plan_execute') return _t('chat.agentModePlanExecuteLabel');
|
||||
if (m === 'supervisor') return _t('chat.agentModeSupervisorLabel');
|
||||
return _t('chat.agentModeReactNative');
|
||||
return _t('chat.agentModeEinoSingle');
|
||||
}
|
||||
|
||||
/** Cron 队列在「本轮 completed」等状态下的展示文案(底层 status 不变,仅 UI 强调循环调度) */
|
||||
@@ -867,7 +866,7 @@ async function showBatchImportModal() {
|
||||
projectSelect.value = '';
|
||||
}
|
||||
if (agentModeSelect) {
|
||||
agentModeSelect.value = 'single';
|
||||
agentModeSelect.value = 'eino_single';
|
||||
}
|
||||
if (scheduleModeSelect) {
|
||||
scheduleModeSelect.value = 'manual';
|
||||
@@ -997,8 +996,8 @@ async function createBatchQueue() {
|
||||
// 获取角色(可选,空字符串表示默认角色)
|
||||
const role = roleSelect ? roleSelect.value || '' : '';
|
||||
const projectId = projectSelect ? (projectSelect.value || '').trim() : '';
|
||||
const rawMode = agentModeSelect ? agentModeSelect.value : 'single';
|
||||
const agentMode = isBatchQueueAgentMode(rawMode) ? rawMode : 'single';
|
||||
const rawMode = agentModeSelect ? agentModeSelect.value : 'eino_single';
|
||||
const agentMode = isBatchQueueAgentMode(rawMode) ? rawMode : 'eino_single';
|
||||
const scheduleMode = scheduleModeSelect ? (scheduleModeSelect.value === 'cron' ? 'cron' : 'manual') : 'manual';
|
||||
const cronExpr = cronExprInput ? cronExprInput.value.trim() : '';
|
||||
const executeNow = executeNowCheckbox ? !!executeNowCheckbox.checked : false;
|
||||
@@ -2217,12 +2216,10 @@ function startInlineEditAgentMode() {
|
||||
if (!queueId) return;
|
||||
apiFetch(`/api/batch-tasks/${queueId}`).then(r => r.json()).then(detail => {
|
||||
const queue = detail.queue;
|
||||
let currentMode = (queue.agentMode || 'single').toLowerCase();
|
||||
if (currentMode === 'multi') currentMode = 'deep';
|
||||
if (!isBatchQueueAgentMode(currentMode)) currentMode = 'single';
|
||||
let currentMode = (queue.agentMode || 'eino_single').toLowerCase();
|
||||
if (!isBatchQueueAgentMode(currentMode)) currentMode = 'eino_single';
|
||||
container.innerHTML = `<span class="bq-inline-edit-controls">
|
||||
<select id="bq-edit-agentmode">
|
||||
<option value="single" ${currentMode === 'single' ? 'selected' : ''}>${escapeHtml(_t('chat.agentModeReactNative'))}</option>
|
||||
<option value="eino_single" ${currentMode === 'eino_single' ? 'selected' : ''}>${escapeHtml(_t('chat.agentModeEinoSingle'))}</option>
|
||||
<option value="deep" ${currentMode === 'deep' ? 'selected' : ''}>${escapeHtml(_t('chat.agentModeDeep'))}</option>
|
||||
<option value="plan_execute" ${currentMode === 'plan_execute' ? 'selected' : ''}>${escapeHtml(_t('chat.agentModePlanExecuteLabel'))}</option>
|
||||
@@ -2247,8 +2244,8 @@ async function saveInlineAgentMode() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) { _bqInlineSaving = false; return; }
|
||||
const sel = document.getElementById('bq-edit-agentmode');
|
||||
const raw = sel ? sel.value : 'single';
|
||||
const agentMode = isBatchQueueAgentMode(raw) ? raw : 'single';
|
||||
const raw = sel ? sel.value : 'eino_single';
|
||||
const agentMode = isBatchQueueAgentMode(raw) ? raw : 'eino_single';
|
||||
try {
|
||||
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||||
const detail = await detailResp.json();
|
||||
|
||||
+18
-21
@@ -154,33 +154,26 @@ function applyWebshellDetectedOS(conn, data) {
|
||||
/** 与主对话页一致:Eino 模式走 /api/multi-agent/stream,body 带 orchestration */
|
||||
function resolveWebshellAiStreamRequest() {
|
||||
if (typeof apiFetch === 'undefined') {
|
||||
return Promise.resolve({ path: '/api/agent-loop/stream', orchestration: null });
|
||||
return Promise.resolve({ path: '/api/eino-agent/stream', orchestration: null });
|
||||
}
|
||||
return apiFetch('/api/config').then(function (r) {
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
}).then(function (cfg) {
|
||||
var norm = null;
|
||||
var norm = 'eino_single';
|
||||
if (typeof window.csaiChatAgentMode === 'object' && typeof window.csaiChatAgentMode.normalizeStored === 'function') {
|
||||
norm = window.csaiChatAgentMode.normalizeStored(localStorage.getItem('cyberstrike-chat-agent-mode'), cfg);
|
||||
} else {
|
||||
var mode = localStorage.getItem('cyberstrike-chat-agent-mode');
|
||||
if (mode === 'single') mode = 'react';
|
||||
if (mode === 'multi') mode = 'deep';
|
||||
norm = mode || 'react';
|
||||
norm = (mode && (mode === 'eino_single' || mode === 'deep' || mode === 'plan_execute' || mode === 'supervisor')) ? mode : 'eino_single';
|
||||
}
|
||||
if (typeof window.csaiChatAgentMode === 'object' && typeof window.csaiChatAgentMode.isEinoSingle === 'function' && window.csaiChatAgentMode.isEinoSingle(norm)) {
|
||||
return { path: '/api/eino-agent/stream', orchestration: null };
|
||||
}
|
||||
if (!cfg || !cfg.multi_agent || !cfg.multi_agent.enabled) {
|
||||
return { path: '/api/agent-loop/stream', orchestration: null };
|
||||
}
|
||||
if (typeof window.csaiChatAgentMode === 'object' && typeof window.csaiChatAgentMode.isEino === 'function' && window.csaiChatAgentMode.isEino(norm)) {
|
||||
if (cfg && cfg.multi_agent && cfg.multi_agent.enabled &&
|
||||
typeof window.csaiChatAgentMode === 'object' && typeof window.csaiChatAgentMode.isEino === 'function' && window.csaiChatAgentMode.isEino(norm)) {
|
||||
return { path: '/api/multi-agent/stream', orchestration: norm };
|
||||
}
|
||||
return { path: '/api/agent-loop/stream', orchestration: null };
|
||||
return { path: '/api/eino-agent/stream', orchestration: null };
|
||||
}).catch(function () {
|
||||
return { path: '/api/agent-loop/stream', orchestration: null };
|
||||
return { path: '/api/eino-agent/stream', orchestration: null };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -304,15 +297,17 @@ function wsInitAgentMode() {
|
||||
if (typeof window.csaiChatAgentMode === 'object' && typeof window.csaiChatAgentMode.normalizeStored === 'function') {
|
||||
norm = window.csaiChatAgentMode.normalizeStored(stored, cfg);
|
||||
} else {
|
||||
norm = stored || 'react';
|
||||
if (norm === 'single') norm = 'react';
|
||||
norm = stored || 'eino_single';
|
||||
if (norm !== 'eino_single' && norm !== 'deep' && norm !== 'plan_execute' && norm !== 'supervisor') {
|
||||
norm = 'eino_single';
|
||||
}
|
||||
if (norm === 'multi') norm = 'deep';
|
||||
}
|
||||
wsSyncAgentMode(norm);
|
||||
}).catch(function () {
|
||||
var wrapper = document.getElementById('ws-agent-mode-wrapper');
|
||||
if (wrapper) wrapper.style.display = '';
|
||||
wsSyncAgentMode('react');
|
||||
wsSyncAgentMode('eino_single');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -356,7 +351,10 @@ function wsCloseAgentModePanel() {
|
||||
function wsRefreshSelectors() {
|
||||
wsUpdateRoleSelectorDisplay();
|
||||
wsRenderRoleList();
|
||||
var stored = localStorage.getItem('cyberstrike-chat-agent-mode') || 'react';
|
||||
var stored = localStorage.getItem('cyberstrike-chat-agent-mode') || 'eino_single';
|
||||
if (stored !== 'eino_single' && stored !== 'deep' && stored !== 'plan_execute' && stored !== 'supervisor') {
|
||||
stored = 'eino_single';
|
||||
}
|
||||
wsSyncAgentMode(stored);
|
||||
}
|
||||
|
||||
@@ -2020,7 +2018,7 @@ function selectWebshell(id, stateReady) {
|
||||
'<div class="agent-mode-inner">' +
|
||||
'<button type="button" class="role-selector-btn agent-mode-btn" id="ws-agent-mode-btn" onclick="wsToggleAgentModePanel()">' +
|
||||
'<span id="ws-agent-mode-icon" class="role-selector-icon">\ud83e\udd16</span>' +
|
||||
'<span id="ws-agent-mode-text" class="role-selector-text">' + (wsT('chat.agentModeReactNative') || '原生 ReAct') + '</span>' +
|
||||
'<span id="ws-agent-mode-text" class="role-selector-text">' + (wsT('chat.agentModeEinoSingle') || 'Eino 单代理') + '</span>' +
|
||||
'<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
|
||||
'</button>' +
|
||||
'<div id="ws-agent-mode-panel" class="agent-mode-panel" style="display:none;" role="listbox">' +
|
||||
@@ -2028,13 +2026,12 @@ function selectWebshell(id, stateReady) {
|
||||
'<button type="button" class="role-selection-panel-close" onclick="wsCloseAgentModePanel()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
||||
'</div>' +
|
||||
'<div class="agent-mode-options">' +
|
||||
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="react" role="option" onclick="wsSelectAgentMode(\'react\')"><div class="role-selection-item-icon-main">\ud83e\udd16</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeReactNative') || '原生 ReAct 模式') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeReactNativeHint') || '经典单代理 ReAct 与 MCP 工具') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="react">\u2713</div></button>' +
|
||||
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="eino_single" role="option" onclick="wsSelectAgentMode(\'eino_single\')"><div class="role-selection-item-icon-main">\u26a1</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeEinoSingle') || 'Eino 单代理(ADK)') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeEinoSingleHint') || 'Eino ChatModelAgent + Runner') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single">\u2713</div></button>' +
|
||||
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="deep" role="option" onclick="wsSelectAgentMode(\'deep\')"><div class="role-selection-item-icon-main">\ud83e\udde9</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeDeep') || 'Deep(DeepAgent)') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeDeepHint') || 'Eino DeepAgent,task 调度子代理') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep">\u2713</div></button>' +
|
||||
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="plan_execute" role="option" onclick="wsSelectAgentMode(\'plan_execute\')"><div class="role-selection-item-icon-main">\ud83d\udccb</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModePlanExecuteLabel') || 'Plan-Execute') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModePlanExecuteHint') || '规划 → 执行 → 重规划') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute">\u2713</div></button>' +
|
||||
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="supervisor" role="option" onclick="wsSelectAgentMode(\'supervisor\')"><div class="role-selection-item-icon-main">\ud83c\udfaf</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeSupervisorLabel') || 'Supervisor') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeSupervisorHint') || '监督者协调,transfer 委派子代理') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="supervisor">\u2713</div></button>' +
|
||||
'</div></div></div>' +
|
||||
'<input type="hidden" id="ws-agent-mode-select" value="react" autocomplete="off" />' +
|
||||
'<input type="hidden" id="ws-agent-mode-select" value="eino_single" autocomplete="off" />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="webshell-ai-input-row">' +
|
||||
|
||||
+170
-25
@@ -14,7 +14,13 @@
|
||||
<div id="login-overlay" class="login-overlay" style="display: none;">
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<h2 data-i18n="login.title">登录 CyberStrikeAI</h2>
|
||||
<img src="/static/logo.png" alt="" class="login-brand-logo" width="56" height="56">
|
||||
<h2 class="login-title">
|
||||
<span data-i18n="login.titlePrefix">登录</span>
|
||||
<span class="brand-wordmark brand-wordmark--sm" aria-label="CyberStrikeAI">
|
||||
<span class="brand-wordmark__core">CyberStrike</span><span class="brand-wordmark__ai">AI</span>
|
||||
</span>
|
||||
</h2>
|
||||
<p class="login-subtitle" data-i18n="login.subtitle">请输入配置中的访问密码</p>
|
||||
</div>
|
||||
<form id="login-form" class="login-form">
|
||||
@@ -34,8 +40,10 @@
|
||||
<header>
|
||||
<div class="header-content">
|
||||
<div class="logo header-logo-link" onclick="switchPage('dashboard')" role="button" data-i18n="header.backToDashboard" data-i18n-attr="title" data-i18n-skip-text="true" title="返回仪表盘">
|
||||
<img src="/static/logo.png" alt="CyberStrikeAI Logo" style="width: 32px; height: 32px; margin-right: 8px;">
|
||||
<h1>CyberStrikeAI</h1>
|
||||
<img src="/static/logo.png" alt="CyberStrikeAI Logo" class="brand-logo" width="36" height="36">
|
||||
<h1 class="brand-wordmark brand-wordmark--lg">
|
||||
<span class="brand-wordmark__core">CyberStrike</span><span class="brand-wordmark__ai">AI</span>
|
||||
</h1>
|
||||
<span class="version-badge" data-i18n="header.version" data-i18n-attr="title" data-i18n-skip-text="true" title="当前版本">{{.Version}}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@@ -1023,14 +1031,6 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="agent-mode-options">
|
||||
<button type="button" class="role-selection-item-main agent-mode-option" data-value="react" role="option" onclick="selectAgentMode('react')">
|
||||
<div class="role-selection-item-icon-main" aria-hidden="true">🤖</div>
|
||||
<div class="role-selection-item-content-main">
|
||||
<div class="role-selection-item-name-main" data-i18n="chat.agentModeReactNative">原生 ReAct 模式</div>
|
||||
<div class="role-selection-item-description-main" data-i18n="chat.agentModeReactNativeHint">经典单代理 ReAct 与 MCP 工具(/api/agent-loop)</div>
|
||||
</div>
|
||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="react">✓</div>
|
||||
</button>
|
||||
<button type="button" class="role-selection-item-main agent-mode-option" data-value="eino_single" role="option" onclick="selectAgentMode('eino_single')">
|
||||
<div class="role-selection-item-icon-main" aria-hidden="true">⚡</div>
|
||||
<div class="role-selection-item-content-main">
|
||||
@@ -1066,7 +1066,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="agent-mode-select" value="react" autocomplete="off">
|
||||
<input type="hidden" id="agent-mode-select" value="eino_single" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-with-files">
|
||||
@@ -1539,8 +1539,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden>
|
||||
<div class="projects-panel-toolbar">
|
||||
<span class="projects-panel-hint" data-i18n="projects.boundConversationsHint">绑定到本项目的对话;点击可打开会话</span>
|
||||
<div class="projects-panel-toolbar projects-panel-toolbar--hint">
|
||||
<p class="projects-fact-toolbar-hint" role="note">
|
||||
<svg class="projects-fact-toolbar-hint-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 10v6M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span data-i18n="projects.boundConversationsHint">绑定到本项目的对话;点击可打开会话</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="projects-table-wrap">
|
||||
<table class="data-table data-table--projects">
|
||||
@@ -1550,9 +1556,48 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="project-panel-vulns" class="projects-panel" role="tabpanel" hidden>
|
||||
<div class="projects-panel-toolbar">
|
||||
<span class="projects-panel-hint" data-i18n="projects.projectVulnSummaryHint">本项目下记录的漏洞汇总</span>
|
||||
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.viewInVulnerabilityManagement">在漏洞管理中查看</button>
|
||||
<div class="projects-fact-toolbar">
|
||||
<div class="projects-vuln-toolbar-top">
|
||||
<p class="projects-fact-toolbar-hint" role="note">
|
||||
<svg class="projects-fact-toolbar-hint-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 10v6M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span data-i18n="projects.projectVulnSummaryHint">本项目下记录的漏洞汇总</span>
|
||||
</p>
|
||||
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.viewInVulnerabilityManagement">在漏洞管理中查看</button>
|
||||
</div>
|
||||
<div class="projects-fact-toolbar-filters" role="search">
|
||||
<label class="projects-fact-filter-field projects-fact-filter-field--search">
|
||||
<span class="sr-only" data-i18n="projects.searchVulnsSr">搜索漏洞</span>
|
||||
<svg class="projects-fact-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M20 20L16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input type="search" id="project-vulns-search" placeholder="搜索标题、描述、类型、目标…" oninput="debouncedLoadProjectVulnerabilities()" autocomplete="off" data-i18n="projects.searchVulnsPlaceholder" data-i18n-attr="placeholder">
|
||||
</label>
|
||||
<label class="projects-fact-filter-field">
|
||||
<span class="projects-fact-filter-label" data-i18n="projects.severity">严重度</span>
|
||||
<select id="project-vulns-filter-severity" onchange="loadProjectVulnerabilities()">
|
||||
<option value="" data-i18n="projects.all">全部</option>
|
||||
<option value="critical">critical</option>
|
||||
<option value="high">high</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="low">low</option>
|
||||
<option value="info">info</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="projects-fact-filter-field">
|
||||
<span class="projects-fact-filter-label" data-i18n="projects.status">状态</span>
|
||||
<select id="project-vulns-filter-status" onchange="loadProjectVulnerabilities()">
|
||||
<option value="" data-i18n="projects.all">全部</option>
|
||||
<option value="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option>
|
||||
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
||||
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
|
||||
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-table-wrap">
|
||||
<table class="data-table data-table--projects">
|
||||
@@ -2430,6 +2475,89 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vision 视觉分析 -->
|
||||
<div class="settings-subsection">
|
||||
<h4 data-i18n="settingsBasic.visionConfig">视觉分析(analyze_image)</h4>
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="vision-enabled" class="modern-checkbox" onchange="syncVisionFormEnabled()" />
|
||||
<span class="checkbox-custom"></span>
|
||||
<span class="checkbox-text" data-i18n="settingsBasic.visionEnabled">启用视觉分析工具 analyze_image</span>
|
||||
</label>
|
||||
<small class="form-hint" data-i18n="settingsBasic.visionEnabledHint">启用后注册 MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要。</small>
|
||||
</div>
|
||||
<div id="vision-fields-panel">
|
||||
<div class="form-group">
|
||||
<label for="vision-provider" data-i18n="settingsBasic.provider">提供商</label>
|
||||
<select id="vision-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
|
||||
<option value="">OpenAI 配置(留空复用)</option>
|
||||
<option value="openai">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vision-base-url" data-i18n="settingsBasic.baseUrl">Base URL</label>
|
||||
<input type="text" id="vision-base-url" data-i18n="settingsBasic.visionBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="留空则复用 OpenAI Base URL" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vision-api-key" data-i18n="settingsBasic.apiKey">API Key</label>
|
||||
<input type="password" id="vision-api-key" data-i18n="settingsBasic.visionApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="留空则复用 OpenAI API Key" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vision-model"><span data-i18n="settingsBasic.visionModel">视觉模型</span> <span style="color: red;">*</span></label>
|
||||
<input type="text" id="vision-model" data-i18n="settingsBasic.visionModelPlaceholder" data-i18n-attr="placeholder" placeholder="qwen-vl-max" />
|
||||
</div>
|
||||
<details style="margin-top: 8px;">
|
||||
<summary style="cursor: pointer; font-size: 0.875rem; color: var(--accent-color, #3182ce);" data-i18n="settingsBasic.visionAdvanced">高级:预处理与限制</summary>
|
||||
<div style="margin-top: 12px;">
|
||||
<div class="form-group">
|
||||
<label for="vision-max-image-bytes" data-i18n="settingsBasic.visionMaxImageBytes">原始文件上限(字节)</label>
|
||||
<input type="number" id="vision-max-image-bytes" min="0" step="1024" placeholder="5242880" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vision-max-dimension" data-i18n="settingsBasic.visionMaxDimension">长边缩放像素</label>
|
||||
<input type="number" id="vision-max-dimension" min="256" step="1" placeholder="2048" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vision-jpeg-quality" data-i18n="settingsBasic.visionJpegQuality">JPEG 质量</label>
|
||||
<input type="number" id="vision-jpeg-quality" min="60" max="100" step="1" placeholder="82" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vision-max-payload-bytes" data-i18n="settingsBasic.visionMaxPayloadBytes">送 API 体积上限(字节)</label>
|
||||
<input type="number" id="vision-max-payload-bytes" min="0" step="1024" placeholder="524288" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vision-skip-preprocess-bytes" data-i18n="settingsBasic.visionSkipPreprocessBytes">低于该字节可原图直传</label>
|
||||
<input type="number" id="vision-skip-preprocess-bytes" min="0" step="1024" placeholder="2097152" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.visionSkipPreprocessHint">0 表示始终 JPEG 压缩;需同时满足长边与 payload 限制。</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vision-detail" data-i18n="settingsBasic.visionDetail">Image detail</label>
|
||||
<select id="vision-detail" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px;">
|
||||
<option value="low">low</option>
|
||||
<option value="auto">auto</option>
|
||||
<option value="high">high</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vision-timeout-seconds" data-i18n="settingsBasic.visionTimeout">超时(秒)</label>
|
||||
<input type="number" id="vision-timeout-seconds" min="5" step="1" placeholder="60" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vision-allowed-roots" data-i18n="settingsBasic.visionAllowedRoots">额外允许路径根目录</label>
|
||||
<textarea id="vision-allowed-roots" rows="2" data-i18n="settingsBasic.visionAllowedRootsPlaceholder" data-i18n-attr="placeholder" placeholder="每行一个绝对路径,可选"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||||
<a href="javascript:void(0)" id="test-vision-btn" onclick="testVisionConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer;" data-i18n="settingsBasic.testConnection">测试连接</a>
|
||||
<span id="test-vision-result" style="font-size: 0.8125rem;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent配置 -->
|
||||
<div class="settings-subsection">
|
||||
<h4 data-i18n="settingsBasic.agentConfig">Agent 配置</h4>
|
||||
@@ -2454,7 +2582,6 @@
|
||||
<div class="form-group">
|
||||
<label for="multi-agent-robot-mode" data-i18n="settingsBasic.multiAgentRobotMode">机器人默认对话模式</label>
|
||||
<select id="multi-agent-robot-mode" class="form-select">
|
||||
<option value="react" data-i18n="chat.agentModeReactNative">原生 ReAct</option>
|
||||
<option value="eino_single" data-i18n="chat.agentModeEinoSingle">Eino 单代理(ADK)</option>
|
||||
<option value="deep" data-i18n="chat.agentModeDeep">Deep(DeepAgent)</option>
|
||||
<option value="plan_execute" data-i18n="chat.agentModePlanExecuteLabel">Plan-Execute</option>
|
||||
@@ -2846,20 +2973,39 @@
|
||||
<div class="settings-subsection">
|
||||
<h4 data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h4>
|
||||
<p class="settings-description" data-i18n="settingsRobotsExtra.botCommandsDesc">在对话中可发送以下命令(支持中英文):</p>
|
||||
<ul style="color: var(--text-muted); font-size: 13px; line-height: 1.8; margin: 8px 0 0 16px;">
|
||||
|
||||
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryGeneral">通用</p>
|
||||
<ul class="robot-cmd-list">
|
||||
<li><code>帮助</code> <code>help</code> — <span data-i18n="settingsRobotsExtra.botCmdHelp">显示本帮助 | Show this help</span></li>
|
||||
<li><code>版本</code> <code>version</code> — <span data-i18n="settingsRobotsExtra.botCmdVersion">显示当前版本号 | Show version</span></li>
|
||||
</ul>
|
||||
|
||||
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryConversation">对话</p>
|
||||
<ul class="robot-cmd-list">
|
||||
<li><code>列表</code> <code>list</code> — <span data-i18n="settingsRobotsExtra.botCmdList">列出所有对话标题与 ID | List conversations</span></li>
|
||||
<li><code>切换 <ID></code> <code>switch <ID></code> — <span data-i18n="settingsRobotsExtra.botCmdSwitch">指定对话继续 | Switch to conversation</span></li>
|
||||
<li><code>新对话</code> <code>new</code> — <span data-i18n="settingsRobotsExtra.botCmdNew">开启新对话 | Start new conversation</span></li>
|
||||
<li><code>清空</code> <code>clear</code> — <span data-i18n="settingsRobotsExtra.botCmdClear">清空当前上下文 | Clear context</span></li>
|
||||
<li><code>当前</code> <code>current</code> — <span data-i18n="settingsRobotsExtra.botCmdCurrent">显示当前对话 ID 与标题 | Show current conversation</span></li>
|
||||
<li><code>当前</code> <code>current</code> — <span data-i18n="settingsRobotsExtra.botCmdCurrent">显示当前对话、角色与项目 | Show current conversation</span></li>
|
||||
<li><code>停止</code> <code>stop</code> — <span data-i18n="settingsRobotsExtra.botCmdStop">中断当前任务 | Stop running task</span></li>
|
||||
<li><code>删除 <ID></code> <code>delete <ID></code> — <span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话 | Delete conversation</span></li>
|
||||
</ul>
|
||||
|
||||
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryRole">角色</p>
|
||||
<ul class="robot-cmd-list">
|
||||
<li><code>角色</code> <code>roles</code> — <span data-i18n="settingsRobotsExtra.botCmdRoles">列出所有可用角色 | List roles</span></li>
|
||||
<li><code>角色 <名></code> <code>role <name></code> — <span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li>
|
||||
<li><code>删除 <ID></code> <code>delete <ID></code> — <span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话 | Delete conversation</span></li>
|
||||
<li><code>版本</code> <code>version</code> — <span data-i18n="settingsRobotsExtra.botCmdVersion">显示当前版本号 | Show version</span></li>
|
||||
</ul>
|
||||
<p class="settings-description" style="margin-top: 8px;" data-i18n="settingsRobotsExtra.botCommandsFooter">除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis.</p>
|
||||
|
||||
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryProject">项目</p>
|
||||
<ul class="robot-cmd-list">
|
||||
<li><code>项目</code> <code>projects</code> — <span data-i18n="settingsRobotsExtra.botCmdProjects">列出所有项目 | List projects</span></li>
|
||||
<li><code>新建项目 <名称></code> <code>new project <name></code> — <span data-i18n="settingsRobotsExtra.botCmdNewProject">创建项目并绑定当前对话 | Create & bind project</span></li>
|
||||
<li><code>绑定项目 <ID或名称></code> <code>bind project <ID|name></code> — <span data-i18n="settingsRobotsExtra.botCmdBindProject">将当前对话绑定到项目 | Bind conversation</span></li>
|
||||
<li><code>解除项目</code> <code>unbind project</code> — <span data-i18n="settingsRobotsExtra.botCmdUnbindProject">解除当前对话的项目绑定 | Unbind project</span></li>
|
||||
</ul>
|
||||
|
||||
<p class="settings-description robot-cmd-footer" data-i18n="settingsRobotsExtra.botCommandsFooter">除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
@@ -3688,13 +3834,12 @@
|
||||
<div class="form-group">
|
||||
<label for="batch-queue-agent-mode" data-i18n="batchImportModal.agentMode">代理模式</label>
|
||||
<select id="batch-queue-agent-mode" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem;">
|
||||
<option value="single" data-i18n="chat.agentModeReactNative">原生 ReAct 模式</option>
|
||||
<option value="eino_single" data-i18n="chat.agentModeEinoSingle">Eino 单代理(ADK)</option>
|
||||
<option value="deep" data-i18n="chat.agentModeDeep">Deep(DeepAgent)</option>
|
||||
<option value="plan_execute" data-i18n="chat.agentModePlanExecuteLabel">Plan-Execute</option>
|
||||
<option value="supervisor" data-i18n="chat.agentModeSupervisorLabel">Supervisor</option>
|
||||
</select>
|
||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.agentModeHint">与对话页一致:原生 ReAct、Eino 单代理(ADK),或 Deep / Plan-Execute / Supervisor(后三种需已启用多代理)。</div>
|
||||
<div class="form-hint" style="margin-top: 4px;" data-i18n="batchImportModal.agentModeHint">与对话页一致:Eino 单代理(ADK),或 Deep / Plan-Execute / Supervisor(后三种需已启用多代理)。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="batch-queue-schedule-mode" data-i18n="batchImportModal.scheduleMode">调度方式</label>
|
||||
|
||||
Reference in New Issue
Block a user