Compare commits

...

75 Commits

Author SHA1 Message Date
公明 e5d52cdf85 Update config.yaml 2026-05-11 20:36:58 +08:00
公明 65e48826ff Update config.yaml 2026-05-11 19:59:41 +08:00
公明 0cff507272 Add files via upload 2026-05-11 19:57:46 +08:00
公明 30afd71c05 Add files via upload 2026-05-11 19:56:38 +08:00
公明 d2b6a154de Add files via upload 2026-05-11 19:54:40 +08:00
公明 278d5aa25c Add files via upload 2026-05-11 19:52:39 +08:00
公明 215f5a4a93 Update config.yaml 2026-05-10 23:33:39 +08:00
公明 44185d748d Add files via upload 2026-05-10 23:28:18 +08:00
公明 fe47f1f058 Add files via upload 2026-05-10 23:27:07 +08:00
公明 99ce183f41 Add files via upload 2026-05-10 23:25:11 +08:00
公明 2ed1947f36 Add files via upload 2026-05-10 23:22:35 +08:00
公明 97f3e8c179 Add files via upload 2026-05-10 22:52:34 +08:00
公明 38b0c31b87 Add files via upload 2026-05-10 22:47:04 +08:00
公明 cb839da4d1 Add files via upload 2026-05-10 22:44:51 +08:00
公明 5ed730f17c Add files via upload 2026-05-10 22:43:21 +08:00
公明 30b1e5f820 Add files via upload 2026-05-10 22:16:12 +08:00
公明 8e5c70703e Add files via upload 2026-05-10 22:14:51 +08:00
公明 3cc3b25a7b Add files via upload 2026-05-10 22:12:23 +08:00
公明 44cf63fa52 Add files via upload 2026-05-10 22:10:33 +08:00
公明 12057c065b Add files via upload 2026-05-10 21:39:50 +08:00
公明 c4e0b9735c Add files via upload 2026-05-10 21:38:28 +08:00
公明 218e9b9880 Add files via upload 2026-05-10 21:36:28 +08:00
公明 82d840966e Add files via upload 2026-05-10 21:34:34 +08:00
公明 c62ff3bde9 Add files via upload 2026-05-10 20:29:34 +08:00
公明 df2506b651 Add files via upload 2026-05-10 02:04:23 +08:00
公明 efe9172f85 Add files via upload 2026-05-10 02:03:07 +08:00
公明 b788bc6dab Add files via upload 2026-05-10 02:01:28 +08:00
公明 9134f2bbcb Update config.yaml 2026-05-10 01:53:51 +08:00
公明 d76cf2a162 Add files via upload 2026-05-10 00:58:35 +08:00
公明 2f96feb98f Add files via upload 2026-05-10 00:57:26 +08:00
公明 a374c3950c Add files via upload 2026-05-10 00:55:20 +08:00
公明 a93e3455fa Add files via upload 2026-05-10 00:53:33 +08:00
公明 6cd864c5ca Update config.yaml 2026-05-08 23:00:15 +08:00
公明 e34faff001 Add files via upload 2026-05-08 22:45:46 +08:00
公明 fa09796ddd Add files via upload 2026-05-08 22:44:32 +08:00
公明 1ab7e98f56 Add files via upload 2026-05-08 22:42:31 +08:00
公明 0743086873 Add files via upload 2026-05-08 22:32:21 +08:00
公明 a1ceb9c108 Add files via upload 2026-05-08 17:22:40 +08:00
公明 9ddea33dab Add files via upload 2026-05-08 17:15:27 +08:00
公明 e948940b18 Delete images/dashboard.png 2026-05-08 17:14:56 +08:00
公明 94bbbf87bf Add files via upload 2026-05-08 16:50:56 +08:00
公明 4f09ffbaaa Add files via upload 2026-05-08 13:57:18 +08:00
公明 6d77081b2b Add files via upload 2026-05-08 13:56:04 +08:00
公明 99ccb07ec9 Add files via upload 2026-05-08 13:54:25 +08:00
公明 1130fdbfa4 Add files via upload 2026-05-08 13:08:45 +08:00
公明 84f4da4d1d Add files via upload 2026-05-08 13:07:33 +08:00
公明 34dae98329 Add files via upload 2026-05-08 13:05:45 +08:00
公明 3ee7d64b09 Add files via upload 2026-05-08 13:04:18 +08:00
公明 22a3aa1531 Add files via upload 2026-05-07 18:03:19 +08:00
公明 8ad61906fa Add files via upload 2026-05-07 18:02:15 +08:00
公明 487522707f Add files via upload 2026-05-07 18:00:22 +08:00
公明 fe625010eb Update config.yaml 2026-05-07 17:04:39 +08:00
公明 40cd0293b5 Add files via upload 2026-05-07 17:04:14 +08:00
公明 b62dc1f326 Add files via upload 2026-05-07 17:02:26 +08:00
公明 6d180c814d Add files via upload 2026-05-07 17:01:15 +08:00
公明 e68d3a3d23 Add files via upload 2026-05-07 16:58:54 +08:00
公明 699b9181e6 Add files via upload 2026-05-07 16:57:17 +08:00
公明 7b9070f106 Update config.yaml 2026-05-06 21:37:55 +08:00
公明 5a31b69245 Add files via upload 2026-05-06 21:31:21 +08:00
公明 104a6e30d5 Add files via upload 2026-05-06 21:29:25 +08:00
公明 80c4299dbb Add files via upload 2026-05-06 21:26:38 +08:00
公明 debe967272 Add files via upload 2026-05-06 20:50:28 +08:00
公明 b28f9c25f8 Update config.yaml 2026-05-06 18:00:13 +08:00
公明 6f5d0b0174 Add files via upload 2026-05-06 17:59:31 +08:00
公明 231a48db8e Add files via upload 2026-05-06 17:58:42 +08:00
公明 d82ea60827 Add files via upload 2026-05-06 17:56:30 +08:00
公明 24a0c813e2 Add files via upload 2026-05-06 17:50:59 +08:00
公明 24938f92ff Add files via upload 2026-05-04 13:22:36 +08:00
公明 b24bc63964 Update config.yaml 2026-05-04 13:19:35 +08:00
公明 60517fff44 Update config.yaml 2026-05-04 13:12:56 +08:00
公明 d2635eeb9c Add files via upload 2026-05-04 13:12:09 +08:00
公明 57ebc7c04b Add files via upload 2026-05-04 13:09:43 +08:00
公明 b27e443d37 Add files via upload 2026-05-04 13:07:37 +08:00
公明 9b4c6dedc8 Add files via upload 2026-05-04 04:50:53 +08:00
公明 d603060511 Add files via upload 2026-05-04 03:52:47 +08:00
69 changed files with 5701 additions and 1042 deletions
+11 -2
View File
@@ -27,7 +27,7 @@ If CyberStrikeAI helps you, you can support the project via **WeChat Pay** or **
</details> </details>
CyberStrikeAI is an **AI-native security testing platform** built in Go. It integrates 100+ security tools, an intelligent orchestration engine, role-based testing with predefined security roles, a skills system with specialized testing skills, and comprehensive lifecycle management capabilities. Through native MCP protocol and AI agents, it enables end-to-end automation from conversational commands to vulnerability discovery, attack-chain analysis, knowledge retrieval, and result visualization—delivering an auditable, traceable, and collaborative testing environment for security teams. CyberStrikeAI is an **AI-native security testing platform** built in Go. It integrates 100+ security tools, an intelligent orchestration engine, role-based testing with predefined security roles, a skills system with specialized testing skills, comprehensive lifecycle management capabilities, and a **built-in lightweight C2 (Command & Control) framework** for **authorized** engagements (listeners, encrypted implants, sessions, tasks, real-time events, REST and MCP). Through native MCP protocol and AI agents, it enables end-to-end automation from conversational commands to vulnerability discovery, attack-chain analysis, knowledge retrieval, and result visualization—delivering an auditable, traceable, and collaborative testing environment for security teams.
## Interface & Integration Preview ## Interface & Integration Preview
@@ -121,6 +121,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
- 📱 **Chatbot**: DingTalk and Lark (Feishu) long-lived connections so you can talk to CyberStrikeAI from mobile (see [Robot / Chatbot guide](docs/robot_en.md) for setup and commands) - 📱 **Chatbot**: DingTalk and Lark (Feishu) long-lived connections so you can talk to CyberStrikeAI from mobile (see [Robot / Chatbot guide](docs/robot_en.md) for setup and commands)
- 🧑‍⚖️ **Human-in-the-loop (HITL)**: Chat sidebar to set approval mode and tool allowlists (listed tools skip approval); global list in `config.yaml` under `hitl.tool_whitelist`; **Apply** can merge new tools into the file and update the running server without restart; dedicated **HITL** page for pending approvals - 🧑‍⚖️ **Human-in-the-loop (HITL)**: Chat sidebar to set approval mode and tool allowlists (listed tools skip approval); global list in `config.yaml` under `hitl.tool_whitelist`; **Apply** can merge new tools into the file and update the running server without restart; dedicated **HITL** page for pending approvals
- 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter. - 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter.
- 📡 **Built-in C2**: AI-oriented lightweight command-and-control—**listeners** (TCP reverse, HTTP/HTTPS beacon, WebSocket), **encrypted** beacon channel, **session** and **task** queues with persistence, **payload** helpers (one-liner / build / download), **SSE** live events, REST under `/api/c2/*`, plus unified MCP tools (`c2_listener`, `c2_session`, **`c2_task`**, `c2_task_manage`, `c2_payload`, `c2_event`, `c2_profile`, `c2_file`); optional **HITL** approval for sensitive operations and OPSEC-style controls (e.g. command deny rules). **Authorized testing only.**
## Plugins ## Plugins
@@ -237,6 +238,7 @@ Requirements / tips:
- **Vulnerability management** Create, update, and track vulnerabilities discovered during testing. Filter by severity (critical/high/medium/low/info), status (open/confirmed/fixed/false_positive), and conversation. View statistics and export findings. - **Vulnerability management** Create, update, and track vulnerabilities discovered during testing. Filter by severity (critical/high/medium/low/info), status (open/confirmed/fixed/false_positive), and conversation. View statistics and export findings.
- **Batch task management** Create task queues with multiple tasks, add or edit tasks before execution, and run them sequentially. Each task executes as a separate conversation, with status tracking (pending/running/completed/failed/cancelled) and full execution history. - **Batch task management** Create task queues with multiple tasks, add or edit tasks before execution, and run them sequentially. Each task executes as a separate conversation, with status tracking (pending/running/completed/failed/cancelled) and full execution history.
- **WebShell management** Add and manage WebShell connections (PHP/ASP/ASPX/JSP or custom). Use the virtual terminal to run commands, the file manager to list, read, edit, upload, and delete files, and the AI assistant tab to drive scripted tests with per-connection conversation history. Connections are stored in SQLite; supports GET/POST and configurable command parameter (e.g. IceSword/AntSword style). - **WebShell management** Add and manage WebShell connections (PHP/ASP/ASPX/JSP or custom). Use the virtual terminal to run commands, the file manager to list, read, edit, upload, and delete files, and the AI assistant tab to drive scripted tests with per-connection conversation history. Connections are stored in SQLite; supports GET/POST and configurable command parameter (e.g. IceSword/AntSword style).
- **Built-in C2** Create/start **listeners**, generate **payloads**, track **sessions**, enqueue **tasks**, and subscribe to **events** (SSE) from the Web UI or `/api/c2/*`. Agents and external clients use the C2 MCP tool family (including **`c2_task`**); when HITL is enabled, high-risk tasks can require human approval. Intended **only** for systems you are explicitly authorized to test.
- **Settings** Tweak provider keys, MCP enablement, tool toggles, and agent iteration limits. - **Settings** Tweak provider keys, MCP enablement, tool toggles, and agent iteration limits.
- **Human-in-the-loop (HITL)** Sidebar sets mode and allowlisted tools (comma- or newline-separated); global list lives in `config.yaml` under `hitl.tool_whitelist`. **Apply** updates browser/server and can merge new tools into the file (**no restart**). **New chat** keeps sidebar choices; **HITL** nav shows pending approvals. Removing a tool in the sidebar does not remove it from the global list in `config.yaml`—edit the file if needed. - **Human-in-the-loop (HITL)** Sidebar sets mode and allowlisted tools (comma- or newline-separated); global list lives in `config.yaml` under `hitl.tool_whitelist`. **Apply** updates browser/server and can merge new tools into the file (**no restart**). **New chat** keeps sidebar choices; **HITL** nav shows pending approvals. Removing a tool in the sidebar does not remove it from the global list in `config.yaml`—edit the file if needed.
@@ -320,6 +322,12 @@ Requirements / tips:
- **Connectivity test** Use **Test connectivity** to verify that the shell URL, password, and command parameter are correct before running commands (sends a lightweight `echo 1` check). - **Connectivity test** Use **Test connectivity** to verify that the shell URL, password, and command parameter are correct before running commands (sends a lightweight `echo 1` check).
- **Persistence** All WebShell connections and AI conversations are stored in SQLite (same database as conversations), so they persist across restarts. - **Persistence** All WebShell connections and AI conversations are stored in SQLite (same database as conversations), so they persist across restarts.
### Built-in C2 (Command & Control)
- **What it is** A first-party, **AI-native** C2 stack: listeners accept implants (beacons), the server stores **sessions** and **tasks** in SQLite, pushes updates over an **event bus** (including **SSE**), and exposes everything through authenticated **REST** plus MCP.
- **Listeners & transports** `tcp_reverse`, `http_beacon`, `https_beacon`, and `websocket`; per-listener crypto keys; running listeners can be **restored after restart** when marked running in the database.
- **Agent integration** MCP exposes a small **C2 tool family** (listeners, sessions, **`c2_task`**, task management, payloads, events, profiles, files) so the same agent loop can orchestrate C2 alongside other tools; dangerous task types can go through the existing **HITL** bridge when your session policy requires it.
- **Safety** Use **only** in lab or **fully authorized** engagements; combine network isolation, strong auth, and HITL/allowlists as your policy demands.
### MCP Everywhere ### MCP Everywhere
- **Web mode** ships with HTTP MCP server automatically consumed by the UI. - **Web mode** ships with HTTP MCP server automatically consumed by the UI.
- **MCP stdio mode** `go run cmd/mcp-stdio/main.go` exposes the agent to Cursor/CLI. - **MCP stdio mode** `go run cmd/mcp-stdio/main.go` exposes the agent to Cursor/CLI.
@@ -476,6 +484,7 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
- **Vulnerability APIs** manage vulnerabilities via `/api/vulnerabilities` endpoints: `GET /api/vulnerabilities` (list with filters), `POST /api/vulnerabilities` (create), `GET /api/vulnerabilities/:id` (get), `PUT /api/vulnerabilities/:id` (update), `DELETE /api/vulnerabilities/:id` (delete), `GET /api/vulnerabilities/stats` (statistics). - **Vulnerability APIs** manage vulnerabilities via `/api/vulnerabilities` endpoints: `GET /api/vulnerabilities` (list with filters), `POST /api/vulnerabilities` (create), `GET /api/vulnerabilities/:id` (get), `PUT /api/vulnerabilities/:id` (update), `DELETE /api/vulnerabilities/:id` (delete), `GET /api/vulnerabilities/stats` (statistics).
- **Batch Task APIs** manage batch task queues via `/api/batch-tasks` endpoints: `POST /api/batch-tasks` (create queue), `GET /api/batch-tasks` (list queues), `GET /api/batch-tasks/:queueId` (get queue), `POST /api/batch-tasks/:queueId/start` (start execution), `POST /api/batch-tasks/:queueId/cancel` (cancel), `DELETE /api/batch-tasks/:queueId` (delete), `POST /api/batch-tasks/:queueId/tasks` (add task), `PUT /api/batch-tasks/:queueId/tasks/:taskId` (update task), `DELETE /api/batch-tasks/:queueId/tasks/:taskId` (delete task). Tasks execute sequentially, each creating a separate conversation with full status tracking. - **Batch Task APIs** manage batch task queues via `/api/batch-tasks` endpoints: `POST /api/batch-tasks` (create queue), `GET /api/batch-tasks` (list queues), `GET /api/batch-tasks/:queueId` (get queue), `POST /api/batch-tasks/:queueId/start` (start execution), `POST /api/batch-tasks/:queueId/cancel` (cancel), `DELETE /api/batch-tasks/:queueId` (delete), `POST /api/batch-tasks/:queueId/tasks` (add task), `PUT /api/batch-tasks/:queueId/tasks/:taskId` (update task), `DELETE /api/batch-tasks/:queueId/tasks/:taskId` (delete task). Tasks execute sequentially, each creating a separate conversation with full status tracking.
- **WebShell APIs** manage WebShell connections and execute commands via `/api/webshell/connections` (GET list, POST create, PUT update, DELETE delete) and `/api/webshell/exec` (command execution), `/api/webshell/fileop` (list/read/write/delete files). - **WebShell APIs** manage WebShell connections and execute commands via `/api/webshell/connections` (GET list, POST create, PUT update, DELETE delete) and `/api/webshell/exec` (command execution), `/api/webshell/fileop` (list/read/write/delete files).
- **C2 APIs** manage listeners, sessions, tasks, payloads, files, and events under `/api/c2/*` (e.g. listeners CRUD/start/stop, session sleep, task create/cancel/wait, payload build/download, event stream).
- **Task control** pause/resume/stop long scans, re-run steps with new params, or stream transcripts. - **Task control** pause/resume/stop long scans, re-run steps with new params, or stream transcripts.
- **Audit & security** rotate passwords via `/api/auth/change-password`, enforce short-lived sessions, and restrict MCP ports at the network layer when exposing the service. - **Audit & security** rotate passwords via `/api/auth/change-password`, enforce short-lived sessions, and restrict MCP ports at the network layer when exposing the service.
@@ -581,7 +590,7 @@ enabled: true
``` ```
CyberStrikeAI/ CyberStrikeAI/
├── cmd/ # Server, MCP stdio entrypoints, tooling ├── cmd/ # Server, MCP stdio entrypoints, tooling
├── internal/ # Agent, MCP core, handlers, security executor ├── internal/ # Agent, MCP core, handlers, C2 (`internal/c2`), security executor
├── web/ # Static SPA + templates ├── web/ # Static SPA + templates
├── tools/ # YAML tool recipes (100+ examples provided) ├── tools/ # YAML tool recipes (100+ examples provided)
├── roles/ # Role configurations (12+ predefined security testing roles) ├── roles/ # Role configurations (12+ predefined security testing roles)
+11 -2
View File
@@ -26,7 +26,7 @@
</details> </details>
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能,以及完整的测试生命周期管理能力。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。 CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能完整的测试生命周期管理能力,以及面向 **授权场景****内置轻量 C2Command & Control,指挥与控制)** 能力(监听器、加密通信、会话与任务、实时事件、REST 与 MCP 协同)。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
## 界面与集成预览 ## 界面与集成预览
@@ -120,6 +120,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md) - 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md)
- 🧑‍⚖️ **人机协同(HITL**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml``hitl.tool_whitelist`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用 - 🧑‍⚖️ **人机协同(HITL**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml``hitl.tool_whitelist`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。 - 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
- 📡 **内置 C2**:面向 AI 协同的轻量 **C2**——**多种监听器**TCP 反向、HTTP/HTTPS Beacon、WebSocket)、**加密** Beacon 信道、**会话与任务**队列及持久化、**Payload** 辅助(一键命令 / 构建 / 下载)、**SSE** 实时事件、REST`/api/c2/*`)及智能体侧 **一组 C2 MCP 工具**(如 `c2_listener``c2_session`、**`c2_task`**、`c2_task_manage``c2_payload``c2_event``c2_profile``c2_file`);敏感操作可对接 **人机协同(HITL**,并支持 OPSEC 类规则(如命令拒绝正则)。**仅限授权测试。**
## 插件(Plugins ## 插件(Plugins
@@ -235,6 +236,7 @@ go build -o cyberstrike-ai cmd/server/main.go
- **漏洞管理**:在测试过程中创建、更新和跟踪发现的漏洞。支持按严重程度(严重/高/中/低/信息)、状态(待确认/已确认/已修复/误报)和对话进行过滤,查看统计信息并导出发现。 - **漏洞管理**:在测试过程中创建、更新和跟踪发现的漏洞。支持按严重程度(严重/高/中/低/信息)、状态(待确认/已确认/已修复/误报)和对话进行过滤,查看统计信息并导出发现。
- **批量任务管理**:创建任务队列,批量添加多个任务,执行前可编辑或删除任务,然后依次顺序执行。每个任务会作为独立对话执行,支持完整的状态跟踪(待执行/执行中/已完成/失败/已取消)和执行历史。 - **批量任务管理**:创建任务队列,批量添加多个任务,执行前可编辑或删除任务,然后依次顺序执行。每个任务会作为独立对话执行,支持完整的状态跟踪(待执行/执行中/已完成/失败/已取消)和执行历史。
- **WebShell 管理**:添加并管理 WebShell 连接(PHP/ASP/ASPX/JSP 或自定义类型)。使用虚拟终端执行命令(带命令历史与快捷命令),使用文件管理浏览、读取、编辑、上传与删除目标文件,并支持按路径导航和名称过滤。连接信息持久化存储于 SQLite,支持 GET/POST 及可配置命令参数(兼容冰蝎/蚁剑等)。 - **WebShell 管理**:添加并管理 WebShell 连接(PHP/ASP/ASPX/JSP 或自定义类型)。使用虚拟终端执行命令(带命令历史与快捷命令),使用文件管理浏览、读取、编辑、上传与删除目标文件,并支持按路径导航和名称过滤。连接信息持久化存储于 SQLite,支持 GET/POST 及可配置命令参数(兼容冰蝎/蚁剑等)。
- **内置 C2**:在 Web 界面或 `/api/c2/*` 创建/启动 **监听器**、生成 **Payload**、查看 **会话**、下发 **任务** 并订阅 **事件(SSE)**。智能体与外部客户端通过 **C2 MCP 工具族**(含 **`c2_task`** 等)编排;开启人机协同时,高风险任务可走审批。**仅用于已获明确授权的目标。**
- **可视化配置**:在界面中切换模型、启停工具、设置迭代次数等。 - **可视化配置**:在界面中切换模型、启停工具、设置迭代次数等。
- **人机协同(HITL)**:侧栏设置协同模式与免审批工具(逗号或换行);全局白名单见 `config.yaml` 的 `hitl.tool_whitelist`。点「**应用**」可写浏览器/服务端并合并新增工具进配置(**无需重启**)。**新对话**保留侧栏选择;导航 **人机协同** 处理待审批。从侧栏删掉工具不会自动从配置文件移除全局项,需手改 `config.yaml`。 - **人机协同(HITL)**:侧栏设置协同模式与免审批工具(逗号或换行);全局白名单见 `config.yaml` 的 `hitl.tool_whitelist`。点「**应用**」可写浏览器/服务端并合并新增工具进配置(**无需重启**)。**新对话**保留侧栏选择;导航 **人机协同** 处理待审批。从侧栏删掉工具不会自动从配置文件移除全局项,需手改 `config.yaml`。
@@ -317,6 +319,12 @@ go build -o cyberstrike-ai cmd/server/main.go
- **连通性测试**:使用 **测试连通性** 可在执行命令前通过一次 `echo 1` 调用校验 Shell 地址、密码与命令参数是否正确。 - **连通性测试**:使用 **测试连通性** 可在执行命令前通过一次 `echo 1` 调用校验 Shell 地址、密码与命令参数是否正确。
- **持久化**:所有 WebShell 连接与相关 AI 会话均保存在 SQLite(与对话共用数据库),服务重启后仍可继续使用。 - **持久化**:所有 WebShell 连接与相关 AI 会话均保存在 SQLite(与对话共用数据库),服务重启后仍可继续使用。
### 内置 C2Command & Control
- **定位**:平台内置的 **AI 原生** C2 能力栈——监听器接入植入体(Beacon),服务端以 SQLite 持久化 **会话** 与 **任务**,通过 **事件总线** 推送变更(含 **SSE**),并由鉴权后的 **REST** 与 MCP 统一对外。
- **监听器与传输**:支持 `tcp_reverse`、`http_beacon`、`https_beacon`、`websocket`;按监听器独立密钥;数据库中标记为运行中的监听器可在 **服务重启后尝试恢复**。
- **与智能体联动**:通过 **`c2_task` 等 C2 MCP 工具** 与现有对话/多代理工具链协同;在会话策略需要时,危险任务类型可走既有 **人机协同(HITL)** 审批流。
- **安全提示**:**仅**在实验环境或 **已获完整书面授权** 的对抗演练中使用;结合网络隔离、强鉴权及 HITL/白名单等策略管控风险。
### MCP 全场景 ### MCP 全场景
- **Web 模式**:自带 HTTP MCP 服务供前端调用。 - **Web 模式**:自带 HTTP MCP 服务供前端调用。
- **MCP stdio 模式**`go run cmd/mcp-stdio/main.go` 可接入 Cursor/命令行。 - **MCP stdio 模式**`go run cmd/mcp-stdio/main.go` 可接入 Cursor/命令行。
@@ -474,6 +482,7 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
- **漏洞管理 API**:通过 `/api/vulnerabilities` 端点管理漏洞:`GET /api/vulnerabilities`(列表,支持过滤)、`POST /api/vulnerabilities`(创建)、`GET /api/vulnerabilities/:id`(获取)、`PUT /api/vulnerabilities/:id`(更新)、`DELETE /api/vulnerabilities/:id`(删除)、`GET /api/vulnerabilities/stats`(统计)。 - **漏洞管理 API**:通过 `/api/vulnerabilities` 端点管理漏洞:`GET /api/vulnerabilities`(列表,支持过滤)、`POST /api/vulnerabilities`(创建)、`GET /api/vulnerabilities/:id`(获取)、`PUT /api/vulnerabilities/:id`(更新)、`DELETE /api/vulnerabilities/:id`(删除)、`GET /api/vulnerabilities/stats`(统计)。
- **批量任务 API**:通过 `/api/batch-tasks` 端点管理批量任务队列:`POST /api/batch-tasks`(创建队列)、`GET /api/batch-tasks`(列表)、`GET /api/batch-tasks/:queueId`(获取队列)、`POST /api/batch-tasks/:queueId/start`(开始执行)、`POST /api/batch-tasks/:queueId/cancel`(取消)、`DELETE /api/batch-tasks/:queueId`(删除队列)、`POST /api/batch-tasks/:queueId/tasks`(添加任务)、`PUT /api/batch-tasks/:queueId/tasks/:taskId`(更新任务)、`DELETE /api/batch-tasks/:queueId/tasks/:taskId`(删除任务)。任务依次顺序执行,每个任务创建独立对话,支持完整状态跟踪。 - **批量任务 API**:通过 `/api/batch-tasks` 端点管理批量任务队列:`POST /api/batch-tasks`(创建队列)、`GET /api/batch-tasks`(列表)、`GET /api/batch-tasks/:queueId`(获取队列)、`POST /api/batch-tasks/:queueId/start`(开始执行)、`POST /api/batch-tasks/:queueId/cancel`(取消)、`DELETE /api/batch-tasks/:queueId`(删除队列)、`POST /api/batch-tasks/:queueId/tasks`(添加任务)、`PUT /api/batch-tasks/:queueId/tasks/:taskId`(更新任务)、`DELETE /api/batch-tasks/:queueId/tasks/:taskId`(删除任务)。任务依次顺序执行,每个任务创建独立对话,支持完整状态跟踪。
- **WebShell API**:通过 `/api/webshell/connections`GET 列表、POST 创建、PUT 更新、DELETE 删除)及 `/api/webshell/exec`(执行命令)、`/api/webshell/fileop`(列出/读取/写入/删除文件)管理 WebShell 连接与执行操作。 - **WebShell API**:通过 `/api/webshell/connections`GET 列表、POST 创建、PUT 更新、DELETE 删除)及 `/api/webshell/exec`(执行命令)、`/api/webshell/fileop`(列出/读取/写入/删除文件)管理 WebShell 连接与执行操作。
- **C2 API**:在 `/api/c2/*` 管理监听器、会话、任务、Payload、文件与事件(如监听器增删改查/启停、会话休眠、任务创建/取消/等待、Payload 构建/下载、事件流等)。
- **任务控制**:支持暂停/终止长任务、修改参数后重跑、流式获取日志。 - **任务控制**:支持暂停/终止长任务、修改参数后重跑、流式获取日志。
- **安全管理**`/api/auth/change-password` 可即时轮换口令;建议在暴露 MCP 端口时配合网络层 ACL。 - **安全管理**`/api/auth/change-password` 可即时轮换口令;建议在暴露 MCP 端口时配合网络层 ACL。
@@ -579,7 +588,7 @@ enabled: true
``` ```
CyberStrikeAI/ CyberStrikeAI/
├── cmd/ # Web 服务、MCP stdio 入口及辅助工具 ├── cmd/ # Web 服务、MCP stdio 入口及辅助工具
├── internal/ # Agent、MCP 核心、路由与执行器 ├── internal/ # Agent、MCP 核心、路由、C2`internal/c2`与执行器
├── web/ # 前端静态资源与模板 ├── web/ # 前端静态资源与模板
├── tools/ # YAML 工具目录(含 100+ 示例) ├── tools/ # YAML 工具目录(含 100+ 示例)
├── roles/ # 角色配置文件目录(含 12+ 预设安全测试角色) ├── roles/ # 角色配置文件目录(含 12+ 预设安全测试角色)
+11 -1
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.0" version: "v1.6.8"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -41,6 +41,13 @@ openai:
api_key: sk-xxxxxxx # API 密钥(必填) api_key: sk-xxxxxxx # API 密钥(必填)
model: qwen3-max # 模型名称(必填) model: qwen3-max # 模型名称(必填)
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置) max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinkingextended thinking),mode: off 关闭
reasoning:
mode: off # auto | on | offoff 时不附加任何推理扩展字段
effort: max # low | medium | high | max;空表示不指定(openai_compat 下 auto 且无强度时不发请求扩展)
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
# ============================================ # ============================================
# 信息收集(FOFA)配置(可选) # 信息收集(FOFA)配置(可选)
# ============================================ # ============================================
@@ -147,6 +154,9 @@ mcp:
# 外部 MCP 配置 # 外部 MCP 配置
external_mcp: external_mcp:
servers: {} servers: {}
# 内置 C2:本机仅做对话/知识库时可设为 false,不启动监听器、不注册 C2 MCP 工具;省略本段时默认启用
c2:
enabled: true
# ============================================ # ============================================
# 知识库相关配置 # 知识库相关配置
# ============================================ # ============================================
+8 -8
View File
@@ -9,13 +9,13 @@ toolchain go1.24.4
require ( require (
github.com/bytedance/sonic v1.15.0 github.com/bytedance/sonic v1.15.0
github.com/cloudwego/eino v0.8.8 github.com/cloudwego/eino v0.8.13
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2 github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260416081055-0ebab92e14f2 github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260416081055-0ebab92e14f2 github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260416081055-0ebab92e14f2 github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260416081055-0ebab92e14f2 github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b
github.com/cloudwego/eino-ext/components/model/openai v0.1.12 github.com/cloudwego/eino-ext/components/model/openai v0.1.13
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/eino-contrib/jsonschema v1.0.3 github.com/eino-contrib/jsonschema v1.0.3
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
@@ -28,6 +28,7 @@ require (
github.com/pkoukk/tiktoken-go v0.1.8 github.com/pkoukk/tiktoken-go v0.1.8
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
go.uber.org/zap v1.26.0 go.uber.org/zap v1.26.0
golang.org/x/text v0.26.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -39,7 +40,7 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 // indirect github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect github.com/evanphx/json-patch v0.5.2 // indirect
@@ -77,7 +78,6 @@ require (
golang.org/x/net v0.24.0 // indirect golang.org/x/net v0.24.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect google.golang.org/protobuf v1.30.0 // indirect
) )
+14 -14
View File
@@ -20,22 +20,22 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.8.8 h1:64NuheQBmxOXe/28Tm85rkBkxXMB5ZhjSu/j0RDFyZU= github.com/cloudwego/eino v0.8.13 h1:z5dhaZNN8TWZbP/lgKxGmF26Ii8fPeUlQCGV/NTtms0=
github.com/cloudwego/eino v0.8.8/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU= github.com/cloudwego/eino v0.8.13/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2 h1:v2w9TyLAmNsMWo8NwntCc76uvNf6isTFkHB+oZZ8NqI= github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2 h1:v2w9TyLAmNsMWo8NwntCc76uvNf6isTFkHB+oZZ8NqI=
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:os5Tq5FuSoz/MLqAdZER3ip49Oef9prc0kVsKsPYO48= github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:os5Tq5FuSoz/MLqAdZER3ip49Oef9prc0kVsKsPYO48=
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260416081055-0ebab92e14f2 h1:H5Ohr3OWSjiTOe7y9pOPyVCKCNjAVj9YMaWmvZNTYPg= github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b h1:GIOC/VnXuSQx79mnQ3HgMvECjtyqvpJipmSUTFFfVsc=
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:HnxTQxmhuev6zaBl92EHUy/vEDWCuoE/OE4cTiF5JCg= github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b/go.mod h1:HnxTQxmhuev6zaBl92EHUy/vEDWCuoE/OE4cTiF5JCg=
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260416081055-0ebab92e14f2 h1:PRli0CmPfgUhwMGWGEAwg8nxde8hInC2OWv0vcIuwMk= github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b h1:3owjV4nv+XRplavTeqFlCeAV4v7EHR2tIXDqLEmPc38=
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:KVOVct4e2BQ7epDONW2QE1qU5+ccoh91FzJTs9vIJj0= github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b/go.mod h1:KVOVct4e2BQ7epDONW2QE1qU5+ccoh91FzJTs9vIJj0=
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260416081055-0ebab92e14f2 h1:8sOFcDf9MtMVDQyozZtuhrmt+mLQRHEaf6dYC20Vxhs= github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b h1:j8sj/5QiooV3LWphFDsJvyD/csWwupz+UKXeG+nqiNg=
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:9R0RQrQSpg1JaNnRtw7+RfRAAv0HgdE348YnrlZ6coo= github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b/go.mod h1:9R0RQrQSpg1JaNnRtw7+RfRAAv0HgdE348YnrlZ6coo=
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260416081055-0ebab92e14f2 h1:OzKPBfGCJhjbtO+WfIMNSSnXxsj6/hUiyYOTaG2LUf4= github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b h1:pOqupZQyc46rw2Z0HeybtTmSMTwqfTrbRuGDuDsNf2A=
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260416081055-0ebab92e14f2/go.mod h1:zyPrZT2bO6LyRJgVksQowR18jVgyLSvqK93hnO53/Lc= github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b/go.mod h1:zyPrZT2bO6LyRJgVksQowR18jVgyLSvqK93hnO53/Lc=
github.com/cloudwego/eino-ext/components/model/openai v0.1.12 h1:vcwNXeT7bpaXMNwUhtcHZwMYY8II2jAihuooyivmEZ0= github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM=
github.com/cloudwego/eino-ext/components/model/openai v0.1.12/go.mod h1:ve/+/hLZMvxD5AieQ355xHIFhAZVlsG4rdwTnE16aQU= github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16 h1:q242n5P5Tx3a2QLaBmkfEpfRs/o17Ac6u3EAgItEEOc= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.16/go.mod h1:p+l0zBB0GjjX8HTlbTs3g3KfUFwZC11bsCGZOXW/3L0= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 KiB

After

Width:  |  Height:  |  Size: 726 KiB

+42 -3
View File
@@ -193,6 +193,10 @@ type ChatMessage struct {
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"`
// ToolName 仅 tool 角色:从 Eino/轨迹 JSON 的 name 或 tool_name 恢复,供续跑构造 ToolMessage。
ToolName string `json:"tool_name,omitempty"`
// ReasoningContent 对应 OpenAI/DeepSeek 的 reasoning_content;思考模式 + 工具调用后续跑须回传(见 DeepSeek 文档)。
ReasoningContent string `json:"reasoning_content,omitempty"`
} }
// MarshalJSON 自定义JSON序列化,将tool_calls中的arguments转换为JSON字符串 // MarshalJSON 自定义JSON序列化,将tool_calls中的arguments转换为JSON字符串
@@ -206,11 +210,17 @@ func (cm ChatMessage) MarshalJSON() ([]byte, error) {
if cm.Content != "" { if cm.Content != "" {
aux["content"] = cm.Content aux["content"] = cm.Content
} }
if cm.ReasoningContent != "" {
aux["reasoning_content"] = cm.ReasoningContent
}
// 添加tool_call_id(如果存在) // 添加tool_call_id(如果存在)
if cm.ToolCallID != "" { if cm.ToolCallID != "" {
aux["tool_call_id"] = cm.ToolCallID aux["tool_call_id"] = cm.ToolCallID
} }
if cm.ToolName != "" {
aux["tool_name"] = cm.ToolName
}
// 转换tool_calls,将arguments转换为JSON字符串 // 转换tool_calls,将arguments转换为JSON字符串
if len(cm.ToolCalls) > 0 { if len(cm.ToolCalls) > 0 {
@@ -438,6 +448,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
Content: msg.Content, Content: msg.Content,
ToolCalls: msg.ToolCalls, ToolCalls: msg.ToolCalls,
ToolCallID: msg.ToolCallID, ToolCallID: msg.ToolCallID,
ToolName: msg.ToolName,
}) })
addedCount++ addedCount++
contentPreview := msg.Content contentPreview := msg.Content
@@ -657,8 +668,8 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
// 检查是否有工具调用 // 检查是否有工具调用
if len(choice.Message.ToolCalls) > 0 { if len(choice.Message.ToolCalls) > 0 {
// 思考内容:如果本轮启用了思考流式增量(thinking_stream_*前端会去重 // ReAct 助手正文流式增量(thinking_stream_*在 UI 上归为「思考」;若与 streamId 重复则前端会去重
// 同时也需要在该“思考阶段结束”时补一条可落库的 thinking用于刷新后持久化展示)。 // 该条 thinking 用于刷新后持久化展示(与流式聚合一致)。
if choice.Message.Content != "" { if choice.Message.Content != "" {
sendProgress("thinking", choice.Message.Content, map[string]interface{}{ sendProgress("thinking", choice.Message.Content, map[string]interface{}{
"iteration": i + 1, "iteration": i + 1,
@@ -1514,7 +1525,9 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常 // 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
if err != nil { if err != nil {
detail := err.Error() detail := err.Error()
if errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) {
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
} else if errors.Is(err, context.DeadlineExceeded) {
min := 10 min := 10
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 { if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
min = a.agentConfig.ToolTimeoutMinutes min = a.agentConfig.ToolTimeoutMinutes
@@ -1903,9 +1916,35 @@ func (a *Agent) ExecuteMCPToolForConversation(ctx context.Context, conversationI
a.currentConversationID = prev a.currentConversationID = prev
a.mu.Unlock() a.mu.Unlock()
}() }()
ctx = withAgentConversationID(ctx, conversationID)
return a.executeToolViaMCP(ctx, toolName, args) return a.executeToolViaMCP(ctx, toolName, args)
} }
// RecordLocalToolExecution 将非 CallTool 路径完成的工具调用写入 MCP 监控库(与 CallTool 落库一致),返回 executionId。
// 用于 Eino filesystem execute 等场景,使助手气泡「渗透测试详情」与常规 MCP 一致可点进监控。
func (a *Agent) RecordLocalToolExecution(toolName string, args map[string]interface{}, resultText string, invokeErr error) string {
if a == nil || a.mcpServer == nil {
return ""
}
return a.mcpServer.RecordCompletedToolInvocation(toolName, args, resultText, invokeErr)
}
// CancelMCPToolExecutionWithNote 取消一次进行中的 MCP 工具(先内部后外部),与监控页「终止工具」一致;note 非空时合并进返回给模型的文本。
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
executionID = strings.TrimSpace(executionID)
note = strings.TrimSpace(note)
if executionID == "" {
return false
}
if a.mcpServer != nil && a.mcpServer.CancelToolExecutionWithNote(executionID, note) {
return true
}
if a.externalMCPMgr != nil && a.externalMCPMgr.CancelToolExecutionWithNote(executionID, note) {
return true
}
return false
}
// extractQuotedToolName 尝试从错误信息中提取被引用的工具名称 // extractQuotedToolName 尝试从错误信息中提取被引用的工具名称
func extractQuotedToolName(errMsg string) string { func extractQuotedToolName(errMsg string) string {
start := strings.Index(errMsg, "\"") start := strings.Index(errMsg, "\"")
+65 -96
View File
@@ -52,9 +52,10 @@ type App struct {
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启 dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启
larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启 larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启
c2Manager *c2.Manager // C2 管理器 c2Manager *c2.Manager // C2 管理器(未启用 C2 时为 nil
c2Watchdog *c2.SessionWatchdog // C2 会话看门狗 c2Watchdog *c2.SessionWatchdog // C2 会话看门狗
c2WatchdogCancel context.CancelFunc // 看门狗取消函数 c2WatchdogCancel context.CancelFunc // 看门狗取消函数
c2Handler *handler.C2Handler // C2 REST(与 Manager 生命周期同步)
} }
// New 创建新应用 // New 创建新应用
@@ -343,50 +344,13 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
} }
// ============================================================================ // ============================================================================
// 初始化 C2 模块 // 初始化 C2 模块(可按配置关闭,节省本机部署资源)
// ============================================================================ // ============================================================================
c2Manager := c2.NewManager(db, log.Logger, "tmp/c2") c2Manager, c2Watchdog, watchdogCancel := setupC2Runtime(cfg, db, agentHandler, log.Logger)
// 注册 Listener 工厂 if c2Manager != nil {
c2Manager.Registry().Register(string(c2.ListenerTypeTCPReverse), c2.NewTCPReverseListener)
c2Manager.Registry().Register(string(c2.ListenerTypeHTTPBeacon), c2.NewHTTPBeaconListener)
c2Manager.Registry().Register(string(c2.ListenerTypeHTTPSBeacon), c2.NewHTTPSBeaconListener)
c2Manager.Registry().Register(string(c2.ListenerTypeWebSocket), c2.NewWebSocketListener)
// 设置 HITL 桥(仅当会话开启人机协同且 c2_task 不在免审批白名单时,危险任务才走桥)
c2HITLBridge := NewC2HITLBridge(db, log.Logger)
c2Manager.SetHITLBridge(c2HITLBridge)
c2Manager.SetHITLDangerousGate(func(conversationID, toolName string) bool {
return agentHandler.HITLNeedsToolApproval(conversationID, toolName)
})
// 设置业务钩子
c2Hooks := SetupC2Hooks(&C2HooksConfig{
DB: db,
Logger: log.Logger,
AttackChainRecord: func(session *database.C2Session, phase string, description string) {
// 通过攻击链处理器记录(简化版,实际需要完整实现)
log.Logger.Info("C2 Attack Chain",
zap.String("session_id", session.ID),
zap.String("phase", phase),
zap.String("desc", description),
)
},
VulnRecord: func(session *database.C2Session, title string, severity string) {
// 记录漏洞(简化版)
log.Logger.Info("C2 Vulnerability",
zap.String("session_id", session.ID),
zap.String("title", title),
zap.String("severity", severity),
)
},
})
c2Manager.SetHooks(c2Hooks)
// 恢复运行中的监听器
c2Manager.RestoreRunningListeners()
// 启动会话看门狗
c2Watchdog := c2.NewSessionWatchdog(c2Manager)
watchdogCtx, watchdogCancel := context.WithCancel(context.Background())
go c2Watchdog.Run(watchdogCtx)
// 注册 C2 MCP 工具
registerC2Tools(mcpServer, c2Manager, log.Logger, cfg.Server.Port) registerC2Tools(mcpServer, c2Manager, log.Logger, cfg.Server.Port)
}
c2Handler := handler.NewC2Handler(c2Manager, log.Logger)
// 创建OpenAPI处理器 // 创建OpenAPI处理器
conversationHandler := handler.NewConversationHandler(db, log.Logger) conversationHandler := handler.NewConversationHandler(db, log.Logger)
@@ -414,6 +378,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
c2Manager: c2Manager, c2Manager: c2Manager,
c2Watchdog: c2Watchdog, c2Watchdog: c2Watchdog,
c2WatchdogCancel: watchdogCancel, c2WatchdogCancel: watchdogCancel,
c2Handler: c2Handler,
} }
// 飞书/钉钉长连接(无需公网),启用时在后台启动;后续前端应用配置时会通过 RestartRobotConnections 重启 // 飞书/钉钉长连接(无需公网),启用时在后台启动;后续前端应用配置时会通过 RestartRobotConnections 重启
app.startRobotConnections() app.startRobotConnections()
@@ -482,8 +447,13 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
// 设置机器人连接重启器,前端应用配置后无需重启服务即可使钉钉/飞书新配置生效 // 设置机器人连接重启器,前端应用配置后无需重启服务即可使钉钉/飞书新配置生效
configHandler.SetRobotRestarter(app) configHandler.SetRobotRestarter(app)
// 创建 C2 Handler configHandler.SetC2Runtime(app)
c2Handler := handler.NewC2Handler(c2Manager, log.Logger) configHandler.SetC2ToolRegistrar(func() error {
if app.config.C2.EnabledEffective() && app.c2Manager != nil {
registerC2Tools(mcpServer, app.c2Manager, log.Logger, app.config.Server.Port)
}
return nil
})
// 设置路由(使用 App 实例以便动态获取 handler // 设置路由(使用 App 实例以便动态获取 handler
setupRoutes( setupRoutes(
@@ -507,7 +477,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
markdownAgentsHandler, markdownAgentsHandler,
fofaHandler, fofaHandler,
terminalHandler, terminalHandler,
c2Handler, app.c2Handler,
mcpServer, mcpServer,
authManager, authManager,
openAPIHandler, openAPIHandler,
@@ -599,14 +569,7 @@ func (a *App) Shutdown() {
} }
a.robotMu.Unlock() a.robotMu.Unlock()
// 停止 C2 看门狗 a.shutdownC2()
if a.c2WatchdogCancel != nil {
a.c2WatchdogCancel()
}
// 关闭 C2 Manager(停止所有监听器)
if a.c2Manager != nil {
a.c2Manager.Close()
}
// 停止所有外部MCP客户端 // 停止所有外部MCP客户端
if a.externalMCPMgr != nil { if a.externalMCPMgr != nil {
@@ -636,12 +599,12 @@ func (a *App) startRobotConnections() {
if cfg.Robots.Lark.Enabled && cfg.Robots.Lark.AppID != "" && cfg.Robots.Lark.AppSecret != "" { if cfg.Robots.Lark.Enabled && cfg.Robots.Lark.AppID != "" && cfg.Robots.Lark.AppSecret != "" {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
a.larkCancel = cancel a.larkCancel = cancel
go robot.StartLark(ctx, cfg.Robots.Lark, a.robotHandler, a.logger.Logger) go robot.StartLark(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
} }
if cfg.Robots.Dingtalk.Enabled && cfg.Robots.Dingtalk.ClientID != "" && cfg.Robots.Dingtalk.ClientSecret != "" { if cfg.Robots.Dingtalk.Enabled && cfg.Robots.Dingtalk.ClientID != "" && cfg.Robots.Dingtalk.ClientSecret != "" {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
a.dingCancel = cancel a.dingCancel = cancel
go robot.StartDing(ctx, cfg.Robots.Dingtalk, a.robotHandler, a.logger.Logger) go robot.StartDing(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
} }
} }
@@ -794,6 +757,7 @@ func setupRoutes(
// 监控 // 监控
protected.GET("/monitor", monitorHandler.Monitor) protected.GET("/monitor", monitorHandler.Monitor)
protected.GET("/monitor/execution/:id", monitorHandler.GetExecution) protected.GET("/monitor/execution/:id", monitorHandler.GetExecution)
protected.POST("/monitor/execution/:id/cancel", monitorHandler.CancelExecution)
protected.POST("/monitor/executions/names", monitorHandler.BatchGetToolNames) protected.POST("/monitor/executions/names", monitorHandler.BatchGetToolNames)
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution) protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions) protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
@@ -994,46 +958,51 @@ func setupRoutes(
protected.POST("/webshell/exec", webshellHandler.Exec) protected.POST("/webshell/exec", webshellHandler.Exec)
protected.POST("/webshell/file", webshellHandler.FileOp) protected.POST("/webshell/file", webshellHandler.FileOp)
// C2 管理(AI-Native 轻量级 C2 框架 // C2 管理(未启用时返回 503,避免 Handler 空指针
// 监听器 c2Routes := protected.Group("/c2")
protected.GET("/c2/listeners", c2Handler.ListListeners) c2Routes.Use(func(c *gin.Context) {
protected.POST("/c2/listeners", c2Handler.CreateListener) if app.c2Manager == nil {
protected.GET("/c2/listeners/:id", c2Handler.GetListener) c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
protected.PUT("/c2/listeners/:id", c2Handler.UpdateListener) "error": "c2_disabled",
protected.DELETE("/c2/listeners/:id", c2Handler.DeleteListener) "message": "C2 功能已在系统设置中关闭",
protected.POST("/c2/listeners/:id/start", c2Handler.StartListener) "enabled": false,
protected.POST("/c2/listeners/:id/stop", c2Handler.StopListener) })
// 会话 return
protected.GET("/c2/sessions", c2Handler.ListSessions) }
protected.GET("/c2/sessions/:id", c2Handler.GetSession) c.Next()
protected.DELETE("/c2/sessions/:id", c2Handler.DeleteSession) })
protected.PUT("/c2/sessions/:id/sleep", c2Handler.SetSessionSleep) c2Routes.GET("/listeners", c2Handler.ListListeners)
// 任务 c2Routes.POST("/listeners", c2Handler.CreateListener)
protected.GET("/c2/tasks", c2Handler.ListTasks) c2Routes.GET("/listeners/:id", c2Handler.GetListener)
protected.DELETE("/c2/tasks", c2Handler.DeleteTasks) c2Routes.PUT("/listeners/:id", c2Handler.UpdateListener)
protected.GET("/c2/tasks/:id", c2Handler.GetTask) c2Routes.DELETE("/listeners/:id", c2Handler.DeleteListener)
protected.POST("/c2/tasks", c2Handler.CreateTask) c2Routes.POST("/listeners/:id/start", c2Handler.StartListener)
protected.POST("/c2/tasks/:id/cancel", c2Handler.CancelTask) c2Routes.POST("/listeners/:id/stop", c2Handler.StopListener)
protected.GET("/c2/tasks/:id/wait", c2Handler.WaitTask) c2Routes.GET("/sessions", c2Handler.ListSessions)
protected.POST("/c2/sessions/:id/tasks", c2Handler.CreateTask) // 快捷方式:直接对会话下发任务 c2Routes.GET("/sessions/:id", c2Handler.GetSession)
// Payload c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
protected.POST("/c2/payloads/oneliner", c2Handler.PayloadOneliner) c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
protected.POST("/c2/payloads/build", c2Handler.PayloadBuild) c2Routes.GET("/tasks", c2Handler.ListTasks)
protected.GET("/c2/payloads/:id/download", c2Handler.PayloadDownload) c2Routes.DELETE("/tasks", c2Handler.DeleteTasks)
// 事件 & SSE c2Routes.GET("/tasks/:id", c2Handler.GetTask)
protected.GET("/c2/events", c2Handler.ListEvents) c2Routes.POST("/tasks", c2Handler.CreateTask)
protected.DELETE("/c2/events", c2Handler.DeleteEvents) c2Routes.POST("/tasks/:id/cancel", c2Handler.CancelTask)
protected.GET("/c2/events/stream", c2Handler.EventStream) c2Routes.GET("/tasks/:id/wait", c2Handler.WaitTask)
// 文件管理 c2Routes.POST("/sessions/:id/tasks", c2Handler.CreateTask)
protected.POST("/c2/files/upload", c2Handler.UploadFileForImplant) c2Routes.POST("/payloads/oneliner", c2Handler.PayloadOneliner)
protected.GET("/c2/files", c2Handler.ListFiles) c2Routes.POST("/payloads/build", c2Handler.PayloadBuild)
protected.GET("/c2/tasks/:id/result-file", c2Handler.DownloadResultFile) c2Routes.GET("/payloads/:id/download", c2Handler.PayloadDownload)
// Malleable Profile c2Routes.GET("/events", c2Handler.ListEvents)
protected.GET("/c2/profiles", c2Handler.ListProfiles) c2Routes.DELETE("/events", c2Handler.DeleteEvents)
protected.GET("/c2/profiles/:id", c2Handler.GetProfile) c2Routes.GET("/events/stream", c2Handler.EventStream)
protected.POST("/c2/profiles", c2Handler.CreateProfile) c2Routes.POST("/files/upload", c2Handler.UploadFileForImplant)
protected.PUT("/c2/profiles/:id", c2Handler.UpdateProfile) c2Routes.GET("/files", c2Handler.ListFiles)
protected.DELETE("/c2/profiles/:id", c2Handler.DeleteProfile) c2Routes.GET("/tasks/:id/result-file", c2Handler.DownloadResultFile)
c2Routes.GET("/profiles", c2Handler.ListProfiles)
c2Routes.GET("/profiles/:id", c2Handler.GetProfile)
c2Routes.POST("/profiles", c2Handler.CreateProfile)
c2Routes.PUT("/profiles/:id", c2Handler.UpdateProfile)
c2Routes.DELETE("/profiles/:id", c2Handler.DeleteProfile)
// 对话附件(chat_uploads)管理 // 对话附件(chat_uploads)管理
protected.GET("/chat-uploads", chatUploadsHandler.List) protected.GET("/chat-uploads", chatUploadsHandler.List)
+104
View File
@@ -0,0 +1,104 @@
package app
import (
"context"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/handler"
"go.uber.org/zap"
)
// setupC2Runtime 创建 C2 Manager、看门狗与取消函数;不注册 MCP 工具(由 Apply 统一 ClearTools 后注册)。
func setupC2Runtime(
cfg *config.Config,
db *database.DB,
agentHandler *handler.AgentHandler,
logger *zap.Logger,
) (*c2.Manager, *c2.SessionWatchdog, context.CancelFunc) {
if !cfg.C2.EnabledEffective() {
return nil, nil, nil
}
c2Manager := c2.NewManager(db, logger, "tmp/c2")
c2Manager.Registry().Register(string(c2.ListenerTypeTCPReverse), c2.NewTCPReverseListener)
c2Manager.Registry().Register(string(c2.ListenerTypeHTTPBeacon), c2.NewHTTPBeaconListener)
c2Manager.Registry().Register(string(c2.ListenerTypeHTTPSBeacon), c2.NewHTTPSBeaconListener)
c2Manager.Registry().Register(string(c2.ListenerTypeWebSocket), c2.NewWebSocketListener)
c2HITLBridge := NewC2HITLBridge(db, logger)
c2Manager.SetHITLBridge(c2HITLBridge)
c2Manager.SetHITLDangerousGate(func(conversationID, toolName string) bool {
return agentHandler.HITLNeedsToolApproval(conversationID, toolName)
})
c2Hooks := SetupC2Hooks(&C2HooksConfig{
DB: db,
Logger: logger,
AttackChainRecord: func(session *database.C2Session, phase string, description string) {
logger.Info("C2 Attack Chain",
zap.String("session_id", session.ID),
zap.String("phase", phase),
zap.String("desc", description),
)
},
VulnRecord: func(session *database.C2Session, title string, severity string) {
logger.Info("C2 Vulnerability",
zap.String("session_id", session.ID),
zap.String("title", title),
zap.String("severity", severity),
)
},
})
c2Manager.SetHooks(c2Hooks)
c2Manager.RestoreRunningListeners()
c2Watchdog := c2.NewSessionWatchdog(c2Manager)
watchdogCtx, watchdogCancel := context.WithCancel(context.Background())
go c2Watchdog.Run(watchdogCtx)
return c2Manager, c2Watchdog, watchdogCancel
}
// ReconcileC2AfterConfigApply 根据当前内存配置启停 C2(不写盘;在 Apply 中 ClearTools 之前调用)。
func (a *App) ReconcileC2AfterConfigApply() error {
if !a.config.C2.EnabledEffective() {
a.shutdownC2()
return nil
}
if a.c2Manager != nil {
return nil
}
if a.db == nil || a.agentHandler == nil {
return nil
}
m, wd, cancel := setupC2Runtime(a.config, a.db, a.agentHandler, a.logger.Logger)
if m == nil {
return nil
}
a.c2Manager = m
a.c2Watchdog = wd
a.c2WatchdogCancel = cancel
if a.c2Handler != nil {
a.c2Handler.SetManager(m)
}
a.logger.Info("C2 子系统已按配置启动")
return nil
}
// shutdownC2 停止看门狗与所有监听器,并断开 Handler 引用。
func (a *App) shutdownC2() {
had := a.c2WatchdogCancel != nil || a.c2Manager != nil
if a.c2WatchdogCancel != nil {
a.c2WatchdogCancel()
a.c2WatchdogCancel = nil
}
a.c2Watchdog = nil
if a.c2Manager != nil {
a.c2Manager.Close()
a.c2Manager = nil
}
if a.c2Handler != nil {
a.c2Handler.SetManager(nil)
}
if had {
a.logger.Info("C2 子系统已关闭")
}
}
+2 -2
View File
@@ -301,7 +301,7 @@ func (b *Builder) formatProcessDetailsForAttackChain(details []database.ProcessD
// 目标:以主 agent(编排器)视角输出整轮迭代 // 目标:以主 agent(编排器)视角输出整轮迭代
// - 保留:编排器工具调用/结果、对子代理的 task 调度、子代理最终回复(不含推理) // - 保留:编排器工具调用/结果、对子代理的 task 调度、子代理最终回复(不含推理)
// - 丢弃:thinking/planning/progress 等噪声、子代理的工具细节与推理过程 // - 丢弃:thinking/planning/progress 等噪声、子代理的工具细节与推理过程
if d.EventType == "progress" || d.EventType == "thinking" || d.EventType == "planning" { if d.EventType == "progress" || d.EventType == "thinking" || d.EventType == "reasoning_chain" || d.EventType == "planning" {
continue continue
} }
@@ -812,7 +812,7 @@ func (b *Builder) callAIForChainGeneration(ctx context.Context, prompt string) (
}, },
}, },
"temperature": 0.3, "temperature": 0.3,
"max_tokens": 8000, "max_completion_tokens": 80000,
} }
var apiResponse struct { var apiResponse struct {
+96 -2
View File
@@ -28,6 +28,7 @@ type Config struct {
Auth AuthConfig `yaml:"auth"` Auth AuthConfig `yaml:"auth"`
ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"` ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"`
Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"` Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"`
C2 C2Config `yaml:"c2,omitempty" json:"c2,omitempty"` // 内置 C2 总开关;未配置时默认启用
Robots RobotsConfig `yaml:"robots,omitempty" json:"robots,omitempty"` // 企业微信/钉钉/飞书等机器人配置 Robots RobotsConfig `yaml:"robots,omitempty" json:"robots,omitempty"` // 企业微信/钉钉/飞书等机器人配置
RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式) RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式)
Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色 Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色
@@ -89,7 +90,8 @@ type MultiAgentEinoMiddlewareConfig struct {
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"` SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
// SummarizationEmitInternalEvents controls middleware internal event emission (default true). // SummarizationEmitInternalEvents controls middleware internal event emission (default true).
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"` SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
// HistoryInputBudgetRatio caps pre-agent history tokens as max_total_tokens * ratio (default 0.35). // HistoryInputBudgetRatio 已不影响 Eino:从 last_react 轨迹转 ADK 消息时**不再**按 token 比例裁剪(完整注入)。
// 字段仍保留,便于旧版 config 不报错;新部署可省略。
HistoryInputBudgetRatio float64 `yaml:"history_input_budget_ratio,omitempty" json:"history_input_budget_ratio,omitempty"` HistoryInputBudgetRatio float64 `yaml:"history_input_budget_ratio,omitempty" json:"history_input_budget_ratio,omitempty"`
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35). // PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"` PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
@@ -274,11 +276,25 @@ type MultiAgentAPIUpdate struct {
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等) // RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
type RobotsConfig struct { type RobotsConfig struct {
Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信 Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉 Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书 Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
} }
// RobotSessionConfig 机器人会话隔离策略
type RobotSessionConfig struct {
StrictUserIdentity *bool `yaml:"strict_user_identity,omitempty" json:"strict_user_identity,omitempty"` // true 时只允许真实用户标识,不允许会话/群 ID 兜底
}
// StrictUserIdentityEnabled 返回是否启用严格用户身份模式;未配置时默认 true。
func (c RobotSessionConfig) StrictUserIdentityEnabled() bool {
if c.StrictUserIdentity == nil {
return true
}
return *c.StrictUserIdentity
}
// RobotWecomConfig 企业微信机器人配置 // RobotWecomConfig 企业微信机器人配置
type RobotWecomConfig struct { type RobotWecomConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `yaml:"enabled" json:"enabled"`
@@ -294,6 +310,7 @@ type RobotDingtalkConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `yaml:"enabled" json:"enabled"`
ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey) ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey)
ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret
AllowConversationIDFallback bool `yaml:"allow_conversation_id_fallback" json:"allow_conversation_id_fallback"` // sender_id 缺失时是否允许回退到会话 ID
} }
// RobotLarkConfig 飞书机器人配置 // RobotLarkConfig 飞书机器人配置
@@ -302,6 +319,7 @@ type RobotLarkConfig struct {
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选) VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
AllowChatIDFallback bool `yaml:"allow_chat_id_fallback" json:"allow_chat_id_fallback"` // 用户 ID 缺失时是否允许回退到 chat_id
} }
type ServerConfig struct { type ServerConfig struct {
@@ -328,6 +346,48 @@ type OpenAIConfig struct {
BaseURL string `yaml:"base_url" json:"base_url"` BaseURL string `yaml:"base_url" json:"base_url"`
Model string `yaml:"model" json:"model"` Model string `yaml:"model" json:"model"`
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"` MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
// Reasoning 控制 Eino ChatModel 的 thinking / reasoning_effort / output_config 等(仅 Eino 路径生效;原生 ReAct 忽略)。
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
}
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
type OpenAIReasoningConfig struct {
// Mode: auto(默认)| on | off | default(与 auto 相同)。off 时不向模型附加推理扩展字段。
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
// Effort: low | medium | high | max;空表示不单独指定强度(各 profile 行为见 internal/reasoning)。
Effort string `yaml:"effort,omitempty" json:"effort,omitempty"`
// AllowClientReasoning 为 false 时忽略请求体 reasoningnil 或未设置等同于 true。
AllowClientReasoning *bool `yaml:"allow_client_reasoning,omitempty" json:"allow_client_reasoning,omitempty"`
// Profile: auto | deepseek_compat | openai_compat | output_config_effort
Profile string `yaml:"profile,omitempty" json:"profile,omitempty"`
// ExtraRequestFields 合并进 Chat Completions 根 JSON(管理员用;与自动字段同名时后者覆盖)。
ExtraRequestFields map[string]interface{} `yaml:"extra_request_fields,omitempty" json:"extra_request_fields,omitempty"`
}
// ModeEffective returns auto when empty or default.
func (c OpenAIReasoningConfig) ModeEffective() string {
m := strings.ToLower(strings.TrimSpace(c.Mode))
if m == "" || m == "default" {
return "auto"
}
return m
}
// ProfileEffective returns auto when empty.
func (c OpenAIReasoningConfig) ProfileEffective() string {
p := strings.ToLower(strings.TrimSpace(c.Profile))
if p == "" {
return "auto"
}
return p
}
// AllowClientReasoningEffective true when client may send ChatRequest.reasoning.
func (c OpenAIReasoningConfig) AllowClientReasoningEffective() bool {
if c.AllowClientReasoning == nil {
return true
}
return *c.AllowClientReasoning
} }
type FofaConfig struct { type FofaConfig struct {
@@ -464,7 +524,6 @@ func Load(path string) (*Config, error) {
if cfg.Auth.SessionDurationHours <= 0 { if cfg.Auth.SessionDurationHours <= 0 {
cfg.Auth.SessionDurationHours = 12 cfg.Auth.SessionDurationHours = 12
} }
if strings.TrimSpace(cfg.Auth.Password) == "" { if strings.TrimSpace(cfg.Auth.Password) == "" {
password, err := generateStrongPassword(24) password, err := generateStrongPassword(24)
if err != nil { if err != nil {
@@ -933,6 +992,7 @@ func LoadRoleFromFile(path string) (*RoleConfig, error) {
} }
func Default() *Config { func Default() *Config {
strictRobotIdentity := true
return &Config{ return &Config{
Server: ServerConfig{ Server: ServerConfig{
Host: "0.0.0.0", Host: "0.0.0.0",
@@ -967,6 +1027,11 @@ func Default() *Config {
Auth: AuthConfig{ Auth: AuthConfig{
SessionDurationHours: 12, SessionDurationHours: 12,
}, },
Robots: RobotsConfig{
Session: RobotSessionConfig{
StrictUserIdentity: &strictRobotIdentity,
},
},
Knowledge: KnowledgeConfig{ Knowledge: KnowledgeConfig{
Enabled: true, Enabled: true,
BasePath: "knowledge_base", BasePath: "knowledge_base",
@@ -997,6 +1062,35 @@ func Default() *Config {
} }
} }
// C2Config 内置 C2 模块开关(与知识库 enabled 语义一致:关闭后不初始化监听器、不注册 C2 MCP 工具)。
type C2Config struct {
// Enabled 为 nil 表示未写配置,按 true 处理(兼容旧 config.yaml
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
}
// EnabledEffective 返回是否启用 C2;未显式配置时默认启用。
func (c C2Config) EnabledEffective() bool {
if c.Enabled == nil {
return true
}
return *c.Enabled
}
// C2Public 返回给前端的 C2 状态(仅标量)。
type C2Public struct {
Enabled bool `json:"enabled"`
}
// Public 将内部配置转为 API 响应。
func (c C2Config) Public() C2Public {
return C2Public{Enabled: c.EnabledEffective()}
}
// C2APIUpdate 设置页/API 更新 C2 开关。
type C2APIUpdate struct {
Enabled bool `json:"enabled"`
}
// KnowledgeConfig 知识库配置 // KnowledgeConfig 知识库配置
type KnowledgeConfig struct { type KnowledgeConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用知识检索 Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用知识检索
+49 -6
View File
@@ -29,9 +29,11 @@ type Message struct {
ConversationID string `json:"conversationId"` ConversationID string `json:"conversationId"`
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
ReasoningContent string `json:"reasoningContent,omitempty"`
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"`
ProcessDetails []map[string]interface{} `json:"processDetails,omitempty"` ProcessDetails []map[string]interface{} `json:"processDetails,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
// CreateConversation 创建新对话 // CreateConversation 创建新对话
@@ -484,6 +486,7 @@ func (db *DB) ConversationHasToolProcessDetails(conversationID string) (bool, er
// AddMessage 添加消息 // AddMessage 添加消息
func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs []string) (*Message, error) { func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs []string) (*Message, error) {
id := uuid.New().String() id := uuid.New().String()
now := time.Now()
var mcpIDsJSON string var mcpIDsJSON string
if len(mcpExecutionIDs) > 0 { if len(mcpExecutionIDs) > 0 {
@@ -496,8 +499,8 @@ func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs [
} }
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO messages (id, conversation_id, role, content, mcp_execution_ids, created_at) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO messages (id, conversation_id, role, content, reasoning_content, mcp_execution_ids, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
id, conversationID, role, content, mcpIDsJSON, time.Now(), id, conversationID, role, content, "", mcpIDsJSON, now, now,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("添加消息失败: %w", err) return nil, fmt.Errorf("添加消息失败: %w", err)
@@ -514,16 +517,37 @@ func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs [
Role: role, Role: role,
Content: content, Content: content,
MCPExecutionIDs: mcpExecutionIDs, MCPExecutionIDs: mcpExecutionIDs,
CreatedAt: time.Now(), CreatedAt: now,
UpdatedAt: now,
} }
return message, nil return message, nil
} }
// UpdateAssistantMessageFinalize 更新助手消息终态(正文、MCP id、思考链聚合文本,供无轨迹回退时回放)。
func (db *DB) UpdateAssistantMessageFinalize(messageID, content string, mcpExecutionIDs []string, reasoningContent string) error {
var mcpIDsJSON string
if len(mcpExecutionIDs) > 0 {
jsonData, err := json.Marshal(mcpExecutionIDs)
if err != nil {
return fmt.Errorf("序列化MCP执行ID失败: %w", err)
}
mcpIDsJSON = string(jsonData)
}
_, err := db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ?, reasoning_content = ?, updated_at = ? WHERE id = ?",
content, mcpIDsJSON, strings.TrimSpace(reasoningContent), time.Now(), messageID,
)
if err != nil {
return fmt.Errorf("更新助手消息失败: %w", err)
}
return nil
}
// GetMessages 获取对话的所有消息 // GetMessages 获取对话的所有消息
func (db *DB) GetMessages(conversationID string) ([]Message, error) { func (db *DB) GetMessages(conversationID string) ([]Message, error) {
rows, err := db.Query( rows, err := db.Query(
"SELECT id, conversation_id, role, content, mcp_execution_ids, created_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC", "SELECT id, conversation_id, role, content, reasoning_content, mcp_execution_ids, created_at, updated_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",
conversationID, conversationID,
) )
if err != nil { if err != nil {
@@ -534,12 +558,17 @@ func (db *DB) GetMessages(conversationID string) ([]Message, error) {
var messages []Message var messages []Message
for rows.Next() { for rows.Next() {
var msg Message var msg Message
var reasoning sql.NullString
var mcpIDsJSON sql.NullString var mcpIDsJSON sql.NullString
var createdAt string var createdAt string
var updatedAt sql.NullString
if err := rows.Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &mcpIDsJSON, &createdAt); err != nil { if err := rows.Scan(&msg.ID, &msg.ConversationID, &msg.Role, &msg.Content, &reasoning, &mcpIDsJSON, &createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("扫描消息失败: %w", err) return nil, fmt.Errorf("扫描消息失败: %w", err)
} }
if reasoning.Valid {
msg.ReasoningContent = reasoning.String
}
// 尝试多种时间格式解析 // 尝试多种时间格式解析
var err error var err error
@@ -551,6 +580,20 @@ func (db *DB) GetMessages(conversationID string) ([]Message, error) {
msg.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) msg.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
} }
// updated_at 兼容老库:字段不存在/为空时回退为 created_at
if updatedAt.Valid && strings.TrimSpace(updatedAt.String) != "" {
msg.UpdatedAt, err = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt.String)
if err != nil {
msg.UpdatedAt, err = time.Parse("2006-01-02 15:04:05", updatedAt.String)
}
if err != nil {
msg.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
}
}
if msg.UpdatedAt.IsZero() {
msg.UpdatedAt = msg.CreatedAt
}
// 解析MCP执行ID // 解析MCP执行ID
if mcpIDsJSON.Valid && mcpIDsJSON.String != "" { if mcpIDsJSON.Valid && mcpIDsJSON.String != "" {
if err := json.Unmarshal([]byte(mcpIDsJSON.String), &msg.MCPExecutionIDs); err != nil { if err := json.Unmarshal([]byte(mcpIDsJSON.String), &msg.MCPExecutionIDs); err != nil {
@@ -665,7 +708,7 @@ type ProcessDetail struct {
ID string `json:"id"` ID string `json:"id"`
MessageID string `json:"messageId"` MessageID string `json:"messageId"`
ConversationID string `json:"conversationId"` ConversationID string `json:"conversationId"`
EventType string `json:"eventType"` // iteration, thinking, tool_calls_detected, tool_call, tool_result, progress, error EventType string `json:"eventType"` // iteration, thinking, reasoning_chain, tool_calls_detected, tool_call, tool_result, progress, error
Message string `json:"message"` Message string `json:"message"`
Data string `json:"data"` // JSON格式的数据 Data string `json:"data"` // JSON格式的数据
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
+66
View File
@@ -82,6 +82,7 @@ func (db *DB) initTables() error {
content TEXT NOT NULL, content TEXT NOT NULL,
mcp_execution_ids TEXT, mcp_execution_ids TEXT,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);` );`
@@ -202,6 +203,16 @@ func (db *DB) initTables() error {
UNIQUE(conversation_id, group_id) UNIQUE(conversation_id, group_id)
);` );`
// 机器人会话绑定表(用于跨重启保持「平台+租户+用户」到 conversation 的映射)
createRobotUserSessionsTable := `
CREATE TABLE IF NOT EXISTS robot_user_sessions (
session_key TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role_name TEXT NOT NULL DEFAULT '默认',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);`
// 创建漏洞表 // 创建漏洞表
createVulnerabilitiesTable := ` createVulnerabilitiesTable := `
CREATE TABLE IF NOT EXISTS vulnerabilities ( CREATE TABLE IF NOT EXISTS vulnerabilities (
@@ -408,6 +419,7 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_knowledge_retrieval_logs_created_at ON knowledge_retrieval_logs(created_at); CREATE INDEX IF NOT EXISTS idx_knowledge_retrieval_logs_created_at ON knowledge_retrieval_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_conversation ON conversation_group_mappings(conversation_id); CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_conversation ON conversation_group_mappings(conversation_id);
CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_group ON conversation_group_mappings(group_id); CREATE INDEX IF NOT EXISTS idx_conversation_group_mappings_group ON conversation_group_mappings(group_id);
CREATE INDEX IF NOT EXISTS idx_robot_user_sessions_updated_at ON robot_user_sessions(updated_at);
CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned); CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_id ON vulnerabilities(conversation_id); CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_id ON vulnerabilities(conversation_id);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_tag ON vulnerabilities(conversation_tag); CREATE INDEX IF NOT EXISTS idx_vulnerabilities_conversation_tag ON vulnerabilities(conversation_tag);
@@ -478,6 +490,9 @@ func (db *DB) initTables() error {
if _, err := db.Exec(createConversationGroupMappingsTable); err != nil { if _, err := db.Exec(createConversationGroupMappingsTable); err != nil {
return fmt.Errorf("创建conversation_group_mappings表失败: %w", err) return fmt.Errorf("创建conversation_group_mappings表失败: %w", err)
} }
if _, err := db.Exec(createRobotUserSessionsTable); err != nil {
return fmt.Errorf("创建robot_user_sessions表失败: %w", err)
}
if _, err := db.Exec(createVulnerabilitiesTable); err != nil { if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
return fmt.Errorf("创建vulnerabilities表失败: %w", err) return fmt.Errorf("创建vulnerabilities表失败: %w", err)
@@ -518,6 +533,11 @@ func (db *DB) initTables() error {
// 不返回错误,允许继续运行 // 不返回错误,允许继续运行
} }
if err := db.migrateMessagesTable(); err != nil {
db.logger.Warn("迁移messages表失败", zap.Error(err))
// 不返回错误,允许继续运行
}
if err := db.migrateConversationGroupsTable(); err != nil { if err := db.migrateConversationGroupsTable(); err != nil {
db.logger.Warn("迁移conversation_groups表失败", zap.Error(err)) db.logger.Warn("迁移conversation_groups表失败", zap.Error(err))
// 不返回错误,允许继续运行 // 不返回错误,允许继续运行
@@ -550,6 +570,52 @@ func (db *DB) initTables() error {
return nil return nil
} }
// migrateMessagesTable 迁移 messages 表,补充 updated_at 字段。
// 语义:updated_at 表示该条消息最后一次被写入/更新的时间(例如助手占位消息在任务结束时更新正文)。
func (db *DB) migrateMessagesTable() error {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('messages') WHERE name='updated_at'").Scan(&count)
if err != nil {
// 如果查询失败,尝试添加字段
if _, addErr := db.Exec("ALTER TABLE messages ADD COLUMN updated_at DATETIME"); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
return fmt.Errorf("添加 messages.updated_at 字段失败: %w", addErr)
}
}
} else if count == 0 {
if _, err := db.Exec("ALTER TABLE messages ADD COLUMN updated_at DATETIME"); err != nil {
errMsg := strings.ToLower(err.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
return fmt.Errorf("添加 messages.updated_at 字段失败: %w", err)
}
}
}
// 回填已有数据:让 updated_at 至少等于 created_at,避免前端出现空/当前时间回退。
_, _ = db.Exec("UPDATE messages SET updated_at = created_at WHERE updated_at IS NULL OR updated_at = ''")
// reasoning_contentDeepSeek 思考模式 + 工具调用续跑;与 last_react_input 互补,供消息表回退路径回放
var rcColCount int
errRC := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('messages') WHERE name='reasoning_content'").Scan(&rcColCount)
if errRC != nil {
if _, addErr := db.Exec("ALTER TABLE messages ADD COLUMN reasoning_content TEXT"); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
return fmt.Errorf("添加 messages.reasoning_content 字段失败: %w", addErr)
}
}
} else if rcColCount == 0 {
if _, err := db.Exec("ALTER TABLE messages ADD COLUMN reasoning_content TEXT"); err != nil {
errMsg := strings.ToLower(err.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
return fmt.Errorf("添加 messages.reasoning_content 字段失败: %w", err)
}
}
}
return nil
}
// migrateConversationsTable 迁移conversations表,添加新字段 // migrateConversationsTable 迁移conversations表,添加新字段
func (db *DB) migrateConversationsTable() error { func (db *DB) migrateConversationsTable() error {
// 检查last_react_input字段是否存在 // 检查last_react_input字段是否存在
+84
View File
@@ -0,0 +1,84 @@
package database
import (
"database/sql"
"fmt"
"strings"
"time"
)
// RobotSessionBinding 机器人会话绑定信息。
type RobotSessionBinding struct {
SessionKey string
ConversationID string
RoleName string
UpdatedAt time.Time
}
// GetRobotSessionBinding 按 session_key 获取机器人会话绑定。
func (db *DB) GetRobotSessionBinding(sessionKey string) (*RobotSessionBinding, error) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return nil, nil
}
var b RobotSessionBinding
var updatedAt string
err := db.QueryRow(
"SELECT session_key, conversation_id, role_name, updated_at FROM robot_user_sessions WHERE session_key = ?",
sessionKey,
).Scan(&b.SessionKey, &b.ConversationID, &b.RoleName, &updatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("查询机器人会话绑定失败: %w", err)
}
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt); e == nil {
b.UpdatedAt = t
} else if t, e := time.Parse("2006-01-02 15:04:05", updatedAt); e == nil {
b.UpdatedAt = t
} else {
b.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
if strings.TrimSpace(b.RoleName) == "" {
b.RoleName = "默认"
}
return &b, nil
}
// UpsertRobotSessionBinding 写入或更新机器人会话绑定(包含角色)。
func (db *DB) UpsertRobotSessionBinding(sessionKey, conversationID, roleName string) error {
sessionKey = strings.TrimSpace(sessionKey)
conversationID = strings.TrimSpace(conversationID)
roleName = strings.TrimSpace(roleName)
if sessionKey == "" || conversationID == "" {
return nil
}
if roleName == "" {
roleName = "默认"
}
_, err := db.Exec(`
INSERT INTO robot_user_sessions (session_key, conversation_id, role_name, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_key) DO UPDATE SET
conversation_id = excluded.conversation_id,
role_name = excluded.role_name,
updated_at = excluded.updated_at
`, sessionKey, conversationID, roleName, time.Now())
if err != nil {
return fmt.Errorf("写入机器人会话绑定失败: %w", err)
}
return nil
}
// DeleteRobotSessionBinding 删除机器人会话绑定。
func (db *DB) DeleteRobotSessionBinding(sessionKey string) error {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return nil
}
if _, err := db.Exec("DELETE FROM robot_user_sessions WHERE session_key = ?", sessionKey); err != nil {
return fmt.Errorf("删除机器人会话绑定失败: %w", err)
}
return nil
}
+28 -1
View File
@@ -23,12 +23,16 @@ type ExecutionRecorder func(executionID string)
const ToolErrorPrefix = "__CYBERSTRIKE_AI_TOOL_ERROR__\n" const ToolErrorPrefix = "__CYBERSTRIKE_AI_TOOL_ERROR__\n"
// ToolsFromDefinitions 将单 Agent 使用的 OpenAI 风格工具定义转为 Eino InvokableTool,执行时走 Agent 的 MCP 路径。 // ToolsFromDefinitions 将单 Agent 使用的 OpenAI 风格工具定义转为 Eino InvokableTool,执行时走 Agent 的 MCP 路径。
// invokeNotify 可选:与 runEinoADKAgentLoop 共享,在 InvokableRun 返回时触发 UI 与 pending 清理(与 ADK Tool 事件去重)。
// einoAgentName 为该套工具所属 ChatModelAgent 的 Name(主代理或子代理 id),用于 SSE 上的 einoAgent 字段。
func ToolsFromDefinitions( func ToolsFromDefinitions(
ag *agent.Agent, ag *agent.Agent,
holder *ConversationHolder, holder *ConversationHolder,
defs []agent.Tool, defs []agent.Tool,
rec ExecutionRecorder, rec ExecutionRecorder,
toolOutputChunk func(toolName, toolCallID, chunk string), toolOutputChunk func(toolName, toolCallID, chunk string),
invokeNotify *ToolInvokeNotifyHolder,
einoAgentName string,
) ([]tool.BaseTool, error) { ) ([]tool.BaseTool, error) {
out := make([]tool.BaseTool, 0, len(defs)) out := make([]tool.BaseTool, 0, len(defs))
for _, d := range defs { for _, d := range defs {
@@ -46,6 +50,8 @@ func ToolsFromDefinitions(
holder: holder, holder: holder,
record: rec, record: rec,
chunk: toolOutputChunk, chunk: toolOutputChunk,
invokeNotify: invokeNotify,
einoAgentName: strings.TrimSpace(einoAgentName),
}) })
} }
return out, nil return out, nil
@@ -83,6 +89,8 @@ type mcpBridgeTool struct {
holder *ConversationHolder holder *ConversationHolder
record ExecutionRecorder record ExecutionRecorder
chunk func(toolName, toolCallID, chunk string) chunk func(toolName, toolCallID, chunk string)
invokeNotify *ToolInvokeNotifyHolder
einoAgentName string
} }
func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) { func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
@@ -90,8 +98,27 @@ func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return m.info, nil return m.info, nil
} }
func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) { func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (out string, err error) {
_ = opts _ = opts
toolCallID := compose.GetToolCallID(ctx)
defer func() {
if m.invokeNotify == nil {
return
}
tid := strings.TrimSpace(toolCallID)
if tid == "" {
return
}
success := err == nil && !strings.HasPrefix(out, ToolErrorPrefix)
body := out
if err != nil {
success = false
} else if strings.HasPrefix(out, ToolErrorPrefix) {
success = false
body = strings.TrimPrefix(out, ToolErrorPrefix)
}
m.invokeNotify.Fire(tid, m.name, m.einoAgentName, success, body, err)
}()
return runMCPToolInvocation(ctx, m.agent, m.holder, m.name, argumentsInJSON, m.record, m.chunk) return runMCPToolInvocation(ctx, m.agent, m.holder, m.name, argumentsInJSON, m.record, m.chunk)
} }
+39
View File
@@ -0,0 +1,39 @@
package einomcp
import "sync"
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP 桥在每次 InvokableRun 结束时 Fire
// 用于在 ADK 未透出 schema.Tool 事件时仍推送 tool_result、清 pending,避免 UI 卡在「执行中」或迭代末 force-close。
type ToolInvokeNotifyHolder struct {
mu sync.RWMutex
fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)
}
// NewToolInvokeNotifyHolder 创建可在 ToolsFromDefinitions 与 run loop 之间共享的 holder。
func NewToolInvokeNotifyHolder() *ToolInvokeNotifyHolder {
return &ToolInvokeNotifyHolder{}
}
// Set 由 runEinoADKAgentLoop 在开始消费 iter 之前调用;可多次覆盖(通常仅一次)。
func (h *ToolInvokeNotifyHolder) Set(fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.fn = fn
}
// Fire 由 mcpBridgeTool 在工具调用返回时调用;若尚未 Set 或 toolCallID 为空则忽略。
func (h *ToolInvokeNotifyHolder) Fire(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
if h == nil {
return
}
h.mu.RLock()
fn := h.fn
h.mu.RUnlock()
if fn == nil {
return
}
fn(toolCallID, toolName, einoAgent, success, content, invokeErr)
}
+302 -122
View File
@@ -19,6 +19,8 @@ import (
"cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database" "cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/reasoning"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin" "cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/multiagent" "cyberstrike-ai/internal/multiagent"
@@ -200,6 +202,14 @@ type ChatAttachment struct {
ServerPath string `json:"serverPath,omitempty"` // 已保存在 chat_uploads 下的绝对路径(由 POST /api/chat-uploads 返回) ServerPath string `json:"serverPath,omitempty"` // 已保存在 chat_uploads 下的绝对路径(由 POST /api/chat-uploads 返回)
} }
// ChatReasoningRequest 对话页「模型推理」意图(仅 Eino 路径消费;原生 agent-loop 忽略)。
type ChatReasoningRequest struct {
// Mode: default(跟随系统)| off | on | auto
Mode string `json:"mode,omitempty"`
// Effort: low | medium | high | max;空表示不指定(由系统默认与各 profile 决定)。
Effort string `json:"effort,omitempty"`
}
// ChatRequest 聊天请求 // ChatRequest 聊天请求
type ChatRequest struct { type ChatRequest struct {
Message string `json:"message" binding:"required"` Message string `json:"message" binding:"required"`
@@ -208,10 +218,18 @@ type ChatRequest struct {
Attachments []ChatAttachment `json:"attachments,omitempty"` Attachments []ChatAttachment `json:"attachments,omitempty"`
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具 WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
Hitl *HITLRequest `json:"hitl,omitempty"` Hitl *HITLRequest `json:"hitl,omitempty"`
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/streamdeep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。 // Orchestration 仅对 /api/multi-agent、/api/multi-agent/streamdeep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
Orchestration string `json:"orchestration,omitempty"` Orchestration string `json:"orchestration,omitempty"`
} }
func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientIntent {
if r == nil {
return nil
}
return &reasoning.ClientIntent{Mode: r.Mode, Effort: r.Effort}
}
type HITLRequest struct { type HITLRequest struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
@@ -458,6 +476,57 @@ func appendAttachmentsToMessage(msg string, attachments []ChatAttachment, savedP
return b.String() return b.String()
} }
// appendAssistantMessageNotice 在助手消息末尾追加提示,避免覆盖已生成内容。
// 若消息为空则直接写入提示;若已包含相同提示则保持不变。
func (h *AgentHandler) appendAssistantMessageNotice(messageID, notice string) error {
trimmedNotice := strings.TrimSpace(notice)
if strings.TrimSpace(messageID) == "" || trimmedNotice == "" {
return nil
}
_, err := h.db.Exec(
`UPDATE messages
SET content = CASE
WHEN content IS NULL OR TRIM(content) = '' THEN ?
WHEN INSTR(content, ?) > 0 THEN content
ELSE content || '\n\n' || ?
END,
updated_at = ?
WHERE id = ?`,
trimmedNotice,
trimmedNotice,
trimmedNotice,
time.Now(),
messageID,
)
return err
}
// mergeAssistantMessagePartialOnCancel 将取消前已生成的部分回复尽量合并进消息:
// - content 为空或仅占位(处理中...)时,直接替换为 partial;
// - 已有正文时,仅在尚未包含 partial 时追加,避免丢失与重复。
func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial string) error {
trimmedPartial := strings.TrimSpace(partial)
if strings.TrimSpace(messageID) == "" || trimmedPartial == "" {
return nil
}
_, err := h.db.Exec(
`UPDATE messages
SET content = CASE
WHEN content IS NULL OR TRIM(content) = '' OR TRIM(content) = '处理中...' THEN ?
WHEN INSTR(content, ?) > 0 THEN content
ELSE content || '\n\n' || ?
END,
updated_at = ?
WHERE id = ?`,
trimmedPartial,
trimmedPartial,
trimmedPartial,
time.Now(),
messageID,
)
return err
}
// ChatResponse 聊天响应 // ChatResponse 聊天响应
type ChatResponse struct { type ChatResponse struct {
Response string `json:"response"` Response string `json:"response"`
@@ -515,14 +584,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
h.logger.Warn("获取历史消息失败", zap.Error(err)) h.logger.Warn("获取历史消息失败", zap.Error(err))
agentHistoryMessages = []agent.ChatMessage{} agentHistoryMessages = []agent.ChatMessage{}
} else { } else {
// 将数据库消息转换为Agent消息格式 agentHistoryMessages = dbMessagesToAgentChatMessages(historyMessages)
agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages))
for _, msg := range historyMessages {
agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{
Role: msg.Role,
Content: msg.Content,
})
}
h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages))) h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages)))
} }
} else { } else {
@@ -723,28 +785,22 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
progressCallback, progressCallback,
h.agentsMarkdownDir, h.agentsMarkdownDir,
"deep", "deep",
nil,
) )
if errMA != nil { if errMA != nil {
if shouldPersistEinoAgentTraceAfterRunError(ctx) {
h.persistEinoAgentTraceForResume(conversationID, resultMA) h.persistEinoAgentTraceForResume(conversationID, resultMA)
}
errMsg := "执行失败: " + errMA.Error() errMsg := "执行失败: " + errMA.Error()
if assistantMessageID != "" { if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID) _, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil) _ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
} }
return "", conversationID, errMA return "", conversationID, errMA
} }
if assistantMessageID != "" { if assistantMessageID != "" {
mcpIDsJSON := "" if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
if len(resultMA.MCPExecutionIDs) > 0 { h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
jsonData, _ := json.Marshal(resultMA.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
_, err = h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
resultMA.Response, mcpIDsJSON, assistantMessageID,
)
if err != nil {
h.logger.Warn("机器人:更新助手消息失败", zap.Error(err))
} }
} else { } else {
if _, err = h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil { if _, err = h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
@@ -761,7 +817,7 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
if err != nil { if err != nil {
errMsg := "执行失败: " + err.Error() errMsg := "执行失败: " + err.Error()
if assistantMessageID != "" { if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID) _, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil) _ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
} }
return "", conversationID, err return "", conversationID, err
@@ -769,17 +825,8 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
// 更新助手消息内容与 MCP 执行 ID(与 stream 一致) // 更新助手消息内容与 MCP 执行 ID(与 stream 一致)
if assistantMessageID != "" { if assistantMessageID != "" {
mcpIDsJSON := "" if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)); errU != nil {
if len(result.MCPExecutionIDs) > 0 { h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
_, err = h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
result.Response, mcpIDsJSON, assistantMessageID,
)
if err != nil {
h.logger.Warn("机器人:更新助手消息失败", zap.Error(err))
} }
} else { } else {
if _, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs); err != nil { if _, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs); err != nil {
@@ -837,10 +884,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
return "" return ""
} }
// thinking_stream_*:不逐条落库,按 streamId 聚合,在后续关键事件前补一条可持久化的 thinking // thinking_stream_*(ReAct 等助手正文流)与 reasoning_chain_stream_*Eino ReasoningContent):
// 不逐条落库,按 streamId 聚合,flush 时分别落 thinking / reasoning_chain。
type thinkingBuf struct { type thinkingBuf struct {
b strings.Builder b strings.Builder
meta map[string]interface{} meta map[string]interface{}
persistAs string // "thinking" | "reasoning_chain"
} }
thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf
flushedThinking := make(map[string]bool) // streamId -> flushed flushedThinking := make(map[string]bool) // streamId -> flushed
@@ -894,8 +943,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
} }
data[k] = v data[k] = v
} }
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "thinking", content, data); err != nil { persist := tb.persistAs
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "thinking")) if persist != "reasoning_chain" {
persist = "thinking"
}
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, persist, content, data); err != nil {
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", persist))
} }
flushedThinking[sid] = true flushedThinking[sid] = true
} }
@@ -1123,14 +1176,20 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
return return
} }
// 聚合 thinking_stream_*ReasoningContent,不逐条落库 // 聚合 thinking_stream_* / reasoning_chain_stream_*,不逐条落库
if eventType == "thinking_stream_start" { if eventType == "thinking_stream_start" || eventType == "reasoning_chain_stream_start" {
persistAs := "thinking"
if eventType == "reasoning_chain_stream_start" {
persistAs = "reasoning_chain"
}
if dataMap, ok := data.(map[string]interface{}); ok { if dataMap, ok := data.(map[string]interface{}); ok {
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" { if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
tb := thinkingStreams[sid] tb := thinkingStreams[sid]
if tb == nil { if tb == nil {
tb = &thinkingBuf{meta: map[string]interface{}{}} tb = &thinkingBuf{meta: map[string]interface{}{}, persistAs: persistAs}
thinkingStreams[sid] = tb thinkingStreams[sid] = tb
} else {
tb.persistAs = persistAs
} }
// 记录元信息(source/einoAgent/einoRole/iteration 等) // 记录元信息(source/einoAgent/einoRole/iteration 等)
for k, v := range dataMap { for k, v := range dataMap {
@@ -1140,15 +1199,21 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
} }
return return
} }
if eventType == "thinking_stream_delta" { if eventType == "thinking_stream_delta" || eventType == "reasoning_chain_stream_delta" {
persistAs := "thinking"
if eventType == "reasoning_chain_stream_delta" {
persistAs = "reasoning_chain"
}
if dataMap, ok := data.(map[string]interface{}); ok { if dataMap, ok := data.(map[string]interface{}); ok {
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" { if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
tb := thinkingStreams[sid] tb := thinkingStreams[sid]
if tb == nil { if tb == nil {
tb = &thinkingBuf{meta: map[string]interface{}{}} tb = &thinkingBuf{meta: map[string]interface{}{}, persistAs: persistAs}
thinkingStreams[sid] = tb thinkingStreams[sid] = tb
} else if tb.persistAs == "" {
tb.persistAs = persistAs
} }
// delta 片段直接拼接message 本身就是 reasoning content // delta 片段直接拼接
tb.b.WriteString(message) tb.b.WriteString(message)
// 有时 delta 先到 start 未到,补充元信息 // 有时 delta 先到 start 未到,补充元信息
for k, v := range dataMap { for k, v := range dataMap {
@@ -1159,10 +1224,9 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
return return
} }
// 当 Agent 同时发送 thinking_stream_* 和 thinking(带同一 streamId时, // 当 Agent 同时发送 *_stream_* 与同名 streamId 的 thinking/reasoning_chain 时,
// thinking_stream_* 已经会在 flushThinkingStreams() 聚合落库; // 流式聚合已会在 flushThinkingStreams() 落库;此处跳过逐条重复。
// 这里跳过同 streamId 的 thinking,避免 processDetails 双份展示。 if eventType == "thinking" || eventType == "reasoning_chain" {
if eventType == "thinking" {
if dataMap, ok := data.(map[string]interface{}); ok { if dataMap, ok := data.(map[string]interface{}); ok {
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" { if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
if tb, exists := thinkingStreams[sid]; exists && tb != nil { if tb, exists := thinkingStreams[sid]; exists && tb != nil {
@@ -1191,7 +1255,7 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
if eventType == "tool_result" { if eventType == "tool_result" {
discardPlanningIfEchoesToolResult(&respPlan, data) discardPlanningIfEchoesToolResult(&respPlan, data)
} }
// 在关键过程事件落库前,先把「规划中」与 thinking_stream 落库 // 在关键过程事件落库前,先把「规划中」与聚合中的 thinking / reasoning_chain 流落库
flushResponsePlan() flushResponsePlan()
flushThinkingStreams() flushThinkingStreams()
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil { if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
@@ -1373,14 +1437,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
h.logger.Warn("获取历史消息失败", zap.Error(err)) h.logger.Warn("获取历史消息失败", zap.Error(err))
agentHistoryMessages = []agent.ChatMessage{} agentHistoryMessages = []agent.ChatMessage{}
} else { } else {
// 将数据库消息转换为Agent消息格式 agentHistoryMessages = dbMessagesToAgentChatMessages(historyMessages)
agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages))
for _, msg := range historyMessages {
agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{
Role: msg.Role,
Content: msg.Content,
})
}
h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages))) h.logger.Info("从消息表加载历史消息", zap.Int("count", len(agentHistoryMessages)))
} }
} else { } else {
@@ -1493,6 +1550,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute) taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
defer timeoutCancel() defer timeoutCancel()
defer cancelWithCause(nil) defer cancelWithCause(nil)
taskCtx = mcp.WithMCPConversationID(taskCtx, conversationID)
taskCtx = mcp.WithToolRunRegistry(taskCtx, h.tasks)
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent) progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
taskCtx = h.injectReactHITLInterceptor(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent) taskCtx = h.injectReactHITLInterceptor(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
@@ -1515,9 +1574,9 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 更新助手消息内容并保存错误详情到数据库 // 更新助手消息内容并保存错误详情到数据库
if assistantMessageID != "" { if assistantMessageID != "" {
if _, updateErr := h.db.Exec( if _, updateErr := h.db.Exec(
"UPDATE messages SET content = ? WHERE id = ?", "UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
errorMsg, errorMsg,
assistantMessageID, time.Now(), assistantMessageID,
); updateErr != nil { ); updateErr != nil {
h.logger.Warn("更新错误后的助手消息失败", zap.Error(updateErr)) h.logger.Warn("更新错误后的助手消息失败", zap.Error(updateErr))
} }
@@ -1568,11 +1627,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
h.tasks.UpdateTaskStatus(conversationID, taskStatus) h.tasks.UpdateTaskStatus(conversationID, taskStatus)
if assistantMessageID != "" { if assistantMessageID != "" {
if _, updateErr := h.db.Exec( if result != nil {
"UPDATE messages SET content = ? WHERE id = ?", if updateErr := h.mergeAssistantMessagePartialOnCancel(assistantMessageID, result.Response); updateErr != nil {
cancelMsg, h.logger.Warn("合并取消前的部分回复失败", zap.Error(updateErr))
assistantMessageID, }
); updateErr != nil { }
if updateErr := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); updateErr != nil {
h.logger.Warn("更新取消后的助手消息失败", zap.Error(updateErr)) h.logger.Warn("更新取消后的助手消息失败", zap.Error(updateErr))
} }
h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil) h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
@@ -1604,9 +1664,9 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
if assistantMessageID != "" { if assistantMessageID != "" {
if _, updateErr := h.db.Exec( if _, updateErr := h.db.Exec(
"UPDATE messages SET content = ? WHERE id = ?", "UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
timeoutMsg, timeoutMsg,
assistantMessageID, time.Now(), assistantMessageID,
); updateErr != nil { ); updateErr != nil {
h.logger.Warn("更新超时后的助手消息失败", zap.Error(updateErr)) h.logger.Warn("更新超时后的助手消息失败", zap.Error(updateErr))
} }
@@ -1639,9 +1699,9 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
if assistantMessageID != "" { if assistantMessageID != "" {
if _, updateErr := h.db.Exec( if _, updateErr := h.db.Exec(
"UPDATE messages SET content = ? WHERE id = ?", "UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
errorMsg, errorMsg,
assistantMessageID, time.Now(), assistantMessageID,
); updateErr != nil { ); updateErr != nil {
h.logger.Warn("更新失败后的助手消息失败", zap.Error(updateErr)) h.logger.Warn("更新失败后的助手消息失败", zap.Error(updateErr))
} }
@@ -1670,20 +1730,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 更新助手消息内容 // 更新助手消息内容
if assistantMsg != nil { if assistantMsg != nil {
_, err = h.db.Exec( if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput)); errU != nil {
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?", h.logger.Error("更新助手消息失败", zap.Error(errU))
result.Response,
func() string {
if len(result.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
return string(jsonData)
}
return ""
}(),
assistantMessageID,
)
if err != nil {
h.logger.Error("更新助手消息失败", zap.Error(err))
} }
} else { } else {
// 如果之前创建失败,现在创建 // 如果之前创建失败,现在创建
@@ -1717,6 +1765,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
func (h *AgentHandler) CancelAgentLoop(c *gin.Context) { func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
var req struct { var req struct {
ConversationID string `json:"conversationId" binding:"required"` ConversationID string `json:"conversationId" binding:"required"`
Reason string `json:"reason,omitempty"`
ContinueAfter bool `json:"continueAfter,omitempty"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -1724,7 +1774,64 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
return return
} }
ok, err := h.tasks.CancelTask(req.ConversationID, ErrTaskCancelled) if req.ContinueAfter {
if h.tasks.GetTask(req.ConversationID) == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "未找到正在执行的任务"})
return
}
execID := h.tasks.ActiveMCPExecutionID(req.ConversationID)
note := strings.TrimSpace(req.Reason)
if execID != "" {
if !h.agent.CancelMCPToolExecutionWithNote(execID, note) {
c.JSON(http.StatusNotFound, gin.H{"error": "未找到进行中的工具执行或该调用已结束"})
return
}
h.logger.Info("对话页仅终止当前 MCP 工具",
zap.String("conversationId", req.ConversationID),
zap.String("executionId", execID),
zap.Bool("hasNote", note != ""),
)
c.JSON(http.StatusOK, gin.H{
"status": "tool_abort_requested",
"conversationId": req.ConversationID,
"executionId": execID,
"message": "已请求终止当前工具调用;工具返回后本轮推理将继续(与 MCP 监控页终止一致)。",
"continueAfter": true,
"interruptWithNote": note != "",
"continueWithoutTool": false,
})
return
}
// 无进行中的 MCP 工具(模型纯推理/流式输出阶段):取消当前上下文并由 Eino 流式处理器合并用户补充后自动续跑。
h.tasks.SetInterruptContinueNote(req.ConversationID, note)
ok, err := h.tasks.CancelTask(req.ConversationID, multiagent.ErrInterruptContinue)
if err != nil {
h.logger.Error("中断并继续(无工具)失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "未找到正在执行的任务"})
return
}
h.logger.Info("对话页中断并继续(无 MCP 工具,将自动续跑)",
zap.String("conversationId", req.ConversationID),
zap.Bool("hasNote", note != ""),
)
c.JSON(http.StatusOK, gin.H{
"status": "interrupt_continue_scheduled",
"conversationId": req.ConversationID,
"message": "已请求暂停当前推理;用户补充将合并到上下文并自动继续执行(无需整轮停止)。",
"continueAfter": true,
"interruptWithNote": note != "",
"continueWithoutTool": true,
})
return
}
var cause error = ErrTaskCancelled
msg := "已提交取消请求,任务将在当前步骤完成后停止。"
ok, err := h.tasks.CancelTask(req.ConversationID, cause)
if err != nil { if err != nil {
h.logger.Error("取消任务失败", zap.Error(err)) h.logger.Error("取消任务失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -1739,7 +1846,9 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": "cancelling", "status": "cancelling",
"conversationId": req.ConversationID, "conversationId": req.ConversationID,
"message": "已提交取消请求,任务将在当前步骤完成后停止。", "message": msg,
"continueAfter": false,
"interruptWithNote": false,
}) })
} }
@@ -2448,15 +2557,78 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
if assistantMsg != nil { if assistantMsg != nil {
assistantMessageID = assistantMsg.ID assistantMessageID = assistantMsg.ID
} }
progressCallback := h.createProgressCallback(context.Background(), nil, conversationID, assistantMessageID, nil) // 注意:批量任务没有前端直连的 POST /stream,因此若要支持「刷新后补流」,
// 需要把进度事件镜像到 TaskEventBusGET /api/agent-loop/task-events 会订阅这里)。
// progressCallback 将在子任务的 IIFE 内创建,以便拿到 taskCtx/cancelWithCause 与 sendEvent。
var progressCallback func(eventType, message string, data interface{})
// 执行任务(使用包含角色提示词的finalMessage和角色工具列表) // 执行任务(使用包含角色提示词的finalMessage和角色工具列表)
h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("role", queue.Role), zap.String("conversationId", conversationID)) h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("role", queue.Role), zap.String("conversationId", conversationID))
// 单个子任务超时时间:从30分钟调整为6小时,适配长时间渗透/扫描任务 func() {
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour) // 与对话流式接口一致:同 conversationId 仅允许一个运行中任务,并支持 /api/agent-loop/cancel 与会话锁对齐。
// 存储取消函数,以便在取消队列时能够取消当前任务 baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
h.batchTaskManager.SetTaskCancel(queueID, cancel) // 单个子任务超时:6 小时(与原先 WithTimeout(Background) 一致)
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 6*time.Hour)
registered := false
finishStatus := "completed"
defer func() {
h.batchTaskManager.SetTaskCancel(queueID, nil)
timeoutCancel()
if registered {
// 与流式接口保持一致:结束前补一个 done,便于前端 task-events 侧及时收口 UI。
if h.taskEventBus != nil {
ev := StreamEvent{Type: "done", Message: "", Data: map[string]interface{}{"conversationId": conversationID}}
if b, err := json.Marshal(ev); err == nil {
h.taskEventBus.Publish(conversationID, append(append([]byte("data: "), b...), '\n', '\n'))
}
}
h.tasks.FinishTask(conversationID, finishStatus)
}
cancelWithCause(nil)
}()
// 事件镜像:只发布到 TaskEventBus,不直接写 HTTP Response(用于刷新后的补流)。
sendEvent := func(eventType, message string, data interface{}) {
if h.taskEventBus == nil {
return
}
ev := StreamEvent{Type: eventType, Message: message, Data: data}
b, err := json.Marshal(ev)
if err != nil {
b = []byte(`{"type":"error","message":"marshal failed"}`)
}
line := make([]byte, 0, len(b)+8)
line = append(line, []byte("data: ")...)
line = append(line, b...)
line = append(line, '\n', '\n')
h.taskEventBus.Publish(conversationID, line)
}
if _, err := h.tasks.StartTask(conversationID, task.Message, cancelWithCause); err != nil {
h.logger.Warn("批量队列子任务注册会话运行状态失败",
zap.String("queueId", queueID),
zap.String("taskId", task.ID),
zap.String("conversationId", conversationID),
zap.Error(err))
failMsg := err.Error()
if errors.Is(err, ErrTaskAlreadyRunning) {
failMsg = "会话已有任务正在执行,无法在该会话上并行启动批量子任务"
}
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", failMsg)
return
}
registered = true
// 存储取消函数:暂停队列时取消子任务 context(与原先语义一致)
h.batchTaskManager.SetTaskCancel(queueID, timeoutCancel)
// 创建进度回调函数:写 DB + 镜像到 task-events,支持刷新后继续流式展示。
progressCallback = h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
taskCtx = mcp.WithMCPConversationID(taskCtx, conversationID)
taskCtx = mcp.WithToolRunRegistry(taskCtx, h.tasks)
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具) // 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
useBatchMulti := false useBatchMulti := false
useEinoSingle := false useEinoSingle := false
@@ -2483,22 +2655,19 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
var runErr error var runErr error
switch { switch {
case useBatchMulti: case useBatchMulti:
resultMA, runErr = multiagent.RunDeepAgent(ctx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch) resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil)
case useEinoSingle: case useEinoSingle:
if h.config == nil { if h.config == nil {
runErr = fmt.Errorf("服务器配置未加载") runErr = fmt.Errorf("服务器配置未加载")
} else { } else {
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(ctx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback) resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil)
} }
default: default:
result, runErr = h.agent.AgentLoopWithProgress(ctx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools) result, runErr = h.agent.AgentLoopWithProgress(taskCtx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools)
} }
// 任务执行完成,清理取消函数
h.batchTaskManager.SetTaskCancel(queueID, nil)
cancel()
if runErr != nil { if runErr != nil {
if useRunResult { if useRunResult && shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(conversationID, resultMA) h.persistEinoAgentTraceForResume(conversationID, resultMA)
} }
// 检查是否是取消错误 // 检查是否是取消错误
@@ -2512,10 +2681,20 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
} else if result != nil { } else if result != nil {
partialResp = result.Response partialResp = result.Response
} }
isCancelled := errors.Is(runErr, context.Canceled) || isCancelled := errors.Is(context.Cause(baseCtx), ErrTaskCancelled) ||
errors.Is(runErr, context.Canceled) ||
strings.Contains(strings.ToLower(errStr), "context canceled") || strings.Contains(strings.ToLower(errStr), "context canceled") ||
strings.Contains(strings.ToLower(errStr), "context cancelled") || strings.Contains(strings.ToLower(errStr), "context cancelled") ||
(partialResp != "" && (strings.Contains(partialResp, "任务已被取消") || strings.Contains(partialResp, "任务执行中断"))) (partialResp != "" && (strings.Contains(partialResp, "任务已被取消") || strings.Contains(partialResp, "任务执行中断")))
isTimeout := errors.Is(runErr, context.DeadlineExceeded) || errors.Is(context.Cause(taskCtx), context.DeadlineExceeded)
if isTimeout {
finishStatus = "timeout"
} else if isCancelled {
finishStatus = "cancelled"
} else {
finishStatus = "failed"
}
if isCancelled { if isCancelled {
h.logger.Info("批量任务被取消", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID)) h.logger.Info("批量任务被取消", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
@@ -2526,11 +2705,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
} }
// 更新助手消息内容 // 更新助手消息内容
if assistantMessageID != "" { if assistantMessageID != "" {
if _, updateErr := h.db.Exec( if updateErr := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); updateErr != nil {
"UPDATE messages SET content = ? WHERE id = ?",
cancelMsg,
assistantMessageID,
); updateErr != nil {
h.logger.Warn("更新取消后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr)) h.logger.Warn("更新取消后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
} }
// 保存取消详情到数据库 // 保存取消详情到数据库
@@ -2544,16 +2719,6 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
h.logger.Warn("保存取消消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(errMsg)) h.logger.Warn("保存取消消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(errMsg))
} }
} }
// 保存代理轨迹(如果存在)
if result != nil && (result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "") {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存取消任务的代理轨迹失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
} else if useRunResult && resultMA != nil && (resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "") {
if err := h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput); err != nil {
h.logger.Warn("保存取消任务的代理轨迹失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
}
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "cancelled", cancelMsg, "", conversationID) h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "cancelled", cancelMsg, "", conversationID)
} else { } else {
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr)) h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr))
@@ -2561,9 +2726,9 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
// 更新助手消息内容 // 更新助手消息内容
if assistantMessageID != "" { if assistantMessageID != "" {
if _, updateErr := h.db.Exec( if _, updateErr := h.db.Exec(
"UPDATE messages SET content = ? WHERE id = ?", "UPDATE messages SET content = ?, updated_at = ? WHERE id = ?",
errorMsg, errorMsg,
assistantMessageID, time.Now(), assistantMessageID,
); updateErr != nil { ); updateErr != nil {
h.logger.Warn("更新失败后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr)) h.logger.Warn("更新失败后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
} }
@@ -2594,17 +2759,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
// 更新助手消息内容 // 更新助手消息内容
if assistantMessageID != "" { if assistantMessageID != "" {
mcpIDsJSON := "" if updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil {
if len(mcpIDs) > 0 {
jsonData, _ := json.Marshal(mcpIDs)
mcpIDsJSON = string(jsonData)
}
if _, updateErr := h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
resText,
mcpIDsJSON,
assistantMessageID,
); updateErr != nil {
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr)) h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
// 如果更新失败,尝试创建新消息 // 如果更新失败,尝试创建新消息
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs) _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
@@ -2632,6 +2787,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
// 保存结果 // 保存结果
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "completed", resText, "", conversationID) h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "completed", resText, "", conversationID)
} }
}()
// 移动到下一个任务 // 移动到下一个任务
h.batchTaskManager.MoveToNextTask(queueID) h.batchTaskManager.MoveToNextTask(queueID)
@@ -2695,6 +2851,10 @@ func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent
if content, ok := msgMap["content"].(string); ok { if content, ok := msgMap["content"].(string); ok {
msg.Content = content msg.Content = content
} }
// DeepSeek 思考模式:含工具调用的 assistant 须在后续请求中回传 reasoning_content
if rc, ok := msgMap["reasoning_content"].(string); ok && strings.TrimSpace(rc) != "" {
msg.ReasoningContent = rc
}
// 解析tool_calls(如果存在) // 解析tool_calls(如果存在)
if toolCallsRaw, ok := msgMap["tool_calls"]; ok && toolCallsRaw != nil { if toolCallsRaw, ok := msgMap["tool_calls"]; ok && toolCallsRaw != nil {
@@ -2750,6 +2910,11 @@ func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent
if toolCallID, ok := msgMap["tool_call_id"].(string); ok { if toolCallID, ok := msgMap["tool_call_id"].(string); ok {
msg.ToolCallID = toolCallID msg.ToolCallID = toolCallID
} }
if tn, ok := msgMap["tool_name"].(string); ok && strings.TrimSpace(tn) != "" {
msg.ToolName = strings.TrimSpace(tn)
} else if tn, ok := msgMap["name"].(string); ok && strings.TrimSpace(tn) != "" && strings.EqualFold(msg.Role, "tool") {
msg.ToolName = strings.TrimSpace(tn)
}
agentMessages = append(agentMessages, msg) agentMessages = append(agentMessages, msg)
} }
@@ -2795,3 +2960,18 @@ func (h *AgentHandler) loadHistoryFromAgentTrace(conversationID string) ([]agent
) )
return agentMessages, nil return agentMessages, nil
} }
// dbMessagesToAgentChatMessages maps DB rows to agent ChatMessage for history fallback
// (includes reasoning_content for DeepSeek thinking + tool replay).
func dbMessagesToAgentChatMessages(msgs []database.Message) []agent.ChatMessage {
out := make([]agent.ChatMessage, 0, len(msgs))
for i := range msgs {
m := msgs[i]
out = append(out, agent.ChatMessage{
Role: m.Role,
Content: m.Content,
ReasoningContent: m.ReasoningContent,
})
}
return out
}
+58 -47
View File
@@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"time" "time"
"cyberstrike-ai/internal/c2" "cyberstrike-ai/internal/c2"
@@ -20,18 +21,28 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// C2Handler 处理 C2 相关的 REST API // C2Handler 处理 C2 相关的 REST APImanager 可在运行时置 nil 以关闭 C2)
type C2Handler struct { type C2Handler struct {
manager *c2.Manager mgrPtr atomic.Pointer[c2.Manager]
logger *zap.Logger logger *zap.Logger
} }
// NewC2Handler 创建 C2 处理器 // NewC2Handler 创建 C2 处理器manager 可为 nil(功能关闭时)
func NewC2Handler(manager *c2.Manager, logger *zap.Logger) *C2Handler { func NewC2Handler(manager *c2.Manager, logger *zap.Logger) *C2Handler {
return &C2Handler{ h := &C2Handler{logger: logger}
manager: manager, if manager != nil {
logger: logger, h.mgrPtr.Store(manager)
} }
return h
}
func (h *C2Handler) mgr() *c2.Manager {
return h.mgrPtr.Load()
}
// SetManager 运行时切换或清空 C2 Manager(与 App 启停同步)
func (h *C2Handler) SetManager(m *c2.Manager) {
h.mgrPtr.Store(m)
} }
// ============================================================================ // ============================================================================
@@ -40,7 +51,7 @@ func NewC2Handler(manager *c2.Manager, logger *zap.Logger) *C2Handler {
// ListListeners 获取监听器列表 // ListListeners 获取监听器列表
func (h *C2Handler) ListListeners(c *gin.Context) { func (h *C2Handler) ListListeners(c *gin.Context) {
listeners, err := h.manager.DB().ListC2Listeners() listeners, err := h.mgr().DB().ListC2Listeners()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -81,7 +92,7 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
CallbackHost: strings.TrimSpace(req.CallbackHost), CallbackHost: strings.TrimSpace(req.CallbackHost),
} }
listener, err := h.manager.CreateListener(input) listener, err := h.mgr().CreateListener(input)
if err != nil { if err != nil {
code := http.StatusInternalServerError code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok { if e, ok := err.(*c2.CommonError); ok {
@@ -99,7 +110,7 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
// GetListener 获取单个监听器 // GetListener 获取单个监听器
func (h *C2Handler) GetListener(c *gin.Context) { func (h *C2Handler) GetListener(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
listener, err := h.manager.DB().GetC2Listener(id) listener, err := h.mgr().DB().GetC2Listener(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -116,7 +127,7 @@ func (h *C2Handler) GetListener(c *gin.Context) {
// UpdateListener 更新监听器 // UpdateListener 更新监听器
func (h *C2Handler) UpdateListener(c *gin.Context) { func (h *C2Handler) UpdateListener(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
listener, err := h.manager.DB().GetC2Listener(id) listener, err := h.mgr().DB().GetC2Listener(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -141,7 +152,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
} }
// 若监听器在运行,不能修改关键字段 // 若监听器在运行,不能修改关键字段
if h.manager.IsListenerRunning(id) { if h.mgr().IsListenerRunning(id) {
if req.BindHost != listener.BindHost || req.BindPort != listener.BindPort { if req.BindHost != listener.BindHost || req.BindPort != listener.BindPort {
c.JSON(http.StatusConflict, gin.H{"error": "cannot modify bind address while listener is running"}) c.JSON(http.StatusConflict, gin.H{"error": "cannot modify bind address while listener is running"})
return return
@@ -174,7 +185,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
listener.ConfigJSON = string(cfgJSON) listener.ConfigJSON = string(cfgJSON)
} }
if err := h.manager.DB().UpdateC2Listener(listener); err != nil { if err := h.mgr().DB().UpdateC2Listener(listener); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -186,7 +197,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
// DeleteListener 删除监听器 // DeleteListener 删除监听器
func (h *C2Handler) DeleteListener(c *gin.Context) { func (h *C2Handler) DeleteListener(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
if err := h.manager.DeleteListener(id); err != nil { if err := h.mgr().DeleteListener(id); err != nil {
code := http.StatusInternalServerError code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok { if e, ok := err.(*c2.CommonError); ok {
code = e.HTTP code = e.HTTP
@@ -200,7 +211,7 @@ func (h *C2Handler) DeleteListener(c *gin.Context) {
// StartListener 启动监听器 // StartListener 启动监听器
func (h *C2Handler) StartListener(c *gin.Context) { func (h *C2Handler) StartListener(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
listener, err := h.manager.StartListener(id) listener, err := h.mgr().StartListener(id)
if err != nil { if err != nil {
code := http.StatusInternalServerError code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok { if e, ok := err.(*c2.CommonError); ok {
@@ -217,7 +228,7 @@ func (h *C2Handler) StartListener(c *gin.Context) {
// StopListener 停止监听器 // StopListener 停止监听器
func (h *C2Handler) StopListener(c *gin.Context) { func (h *C2Handler) StopListener(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
if err := h.manager.StopListener(id); err != nil { if err := h.mgr().StopListener(id); err != nil {
code := http.StatusInternalServerError code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok { if e, ok := err.(*c2.CommonError); ok {
code = e.HTTP code = e.HTTP
@@ -246,7 +257,7 @@ func (h *C2Handler) ListSessions(c *gin.Context) {
} }
} }
sessions, err := h.manager.DB().ListC2Sessions(filter) sessions, err := h.mgr().DB().ListC2Sessions(filter)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -257,7 +268,7 @@ func (h *C2Handler) ListSessions(c *gin.Context) {
// GetSession 获取单个会话 // GetSession 获取单个会话
func (h *C2Handler) GetSession(c *gin.Context) { func (h *C2Handler) GetSession(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
session, err := h.manager.DB().GetC2Session(id) session, err := h.mgr().DB().GetC2Session(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -268,7 +279,7 @@ func (h *C2Handler) GetSession(c *gin.Context) {
} }
// 获取最近任务 // 获取最近任务
tasks, _ := h.manager.DB().ListC2Tasks(database.ListC2TasksFilter{ tasks, _ := h.mgr().DB().ListC2Tasks(database.ListC2TasksFilter{
SessionID: id, SessionID: id,
Limit: 20, Limit: 20,
}) })
@@ -282,7 +293,7 @@ func (h *C2Handler) GetSession(c *gin.Context) {
// DeleteSession 删除会话 // DeleteSession 删除会话
func (h *C2Handler) DeleteSession(c *gin.Context) { func (h *C2Handler) DeleteSession(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
if err := h.manager.DB().DeleteC2Session(id); err != nil { if err := h.mgr().DB().DeleteC2Session(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -301,7 +312,7 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
return return
} }
if err := h.manager.DB().SetC2SessionSleep(id, req.SleepSeconds, req.JitterPercent); err != nil { if err := h.mgr().DB().SetC2SessionSleep(id, req.SleepSeconds, req.JitterPercent); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -343,14 +354,14 @@ func (h *C2Handler) ListTasks(c *gin.Context) {
} }
} }
tasks, err := h.manager.DB().ListC2Tasks(filter) tasks, err := h.mgr().DB().ListC2Tasks(filter)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
// 仪表盘「待审任务」为全局 queued/pending 数量,与列表 session 过滤无关 // 仪表盘「待审任务」为全局 queued/pending 数量,与列表 session 过滤无关
pendingN, _ := h.manager.DB().CountC2TasksQueuedOrPending("") pendingN, _ := h.mgr().DB().CountC2TasksQueuedOrPending("")
if !paginated { if !paginated {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -360,7 +371,7 @@ func (h *C2Handler) ListTasks(c *gin.Context) {
return return
} }
total, err := h.manager.DB().CountC2Tasks(filter) total, err := h.mgr().DB().CountC2Tasks(filter)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -387,7 +398,7 @@ func (h *C2Handler) DeleteTasks(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"})
return return
} }
n, err := h.manager.DB().DeleteC2TasksByIDs(req.IDs) n, err := h.mgr().DB().DeleteC2TasksByIDs(req.IDs)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoValidC2TaskIDs) { if errors.Is(err, database.ErrNoValidC2TaskIDs) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -402,7 +413,7 @@ func (h *C2Handler) DeleteTasks(c *gin.Context) {
// GetTask 获取单个任务 // GetTask 获取单个任务
func (h *C2Handler) GetTask(c *gin.Context) { func (h *C2Handler) GetTask(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
task, err := h.manager.DB().GetC2Task(id) task, err := h.mgr().DB().GetC2Task(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -437,7 +448,7 @@ func (h *C2Handler) CreateTask(c *gin.Context) {
UserCtx: c.Request.Context(), UserCtx: c.Request.Context(),
} }
task, err := h.manager.EnqueueTask(input) task, err := h.mgr().EnqueueTask(input)
if err != nil { if err != nil {
code := http.StatusInternalServerError code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok { if e, ok := err.(*c2.CommonError); ok {
@@ -452,7 +463,7 @@ func (h *C2Handler) CreateTask(c *gin.Context) {
// CancelTask 取消任务 // CancelTask 取消任务
func (h *C2Handler) CancelTask(c *gin.Context) { func (h *C2Handler) CancelTask(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
if err := h.manager.CancelTask(id); err != nil { if err := h.mgr().CancelTask(id); err != nil {
code := http.StatusInternalServerError code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok { if e, ok := err.(*c2.CommonError); ok {
code = e.HTTP code = e.HTTP
@@ -475,7 +486,7 @@ func (h *C2Handler) WaitTask(c *gin.Context) {
deadline := time.Now().Add(timeout) deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
task, err := h.manager.DB().GetC2Task(id) task, err := h.mgr().DB().GetC2Task(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -509,7 +520,7 @@ func (h *C2Handler) PayloadOneliner(c *gin.Context) {
return return
} }
listener, err := h.manager.DB().GetC2Listener(req.ListenerID) listener, err := h.mgr().DB().GetC2Listener(req.ListenerID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -572,7 +583,7 @@ func (h *C2Handler) PayloadBuild(c *gin.Context) {
return return
} }
listener, err := h.manager.DB().GetC2Listener(req.ListenerID) listener, err := h.mgr().DB().GetC2Listener(req.ListenerID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -582,7 +593,7 @@ func (h *C2Handler) PayloadBuild(c *gin.Context) {
return return
} }
builder := c2.NewPayloadBuilder(h.manager, h.logger, "", "") builder := c2.NewPayloadBuilder(h.mgr(), h.logger, "", "")
input := c2.PayloadBuilderInput{ input := c2.PayloadBuilderInput{
ListenerID: req.ListenerID, ListenerID: req.ListenerID,
OS: req.OS, OS: req.OS,
@@ -616,7 +627,7 @@ func (h *C2Handler) PayloadDownload(c *gin.Context) {
return return
} }
builder := c2.NewPayloadBuilder(h.manager, h.logger, "", "") builder := c2.NewPayloadBuilder(h.mgr(), h.logger, "", "")
storageDir := builder.GetPayloadStoragePath() storageDir := builder.GetPayloadStoragePath()
targetPath := filepath.Join(storageDir, filename) targetPath := filepath.Join(storageDir, filename)
@@ -676,7 +687,7 @@ func (h *C2Handler) ListEvents(c *gin.Context) {
} }
} }
events, err := h.manager.DB().ListC2Events(filter) events, err := h.mgr().DB().ListC2Events(filter)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -685,7 +696,7 @@ func (h *C2Handler) ListEvents(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"events": events}) c.JSON(http.StatusOK, gin.H{"events": events})
return return
} }
total, err := h.manager.DB().CountC2Events(filter) total, err := h.mgr().DB().CountC2Events(filter)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -711,7 +722,7 @@ func (h *C2Handler) DeleteEvents(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"})
return return
} }
n, err := h.manager.DB().DeleteC2EventsByIDs(req.IDs) n, err := h.mgr().DB().DeleteC2EventsByIDs(req.IDs)
if err != nil { if err != nil {
if errors.Is(err, database.ErrNoValidC2EventIDs) { if errors.Is(err, database.ErrNoValidC2EventIDs) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -733,14 +744,14 @@ func (h *C2Handler) EventStream(c *gin.Context) {
categoryFilter := c.Query("category") categoryFilter := c.Query("category")
levels := c.QueryArray("level") levels := c.QueryArray("level")
sub := h.manager.EventBus().Subscribe( sub := h.mgr().EventBus().Subscribe(
"sse-"+uuid.New().String(), "sse-"+uuid.New().String(),
128, 128,
sessionFilter, sessionFilter,
categoryFilter, categoryFilter,
levels, levels,
) )
defer h.manager.EventBus().Unsubscribe(sub.ID) defer h.mgr().EventBus().Unsubscribe(sub.ID)
c.Stream(func(w io.Writer) bool { c.Stream(func(w io.Writer) bool {
select { select {
@@ -763,7 +774,7 @@ func (h *C2Handler) EventStream(c *gin.Context) {
// ListProfiles 获取 Malleable Profile 列表 // ListProfiles 获取 Malleable Profile 列表
func (h *C2Handler) ListProfiles(c *gin.Context) { func (h *C2Handler) ListProfiles(c *gin.Context) {
profiles, err := h.manager.DB().ListC2Profiles() profiles, err := h.mgr().DB().ListC2Profiles()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -774,7 +785,7 @@ func (h *C2Handler) ListProfiles(c *gin.Context) {
// GetProfile 获取单个 Profile // GetProfile 获取单个 Profile
func (h *C2Handler) GetProfile(c *gin.Context) { func (h *C2Handler) GetProfile(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
profile, err := h.manager.DB().GetC2Profile(id) profile, err := h.mgr().DB().GetC2Profile(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -797,7 +808,7 @@ func (h *C2Handler) CreateProfile(c *gin.Context) {
req.ID = "p_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14] req.ID = "p_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
req.CreatedAt = time.Now() req.CreatedAt = time.Now()
if err := h.manager.DB().CreateC2Profile(&req); err != nil { if err := h.mgr().DB().CreateC2Profile(&req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -807,7 +818,7 @@ func (h *C2Handler) CreateProfile(c *gin.Context) {
// UpdateProfile 更新 Profile // UpdateProfile 更新 Profile
func (h *C2Handler) UpdateProfile(c *gin.Context) { func (h *C2Handler) UpdateProfile(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
profile, err := h.manager.DB().GetC2Profile(id) profile, err := h.mgr().DB().GetC2Profile(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -832,7 +843,7 @@ func (h *C2Handler) UpdateProfile(c *gin.Context) {
profile.JitterMinMS = req.JitterMinMS profile.JitterMinMS = req.JitterMinMS
profile.JitterMaxMS = req.JitterMaxMS profile.JitterMaxMS = req.JitterMaxMS
if err := h.manager.DB().UpdateC2Profile(profile); err != nil { if err := h.mgr().DB().UpdateC2Profile(profile); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -842,7 +853,7 @@ func (h *C2Handler) UpdateProfile(c *gin.Context) {
// DeleteProfile 删除 Profile // DeleteProfile 删除 Profile
func (h *C2Handler) DeleteProfile(c *gin.Context) { func (h *C2Handler) DeleteProfile(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
if err := h.manager.DB().DeleteC2Profile(id); err != nil { if err := h.mgr().DB().DeleteC2Profile(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -870,7 +881,7 @@ func (h *C2Handler) UploadFileForImplant(c *gin.Context) {
defer file.Close() defer file.Close()
fileID := "f_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14] fileID := "f_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
dir := filepath.Join(h.manager.StorageDir(), "downstream") dir := filepath.Join(h.mgr().StorageDir(), "downstream")
if err := osMkdirAll(dir); err != nil { if err := osMkdirAll(dir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -898,7 +909,7 @@ func (h *C2Handler) UploadFileForImplant(c *gin.Context) {
SizeBytes: n, SizeBytes: n,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
_ = h.manager.DB().CreateC2File(dbFile) _ = h.mgr().DB().CreateC2File(dbFile)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"file_id": fileID, "file_id": fileID,
@@ -915,7 +926,7 @@ func (h *C2Handler) ListFiles(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"})
return return
} }
files, err := h.manager.DB().ListC2FilesBySession(sessionID) files, err := h.mgr().DB().ListC2FilesBySession(sessionID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -926,7 +937,7 @@ func (h *C2Handler) ListFiles(c *gin.Context) {
// DownloadResultFile 下载任务结果文件(截图等 blob 结果) // DownloadResultFile 下载任务结果文件(截图等 blob 结果)
func (h *C2Handler) DownloadResultFile(c *gin.Context) { func (h *C2Handler) DownloadResultFile(c *gin.Context) {
taskID := c.Param("id") taskID := c.Param("id")
task, err := h.manager.DB().GetC2Task(taskID) task, err := h.mgr().DB().GetC2Task(taskID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
+76 -1
View File
@@ -41,6 +41,14 @@ type SkillsToolRegistrar func() error
// BatchTaskToolRegistrar 批量任务 MCP 工具注册器(ApplyConfig 时重新注册) // BatchTaskToolRegistrar 批量任务 MCP 工具注册器(ApplyConfig 时重新注册)
type BatchTaskToolRegistrar func() error type BatchTaskToolRegistrar func() error
// C2ToolRegistrar C2 MCP 工具注册器(ApplyConfig 时 ClearTools 之后调用)
type C2ToolRegistrar func() error
// C2Runtime ApplyConfig 时按配置启停 C2 子系统(由 internal/app.App 实现)
type C2Runtime interface {
ReconcileC2AfterConfigApply() error
}
// RetrieverUpdater 检索器更新接口 // RetrieverUpdater 检索器更新接口
type RetrieverUpdater interface { type RetrieverUpdater interface {
UpdateConfig(config *knowledge.RetrievalConfig) UpdateConfig(config *knowledge.RetrievalConfig)
@@ -73,6 +81,8 @@ type ConfigHandler struct {
webshellToolRegistrar WebshellToolRegistrar // WebShell 工具注册器(可选) webshellToolRegistrar WebshellToolRegistrar // WebShell 工具注册器(可选)
skillsToolRegistrar SkillsToolRegistrar // Skills工具注册器(可选) skillsToolRegistrar SkillsToolRegistrar // Skills工具注册器(可选)
batchTaskToolRegistrar BatchTaskToolRegistrar // 批量任务 MCP 工具(可选) batchTaskToolRegistrar BatchTaskToolRegistrar // 批量任务 MCP 工具(可选)
c2ToolRegistrar C2ToolRegistrar // C2 MCP 工具(可选)
c2Runtime C2Runtime // C2 启停(可选)
retrieverUpdater RetrieverUpdater // 检索器更新器(可选) retrieverUpdater RetrieverUpdater // 检索器更新器(可选)
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选) knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
appUpdater AppUpdater // App更新器(可选) appUpdater AppUpdater // App更新器(可选)
@@ -154,6 +164,20 @@ func (h *ConfigHandler) SetBatchTaskToolRegistrar(registrar BatchTaskToolRegistr
h.batchTaskToolRegistrar = registrar h.batchTaskToolRegistrar = registrar
} }
// SetC2ToolRegistrar 设置 C2 MCP 工具注册器
func (h *ConfigHandler) SetC2ToolRegistrar(registrar C2ToolRegistrar) {
h.mu.Lock()
defer h.mu.Unlock()
h.c2ToolRegistrar = registrar
}
// SetC2Runtime 设置 C2 运行时(Apply 时启停)
func (h *ConfigHandler) SetC2Runtime(rt C2Runtime) {
h.mu.Lock()
defer h.mu.Unlock()
h.c2Runtime = rt
}
// SetRetrieverUpdater 设置检索器更新器 // SetRetrieverUpdater 设置检索器更新器
func (h *ConfigHandler) SetRetrieverUpdater(updater RetrieverUpdater) { func (h *ConfigHandler) SetRetrieverUpdater(updater RetrieverUpdater) {
h.mu.Lock() h.mu.Lock()
@@ -193,6 +217,7 @@ type GetConfigResponse struct {
Knowledge config.KnowledgeConfig `json:"knowledge"` Knowledge config.KnowledgeConfig `json:"knowledge"`
Robots config.RobotsConfig `json:"robots,omitempty"` Robots config.RobotsConfig `json:"robots,omitempty"`
MultiAgent config.MultiAgentPublic `json:"multi_agent,omitempty"` MultiAgent config.MultiAgentPublic `json:"multi_agent,omitempty"`
C2 config.C2Public `json:"c2"`
} }
// ToolConfigInfo 工具配置信息 // ToolConfigInfo 工具配置信息
@@ -286,6 +311,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
Agent: h.config.Agent, Agent: h.config.Agent,
Hitl: h.config.Hitl, Hitl: h.config.Hitl,
Knowledge: h.config.Knowledge, Knowledge: h.config.Knowledge,
C2: h.config.C2.Public(),
Robots: h.config.Robots, Robots: h.config.Robots,
MultiAgent: multiPub, MultiAgent: multiPub,
}) })
@@ -591,6 +617,7 @@ type UpdateConfigRequest struct {
Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"` Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"`
Robots *config.RobotsConfig `json:"robots,omitempty"` Robots *config.RobotsConfig `json:"robots,omitempty"`
MultiAgent *config.MultiAgentAPIUpdate `json:"multi_agent,omitempty"` MultiAgent *config.MultiAgentAPIUpdate `json:"multi_agent,omitempty"`
C2 *config.C2APIUpdate `json:"c2,omitempty"`
} }
// ToolEnableStatus 工具启用状态 // ToolEnableStatus 工具启用状态
@@ -676,6 +703,12 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
) )
} }
if req.C2 != nil {
v := req.C2.Enabled
h.config.C2.Enabled = &v
h.logger.Info("更新C2配置", zap.Bool("enabled", v))
}
// 多代理标量(sub_agents 等仍由 config.yaml 维护) // 多代理标量(sub_agents 等仍由 config.yaml 维护)
if req.MultiAgent != nil { if req.MultiAgent != nil {
h.config.MultiAgent.Enabled = req.MultiAgent.Enabled h.config.MultiAgent.Enabled = req.MultiAgent.Enabled
@@ -853,7 +886,7 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
"messages": []map[string]string{ "messages": []map[string]string{
{"role": "user", "content": "Hi"}, {"role": "user", "content": "Hi"},
}, },
"max_tokens": 5, "max_completion_tokens": 5,
} }
// 使用内部 openai Client 进行测试,若 provider 为 claude 会自动走桥接层 // 使用内部 openai Client 进行测试,若 provider 为 claude 会自动走桥接层
@@ -980,6 +1013,18 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
h.logger.Info("知识库组件重新初始化完成") h.logger.Info("知识库组件重新初始化完成")
} }
// C2:在 ClearTools 之前按配置启停(随后由 c2ToolRegistrar 注册 MCP 工具)
h.mu.RLock()
c2Rt := h.c2Runtime
h.mu.RUnlock()
if c2Rt != nil {
if err := c2Rt.ReconcileC2AfterConfigApply(); err != nil {
h.logger.Error("C2 配置应用失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "C2 启动失败: " + err.Error()})
return
}
}
// 现在获取写锁,执行快速的操作 // 现在获取写锁,执行快速的操作
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
@@ -1044,6 +1089,16 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
} }
} }
// 重新注册 C2 MCP 工具(仅当 C2 已启动)
if h.c2ToolRegistrar != nil {
h.logger.Info("重新注册 C2 MCP 工具")
if err := h.c2ToolRegistrar(); err != nil {
h.logger.Error("重新注册 C2 MCP 工具失败", zap.Error(err))
} else {
h.logger.Info("C2 MCP 工具已处理")
}
}
// 如果知识库启用,重新注册知识库工具 // 如果知识库启用,重新注册知识库工具
if h.config.Knowledge.Enabled && h.knowledgeToolRegistrar != nil { if h.config.Knowledge.Enabled && h.knowledgeToolRegistrar != nil {
h.logger.Info("重新注册知识库工具") h.logger.Info("重新注册知识库工具")
@@ -1131,6 +1186,7 @@ func (h *ConfigHandler) saveConfig() error {
updateOpenAIConfig(root, h.config.OpenAI) updateOpenAIConfig(root, h.config.OpenAI)
updateFOFAConfig(root, h.config.FOFA) updateFOFAConfig(root, h.config.FOFA)
updateKnowledgeConfig(root, h.config.Knowledge) updateKnowledgeConfig(root, h.config.Knowledge)
updateC2Config(root, h.config.C2)
updateRobotsConfig(root, h.config.Robots) updateRobotsConfig(root, h.config.Robots)
updateHitlConfig(root, h.config.Hitl) updateHitlConfig(root, h.config.Hitl)
updateMultiAgentConfig(root, h.config.MultiAgent) updateMultiAgentConfig(root, h.config.MultiAgent)
@@ -1256,6 +1312,19 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
if cfg.MaxTotalTokens > 0 { if cfg.MaxTotalTokens > 0 {
setIntInMap(openaiNode, "max_total_tokens", cfg.MaxTotalTokens) setIntInMap(openaiNode, "max_total_tokens", cfg.MaxTotalTokens)
} }
rn := ensureMap(openaiNode, "reasoning")
if strings.TrimSpace(cfg.Reasoning.Mode) != "" {
setStringInMap(rn, "mode", cfg.Reasoning.Mode)
}
if strings.TrimSpace(cfg.Reasoning.Effort) != "" {
setStringInMap(rn, "effort", cfg.Reasoning.Effort)
}
if cfg.Reasoning.AllowClientReasoning != nil {
setBoolInMap(rn, "allow_client_reasoning", *cfg.Reasoning.AllowClientReasoning)
}
if strings.TrimSpace(cfg.Reasoning.Profile) != "" {
setStringInMap(rn, "profile", cfg.Reasoning.Profile)
}
} }
func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) { func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
@@ -1309,6 +1378,12 @@ func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
setIntInMap(indexingNode, "retry_delay_ms", cfg.Indexing.RetryDelayMs) setIntInMap(indexingNode, "retry_delay_ms", cfg.Indexing.RetryDelayMs)
} }
func updateC2Config(doc *yaml.Node, cfg config.C2Config) {
root := doc.Content[0]
c2Node := ensureMap(root, "c2")
setBoolInMap(c2Node, "enabled", cfg.EnabledEffective())
}
func mergeHitlToolWhitelistSlice(existing, add []string) []string { func mergeHitlToolWhitelistSlice(existing, add []string) []string {
seen := make(map[string]struct{}) seen := make(map[string]struct{})
out := make([]string, 0, len(existing)+len(add)) out := make([]string, 0, len(existing)+len(add))
+116 -62
View File
@@ -10,6 +10,7 @@ import (
"sync" "sync"
"time" "time"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent" "cyberstrike-ai/internal/multiagent"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -43,9 +44,12 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
var sseWriteMu sync.Mutex var sseWriteMu sync.Mutex
var ssePublishConversationID string var ssePublishConversationID string
sendEvent := func(eventType, message string, data interface{}) { sendEvent := func(eventType, message string, data interface{}) {
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) { if eventType == "error" && baseCtx != nil {
cause := context.Cause(baseCtx)
if errors.Is(cause, ErrTaskCancelled) || errors.Is(cause, multiagent.ErrInterruptContinue) {
return return
} }
}
ev := StreamEvent{Type: eventType, Message: message, Data: data} ev := StreamEvent{Type: eventType, Message: message, Data: data}
b, errMarshal := json.Marshal(ev) b, errMarshal := json.Marshal(ev)
if errMarshal != nil { if errMarshal != nil {
@@ -114,36 +118,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
} }
var cancelWithCause context.CancelCauseFunc var cancelWithCause context.CancelCauseFunc
baseCtx, cancelWithCause = context.WithCancelCause(context.Background()) curFinalMessage := prep.FinalMessage
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute) curHistory := prep.History
defer timeoutCancel() roleTools := prep.RoleTools
defer cancelWithCause(nil)
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
})
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
var errorMsg string
if errors.Is(err, ErrTaskAlreadyRunning) {
errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
sendEvent("error", errorMsg, map[string]interface{}{
"conversationId": conversationID,
"errorType": "task_already_running",
})
} else {
errorMsg = "❌ 无法启动任务: " + err.Error()
sendEvent("error", errorMsg, nil)
}
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errorMsg, assistantMessageID)
}
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
taskStatus := "completed" taskStatus := "completed"
defer h.tasks.FinishTask(conversationID, taskStatus) // 仅在成功 StartTask 后再 FinishTask。若 StartTask 因 ErrTaskAlreadyRunning 失败仍 defer FinishTask
// 会误删其他连接上正在运行的同会话任务,导致「第一次拦截、第二次却放行」。
taskOwned := false
defer func() {
if taskOwned {
h.tasks.FinishTask(conversationID, taskStatus)
}
}()
sendEvent("progress", "正在启动 Eino ADK 单代理(ChatModelAgent...", map[string]interface{}{ sendEvent("progress", "正在启动 Eino ADK 单代理(ChatModelAgent...", map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
@@ -161,28 +148,112 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
return return
} }
result, runErr := multiagent.RunEinoSingleChatModelAgent( var result *multiagent.RunResult
taskCtx, var runErr error
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
var errorMsg string
if errors.Is(err, ErrTaskAlreadyRunning) {
errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
sendEvent("error", errorMsg, map[string]interface{}{
"conversationId": conversationID,
"errorType": "task_already_running",
})
} else {
errorMsg = "❌ 无法启动任务: " + err.Error()
sendEvent("error", errorMsg, nil)
}
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
}
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
taskOwned = true
var cumulativeMCPExecutionIDs []string
for {
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
})
result, runErr = multiagent.RunEinoSingleChatModelAgent(
taskCtxLoop,
h.config, h.config,
&h.config.MultiAgent, &h.config.MultiAgent,
h.agent, h.agent,
h.logger, h.logger,
conversationID, conversationID,
prep.FinalMessage, curFinalMessage,
prep.History, curHistory,
prep.RoleTools, roleTools,
progressCallback, progressCallback,
chatReasoningToClientIntent(req.Reasoning),
) )
timeoutCancel()
if result != nil && len(result.MCPExecutionIDs) > 0 {
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
}
if runErr == nil {
break
}
if runErr != nil {
h.persistEinoAgentTraceForResume(conversationID, result)
cause := context.Cause(baseCtx) cause := context.Cause(baseCtx)
if errors.Is(cause, multiagent.ErrInterruptContinue) {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(conversationID, result)
}
note := h.tasks.TakeInterruptContinueNote(conversationID)
icSummary := interruptContinueTimelineSummary(note)
progressCallback("user_interrupt_continue", icSummary, map[string]interface{}{
"conversationId": conversationID,
"rawReason": strings.TrimSpace(note),
"emptyReason": strings.TrimSpace(note) == "",
"kind": "no_active_mcp_tool",
})
inject := formatInterruptContinueUserMessage(note)
// 不写入 messages 表为 user 气泡:避免主对话流出现大段模板;说明已由 user_interrupt_continue 记入助手 process_details(迭代详情)。
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
curHistory = hist
}
curFinalMessage = inject
sendEvent("progress", "已合并用户补充与最新轨迹,正在继续推理…", map[string]interface{}{
"conversationId": conversationID,
"source": "interrupt_continue",
})
h.tasks.UpdateTaskStatus(conversationID, "running")
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
continue
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(conversationID, result)
}
if errors.Is(cause, ErrTaskCancelled) { if errors.Is(cause, ErrTaskCancelled) {
taskStatus = "cancelled" taskStatus = "cancelled"
h.tasks.UpdateTaskStatus(conversationID, taskStatus) h.tasks.UpdateTaskStatus(conversationID, taskStatus)
cancelMsg := "任务已被用户取消,后续操作已停止。" cancelMsg := "任务已被用户取消,后续操作已停止。"
if assistantMessageID != "" { if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", cancelMsg, assistantMessageID) if result != nil {
if err := h.mergeAssistantMessagePartialOnCancel(assistantMessageID, result.Response); err != nil {
h.logger.Warn("合并取消前的部分回复失败", zap.Error(err))
}
}
if err := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); err != nil {
h.logger.Warn("更新取消后的助手消息失败", zap.Error(err))
}
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil) _ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
} }
sendEvent("cancelled", cancelMsg, map[string]interface{}{ sendEvent("cancelled", cancelMsg, map[string]interface{}{
@@ -198,7 +269,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
h.tasks.UpdateTaskStatus(conversationID, taskStatus) h.tasks.UpdateTaskStatus(conversationID, taskStatus)
timeoutMsg := "任务执行超时,已自动终止。" timeoutMsg := "任务执行超时,已自动终止。"
if assistantMessageID != "" { if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", timeoutMsg, assistantMessageID) _, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", timeoutMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "timeout", timeoutMsg, nil) _ = h.db.AddProcessDetail(assistantMessageID, conversationID, "timeout", timeoutMsg, nil)
} }
sendEvent("error", timeoutMsg, map[string]interface{}{ sendEvent("error", timeoutMsg, map[string]interface{}{
@@ -215,7 +286,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
h.tasks.UpdateTaskStatus(conversationID, taskStatus) h.tasks.UpdateTaskStatus(conversationID, taskStatus)
errMsg := "执行失败: " + runErr.Error() errMsg := "执行失败: " + runErr.Error()
if assistantMessageID != "" { if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID) _, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil) _ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
} }
sendEvent("error", errMsg, map[string]interface{}{ sendEvent("error", errMsg, map[string]interface{}{
@@ -227,17 +298,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
} }
if assistantMessageID != "" { if assistantMessageID != "" {
mcpIDsJSON := "" _ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
if len(result.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
_, _ = h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
result.Response,
mcpIDsJSON,
assistantMessageID,
)
} }
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" { if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
@@ -247,7 +308,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
} }
sendEvent("response", result.Response, map[string]interface{}{ sendEvent("response", result.Response, map[string]interface{}{
"mcpExecutionIds": result.MCPExecutionIDs, "mcpExecutionIds": cumulativeMCPExecutionIDs,
"conversationId": conversationID, "conversationId": conversationID,
"messageId": assistantMessageID, "messageId": assistantMessageID,
"agentMode": "eino_single", "agentMode": "eino_single",
@@ -305,25 +366,18 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
prep.History, prep.History,
prep.RoleTools, prep.RoleTools,
progressCallback, progressCallback,
chatReasoningToClientIntent(req.Reasoning),
) )
if runErr != nil { if runErr != nil {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result) h.persistEinoAgentTraceForResume(prep.ConversationID, result)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
return return
} }
if prep.AssistantMessageID != "" { if prep.AssistantMessageID != "" {
mcpIDsJSON := "" _ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
if len(result.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
_, _ = h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
result.Response,
mcpIDsJSON,
prep.AssistantMessageID,
)
} }
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" { if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput) _ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
+1 -1
View File
@@ -269,7 +269,7 @@ func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) {
{"role": "user", "content": userPrompt}, {"role": "user", "content": userPrompt},
}, },
"temperature": 0.1, "temperature": 0.1,
"max_tokens": 1200, "max_completion_tokens": 12000,
} }
// OpenAI 返回结构:只需要 choices[0].message.content // OpenAI 返回结构:只需要 choices[0].message.content
+36 -2
View File
@@ -1,6 +1,9 @@
package handler package handler
import ( import (
"encoding/json"
"errors"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -245,6 +248,37 @@ func (h *MonitorHandler) GetExecution(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "执行记录未找到"}) c.JSON(http.StatusNotFound, gin.H{"error": "执行记录未找到"})
} }
// CancelExecution 手动取消进行中的 MCP 工具调用(仅取消该次 tools/call 的上下文,不停止整条 Agent / 迭代任务)
// 请求体可选 JSON{ "note": "用户说明" },将与工具已返回输出合并交给模型(含「用户终止说明」标题块,与命令行原文区分)。
func (h *MonitorHandler) CancelExecution(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "执行记录ID不能为空"})
return
}
note := ""
dec := json.NewDecoder(c.Request.Body)
var body struct {
Note string `json:"note"`
}
if err := dec.Decode(&body); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体须为 JSON,例如 {\"note\":\"说明\"},可为空对象"})
return
}
note = strings.TrimSpace(body.Note)
if h.mcpServer.CancelToolExecutionWithNote(id, note) {
h.logger.Info("已请求取消 MCP 工具执行", zap.String("executionId", id), zap.String("source", "internal"), zap.Bool("hasNote", note != ""))
c.JSON(http.StatusOK, gin.H{"message": "已发送终止信号", "executionId": id})
return
}
if h.externalMCPMgr != nil && h.externalMCPMgr.CancelToolExecutionWithNote(id, note) {
h.logger.Info("已请求取消 MCP 工具执行", zap.String("executionId", id), zap.String("source", "external"), zap.Bool("hasNote", note != ""))
c.JSON(http.StatusOK, gin.H{"message": "已发送终止信号", "executionId": id})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "未找到进行中的工具执行,或该任务已结束"})
}
// BatchGetToolNames 批量获取工具执行的工具名称(消除前端 N+1 请求) // BatchGetToolNames 批量获取工具执行的工具名称(消除前端 N+1 请求)
func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) { func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
var req struct { var req struct {
@@ -317,7 +351,7 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
totalCalls := 1 totalCalls := 1
successCalls := 0 successCalls := 0
failedCalls := 0 failedCalls := 0
if exec.Status == "failed" { if exec.Status == "failed" || exec.Status == "cancelled" {
failedCalls = 1 failedCalls = 1
} else if exec.Status == "completed" { } else if exec.Status == "completed" {
successCalls = 1 successCalls = 1
@@ -381,7 +415,7 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
stats := toolStats[exec.ToolName] stats := toolStats[exec.ToolName]
stats.totalCalls++ stats.totalCalls++
if exec.Status == "failed" { if exec.Status == "failed" || exec.Status == "cancelled" {
stats.failedCalls++ stats.failedCalls++
} else if exec.Status == "completed" { } else if exec.Status == "completed" {
stats.successCalls++ stats.successCalls++
+154 -52
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent" "cyberstrike-ai/internal/multiagent"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -60,9 +61,12 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
sendEvent := func(eventType, message string, data interface{}) { sendEvent := func(eventType, message string, data interface{}) {
// 用户主动停止时,Eino 可能仍会并发上报 eventType=="error"。 // 用户主动停止时,Eino 可能仍会并发上报 eventType=="error"。
// 为避免 UI 看到“取消错误 + cancelled 文案”两条回复,这里直接丢弃取消对应的 error。 // 为避免 UI 看到“取消错误 + cancelled 文案”两条回复,这里直接丢弃取消对应的 error。
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) { if eventType == "error" && baseCtx != nil {
cause := context.Cause(baseCtx)
if errors.Is(cause, ErrTaskCancelled) || errors.Is(cause, multiagent.ErrInterruptContinue) {
return return
} }
}
ev := StreamEvent{Type: eventType, Message: message, Data: data} ev := StreamEvent{Type: eventType, Message: message, Data: data}
b, errMarshal := json.Marshal(ev) b, errMarshal := json.Marshal(ev)
if errMarshal != nil { if errMarshal != nil {
@@ -130,15 +134,35 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
}) })
} }
baseCtx, cancelWithCause := context.WithCancelCause(context.Background()) var cancelWithCause context.CancelCauseFunc
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute) curFinalMessage := prep.FinalMessage
defer timeoutCancel() curHistory := prep.History
defer cancelWithCause(nil) roleTools := prep.RoleTools
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent) orch := strings.TrimSpace(req.Orchestration)
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments) taskStatus := "completed"
// 仅在成功 StartTask 后再 FinishTask;避免「任务已存在」分支 return 时误删正在运行的同会话任务。
taskOwned := false
defer func() {
if taskOwned {
h.tasks.FinishTask(conversationID, taskStatus)
}
}()
sendEvent("progress", "正在启动 Eino 多代理...", map[string]interface{}{
"conversationId": conversationID,
}) })
stopKeepalive := make(chan struct{})
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
defer close(stopKeepalive)
var result *multiagent.RunResult
var runErr error
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil { if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
var errorMsg string var errorMsg string
if errors.Is(err, ErrTaskAlreadyRunning) { if errors.Is(err, ErrTaskAlreadyRunning) {
@@ -152,47 +176,96 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
sendEvent("error", errorMsg, nil) sendEvent("error", errorMsg, nil)
} }
if assistantMessageID != "" { if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errorMsg, assistantMessageID) _, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
} }
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID}) sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return return
} }
taskOwned = true
taskStatus := "completed" // 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表
defer h.tasks.FinishTask(conversationID, taskStatus) var cumulativeMCPExecutionIDs []string
sendEvent("progress", "正在启动 Eino 多代理...", map[string]interface{}{ for {
"conversationId": conversationID, progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
return h.interceptHITLForEinoTool(ctx, cancelWithCause, conversationID, assistantMessageID, sendEvent, toolName, arguments)
}) })
stopKeepalive := make(chan struct{}) result, runErr = multiagent.RunDeepAgent(
go sseKeepalive(c, stopKeepalive, &sseWriteMu) taskCtxLoop,
defer close(stopKeepalive)
result, runErr := multiagent.RunDeepAgent(
taskCtx,
h.config, h.config,
&h.config.MultiAgent, &h.config.MultiAgent,
h.agent, h.agent,
h.logger, h.logger,
conversationID, conversationID,
prep.FinalMessage, curFinalMessage,
prep.History, curHistory,
prep.RoleTools, roleTools,
progressCallback, progressCallback,
h.agentsMarkdownDir, h.agentsMarkdownDir,
strings.TrimSpace(req.Orchestration), orch,
chatReasoningToClientIntent(req.Reasoning),
) )
timeoutCancel()
if result != nil && len(result.MCPExecutionIDs) > 0 {
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
}
if runErr == nil {
break
}
if runErr != nil {
h.persistEinoAgentTraceForResume(conversationID, result)
cause := context.Cause(baseCtx) cause := context.Cause(baseCtx)
if errors.Is(cause, multiagent.ErrInterruptContinue) {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(conversationID, result)
}
note := h.tasks.TakeInterruptContinueNote(conversationID)
icSummary := interruptContinueTimelineSummary(note)
progressCallback("user_interrupt_continue", icSummary, map[string]interface{}{
"conversationId": conversationID,
"rawReason": strings.TrimSpace(note),
"emptyReason": strings.TrimSpace(note) == "",
"kind": "no_active_mcp_tool",
})
inject := formatInterruptContinueUserMessage(note)
// 不写入 messages 表为 user 气泡:避免主对话流出现大段模板;说明已由 user_interrupt_continue 记入助手 process_details(迭代详情)。
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
curHistory = hist
}
curFinalMessage = inject
sendEvent("progress", "已合并用户补充与最新轨迹,正在继续推理…", map[string]interface{}{
"conversationId": conversationID,
"source": "interrupt_continue",
})
h.tasks.UpdateTaskStatus(conversationID, "running")
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
continue
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(conversationID, result)
}
if errors.Is(cause, ErrTaskCancelled) { if errors.Is(cause, ErrTaskCancelled) {
taskStatus = "cancelled" taskStatus = "cancelled"
h.tasks.UpdateTaskStatus(conversationID, taskStatus) h.tasks.UpdateTaskStatus(conversationID, taskStatus)
cancelMsg := "任务已被用户取消,后续操作已停止。" cancelMsg := "任务已被用户取消,后续操作已停止。"
if assistantMessageID != "" { if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", cancelMsg, assistantMessageID) if result != nil {
if err := h.mergeAssistantMessagePartialOnCancel(assistantMessageID, result.Response); err != nil {
h.logger.Warn("合并取消前的部分回复失败", zap.Error(err))
}
}
if err := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); err != nil {
h.logger.Warn("更新取消后的助手消息失败", zap.Error(err))
}
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil) _ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
} }
sendEvent("cancelled", cancelMsg, map[string]interface{}{ sendEvent("cancelled", cancelMsg, map[string]interface{}{
@@ -208,7 +281,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
h.tasks.UpdateTaskStatus(conversationID, taskStatus) h.tasks.UpdateTaskStatus(conversationID, taskStatus)
timeoutMsg := "任务执行超时,已自动终止。" timeoutMsg := "任务执行超时,已自动终止。"
if assistantMessageID != "" { if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", timeoutMsg, assistantMessageID) _, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", timeoutMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "timeout", timeoutMsg, nil) _ = h.db.AddProcessDetail(assistantMessageID, conversationID, "timeout", timeoutMsg, nil)
} }
sendEvent("error", timeoutMsg, map[string]interface{}{ sendEvent("error", timeoutMsg, map[string]interface{}{
@@ -225,7 +298,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
h.tasks.UpdateTaskStatus(conversationID, taskStatus) h.tasks.UpdateTaskStatus(conversationID, taskStatus)
errMsg := "执行失败: " + runErr.Error() errMsg := "执行失败: " + runErr.Error()
if assistantMessageID != "" { if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID) _, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil) _ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
} }
sendEvent("error", errMsg, map[string]interface{}{ sendEvent("error", errMsg, map[string]interface{}{
@@ -237,17 +310,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
} }
if assistantMessageID != "" { if assistantMessageID != "" {
mcpIDsJSON := "" _ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
if len(result.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
_, _ = h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
result.Response,
mcpIDsJSON,
assistantMessageID,
)
} }
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" { if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
@@ -261,7 +324,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
effectiveOrch = config.NormalizeMultiAgentOrchestration(o) effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
} }
sendEvent("response", result.Response, map[string]interface{}{ sendEvent("response", result.Response, map[string]interface{}{
"mcpExecutionIds": result.MCPExecutionIDs, "mcpExecutionIds": cumulativeMCPExecutionIDs,
"conversationId": conversationID, "conversationId": conversationID,
"messageId": assistantMessageID, "messageId": assistantMessageID,
"agentMode": "eino_" + effectiveOrch, "agentMode": "eino_" + effectiveOrch,
@@ -317,30 +380,23 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
progressCallback, progressCallback,
h.agentsMarkdownDir, h.agentsMarkdownDir,
strings.TrimSpace(req.Orchestration), strings.TrimSpace(req.Orchestration),
chatReasoningToClientIntent(req.Reasoning),
) )
if runErr != nil { if runErr != nil {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result) h.persistEinoAgentTraceForResume(prep.ConversationID, result)
}
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr)) h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
errMsg := "执行失败: " + runErr.Error() errMsg := "执行失败: " + runErr.Error()
if prep.AssistantMessageID != "" { if prep.AssistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, prep.AssistantMessageID) _, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg}) c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
return return
} }
if prep.AssistantMessageID != "" { if prep.AssistantMessageID != "" {
mcpIDsJSON := "" _ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
if len(result.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
_, _ = h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
result.Response,
mcpIDsJSON,
prep.AssistantMessageID,
)
} }
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" { if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
@@ -370,6 +426,52 @@ func (h *AgentHandler) persistEinoAgentTraceForResume(conversationID string, res
} }
} }
// mergeMCPExecutionIDLists 去重合并多段 Run 的 MCP execution id(顺序:先 dst 后 more)。
func mergeMCPExecutionIDLists(dst []string, more []string) []string {
seen := make(map[string]struct{}, len(dst)+len(more))
out := make([]string, 0, len(dst)+len(more))
add := func(ids []string) {
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
}
add(dst)
add(more)
return out
}
// interruptContinueTimelineSummary 时间线 / process_details 中展示的简短正文(完整模板已写入另一条用户消息)。
func interruptContinueTimelineSummary(note string) string {
note = strings.TrimSpace(note)
if note == "" {
return "用户选择「中断并继续」,未填写说明;已按默认渗透补充模板合并上下文并续跑。"
}
return "用户中断说明(原文):\n\n" + note
}
// formatInterruptContinueUserMessage 将「中断并继续」弹窗中的说明格式化为新一轮 user 消息(渗透场景下强调路径补充与端口复扫)。
func formatInterruptContinueUserMessage(note string) string {
var b strings.Builder
b.WriteString("【用户补充 / 中断后继续】\n")
if s := strings.TrimSpace(note); s != "" {
b.WriteString(s)
b.WriteString("\n\n")
}
b.WriteString("【请在本轮落实】\n")
b.WriteString("- 将用户提供的接口路径、参数、业务变化纳入后续测试与推理。\n")
b.WriteString("- 若资产或目标信息有更新,请对目标重新执行端口/服务探测,再基于新结果规划下一步。\n")
b.WriteString("- 在已有轨迹基础上推进,避免无意义重复已完成的步骤。\n")
return strings.TrimSpace(b.String())
}
func multiAgentHTTPErrorStatus(err error) (int, string) { func multiAgentHTTPErrorStatus(err error) (int, string) {
msg := err.Error() msg := err.Error()
switch { switch {
+1 -7
View File
@@ -55,13 +55,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
if getErr != nil { if getErr != nil {
agentHistoryMessages = []agent.ChatMessage{} agentHistoryMessages = []agent.ChatMessage{}
} else { } else {
agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages)) agentHistoryMessages = dbMessagesToAgentChatMessages(historyMessages)
for _, msg := range historyMessages {
agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{
Role: msg.Role,
Content: msg.Content,
})
}
} }
} }
+57
View File
@@ -461,6 +461,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string", "type": "string",
"description": "对话ID", "description": "对话ID",
}, },
"reason": map[string]interface{}{
"type": "string",
"description": "可选。与 MCP 监控页「终止并说明」一致:非空时合并进当前工具返回给模型的文本(含 USER INTERRUPT NOTE 块)",
},
"continueAfter": map[string]interface{}{
"type": "boolean",
"description": "为 true 时仅终止当前进行中的 MCP 工具调用(不取消整轮任务);须已有工具在执行,否则 400",
},
}, },
}, },
"AgentTask": map[string]interface{}{ "AgentTask": map[string]interface{}{
@@ -3318,6 +3326,55 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
}, },
}, },
}, },
"/api/monitor/execution/{id}/cancel": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"监控"},
"summary": "取消进行中的工具执行",
"description": "对当前进程内正在执行的 MCP 工具调用发送 context 取消信号;上层对话/多步任务可继续。若执行已结束或未在本进程内运行则返回 404。",
"operationId": "cancelExecution",
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"description": "执行ID",
"schema": map[string]interface{}{
"type": "string",
},
},
},
"requestBody": map[string]interface{}{
"required": false,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"note": map[string]interface{}{
"type": "string",
"description": "可选。非空时与工具已返回输出合并交给大模型,并带有「用户终止说明」标题块以便与命令行原文区分",
},
},
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "已发送终止信号",
},
"400": map[string]interface{}{
"description": "请求体不是合法 JSON",
},
"404": map[string]interface{}{
"description": "未找到进行中的工具执行",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/monitor/executions": map[string]interface{}{ "/api/monitor/executions": map[string]interface{}{
"delete": map[string]interface{}{ "delete": map[string]interface{}{
"tags": []string{"监控"}, "tags": []string{"监控"},
+99 -12
View File
@@ -75,14 +75,58 @@ func (h *RobotHandler) sessionKey(platform, userID string) string {
return platform + "_" + userID return platform + "_" + userID
} }
func (h *RobotHandler) loadSessionBinding(sk string) (convID, role string) {
if h.db == nil || strings.TrimSpace(sk) == "" {
return "", ""
}
binding, err := h.db.GetRobotSessionBinding(sk)
if err != nil {
h.logger.Warn("读取机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err))
return "", ""
}
if binding == nil {
return "", ""
}
return binding.ConversationID, binding.RoleName
}
func (h *RobotHandler) persistSessionBinding(sk, convID, role string) {
if h.db == nil || strings.TrimSpace(sk) == "" || strings.TrimSpace(convID) == "" {
return
}
if err := h.db.UpsertRobotSessionBinding(sk, convID, role); err != nil {
h.logger.Warn("写入机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err))
}
}
func (h *RobotHandler) deleteSessionBinding(sk string) {
if h.db == nil || strings.TrimSpace(sk) == "" {
return
}
if err := h.db.DeleteRobotSessionBinding(sk); err != nil {
h.logger.Warn("删除机器人会话绑定失败", zap.String("session_key", sk), zap.Error(err))
}
}
// getOrCreateConversation 获取或创建当前会话,title 用于新对话的标题(取用户首条消息前50字) // getOrCreateConversation 获取或创建当前会话,title 用于新对话的标题(取用户首条消息前50字)
func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (convID string, isNew bool) { func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (convID string, isNew bool) {
sk := h.sessionKey(platform, userID)
h.mu.RLock() h.mu.RLock()
convID = h.sessions[h.sessionKey(platform, userID)] convID = h.sessions[sk]
h.mu.RUnlock() h.mu.RUnlock()
if convID != "" { if convID != "" {
return convID, false return convID, false
} }
if persistedConvID, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedConvID) != "" {
// 会话绑定持久化:服务重启后也可恢复当前对话和角色。
h.mu.Lock()
h.sessions[sk] = persistedConvID
if strings.TrimSpace(persistedRole) != "" {
h.sessionRoles[sk] = persistedRole
}
h.mu.Unlock()
return persistedConvID, false
}
t := strings.TrimSpace(title) t := strings.TrimSpace(title)
if t == "" { if t == "" {
t = "新对话 " + time.Now().Format("01-02 15:04") t = "新对话 " + time.Now().Format("01-02 15:04")
@@ -96,34 +140,49 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (
} }
convID = conv.ID convID = conv.ID
h.mu.Lock() h.mu.Lock()
h.sessions[h.sessionKey(platform, userID)] = convID role := h.sessionRoles[sk]
h.sessions[sk] = convID
h.mu.Unlock() h.mu.Unlock()
h.persistSessionBinding(sk, convID, role)
return convID, true return convID, true
} }
// setConversation 切换当前会话 // setConversation 切换当前会话
func (h *RobotHandler) setConversation(platform, userID, convID string) { func (h *RobotHandler) setConversation(platform, userID, convID string) {
sk := h.sessionKey(platform, userID)
h.mu.Lock() h.mu.Lock()
h.sessions[h.sessionKey(platform, userID)] = convID role := h.sessionRoles[sk]
h.sessions[sk] = convID
h.mu.Unlock() h.mu.Unlock()
h.persistSessionBinding(sk, convID, role)
} }
// getRole 获取当前用户使用的角色,未设置时返回"默认" // getRole 获取当前用户使用的角色,未设置时返回"默认"
func (h *RobotHandler) getRole(platform, userID string) string { func (h *RobotHandler) getRole(platform, userID string) string {
sk := h.sessionKey(platform, userID)
h.mu.RLock() h.mu.RLock()
role := h.sessionRoles[h.sessionKey(platform, userID)] role := h.sessionRoles[sk]
h.mu.RUnlock() h.mu.RUnlock()
if role == "" { if strings.TrimSpace(role) != "" {
return "默认"
}
return role return role
} }
if _, persistedRole := h.loadSessionBinding(sk); strings.TrimSpace(persistedRole) != "" {
h.mu.Lock()
h.sessionRoles[sk] = persistedRole
h.mu.Unlock()
return persistedRole
}
return "默认"
}
// setRole 设置当前用户使用的角色 // setRole 设置当前用户使用的角色
func (h *RobotHandler) setRole(platform, userID, roleName string) { func (h *RobotHandler) setRole(platform, userID, roleName string) {
sk := h.sessionKey(platform, userID)
h.mu.Lock() h.mu.Lock()
h.sessionRoles[h.sessionKey(platform, userID)] = roleName h.sessionRoles[sk] = roleName
convID := h.sessions[sk]
h.mu.Unlock() h.mu.Unlock()
h.persistSessionBinding(sk, convID, roleName)
} }
// clearConversation 清空当前会话(切换到新对话) // clearConversation 清空当前会话(切换到新对话)
@@ -140,7 +199,16 @@ func (h *RobotHandler) clearConversation(platform, userID string) (newConvID str
// HandleMessage 处理用户输入,返回回复文本(供各平台 webhook 调用) // HandleMessage 处理用户输入,返回回复文本(供各平台 webhook 调用)
func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply string) { func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply string) {
platform = strings.TrimSpace(platform)
userID = strings.TrimSpace(userID)
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
if platform == "" {
platform = "unknown"
}
if userID == "" {
h.logger.Warn("机器人消息缺少用户标识,已拒绝处理", zap.String("platform", platform))
return "无法识别发送者身份,请检查机器人事件订阅权限(需返回可用的用户 ID)。"
}
if text == "" { if text == "" {
return "请输入内容或发送「帮助」/ help 查看命令。" return "请输入内容或发送「帮助」/ help 查看命令。"
} }
@@ -345,7 +413,9 @@ func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
// 删除当前对话时,先清空会话绑定 // 删除当前对话时,先清空会话绑定
h.mu.Lock() h.mu.Lock()
delete(h.sessions, sk) delete(h.sessions, sk)
delete(h.sessionRoles, sk)
h.mu.Unlock() h.mu.Unlock()
h.deleteSessionBinding(sk)
} }
if err := h.db.DeleteConversation(convID); err != nil { if err := h.db.DeleteConversation(convID); err != nil {
return "删除失败: " + err.Error() return "删除失败: " + err.Error()
@@ -647,8 +717,25 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
h.logger.Debug("企业微信内层 XML 解析成功", zap.String("FromUserName", body.FromUserName), zap.String("Content", body.Content)) h.logger.Debug("企业微信内层 XML 解析成功", zap.String("FromUserName", body.FromUserName), zap.String("Content", body.Content))
} }
userID := body.FromUserName tenantKey := strings.TrimSpace(enterpriseID)
if tenantKey == "" {
tenantKey = strings.TrimSpace(h.config.Robots.Wecom.CorpID)
}
if tenantKey == "" {
tenantKey = "default"
}
rawUserID := strings.TrimSpace(body.FromUserName)
replyUserID := rawUserID
userID := ""
if rawUserID != "" {
userID = "t:" + tenantKey + "|u:" + rawUserID
}
text := strings.TrimSpace(body.Content) text := strings.TrimSpace(body.Content)
if userID == "" {
h.logger.Warn("企业微信消息缺少可用用户标识,已忽略")
c.String(http.StatusOK, "success")
return
}
// 限制回复内容长度(企业微信限制 2048 字节) // 限制回复内容长度(企业微信限制 2048 字节)
maxReplyLen := 2000 maxReplyLen := 2000
@@ -661,14 +748,14 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
if body.MsgType != "text" { if body.MsgType != "text" {
h.logger.Debug("企业微信收到非文本消息", zap.String("MsgType", body.MsgType)) h.logger.Debug("企业微信收到非文本消息", zap.String("MsgType", body.MsgType))
h.sendWecomReply(c, userID, enterpriseID, limitReply("暂仅支持文本消息,请发送文字。"), timestamp, nonce) h.sendWecomReply(c, replyUserID, enterpriseID, limitReply("暂仅支持文本消息,请发送文字。"), timestamp, nonce)
return return
} }
// 文本消息:先判断是否为内置命令(如 帮助/列表/新对话 等),这类命令处理很快,可以直接走被动回复,避免依赖主动发送 API。 // 文本消息:先判断是否为内置命令(如 帮助/列表/新对话 等),这类命令处理很快,可以直接走被动回复,避免依赖主动发送 API。
if cmdReply, ok := h.handleRobotCommand("wecom", userID, text); ok { if cmdReply, ok := h.handleRobotCommand("wecom", userID, text); ok {
h.logger.Debug("企业微信收到命令消息,走被动回复", zap.String("userID", userID), zap.String("text", text)) h.logger.Debug("企业微信收到命令消息,走被动回复", zap.String("userID", userID), zap.String("text", text))
h.sendWecomReply(c, userID, enterpriseID, limitReply(cmdReply), timestamp, nonce) h.sendWecomReply(c, replyUserID, enterpriseID, limitReply(cmdReply), timestamp, nonce)
return return
} }
@@ -684,7 +771,7 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
reply = limitReply(reply) reply = limitReply(reply)
h.logger.Debug("企业微信消息处理完成", zap.String("userID", userID), zap.String("reply", reply)) h.logger.Debug("企业微信消息处理完成", zap.String("userID", userID), zap.String("reply", reply))
// 调用企业微信 API 主动发送消息 // 调用企业微信 API 主动发送消息
h.sendWecomMessageViaAPI(userID, enterpriseID, reply) h.sendWecomMessageViaAPI(rawUserID, enterpriseID, reply)
}() }()
} }
+112
View File
@@ -3,8 +3,11 @@ package handler
import ( import (
"context" "context"
"errors" "errors"
"strings"
"sync" "sync"
"time" "time"
"cyberstrike-ai/internal/multiagent"
) )
// ErrTaskCancelled 用户取消任务的错误 // ErrTaskCancelled 用户取消任务的错误
@@ -13,6 +16,13 @@ var ErrTaskCancelled = errors.New("agent task cancelled by user")
// ErrTaskAlreadyRunning 会话已有任务正在执行 // ErrTaskAlreadyRunning 会话已有任务正在执行
var ErrTaskAlreadyRunning = errors.New("agent task already running for conversation") var ErrTaskAlreadyRunning = errors.New("agent task already running for conversation")
// shouldPersistEinoAgentTraceAfterRunErrorEino 相关 Run 非成功返回时,是否仍写入 last_react_* 供下轮 loadHistoryFromAgentTrace。
// 当前策略:无论正常结束、异常结束或用户主动停止,都尽量保留最后可用轨迹,
// 以便在同一会话继续时可基于原始上下文续跑,而不是回退到仅消息文本历史。
func shouldPersistEinoAgentTraceAfterRunError(baseCtx context.Context) bool {
return true
}
// AgentTask 描述正在运行的Agent任务 // AgentTask 描述正在运行的Agent任务
type AgentTask struct { type AgentTask struct {
ConversationID string `json:"conversationId"` ConversationID string `json:"conversationId"`
@@ -21,9 +31,103 @@ type AgentTask struct {
Status string `json:"status"` Status string `json:"status"`
CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务 CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务
// ActiveMCPExecutionID 当前正在执行的 MCP 工具 executionId(仅内存,供「中断并继续」= 仅掐当前工具)
ActiveMCPExecutionID string `json:"-"`
// InterruptContinueNote 无 MCP 时「中断并继续」由用户在弹窗中填写的补充说明(Cancel 前写入,续跑轮次读取后清空)
InterruptContinueNote string `json:"-"`
cancel func(error) cancel func(error)
} }
// RegisterRunningTool 实现 mcp.ToolRunRegistry:工具开始时登记本会话当前 executionId。
func (m *AgentTaskManager) RegisterRunningTool(conversationID, executionID string) {
conversationID = strings.TrimSpace(conversationID)
executionID = strings.TrimSpace(executionID)
if conversationID == "" || executionID == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.tasks[conversationID]; ok && t != nil {
t.ActiveMCPExecutionID = executionID
}
}
// UnregisterRunningTool 工具结束时清除登记(仅当 id 仍匹配时清除,避免并发串单)。
func (m *AgentTaskManager) UnregisterRunningTool(conversationID, executionID string) {
conversationID = strings.TrimSpace(conversationID)
executionID = strings.TrimSpace(executionID)
if conversationID == "" || executionID == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.tasks[conversationID]; ok && t != nil {
if t.ActiveMCPExecutionID == executionID {
t.ActiveMCPExecutionID = ""
}
}
}
// SetInterruptContinueNote 在发起 ErrInterruptContinue 取消前写入用户补充说明(仅内存)。
func (m *AgentTaskManager) SetInterruptContinueNote(conversationID, note string) {
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.tasks[conversationID]; ok && t != nil {
t.InterruptContinueNote = note
}
}
// TakeInterruptContinueNote 读取并清空补充说明(续跑开始时调用一次)。
func (m *AgentTaskManager) TakeInterruptContinueNote(conversationID string) string {
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" {
return ""
}
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.tasks[conversationID]; ok && t != nil {
n := t.InterruptContinueNote
t.InterruptContinueNote = ""
return n
}
return ""
}
// BindTaskCancel 在同一运行任务内替换与 context 绑定的 cancel 函数(用于中断后继续时换新 baseCtx)。
func (m *AgentTaskManager) BindTaskCancel(conversationID string, cancel context.CancelCauseFunc) {
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" || cancel == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.tasks[conversationID]; ok && t != nil {
t.cancel = func(err error) {
cancel(err)
}
}
}
// ActiveMCPExecutionID 返回当前会话进行中的工具 executionId,无则空串。
func (m *AgentTaskManager) ActiveMCPExecutionID(conversationID string) string {
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" {
return ""
}
m.mu.RLock()
defer m.mu.RUnlock()
if t, ok := m.tasks[conversationID]; ok && t != nil {
return strings.TrimSpace(t.ActiveMCPExecutionID)
}
return ""
}
// CompletedTask 已完成的任务(用于历史记录) // CompletedTask 已完成的任务(用于历史记录)
type CompletedTask struct { type CompletedTask struct {
ConversationID string `json:"conversationId"` ConversationID string `json:"conversationId"`
@@ -155,8 +259,16 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
return true, nil return true, nil
} }
// ErrInterruptContinue:仅掐断当前推理步骤,随后由处理器续跑,不进入长时间「取消中」态。
if cause != nil && errors.Is(cause, multiagent.ErrInterruptContinue) {
task.Status = "running"
} else {
task.Status = "cancelling" task.Status = "cancelling"
task.CancellingAt = time.Now() task.CancellingAt = time.Now()
}
if cause != nil && errors.Is(cause, ErrTaskCancelled) {
task.InterruptContinueNote = ""
}
cancel := task.cancel cancel := task.cancel
m.mu.Unlock() m.mu.Unlock()
+104 -3
View File
@@ -32,6 +32,8 @@ type ExternalMCPManager struct {
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成 refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积 refreshing atomic.Bool // 防止 refreshToolCounts 并发堆积
mu sync.RWMutex mu sync.RWMutex
runningCancels map[string]context.CancelFunc
abortUserNotes map[string]string
} }
// NewExternalMCPManager 创建外部MCP管理器 // NewExternalMCPManager 创建外部MCP管理器
@@ -52,6 +54,8 @@ func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage
toolCounts: make(map[string]int), toolCounts: make(map[string]int),
toolCache: make(map[string][]Tool), toolCache: make(map[string][]Tool),
stopRefresh: make(chan struct{}), stopRefresh: make(chan struct{}),
runningCancels: make(map[string]context.CancelFunc),
abortUserNotes: make(map[string]string),
} }
// 启动后台刷新工具数量的goroutine // 启动后台刷新工具数量的goroutine
manager.startToolCountRefresh() manager.startToolCountRefresh()
@@ -452,8 +456,18 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
} }
} }
execCtx, runCancel := context.WithCancel(ctx)
m.registerRunningCancel(executionID, runCancel)
notifyToolRunBegin(ctx, executionID)
defer func() {
notifyToolRunEnd(ctx, executionID)
runCancel()
m.unregisterRunningCancel(executionID)
}()
// 调用工具 // 调用工具
result, err := client.CallTool(ctx, actualToolName, args) result, err := client.CallTool(execCtx, actualToolName, args)
cancelledWithUserNote := m.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
// 更新执行记录 // 更新执行记录
m.mu.Lock() m.mu.Lock()
@@ -462,9 +476,15 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
execution.Duration = now.Sub(execution.StartTime) execution.Duration = now.Sub(execution.StartTime)
if err != nil { if err != nil {
execution.Status = "failed" st, msg := executionStatusAndMessage(err)
execution.Error = err.Error() execution.Status = st
execution.Error = msg
} else if result != nil && result.IsError { } else if result != nil && result.IsError {
if cancelledWithUserNote {
execution.Status = "cancelled"
execution.Error = ""
execution.Result = result
} else {
execution.Status = "failed" execution.Status = "failed"
if len(result.Content) > 0 { if len(result.Content) > 0 {
execution.Error = result.Content[0].Text execution.Error = result.Content[0].Text
@@ -472,6 +492,7 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
execution.Error = "工具执行返回错误结果" execution.Error = "工具执行返回错误结果"
} }
execution.Result = result execution.Result = result
}
} else { } else {
execution.Status = "completed" execution.Status = "completed"
if result == nil { if result == nil {
@@ -509,6 +530,50 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
return result, executionID, nil return result, executionID, nil
} }
func (m *ExternalMCPManager) applyAbortUserNoteToCancelledToolResult(executionID string, result **ToolResult, err *error) (cancelledWithUserNote bool) {
note := strings.TrimSpace(m.readAbortUserNote(executionID))
if note == "" {
return false
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if !hasErr && !hasRes {
return false
}
_ = m.takeAbortUserNote(executionID)
partial := ""
if hasRes {
partial = ToolResultPlainText(*result)
}
if partial == "" && hasErr {
partial = (*err).Error()
}
merged := MergePartialToolOutputAndAbortNote(partial, note)
*err = nil
*result = &ToolResult{Content: []Content{{Type: "text", Text: merged}}, IsError: true}
return true
}
func (m *ExternalMCPManager) readAbortUserNote(id string) string {
m.mu.Lock()
defer m.mu.Unlock()
if m.abortUserNotes == nil {
return ""
}
return m.abortUserNotes[id]
}
func (m *ExternalMCPManager) takeAbortUserNote(id string) string {
m.mu.Lock()
defer m.mu.Unlock()
if m.abortUserNotes == nil {
return ""
}
n := m.abortUserNotes[id]
delete(m.abortUserNotes, id)
return n
}
// cleanupOldExecutions 清理旧的执行记录(保持内存中的记录数量在限制内) // cleanupOldExecutions 清理旧的执行记录(保持内存中的记录数量在限制内)
func (m *ExternalMCPManager) cleanupOldExecutions() { func (m *ExternalMCPManager) cleanupOldExecutions() {
const maxExecutionsInMemory = 1000 const maxExecutionsInMemory = 1000
@@ -562,6 +627,42 @@ func (m *ExternalMCPManager) GetExecution(id string) (*ToolExecution, bool) {
return nil, false return nil, false
} }
func (m *ExternalMCPManager) registerRunningCancel(id string, cancel context.CancelFunc) {
m.mu.Lock()
m.runningCancels[id] = cancel
m.mu.Unlock()
}
func (m *ExternalMCPManager) unregisterRunningCancel(id string) {
m.mu.Lock()
delete(m.runningCancels, id)
m.mu.Unlock()
}
// CancelToolExecutionWithNote 取消外部 MCP 工具;note 非空时与已返回输出合并后交给模型。
func (m *ExternalMCPManager) CancelToolExecutionWithNote(id string, note string) bool {
m.mu.Lock()
cancel, ok := m.runningCancels[id]
if !ok || cancel == nil {
m.mu.Unlock()
return false
}
if strings.TrimSpace(note) != "" {
if m.abortUserNotes == nil {
m.abortUserNotes = make(map[string]string)
}
m.abortUserNotes[id] = strings.TrimSpace(note)
}
m.mu.Unlock()
cancel()
return true
}
// CancelToolExecution 取消正在执行的外部 MCP 工具(无用户说明)。
func (m *ExternalMCPManager) CancelToolExecution(id string) bool {
return m.CancelToolExecutionWithNote(id, "")
}
// updateStats 更新统计信息 // updateStats 更新统计信息
func (m *ExternalMCPManager) updateStats(toolName string, failed bool) { func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
now := time.Now() now := time.Now()
+77
View File
@@ -0,0 +1,77 @@
package mcp
import (
"context"
"strings"
)
// ToolRunRegistry 在工具开始/结束时登记当前 executionId,供对话页「仅终止当前工具」与监控页共用取消逻辑。
type ToolRunRegistry interface {
RegisterRunningTool(conversationID, executionID string)
UnregisterRunningTool(conversationID, executionID string)
}
type toolRunRegistryCtxKey struct{}
type mcpConversationIDCtxKey struct{}
// WithToolRunRegistry 将登记器注入 ctxEino / 原生 Agent 任务 ctx)。
func WithToolRunRegistry(ctx context.Context, reg ToolRunRegistry) context.Context {
if ctx == nil || reg == nil {
return ctx
}
return context.WithValue(ctx, toolRunRegistryCtxKey{}, reg)
}
// ToolRunRegistryFromContext 取出登记器(无则 nil)。
func ToolRunRegistryFromContext(ctx context.Context) ToolRunRegistry {
if ctx == nil {
return nil
}
v, _ := ctx.Value(toolRunRegistryCtxKey{}).(ToolRunRegistry)
return v
}
// WithMCPConversationID 将对话 ID 注入 ctx,供 CallTool 内与 executionId 关联。
func WithMCPConversationID(ctx context.Context, conversationID string) context.Context {
if ctx == nil {
return nil
}
id := strings.TrimSpace(conversationID)
if id == "" {
return ctx
}
return context.WithValue(ctx, mcpConversationIDCtxKey{}, id)
}
// MCPConversationIDFromContext 读取对话 ID。
func MCPConversationIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
v, _ := ctx.Value(mcpConversationIDCtxKey{}).(string)
return v
}
func notifyToolRunBegin(ctx context.Context, executionID string) {
reg := ToolRunRegistryFromContext(ctx)
if reg == nil {
return
}
conv := MCPConversationIDFromContext(ctx)
if conv == "" || strings.TrimSpace(executionID) == "" {
return
}
reg.RegisterRunningTool(conv, executionID)
}
func notifyToolRunEnd(ctx context.Context, executionID string) {
reg := ToolRunRegistryFromContext(ctx)
if reg == nil {
return
}
conv := MCPConversationIDFromContext(ctx)
if conv == "" || strings.TrimSpace(executionID) == "" {
return
}
reg.UnregisterRunningTool(conv, executionID)
}
+185 -9
View File
@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -40,6 +41,9 @@ type Server struct {
logger *zap.Logger logger *zap.Logger
maxExecutionsInMemory int // 内存中最大执行记录数 maxExecutionsInMemory int // 内存中最大执行记录数
sseClients map[string]*sseClient sseClients map[string]*sseClient
runningCancels map[string]context.CancelFunc
runningCancelsMu sync.Mutex
abortUserNotes map[string]string // 监控页终止时附带的用户说明,与 executionID 对应
} }
type sseClient struct { type sseClient struct {
@@ -50,6 +54,13 @@ type sseClient struct {
// ToolHandler 工具处理函数 // ToolHandler 工具处理函数
type ToolHandler func(ctx context.Context, args map[string]interface{}) (*ToolResult, error) type ToolHandler func(ctx context.Context, args map[string]interface{}) (*ToolResult, error)
func executionStatusAndMessage(err error) (status string, errMsg string) {
if errors.Is(err, context.Canceled) {
return "cancelled", "已手动终止(MCP 监控)"
}
return "failed", err.Error()
}
// NewServer 创建新的MCP服务器 // NewServer 创建新的MCP服务器
func NewServer(logger *zap.Logger) *Server { func NewServer(logger *zap.Logger) *Server {
return NewServerWithStorage(logger, nil) return NewServerWithStorage(logger, nil)
@@ -68,6 +79,8 @@ func NewServerWithStorage(logger *zap.Logger, storage MonitorStorage) *Server {
logger: logger, logger: logger,
maxExecutionsInMemory: 1000, // 默认最多在内存中保留1000条执行记录 maxExecutionsInMemory: 1000, // 默认最多在内存中保留1000条执行记录
sseClients: make(map[string]*sseClient), sseClients: make(map[string]*sseClient),
runningCancels: make(map[string]context.CancelFunc),
abortUserNotes: make(map[string]string),
} }
// 初始化默认提示词和资源 // 初始化默认提示词和资源
@@ -444,15 +457,22 @@ func (s *Server) handleCallTool(msg *Message) *Message {
} }
} }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) baseCtx, timeoutCancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel() defer timeoutCancel()
execCtx, runCancel := context.WithCancel(baseCtx)
s.registerRunningCancel(executionID, runCancel)
defer func() {
runCancel()
s.unregisterRunningCancel(executionID)
}()
s.logger.Info("开始执行工具", s.logger.Info("开始执行工具",
zap.String("toolName", req.Name), zap.String("toolName", req.Name),
zap.Any("arguments", req.Arguments), zap.Any("arguments", req.Arguments),
) )
result, err := handler(ctx, req.Arguments) result, err := handler(execCtx, req.Arguments)
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
now := time.Now() now := time.Now()
var failed bool var failed bool
var finalResult *ToolResult var finalResult *ToolResult
@@ -462,10 +482,17 @@ func (s *Server) handleCallTool(msg *Message) *Message {
execution.Duration = now.Sub(execution.StartTime) execution.Duration = now.Sub(execution.StartTime)
if err != nil { if err != nil {
execution.Status = "failed" st, msg := executionStatusAndMessage(err)
execution.Error = err.Error() execution.Status = st
execution.Error = msg
failed = true failed = true
} else if result != nil && result.IsError { } else if result != nil && result.IsError {
if cancelledWithUserNote {
execution.Status = "cancelled"
execution.Error = ""
execution.Result = result
failed = true
} else {
execution.Status = "failed" execution.Status = "failed"
if len(result.Content) > 0 { if len(result.Content) > 0 {
execution.Error = result.Content[0].Text execution.Error = result.Content[0].Text
@@ -474,6 +501,7 @@ func (s *Server) handleCallTool(msg *Message) *Message {
} }
execution.Result = result execution.Result = result
failed = true failed = true
}
} else { } else {
execution.Status = "completed" execution.Status = "completed"
if result == nil { if result == nil {
@@ -510,9 +538,13 @@ func (s *Server) handleCallTool(msg *Message) *Message {
zap.Error(err), zap.Error(err),
) )
errText := fmt.Sprintf("工具执行失败: %v", err)
if errors.Is(err, context.Canceled) {
errText = "工具执行已手动终止(MCP 监控)。后续编排步骤可继续。"
}
errorResult, _ := json.Marshal(CallToolResponse{ errorResult, _ := json.Marshal(CallToolResponse{
Content: []Content{ Content: []Content{
{Type: "text", Text: fmt.Sprintf("工具执行失败: %v", err)}, {Type: "text", Text: errText},
}, },
IsError: true, IsError: true,
}) })
@@ -769,7 +801,17 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
} }
} }
result, err := handler(ctx, args) execCtx, runCancel := context.WithCancel(ctx)
s.registerRunningCancel(executionID, runCancel)
notifyToolRunBegin(ctx, executionID)
defer func() {
notifyToolRunEnd(ctx, executionID)
runCancel()
s.unregisterRunningCancel(executionID)
}()
result, err := handler(execCtx, args)
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
s.mu.Lock() s.mu.Lock()
now := time.Now() now := time.Now()
@@ -779,10 +821,18 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
var finalResult *ToolResult var finalResult *ToolResult
if err != nil { if err != nil {
execution.Status = "failed" st, msg := executionStatusAndMessage(err)
execution.Error = err.Error() execution.Status = st
execution.Error = msg
failed = true failed = true
} else if result != nil && result.IsError { } else if result != nil && result.IsError {
if cancelledWithUserNote {
execution.Status = "cancelled"
execution.Error = ""
execution.Result = result
failed = true
finalResult = result
} else {
execution.Status = "failed" execution.Status = "failed"
if len(result.Content) > 0 { if len(result.Content) > 0 {
execution.Error = result.Content[0].Text execution.Error = result.Content[0].Text
@@ -792,6 +842,7 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
execution.Result = result execution.Result = result
failed = true failed = true
finalResult = result finalResult = result
}
} else { } else {
execution.Status = "completed" execution.Status = "completed"
if result == nil { if result == nil {
@@ -832,6 +883,49 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
return finalResult, executionID, nil return finalResult, executionID, nil
} }
// RecordCompletedToolInvocation 将已在其它路径完成的工具调用写入监控存储(格式与 CallTool 结束后一致),
// 用于 Eino ADK filesystem execute 等未经过 CallTool 的场景;返回 executionId 供助手消息 mcpExecutionIds 关联。
func (s *Server) RecordCompletedToolInvocation(toolName string, args map[string]interface{}, resultText string, invokeErr error) string {
if s == nil {
return ""
}
if args == nil {
args = map[string]interface{}{}
}
executionID := uuid.New().String()
now := time.Now()
failed := invokeErr != nil
exec := &ToolExecution{
ID: executionID,
ToolName: toolName,
Arguments: args,
StartTime: now,
EndTime: &now,
Duration: 0,
}
if failed {
exec.Status = "failed"
exec.Error = invokeErr.Error()
if strings.TrimSpace(resultText) != "" {
exec.Result = &ToolResult{Content: []Content{{Type: "text", Text: resultText}}}
}
} else {
exec.Status = "completed"
text := resultText
if strings.TrimSpace(text) == "" {
text = "(无输出)"
}
exec.Result = &ToolResult{Content: []Content{{Type: "text", Text: text}}}
}
if s.storage != nil {
if err := s.storage.SaveToolExecution(exec); err != nil {
s.logger.Warn("RecordCompletedToolInvocation 保存失败", zap.Error(err))
}
}
s.updateStats(toolName, failed)
return executionID
}
// cleanupOldExecutions 清理旧的执行记录,防止内存无限增长 // cleanupOldExecutions 清理旧的执行记录,防止内存无限增长
func (s *Server) cleanupOldExecutions() { func (s *Server) cleanupOldExecutions() {
if len(s.executions) <= s.maxExecutionsInMemory { if len(s.executions) <= s.maxExecutionsInMemory {
@@ -869,6 +963,88 @@ func (s *Server) cleanupOldExecutions() {
) )
} }
func (s *Server) registerRunningCancel(id string, cancel context.CancelFunc) {
s.runningCancelsMu.Lock()
s.runningCancels[id] = cancel
s.runningCancelsMu.Unlock()
}
func (s *Server) unregisterRunningCancel(id string) {
s.runningCancelsMu.Lock()
delete(s.runningCancels, id)
s.runningCancelsMu.Unlock()
}
func (s *Server) readAbortUserNote(id string) string {
s.runningCancelsMu.Lock()
defer s.runningCancelsMu.Unlock()
if s.abortUserNotes == nil {
return ""
}
return s.abortUserNotes[id]
}
func (s *Server) takeAbortUserNote(id string) string {
s.runningCancelsMu.Lock()
defer s.runningCancelsMu.Unlock()
if s.abortUserNotes == nil {
return ""
}
n := s.abortUserNotes[id]
delete(s.abortUserNotes, id)
return n
}
// applyAbortUserNoteToCancelledToolResult 监控页「终止并填写说明」时合并「工具已输出 + 用户说明」交给模型。
// exec 等工具会把失败写在 *ToolResult 里并返回 err==nil,若仅在 err!=nil 时合并会漏掉说明,甚至误 clear 掉 note。
func (s *Server) applyAbortUserNoteToCancelledToolResult(executionID string, result **ToolResult, err *error) (cancelledWithUserNote bool) {
note := strings.TrimSpace(s.readAbortUserNote(executionID))
if note == "" {
return false
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if !hasErr && !hasRes {
return false
}
_ = s.takeAbortUserNote(executionID)
partial := ""
if hasRes {
partial = ToolResultPlainText(*result)
}
if partial == "" && hasErr {
partial = (*err).Error()
}
merged := MergePartialToolOutputAndAbortNote(partial, note)
*err = nil
*result = &ToolResult{Content: []Content{{Type: "text", Text: merged}}, IsError: true}
return true
}
// CancelToolExecutionWithNote 取消内部工具;note 非空时与工具已返回文本合并后交给上层模型。
func (s *Server) CancelToolExecutionWithNote(id string, note string) bool {
s.runningCancelsMu.Lock()
cancel, ok := s.runningCancels[id]
if !ok || cancel == nil {
s.runningCancelsMu.Unlock()
return false
}
if strings.TrimSpace(note) != "" {
if s.abortUserNotes == nil {
s.abortUserNotes = make(map[string]string)
}
s.abortUserNotes[id] = strings.TrimSpace(note)
}
s.runningCancelsMu.Unlock()
cancel()
return true
}
// CancelToolExecution 取消正在执行的内部工具调用(无用户说明)。
func (s *Server) CancelToolExecution(id string) bool {
return s.CancelToolExecutionWithNote(id, "")
}
// initDefaultPrompts 初始化默认提示词模板 // initDefaultPrompts 初始化默认提示词模板
func (s *Server) initDefaultPrompts() { func (s *Server) initDefaultPrompts() {
s.mu.Lock() s.mu.Lock()
+35 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
) )
@@ -192,7 +193,7 @@ type ToolExecution struct {
ID string `json:"id"` ID string `json:"id"`
ToolName string `json:"toolName"` ToolName string `json:"toolName"`
Arguments map[string]interface{} `json:"arguments"` Arguments map[string]interface{} `json:"arguments"`
Status string `json:"status"` // pending, running, completed, failed Status string `json:"status"` // pending, running, completed, failed, cancelled
Result *ToolResult `json:"result,omitempty"` Result *ToolResult `json:"result,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
StartTime time.Time `json:"startTime"` StartTime time.Time `json:"startTime"`
@@ -293,3 +294,36 @@ type SamplingContent struct {
Type string `json:"type"` Type string `json:"type"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
} }
// ToolResultPlainText 拼接工具结果中的文本(手动终止时作为「工具原始输出」)。
func ToolResultPlainText(r *ToolResult) string {
if r == nil || len(r.Content) == 0 {
return ""
}
var b strings.Builder
for _, c := range r.Content {
b.WriteString(c.Text)
}
return strings.TrimSpace(b.String())
}
// AbortNoteBannerForModel 标出后续文本来自「用户手动终止工具时在弹窗中填写」,避免与 stdout/stderr 混淆。
const AbortNoteBannerForModel = "---\n" +
"【用户终止说明|USER INTERRUPT NOTE】\n" +
"(以下由操作者填写,用于指示模型如何继续;不是工具原始输出。)\n" +
"Written by the operator when stopping this tool; not raw tool output.\n" +
"---"
// MergePartialToolOutputAndAbortNote 格式:工具原始输出 + 醒目标题 + 用户终止说明(无说明则原样返回 partial)。
func MergePartialToolOutputAndAbortNote(partial, userNote string) string {
partial = strings.TrimSpace(partial)
userNote = strings.TrimSpace(userNote)
if userNote == "" {
return partial
}
section := AbortNoteBannerForModel + "\n" + userNote
if partial == "" {
return section
}
return partial + "\n\n" + section
}
+355 -21
View File
@@ -11,14 +11,44 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"unicode/utf8"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/einomcp" "cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai"
"github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
"go.uber.org/zap" "go.uber.org/zap"
) )
// normalizeStreamingDelta 将可能是“累计片段”的 chunk 归一化为“纯增量”。
// 一些模型/桥接层在流式过程中会重复发送已输出前缀,前端若直接 buffer+=chunk 会出现重复文本。
//
// 注意:与 internal/openai.normalizeStreamingDelta 保持一致。
func normalizeStreamingDelta(current, incoming string) (next, delta string) {
if incoming == "" {
return current, ""
}
if current == "" {
return incoming, incoming
}
if strings.HasPrefix(incoming, current) && len(incoming) > len(current) {
return incoming, incoming[len(current):]
}
if incoming == current && utf8.RuneCountInString(current) > 1 {
return current, ""
}
return current + incoming, incoming
}
func isInterruptContinue(ctx context.Context) bool {
if ctx == nil {
return false
}
return errors.Is(context.Cause(ctx), ErrInterruptContinue)
}
func isEinoIterationLimitError(err error) bool { func isEinoIterationLimitError(err error) bool {
if err == nil { if err == nil {
return false return false
@@ -49,10 +79,22 @@ type einoADKRunLoopArgs struct {
McpIDsMu *sync.Mutex McpIDsMu *sync.Mutex
McpIDs *[]string McpIDs *[]string
// FilesystemMonitorAgent / FilesystemMonitorRecord 非 nil 时,将 Eino ADK filesystem 中间件工具(ls/read_file/write_file/edit_file/glob/grep
// 在完成时写入 MCP 监控;execute 仍由 eino_execute_monitor 记录,此处跳过。
FilesystemMonitorAgent *agent.Agent
FilesystemMonitorRecord einomcp.ExecutionRecorder
// ToolInvokeNotify 与 einomcp.ToolsFromDefinitions 共享:run loop 在迭代前 SetMCP 桥 Fire 以补全 tool_result。
ToolInvokeNotify *einomcp.ToolInvokeNotifyHolder
DA adk.Agent DA adk.Agent
// EmptyResponseMessage 当未捕获到助手正文时的占位(多代理与单代理文案不同)。 // EmptyResponseMessage 当未捕获到助手正文时的占位(多代理与单代理文案不同)。
EmptyResponseMessage string EmptyResponseMessage string
// ModelFacingTrace 可选:由各 ChatModelAgent Handlers 链末尾中间件写入「即将送入模型」的消息快照;
// 非空时优先用于 LastAgentTraceInput 序列化,使续跑与 summarization/reduction 后的上下文一致。
ModelFacingTrace *modelFacingTraceHolder
} }
func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs []adk.Message) (*RunResult, error) { func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs []adk.Message) (*RunResult, error) {
@@ -190,6 +232,63 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
pendingQueueByAgent = make(map[string][]string) pendingQueueByAgent = make(map[string][]string)
} }
// 最近一次成功的 Eino filesystem execute 的标准输出(trim):用于抑制模型紧接着复述同一字符串时的重复「助手输出」时间线。
var executeStdoutDupMu sync.Mutex
var pendingExecuteStdoutDup string
recordPendingExecuteStdoutDup := func(toolName, stdout string, isErr bool) {
if isErr || !strings.EqualFold(strings.TrimSpace(toolName), "execute") {
return
}
t := strings.TrimSpace(stdout)
if t == "" {
return
}
executeStdoutDupMu.Lock()
pendingExecuteStdoutDup = t
executeStdoutDupMu.Unlock()
}
var toolResultSent sync.Map // toolCallID -> struct{};与 ADK Tool 消息去重,避免 bridge 与事件流各推一次
if args.ToolInvokeNotify != nil {
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
tid := strings.TrimSpace(toolCallID)
removePendingByID(tid)
if tid == "" || progress == nil {
return
}
if _, loaded := toolResultSent.LoadOrStore(tid, struct{}{}); loaded {
return
}
isErr := !success || invokeErr != nil
body := content
if invokeErr != nil {
body = invokeErr.Error()
isErr = true
}
recordPendingExecuteStdoutDup(toolName, body, isErr)
preview := body
if len(preview) > 200 {
preview = preview[:200] + "..."
}
agentTag := strings.TrimSpace(einoAgent)
if agentTag == "" {
agentTag = orchestratorName
}
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), map[string]interface{}{
"toolName": toolName,
"success": !isErr,
"isError": isErr,
"result": body,
"resultPreview": preview,
"toolCallId": tid,
"conversationId": conversationID,
"einoAgent": agentTag,
"einoRole": einoRoleTag(agentTag),
"source": "eino",
})
})
}
runnerCfg := adk.RunnerConfig{ runnerCfg := adk.RunnerConfig{
Agent: da, Agent: da,
EnableStreaming: true, EnableStreaming: true,
@@ -318,7 +417,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
} }
ids := snapshotMCPIDs() ids := snapshotMCPIDs()
return buildEinoRunResultFromAccumulated( return buildEinoRunResultFromAccumulated(
orchMode, runAccumulatedMsgs, lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, true, orchMode, runAccumulatedMsgs, persistTraceSource(args, runAccumulatedMsgs),
lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, true,
), runErr ), runErr
} }
@@ -328,11 +428,19 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
case <-ctx.Done(): case <-ctx.Done():
flushAllPendingAsFailed(ctx.Err()) flushAllPendingAsFailed(ctx.Err())
if progress != nil { if progress != nil {
if isInterruptContinue(ctx) {
progress("progress", "已暂停当前输出,正在合并用户补充并继续…", map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"kind": "interrupt_continue",
})
} else {
progress("error", "Request cancelled / 请求已取消", map[string]interface{}{ progress("error", "Request cancelled / 请求已取消", map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
"source": "eino", "source": "eino",
}) })
} }
}
return takePartial(ctx.Err()) return takePartial(ctx.Err())
default: default:
} }
@@ -345,11 +453,19 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
if ctxErr := ctx.Err(); ctxErr != nil { if ctxErr := ctx.Err(); ctxErr != nil {
flushAllPendingAsFailed(ctxErr) flushAllPendingAsFailed(ctxErr)
if progress != nil { if progress != nil {
if isInterruptContinue(ctx) {
progress("progress", "已暂停当前输出,正在合并用户补充并继续…", map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"kind": "interrupt_continue",
})
} else {
progress("error", ctxErr.Error(), map[string]interface{}{ progress("error", ctxErr.Error(), map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
"source": "eino", "source": "eino",
}) })
} }
}
return takePartial(ctxErr) return takePartial(ctxErr)
} }
if len(pendingByID) > 0 { if len(pendingByID) > 0 {
@@ -430,15 +546,42 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
streamHeaderSent := false streamHeaderSent := false
var reasoningStreamID string var reasoningStreamID string
var toolStreamFragments []schema.ToolCall var toolStreamFragments []schema.ToolCall
var subAssistantBuf strings.Builder var subAssistantBuf string
var subReplyStreamID string var subReplyStreamID string
var mainAssistantBuf strings.Builder var mainAssistantBuf string
var mainAssistDupTarget string // 非空表示本段主助手流需缓冲至 EOF,与 execute 输出比对去重
var reasoningBuf string
var prevReasoningDisplay string // UI 用:剥离 Claude 内部 signature 尾缀后的累计展示
var streamRecvErr error var streamRecvErr error
type streamMsg struct {
chunk *schema.Message
err error
}
recvCh := make(chan streamMsg, 8)
go func() {
defer close(recvCh)
for { for {
chunk, rerr := mv.MessageStream.Recv() ch, rerr := mv.MessageStream.Recv()
recvCh <- streamMsg{chunk: ch, err: rerr}
if rerr != nil {
return
}
}
}()
streamRecvLoop:
for {
select {
case <-ctx.Done():
streamRecvErr = ctx.Err()
break streamRecvLoop
case sm, ok := <-recvCh:
if !ok {
break streamRecvLoop
}
chunk, rerr := sm.chunk, sm.err
if rerr != nil { if rerr != nil {
if errors.Is(rerr, io.EOF) { if errors.Is(rerr, io.EOF) {
break break streamRecvLoop
} }
if logger != nil { if logger != nil {
logger.Warn("eino stream recv error, flushing incomplete stream", logger.Warn("eino stream recv error, flushing incomplete stream",
@@ -447,15 +590,27 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
zap.Int("toolFragments", len(toolStreamFragments))) zap.Int("toolFragments", len(toolStreamFragments)))
} }
streamRecvErr = rerr streamRecvErr = rerr
break break streamRecvLoop
} }
if chunk == nil { if chunk == nil {
continue continue
} }
if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" { if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
var reasoningDelta string
reasoningBuf, reasoningDelta = normalizeStreamingDelta(reasoningBuf, chunk.ReasoningContent)
if reasoningDelta != "" {
fullDisplay := openai.DisplayReasoningContent(reasoningBuf)
var displayDelta string
if strings.HasPrefix(fullDisplay, prevReasoningDisplay) {
displayDelta = fullDisplay[len(prevReasoningDisplay):]
} else {
displayDelta = fullDisplay
}
prevReasoningDisplay = fullDisplay
if displayDelta != "" {
if reasoningStreamID == "" { if reasoningStreamID == "" {
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1)) reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
progress("thinking_stream_start", " ", map[string]interface{}{ progress("reasoning_chain_stream_start", " ", map[string]interface{}{
"streamId": reasoningStreamID, "streamId": reasoningStreamID,
"source": "eino", "source": "eino",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
@@ -463,12 +618,27 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"orchestration": orchMode, "orchestration": orchMode,
}) })
} }
progress("thinking_stream_delta", chunk.ReasoningContent, map[string]interface{}{ progress("reasoning_chain_stream_delta", displayDelta, map[string]interface{}{
"streamId": reasoningStreamID, "streamId": reasoningStreamID,
}) })
} }
}
}
if chunk.Content != "" { if chunk.Content != "" {
if progress != nil && streamsMainAssistant(ev.AgentName) { if progress != nil && streamsMainAssistant(ev.AgentName) {
var contentDelta string
mainAssistantBuf, contentDelta = normalizeStreamingDelta(mainAssistantBuf, chunk.Content)
if contentDelta != "" {
if mainAssistDupTarget == "" {
executeStdoutDupMu.Lock()
if pendingExecuteStdoutDup != "" {
mainAssistDupTarget = pendingExecuteStdoutDup
}
executeStdoutDupMu.Unlock()
}
if mainAssistDupTarget != "" {
// 已展示过 tool_result,缓冲全文;EOF 后与 execute 输出相同则不再发助手流
} else {
if !streamHeaderSent { if !streamHeaderSent {
progress("response_start", "", map[string]interface{}{ progress("response_start", "", map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
@@ -480,15 +650,19 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
}) })
streamHeaderSent = true streamHeaderSent = true
} }
progress("response_delta", chunk.Content, map[string]interface{}{ progress("response_delta", contentDelta, map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(), "mcpExecutionIds": snapshotMCPIDs(),
"einoRole": "orchestrator", "einoRole": "orchestrator",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
"orchestration": orchMode, "orchestration": orchMode,
}) })
mainAssistantBuf.WriteString(chunk.Content) }
}
} else if !streamsMainAssistant(ev.AgentName) { } else if !streamsMainAssistant(ev.AgentName) {
var subDelta string
subAssistantBuf, subDelta = normalizeStreamingDelta(subAssistantBuf, chunk.Content)
if subDelta != "" {
if progress != nil { if progress != nil {
if subReplyStreamID == "" { if subReplyStreamID == "" {
subReplyStreamID = fmt.Sprintf("eino-sub-reply-%s-%d", conversationID, atomic.AddInt64(&einoSubReplyStreamSeq, 1)) subReplyStreamID = fmt.Sprintf("eino-sub-reply-%s-%d", conversationID, atomic.AddInt64(&einoSubReplyStreamSeq, 1))
@@ -500,20 +674,57 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"source": "eino", "source": "eino",
}) })
} }
progress("eino_agent_reply_stream_delta", chunk.Content, map[string]interface{}{ progress("eino_agent_reply_stream_delta", subDelta, map[string]interface{}{
"streamId": subReplyStreamID, "streamId": subReplyStreamID,
"conversationId": conversationID, "conversationId": conversationID,
}) })
} }
subAssistantBuf.WriteString(chunk.Content) }
} }
} }
if len(chunk.ToolCalls) > 0 { if len(chunk.ToolCalls) > 0 {
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...) toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
} }
} }
}
if streamsMainAssistant(ev.AgentName) { if streamsMainAssistant(ev.AgentName) {
if s := strings.TrimSpace(mainAssistantBuf.String()); s != "" { s := strings.TrimSpace(mainAssistantBuf)
if mainAssistDupTarget != "" {
executeStdoutDupMu.Lock()
pendingExecuteStdoutDup = ""
executeStdoutDupMu.Unlock()
if s != "" && s == mainAssistDupTarget {
// 与刚展示的 execute 结果完全一致:不再发助手流式事件,仍写入轨迹与最终回复字段
lastAssistant = s
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(s)
}
} else if s != "" {
if progress != nil {
progress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(),
"messageGeneratedBy": "eino:" + ev.AgentName,
"einoRole": "orchestrator",
"einoAgent": ev.AgentName,
"orchestration": orchMode,
})
progress("response_delta", s, map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(),
"einoRole": "orchestrator",
"einoAgent": ev.AgentName,
"orchestration": orchMode,
})
}
lastAssistant = s
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(s)
}
}
} else if s != "" {
lastAssistant = s lastAssistant = s
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil)) runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") { if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
@@ -521,8 +732,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
} }
} }
} }
if subAssistantBuf.Len() > 0 && progress != nil { if strings.TrimSpace(subAssistantBuf) != "" && progress != nil {
if s := strings.TrimSpace(subAssistantBuf.String()); s != "" { if s := strings.TrimSpace(subAssistantBuf); s != "" {
if subReplyStreamID != "" { if subReplyStreamID != "" {
progress("eino_agent_reply_stream_end", s, map[string]interface{}{ progress("eino_agent_reply_stream_end", s, map[string]interface{}{
"streamId": subReplyStreamID, "streamId": subReplyStreamID,
@@ -543,10 +754,17 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
} }
var lastToolChunk *schema.Message var lastToolChunk *schema.Message
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 { if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
lastToolChunk = &schema.Message{ToolCalls: merged} lastToolChunk = mergeMessageToolCalls(&schema.Message{ToolCalls: merged})
} }
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending) tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending)
// 流式路径此前只把 tool_calls 推给进度 UI,未写入 runAccumulatedMsgs;落库后 loadHistory→RepairOrphan 会删掉全部 tool 结果,表现为「续跑/下轮失忆」。
if lastToolChunk != nil && len(lastToolChunk.ToolCalls) > 0 {
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage("", lastToolChunk.ToolCalls))
}
if streamRecvErr != nil { if streamRecvErr != nil {
if isInterruptContinue(ctx) {
return takePartial(streamRecvErr)
}
if progress != nil { if progress != nil {
progress("eino_stream_error", streamRecvErr.Error(), map[string]interface{}{ progress("eino_stream_error", streamRecvErr.Error(), map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
@@ -571,7 +789,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
if mv.Role == schema.Assistant { if mv.Role == schema.Assistant {
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" { if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
progress("thinking", strings.TrimSpace(msg.ReasoningContent), map[string]interface{}{ progress("reasoning_chain", openai.DisplayReasoningContent(strings.TrimSpace(msg.ReasoningContent)), map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
"source": "eino", "source": "eino",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
@@ -582,6 +800,21 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
body := strings.TrimSpace(msg.Content) body := strings.TrimSpace(msg.Content)
if body != "" { if body != "" {
if streamsMainAssistant(ev.AgentName) { if streamsMainAssistant(ev.AgentName) {
executeStdoutDupMu.Lock()
dup := pendingExecuteStdoutDup
if dup != "" && body == dup {
pendingExecuteStdoutDup = ""
executeStdoutDupMu.Unlock()
lastAssistant = body
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(body)
}
// 非流式:与 execute 输出相同则跳过助手通道展示(msg 已在上方写入 runAccumulatedMsgs
} else {
if dup != "" {
pendingExecuteStdoutDup = ""
}
executeStdoutDupMu.Unlock()
if progress != nil { if progress != nil {
progress("response_start", "", map[string]interface{}{ progress("response_start", "", map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
@@ -603,6 +836,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") { if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(body) lastPlanExecuteExecutor = UnwrapPlanExecuteUserText(body)
} }
}
} else if progress != nil { } else if progress != nil {
progress("eino_agent_reply", body, map[string]interface{}{ progress("eino_agent_reply", body, map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
@@ -657,12 +891,19 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
break break
} }
} }
} else {
removePendingByID(toolCallID)
} }
if toolCallID != "" { if toolCallID != "" {
removePendingByID(toolCallID)
if _, loaded := toolResultSent.LoadOrStore(toolCallID, struct{}{}); loaded {
// ToolInvokeNotify 可能已推过 tool_result(如 execute 流式包装里 Fire 仅携带截断后的 stdout),
// 此处仍应用 ADK Tool 消息中的完整内容刷新去重基准,避免模型复述全文时与截断串比对失败而重复展示「助手输出」。
recordPendingExecuteStdoutDup(toolName, content, isErr)
continue
}
data["toolCallId"] = toolCallID data["toolCallId"] = toolCallID
} }
recordPendingExecuteStdoutDup(toolName, content, isErr)
recordEinoADKFilesystemToolMonitor(args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, toolName, toolCallID, runAccumulatedMsgs, content, isErr)
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data) progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
} }
} }
@@ -672,11 +913,21 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
mcpIDsMu.Unlock() mcpIDsMu.Unlock()
out := buildEinoRunResultFromAccumulated( out := buildEinoRunResultFromAccumulated(
orchMode, runAccumulatedMsgs, lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, false, orchMode, runAccumulatedMsgs, persistTraceSource(args, runAccumulatedMsgs),
lastAssistant, lastPlanExecuteExecutor, emptyHint, ids, false,
) )
return out, nil return out, nil
} }
func persistTraceSource(args *einoADKRunLoopArgs, fallback []adk.Message) []adk.Message {
if args != nil && args.ModelFacingTrace != nil {
if snap := args.ModelFacingTrace.Snapshot(); len(snap) > 0 {
return snap
}
}
return fallback
}
func einoPartialRunLastOutputHint() string { func einoPartialRunLastOutputHint() string {
return "[执行未正常结束(用户停止、超时或异常)。续跑时请基于上文已产生的工具与结果继续,勿重复已完成步骤。]\n" + return "[执行未正常结束(用户停止、超时或异常)。续跑时请基于上文已产生的工具与结果继续,勿重复已完成步骤。]\n" +
"[Run ended abnormally; continue from the trace above without repeating completed steps.]" "[Run ended abnormally; continue from the trace above without repeating completed steps.]"
@@ -685,13 +936,18 @@ func einoPartialRunLastOutputHint() string {
func buildEinoRunResultFromAccumulated( func buildEinoRunResultFromAccumulated(
orchMode string, orchMode string,
runAccumulatedMsgs []adk.Message, runAccumulatedMsgs []adk.Message,
persistMsgs []adk.Message,
lastAssistant string, lastAssistant string,
lastPlanExecuteExecutor string, lastPlanExecuteExecutor string,
emptyHint string, emptyHint string,
mcpIDs []string, mcpIDs []string,
partial bool, partial bool,
) *RunResult { ) *RunResult {
histJSON, _ := json.Marshal(runAccumulatedMsgs) traceForJSON := persistMsgs
if len(traceForJSON) == 0 {
traceForJSON = runAccumulatedMsgs
}
histJSON, _ := json.Marshal(traceForJSON)
cleaned := strings.TrimSpace(lastAssistant) cleaned := strings.TrimSpace(lastAssistant)
if orchMode == "plan_execute" { if orchMode == "plan_execute" {
if e := strings.TrimSpace(lastPlanExecuteExecutor); e != "" { if e := strings.TrimSpace(lastPlanExecuteExecutor); e != "" {
@@ -700,6 +956,11 @@ func buildEinoRunResultFromAccumulated(
cleaned = UnwrapPlanExecuteUserText(cleaned) cleaned = UnwrapPlanExecuteUserText(cleaned)
} }
} }
if cleaned == "" {
if fb := strings.TrimSpace(einoExtractFallbackAssistantFromMsgs(runAccumulatedMsgs)); fb != "" {
cleaned = fb
}
}
cleaned = dedupeRepeatedParagraphs(cleaned, 80) cleaned = dedupeRepeatedParagraphs(cleaned, 80)
cleaned = dedupeParagraphsByLineFingerprint(cleaned, 100) cleaned = dedupeParagraphsByLineFingerprint(cleaned, 100)
// 防止超长响应导致 JSON 序列化慢或 OOM(多代理拼接大量工具输出时可能触发)。 // 防止超长响应导致 JSON 序列化慢或 OOM(多代理拼接大量工具输出时可能触发)。
@@ -726,6 +987,79 @@ func buildEinoRunResultFromAccumulated(
return out return out
} }
// einoExtractFallbackAssistantFromMsgs 在「主通道未产出助手正文」时,从 Eino ADK 轨迹中回填用户可见回复。
// 典型场景:监督者仅调用 exitfinal_result 落在 Tool 消息中),或工具结果已写入历史但 lastAssistant 未更新。
//
// 优先级:最后一次 exit 工具输出 → 最后一条含 exit 的助手 tool_calls 参数中的 final_result。
func einoExtractFallbackAssistantFromMsgs(msgs []adk.Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if m == nil || m.Role != schema.Tool {
continue
}
if !strings.EqualFold(strings.TrimSpace(m.ToolName), adk.ToolInfoExit.Name) {
continue
}
content := strings.TrimSpace(m.Content)
if content == "" || strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
continue
}
return content
}
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if m == nil || m.Role != schema.Assistant {
continue
}
if s := einoExtractExitFinalFromAssistantToolCalls(m); s != "" {
return s
}
}
return ""
}
func einoExtractExitFinalFromAssistantToolCalls(msg *schema.Message) string {
if msg == nil || len(msg.ToolCalls) == 0 {
return ""
}
for i := len(msg.ToolCalls) - 1; i >= 0; i-- {
tc := msg.ToolCalls[i]
if !strings.EqualFold(strings.TrimSpace(tc.Function.Name), adk.ToolInfoExit.Name) {
continue
}
if s := einoParseExitFinalResultArguments(tc.Function.Arguments); s != "" {
return s
}
}
return ""
}
func einoParseExitFinalResultArguments(arguments string) string {
arguments = strings.TrimSpace(arguments)
if arguments == "" {
return ""
}
var wrap struct {
FinalResult json.RawMessage `json:"final_result"`
}
if err := json.Unmarshal([]byte(arguments), &wrap); err != nil || len(wrap.FinalResult) == 0 {
return ""
}
var s string
if err := json.Unmarshal(wrap.FinalResult, &s); err == nil {
return strings.TrimSpace(s)
}
var anyVal interface{}
if err := json.Unmarshal(wrap.FinalResult, &anyVal); err != nil {
return ""
}
b, err := json.Marshal(anyVal)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func buildEinoCheckpointID(orchMode string) string { func buildEinoCheckpointID(orchMode string) string {
mode := sanitizeEinoPathSegment(strings.TrimSpace(orchMode)) mode := sanitizeEinoPathSegment(strings.TrimSpace(orchMode))
if mode == "" { if mode == "" {
@@ -0,0 +1,31 @@
package multiagent
import (
"fmt"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/einomcp"
)
// newEinoExecuteMonitorCallback 在 Eino filesystem execute 结束时写入 MCP 监控库并 recorder(executionId)
// 与 CallTool 路径一致,供助手消息展示「渗透测试详情」芯片。
func newEinoExecuteMonitorCallback(ag *agent.Agent, recorder einomcp.ExecutionRecorder) func(command, stdout string, success bool, invokeErr error) {
return func(command, stdout string, success bool, invokeErr error) {
if ag == nil || recorder == nil {
return
}
var err error
if !success {
if invokeErr != nil {
err = invokeErr
} else {
err = fmt.Errorf("execute failed")
}
}
args := map[string]interface{}{"command": command}
id := ag.RecordLocalToolExecution("execute", args, stdout, err)
if id != "" {
recorder(id)
}
}
}
@@ -2,11 +2,16 @@ package multiagent
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io"
"strings"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/security" "cyberstrike-ai/internal/security"
"github.com/cloudwego/eino/adk/filesystem" "github.com/cloudwego/eino/adk/filesystem"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
) )
@@ -14,8 +19,15 @@ import (
// 官方 execute 工具默认走 ExecuteStreaming 且不设 RunInBackendGround;末尾带 & 时子进程仍与管道相连, // 官方 execute 工具默认走 ExecuteStreaming 且不设 RunInBackendGround;末尾带 & 时子进程仍与管道相连,
// streamStdout 按行读取会在无换行输出时长时间阻塞(与 MCP 工具 exec 的独立实现不同)。 // streamStdout 按行读取会在无换行输出时长时间阻塞(与 MCP 工具 exec 的独立实现不同)。
// 对「完全后台」命令自动开启 RunInBackendGround,与 local.runCmdInBackground 行为对齐。 // 对「完全后台」命令自动开启 RunInBackendGround,与 local.runCmdInBackground 行为对齐。
//
// 使用 Pipe 将内层流转发给调用方:在 inner EOF 后、关闭 Pipe 前同步调用 ToolInvokeNotify.Fire
// 保证 run loop 在模型开始下一轮输出前已记录 execute 结果(用于 UI 与「重复助手复述」去重)。
type einoStreamingShellWrap struct { type einoStreamingShellWrap struct {
inner filesystem.StreamingShell inner filesystem.StreamingShell
invokeNotify *einomcp.ToolInvokeNotifyHolder
einoAgentName string
// recordMonitor 在 execute 流结束后写入 tool_executions 并 recorder(executionId),使「渗透测试详情」与常规 MCP 一致。
recordMonitor func(command, stdout string, success bool, invokeErr error)
} }
func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) { func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
@@ -26,8 +38,73 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
return w.inner.ExecuteStreaming(ctx, nil) return w.inner.ExecuteStreaming(ctx, nil)
} }
req := *input req := *input
cmd := strings.TrimSpace(req.Command)
if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround { if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround {
req.RunInBackendGround = true req.RunInBackendGround = true
} }
return w.inner.ExecuteStreaming(ctx, &req) sr, err := w.inner.ExecuteStreaming(ctx, &req)
if err != nil {
return nil, err
}
tid := strings.TrimSpace(compose.GetToolCallID(ctx))
if sr == nil || w.invokeNotify == nil || tid == "" {
return sr, nil
}
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](32)
agentTag := strings.TrimSpace(w.einoAgentName)
go func(inner *schema.StreamReader[*filesystem.ExecuteResponse], command string) {
defer inner.Close()
var sb strings.Builder
const maxCapture = 16 * 1024
success := true
var invokeErr error
exitCode := 0
hasExitCode := false
for {
resp, rerr := inner.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
success = false
invokeErr = rerr
_ = outW.Send(nil, rerr)
break
}
if resp != nil {
if resp.ExitCode != nil {
hasExitCode = true
exitCode = *resp.ExitCode
}
if remain := maxCapture - sb.Len(); remain > 0 {
out := resp.Output
if len(out) > remain {
out = out[:remain]
}
sb.WriteString(out)
}
if outW.Send(resp, nil) {
success = false
invokeErr = fmt.Errorf("execute stream closed by consumer")
break
}
}
}
if success && hasExitCode && exitCode != 0 {
success = false
invokeErr = fmt.Errorf("execute exited with code %d", exitCode)
}
if w.recordMonitor != nil {
w.recordMonitor(command, sb.String(), success, invokeErr)
}
w.invokeNotify.Fire(tid, "execute", agentTag, success, sb.String(), invokeErr)
outW.Close()
}(sr, cmd)
return outR, nil
} }
@@ -0,0 +1,62 @@
package multiagent
import (
"testing"
"github.com/cloudwego/eino/schema"
)
func TestEinoExtractFallbackAssistantFromMsgs_exitToolMessage(t *testing.T) {
u := schema.UserMessage("hi")
tm := schema.ToolMessage("answer for user", "call-exit-1")
tm.ToolName = "exit"
if got := einoExtractFallbackAssistantFromMsgs([]*schema.Message{u, tm}); got != "answer for user" {
t.Fatalf("got %q", got)
}
}
func TestEinoExtractFallbackAssistantFromMsgs_lastExitWins(t *testing.T) {
msgs := []*schema.Message{
schema.UserMessage("hi"),
toolExitMsg("first", "c1"),
toolExitMsg("second", "c2"),
}
if got := einoExtractFallbackAssistantFromMsgs(msgs); got != "second" {
t.Fatalf("got %q", got)
}
}
func TestEinoExtractFallbackAssistantFromMsgs_fromAssistantToolCalls(t *testing.T) {
m := schema.AssistantMessage("", []schema.ToolCall{{
ID: "x",
Type: "function",
Function: schema.FunctionCall{
Name: "exit",
Arguments: `{"final_result":"from args"}`,
},
}})
if got := einoExtractFallbackAssistantFromMsgs([]*schema.Message{m}); got != "from args" {
t.Fatalf("got %q", got)
}
}
func TestEinoExtractFallbackAssistantFromMsgs_prefersToolOverEarlierAssistant(t *testing.T) {
asst := schema.AssistantMessage("", []schema.ToolCall{{
ID: "x",
Type: "function",
Function: schema.FunctionCall{
Name: "exit",
Arguments: `{"final_result":"from args"}`,
},
}})
tool := toolExitMsg("from tool", "c1")
if got := einoExtractFallbackAssistantFromMsgs([]*schema.Message{asst, tool}); got != "from tool" {
t.Fatalf("got %q", got)
}
}
func toolExitMsg(content, callID string) *schema.Message {
m := schema.ToolMessage(content, callID)
m.ToolName = "exit"
return m
}
@@ -0,0 +1,101 @@
package multiagent
import (
"encoding/json"
"errors"
"strings"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/einomcp"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
// einoADKFilesystemToolNames 与 cloudwego/eino/adk/middlewares/filesystem 默认 ToolName* 一致。
// execute 已由 eino_execute_monitor 落库,此处不包含。
var einoADKFilesystemToolNames = map[string]struct{}{
"ls": {},
"read_file": {},
"write_file": {},
"edit_file": {},
"glob": {},
"grep": {},
}
func isBuiltinEinoADKFilesystemToolName(name string) bool {
n := strings.ToLower(strings.TrimSpace(name))
_, ok := einoADKFilesystemToolNames[n]
return ok
}
func toolCallArgsFromAccumulated(msgs []adk.Message, toolCallID, expectToolName string) map[string]interface{} {
tid := strings.TrimSpace(toolCallID)
expect := strings.TrimSpace(expectToolName)
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if m == nil || m.Role != schema.Assistant || len(m.ToolCalls) == 0 {
continue
}
for j := len(m.ToolCalls) - 1; j >= 0; j-- {
tc := m.ToolCalls[j]
if tid != "" && strings.TrimSpace(tc.ID) != tid {
continue
}
fn := strings.TrimSpace(tc.Function.Name)
if expect != "" && !strings.EqualFold(fn, expect) {
continue
}
raw := strings.TrimSpace(tc.Function.Arguments)
if raw == "" {
return map[string]interface{}{}
}
var args map[string]interface{}
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return map[string]interface{}{"arguments_raw": raw}
}
if args == nil {
return map[string]interface{}{}
}
return args
}
}
return map[string]interface{}{}
}
// recordEinoADKFilesystemToolMonitor 将 Eino ADK filesystem 中间件工具结果写入 MCP 监控(与 execute / MCP 桥芯片一致)。
func recordEinoADKFilesystemToolMonitor(
ag *agent.Agent,
rec einomcp.ExecutionRecorder,
toolName string,
toolCallID string,
msgs []adk.Message,
resultText string,
isErr bool,
) {
if ag == nil || rec == nil {
return
}
name := strings.TrimSpace(toolName)
if name == "" || strings.EqualFold(name, "execute") {
return
}
if !isBuiltinEinoADKFilesystemToolName(name) {
return
}
args := toolCallArgsFromAccumulated(msgs, toolCallID, name)
storedName := "eino_fs::" + strings.ToLower(name)
var invErr error
if isErr {
t := strings.TrimSpace(resultText)
if t == "" {
invErr = errors.New("tool error")
} else {
invErr = errors.New(t)
}
}
id := ag.RecordLocalToolExecution(storedName, args, resultText, invErr)
if id != "" {
rec(id)
}
}
@@ -0,0 +1,84 @@
package multiagent
import (
"context"
"encoding/json"
"sync"
"github.com/cloudwego/eino/adk"
)
// modelFacingTraceHolder 保存「即将送入 ChatModel」的消息快照(已走 summarization / reduction / orphan 修剪等),
// 用于 last_react_input 落库,使续跑与「上下文压缩后」的模型视角一致,而非仅依赖事件流 append 的 runAccumulatedMsgs。
type modelFacingTraceHolder struct {
mu sync.Mutex
// msgs 为深拷贝后的切片,避免框架后续原地修改污染快照
msgs []adk.Message
}
func newModelFacingTraceHolder() *modelFacingTraceHolder {
return &modelFacingTraceHolder{}
}
// Snapshot 返回当前快照的再一次深拷贝(供序列化落库,避免与 holder 互斥长期持锁)。
func (h *modelFacingTraceHolder) Snapshot() []adk.Message {
if h == nil {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
return cloneADKMessagesForTrace(h.msgs)
}
func (h *modelFacingTraceHolder) storeFromState(state *adk.ChatModelAgentState) {
if h == nil || state == nil || len(state.Messages) == 0 {
return
}
cloned := cloneADKMessagesForTrace(state.Messages)
if len(cloned) == 0 {
return
}
h.mu.Lock()
h.msgs = cloned
h.mu.Unlock()
}
func cloneADKMessagesForTrace(msgs []adk.Message) []adk.Message {
if len(msgs) == 0 {
return nil
}
b, err := json.Marshal(msgs)
if err != nil {
return nil
}
var out []adk.Message
if err := json.Unmarshal(b, &out); err != nil {
return nil
}
return out
}
// modelFacingTraceMiddleware 必须在 Handlers 链中处于 **BeforeModel 最后**telemetry 之后),
// 此时 state.Messages 即为本次 LLM 调用的最终入参。
type modelFacingTraceMiddleware struct {
adk.BaseChatModelAgentMiddleware
holder *modelFacingTraceHolder
}
func newModelFacingTraceMiddleware(holder *modelFacingTraceHolder) adk.ChatModelAgentMiddleware {
if holder == nil {
return nil
}
return &modelFacingTraceMiddleware{holder: holder}
}
func (m *modelFacingTraceMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.ChatModelAgentState,
mc *adk.ModelContext,
) (context.Context, *adk.ChatModelAgentState, error) {
if m.holder != nil && state != nil {
m.holder.storeFromState(state)
}
return ctx, state, nil
}
@@ -41,6 +41,8 @@ type PlanExecuteRootArgs struct {
FilesystemMiddleware adk.ChatModelAgentMiddleware FilesystemMiddleware adk.ChatModelAgentMiddleware
// PlannerReplannerRewriteHandlers applies BeforeModelRewriteState pipeline for planner/replanner input. // PlannerReplannerRewriteHandlers applies BeforeModelRewriteState pipeline for planner/replanner input.
PlannerReplannerRewriteHandlers []adk.ChatModelAgentMiddleware PlannerReplannerRewriteHandlers []adk.ChatModelAgentMiddleware
// ModelFacingTrace 可选:由 Executor Handlers 链末尾写入,供 last_react 与 summarization 后上下文对齐。
ModelFacingTrace *modelFacingTraceHolder
} }
// NewPlanExecuteRoot 返回 plan → execute → replan 预置编排根节点(与 Deep / Supervisor 并列)。 // NewPlanExecuteRoot 返回 plan → execute → replan 预置编排根节点(与 Deep / Supervisor 并列)。
@@ -101,6 +103,11 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
if teleMw := newEinoModelInputTelemetryMiddleware(a.Logger, a.ModelName, a.ConversationID, "plan_execute_executor"); teleMw != nil { if teleMw := newEinoModelInputTelemetryMiddleware(a.Logger, a.ModelName, a.ConversationID, "plan_execute_executor"); teleMw != nil {
execHandlers = append(execHandlers, teleMw) execHandlers = append(execHandlers, teleMw)
} }
if a.ModelFacingTrace != nil {
if capMw := newModelFacingTraceMiddleware(a.ModelFacingTrace); capMw != nil {
execHandlers = append(execHandlers, capMw)
}
}
executor, err := newPlanExecuteExecutor(ctx, &planexecute.ExecutorConfig{ executor, err := newPlanExecuteExecutor(ctx, &planexecute.ExecutorConfig{
Model: a.ExecModel, Model: a.ExecModel,
ToolsConfig: a.ToolsCfg, ToolsConfig: a.ToolsCfg,
+18 -4
View File
@@ -13,6 +13,7 @@ import (
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp" "cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai" "cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/reasoning"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai" einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk"
@@ -37,6 +38,7 @@ func RunEinoSingleChatModelAgent(
history []agent.ChatMessage, history []agent.ChatMessage,
roleTools []string, roleTools []string,
progress func(eventType, message string, data interface{}), progress func(eventType, message string, data interface{}),
reasoningClient *reasoning.ClientIntent,
) (*RunResult, error) { ) (*RunResult, error) {
if appCfg == nil || ag == nil { if appCfg == nil || ag == nil {
return nil, fmt.Errorf("eino single: 配置或 Agent 为空") return nil, fmt.Errorf("eino single: 配置或 Agent 为空")
@@ -86,8 +88,10 @@ func RunEinoSingleChatModelAgent(
}) })
} }
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
einoExecMonitor := newEinoExecuteMonitorCallback(ag, recorder)
mainDefs := ag.ToolsForRole(roleTools) mainDefs := ag.ToolsForRole(roleTools)
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk) mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk, toolInvokeNotify, einoSingleAgentName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -119,6 +123,7 @@ func RunEinoSingleChatModelAgent(
Model: appCfg.OpenAI.Model, Model: appCfg.OpenAI.Model,
HTTPClient: httpClient, HTTPClient: httpClient,
} }
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
mainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg) mainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
if err != nil { if err != nil {
@@ -130,13 +135,15 @@ func RunEinoSingleChatModelAgent(
return nil, fmt.Errorf("eino single summarization: %w", err) return nil, fmt.Errorf("eino single summarization: %w", err)
} }
handlers := make([]adk.ChatModelAgentMiddleware, 0, 4) modelFacingTrace := newModelFacingTraceHolder()
handlers := make([]adk.ChatModelAgentMiddleware, 0, 8)
if len(mainOrchestratorPre) > 0 { if len(mainOrchestratorPre) > 0 {
handlers = append(handlers, mainOrchestratorPre...) handlers = append(handlers, mainOrchestratorPre...)
} }
if einoSkillMW != nil { if einoSkillMW != nil {
if einoFSTools && einoLoc != nil { if einoFSTools && einoLoc != nil {
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc) fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecMonitor)
if fsErr != nil { if fsErr != nil {
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr) return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
} }
@@ -148,6 +155,9 @@ func RunEinoSingleChatModelAgent(
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "eino_single"); teleMw != nil { if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "eino_single"); teleMw != nil {
handlers = append(handlers, teleMw) handlers = append(handlers, teleMw)
} }
if capMw := newModelFacingTraceMiddleware(modelFacingTrace); capMw != nil {
handlers = append(handlers, capMw)
}
maxIter := ma.MaxIteration maxIter := ma.MaxIteration
if maxIter <= 0 { if maxIter <= 0 {
@@ -162,7 +172,7 @@ func RunEinoSingleChatModelAgent(
Tools: mainToolsForCfg, Tools: mainToolsForCfg,
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(), UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
ToolCallMiddlewares: []compose.ToolMiddleware{ ToolCallMiddlewares: []compose.ToolMiddleware{
{Invokable: hitlToolCallMiddleware()}, hitlToolCallMiddleware(),
{Invokable: softRecoveryToolCallMiddleware()}, {Invokable: softRecoveryToolCallMiddleware()},
}, },
}, },
@@ -232,7 +242,11 @@ func RunEinoSingleChatModelAgent(
CheckpointDir: ma.EinoMiddleware.CheckpointDir, CheckpointDir: ma.EinoMiddleware.CheckpointDir,
McpIDsMu: &mcpIDsMu, McpIDsMu: &mcpIDsMu,
McpIDs: &mcpIDs, McpIDs: &mcpIDs,
FilesystemMonitorAgent: ag,
FilesystemMonitorRecord: recorder,
ToolInvokeNotify: toolInvokeNotify,
DA: chatAgent, DA: chatAgent,
ModelFacingTrace: modelFacingTrace,
EmptyResponseMessage: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " + EmptyResponseMessage: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " +
"Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)", "Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
}, baseMsgs) }, baseMsgs)
+14 -2
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp"
localbk "github.com/cloudwego/eino-ext/adk/backend/local" localbk "github.com/cloudwego/eino-ext/adk/backend/local"
"github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk"
@@ -75,12 +76,23 @@ func prepareEinoSkills(
// subAgentFilesystemMiddleware returns filesystem middleware for a sub-agent when Deep itself // subAgentFilesystemMiddleware returns filesystem middleware for a sub-agent when Deep itself
// does not set Backend (fsTools false on orchestrator) but we still want tools on subs — not used; // does not set Backend (fsTools false on orchestrator) but we still want tools on subs — not used;
// when orchestrator has Backend, builtin FS is only on outer agent; subs need explicit FS for parity. // when orchestrator has Backend, builtin FS is only on outer agent; subs need explicit FS for parity.
func subAgentFilesystemMiddleware(ctx context.Context, loc *localbk.Local) (adk.ChatModelAgentMiddleware, error) { func subAgentFilesystemMiddleware(
ctx context.Context,
loc *localbk.Local,
invokeNotify *einomcp.ToolInvokeNotifyHolder,
einoAgentName string,
recordMonitor func(command, stdout string, success bool, invokeErr error),
) (adk.ChatModelAgentMiddleware, error) {
if loc == nil { if loc == nil {
return nil, nil return nil, nil
} }
return filesystem.New(ctx, &filesystem.MiddlewareConfig{ return filesystem.New(ctx, &filesystem.MiddlewareConfig{
Backend: loc, Backend: loc,
StreamingShell: &einoStreamingShellWrap{inner: loc}, StreamingShell: &einoStreamingShellWrap{
inner: loc,
invokeNotify: invokeNotify,
einoAgentName: strings.TrimSpace(einoAgentName),
recordMonitor: recordMonitor,
},
}) })
} }
+1 -1
View File
@@ -214,7 +214,7 @@ func summarizeFinalizeWithRecentAssistantToolTrail(
selectedCount++ selectedCount++
} }
// 还原时间顺序 // 还原时间顺序。round 内为原始 *schema.Message 指针,保留 ReasoningContentDeepSeek 工具续跑所必需)。
selectedMsgs := make([]adk.Message, 0, 8) selectedMsgs := make([]adk.Message, 0, 8)
for i := len(selectedRoundsReverse) - 1; i >= 0; i-- { for i := len(selectedRoundsReverse) - 1; i >= 0; i-- {
selectedMsgs = append(selectedMsgs, selectedRoundsReverse[i].messages...) selectedMsgs = append(selectedMsgs, selectedRoundsReverse[i].messages...)
+54 -12
View File
@@ -8,6 +8,7 @@ import (
"github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/compose" "github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
) )
type hitlInterceptorKey struct{} type hitlInterceptorKey struct{}
@@ -41,7 +42,31 @@ func WithHITLToolInterceptor(ctx context.Context, fn HITLToolInterceptor) contex
return context.WithValue(ctx, hitlInterceptorKey{}, fn) return context.WithValue(ctx, hitlInterceptorKey{}, fn)
} }
func hitlToolCallMiddleware() compose.InvokableToolMiddleware { // hitlToolCallMiddleware 同时注册 Invokable 与 Streamable。
// Eino filesystem 的 execute 为流式工具(StreamableTool),仅挂 Invokable 时人机协同不会拦截,会直接执行。
func hitlToolCallMiddleware() compose.ToolMiddleware {
return compose.ToolMiddleware{
Invokable: hitlInvokableToolCallMiddleware(),
Streamable: hitlStreamableToolCallMiddleware(),
}
}
func hitlClearReturnDirectlyIfTransfer(ctx context.Context, toolName string) {
if !strings.EqualFold(strings.TrimSpace(toolName), adk.TransferToAgentToolName) {
return
}
_ = compose.ProcessState[*adk.State](ctx, func(_ context.Context, st *adk.State) error {
if st == nil {
return nil
}
st.ReturnDirectlyToolCallID = ""
st.HasReturnDirectly = false
st.ReturnDirectlyEvent = nil
return nil
})
}
func hitlInvokableToolCallMiddleware() compose.InvokableToolMiddleware {
return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint { return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) { return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
if input != nil { if input != nil {
@@ -55,17 +80,7 @@ func hitlToolCallMiddleware() compose.InvokableToolMiddleware {
// transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END, // transfer_to_agent 在 Eino 中标记为 returnDirectly:工具成功后 ReAct 子图会直接 END,
// 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具, // 并依赖真实工具内的 SendToolGenAction 触发移交。HITL 拒绝时不会执行真实工具,
// 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。 // 若仍走 returnDirectly 分支,监督者会在无 Transfer 动作的情况下结束,模型不再迭代。
if strings.EqualFold(strings.TrimSpace(input.Name), adk.TransferToAgentToolName) { hitlClearReturnDirectlyIfTransfer(ctx, input.Name)
_ = compose.ProcessState[*adk.State](ctx, func(_ context.Context, st *adk.State) error {
if st == nil {
return nil
}
st.ReturnDirectlyToolCallID = ""
st.HasReturnDirectly = false
st.ReturnDirectlyEvent = nil
return nil
})
}
return &compose.ToolOutput{Result: msg}, nil return &compose.ToolOutput{Result: msg}, nil
} }
return nil, err return nil, err
@@ -79,3 +94,30 @@ func hitlToolCallMiddleware() compose.InvokableToolMiddleware {
} }
} }
} }
func hitlStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
return func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint {
return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
if input != nil {
if fn, ok := ctx.Value(hitlInterceptorKey{}).(HITLToolInterceptor); ok && fn != nil {
edited, err := fn(ctx, input.Name, input.Arguments)
if err != nil {
if IsHumanRejectError(err) {
msg := fmt.Sprintf("[HITL Reject] Tool '%s' was rejected by human reviewer. Reason: %s\nPlease adjust parameters/plan and continue without this call.",
input.Name, strings.TrimSpace(err.Error()))
hitlClearReturnDirectlyIfTransfer(ctx, input.Name)
return &compose.StreamToolOutput{
Result: schema.StreamReaderFromArray([]string{msg}),
}, nil
}
return nil, err
}
if edited != "" {
input.Arguments = edited
}
}
}
return next(ctx, input)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
package multiagent
import "errors"
// ErrInterruptContinue 作为 context.CancelCause 使用:用户选择「中断并继续」且当前无进行中的 MCP 工具时,
// 取消当前推理/流式输出,并在同一会话任务内携带用户补充说明自动续跑下一轮(类似 Hermes 式人机回合)。
var ErrInterruptContinue = errors.New("agent interrupt: continue with user-supplied context")
+52
View File
@@ -0,0 +1,52 @@
package multiagent
import (
"encoding/json"
"fmt"
"strings"
)
// AggregatedReasoningFromTraceJSON concatenates non-empty assistant `reasoning_content`
// fields from last_react-style JSON (slice of message objects) in document order.
// Used to persist on the single assistant bubble row for audit and for GetMessages fallback
// when the full trace JSON is unavailable. For strict per-message replay, prefer last_react_input.
func AggregatedReasoningFromTraceJSON(traceJSON string) string {
traceJSON = strings.TrimSpace(traceJSON)
if traceJSON == "" {
return ""
}
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(traceJSON), &arr); err != nil {
return ""
}
var b strings.Builder
for _, m := range arr {
role, _ := m["role"].(string)
if !strings.EqualFold(strings.TrimSpace(role), "assistant") {
continue
}
rc := reasoningContentFromMessageMap(m)
if rc == "" {
continue
}
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(rc)
}
return b.String()
}
func reasoningContentFromMessageMap(m map[string]interface{}) string {
if m == nil {
return ""
}
switch v := m["reasoning_content"].(type) {
case string:
return strings.TrimSpace(v)
case nil:
return ""
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
@@ -0,0 +1,20 @@
package multiagent
import "testing"
func TestAggregatedReasoningFromTraceJSON(t *testing.T) {
const j = `[
{"role":"user","content":"hi"},
{"role":"assistant","content":"c1","reasoning_content":"r1","tool_calls":[{"id":"1","type":"function","function":{"name":"f","arguments":"{}"}}]},
{"role":"tool","tool_call_id":"1","content":"out"},
{"role":"assistant","content":"c2","reasoning_content":"r2"}
]`
got := AggregatedReasoningFromTraceJSON(j)
want := "r1\nr2"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
if AggregatedReasoningFromTraceJSON("") != "" || AggregatedReasoningFromTraceJSON("[]") != "" {
t.Fatal("empty expected")
}
}
+95 -71
View File
@@ -17,6 +17,7 @@ import (
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp" "cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai" "cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/reasoning"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai" einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk"
@@ -48,6 +49,7 @@ type toolCallPendingInfo struct {
// RunDeepAgent 使用 Eino 多代理预置编排执行一轮对话(deep / plan_execute / supervisor;流式事件通过 progress 回调输出)。 // RunDeepAgent 使用 Eino 多代理预置编排执行一轮对话(deep / plan_execute / supervisor;流式事件通过 progress 回调输出)。
// orchestrationOverride 非空时优先(如聊天/WebShell 请求体);否则用 multi_agent.orchestration(遗留 yaml);皆空则按 deep。 // orchestrationOverride 非空时优先(如聊天/WebShell 请求体);否则用 multi_agent.orchestration(遗留 yaml);皆空则按 deep。
// reasoningClient 来自 ChatRequest.reasoning;可为 nil(机器人/批量等走全局 openai.reasoning)。
func RunDeepAgent( func RunDeepAgent(
ctx context.Context, ctx context.Context,
appCfg *config.Config, appCfg *config.Config,
@@ -61,6 +63,7 @@ func RunDeepAgent(
progress func(eventType, message string, data interface{}), progress func(eventType, message string, data interface{}),
agentsMarkdownDir string, agentsMarkdownDir string,
orchestrationOverride string, orchestrationOverride string,
reasoningClient *reasoning.ClientIntent,
) (*RunResult, error) { ) (*RunResult, error) {
if appCfg == nil || ma == nil || ag == nil { if appCfg == nil || ma == nil || ag == nil {
return nil, fmt.Errorf("multiagent: 配置或 Agent 为空") return nil, fmt.Errorf("multiagent: 配置或 Agent 为空")
@@ -110,6 +113,7 @@ func RunDeepAgent(
mcpIDs = append(mcpIDs, id) mcpIDs = append(mcpIDs, id)
mcpIDsMu.Unlock() mcpIDsMu.Unlock()
} }
einoExecMonitor := newEinoExecuteMonitorCallback(ag, recorder)
// 与单代理流式一致:在 response_start / response_delta 的 data 中带当前 mcpExecutionIds,供主聊天绑定复制与展示。 // 与单代理流式一致:在 response_start / response_delta 的 data 中带当前 mcpExecutionIds,供主聊天绑定复制与展示。
snapshotMCPIDs := func() []string { snapshotMCPIDs := func() []string {
@@ -120,6 +124,7 @@ func RunDeepAgent(
return out return out
} }
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
mainDefs := ag.ToolsForRole(roleTools) mainDefs := ag.ToolsForRole(roleTools)
toolOutputChunk := func(toolName, toolCallID, chunk string) { toolOutputChunk := func(toolName, toolCallID, chunk string) {
// When toolCallId is missing, frontend ignores tool_result_delta. // When toolCallId is missing, frontend ignores tool_result_delta.
@@ -137,16 +142,6 @@ func RunDeepAgent(
}) })
} }
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk)
if err != nil {
return nil, err
}
mainToolsForCfg, mainOrchestratorPre, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger)
if err != nil {
return nil, err
}
httpClient := &http.Client{ httpClient := &http.Client{
Timeout: 30 * time.Minute, Timeout: 30 * time.Minute,
Transport: &http.Transport{ Transport: &http.Transport{
@@ -171,6 +166,7 @@ func RunDeepAgent(
Model: appCfg.OpenAI.Model, Model: appCfg.OpenAI.Model,
HTTPClient: httpClient, HTTPClient: httpClient,
} }
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
deepMaxIter := ma.MaxIteration deepMaxIter := ma.MaxIteration
if deepMaxIter <= 0 { if deepMaxIter <= 0 {
@@ -222,7 +218,7 @@ func RunDeepAgent(
} }
subDefs := ag.ToolsForRole(roleTools) subDefs := ag.ToolsForRole(roleTools)
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, toolOutputChunk) subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, toolOutputChunk, toolInvokeNotify, id)
if err != nil { if err != nil {
return nil, fmt.Errorf("子代理 %q 工具: %w", id, err) return nil, fmt.Errorf("子代理 %q 工具: %w", id, err)
} }
@@ -248,7 +244,7 @@ func RunDeepAgent(
} }
if einoSkillMW != nil { if einoSkillMW != nil {
if einoFSTools && einoLoc != nil { if einoFSTools && einoLoc != nil {
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc) subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecMonitor)
if fsErr != nil { if fsErr != nil {
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr) return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
} }
@@ -293,7 +289,7 @@ func RunDeepAgent(
Tools: subToolsForCfg, Tools: subToolsForCfg,
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(), UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
ToolCallMiddlewares: []compose.ToolMiddleware{ ToolCallMiddlewares: []compose.ToolMiddleware{
{Invokable: hitlToolCallMiddleware()}, hitlToolCallMiddleware(),
{Invokable: softRecoveryToolCallMiddleware()}, {Invokable: softRecoveryToolCallMiddleware()},
}, },
}, },
@@ -319,6 +315,8 @@ func RunDeepAgent(
return nil, fmt.Errorf("多代理主 summarization 中间件: %w", err) return nil, fmt.Errorf("多代理主 summarization 中间件: %w", err)
} }
modelFacingTrace := newModelFacingTraceHolder()
// 与 deep.Config.Name / supervisor 主代理 Name 一致。 // 与 deep.Config.Name / supervisor 主代理 Name 一致。
orchestratorName := "cyberstrike-deep" orchestratorName := "cyberstrike-deep"
orchDescription := "Coordinates specialist agents and MCP tools for authorized security testing." orchDescription := "Coordinates specialist agents and MCP tools for authorized security testing."
@@ -338,6 +336,16 @@ func RunDeepAgent(
orchDescription = d orchDescription = d
} }
} }
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk, toolInvokeNotify, orchestratorName)
if err != nil {
return nil, err
}
mainToolsForCfg, mainOrchestratorPre, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger)
if err != nil {
return nil, err
}
orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools) orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools)
if logger != nil { if logger != nil {
mainNames := collectToolNames(ctx, mainTools) mainNames := collectToolNames(ctx, mainTools)
@@ -381,7 +389,12 @@ func RunDeepAgent(
var deepShell filesystem.StreamingShell var deepShell filesystem.StreamingShell
if einoLoc != nil && einoFSTools { if einoLoc != nil && einoFSTools {
deepBackend = einoLoc deepBackend = einoLoc
deepShell = einoLoc deepShell = &einoStreamingShellWrap{
inner: einoLoc,
invokeNotify: toolInvokeNotify,
einoAgentName: orchestratorName,
recordMonitor: einoExecMonitor,
}
} }
// noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。 // noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。
@@ -400,6 +413,9 @@ func RunDeepAgent(
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "deep_orchestrator"); teleMw != nil { if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "deep_orchestrator"); teleMw != nil {
deepHandlers = append(deepHandlers, teleMw) deepHandlers = append(deepHandlers, teleMw)
} }
if capMw := newModelFacingTraceMiddleware(modelFacingTrace); capMw != nil {
deepHandlers = append(deepHandlers, capMw)
}
supHandlers := []adk.ChatModelAgentMiddleware{} supHandlers := []adk.ChatModelAgentMiddleware{}
if len(mainOrchestratorPre) > 0 { if len(mainOrchestratorPre) > 0 {
@@ -413,13 +429,16 @@ func RunDeepAgent(
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "supervisor_orchestrator"); teleMw != nil { if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "supervisor_orchestrator"); teleMw != nil {
supHandlers = append(supHandlers, teleMw) supHandlers = append(supHandlers, teleMw)
} }
if capMw := newModelFacingTraceMiddleware(modelFacingTrace); capMw != nil {
supHandlers = append(supHandlers, capMw)
}
mainToolsCfg := adk.ToolsConfig{ mainToolsCfg := adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{ ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: mainToolsForCfg, Tools: mainToolsForCfg,
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(), UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
ToolCallMiddlewares: []compose.ToolMiddleware{ ToolCallMiddlewares: []compose.ToolMiddleware{
{Invokable: hitlToolCallMiddleware()}, hitlToolCallMiddleware(),
{Invokable: softRecoveryToolCallMiddleware()}, {Invokable: softRecoveryToolCallMiddleware()},
}, },
}, },
@@ -438,7 +457,7 @@ func RunDeepAgent(
// 构建 filesystem 中间件(与 Deep sub-agent 一致) // 构建 filesystem 中间件(与 Deep sub-agent 一致)
var peFsMw adk.ChatModelAgentMiddleware var peFsMw adk.ChatModelAgentMiddleware
if einoSkillMW != nil && einoFSTools && einoLoc != nil { if einoSkillMW != nil && einoFSTools && einoLoc != nil {
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc) peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecMonitor)
if err != nil { if err != nil {
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err) return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
} }
@@ -458,6 +477,7 @@ func RunDeepAgent(
ExecPreMiddlewares: mainOrchestratorPre, ExecPreMiddlewares: mainOrchestratorPre,
SkillMiddleware: einoSkillMW, SkillMiddleware: einoSkillMW,
FilesystemMiddleware: peFsMw, FilesystemMiddleware: peFsMw,
ModelFacingTrace: modelFacingTrace,
PlannerReplannerRewriteHandlers: []adk.ChatModelAgentMiddleware{ PlannerReplannerRewriteHandlers: []adk.ChatModelAgentMiddleware{
mainSumMw, mainSumMw,
// 孤儿 tool 消息兜底:必须挂在 summarization 之后、telemetry 之前。 // 孤儿 tool 消息兜底:必须挂在 summarization 之后、telemetry 之前。
@@ -560,85 +580,89 @@ func RunDeepAgent(
CheckpointDir: ma.EinoMiddleware.CheckpointDir, CheckpointDir: ma.EinoMiddleware.CheckpointDir,
McpIDsMu: &mcpIDsMu, McpIDsMu: &mcpIDsMu,
McpIDs: &mcpIDs, McpIDs: &mcpIDs,
FilesystemMonitorAgent: ag,
FilesystemMonitorRecord: recorder,
ToolInvokeNotify: toolInvokeNotify,
DA: da, DA: da,
ModelFacingTrace: modelFacingTrace,
EmptyResponseMessage: "(Eino multi-agent orchestration completed but no assistant text was captured. Check process details or logs.) " + EmptyResponseMessage: "(Eino multi-agent orchestration completed but no assistant text was captured. Check process details or logs.) " +
"(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)", "(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
}, baseMsgs) }, baseMsgs)
} }
func chatToolCallsToSchema(tcs []agent.ToolCall) []schema.ToolCall {
if len(tcs) == 0 {
return nil
}
out := make([]schema.ToolCall, 0, len(tcs))
for _, tc := range tcs {
if strings.TrimSpace(tc.ID) == "" {
continue
}
argsStr := ""
if tc.Function.Arguments != nil {
b, err := json.Marshal(tc.Function.Arguments)
if err == nil {
argsStr = string(b)
}
}
typ := tc.Type
if typ == "" {
typ = "function"
}
out = append(out, schema.ToolCall{
ID: tc.ID,
Type: typ,
Function: schema.FunctionCall{
Name: tc.Function.Name,
Arguments: argsStr,
},
})
}
return out
}
// historyToMessages 将轨迹恢复的 ChatMessage 转为 Eino ADK 消息:**不裁剪条数、不按 token 预算截断**,
// 并保留 user / assistant(含仅 tool_calls/ tool,与库中 last_react 轨迹一致。
func historyToMessages(history []agent.ChatMessage, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) []adk.Message { func historyToMessages(history []agent.ChatMessage, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig) []adk.Message {
_ = appCfg
_ = mwCfg
if len(history) == 0 { if len(history) == 0 {
return nil return nil
} }
// Keep a bounded tail first; then enforce a token budget. raw := make([]adk.Message, 0, len(history))
const maxHistoryMessages = 200 for _, h := range history {
start := 0 role := strings.ToLower(strings.TrimSpace(h.Role))
if len(history) > maxHistoryMessages { switch role {
start = len(history) - maxHistoryMessages
}
raw := make([]adk.Message, 0, len(history[start:]))
for _, h := range history[start:] {
switch h.Role {
case "user": case "user":
if strings.TrimSpace(h.Content) != "" { if strings.TrimSpace(h.Content) != "" {
raw = append(raw, schema.UserMessage(h.Content)) raw = append(raw, schema.UserMessage(h.Content))
} }
case "assistant": case "assistant":
if strings.TrimSpace(h.Content) == "" && len(h.ToolCalls) > 0 { toolSchema := chatToolCallsToSchema(h.ToolCalls)
hasRC := strings.TrimSpace(h.ReasoningContent) != ""
if len(toolSchema) > 0 || strings.TrimSpace(h.Content) != "" || hasRC {
am := schema.AssistantMessage(h.Content, toolSchema)
if hasRC {
am.ReasoningContent = strings.TrimSpace(h.ReasoningContent)
}
raw = append(raw, am)
}
case "tool":
if strings.TrimSpace(h.ToolCallID) == "" && strings.TrimSpace(h.Content) == "" {
continue continue
} }
if strings.TrimSpace(h.Content) != "" { var opts []schema.ToolMessageOption
raw = append(raw, schema.AssistantMessage(h.Content, nil)) if tn := strings.TrimSpace(h.ToolName); tn != "" {
opts = append(opts, schema.WithToolName(tn))
} }
raw = append(raw, schema.ToolMessage(h.Content, h.ToolCallID, opts...))
default: default:
continue continue
} }
} }
if len(raw) == 0 {
return raw return raw
} }
maxTotal := 120000
modelName := "gpt-4o"
if appCfg != nil {
if appCfg.OpenAI.MaxTotalTokens > 0 {
maxTotal = appCfg.OpenAI.MaxTotalTokens
}
if m := strings.TrimSpace(appCfg.OpenAI.Model); m != "" {
modelName = m
}
}
ratio := 0.35
if mwCfg != nil {
ratio = mwCfg.HistoryInputBudgetRatioEffective()
}
budget := int(float64(maxTotal) * ratio)
if budget < 4096 {
budget = 4096
}
tc := agent.NewTikTokenCounter()
outRev := make([]adk.Message, 0, len(raw))
used := 0
for i := len(raw) - 1; i >= 0; i-- {
msg := raw[i]
n, err := tc.Count(modelName, string(msg.Role)+"\n"+msg.Content)
if err != nil {
n = (len(msg.Content) + 3) / 4
}
if n <= 0 {
n = 1
}
if used+n > budget {
break
}
used += n
outRev = append(outRev, msg)
}
out := make([]adk.Message, 0, len(outRev))
for i := len(outRev) - 1; i >= 0; i-- {
out = append(out, outRev[i])
}
return out
}
// mergeStreamingToolCallFragments 将流式多帧的 ToolCall 按 index 合并 arguments(与 schema.concatToolCalls 行为一致)。 // mergeStreamingToolCallFragments 将流式多帧的 ToolCall 按 index 合并 arguments(与 schema.concatToolCalls 行为一致)。
func mergeStreamingToolCallFragments(fragments []schema.ToolCall) []schema.ToolCall { func mergeStreamingToolCallFragments(fragments []schema.ToolCall) []schema.ToolCall {
@@ -0,0 +1,22 @@
package multiagent
import (
"testing"
"cyberstrike-ai/internal/agent"
)
func TestHistoryToMessagesPreservesReasoningContent(t *testing.T) {
h := []agent.ChatMessage{
{Role: "user", Content: "u"},
{Role: "assistant", Content: "c", ReasoningContent: "r1", ToolCalls: []agent.ToolCall{{ID: "t1", Type: "function", Function: agent.FunctionCall{Name: "f", Arguments: map[string]interface{}{}}}}},
}
msgs := historyToMessages(h, nil, nil)
if len(msgs) != 2 {
t.Fatalf("len=%d", len(msgs))
}
am := msgs[1]
if am.ReasoningContent != "r1" || am.Content != "c" {
t.Fatalf("got reasoning=%q content=%q", am.ReasoningContent, am.Content)
}
}
+128 -5
View File
@@ -9,6 +9,9 @@ package openai
// Stream: Claude SSE (event: content_block_delta / message_delta) → OpenAI SSE 格式 // Stream: Claude SSE (event: content_block_delta / message_delta) → OpenAI SSE 格式
// Auth: Bearer → x-api-key // Auth: Bearer → x-api-key
// Tools: OpenAI tools[] → Claude tools[] (input_schema) // Tools: OpenAI tools[] → Claude tools[] (input_schema)
//
// Extended thinking: 顶层 `thinking` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为
// `reasoning_content`(可读前缀 + 内部 JSON 尾缀以保留 signature,供多轮工具续跑;UI 用 openai.DisplayReasoningContent 剥离)。
import ( import (
"bufio" "bufio"
@@ -38,6 +41,7 @@ type claudeRequest struct {
Messages []claudeMessage `json:"messages"` Messages []claudeMessage `json:"messages"`
Tools []claudeTool `json:"tools,omitempty"` Tools []claudeTool `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"` Stream bool `json:"stream,omitempty"`
Thinking json.RawMessage `json:"thinking,omitempty"`
} }
type claudeMessage struct { type claudeMessage struct {
@@ -76,6 +80,10 @@ type claudeContentBlock struct {
// text block // text block
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
// thinking block (extended thinking)
Thinking string `json:"thinking,omitempty"`
Signature string `json:"signature,omitempty"`
// tool_use block (assistant 返回) // tool_use block (assistant 返回)
ID string `json:"id,omitempty"` ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@@ -176,7 +184,13 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) {
// tool_calls (assistant 消息中包含工具调用) // tool_calls (assistant 消息中包含工具调用)
if role == "assistant" { if role == "assistant" {
rc, _ := mm["reasoning_content"].(string)
_, thinkingReplay := parseClaudeReasoningAssistantBlocks(rc)
var blocks []claudeContentBlock var blocks []claudeContentBlock
for _, tb := range thinkingReplay {
blocks = append(blocks, tb)
}
if content != "" { if content != "" {
blocks = append(blocks, claudeContentBlock{Type: "text", Text: content}) blocks = append(blocks, claudeContentBlock{Type: "text", Text: content})
} }
@@ -290,6 +304,13 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) {
} }
} }
// Extended thinking (Anthropic top-level); merged from Eino ExtraFields / admin extras.
if th, ok := oai["thinking"]; ok && th != nil {
if raw, err := json.Marshal(th); err == nil && len(raw) > 0 && string(raw) != "null" {
req.Thinking = json.RawMessage(raw)
}
}
return req, nil return req, nil
} }
@@ -318,9 +339,12 @@ func claudeToOpenAIResponseJSON(claudeBody []byte) ([]byte, error) {
var textContent string var textContent string
var toolCalls []interface{} var toolCalls []interface{}
var thinkingBlocks []claudeContentBlock
for _, block := range cr.Content { for _, block := range cr.Content {
switch block.Type { switch block.Type {
case "thinking":
thinkingBlocks = append(thinkingBlocks, block)
case "text": case "text":
textContent += block.Text textContent += block.Text
case "tool_use": case "tool_use":
@@ -344,6 +368,18 @@ func claudeToOpenAIResponseJSON(claudeBody []byte) ([]byte, error) {
if len(toolCalls) > 0 { if len(toolCalls) > 0 {
message["tool_calls"] = toolCalls message["tool_calls"] = toolCalls
} }
if len(thinkingBlocks) > 0 {
var parts []string
for _, tb := range thinkingBlocks {
if strings.TrimSpace(tb.Thinking) != "" {
parts = append(parts, tb.Thinking)
}
}
rc := appendClaudeReasoningRoundTrip(strings.Join(parts, "\n\n"), thinkingBlocks)
if rc != "" {
message["reasoning_content"] = rc
}
}
choice := map[string]interface{}{ choice := map[string]interface{}{
"index": 0, "index": 0,
@@ -499,6 +535,7 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
reader := bufio.NewReader(resp.Body) reader := bufio.NewReader(resp.Body)
var full strings.Builder var full strings.Builder
fullText := ""
for { for {
line, readErr := reader.ReadString('\n') line, readErr := reader.ReadString('\n')
@@ -531,9 +568,14 @@ func (c *Client) claudeChatCompletionStream(ctx context.Context, payload interfa
if deltaType == "text_delta" { if deltaType == "text_delta" {
text, _ := delta["text"].(string) text, _ := delta["text"].(string)
if text != "" { if text != "" {
full.WriteString(text) var textOut string
fullText, textOut = normalizeStreamingDelta(fullText, text)
if textOut == "" {
continue
}
full.WriteString(textOut)
if onDelta != nil { if onDelta != nil {
if err := onDelta(text); err != nil { if err := onDelta(textOut); err != nil {
return full.String(), err return full.String(), err
} }
} }
@@ -603,6 +645,7 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
reader := bufio.NewReader(resp.Body) reader := bufio.NewReader(resp.Body)
var full strings.Builder var full strings.Builder
fullText := ""
finishReason := "" finishReason := ""
// 追踪当前正在构建的 content blocks // 追踪当前正在构建的 content blocks
@@ -665,9 +708,14 @@ func (c *Client) claudeChatCompletionStreamWithToolCalls(
if deltaType == "text_delta" { if deltaType == "text_delta" {
text, _ := delta["text"].(string) text, _ := delta["text"].(string)
if text != "" { if text != "" {
full.WriteString(text) var textOut string
fullText, textOut = normalizeStreamingDelta(fullText, text)
if textOut == "" {
continue
}
full.WriteString(textOut)
if onContentDelta != nil { if onContentDelta != nil {
if err := onContentDelta(text); err != nil { if err := onContentDelta(textOut); err != nil {
return full.String(), nil, finishReason, err return full.String(), nil, finishReason, err
} }
} }
@@ -889,8 +937,16 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
reader := bufio.NewReader(resp.Body) reader := bufio.NewReader(resp.Body)
blockToToolIndex := make(map[int]int) blockToToolIndex := make(map[int]int)
blockIndexToType := make(map[int]string)
nextToolIndex := 0 nextToolIndex := 0
type thinkingAcc struct {
text strings.Builder
sig strings.Builder
}
thinkingByIndex := make(map[int]*thinkingAcc)
var finishedThinking []claudeContentBlock
for { for {
line, readErr := reader.ReadString('\n') line, readErr := reader.ReadString('\n')
if readErr != nil { if readErr != nil {
@@ -935,6 +991,11 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
blockIdx := int(blockIdxFlt) blockIdx := int(blockIdxFlt)
cb, _ := event["content_block"].(map[string]interface{}) cb, _ := event["content_block"].(map[string]interface{})
bt, _ := cb["type"].(string) bt, _ := cb["type"].(string)
blockIndexToType[blockIdx] = bt
if bt == "thinking" {
thinkingByIndex[blockIdx] = &thinkingAcc{}
}
if bt == "tool_use" { if bt == "tool_use" {
id, _ := cb["id"].(string) id, _ := cb["id"].(string)
@@ -974,7 +1035,35 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
delta, _ := event["delta"].(map[string]interface{}) delta, _ := event["delta"].(map[string]interface{})
dt, _ := delta["type"].(string) dt, _ := delta["type"].(string)
if dt == "text_delta" { if dt == "thinking_delta" {
tPart, _ := delta["thinking"].(string)
if tPart != "" {
if acc := thinkingByIndex[blockIdx]; acc != nil {
acc.text.WriteString(tPart)
}
oaiChunk := map[string]interface{}{
"choices": []map[string]interface{}{
{
"delta": map[string]interface{}{
"reasoning_content": tPart,
},
},
},
}
b, _ := json.Marshal(oaiChunk)
if !writeLine("data: " + string(b) + "\n\n") {
pw.Close()
return
}
}
} else if dt == "signature_delta" {
sigPart, _ := delta["signature"].(string)
if sigPart != "" {
if acc := thinkingByIndex[blockIdx]; acc != nil {
acc.sig.WriteString(sigPart)
}
}
} else if dt == "text_delta" {
text, _ := delta["text"].(string) text, _ := delta["text"].(string)
oaiChunk := map[string]interface{}{ oaiChunk := map[string]interface{}{
"choices": []map[string]interface{}{ "choices": []map[string]interface{}{
@@ -1019,6 +1108,21 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
} }
} }
case "content_block_stop":
blockIdxFlt, _ := event["index"].(float64)
blockIdx := int(blockIdxFlt)
bt := blockIndexToType[blockIdx]
if bt == "thinking" {
if acc := thinkingByIndex[blockIdx]; acc != nil {
finishedThinking = append(finishedThinking, claudeContentBlock{
Type: "thinking",
Thinking: acc.text.String(),
Signature: acc.sig.String(),
})
delete(thinkingByIndex, blockIdx)
}
}
case "message_delta": case "message_delta":
d, _ := event["delta"].(map[string]interface{}) d, _ := event["delta"].(map[string]interface{})
if sr, ok := d["stop_reason"].(string); ok { if sr, ok := d["stop_reason"].(string); ok {
@@ -1039,6 +1143,25 @@ func (rt *claudeRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
} }
case "message_stop": case "message_stop":
if len(finishedThinking) > 0 {
suffix := appendClaudeReasoningRoundTrip("", finishedThinking)
if strings.TrimSpace(suffix) != "" {
oaiChunk := map[string]interface{}{
"choices": []map[string]interface{}{
{
"delta": map[string]interface{}{
"reasoning_content": suffix,
},
},
},
}
b, _ := json.Marshal(oaiChunk)
if !writeLine("data: " + string(b) + "\n\n") {
pw.Close()
return
}
}
}
writeLine("data: [DONE]\n\n") writeLine("data: [DONE]\n\n")
pw.Close() pw.Close()
return return
@@ -0,0 +1,81 @@
package openai
import (
"encoding/json"
"strings"
)
// claudeReasoningRoundTripSep separates human-readable reasoning from a JSON payload of
// Anthropic thinking blocks (with signatures) for multi-turn extended thinking + tools.
// Not shown in UI (see DisplayReasoningContent).
const claudeReasoningRoundTripSep = "\n---CSAI_CLAUDE_THINKING_BLOCKS---\n"
// DisplayReasoningContent returns reasoning text suitable for the UI (strips internal
// Claude round-trip JSON suffix). Safe for DeepSeek/plain reasoning strings (no-op).
func DisplayReasoningContent(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
i := strings.LastIndex(s, claudeReasoningRoundTripSep)
if i < 0 {
return s
}
return strings.TrimSpace(s[:i])
}
func appendClaudeReasoningRoundTrip(display string, blocks []claudeContentBlock) string {
var payload []map[string]string
for _, b := range blocks {
if b.Type != "thinking" {
continue
}
payload = append(payload, map[string]string{
"type": b.Type,
"thinking": b.Thinking,
"signature": b.Signature,
})
}
if len(payload) == 0 {
return strings.TrimSpace(display)
}
js, err := json.Marshal(payload)
if err != nil {
return strings.TrimSpace(display)
}
d := strings.TrimSpace(display)
if d == "" {
return claudeReasoningRoundTripSep + string(js)
}
return d + claudeReasoningRoundTripSep + string(js)
}
// parseClaudeReasoningAssistantBlocks extracts Anthropic thinking blocks from an OpenAI-style
// reasoning_content string. When no suffix is present, blocks is nil (caller must not invent signatures).
func parseClaudeReasoningAssistantBlocks(reasoningContent string) (display string, blocks []claudeContentBlock) {
reasoningContent = strings.TrimSpace(reasoningContent)
if reasoningContent == "" {
return "", nil
}
idx := strings.LastIndex(reasoningContent, claudeReasoningRoundTripSep)
if idx < 0 {
return reasoningContent, nil
}
display = strings.TrimSpace(reasoningContent[:idx])
jsonPart := strings.TrimSpace(reasoningContent[idx+len(claudeReasoningRoundTripSep):])
var arr []struct {
Type string `json:"type"`
Thinking string `json:"thinking"`
Signature string `json:"signature"`
}
if err := json.Unmarshal([]byte(jsonPart), &arr); err != nil {
return reasoningContent, nil
}
for _, x := range arr {
if x.Type != "thinking" {
continue
}
blocks = append(blocks, claudeContentBlock{Type: "thinking", Thinking: x.Thinking, Signature: x.Signature})
}
return display, blocks
}
@@ -0,0 +1,102 @@
package openai
import (
"encoding/json"
"strings"
"testing"
)
func TestDisplayReasoningContent(t *testing.T) {
raw := "hello" + claudeReasoningRoundTripSep + `[{"type":"thinking","thinking":"x","signature":"sig"}]`
if d := DisplayReasoningContent(raw); d != "hello" {
t.Fatalf("got %q", d)
}
if DisplayReasoningContent("plain") != "plain" {
t.Fatal()
}
}
func TestAppendParseClaudeReasoningRoundTrip(t *testing.T) {
blocks := []claudeContentBlock{
{Type: "thinking", Thinking: "a", Signature: "s1"},
{Type: "thinking", Thinking: "b", Signature: "s2"},
}
s := appendClaudeReasoningRoundTrip("sum", blocks)
if !strings.Contains(s, claudeReasoningRoundTripSep) {
t.Fatal("missing sep")
}
display, back := parseClaudeReasoningAssistantBlocks(s)
if display != "sum" || len(back) != 2 {
t.Fatalf("display=%q len=%d", display, len(back))
}
if back[0].Signature != "s1" || back[1].Thinking != "b" {
t.Fatalf("%+v", back)
}
}
func TestConvertOpenAIToClaude_AssistantReasoningReplay(t *testing.T) {
rc := appendClaudeReasoningRoundTrip("vis", []claudeContentBlock{
{Type: "thinking", Thinking: "t1", Signature: "sig1"},
})
payload := map[string]interface{}{
"model": "claude-3-5-sonnet-latest",
"messages": []interface{}{
map[string]interface{}{
"role": "assistant",
"content": "out",
"reasoning_content": rc,
},
},
}
req, err := convertOpenAIToClaude(payload)
if err != nil {
t.Fatal(err)
}
if len(req.Messages) != 1 {
t.Fatalf("messages=%d", len(req.Messages))
}
blocks := req.Messages[0].Content.Blocks
if len(blocks) < 2 {
t.Fatalf("blocks=%d", len(blocks))
}
if blocks[0].Type != "thinking" || blocks[0].Signature != "sig1" {
t.Fatalf("first block %+v", blocks[0])
}
foundText := false
for _, b := range blocks {
if b.Type == "text" && b.Text == "out" {
foundText = true
}
}
if !foundText {
t.Fatalf("blocks=%+v", blocks)
}
}
func TestClaudeToOpenAIResponseJSON_Thinking(t *testing.T) {
claudeBody := []byte(`{
"id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn",
"content":[
{"type":"thinking","thinking":"step","signature":"sigx"},
{"type":"text","text":"hi"}
]
}`)
oai, err := claudeToOpenAIResponseJSON(claudeBody)
if err != nil {
t.Fatal(err)
}
var wrap map[string]interface{}
if err := json.Unmarshal(oai, &wrap); err != nil {
t.Fatal(err)
}
choices := wrap["choices"].([]interface{})
ch0 := choices[0].(map[string]interface{})
msg := ch0["message"].(map[string]interface{})
rc, _ := msg["reasoning_content"].(string)
if !strings.Contains(rc, "step") || !strings.Contains(rc, claudeReasoningRoundTripSep) {
t.Fatalf("reasoning_content=%q", rc)
}
if msg["content"] != "hi" {
t.Fatal()
}
}
@@ -0,0 +1,56 @@
package openai
import "testing"
func TestNormalizeStreamingDelta_RepeatedCharBoundary(t *testing.T) {
// 流式在重复数字边界分片:不得把 "43" 的首字符与 "194" 尾字符误合并。
cur, d := normalizeStreamingDelta("https://x:194", "43")
if want := "https://x:19443"; cur != want {
t.Fatalf("next: want %q got %q", want, cur)
}
if d != "43" {
t.Fatalf("delta: want %q got %q", "43", d)
}
}
func TestNormalizeStreamingDelta_CumulativePrefix(t *testing.T) {
cur, d := normalizeStreamingDelta("今天", "今天天气")
if cur != "今天天气" || d != "天气" {
t.Fatalf("got cur=%q d=%q", cur, d)
}
}
func TestNormalizeStreamingDelta_FullRetransmit(t *testing.T) {
cur, d := normalizeStreamingDelta("今天", "今天")
if d != "" || cur != "今天" {
t.Fatalf("got cur=%q d=%q", cur, d)
}
}
func TestNormalizeStreamingDelta_SingleRuneRepeated(t *testing.T) {
cur, d := normalizeStreamingDelta("呀", "呀")
if want := "呀呀"; cur != want {
t.Fatalf("next: want %q got %q", want, cur)
}
if d != "呀" {
t.Fatalf("delta: want %q got %q", "呀", d)
}
cur, d = normalizeStreamingDelta("4", "4")
if want := "44"; cur != want {
t.Fatalf("next: want %q got %q", want, cur)
}
if d != "4" {
t.Fatalf("delta: want %q got %q", "4", d)
}
}
func TestNormalizeStreamingDelta_CumulativeExtendsNumber(t *testing.T) {
// 已缓冲 "194" 后收到累计串 "19443"(注意 "1943" 并非 "19443" 的前缀,不能靠误写的中间态测 HasPrefix)。
cur, d := normalizeStreamingDelta("194", "19443")
if want := "19443"; cur != want {
t.Fatalf("next: want %q got %q", want, cur)
}
if d != "43" {
t.Fatalf("delta: want %q got %q", "43", d)
}
}
+42 -4
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"unicode/utf8"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
@@ -33,6 +34,32 @@ func (e *APIError) Error() string {
return fmt.Sprintf("openai api error: status=%d body=%s", e.StatusCode, e.Body) return fmt.Sprintf("openai api error: status=%d body=%s", e.StatusCode, e.Body)
} }
// normalizeStreamingDelta 将可能是“累计片段/重发片段”的内容归一化为“纯增量”。
// 部分兼容网关会返回累计 content;若直接 append 会出现重复文本。
//
// 注意:
// - 不做「任意后缀与前缀重叠」合并;流式可能在重复字符边界分片("194"+"43"→"19443")。
// - HasPrefix 仅在 incoming 严格长于 current 时视为累计全文,否则会把分片产生的第二个相同
// 单字/单码点(叠字、44、22 等)误判为「整段重复」而吞字。
// - incoming==current 仅当 current 长度 >1 个码点时才视为整包重发;单码点重复必须走拼接。
// - 不再使用「current 以 incoming 结尾则丢弃」:否则 "1943"+"43" 会误吞增量(19443 显示成 1943)。
// 若网关重复发送尾部片段,应重复送完整累计串,由 HasPrefix 分支去重。
func normalizeStreamingDelta(current, incoming string) (next, delta string) {
if incoming == "" {
return current, ""
}
if current == "" {
return incoming, incoming
}
if strings.HasPrefix(incoming, current) && len(incoming) > len(current) {
return incoming, incoming[len(current):]
}
if incoming == current && utf8.RuneCountInString(current) > 1 {
return current, ""
}
return current + incoming, incoming
}
// NewClient 创建一个新的OpenAI客户端。 // NewClient 创建一个新的OpenAI客户端。
func NewClient(cfg *config.OpenAIConfig, httpClient *http.Client, logger *zap.Logger) *Client { func NewClient(cfg *config.OpenAIConfig, httpClient *http.Client, logger *zap.Logger) *Client {
if httpClient == nil { if httpClient == nil {
@@ -219,6 +246,7 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
reader := bufio.NewReader(resp.Body) reader := bufio.NewReader(resp.Body)
var full strings.Builder var full strings.Builder
fullText := ""
// 典型 SSE 结构: // 典型 SSE 结构:
// data: {...}\n\n // data: {...}\n\n
@@ -263,9 +291,14 @@ func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{},
continue continue
} }
full.WriteString(delta) var deltaOut string
fullText, deltaOut = normalizeStreamingDelta(fullText, delta)
if deltaOut == "" {
continue
}
full.WriteString(deltaOut)
if onDelta != nil { if onDelta != nil {
if err := onDelta(delta); err != nil { if err := onDelta(deltaOut); err != nil {
return full.String(), err return full.String(), err
} }
} }
@@ -380,6 +413,7 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
reader := bufio.NewReader(resp.Body) reader := bufio.NewReader(resp.Body)
var full strings.Builder var full strings.Builder
fullText := ""
finishReason := "" finishReason := ""
for { for {
@@ -426,13 +460,17 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
content = delta.Text content = delta.Text
} }
if content != "" { if content != "" {
full.WriteString(content) var contentOut string
fullText, contentOut = normalizeStreamingDelta(fullText, content)
if contentOut != "" {
full.WriteString(contentOut)
if onContentDelta != nil { if onContentDelta != nil {
if err := onContentDelta(content); err != nil { if err := onContentDelta(contentOut); err != nil {
return full.String(), nil, finishReason, err return full.String(), nil, finishReason, err
} }
} }
} }
}
if len(delta.ToolCalls) > 0 { if len(delta.ToolCalls) > 0 {
for _, tc := range delta.ToolCalls { for _, tc := range delta.ToolCalls {
+250
View File
@@ -0,0 +1,250 @@
// Package reasoning maps user/config intent to CloudWeGo Eino OpenAI ChatModel fields
// (ReasoningEffort, ExtraFields such as thinking / reasoning_effort / output_config).
package reasoning
import (
"strings"
"cyberstrike-ai/internal/config"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
)
// ClientIntent is optional per-request override from ChatRequest.reasoning.
type ClientIntent struct {
Mode string
Effort string
}
type wireProfile int
const (
wireNone wireProfile = iota
wireClaude
wireDeepseek
wireOpenAI
wireOutputConfig
)
// ApplyToEinoChatModelConfig merges reasoning-related options into cfg.
// Precondition: cfg already has APIKey, BaseURL, Model, HTTPClient set.
func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.OpenAIConfig, client *ClientIntent) {
if cfg == nil || oa == nil {
return
}
sr := &oa.Reasoning
allowClient := sr.AllowClientReasoningEffective()
mode := effectiveMode(sr, client, allowClient)
// Claude (Anthropic): merge admin extras first; optional extended thinking maps to top-level `thinking`
// (see internal/openai convertOpenAIToClaude). DeepSeek/OpenAI-style fields are not sent.
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") ||
strings.EqualFold(strings.TrimSpace(oa.Provider), "anthropic") {
if len(sr.ExtraRequestFields) > 0 {
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
for k, v := range sr.ExtraRequestFields {
cfg.ExtraFields[k] = v
}
}
if mode == "off" {
return
}
applyClaudeExtendedThinking(cfg, mode, effectiveEffort(sr, client, allowClient), oa.Model)
return
}
if mode == "off" {
return
}
effort := effectiveEffort(sr, client, allowClient)
prof := resolveWireProfile(oa, sr)
// Admin-defined extra root fields (merged first; automatic keys may follow).
if len(sr.ExtraRequestFields) > 0 {
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
for k, v := range sr.ExtraRequestFields {
cfg.ExtraFields[k] = v
}
}
switch prof {
case wireClaude, wireNone:
return
case wireDeepseek:
applyDeepseek(cfg, mode, effort)
case wireOutputConfig:
applyOutputConfigEffort(cfg, mode, effort)
default: // wireOpenAI
applyOpenAICompat(cfg, mode, effort)
}
}
// applyClaudeExtendedThinking sets Anthropic Messages API `thinking` when absent from ExtraRequestFields.
// Uses adaptive + summarized display by default (per Anthropic guidance for Claude 4.x); Sonnet 3.7 uses enabled+budget.
func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort, model string) {
if cfg == nil || mode == "off" {
return
}
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
if _, exists := cfg.ExtraFields["thinking"]; exists {
return
}
m := strings.ToLower(strings.TrimSpace(model))
thinking := map[string]any{
"type": "adaptive",
"display": "summarized",
}
// Sonnet 3.7: manual extended thinking is the documented path.
if strings.Contains(m, "claude-3-7-sonnet") || strings.Contains(m, "3-7-sonnet") || strings.Contains(m, "sonnet-3.7") {
thinking = map[string]any{
"type": "enabled",
"budget_tokens": 10000,
"display": "summarized",
}
}
// Opus 4.7+: manual enabled+budget rejected — keep adaptive only.
if strings.Contains(m, "opus-4-7") || strings.Contains(m, "opus-4.7") {
thinking = map[string]any{
"type": "adaptive",
"display": "summarized",
}
}
_ = effort // reserved: map to Anthropic effort / output_config when API stabilizes in one place
cfg.ExtraFields["thinking"] = thinking
}
func effectiveMode(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string {
server := strings.ToLower(strings.TrimSpace(sr.ModeEffective()))
if server == "" || server == "default" {
server = "auto"
}
if !allowClient || client == nil {
return server
}
cm := strings.ToLower(strings.TrimSpace(client.Mode))
if cm == "" || cm == "default" {
return server
}
return cm
}
func effectiveEffort(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string {
se := normalizeEffort(sr.Effort)
if !allowClient || client == nil {
return se
}
ce := normalizeEffort(client.Effort)
if ce != "" {
return ce
}
return se
}
func normalizeEffort(s string) string {
e := strings.ToLower(strings.TrimSpace(s))
switch e {
case "low", "medium", "high", "max":
return e
default:
return ""
}
}
func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfig) wireProfile {
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") {
return wireClaude
}
p := strings.ToLower(strings.TrimSpace(sr.ProfileEffective()))
switch p {
case "output_config", "output_config_effort":
return wireOutputConfig
case "openai", "openai_compat":
return wireOpenAI
case "deepseek", "deepseek_compat":
return wireDeepseek
case "auto", "":
bu := strings.ToLower(oa.BaseURL)
mo := strings.ToLower(oa.Model)
if strings.Contains(bu, "deepseek") || strings.Contains(mo, "deepseek") {
return wireDeepseek
}
return wireOpenAI
default:
return wireOpenAI
}
}
func applyDeepseek(cfg *einoopenai.ChatModelConfig, mode, effort string) {
// auto: enable thinking for DeepSeek line; on: same; auto without effort still opens thinking.
if mode == "off" {
return
}
if mode == "auto" || mode == "on" {
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
cfg.ExtraFields["thinking"] = map[string]any{"type": "enabled"}
}
if effort != "" {
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
cfg.ExtraFields["reasoning_effort"] = effortStringForAPI(effort)
}
}
func applyOpenAICompat(cfg *einoopenai.ChatModelConfig, mode, effort string) {
if mode == "auto" && effort == "" {
return
}
e := effort
if mode == "on" && e == "" {
e = "medium"
}
if e == "" {
return
}
if e == "max" {
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
cfg.ExtraFields["reasoning_effort"] = "max"
return
}
switch e {
case "low":
cfg.ReasoningEffort = einoopenai.ReasoningEffortLevelLow
case "medium":
cfg.ReasoningEffort = einoopenai.ReasoningEffortLevelMedium
case "high":
cfg.ReasoningEffort = einoopenai.ReasoningEffortLevelHigh
}
}
func applyOutputConfigEffort(cfg *einoopenai.ChatModelConfig, mode, effort string) {
if mode == "auto" && effort == "" {
return
}
e := effort
if mode == "on" && e == "" {
e = "high"
}
if e == "" {
return
}
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
cfg.ExtraFields["output_config"] = map[string]any{"effort": effortStringForAPI(e)}
}
func effortStringForAPI(e string) string {
// Gateways expect lowercase strings; "max" kept as max.
return strings.ToLower(strings.TrimSpace(e))
}
+21 -7
View File
@@ -23,22 +23,23 @@ const (
// StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。 // StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。
// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。 // 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
func StartDing(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) { func StartDing(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
cfg := robotsCfg.Dingtalk
if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" { if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" {
return return
} }
go runDingLoop(ctx, cfg, h, logger) go runDingLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger)
} }
// runDingLoop 循环维持钉钉长连接:断开且 ctx 未取消时按退避间隔重连。 // runDingLoop 循环维持钉钉长连接:断开且 ctx 未取消时按退避间隔重连。
func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) { func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) {
backoff := dingReconnectInitial backoff := dingReconnectInitial
for { for {
streamClient := client.NewStreamClient( streamClient := client.NewStreamClient(
client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)), client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)),
client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get", client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get",
chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) { chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) {
go handleDingMessage(ctx, msg, h, logger) go handleDingMessage(ctx, msg, cfg, strictUserIdentity, h, logger)
return nil, nil return nil, nil
}).OnEventReceived), }).OnEventReceived),
) )
@@ -66,7 +67,7 @@ func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageH
} }
} }
func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h MessageHandler, logger *zap.Logger) { func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, cfg config.RobotDingtalkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) {
if msg == nil || msg.SessionWebhook == "" { if msg == nil || msg.SessionWebhook == "" {
return return
} }
@@ -93,9 +94,22 @@ func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h
return return
} }
logger.Info("钉钉收到消息", zap.String("sender", msg.SenderId), zap.String("content", content)) logger.Info("钉钉收到消息", zap.String("sender", msg.SenderId), zap.String("content", content))
userID := msg.SenderId tenantKey := strings.TrimSpace(cfg.ClientID)
if tenantKey == "" {
tenantKey = "default"
}
userID := strings.TrimSpace(msg.SenderId)
if userID != "" {
userID = "t:" + tenantKey + "|u:" + userID
} else if cfg.AllowConversationIDFallback && !strictUserIdentity {
conversationID := strings.TrimSpace(msg.ConversationId)
if conversationID != "" {
userID = "t:" + tenantKey + "|c:" + conversationID
}
}
if userID == "" { if userID == "" {
userID = msg.ConversationId logger.Warn("钉钉消息缺少可用用户标识,已忽略")
return
} }
reply := h.HandleMessage("dingtalk", userID, content) reply := h.HandleMessage("dingtalk", userID, content)
// 使用 markdown 类型以便正确展示标题、列表、代码块等格式 // 使用 markdown 类型以便正确展示标题、列表、代码块等格式
+38 -8
View File
@@ -27,20 +27,21 @@ type larkTextContent struct {
// StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。 // StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。
// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。 // 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
func StartLark(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) { func StartLark(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
cfg := robotsCfg.Lark
if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" { if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" {
return return
} }
go runLarkLoop(ctx, cfg, h, logger) go runLarkLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger)
} }
// runLarkLoop 循环维持飞书长连接:断开且 ctx 未取消时按退避间隔重连。 // runLarkLoop 循环维持飞书长连接:断开且 ctx 未取消时按退避间隔重连。
func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) { func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) {
backoff := larkReconnectInitial backoff := larkReconnectInitial
for { for {
larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret) larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret)
eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error { eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
go handleLarkMessage(ctx, event, h, larkClient, logger) go handleLarkMessage(ctx, event, cfg, strictUserIdentity, h, larkClient, logger)
return nil return nil
}) })
wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret, wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret,
@@ -70,7 +71,7 @@ func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandl
} }
} }
func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h MessageHandler, client *lark.Client, logger *zap.Logger) { func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, cfg config.RobotLarkConfig, strictUserIdentity bool, h MessageHandler, client *lark.Client, logger *zap.Logger) {
if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil { if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
return return
} }
@@ -89,9 +90,10 @@ func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h
if text == "" { if text == "" {
return return
} }
userID := "" userID := resolveLarkUserID(event, cfg.AllowChatIDFallback && !strictUserIdentity)
if event.Event.Sender.SenderId.UserId != nil { if userID == "" {
userID = *event.Event.Sender.SenderId.UserId logger.Warn("飞书消息缺少可用用户标识,已忽略")
return
} }
messageID := larkcore.StringValue(msg.MessageId) messageID := larkcore.StringValue(msg.MessageId)
reply := h.HandleMessage("lark", userID, text) reply := h.HandleMessage("lark", userID, text)
@@ -109,3 +111,31 @@ func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h
} }
logger.Debug("飞书已回复", zap.String("message_id", messageID)) logger.Debug("飞书已回复", zap.String("message_id", messageID))
} }
// resolveLarkUserID 提取飞书会话隔离键:
// tenant_key + 稳定用户标识(user_id/open_id/union_id);按配置可选 chat_id 兜底。
func resolveLarkUserID(event *larkim.P2MessageReceiveV1, allowChatIDFallback bool) string {
if event == nil || event.Event == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
return ""
}
tenantKey := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.TenantKey))
if tenantKey == "" {
tenantKey = "default"
}
prefix := "t:" + tenantKey + "|"
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.UserId)); id != "" {
return prefix + "u:" + id
}
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.OpenId)); id != "" {
return prefix + "o:" + id
}
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.UnionId)); id != "" {
return prefix + "n:" + id
}
if allowChatIDFallback && event.Event.Message != nil {
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Message.ChatId)); id != "" {
return prefix + "c:" + id
}
}
return ""
}
+13 -8
View File
@@ -7,11 +7,11 @@ set -euo pipefail
# - config.yaml # - config.yaml
# - data/ # - data/
# - venv/ (disabled with --no-venv) # - venv/ (disabled with --no-venv)
# - tools/ (user extensions; never overwritten by upgrade)
# #
# Optional preserves (may overwrite upstream updates): # Optional preserves (may overwrite upstream updates):
# - roles/ # - roles/
# - skills/ # - skills/
# - tools/
# Enable with --preserve-custom # Enable with --preserve-custom
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -43,8 +43,8 @@ Usage:
Options: Options:
--tag <tag> Specify GitHub Release tag (e.g. v1.3.28). --tag <tag> Specify GitHub Release tag (e.g. v1.3.28).
If omitted, the script uses the latest release. If omitted, the script uses the latest release.
--preserve-custom Preserve roles/skills/tools (may overwrite upstream files). --preserve-custom Preserve roles/skills (may overwrite upstream files).
Use with caution. tools/ is always preserved. Use with caution.
--no-venv Do not preserve venv/ (Python deps will be re-installed). --no-venv Do not preserve venv/ (Python deps will be re-installed).
--no-stop Do not try to stop the running service. --no-stop Do not try to stop the running service.
--force-stop If no process matching current directory is found, also stop --force-stop If no process matching current directory is found, also stop
@@ -52,7 +52,7 @@ Options:
--yes Do not ask for confirmation. --yes Do not ask for confirmation.
Description: Description:
The script backs up config.yaml/data/ (and optionally venv/roles/skills/tools) to The script backs up config.yaml/data/tools/ (and optionally venv/roles/skills) to
.upgrade-backup/ .upgrade-backup/
EOF EOF
} }
@@ -176,10 +176,11 @@ confirm_or_exit() {
else else
info " - Preserve venv/: no (will remove old venv and re-install deps)" info " - Preserve venv/: no (will remove old venv and re-install deps)"
fi fi
info " - Preserve tools/: yes (always)"
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then
info " - Preserve roles/skills/tools: yes (may overwrite upstream updates)" info " - Preserve roles/skills: yes (may overwrite upstream updates)"
else else
info " - Preserve roles/skills/tools: no (will use upstream versions)" info " - Preserve roles/skills: no (will use upstream versions)"
fi fi
info " - Stop service: ${STOP_SERVICE}" info " - Stop service: ${STOP_SERVICE}"
echo "" echo ""
@@ -296,10 +297,12 @@ sync_code() {
rsync_excludes+=( "--exclude=knowledge_base/" ) rsync_excludes+=( "--exclude=knowledge_base/" )
fi fi
# User tool extensions: never replace or delete during upgrade.
rsync_excludes+=( "--exclude=tools/" )
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then
rsync_excludes+=( "--exclude=roles/" ) rsync_excludes+=( "--exclude=roles/" )
rsync_excludes+=( "--exclude=skills/" ) rsync_excludes+=( "--exclude=skills/" )
rsync_excludes+=( "--exclude=tools/" )
fi fi
# Ensure this upgrade script itself is not deleted. # Ensure this upgrade script itself is not deleted.
@@ -378,10 +381,12 @@ main() {
if [[ -d "$KNOWLEDGE_BASE_DIR" ]]; then if [[ -d "$KNOWLEDGE_BASE_DIR" ]]; then
backup_dir_tgz "knowledge_base" "$KNOWLEDGE_BASE_DIR" backup_dir_tgz "knowledge_base" "$KNOWLEDGE_BASE_DIR"
fi fi
if [[ -d "$ROOT_DIR/tools" ]]; then
backup_dir_tgz "tools" "$ROOT_DIR/tools"
fi
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then
backup_dir_tgz "roles" "$ROOT_DIR/roles" backup_dir_tgz "roles" "$ROOT_DIR/roles"
backup_dir_tgz "skills" "$ROOT_DIR/skills" backup_dir_tgz "skills" "$ROOT_DIR/skills"
backup_dir_tgz "tools" "$ROOT_DIR/tools"
fi fi
local tmp_dir local tmp_dir
+209 -44
View File
@@ -84,6 +84,16 @@
cursor: pointer; cursor: pointer;
} }
/* 原生下拉:避免 appearance:none 在部分浏览器中导致 select 无法正常展开 */
#page-c2 select.form-control.c2-native-select,
#page-c2-payloads select.form-control.c2-native-select,
.c2-modal select.form-control.c2-native-select {
appearance: auto;
-webkit-appearance: menulist-button;
background-image: none;
padding-right: 14px;
}
#page-c2 textarea.form-control, #page-c2 textarea.form-control,
#page-c2-payloads textarea.form-control, #page-c2-payloads textarea.form-control,
.c2-modal textarea.form-control { .c2-modal textarea.form-control {
@@ -104,7 +114,7 @@
C2 Button Overrides (within C2 scope) C2 Button Overrides (within C2 scope)
============================================================================ */ ============================================================================ */
.c2-listener-actions .btn-primary, .c2-listener-card-actions .btn-primary,
.c2-payload-card .btn-primary, .c2-payload-card .btn-primary,
.c2-modal-footer .btn-primary { .c2-modal-footer .btn-primary {
background: var(--c2-accent); background: var(--c2-accent);
@@ -118,7 +128,7 @@
transition: all 0.2s; transition: all 0.2s;
} }
.c2-listener-actions .btn-primary:hover, .c2-listener-card-actions .btn-primary:hover,
.c2-payload-card .btn-primary:hover, .c2-payload-card .btn-primary:hover,
.c2-modal-footer .btn-primary:hover { .c2-modal-footer .btn-primary:hover {
background: var(--c2-accent-hover); background: var(--c2-accent-hover);
@@ -258,10 +268,10 @@
.c2-listener-grid { .c2-listener-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(292px, 1fr));
gap: 16px; gap: 20px;
padding: 24px; padding: 20px 24px 28px;
align-items: start; align-items: stretch;
} }
.c2-listener-grid:has(.c2-empty) { .c2-listener-grid:has(.c2-empty) {
display: flex; display: flex;
@@ -269,68 +279,214 @@
.c2-listener-card { .c2-listener-card {
background: var(--c2-surface); background: var(--c2-surface);
border: 1.5px solid var(--c2-border); border: 1px solid var(--c2-border);
border-radius: var(--c2-radius); border-radius: 14px;
padding: 24px; padding: 0;
transition: all 0.25s ease; overflow: hidden;
position: relative; display: flex;
flex-direction: column;
min-height: 100%;
box-shadow: var(--c2-shadow-sm);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
border-top: 3px solid var(--c2-border);
} }
.c2-listener-card.running { border-left: 4px solid var(--c2-green); } .c2-listener-card--running { border-top-color: var(--c2-green); }
.c2-listener-card.stopped { border-left: 4px solid var(--c2-text-muted); } .c2-listener-card--stopped { border-top-color: var(--c2-text-muted); }
.c2-listener-card.error { border-left: 4px solid var(--c2-amber); } .c2-listener-card--error { border-top-color: var(--c2-amber); }
.c2-listener-card:hover { .c2-listener-card:hover {
box-shadow: var(--c2-shadow-md); box-shadow: var(--c2-shadow-md);
border-color: var(--c2-border-hover); border-color: var(--c2-border-hover);
} }
.c2-listener-header { .c2-listener-card-head {
display: flex; display: flex;
justify-content: space-between; gap: 14px;
align-items: flex-start; align-items: flex-start;
margin-bottom: 16px; padding: 18px 18px 0;
}
.c2-ltype-mark {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
letter-spacing: -0.02em;
color: #fff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.c2-ltype-mark--http { background: linear-gradient(145deg, #3b82f6, #1d4ed8); }
.c2-ltype-mark--https { background: linear-gradient(145deg, #6366f1, #4338ca); }
.c2-ltype-mark--tcp { background: linear-gradient(145deg, #8b5cf6, #6d28d9); }
.c2-ltype-mark--ws { background: linear-gradient(145deg, #0ea5e9, #0369a1); }
.c2-ltype-mark--def { background: linear-gradient(145deg, #64748b, #475569); }
.c2-listener-card-head-main {
flex: 1;
min-width: 0;
}
.c2-listener-card-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
} }
.c2-listener-name { .c2-listener-name {
margin: 0;
font-weight: 700; font-weight: 700;
font-size: 16px; font-size: 17px;
line-height: 1.3;
color: var(--c2-text); color: var(--c2-text);
letter-spacing: -0.02em;
flex: 1;
min-width: 0;
word-break: break-word;
} }
.c2-listener-id { .c2-listener-pill {
font-size: 11px; flex-shrink: 0;
color: var(--c2-text-muted);
font-family: var(--c2-mono);
margin-top: 4px;
}
.c2-listener-type {
font-size: 11px; font-size: 11px;
font-weight: 700;
padding: 4px 10px; padding: 4px 10px;
border-radius: var(--c2-radius-xs); border-radius: 999px;
letter-spacing: 0.02em;
}
.c2-listener-pill--running {
background: var(--c2-green-dim);
color: #047857;
}
.c2-listener-pill--stopped {
background: var(--c2-surface-alt); background: var(--c2-surface-alt);
color: var(--c2-text-dim); color: var(--c2-text-dim);
font-weight: 600;
border: 1px solid var(--c2-border); border: 1px solid var(--c2-border);
text-transform: capitalize;
} }
.c2-listener-info { .c2-listener-pill--error {
font-size: 13px; background: var(--c2-amber-dim);
color: var(--c2-text-dim); color: #b45309;
margin-bottom: 20px;
line-height: 1.8;
} }
.c2-listener-address { .c2-listener-id-row {
margin-top: 8px;
}
.c2-listener-id-full {
display: block;
font-family: var(--c2-mono); font-family: var(--c2-mono);
font-size: 13px; font-size: 11px;
margin-bottom: 6px; color: var(--c2-text-muted);
line-height: 1.4;
word-break: break-all;
background: var(--c2-surface-alt);
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--c2-border);
}
.c2-listener-card-body {
padding: 14px 18px 4px;
flex: 1;
display: flex; display: flex;
flex-direction: column;
gap: 8px;
}
.c2-listener-kv {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 12px;
align-items: baseline;
font-size: 13px;
}
.c2-listener-kv-label {
color: var(--c2-text-muted);
font-weight: 600;
font-size: 12px;
}
.c2-listener-kv-val {
color: var(--c2-text);
font-weight: 600;
display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: var(--c2-text); min-width: 0;
word-break: break-all;
}
.c2-listener-mono {
font-family: var(--c2-mono);
font-size: 12px;
font-weight: 600;
}
.c2-listener-profile-badge {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
font-size: 12px;
font-weight: 600;
color: #5b21b6;
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.25);
padding: 6px 10px;
border-radius: 999px;
max-width: 100%;
}
.c2-listener-profile-badge span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.c2-listener-profile-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #7c3aed;
flex-shrink: 0;
}
.c2-listener-remark {
font-size: 12px;
color: var(--c2-text-dim);
line-height: 1.45;
padding: 8px 10px;
background: var(--c2-surface-alt);
border-radius: 8px;
border: 1px dashed var(--c2-border);
}
.c2-listener-meta-row {
font-size: 12px;
color: var(--c2-text-dim);
padding-top: 4px;
}
.c2-listener-meta-label {
font-weight: 600;
color: var(--c2-text-muted);
}
.c2-listener-meta-time {
font-family: var(--c2-mono);
font-size: 11px;
color: var(--c2-text-dim);
} }
.c2-status-dot { .c2-status-dot {
@@ -339,23 +495,32 @@
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
flex-shrink: 0; flex-shrink: 0;
background: var(--c2-text-muted);
} }
.c2-status-dot.running { background: var(--c2-green); box-shadow: 0 0 0 3px var(--c2-green-dim); } .c2-status-dot.running { background: var(--c2-green); box-shadow: 0 0 0 3px var(--c2-green-dim); }
.c2-status-dot.stopped { background: var(--c2-text-muted); } .c2-status-dot.stopped { background: var(--c2-text-muted); }
.c2-status-dot.error { background: var(--c2-amber); box-shadow: 0 0 0 3px var(--c2-amber-dim); }
.c2-status-dot.active { background: var(--c2-green); box-shadow: 0 0 0 3px var(--c2-green-dim); } .c2-status-dot.active { background: var(--c2-green); box-shadow: 0 0 0 3px var(--c2-green-dim); }
.c2-status-dot.sleeping { background: var(--c2-amber); box-shadow: 0 0 0 3px var(--c2-amber-dim); } .c2-status-dot.sleeping { background: var(--c2-amber); box-shadow: 0 0 0 3px var(--c2-amber-dim); }
.c2-status-dot.dead { background: var(--c2-text-muted); } .c2-status-dot.dead { background: var(--c2-text-muted); }
.c2-listener-actions { .c2-listener-card-actions {
display: flex; display: grid;
gap: 8px; grid-template-columns: 1fr 1fr 1fr;
flex-wrap: wrap; gap: 10px;
padding-top: 16px; padding: 14px 16px 16px;
margin-top: auto;
border-top: 1px solid var(--c2-border); border-top: 1px solid var(--c2-border);
background: linear-gradient(180deg, rgba(248, 250, 252, 0.5) 0%, var(--c2-surface-alt) 100%);
} }
.c2-listener-actions button { flex: 1; min-width: 70px; } .c2-listener-card-actions button {
min-height: 40px;
font-size: 13px;
font-weight: 600;
border-radius: var(--c2-radius-xs);
}
/* ============================================================================ /* ============================================================================
Session Management Session Management
+165 -2
View File
@@ -2391,7 +2391,118 @@ header {
box-sizing: border-box; box-sizing: border-box;
} }
.chat-input-container > .chat-input-with-files { .chat-input-primary-row {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 8px;
flex: 1;
min-width: 0;
width: 100%;
}
.chat-input-leading {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
/* Eino:模型推理收进浮层,保持主输入行简洁 */
.chat-reasoning-wrapper {
flex-shrink: 0;
}
.chat-reasoning-inner {
position: relative;
}
.chat-reasoning-btn {
max-width: 10.5rem;
padding-left: 0.5rem;
padding-right: 0.45rem;
}
.chat-reasoning-btn .chat-reasoning-btn-icon {
flex-shrink: 0;
font-size: 0.95rem;
line-height: 1;
opacity: 0.95;
}
.chat-reasoning-btn.active .chat-reasoning-btn-icon {
opacity: 1;
}
.chat-reasoning-btn .chat-reasoning-btn-summary {
max-width: 7.6rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-reasoning-btn.active {
border-color: rgba(49, 130, 206, 0.45);
background: rgba(49, 130, 206, 0.06);
}
.chat-reasoning-panel {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 288px;
max-width: calc(100vw - 32px);
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 16px;
padding: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 4px 16px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
}
.chat-reasoning-panel-header {
margin-bottom: 0;
}
.chat-reasoning-panel-hint {
font-size: 0.75rem;
color: var(--text-muted, #718096);
margin: 0;
line-height: 1.45;
}
.chat-reasoning-fields {
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-reasoning-field-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted, #718096);
margin-bottom: 6px;
}
.chat-reasoning-select {
width: 100%;
box-sizing: border-box;
padding: 0.45rem 0.6rem;
font-size: 0.8125rem;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 8px;
background: var(--card-bg, #fff);
color: var(--text-color, #2d3748);
}
.chat-input-container .chat-input-with-files,
.chat-input-primary-row .chat-input-with-files {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2399,7 +2510,8 @@ header {
gap: 6px; gap: 6px;
} }
.chat-input-container > .chat-input-field { .chat-input-container > .chat-input-field,
.chat-input-primary-row .chat-input-field {
flex: 1; flex: 1;
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -3196,6 +3308,12 @@ header {
border-color: rgba(220, 53, 69, 0.3); border-color: rgba(220, 53, 69, 0.3);
} }
.status-chip.status-cancelled {
background: rgba(108, 117, 125, 0.12);
color: var(--text-secondary, #6c757d);
border-color: rgba(108, 117, 125, 0.35);
}
.status-chip.status-pending, .status-chip.status-pending,
.status-chip.status-unknown { .status-chip.status-unknown {
background: rgba(255, 193, 7, 0.12); background: rgba(255, 193, 7, 0.12);
@@ -3203,6 +3321,18 @@ header {
border-color: rgba(255, 193, 7, 0.3); border-color: rgba(255, 193, 7, 0.3);
} }
.detail-abort-hint {
font-size: 0.875rem;
opacity: 0.88;
margin: 0 0 10px;
line-height: 1.45;
}
.detail-abort-section .btn-monitor-abort {
border-color: rgba(253, 126, 20, 0.55);
color: #fd7e14;
}
.detail-code-card { .detail-code-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px dashed rgba(0, 0, 0, 0.06); border: 1px dashed rgba(0, 0, 0, 0.06);
@@ -3550,6 +3680,11 @@ header {
background: rgba(156, 39, 176, 0.05); background: rgba(156, 39, 176, 0.05);
} }
.timeline-item-reasoning_chain {
border-left-color: #5c6bc0;
background: rgba(92, 107, 192, 0.06);
}
.timeline-item-tool_call { .timeline-item-tool_call {
border-left-color: #ff9800; border-left-color: #ff9800;
background: rgba(255, 152, 0, 0.05); background: rgba(255, 152, 0, 0.05);
@@ -3575,6 +3710,11 @@ header {
background: rgba(255, 112, 67, 0.12); background: rgba(255, 112, 67, 0.12);
} }
.timeline-item-user_interrupt_continue {
border-left-color: #d97706;
background: rgba(217, 119, 6, 0.08);
}
.timeline-item-header { .timeline-item-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3605,6 +3745,12 @@ header {
line-height: 1.6; line-height: 1.6;
} }
/* 流式增量阶段纯文本展示(避免半段 Markdown 反复解析) */
.timeline-item-content.timeline-stream-plain {
white-space: pre-wrap;
word-break: break-word;
}
.tool-details { .tool-details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -5517,6 +5663,16 @@ header {
color: var(--error-color); color: var(--error-color);
} }
.monitor-status-chip.cancelled {
background: rgba(108, 117, 125, 0.15);
color: var(--text-muted, #6c757d);
}
.monitor-execution-actions .btn-monitor-abort {
border-color: rgba(253, 126, 20, 0.55);
color: #fd7e14;
}
.monitor-execution-actions { .monitor-execution-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -12255,6 +12411,9 @@ header {
.webshell-ai-process-block .webshell-ai-timeline-thinking { .webshell-ai-process-block .webshell-ai-timeline-thinking {
border-left-color: #9c27b0; border-left-color: #9c27b0;
} }
.webshell-ai-process-block .webshell-ai-timeline-reasoning_chain {
border-left-color: #5c6bc0;
}
.webshell-ai-process-block .webshell-ai-timeline-tool_call, .webshell-ai-process-block .webshell-ai-timeline-tool_call,
.webshell-ai-process-block .webshell-ai-timeline-tool_calls_detected { .webshell-ai-process-block .webshell-ai-timeline-tool_calls_detected {
border-left-color: #ff9800; border-left-color: #ff9800;
@@ -18272,6 +18431,10 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
transform: translateX(-50%) translateY(0); transform: translateX(-50%) translateY(0);
} }
.chat-files-toast.chat-toast--error {
background: #b91c1c;
}
/* 对话附件读取 / 文件管理上传 进度条 */ /* 对话附件读取 / 文件管理上传 进度条 */
/* [hidden] 默认会被本类的 display:flex 覆盖,须显式隐藏否则空闲时仍露出灰条 */ /* [hidden] 默认会被本类的 display:flex 覆盖,须显式隐藏否则空闲时仍露出灰条 */
.chat-upload-progress-row[hidden] { .chat-upload-progress-row[hidden] {
+64
View File
@@ -277,6 +277,7 @@
"planExecuteStreamPhase": "Phase output", "planExecuteStreamPhase": "Phase output",
"einoSubAgentStep": "Sub-agent {{agent}} · step {{n}}", "einoSubAgentStep": "Sub-agent {{agent}} · step {{n}}",
"aiThinking": "AI thinking", "aiThinking": "AI thinking",
"reasoningChain": "Reasoning process",
"planning": "Planning", "planning": "Planning",
"assistantStreamPhase": "Assistant output", "assistantStreamPhase": "Assistant output",
"toolCallsDetected": "Detected {{count}} tool call(s)", "toolCallsDetected": "Detected {{count}} tool call(s)",
@@ -288,6 +289,7 @@
"error": "Error", "error": "Error",
"streamNetworkErrorHint": "Connection lost ({{detail}}). A long task may still be running on the server; check running tasks at the top or refresh this conversation later.", "streamNetworkErrorHint": "Connection lost ({{detail}}). A long task may still be running on the server; check running tasks at the top or refresh this conversation later.",
"taskCancelled": "Task cancelled", "taskCancelled": "Task cancelled",
"userInterruptContinueTitle": "⏸️ User interrupt & continue",
"unknownTool": "Unknown tool", "unknownTool": "Unknown tool",
"einoAgentReplyTitle": "Sub-agent reply", "einoAgentReplyTitle": "Sub-agent reply",
"einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})", "einoStreamErrorTitle": "⚠️ Eino stream interrupted ({{agent}})",
@@ -328,6 +330,19 @@
"agentModeMulti": "Multi-agent", "agentModeMulti": "Multi-agent",
"agentModeSingleHint": "Single-model ReAct loop for chat and tool use", "agentModeSingleHint": "Single-model ReAct loop for chat and tool use",
"agentModeMultiHint": "Eino prebuilt orchestration (deep / plan_execute / supervisor) for complex tasks", "agentModeMultiHint": "Eino prebuilt orchestration (deep / plan_execute / supervisor) for complex tasks",
"reasoningModeLabel": "Model reasoning",
"reasoningEffortLabel": "Reasoning effort",
"reasoningModeDefault": "Use system default",
"reasoningModeOff": "Off",
"reasoningModeOn": "On",
"reasoningModeAuto": "Auto",
"reasoningEffortUnset": "Unspecified",
"reasoningCompactLabel": "Reasoning",
"reasoningCompactAria": "Open model reasoning options",
"reasoningPanelTitle": "Model reasoning",
"reasoningPanelHint": "Only Eino single- and multi-agent requests use these; merged with defaults in Settings.",
"reasoningSummaryFollow": "System",
"reasoningSummaryDash": "—",
"agentModeOrchPlanExecute": "Plan-Exec", "agentModeOrchPlanExecute": "Plan-Exec",
"agentModeOrchSupervisor": "Supervisor", "agentModeOrchSupervisor": "Supervisor",
"hitlTitle": "Human-in-the-loop", "hitlTitle": "Human-in-the-loop",
@@ -394,6 +409,16 @@
"tasks": { "tasks": {
"title": "Task Management", "title": "Task Management",
"stopTask": "Stop task", "stopTask": "Stop task",
"interruptModalTitle": "Interrupt current step",
"interruptReasonLabel": "Interrupt note",
"interruptModalHint": "When a tool is running: same as MCP monitor \"Stop tool\" — only that call is stopped and the run continues; your note can be merged into the tool result (USER INTERRUPT NOTE). When no tool is running (model thinking/streaming only): \"Interrupt & continue\" still works — current output pauses, your note is merged into context and the run resumes automatically; the progress timeline shows a \"User interrupt & continue\" entry. Use this instead of a full stop when you only want to steer; use \"Stop completely\" to end the whole task.",
"interruptReasonPlaceholder": "e.g. Tool is too slow—skip and summarize…",
"interruptReasonRequired": "Please enter a short note so the model can continue accordingly.",
"interruptSubmitting": "Submitting...",
"interruptConfirmContinue": "Interrupt & continue",
"interruptHardStop": "Stop completely",
"interruptModalClose": "Close",
"userInterruptTimelineTitle": "User interrupt note (continuing)",
"collapseDetail": "Collapse details", "collapseDetail": "Collapse details",
"newTask": "New task", "newTask": "New task",
"autoRefresh": "Auto refresh", "autoRefresh": "Auto refresh",
@@ -789,6 +814,7 @@
"nav": { "nav": {
"basic": "Basic", "basic": "Basic",
"knowledge": "Knowledge base", "knowledge": "Knowledge base",
"c2": "C2",
"robots": "Bots", "robots": "Bots",
"terminal": "Terminal", "terminal": "Terminal",
"security": "Security", "security": "Security",
@@ -800,6 +826,12 @@
"knowledge": { "knowledge": {
"title": "Knowledge base" "title": "Knowledge base"
}, },
"c2": {
"title": "C2",
"sectionTitle": "Built-in C2",
"enableLabel": "Enable built-in C2 (listeners, sessions, payloads, MCP tools)",
"enableHint": "When off, listeners are not started and C2 MCP tools are not registered; the C2 sidebar is hidden—useful for local-only chat/knowledge deployments. Click Apply to save."
},
"robots": { "robots": {
"title": "Bot settings", "title": "Bot settings",
"description": "Configure WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.", "description": "Configure WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.",
@@ -1253,6 +1285,8 @@
"statusCompleted": "Completed", "statusCompleted": "Completed",
"statusRunning": "Running", "statusRunning": "Running",
"statusFailed": "Failed", "statusFailed": "Failed",
"statusCancelled": "Cancelled",
"terminateExecution": "Stop",
"loading": "Loading...", "loading": "Loading...",
"noStatsData": "No statistical data", "noStatsData": "No statistical data",
"noExecutions": "No execution records", "noExecutions": "No execution records",
@@ -1572,6 +1606,10 @@
"maxTotalTokens": "Max Context Tokens", "maxTotalTokens": "Max Context Tokens",
"maxTotalTokensPlaceholder": "120000", "maxTotalTokensPlaceholder": "120000",
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000", "maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
"openaiReasoningTitle": "Model reasoning (Eino)",
"openaiReasoningHint": "Applies to Eino single-agent and multi-agent only; works with chat-page reasoning controls.",
"openaiReasoningProfile": "Wire profile",
"openaiReasoningAllowClient": "Allow chat page to override reasoning options",
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)", "fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)",
"fofaBaseUrlHint": "Leave empty for default.", "fofaBaseUrlHint": "Leave empty for default.",
"email": "Email", "email": "Email",
@@ -1720,8 +1758,22 @@
"statusRunning": "Running", "statusRunning": "Running",
"statusCompleted": "Completed", "statusCompleted": "Completed",
"statusFailed": "Failed", "statusFailed": "Failed",
"statusCancelled": "Cancelled",
"unknown": "Unknown", "unknown": "Unknown",
"getDetailFailed": "Failed to get details", "getDetailFailed": "Failed to get details",
"runningNoResponseYet": "No output yet; the tool may still be running. If it hangs, use \"Stop tool\" below to end this call only.",
"abortTitle": "Execution control",
"abortHint": "Stops only this tool call. The conversation / multi-step task continues (unlike stopping the whole task).",
"abortBtn": "Stop tool",
"abortConfirm": "Stop this tool call? The overall conversation or iterative task will not be cancelled.",
"abortSuccess": "Cancellation requested; status will update when the tool returns.",
"abortFailed": "Failed to stop tool",
"abortNoteModalTitle": "Stop tool with a note",
"abortNoteModalHint": "Optional: why you stopped or how the model should continue. The model sees any tool output first, then a labeled block (USER INTERRUPT NOTE — not raw tool output), then your text. Leave empty for a plain stop.",
"abortNoteLabel": "Note (optional)",
"abortNotePlaceholder": "e.g. Output is enough—skip waiting and continue…",
"abortNoteSubmit": "Stop tool",
"abortNoteClose": "Cancel",
"execSuccessNoContent": "Execution succeeded with no displayable content.", "execSuccessNoContent": "Execution succeeded with no displayable content.",
"time": "Time", "time": "Time",
"executionId": "Execution ID", "executionId": "Execution ID",
@@ -2106,9 +2158,19 @@
"bindHintExternal": "Use 0.0.0.0 to allow external access", "bindHintExternal": "Use 0.0.0.0 to allow external access",
"callbackHost": "Callback host (optional)", "callbackHost": "Callback host (optional)",
"callbackHostHint": "Public IP or hostname stored for payloads/beacons; separate from bind address. If empty, payload generation falls back to bind address / auto-detect.", "callbackHostHint": "Public IP or hostname stored for payloads/beacons; separate from bind address. If empty, payload generation falls back to bind address / auto-detect.",
"malleableProfile": "Malleable Profile",
"malleableProfileHint": "Optional; HTTP/HTTPS Beacon response headers and traffic disguise. Stop and start the listener again for changes to take effect.",
"malleableProfileNone": "None",
"malleableProfileNonHttpHint": "This listener type does not use a Malleable Profile. You can still bind one here for later if you switch to HTTP/HTTPS Beacon.",
"malleableProfileEmptyListHint": "No saved profiles yet. Create one under C2 → Traffic disguise / Malleable Profile, then pick it here.",
"placeholderRemarkLong": "Optional remark", "placeholderRemarkLong": "Optional remark",
"editTitle": "Edit Listener", "editTitle": "Edit Listener",
"startedAt": "Started {{time}}", "startedAt": "Started {{time}}",
"startedAtPrefix": "Started",
"statusError": "Error",
"bindEndpoint": "Listen address",
"callbackShort": "Callback",
"profileBadgeTitle": "Malleable Profile bound",
"confirmDelete": "Delete this listener? All related sessions and tasks will be removed.", "confirmDelete": "Delete this listener? All related sessions and tasks will be removed.",
"toastFillRequired": "Please fill in all required fields", "toastFillRequired": "Please fill in all required fields",
"toastCreated": "Listener created", "toastCreated": "Listener created",
@@ -2116,6 +2178,8 @@
"toastStopped": "Listener stopped", "toastStopped": "Listener stopped",
"toastDeleted": "Listener deleted", "toastDeleted": "Listener deleted",
"toastUpdated": "Listener updated", "toastUpdated": "Listener updated",
"loadingProfiles": "Loading Malleable Profiles…",
"toastProfilesLoadFailed": "Failed to load Malleable Profiles",
"submitCreate": "Create", "submitCreate": "Create",
"typeLabels": { "typeLabels": {
"http_beacon": "HTTP Beacon", "http_beacon": "HTTP Beacon",
+64
View File
@@ -266,6 +266,7 @@
"planExecuteStreamPhase": "阶段输出", "planExecuteStreamPhase": "阶段输出",
"einoSubAgentStep": "子代理 {{agent}} · 第 {{n}} 步", "einoSubAgentStep": "子代理 {{agent}} · 第 {{n}} 步",
"aiThinking": "AI思考", "aiThinking": "AI思考",
"reasoningChain": "推理过程",
"planning": "规划中", "planning": "规划中",
"assistantStreamPhase": "助手输出", "assistantStreamPhase": "助手输出",
"toolCallsDetected": "检测到 {{count}} 个工具调用", "toolCallsDetected": "检测到 {{count}} 个工具调用",
@@ -277,6 +278,7 @@
"error": "错误", "error": "错误",
"streamNetworkErrorHint": "连接已中断({{detail}})。长时间任务可能仍在后端执行,请查看顶部「运行中」任务或稍后刷新本对话。", "streamNetworkErrorHint": "连接已中断({{detail}})。长时间任务可能仍在后端执行,请查看顶部「运行中」任务或稍后刷新本对话。",
"taskCancelled": "任务已取消", "taskCancelled": "任务已取消",
"userInterruptContinueTitle": "⏸️ 用户中断并继续",
"unknownTool": "未知工具", "unknownTool": "未知工具",
"einoAgentReplyTitle": "子代理回复", "einoAgentReplyTitle": "子代理回复",
"einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}}", "einoStreamErrorTitle": "⚠️ Eino 流式中断({{agent}}",
@@ -317,6 +319,19 @@
"agentModeMulti": "多代理", "agentModeMulti": "多代理",
"agentModeSingleHint": "单模型 ReAct 循环,适合常规对话与工具调用", "agentModeSingleHint": "单模型 ReAct 循环,适合常规对话与工具调用",
"agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务", "agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务",
"reasoningModeLabel": "模型推理",
"reasoningEffortLabel": "推理强度",
"reasoningModeDefault": "跟随系统",
"reasoningModeOff": "关闭",
"reasoningModeOn": "开启",
"reasoningModeAuto": "自动",
"reasoningEffortUnset": "不指定",
"reasoningCompactLabel": "推理",
"reasoningCompactAria": "打开模型推理选项",
"reasoningPanelTitle": "模型推理",
"reasoningPanelHint": "仅 Eino 单代理与多代理请求会带上这些参数;与系统设置中的默认值合并。",
"reasoningSummaryFollow": "系统",
"reasoningSummaryDash": "—",
"agentModeOrchPlanExecute": "Plan-Exec", "agentModeOrchPlanExecute": "Plan-Exec",
"agentModeOrchSupervisor": "Supervisor", "agentModeOrchSupervisor": "Supervisor",
"hitlTitle": "人机协同", "hitlTitle": "人机协同",
@@ -383,6 +398,16 @@
"tasks": { "tasks": {
"title": "任务管理", "title": "任务管理",
"stopTask": "停止任务", "stopTask": "停止任务",
"interruptModalTitle": "中断当前步骤",
"interruptReasonLabel": "中断说明",
"interruptModalHint": "有工具在执行时:与 MCP 监控页「终止工具」一致,仅结束当前这一次工具调用,本轮推理会继续;说明可写入工具返回(USER INTERRUPT NOTE)。无工具在执行时(模型纯思考/流式输出):仍可「中断并继续」——会暂停当前输出,把你的说明合并进上下文并自动续跑;进度详情时间线会出现「用户中断并继续」条目。不需要整轮停止时请优先用本按钮;要结束整条任务请用「彻底停止」。",
"interruptReasonPlaceholder": "例如:工具耗时过长,请先跳过并总结当前结果…",
"interruptReasonRequired": "请填写中断说明,以便模型根据你的意图继续。",
"interruptSubmitting": "提交中...",
"interruptConfirmContinue": "中断并继续",
"interruptHardStop": "彻底停止",
"interruptModalClose": "关闭",
"userInterruptTimelineTitle": "用户中断说明(继续迭代)",
"collapseDetail": "收起详情", "collapseDetail": "收起详情",
"newTask": "新建任务", "newTask": "新建任务",
"autoRefresh": "自动刷新", "autoRefresh": "自动刷新",
@@ -778,6 +803,7 @@
"nav": { "nav": {
"basic": "基本设置", "basic": "基本设置",
"knowledge": "知识库", "knowledge": "知识库",
"c2": "C2",
"robots": "机器人设置", "robots": "机器人设置",
"terminal": "终端", "terminal": "终端",
"security": "安全设置", "security": "安全设置",
@@ -789,6 +815,12 @@
"knowledge": { "knowledge": {
"title": "知识库设置" "title": "知识库设置"
}, },
"c2": {
"title": "C2 设置",
"sectionTitle": "内置 C2",
"enableLabel": "启用内置 C2(监听器、会话、Payload、MCP 工具等)",
"enableHint": "关闭后不再启动监听器、不注册 C2 相关 MCP 工具,侧栏 C2 入口将隐藏;仅本机使用对话与知识库时可关闭以节省资源。修改后请点击「应用配置」。"
},
"robots": { "robots": {
"title": "机器人设置", "title": "机器人设置",
"description": "配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。", "description": "配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
@@ -1242,6 +1274,8 @@
"statusCompleted": "已完成", "statusCompleted": "已完成",
"statusRunning": "执行中", "statusRunning": "执行中",
"statusFailed": "失败", "statusFailed": "失败",
"statusCancelled": "已终止",
"terminateExecution": "终止",
"loading": "加载中...", "loading": "加载中...",
"noStatsData": "暂无统计数据", "noStatsData": "暂无统计数据",
"noExecutions": "暂无执行记录", "noExecutions": "暂无执行记录",
@@ -1561,6 +1595,10 @@
"maxTotalTokens": "最大上下文 Token 数", "maxTotalTokens": "最大上下文 Token 数",
"maxTotalTokensPlaceholder": "120000", "maxTotalTokensPlaceholder": "120000",
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000", "maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
"openaiReasoningTitle": "模型推理(Eino",
"openaiReasoningHint": "仅 Eino 单代理与多代理请求生效;与对话页「模型推理」下拉配合使用。",
"openaiReasoningProfile": "线路 profile",
"openaiReasoningAllowClient": "允许对话页覆盖推理选项",
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)", "fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)",
"fofaBaseUrlHint": "留空则使用默认地址。", "fofaBaseUrlHint": "留空则使用默认地址。",
"email": "Email", "email": "Email",
@@ -1709,8 +1747,22 @@
"statusRunning": "执行中", "statusRunning": "执行中",
"statusCompleted": "已完成", "statusCompleted": "已完成",
"statusFailed": "失败", "statusFailed": "失败",
"statusCancelled": "已终止",
"unknown": "未知", "unknown": "未知",
"getDetailFailed": "获取详情失败", "getDetailFailed": "获取详情失败",
"runningNoResponseYet": "尚无返回,工具可能仍在执行。若长时间无响应,可使用下方「终止工具」结束本次调用。",
"abortTitle": "运行控制",
"abortHint": "仅中断当前这一次工具调用;对话与多步迭代任务会继续,不会等同于「停止任务」。",
"abortBtn": "终止工具",
"abortConfirm": "确定终止此次工具调用?整条对话或迭代任务不会因此停止。",
"abortSuccess": "已发送终止请求,工具返回后状态将更新。",
"abortFailed": "终止失败",
"abortNoteModalTitle": "终止工具并补充说明",
"abortNoteModalHint": "可选:说明为何终止或希望模型如何继续。提交后模型会先看到工具已输出内容(若有),再看到带「用户终止说明」标题的独立区块(中英标注,与命令行原文区分),最后是您的文字。留空则与原先仅终止一致。",
"abortNoteLabel": "终止说明(可选)",
"abortNotePlaceholder": "例如:输出已够判断,请停止等待并继续下一步…",
"abortNoteSubmit": "提交终止",
"abortNoteClose": "取消",
"execSuccessNoContent": "执行成功,未返回可展示的文本内容。", "execSuccessNoContent": "执行成功,未返回可展示的文本内容。",
"time": "时间", "time": "时间",
"executionId": "执行 ID", "executionId": "执行 ID",
@@ -2095,9 +2147,19 @@
"bindHintExternal": "使用 0.0.0.0 允许外部访问", "bindHintExternal": "使用 0.0.0.0 允许外部访问",
"callbackHost": "回连地址(可选)", "callbackHost": "回连地址(可选)",
"callbackHostHint": "公网 IP 或域名,写入配置供 Payload/Beacon 使用;与「绑定地址」分离。不填则生成 Payload 时按绑定地址或自动探测。", "callbackHostHint": "公网 IP 或域名,写入配置供 Payload/Beacon 使用;与「绑定地址」分离。不填则生成 Payload 时按绑定地址或自动探测。",
"malleableProfile": "Malleable Profile",
"malleableProfileHint": "可选;用于 HTTP/HTTPS Beacon 服务端响应头等流量伪装。修改后需停止并重新启动监听器才会生效。",
"malleableProfileNone": "不使用",
"malleableProfileNonHttpHint": "当前监听器类型不会使用 Profile;若之后改为 HTTP/HTTPS Beacon,可在此预先绑定。",
"malleableProfileEmptyListHint": "暂无已保存的 Profile。请先到侧边栏「流量伪装 / Malleable Profile」页创建,再返回此处选择。",
"placeholderRemarkLong": "可选的备注说明", "placeholderRemarkLong": "可选的备注说明",
"editTitle": "编辑监听器", "editTitle": "编辑监听器",
"startedAt": "启动于 {{time}}", "startedAt": "启动于 {{time}}",
"startedAtPrefix": "启动于",
"statusError": "异常",
"bindEndpoint": "监听地址",
"callbackShort": "回连",
"profileBadgeTitle": "已绑定 Malleable Profile",
"confirmDelete": "确定删除此监听器?相关会话与任务将被清除。", "confirmDelete": "确定删除此监听器?相关会话与任务将被清除。",
"toastFillRequired": "请填写必填项", "toastFillRequired": "请填写必填项",
"toastCreated": "监听器已创建", "toastCreated": "监听器已创建",
@@ -2105,6 +2167,8 @@
"toastStopped": "监听器已停止", "toastStopped": "监听器已停止",
"toastDeleted": "监听器已删除", "toastDeleted": "监听器已删除",
"toastUpdated": "监听器已更新", "toastUpdated": "监听器已更新",
"loadingProfiles": "正在加载 Malleable Profile 列表…",
"toastProfilesLoadFailed": "加载 Malleable Profile 列表失败",
"submitCreate": "创建", "submitCreate": "创建",
"typeLabels": { "typeLabels": {
"http_beacon": "HTTP Beacon", "http_beacon": "HTTP Beacon",
+5 -4
View File
@@ -306,12 +306,13 @@ async function bootstrapApp() {
// 通用工具函数 // 通用工具函数
function getStatusText(status) { function getStatusText(status) {
const s = (status && String(status).toLowerCase()) || '';
if (typeof window.t !== 'function') { if (typeof window.t !== 'function') {
const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败' }; const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败', cancelled: '已终止' };
return fallback[status] || status; return fallback[s] || status;
} }
const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed' }; const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed', cancelled: 'mcpDetailModal.statusCancelled' };
const key = keyMap[status]; const key = keyMap[s];
return key ? window.t(key) : status; return key ? window.t(key) : status;
} }
+221 -31
View File
@@ -151,6 +151,74 @@
return div.innerHTML; return div.innerHTML;
} }
/** 监听器表单:Malleable Profile 下拉选项 HTMLvalue / 文本已转义) */
function listenerProfileSelectHtml(selectedProfileId) {
const sel = selectedProfileId ? String(selectedProfileId) : '';
let opts = `<option value="">${escapeHtml(c2t('c2.listeners.malleableProfileNone'))}</option>`;
for (const p of (C2.profiles || [])) {
if (!p) continue;
const pid = p.id || p.ID;
if (!pid) continue;
const idEsc = escapeHtml(String(pid));
const nameEsc = escapeHtml(p.name || pid);
const selected = sel && String(pid) === sel ? ' selected' : '';
opts += `<option value="${idEsc}"${selected}>${nameEsc}</option>`;
}
return opts;
}
function listenerResolvedProfileId(l) {
if (!l) return '';
const v = l.profileId != null && l.profileId !== '' ? l.profileId : l.profile_id;
return v != null ? String(v).trim() : '';
}
/** 监听器卡片展示用 Profile 名称(依赖 C2.profiles,由 loadListeners 一并拉取) */
function listenerProfileDisplayName(l) {
const pid = listenerResolvedProfileId(l);
if (!pid) return '';
const list = C2.profiles || [];
for (let i = 0; i < list.length; i++) {
const p = list[i];
if (p && (p.id === pid || p.ID === pid)) return String(p.name || p.id || pid).trim() || pid;
}
return pid.length > 18 ? pid.substring(0, 16) + '…' : pid;
}
function listenerTypeVisualClass(type) {
const t = String(type || '').toLowerCase();
if (t === 'https_beacon') return 'c2-ltype-mark--https';
if (t === 'http_beacon') return 'c2-ltype-mark--http';
if (t === 'tcp_reverse') return 'c2-ltype-mark--tcp';
if (t === 'websocket') return 'c2-ltype-mark--ws';
return 'c2-ltype-mark--def';
}
function listenerTypeShortLabel(type) {
const t = String(type || '').toLowerCase();
if (t === 'https_beacon') return 'HTTPS';
if (t === 'http_beacon') return 'HTTP';
if (t === 'tcp_reverse') return 'TCP';
if (t === 'websocket') return 'WS';
return '?';
}
function listenerCardStatusPillLabel(status) {
const s = String(status || '').toLowerCase();
if (s === 'running') return c2t('c2.listeners.running');
if (s === 'stopped') return c2t('c2.listeners.stopped');
if (s === 'error') return c2t('c2.listeners.statusError');
return c2t('c2.listeners.stopped');
}
/** 避免 i18n 插值把日期里的「/」转成 &#x2F;,与 formatTime 拼接后整体转义 */
function formatListenerStartedHtml(dateStr) {
if (!dateStr) return '';
const prefix = c2t('c2.listeners.startedAtPrefix');
const time = formatTime(dateStr);
return '<div class="c2-listener-meta-row"><span class="c2-listener-meta-label">' + escapeHtml(prefix) + '</span> <span class="c2-listener-meta-time">' + escapeHtml(time) + '</span></div>';
}
function copyToClipboard(text) { function copyToClipboard(text) {
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => showToast(c2t('c2.clipboardCopied'), 'success')); navigator.clipboard.writeText(text).then(() => showToast(c2t('c2.clipboardCopied'), 'success'));
@@ -204,13 +272,33 @@
// ============================================================================ // ============================================================================
C2.loadListeners = function() { C2.loadListeners = function() {
apiRequest('GET', `${API_BASE}/listeners`).then(data => { Promise.all([
C2.listeners = data.listeners || []; apiRequest('GET', `${API_BASE}/listeners`),
apiRequest('GET', `${API_BASE}/profiles`).catch(function() { return {}; })
]).then(function(results) {
var ldata = results[0];
var pdata = results[1];
C2.listeners = (ldata && ldata.listeners) || [];
if (pdata && pdata.profiles && !pdata.error) {
C2.profiles = pdata.profiles;
}
C2.renderListeners(); C2.renderListeners();
C2.updateDashboardStats(); C2.updateDashboardStats();
}); });
}; };
/** 拉取 Profile 列表(监听器表单用);失败时置空列表不阻断弹窗 */
C2.ensureProfilesLoaded = function() {
return apiRequest('GET', `${API_BASE}/profiles`).then(data => {
if (data && data.error) {
C2.profiles = [];
return C2.profiles;
}
C2.profiles = (data && data.profiles) || [];
return C2.profiles;
});
};
C2.renderListeners = function() { C2.renderListeners = function() {
const container = document.getElementById('c2-listener-grid'); const container = document.getElementById('c2-listener-grid');
if (!container) return; if (!container) return;
@@ -233,33 +321,60 @@
return; return;
} }
container.innerHTML = C2.listeners.map(l => ` container.innerHTML = C2.listeners.map(function(l) {
<div class="c2-listener-card ${l.status}"> const st = String(l.status || 'stopped').toLowerCase();
<div class="c2-listener-header"> const stUi = st === 'running' || st === 'stopped' || st === 'error' ? st : 'stopped';
<div> const profilePid = listenerResolvedProfileId(l);
<div class="c2-listener-name">${escapeHtml(l.name)}</div> const profileName = listenerProfileDisplayName(l);
<div class="c2-listener-id">${l.id.substring(0, 12)}...</div> const profileBadge = profilePid
? '<div class="c2-listener-profile-badge" title="' + escapeHtml(c2t('c2.listeners.profileBadgeTitle')) + '"><span class="c2-listener-profile-dot" aria-hidden="true"></span><span>' + escapeHtml(profileName) + '</span></div>'
: '';
const cb = C2.getListenerCallbackHost(l);
const cbRow = cb
? '<div class="c2-listener-kv"><span class="c2-listener-kv-label">' + escapeHtml(c2t('c2.listeners.callbackShort')) + '</span><span class="c2-listener-kv-val c2-listener-mono">' + escapeHtml(cb) + '</span></div>'
: '';
const remarkRow = l.remark ? '<div class="c2-listener-remark">' + escapeHtml(l.remark) + '</div>' : '';
const startedHtml = formatListenerStartedHtml(l.startedAt);
const pillLabel = escapeHtml(listenerCardStatusPillLabel(st));
const typeMark = escapeHtml(listenerTypeShortLabel(l.type));
const typeVis = listenerTypeVisualClass(l.type);
const fullType = escapeHtml(listenerTypeLabel(l.type));
const bindVal = escapeHtml(String(l.bindHost)) + ':' + escapeHtml(String(l.bindPort));
return `
<article class="c2-listener-card c2-listener-card--${stUi}" data-listener-id="${escapeHtml(l.id)}">
<div class="c2-listener-card-head">
<div class="c2-ltype-mark ${typeVis}" title="${fullType}"><span>${typeMark}</span></div>
<div class="c2-listener-card-head-main">
<div class="c2-listener-card-title-row">
<h3 class="c2-listener-name">${escapeHtml(l.name)}</h3>
<span class="c2-listener-pill c2-listener-pill--${stUi}">${pillLabel}</span>
</div> </div>
<span class="c2-listener-type">${escapeHtml(listenerTypeLabel(l.type))}</span> <div class="c2-listener-id-row">
<code class="c2-listener-id-full" title="${escapeHtml(l.id)}">${escapeHtml(l.id)}</code>
</div> </div>
<div class="c2-listener-info">
<div class="c2-listener-address">
<span class="c2-status-dot ${l.status}"></span>
<strong>${l.bindHost}:${l.bindPort}</strong>
</div> </div>
${l.startedAt ? `<div style="font-size:12px;margin-top:4px;">${escapeHtml(c2t('c2.listeners.startedAt', { time: formatTime(l.startedAt) }))}</div>` : ''}
${l.remark ? `<div style="font-size:12px;margin-top:2px;opacity:0.7;">${escapeHtml(l.remark)}</div>` : ''}
</div> </div>
<div class="c2-listener-actions"> <div class="c2-listener-card-body">
<div class="c2-listener-kv">
<span class="c2-listener-kv-label">${escapeHtml(c2t('c2.listeners.bindEndpoint'))}</span>
<span class="c2-listener-kv-val c2-listener-mono"><span class="c2-status-dot ${escapeHtml(st)}"></span>${bindVal}</span>
</div>
${cbRow}
${profileBadge}
${remarkRow}
${startedHtml}
</div>
<div class="c2-listener-card-actions">
${l.status === 'stopped' ${l.status === 'stopped'
? `<button class="btn-primary btn-sm" onclick="C2.startListener('${l.id}')">▶ ${escapeHtml(c2t('c2.listeners.start'))}</button>` ? `<button type="button" class="btn-primary btn-sm" onclick="C2.startListener('${l.id}')">▶ ${escapeHtml(c2t('c2.listeners.start'))}</button>`
: `<button class="btn-secondary btn-sm" onclick="C2.stopListener('${l.id}')">⏹ ${escapeHtml(c2t('c2.listeners.stop'))}</button>` : `<button type="button" class="btn-secondary btn-sm" onclick="C2.stopListener('${l.id}')">⏹ ${escapeHtml(c2t('c2.listeners.stop'))}</button>`
} }
<button class="btn-ghost btn-sm" onclick="C2.editListener('${l.id}')">${escapeHtml(c2t('c2.listeners.edit'))}</button> <button type="button" class="btn-secondary btn-sm" onclick="C2.editListener('${l.id}')">${escapeHtml(c2t('c2.listeners.edit'))}</button>
<button class="btn-danger btn-sm" onclick="C2.deleteListener('${l.id}')">${escapeHtml(c2t('c2.listeners.delete'))}</button> <button type="button" class="btn-danger btn-sm" onclick="C2.deleteListener('${l.id}')">${escapeHtml(c2t('c2.listeners.delete'))}</button>
</div> </div>
</div> </article>`;
`).join(''); }).join('');
}; };
C2.getListenerCallbackHost = function(l) { C2.getListenerCallbackHost = function(l) {
@@ -276,8 +391,24 @@
C2.showCreateListenerModal = function() { C2.showCreateListenerModal = function() {
const modal = document.getElementById('c2-modal'); const modal = document.getElementById('c2-modal');
const content = document.getElementById('c2-modal-content'); const content = document.getElementById('c2-modal-content');
if (!content) return; if (!content || !modal) return;
modal.style.display = 'flex';
content.innerHTML = `
<div class="c2-modal-header">
<h3>${escapeHtml(c2t('c2.listeners.modalCreateTitle'))}</h3>
<button class="c2-modal-close" onclick="C2.closeModal()">&times;</button>
</div>
<div class="c2-modal-body">
<p class="form-hint" style="margin-top:0;">${escapeHtml(c2t('c2.listeners.loadingProfiles'))}</p>
</div>
`;
C2.ensureProfilesLoaded().then(() => {
const profileOpts = listenerProfileSelectHtml('');
const emptyProfHintCreate = (C2.profiles && C2.profiles.length > 0)
? ''
: `<div class="form-hint" style="margin-bottom:6px;color:#b45309;">${escapeHtml(c2t('c2.listeners.malleableProfileEmptyListHint'))}</div>`;
content.innerHTML = ` content.innerHTML = `
<div class="c2-modal-header"> <div class="c2-modal-header">
<h3>${escapeHtml(c2t('c2.listeners.modalCreateTitle'))}</h3> <h3>${escapeHtml(c2t('c2.listeners.modalCreateTitle'))}</h3>
@@ -291,7 +422,7 @@
</div> </div>
<div class="c2-form-group"> <div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.type'))}</label> <label>${escapeHtml(c2t('c2.listeners.type'))}</label>
<select id="c2-listener-type" class="form-control"> <select id="c2-listener-type" class="form-control c2-native-select" onchange="C2.syncListenerProfileRowForType()">
<option value="http_beacon">HTTP Beacon</option> <option value="http_beacon">HTTP Beacon</option>
<option value="https_beacon">HTTPS Beacon</option> <option value="https_beacon">HTTPS Beacon</option>
<option value="tcp_reverse">TCP Reverse</option> <option value="tcp_reverse">TCP Reverse</option>
@@ -310,6 +441,12 @@
<input type="number" id="c2-listener-port" class="form-control" placeholder="8443"> <input type="number" id="c2-listener-port" class="form-control" placeholder="8443">
</div> </div>
</div> </div>
<div class="c2-form-group" id="c2-listener-profile-group">
<label>${escapeHtml(c2t('c2.listeners.malleableProfile'))}</label>
${emptyProfHintCreate}
<select id="c2-listener-profile-id" class="form-control c2-native-select">${profileOpts}</select>
<div class="form-hint">${escapeHtml(c2t('c2.listeners.malleableProfileHint'))}</div>
</div>
<div class="c2-form-group"> <div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.callbackHost'))}</label> <label>${escapeHtml(c2t('c2.listeners.callbackHost'))}</label>
<input type="text" id="c2-listener-callback-host" class="form-control" placeholder=""> <input type="text" id="c2-listener-callback-host" class="form-control" placeholder="">
@@ -325,7 +462,25 @@
<button class="btn-primary" onclick="C2.createListener()">${escapeHtml(c2t('c2.listeners.submitCreate'))}</button> <button class="btn-primary" onclick="C2.createListener()">${escapeHtml(c2t('c2.listeners.submitCreate'))}</button>
</div> </div>
`; `;
modal.style.display = 'flex'; C2.syncListenerProfileRowForType();
}).catch(() => {
showToast(c2t('c2.listeners.toastProfilesLoadFailed'), 'error');
C2.closeModal();
});
};
/** 非 HTTP/HTTPS Beacon 时隐藏 Profile 行(避免误以为 TCP 等也会用) */
C2.syncListenerProfileRowForType = function() {
const typeEl = document.getElementById('c2-listener-type');
const row = document.getElementById('c2-listener-profile-group');
if (!typeEl || !row) return;
const t = String(typeEl.value || '').toLowerCase();
const show = t === 'http_beacon' || t === 'https_beacon';
row.style.display = show ? '' : 'none';
if (!show) {
const sel = document.getElementById('c2-listener-profile-id');
if (sel) sel.value = '';
}
}; };
C2.createListener = function() { C2.createListener = function() {
@@ -341,9 +496,12 @@
return; return;
} }
const profileId = (document.getElementById('c2-listener-profile-id')?.value || '').trim();
apiRequest('POST', `${API_BASE}/listeners`, { apiRequest('POST', `${API_BASE}/listeners`, {
name, type, bind_host: bindHost, bind_port: bindPort, remark, name, type, bind_host: bindHost, bind_port: bindPort, remark,
callback_host: callbackHost callback_host: callbackHost,
profile_id: profileId
}).then(data => { }).then(data => {
if (data.error) { if (data.error) {
showToast(data.error, 'error'); showToast(data.error, 'error');
@@ -388,11 +546,31 @@
if (!l) return; if (!l) return;
const cbHost = C2.getListenerCallbackHost(l); const cbHost = C2.getListenerCallbackHost(l);
const modal = document.getElementById('c2-modal'); const modal = document.getElementById('c2-modal');
const content = document.getElementById('c2-modal-content'); const content = document.getElementById('c2-modal-content');
if (!content) return; if (!content || !modal) return;
modal.style.display = 'flex';
content.innerHTML = `
<div class="c2-modal-header">
<h3>${escapeHtml(c2t('c2.listeners.editTitle'))}</h3>
<button class="c2-modal-close" onclick="C2.closeModal()">&times;</button>
</div>
<div class="c2-modal-body">
<p class="form-hint" style="margin-top:0;">${escapeHtml(c2t('c2.listeners.loadingProfiles'))}</p>
</div>
`;
C2.ensureProfilesLoaded().then(() => {
const resolvedPid = listenerResolvedProfileId(l);
const profileOpts = listenerProfileSelectHtml(resolvedPid);
const lt = String(l.type || '').toLowerCase();
const httpHint = (lt === 'http_beacon' || lt === 'https_beacon')
? ''
: `<div class="form-hint" style="margin-bottom:6px;">${escapeHtml(c2t('c2.listeners.malleableProfileNonHttpHint'))}</div>`;
const emptyProfHint = (C2.profiles && C2.profiles.length > 0)
? ''
: `<div class="form-hint" style="margin-bottom:6px;color:#b45309;">${escapeHtml(c2t('c2.listeners.malleableProfileEmptyListHint'))}</div>`;
content.innerHTML = ` content.innerHTML = `
<div class="c2-modal-header"> <div class="c2-modal-header">
<h3>${escapeHtml(c2t('c2.listeners.editTitle'))}</h3> <h3>${escapeHtml(c2t('c2.listeners.editTitle'))}</h3>
@@ -406,13 +584,19 @@
<div class="c2-form-row"> <div class="c2-form-row">
<div class="c2-form-group"> <div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label> <label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
<input type="text" id="c2-listener-host" class="form-control" value="${l.bindHost}"> <input type="text" id="c2-listener-host" class="form-control" value="${escapeHtml(String(l.bindHost))}">
</div> </div>
<div class="c2-form-group"> <div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.bindPort'))}</label> <label>${escapeHtml(c2t('c2.listeners.bindPort'))}</label>
<input type="number" id="c2-listener-port" class="form-control" value="${l.bindPort}"> <input type="number" id="c2-listener-port" class="form-control" value="${l.bindPort}">
</div> </div>
</div> </div>
<div class="c2-form-group" id="c2-listener-profile-group">
<label>${escapeHtml(c2t('c2.listeners.malleableProfile'))}</label>
${httpHint}${emptyProfHint}
<select id="c2-listener-profile-id" class="form-control c2-native-select">${profileOpts}</select>
<div class="form-hint">${escapeHtml(c2t('c2.listeners.malleableProfileHint'))}</div>
</div>
<div class="c2-form-group"> <div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.callbackHost'))}</label> <label>${escapeHtml(c2t('c2.listeners.callbackHost'))}</label>
<input type="text" id="c2-listener-callback-host" class="form-control" value="${escapeHtml(cbHost)}"> <input type="text" id="c2-listener-callback-host" class="form-control" value="${escapeHtml(cbHost)}">
@@ -428,7 +612,10 @@
<button class="btn-primary" onclick="C2.saveListener('${l.id}')">${escapeHtml(c2t('common.save'))}</button> <button class="btn-primary" onclick="C2.saveListener('${l.id}')">${escapeHtml(c2t('common.save'))}</button>
</div> </div>
`; `;
modal.style.display = 'flex'; }).catch(() => {
showToast(c2t('c2.listeners.toastProfilesLoadFailed'), 'error');
C2.closeModal();
});
}; };
C2.saveListener = function(id) { C2.saveListener = function(id) {
@@ -437,10 +624,13 @@
const bindPort = parseInt(document.getElementById('c2-listener-port')?.value); const bindPort = parseInt(document.getElementById('c2-listener-port')?.value);
const callbackHost = document.getElementById('c2-listener-callback-host')?.value?.trim() ?? ''; const callbackHost = document.getElementById('c2-listener-callback-host')?.value?.trim() ?? '';
const remark = document.getElementById('c2-listener-remark')?.value; const remark = document.getElementById('c2-listener-remark')?.value;
const profileEl = document.getElementById('c2-listener-profile-id');
const profileId = profileEl ? String(profileEl.value || '').trim() : '';
apiRequest('PUT', `${API_BASE}/listeners/${id}`, { apiRequest('PUT', `${API_BASE}/listeners/${id}`, {
name, bind_host: bindHost, bind_port: bindPort, remark, name, bind_host: bindHost, bind_port: bindPort, remark,
callback_host: callbackHost callback_host: callbackHost,
profile_id: profileId
}).then(data => { }).then(data => {
if (data.error) showToast(data.error, 'error'); if (data.error) showToast(data.error, 'error');
else { else {
+307 -4
View File
@@ -26,6 +26,11 @@ const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟
// 对话文件上传相关(后端会拼接路径与内容发给大模型,前端不再重复发文件列表) // 对话文件上传相关(后端会拼接路径与内容发给大模型,前端不再重复发文件列表)
const MAX_CHAT_FILES = 10; const MAX_CHAT_FILES = 10;
const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。'; const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。';
/** 与 handler.formatInterruptContinueUserMessage 首段一致;主对话不展示,仅迭代详情(user_interrupt_continue */
const CHAT_INTERRUPT_CONTINUE_USER_PREFIX = '【用户补充 / 中断后继续】';
function isInterruptContinueInjectChatMessage(content) {
return typeof content === 'string' && content.trimStart().startsWith(CHAT_INTERRUPT_CONTINUE_USER_PREFIX);
}
/** /**
* 对话附件选文件后异步 POST /api/chat-uploads发送时只传 serverPath绝对路径请求体不再内联大文件内容 * 对话附件选文件后异步 POST /api/chat-uploads发送时只传 serverPath绝对路径请求体不再内联大文件内容
* @type {{ id: number, fileName: string, mimeType: string, serverPath: string|null, uploading: boolean, uploadPercent: number, uploadPromise: Promise<void>|null, uploadError: string|null }[]} * @type {{ id: number, fileName: string, mimeType: string, serverPath: string|null, uploading: boolean, uploadPercent: number, uploadPromise: Promise<void>|null, uploadError: string|null }[]}
@@ -35,6 +40,8 @@ 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 // 对话模式:react = 原生 ReAct/agent-loop);eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode'; const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
const CHAT_AGENT_MODE_REACT = 'react'; const CHAT_AGENT_MODE_REACT = 'react';
const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single'; const CHAT_AGENT_MODE_EINO_SINGLE = 'eino_single';
const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor']; const CHAT_AGENT_EINO_MODES = ['deep', 'plan_execute', 'supervisor'];
@@ -51,6 +58,28 @@ const HITL_MODE_REVIEW_EDIT = 'review_edit';
const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT]; const HITL_MODE_OPTIONS = [HITL_MODE_OFF, HITL_MODE_APPROVAL, HITL_MODE_REVIEW_EDIT];
let hitlApplyFeedbackTimer = null; let hitlApplyFeedbackTimer = null;
/** 非阻塞提示(与 chat-files-toast 样式共用) */
function showChatToast(message, type) {
const text = message == null ? '' : String(message);
if (!text) return;
const el = document.createElement('div');
el.className = 'chat-files-toast' + (type === 'error' ? ' chat-toast--error' : '');
el.setAttribute('role', 'status');
el.textContent = text;
document.body.appendChild(el);
requestAnimationFrame(function () {
el.classList.add('chat-files-toast-visible');
});
const hideMs = type === 'error' ? 4500 : 2600;
setTimeout(function () {
el.classList.remove('chat-files-toast-visible');
setTimeout(function () { el.remove(); }, 300);
}, hideMs);
}
if (typeof window !== 'undefined') {
window.showChatToast = showChatToast;
}
function normalizeOrchestrationClient(s) { function normalizeOrchestrationClient(s) {
const v = String(s || '').trim().toLowerCase().replace(/-/g, '_'); const v = String(s || '').trim().toLowerCase().replace(/-/g, '_');
if (v === 'plan_execute' || v === 'planexecute' || v === 'pe') return 'plan_execute'; if (v === 'plan_execute' || v === 'planexecute' || v === 'pe') return 'plan_execute';
@@ -293,7 +322,7 @@ function showHitlApplyFeedback(text, isError, partial) {
} }
if (!el) { if (!el) {
if (text && isError) { if (text && isError) {
alert(text); showChatToast(text, 'error');
} }
return; return;
} }
@@ -465,6 +494,132 @@ function syncAgentModeFromValue(value) {
const v = el.getAttribute('data-value'); const v = el.getAttribute('data-value');
el.classList.toggle('selected', v === value); el.classList.toggle('selected', v === value);
}); });
syncReasoningRowVisibility(value);
}
function syncReasoningRowVisibility(modeVal) {
const wrap = document.getElementById('chat-reasoning-wrapper');
if (!wrap) return;
const show = modeVal === CHAT_AGENT_MODE_EINO_SINGLE || (multiAgentAPIEnabled && chatAgentModeIsEino(modeVal));
wrap.style.display = show ? '' : 'none';
if (!show) {
closeChatReasoningPanel();
} else {
updateChatReasoningSummary();
}
}
function reasoningSummaryModeLabel(mode) {
const m = (mode || 'default').trim();
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
switch (m) {
case 'off': return t('chat.reasoningModeOff');
case 'on': return t('chat.reasoningModeOn');
case 'auto': return t('chat.reasoningModeAuto');
default: return t('chat.reasoningSummaryFollow');
}
}
function updateChatReasoningSummary() {
const el = document.getElementById('chat-reasoning-summary');
const modeEl = document.getElementById('chat-reasoning-mode');
const effEl = document.getElementById('chat-reasoning-effort');
if (!el || !modeEl) return;
const mode = (modeEl.value || 'default').trim();
const effort = effEl && effEl.value ? String(effEl.value).trim() : '';
const t = (typeof window.t === 'function') ? window.t : function (k) { return k; };
const modePart = reasoningSummaryModeLabel(mode);
const effPart = effort || t('chat.reasoningSummaryDash');
el.textContent = modePart + ' / ' + effPart;
}
function closeChatReasoningPanel() {
const panel = document.getElementById('chat-reasoning-panel');
const btn = document.getElementById('chat-reasoning-btn');
if (panel) panel.style.display = 'none';
if (btn) {
btn.classList.remove('active');
btn.setAttribute('aria-expanded', 'false');
}
}
function toggleChatReasoningPanel() {
const panel = document.getElementById('chat-reasoning-panel');
const btn = document.getElementById('chat-reasoning-btn');
if (!panel || !btn) return;
const isOpen = panel.style.display === 'flex';
if (isOpen) {
closeChatReasoningPanel();
return;
}
if (typeof closeAgentModePanel === 'function') {
closeAgentModePanel();
}
if (typeof closeRoleSelectionPanel === 'function') {
closeRoleSelectionPanel();
}
updateChatReasoningSummary();
panel.style.display = 'flex';
btn.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
}
function restoreChatReasoningControlsFromStorage() {
try {
const m = document.getElementById('chat-reasoning-mode');
const e = document.getElementById('chat-reasoning-effort');
if (m) {
const v = localStorage.getItem(REASONING_MODE_LS);
if (v && ['default', 'off', 'on', 'auto'].indexOf(v) !== -1) {
m.value = v;
}
}
if (e) {
const v = localStorage.getItem(REASONING_EFFORT_LS);
if (v !== null && ['', 'low', 'medium', 'high', 'max'].indexOf(v) !== -1) {
e.value = v;
}
}
updateChatReasoningSummary();
} catch (err) { /* ignore */ }
}
function persistChatReasoningPrefs() {
try {
const m = document.getElementById('chat-reasoning-mode');
const elEff = document.getElementById('chat-reasoning-effort');
if (m) localStorage.setItem(REASONING_MODE_LS, m.value || 'default');
if (elEff) localStorage.setItem(REASONING_EFFORT_LS, elEff.value || '');
updateChatReasoningSummary();
} catch (err) { /* ignore */ }
}
/** 供 WebShell 等复用:在 Eino 路径下返回 reasoning 请求片段或 undefined */
function buildReasoningRequestPayload() {
const wrap = document.getElementById('chat-reasoning-wrapper');
if (!wrap || wrap.style.display === 'none') {
return undefined;
}
const modeEl = document.getElementById('chat-reasoning-mode');
const effEl = document.getElementById('chat-reasoning-effort');
if (!modeEl) return undefined;
const mode = (modeEl.value || 'default').trim();
const effort = effEl && effEl.value ? String(effEl.value).trim() : '';
if (mode === 'default' && !effort) {
return undefined;
}
const o = {};
if (mode !== 'default') o.mode = mode;
if (effort) o.effort = effort;
return Object.keys(o).length ? o : undefined;
}
if (typeof window !== 'undefined') {
window.persistChatReasoningPrefs = persistChatReasoningPrefs;
window.buildReasoningRequestPayload = buildReasoningRequestPayload;
window.closeChatReasoningPanel = closeChatReasoningPanel;
window.toggleChatReasoningPanel = toggleChatReasoningPanel;
window.updateChatReasoningSummary = updateChatReasoningSummary;
} }
function closeAgentModePanel() { function closeAgentModePanel() {
@@ -486,6 +641,9 @@ function toggleAgentModePanel() {
closeAgentModePanel(); closeAgentModePanel();
return; return;
} }
if (typeof closeChatReasoningPanel === 'function') {
closeChatReasoningPanel();
}
if (typeof closeRoleSelectionPanel === 'function') { if (typeof closeRoleSelectionPanel === 'function') {
closeRoleSelectionPanel(); closeRoleSelectionPanel();
} }
@@ -536,6 +694,8 @@ async function initChatAgentModeFromConfig() {
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
sel.value = stored; sel.value = stored;
syncAgentModeFromValue(stored); syncAgentModeFromValue(stored);
restoreChatReasoningControlsFromStorage();
syncReasoningRowVisibility(stored);
} catch (e) { } catch (e) {
console.warn('initChatAgentModeFromConfig', e); console.warn('initChatAgentModeFromConfig', e);
} }
@@ -548,6 +708,9 @@ document.addEventListener('languagechange', function () {
if (v === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(v) || chatAgentModeIsEino(v)) { if (v === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(v) || chatAgentModeIsEino(v)) {
syncAgentModeFromValue(v); syncAgentModeFromValue(v);
} }
if (typeof updateChatReasoningSummary === 'function') {
updateChatReasoningSummary();
}
}); });
// 保存输入框草稿到localStorage(防抖版本) // 保存输入框草稿到localStorage(防抖版本)
@@ -733,6 +896,10 @@ async function sendMessage() {
serverPath: a.serverPath serverPath: a.serverPath
})); }));
} }
const reasoningPayload = buildReasoningRequestPayload();
if (reasoningPayload) {
body.reasoning = reasoningPayload;
}
// 发送后清空附件列表 // 发送后清空附件列表
chatAttachments = []; chatAttachments = [];
renderChatFileChips(); renderChatFileChips();
@@ -2201,6 +2368,8 @@ function renderProcessDetails(messageId, processDetails) {
} }
} else if (eventType === 'thinking') { } else if (eventType === 'thinking') {
itemTitle = agPx + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'); itemTitle = agPx + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考');
} else if (eventType === 'reasoning_chain') {
itemTitle = agPx + '🔗 ' + (typeof window.t === 'function' ? window.t('chat.reasoningChain') : '推理过程');
} else if (eventType === 'planning') { } else if (eventType === 'planning') {
if (typeof window.einoMainStreamPlanningTitle === 'function') { if (typeof window.einoMainStreamPlanningTitle === 'function') {
itemTitle = window.einoMainStreamPlanningTitle(data); itemTitle = window.einoMainStreamPlanningTitle(data);
@@ -2237,6 +2406,10 @@ function renderProcessDetails(messageId, processDetails) {
itemTitle = agPx + '🧑‍⚖️ HITL · ' + hitlMsg; itemTitle = agPx + '🧑‍⚖️ HITL · ' + hitlMsg;
} else if (eventType === 'progress') { } else if (eventType === 'progress') {
itemTitle = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(detail.message || '') : (detail.message || ''); itemTitle = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(detail.message || '') : (detail.message || '');
} else if (eventType === 'user_interrupt_continue') {
itemTitle = typeof window.t === 'function'
? window.t('chat.userInterruptContinueTitle')
: '⏸️ 用户中断并继续';
} }
addTimelineItem(timeline, eventType, { addTimelineItem(timeline, eventType, {
@@ -2445,9 +2618,26 @@ async function showMCPDetail(executionId) {
successElement.textContent = successText; successElement.textContent = successText;
} }
} }
} else {
if (normalizedStatus === 'running') {
responseElement.textContent = typeof window.t === 'function' ? window.t('mcpDetailModal.runningNoResponseYet') : '尚无返回,工具可能仍在执行。若长时间无响应,可在下方终止本次调用。';
} else { } else {
responseElement.textContent = typeof window.t === 'function' ? window.t('chat.noResponseData') : '暂无响应数据'; responseElement.textContent = typeof window.t === 'function' ? window.t('chat.noResponseData') : '暂无响应数据';
} }
}
const abortSection = document.getElementById('detail-abort-section');
const abortBtn = document.getElementById('detail-abort-btn');
if (abortSection && abortBtn) {
if (normalizedStatus === 'running') {
abortSection.style.display = 'block';
abortBtn.dataset.execId = exec.id || '';
abortBtn.textContent = typeof window.t === 'function' ? window.t('mcpDetailModal.abortBtn') : '终止工具';
} else {
abortSection.style.display = 'none';
delete abortBtn.dataset.execId;
}
}
// 显示模态框 // 显示模态框
document.getElementById('mcp-detail-modal').style.display = 'block'; document.getElementById('mcp-detail-modal').style.display = 'block';
@@ -2464,6 +2654,101 @@ function closeMCPDetail() {
document.getElementById('mcp-detail-modal').style.display = 'none'; document.getElementById('mcp-detail-modal').style.display = 'none';
} }
/** 从详情模态框触发:取消当前进行中的 MCP 工具调用 */
async function abortMCPToolExecutionFromDetail() {
const btn = document.getElementById('detail-abort-btn');
const id = btn && btn.dataset.execId;
if (!id) {
return;
}
await cancelMCPToolExecution(id, { refreshDetail: true });
}
/**
* 打开 MCP 工具终止弹窗说明会经服务端加上用户终止说明标题块后与工具输出合并给模型
* @param {string} executionId
* @param {{ refreshDetail?: boolean }} [options]
*/
function openMcpToolAbortModal(executionId, options = {}) {
window.__mcpToolAbortContext = { executionId: executionId, options: options || {} };
const ta = document.getElementById('mcp-tool-abort-note');
if (ta) {
ta.value = '';
}
const m = document.getElementById('mcp-tool-abort-modal');
if (m) {
m.style.display = 'block';
}
}
function closeMcpToolAbortModal() {
window.__mcpToolAbortContext = null;
const m = document.getElementById('mcp-tool-abort-modal');
if (m) {
m.style.display = 'none';
}
}
async function submitMcpToolAbortModal() {
const ctx = window.__mcpToolAbortContext;
if (!ctx || !ctx.executionId) {
closeMcpToolAbortModal();
return;
}
const note = (document.getElementById('mcp-tool-abort-note') && document.getElementById('mcp-tool-abort-note').value || '').trim();
const executionId = ctx.executionId;
const options = ctx.options || {};
closeMcpToolAbortModal();
await cancelMCPToolExecutionSubmit(executionId, note, options);
}
/**
* 提交终止请求body: { note }
* @param {string} executionId
* @param {string} userNote
* @param {{ refreshDetail?: boolean }} [options]
*/
async function cancelMCPToolExecutionSubmit(executionId, userNote, options = {}) {
if (!executionId) {
return;
}
try {
const res = await apiFetch(`/api/monitor/execution/${encodeURIComponent(executionId)}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: userNote || '' }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error || body.message || res.statusText);
}
const okMsg = typeof window.t === 'function' ? window.t('mcpDetailModal.abortSuccess') : '已发送终止请求';
alert(okMsg);
if (options.refreshDetail && typeof showMCPDetail === 'function') {
await showMCPDetail(executionId);
}
if (typeof refreshMonitorPanel === 'function') {
const page = (typeof monitorState !== 'undefined' && monitorState.pagination && monitorState.pagination.page) ? monitorState.pagination.page : 1;
await refreshMonitorPanel(page);
}
} catch (e) {
const failMsg = typeof window.t === 'function' ? window.t('mcpDetailModal.abortFailed') : '终止失败';
alert(failMsg + ': ' + (e && e.message ? e.message : String(e)));
}
}
/**
* 取消单次 MCP 工具执行监控页终止弹出说明框后提交仅取消该次 tools/call不停止整条对话/迭代任务
* @param {string} executionId
* @param {{ refreshDetail?: boolean }} [options]
*/
async function cancelMCPToolExecution(executionId, options = {}) {
if (!executionId) {
return;
}
openMcpToolAbortModal(executionId, options);
}
// 复制详情面板中的内容 // 复制详情面板中的内容
function copyDetailBlock(elementId, triggerBtn = null) { function copyDetailBlock(elementId, triggerBtn = null) {
const target = document.getElementById(elementId); const target = document.getElementById(elementId);
@@ -2741,7 +3026,7 @@ async function loadConversation(conversationId) {
const conversation = await response.json(); const conversation = await response.json();
if (!response.ok) { if (!response.ok) {
alert('加载对话失败: ' + (conversation.error || '未知错误')); showChatToast('加载对话失败: ' + (conversation.error || '未知错误'), 'error');
return; return;
} }
if (seq !== loadConversationRequestSeq) { if (seq !== loadConversationRequestSeq) {
@@ -2841,6 +3126,9 @@ async function loadConversation(conversationId) {
// 渲染单条消息的辅助函数 // 渲染单条消息的辅助函数
const renderOneMessage = (msg) => { const renderOneMessage = (msg) => {
if (msg.role === 'user' && isInterruptContinueInjectChatMessage(msg.content)) {
return;
}
let displayContent = msg.content; let displayContent = msg.content;
if (msg.role === 'assistant' && msg.content === '处理中...' && msg.processDetails && msg.processDetails.length > 0) { if (msg.role === 'assistant' && msg.content === '处理中...' && msg.processDetails && msg.processDetails.length > 0) {
for (let i = msg.processDetails.length - 1; i >= 0; i--) { for (let i = msg.processDetails.length - 1; i >= 0; i--) {
@@ -2852,7 +3140,11 @@ async function loadConversation(conversationId) {
} }
} }
const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msg.createdAt); // 消息时间口径:
// - user: createdAt 即可(发送后不会再更新)
// - assistant: 如果后端提供 updatedAt(任务完成时写回),优先用它,避免占位消息“任务开始时间”误导
const msgTime = (msg && msg.role === 'assistant' && msg.updatedAt) ? msg.updatedAt : (msg ? msg.createdAt : null);
const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msgTime);
const messageEl = document.getElementById(messageId); const messageEl = document.getElementById(messageId);
if (messageEl && msg && msg.id) { if (messageEl && msg && msg.id) {
messageEl.dataset.backendMessageId = String(msg.id); messageEl.dataset.backendMessageId = String(msg.id);
@@ -2945,7 +3237,7 @@ async function loadConversation(conversationId) {
} }
} catch (error) { } catch (error) {
console.error('加载对话失败:', error); console.error('加载对话失败:', error);
alert('加载对话失败: ' + error.message); showChatToast('加载对话失败: ' + (error && error.message ? error.message : String(error)), 'error');
} }
} }
@@ -6501,6 +6793,9 @@ function formatConversationAsMarkdown(conversation, options = {}) {
} }
messages.forEach((msg, index) => { messages.forEach((msg, index) => {
if (msg && msg.role === 'user' && isInterruptContinueInjectChatMessage(msg.content)) {
return;
}
const role = getConversationRoleLabel(msg && msg.role); const role = getConversationRoleLabel(msg && msg.role);
const timestamp = formatConversationDateForMarkdown(msg && msg.createdAt); const timestamp = formatConversationDateForMarkdown(msg && msg.createdAt);
const content = msg && typeof msg.content === 'string' ? msg.content : ''; const content = msg && typeof msg.content === 'string' ? msg.content : '';
@@ -7080,6 +7375,14 @@ document.addEventListener('click', function(event) {
closeAgentModePanel(); closeAgentModePanel();
} }
} }
const reasoningWrap = document.getElementById('chat-reasoning-wrapper');
const reasoningPanel = document.getElementById('chat-reasoning-panel');
if (reasoningWrap && reasoningPanel && reasoningPanel.style.display === 'flex') {
if (!reasoningWrap.contains(event.target)) {
closeChatReasoningPanel();
}
}
}); });
// 创建分组 // 创建分组
+337 -84
View File
@@ -1,4 +1,6 @@
const progressTaskState = new Map(); const progressTaskState = new Map();
/** @type {{ progressId: string, conversationId: string } | null} */
let userInterruptModalPending = null;
let activeTaskInterval = null; let activeTaskInterval = null;
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次 const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']); const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
@@ -271,6 +273,47 @@ function escapeHtmlLocal(text) {
return div.innerHTML; return div.innerHTML;
} }
/**
* internal/openai.normalizeStreamingDelta 一致兼容网关/模型返回累计全文或整包重发
* 避免前端 buffer += chunk 与后端已归一化的增量叠加导致逐段重复响应中显示了响应中显示了
* @returns {[string, string]} [nextBuffer, effectiveDelta]
*/
function normalizeStreamingDeltaJs(current, incoming) {
const cur = current == null ? '' : String(current);
const inc = incoming == null ? '' : String(incoming);
if (inc === '') {
return [cur, ''];
}
if (cur === '') {
return [inc, inc];
}
if (inc.startsWith(cur) && inc.length > cur.length) {
return [inc, inc.slice(cur.length)];
}
const runeCount = Array.from(cur).length;
if (inc === cur && runeCount > 1) {
return [cur, ''];
}
return [cur + inc, inc];
}
if (typeof window !== 'undefined') {
window.normalizeStreamingDeltaJs = normalizeStreamingDeltaJs;
}
/** 流式 delta:纯文本,避免每条全量 marked + DOMPurify */
function setTimelineItemContentStreamPlain(contentEl, text) {
if (!contentEl) return;
contentEl.classList.add('timeline-stream-plain');
contentEl.textContent = text == null ? '' : String(text);
}
/** 流结束或非流式:富文本(已消毒的 HTML 字符串) */
function setTimelineItemContentStreamRich(contentEl, html) {
if (!contentEl) return;
contentEl.classList.remove('timeline-stream-plain');
contentEl.innerHTML = html;
}
function formatAssistantMarkdownContent(text) { function formatAssistantMarkdownContent(text) {
const raw = text == null ? '' : String(text); const raw = text == null ? '' : String(text);
if (typeof marked !== 'undefined') { if (typeof marked !== 'undefined') {
@@ -354,6 +397,23 @@ function isChatMessagesPinnedToBottom() {
return scrollHeight - clientHeight - scrollTop <= CHAT_SCROLL_PIN_THRESHOLD_PX; return scrollHeight - clientHeight - scrollTop <= CHAT_SCROLL_PIN_THRESHOLD_PX;
} }
/** 顶栏「停止任务」与进度条按钮对齐时,用会话 ID 反查当前页的 progress 块 ID(无则弹窗内仍可按会话取消) */
function findProgressIdByConversationId(conversationId) {
if (!conversationId) {
return null;
}
let fallback = null;
for (const [pid, st] of progressTaskState) {
if (st && st.conversationId === conversationId) {
fallback = pid;
if (document.getElementById(pid)) {
return pid;
}
}
}
return fallback;
}
function registerProgressTask(progressId, conversationId = null) { function registerProgressTask(progressId, conversationId = null) {
const state = progressTaskState.get(progressId) || {}; const state = progressTaskState.get(progressId) || {};
state.conversationId = conversationId !== undefined && conversationId !== null state.conversationId = conversationId !== undefined && conversationId !== null
@@ -410,6 +470,140 @@ async function requestCancel(conversationId) {
return result; return result;
} }
/** 与 MCP 监控一致:仅终止当前进行中的工具调用,工具返回后本轮推理继续(可选 reason 合并进工具结果) */
async function requestCancelWithContinue(conversationId, reason) {
const response = await apiFetch('/api/agent-loop/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
conversationId,
reason: reason || '',
continueAfter: true,
}),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || (typeof window.t === 'function' ? window.t('tasks.cancelFailed') : '取消失败'));
}
return result;
}
function openUserInterruptModal(progressId, conversationId) {
userInterruptModalPending = {
progressId: progressId != null && progressId !== '' ? progressId : null,
conversationId,
};
const ta = document.getElementById('user-interrupt-reason');
if (ta) {
ta.value = '';
}
const m = document.getElementById('user-interrupt-modal');
if (m) {
m.style.display = 'block';
}
}
function closeUserInterruptModal() {
userInterruptModalPending = null;
const m = document.getElementById('user-interrupt-modal');
if (m) {
m.style.display = 'none';
}
}
async function submitUserInterruptContinue() {
if (!userInterruptModalPending) {
return;
}
const reason = (document.getElementById('user-interrupt-reason') && document.getElementById('user-interrupt-reason').value || '').trim();
const { progressId, conversationId } = userInterruptModalPending;
closeUserInterruptModal();
const stopBtn = progressId ? document.getElementById(`${progressId}-stop-btn`) : null;
try {
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.interruptSubmitting') : '提交中...';
}
await requestCancelWithContinue(conversationId, reason);
loadActiveTasks();
} catch (error) {
console.error('中断并继续失败:', error);
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '操作失败') + ': ' + error.message);
} finally {
if (stopBtn) {
stopBtn.disabled = false;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
}
}
}
async function submitUserInterruptHardCancel() {
if (!userInterruptModalPending) {
return;
}
const { progressId, conversationId } = userInterruptModalPending;
closeUserInterruptModal();
if (progressId) {
await performHardCancelProgressTask(progressId);
return;
}
if (!conversationId) {
return;
}
try {
await requestCancel(conversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
}
}
/** 彻底停止任务(原「停止任务」行为) */
async function performHardCancelProgressTask(progressId) {
const state = progressTaskState.get(progressId);
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
if (!state || !state.conversationId) {
if (stopBtn) {
stopBtn.disabled = true;
setTimeout(() => {
stopBtn.disabled = false;
}, 1500);
}
alert(typeof window.t === 'function' ? window.t('tasks.taskInfoNotSynced') : '任务信息尚未同步,请稍后再试。');
return;
}
if (state.cancelling) {
return;
}
markProgressCancelling(progressId);
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
}
try {
await requestCancel(state.conversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
if (stopBtn) {
stopBtn.disabled = false;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
}
const currentState = progressTaskState.get(progressId);
if (currentState) {
currentState.cancelling = false;
}
}
}
function addProgressMessage() { function addProgressMessage() {
const messagesDiv = document.getElementById('chat-messages'); const messagesDiv = document.getElementById('chat-messages');
const messageDiv = document.createElement('div'); const messageDiv = document.createElement('div');
@@ -590,19 +784,33 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
mcpSection.appendChild(buttonsContainer); mcpSection.appendChild(buttonsContainer);
} }
const hasExecBtns = buttonsContainer.querySelector('.mcp-detail-btn:not(.process-detail-btn)'); let maxExecIndex = 0;
if (mcpIds.length > 0 && !hasExecBtns) { const existingExecBtns = buttonsContainer.querySelectorAll('.mcp-detail-btn:not(.process-detail-btn)');
mcpIds.forEach((execId, index) => { existingExecBtns.forEach(function (btn) {
const n = parseInt(btn.dataset.execIndex, 10);
if (!isNaN(n) && n > maxExecIndex) maxExecIndex = n;
});
const seenExec = new Set();
existingExecBtns.forEach(function (btn) {
if (btn.dataset.execId) seenExec.add(String(btn.dataset.execId).trim());
});
let appendedAny = false;
if (mcpIds.length > 0) {
mcpIds.forEach(function (execId) {
const id = execId != null ? String(execId).trim() : '';
if (!id || seenExec.has(id)) return;
seenExec.add(id);
maxExecIndex += 1;
appendedAny = true;
const detailBtn = document.createElement('button'); const detailBtn = document.createElement('button');
detailBtn.className = 'mcp-detail-btn'; detailBtn.className = 'mcp-detail-btn';
detailBtn.dataset.execId = execId; detailBtn.dataset.execId = id;
detailBtn.dataset.execIndex = String(index + 1); detailBtn.dataset.execIndex = String(maxExecIndex);
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>'; detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: maxExecIndex }) : '调用 #' + maxExecIndex) + '</span>';
detailBtn.onclick = () => showMCPDetail(execId); detailBtn.onclick = function () { showMCPDetail(id); };
buttonsContainer.appendChild(detailBtn); buttonsContainer.appendChild(detailBtn);
}); });
// 使用批量 API 一次性获取所有工具名称(消除 N 次单独请求) if (appendedAny && typeof batchUpdateButtonToolNames === 'function') {
if (typeof batchUpdateButtonToolNames === 'function') {
batchUpdateButtonToolNames(buttonsContainer, mcpIds); batchUpdateButtonToolNames(buttonsContainer, mcpIds);
} }
} }
@@ -737,7 +945,7 @@ function toggleProcessDetails(progressId, assistantMessageId) {
} }
} }
// 停止当前进度对应的任务 // 停止当前进度:弹出「中断并说明 / 彻底停止」
async function cancelProgressTask(progressId) { async function cancelProgressTask(progressId) {
const state = progressTaskState.get(progressId); const state = progressTaskState.get(progressId);
const stopBtn = document.getElementById(`${progressId}-stop-btn`); const stopBtn = document.getElementById(`${progressId}-stop-btn`);
@@ -757,27 +965,7 @@ async function cancelProgressTask(progressId) {
return; return;
} }
markProgressCancelling(progressId); openUserInterruptModal(progressId, state.conversationId);
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
}
try {
await requestCancel(state.conversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
if (stopBtn) {
stopBtn.disabled = false;
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
}
const currentState = progressTaskState.get(progressId);
if (currentState) {
currentState.cancelling = false;
}
}
} }
// 将进度消息转换为可折叠的详情组件 // 将进度消息转换为可折叠的详情组件
@@ -905,6 +1093,24 @@ function resolveStreamTimeline(progressId) {
return timeline; return timeline;
} }
/** 去重合并 MCP execution id(顺序:先 prev 后 next),用于多段 Run / 多次 SSE 同一任务。 */
function mergeMcpExecutionIDLists(prev, next) {
const seen = new Set();
const out = [];
const add = function (arr) {
if (!Array.isArray(arr)) return;
for (let i = 0; i < arr.length; i++) {
const s = arr[i] != null ? String(arr[i]).trim() : '';
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
};
add(prev);
add(next);
return out;
}
// 处理流式事件 // 处理流式事件
function handleStreamEvent(event, progressElement, progressId, function handleStreamEvent(event, progressElement, progressId,
getAssistantId, setAssistantId, getMcpIds, setMcpIds) { getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
@@ -1017,20 +1223,38 @@ function handleStreamEvent(event, progressElement, progressId,
break; break;
} }
case 'thinking_stream_start': { case 'thinking_stream_start':
case 'reasoning_chain_stream_start': {
const d = event.data || {}; const d = event.data || {};
const streamId = d.streamId || null; const streamId = d.streamId || null;
if (!streamId) break; if (!streamId) break;
const timelineType = event.type === 'reasoning_chain_stream_start' ? 'reasoning_chain' : 'thinking';
let state = thinkingStreamStateByProgressId.get(progressId); let state = thinkingStreamStateByProgressId.get(progressId);
if (!state) { if (!state) {
state = new Map(); state = new Map();
thinkingStreamStateByProgressId.set(progressId, state); thinkingStreamStateByProgressId.set(progressId, state);
} }
// 若已存在,重置 buffer // 同一 streamId 重复 start:复用已有条目,避免孤儿卡片 + 新条目重复收 delta
const thinkBase = typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'; if (state.has(streamId)) {
const title = timelineAgentBracketPrefix(d) + '🤔 ' + thinkBase; const ex = state.get(streamId);
const itemId = addTimelineItem(timeline, 'thinking', { ex.buffer = '';
const existingItem = document.getElementById(ex.itemId);
if (existingItem) {
const contentEl = existingItem.querySelector('.timeline-item-content');
if (contentEl) {
setTimelineItemContentStreamPlain(contentEl, '');
}
}
break;
}
const labelBase = typeof window.t === 'function'
? window.t(timelineType === 'reasoning_chain' ? 'chat.reasoningChain' : 'chat.aiThinking')
: (timelineType === 'reasoning_chain' ? '推理过程' : 'AI思考');
const emoji = timelineType === 'reasoning_chain' ? '🔗' : '🤔';
const title = timelineAgentBracketPrefix(d) + emoji + ' ' + labelBase;
const itemId = addTimelineItem(timeline, timelineType, {
title: title, title: title,
message: ' ', message: ' ',
data: d data: d
@@ -1039,7 +1263,8 @@ function handleStreamEvent(event, progressElement, progressId,
break; break;
} }
case 'thinking_stream_delta': { case 'thinking_stream_delta':
case 'reasoning_chain_stream_delta': {
const d = event.data || {}; const d = event.data || {};
const streamId = d.streamId || null; const streamId = d.streamId || null;
if (!streamId) break; if (!streamId) break;
@@ -1049,24 +1274,23 @@ function handleStreamEvent(event, progressElement, progressId,
const s = state.get(streamId); const s = state.get(streamId);
const delta = event.message || ''; const delta = event.message || '';
s.buffer += delta; const merged = normalizeStreamingDeltaJs(s.buffer, delta);
s.buffer = merged[0];
const item = document.getElementById(s.itemId); const item = document.getElementById(s.itemId);
if (item) { if (item) {
const contentEl = item.querySelector('.timeline-item-content'); const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) { if (contentEl) {
if (typeof formatMarkdown === 'function') { setTimelineItemContentStreamPlain(contentEl, s.buffer);
contentEl.innerHTML = formatMarkdown(s.buffer);
} else {
contentEl.textContent = s.buffer;
}
} }
} }
break; break;
} }
case 'thinking': case 'thinking':
// 如果本 thinking 是由 thinking_stream_* 聚合出来的(带 streamId),避免重复创建 timeline item case 'reasoning_chain': {
const timelineType = event.type === 'reasoning_chain' ? 'reasoning_chain' : 'thinking';
// 若已由 *_stream_* 聚合(带 streamId),避免重复创建 timeline item
if (event.data && event.data.streamId) { if (event.data && event.data.streamId) {
const streamId = event.data.streamId; const streamId = event.data.streamId;
const state = thinkingStreamStateByProgressId.get(progressId); const state = thinkingStreamStateByProgressId.get(progressId);
@@ -1077,11 +1301,10 @@ function handleStreamEvent(event, progressElement, progressId,
if (item) { if (item) {
const contentEl = item.querySelector('.timeline-item-content'); const contentEl = item.querySelector('.timeline-item-content');
if (contentEl) { if (contentEl) {
// contentEl.innerHTML 用于兼容 Markdown 展示
if (typeof formatMarkdown === 'function') { if (typeof formatMarkdown === 'function') {
contentEl.innerHTML = formatMarkdown(s.buffer); setTimelineItemContentStreamRich(contentEl, formatMarkdown(s.buffer));
} else { } else {
contentEl.textContent = s.buffer; setTimelineItemContentStreamPlain(contentEl, s.buffer);
} }
} }
} }
@@ -1089,12 +1312,17 @@ function handleStreamEvent(event, progressElement, progressId,
} }
} }
addTimelineItem(timeline, 'thinking', { const labelBase = typeof window.t === 'function'
title: timelineAgentBracketPrefix(event.data) + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'), ? window.t(timelineType === 'reasoning_chain' ? 'chat.reasoningChain' : 'chat.aiThinking')
: (timelineType === 'reasoning_chain' ? '推理过程' : 'AI思考');
const emoji = timelineType === 'reasoning_chain' ? '🔗' : '🤔';
addTimelineItem(timeline, timelineType, {
title: timelineAgentBracketPrefix(event.data) + emoji + ' ' + labelBase,
message: event.message, message: event.message,
data: event.data data: event.data
}); });
break; break;
}
case 'tool_calls_detected': case 'tool_calls_detected':
addTimelineItem(timeline, 'tool_calls_detected', { addTimelineItem(timeline, 'tool_calls_detected', {
@@ -1138,6 +1366,19 @@ function handleStreamEvent(event, progressElement, progressId,
}); });
break; break;
case 'user_interrupt_continue': {
const d = event.data || {};
const titleBase = typeof window.t === 'function'
? window.t('chat.userInterruptContinueTitle')
: '⏸️ 用户中断并继续';
addTimelineItem(timeline, 'user_interrupt_continue', {
title: titleBase,
message: event.message || '',
data: d
});
break;
}
case 'eino_stream_error': { case 'eino_stream_error': {
const d = event.data || {}; const d = event.data || {};
const agent = d.einoAgent ? String(d.einoAgent) : ''; const agent = d.einoAgent ? String(d.einoAgent) : '';
@@ -1323,6 +1564,18 @@ function handleStreamEvent(event, progressElement, progressId,
stateMap = new Map(); stateMap = new Map();
einoAgentReplyStreamStateByProgressId.set(progressId, stateMap); einoAgentReplyStreamStateByProgressId.set(progressId, stateMap);
} }
if (stateMap.has(streamId)) {
const ex = stateMap.get(streamId);
ex.buffer = '';
const existingItem = document.getElementById(ex.itemId);
if (existingItem) {
let contentEl = existingItem.querySelector('.timeline-item-content');
if (contentEl) {
setTimelineItemContentStreamPlain(contentEl, '');
}
}
break;
}
const streamingLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...'; const streamingLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...';
const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; const replyTitleBase = typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复';
const itemId = addTimelineItem(timeline, 'eino_agent_reply', { const itemId = addTimelineItem(timeline, 'eino_agent_reply', {
@@ -1344,7 +1597,8 @@ function handleStreamEvent(event, progressElement, progressId,
const stateMap = einoAgentReplyStreamStateByProgressId.get(progressId); const stateMap = einoAgentReplyStreamStateByProgressId.get(progressId);
if (!stateMap || !stateMap.has(streamId)) break; if (!stateMap || !stateMap.has(streamId)) break;
const s = stateMap.get(streamId); const s = stateMap.get(streamId);
s.buffer += delta; const merged = normalizeStreamingDeltaJs(s.buffer, delta);
s.buffer = merged[0];
const item = document.getElementById(s.itemId); const item = document.getElementById(s.itemId);
if (item) { if (item) {
let contentEl = item.querySelector('.timeline-item-content'); let contentEl = item.querySelector('.timeline-item-content');
@@ -1357,11 +1611,7 @@ function handleStreamEvent(event, progressElement, progressId,
} }
} }
if (contentEl) { if (contentEl) {
if (typeof formatMarkdown === 'function') { setTimelineItemContentStreamPlain(contentEl, s.buffer);
contentEl.innerHTML = formatMarkdown(s.buffer);
} else {
contentEl.textContent = s.buffer;
}
} }
} }
break; break;
@@ -1389,9 +1639,9 @@ function handleStreamEvent(event, progressElement, progressId,
item.appendChild(contentEl); item.appendChild(contentEl);
} }
if (typeof formatMarkdown === 'function') { if (typeof formatMarkdown === 'function') {
contentEl.innerHTML = formatMarkdown(full); setTimelineItemContentStreamRich(contentEl, formatMarkdown(full));
} else { } else {
contentEl.textContent = full; setTimelineItemContentStreamPlain(contentEl, full);
} }
if (d.einoAgent != null && String(d.einoAgent).trim() !== '') { if (d.einoAgent != null && String(d.einoAgent).trim() !== '') {
item.dataset.einoAgent = String(d.einoAgent).trim(); item.dataset.einoAgent = String(d.einoAgent).trim();
@@ -1481,7 +1731,7 @@ function handleStreamEvent(event, progressElement, progressId,
const responseData = event.data || {}; const responseData = event.data || {};
const mcpIds = responseData.mcpExecutionIds || []; const mcpIds = responseData.mcpExecutionIds || [];
setMcpIds(mcpIds); setMcpIds(mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], mcpIds));
if (responseData.conversationId) { if (responseData.conversationId) {
// 如果用户已经开始了新对话(currentConversationId 为 null),且这个事件来自旧对话,则忽略 // 如果用户已经开始了新对话(currentConversationId 为 null),且这个事件来自旧对话,则忽略
@@ -1532,7 +1782,8 @@ function handleStreamEvent(event, progressElement, progressId,
} }
const deltaContent = event.message || ''; const deltaContent = event.message || '';
state.buffer += deltaContent; const mergedResp = normalizeStreamingDeltaJs(state.buffer, deltaContent);
state.buffer = mergedResp[0];
// 更新时间线条目内容 // 更新时间线条目内容
if (state.itemId) { if (state.itemId) {
@@ -1542,11 +1793,7 @@ function handleStreamEvent(event, progressElement, progressId,
if (contentEl) { if (contentEl) {
const meta = state.streamMeta || responseData; const meta = state.streamMeta || responseData;
const body = formatTimelineStreamBody(state.buffer, meta); const body = formatTimelineStreamBody(state.buffer, meta);
if (typeof formatMarkdown === 'function') { setTimelineItemContentStreamPlain(contentEl, body);
contentEl.innerHTML = formatMarkdown(body);
} else {
contentEl.textContent = body;
}
} }
} }
} }
@@ -1560,7 +1807,7 @@ function handleStreamEvent(event, progressElement, progressId,
// 先更新 mcp ids // 先更新 mcp ids
const responseData = event.data || {}; const responseData = event.data || {};
const mcpIds = responseData.mcpExecutionIds || []; const mcpIds = mergeMcpExecutionIDLists(typeof getMcpIds === 'function' ? (getMcpIds() || []) : [], responseData.mcpExecutionIds || []);
setMcpIds(mcpIds); setMcpIds(mcpIds);
// 更新对话ID // 更新对话ID
@@ -2084,7 +2331,7 @@ async function attachRunningTaskEventStream(conversationId) {
if (line.indexOf('data: ') === 0) { if (line.indexOf('data: ') === 0) {
try { try {
const eventData = JSON.parse(line.slice(6)); const eventData = JSON.parse(line.slice(6));
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = ids; }); handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
} catch (e) { } catch (e) {
console.error('task-events parse', e); console.error('task-events parse', e);
} }
@@ -2242,7 +2489,7 @@ function addTimelineItem(timeline, type, options) {
`; `;
// 根据类型添加详细内容 // 根据类型添加详细内容
if ((type === 'thinking' || type === 'planning') && options.message) { if ((type === 'thinking' || type === 'reasoning_chain' || type === 'planning') && options.message) {
const streamBody = typeof formatTimelineStreamBody === 'function' const streamBody = typeof formatTimelineStreamBody === 'function'
? formatTimelineStreamBody(options.message, options.data) ? formatTimelineStreamBody(options.message, options.data)
: options.message; : options.message;
@@ -2297,6 +2544,11 @@ function addTimelineItem(timeline, type, options) {
${escapeHtml(options.message || taskCancelledLabel)} ${escapeHtml(options.message || taskCancelledLabel)}
</div> </div>
`; `;
} else if (type === 'user_interrupt_continue' && options.message) {
const streamBody = typeof formatTimelineStreamBody === 'function'
? formatTimelineStreamBody(options.message, options.data)
: options.message;
content += `<div class="timeline-item-content">${formatMarkdown(streamBody)}</div>`;
} }
item.innerHTML = content; item.innerHTML = content;
@@ -2417,7 +2669,7 @@ function renderActiveTasks(tasks) {
if (cancelBtn) { if (cancelBtn) {
cancelBtn.onclick = (evt) => { cancelBtn.onclick = (evt) => {
evt.stopPropagation(); evt.stopPropagation();
cancelActiveTask(task.conversationId, cancelBtn); cancelActiveTask(task.conversationId);
}; };
if (task.status === 'cancelling') { if (task.status === 'cancelling') {
cancelBtn.disabled = true; cancelBtn.disabled = true;
@@ -2430,21 +2682,12 @@ function renderActiveTasks(tasks) {
}); });
} }
async function cancelActiveTask(conversationId, button) { function cancelActiveTask(conversationId) {
if (!conversationId) return; if (!conversationId) {
const originalText = button.textContent; return;
button.disabled = true;
button.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
try {
await requestCancel(conversationId);
loadActiveTasks();
} catch (error) {
console.error('取消任务失败:', error);
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
button.disabled = false;
button.textContent = originalText;
} }
const progressId = findProgressIdByConversationId(conversationId);
openUserInterruptModal(progressId, conversationId);
} }
let monitorPanelFetchSeq = 0; let monitorPanelFetchSeq = 0;
@@ -2777,7 +3020,8 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情'; const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情';
const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除'; const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除';
const deleteExecTitle = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecTitle') : '删除此执行记录'; const deleteExecTitle = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecTitle') : '删除此执行记录';
const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed' }; const terminateLabel = typeof window.t === 'function' ? window.t('mcpMonitor.terminateExecution') : '终止';
const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed', cancelled: 'statusCancelled' };
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined; const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
const rows = executions const rows = executions
.map(exec => { .map(exec => {
@@ -2788,7 +3032,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel; const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
const duration = formatExecutionDuration(exec.startTime, exec.endTime); const duration = formatExecutionDuration(exec.startTime, exec.endTime);
const toolName = escapeHtml(exec.toolName || unknownToolLabel); const toolName = escapeHtml(exec.toolName || unknownToolLabel);
const executionId = escapeHtml(exec.id || ''); const rawExecId = exec.id || '';
const executionId = escapeHtml(rawExecId);
const terminateBtn = status === 'running'
? `<button type="button" class="btn-secondary btn-monitor-abort" onclick="cancelMCPToolExecution('${rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')">${escapeHtml(terminateLabel)}</button>`
: '';
return ` return `
<tr> <tr>
<td> <td>
@@ -2801,6 +3049,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
<td> <td>
<div class="monitor-execution-actions"> <div class="monitor-execution-actions">
<button class="btn-secondary" onclick="showMCPDetail('${executionId}')">${escapeHtml(viewDetailLabel)}</button> <button class="btn-secondary" onclick="showMCPDetail('${executionId}')">${escapeHtml(viewDetailLabel)}</button>
${terminateBtn}
<button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button> <button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button>
</div> </div>
</td> </td>
@@ -3175,6 +3424,8 @@ function refreshProgressAndTimelineI18n() {
} else { } else {
titleSpan.textContent = ap + '\uD83E\uDD14 ' + _t('chat.aiThinking'); titleSpan.textContent = ap + '\uD83E\uDD14 ' + _t('chat.aiThinking');
} }
} else if (type === 'reasoning_chain') {
titleSpan.textContent = ap + '\uD83D\uDD17 ' + _t('chat.reasoningChain');
} else if (type === 'planning') { } else if (type === 'planning') {
if (item.dataset.orchestration && typeof einoMainStreamPlanningTitle === 'function') { if (item.dataset.orchestration && typeof einoMainStreamPlanningTitle === 'function') {
titleSpan.textContent = einoMainStreamPlanningTitle({ titleSpan.textContent = einoMainStreamPlanningTitle({
@@ -3201,6 +3452,8 @@ function refreshProgressAndTimelineI18n() {
titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle'); titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle');
} else if (type === 'cancelled') { } else if (type === 'cancelled') {
titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled'); titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled');
} else if (type === 'user_interrupt_continue') {
titleSpan.textContent = _t('chat.userInterruptContinueTitle');
} else if (type === 'progress' && item.dataset.progressMessage !== undefined) { } else if (type === 'progress' && item.dataset.progressMessage !== undefined) {
titleSpan.textContent = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(item.dataset.progressMessage) : item.dataset.progressMessage; titleSpan.textContent = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(item.dataset.progressMessage) : item.dataset.progressMessage;
} }
+3
View File
@@ -256,6 +256,9 @@ function toggleRoleSelectionPanel() {
if (typeof closeAgentModePanel === 'function') { if (typeof closeAgentModePanel === 'function') {
closeAgentModePanel(); closeAgentModePanel();
} }
if (typeof closeChatReasoningPanel === 'function') {
closeChatReasoningPanel();
}
panel.style.display = 'flex'; // 使用flex布局 panel.style.display = 'flex'; // 使用flex布局
// 添加打开状态的视觉反馈 // 添加打开状态的视觉反馈
if (roleSelectorBtn) { if (roleSelectorBtn) {
+3
View File
@@ -65,6 +65,9 @@ function initRouter() {
// 切换页面 // 切换页面
function switchPage(pageId) { function switchPage(pageId) {
if (typeof window.syncC2NavOnceFromServer === 'function') {
void window.syncC2NavOnceFromServer();
}
// 隐藏所有页面 // 隐藏所有页面
document.querySelectorAll('.page').forEach(page => { document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active'); page.classList.remove('active');
+88 -1
View File
@@ -29,6 +29,42 @@ let toolsPagination = {
totalPages: 0 totalPages: 0
}; };
let c2NavSyncedOnce = false;
/** 首次进入仪表盘等页面前拉一次配置,隐藏侧栏 C2(避免禁用后仍显示) */
window.syncC2NavOnceFromServer = async function syncC2NavOnceFromServer() {
if (c2NavSyncedOnce || typeof apiFetch === 'undefined') {
return;
}
c2NavSyncedOnce = true;
try {
const r = await apiFetch('/api/config');
if (r.ok) {
const cfg = await r.json();
syncC2NavFromConfig(cfg);
}
} catch (_) {
/* ignore */
}
};
// 根据 C2 是否启用显示主导航 C2 入口与仪表盘 C2 区块(与 /api/config 的 c2.enabled 一致)
function syncC2NavFromConfig(cfg) {
const on = cfg && cfg.c2 && cfg.c2.enabled !== false;
const nav = document.getElementById('nav-c2');
if (nav) {
nav.style.display = on ? '' : 'none';
}
const dash = document.getElementById('dashboard-section-c2');
if (dash) {
if (!on) {
dash.hidden = true;
} else {
dash.removeAttribute('hidden');
}
}
}
// 切换设置分类 // 切换设置分类
function switchSettingsSection(section) { function switchSettingsSection(section) {
// 更新导航项状态 // 更新导航项状态
@@ -123,6 +159,27 @@ async function loadConfig(loadTools = true) {
if (maxTokensEl) { if (maxTokensEl) {
maxTokensEl.value = currentConfig.openai.max_total_tokens || 120000; maxTokensEl.value = currentConfig.openai.max_total_tokens || 120000;
} }
const orm = currentConfig.openai && currentConfig.openai.reasoning ? currentConfig.openai.reasoning : {};
const orModeEl = document.getElementById('openai-reasoning-mode');
if (orModeEl) {
const mv = (orm.mode || 'auto').toString().trim().toLowerCase();
orModeEl.value = ['auto', 'on', 'off'].includes(mv) ? mv : 'auto';
}
const orEffEl = document.getElementById('openai-reasoning-effort');
if (orEffEl) {
const ev = (orm.effort || '').toString().trim().toLowerCase();
orEffEl.value = ['', 'low', 'medium', 'high', 'max'].includes(ev) ? ev : '';
}
const orProfEl = document.getElementById('openai-reasoning-profile');
if (orProfEl) {
const pv = (orm.profile || 'auto').toString().trim().toLowerCase();
const ok = ['auto', 'deepseek_compat', 'openai_compat', 'output_config_effort'];
orProfEl.value = ok.includes(pv) ? pv : 'auto';
}
const orAllowEl = document.getElementById('openai-reasoning-allow-client');
if (orAllowEl) {
orAllowEl.checked = orm.allow_client_reasoning !== false;
}
// 填充FOFA配置 // 填充FOFA配置
const fofa = currentConfig.fofa || {}; const fofa = currentConfig.fofa || {};
@@ -274,6 +331,12 @@ async function loadConfig(loadTools = true) {
} }
} }
const c2EnabledCb = document.getElementById('c2-enabled');
if (c2EnabledCb) {
c2EnabledCb.checked = currentConfig.c2?.enabled !== false;
}
syncC2NavFromConfig(currentConfig);
// 填充机器人配置 // 填充机器人配置
const robots = currentConfig.robots || {}; const robots = currentConfig.robots || {};
const wecom = robots.wecom || {}; const wecom = robots.wecom || {};
@@ -975,6 +1038,9 @@ async function applySettings() {
const knowledgeEnabled = knowledgeEnabledCheckbox ? knowledgeEnabledCheckbox.checked : true; const knowledgeEnabled = knowledgeEnabledCheckbox ? knowledgeEnabledCheckbox.checked : true;
// 收集知识库配置 // 收集知识库配置
const c2EnabledCheckbox = document.getElementById('c2-enabled');
const c2Enabled = c2EnabledCheckbox ? c2EnabledCheckbox.checked : true;
const knowledgeConfig = { const knowledgeConfig = {
enabled: knowledgeEnabled, enabled: knowledgeEnabled,
base_path: document.getElementById('knowledge-base-path')?.value.trim() || 'knowledge_base', base_path: document.getElementById('knowledge-base-path')?.value.trim() || 'knowledge_base',
@@ -1020,13 +1086,22 @@ async function applySettings() {
}; };
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim(); const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
const prevOpenai = (currentConfig && currentConfig.openai) ? currentConfig.openai : {};
const config = { const config = {
openai: { openai: {
...prevOpenai,
provider: provider, provider: provider,
api_key: apiKey, api_key: apiKey,
base_url: baseUrl, base_url: baseUrl,
model: model, model: model,
max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value) || 120000 max_total_tokens: parseInt(document.getElementById('openai-max-total-tokens')?.value) || 120000,
reasoning: {
...(prevOpenai.reasoning || {}),
mode: document.getElementById('openai-reasoning-mode')?.value || 'auto',
effort: (document.getElementById('openai-reasoning-effort')?.value || '').trim(),
profile: document.getElementById('openai-reasoning-profile')?.value || 'auto',
allow_client_reasoning: document.getElementById('openai-reasoning-allow-client')?.checked !== false
}
}, },
fofa: { fofa: {
email: document.getElementById('fofa-email')?.value.trim() || '', email: document.getElementById('fofa-email')?.value.trim() || '',
@@ -1048,6 +1123,9 @@ async function applySettings() {
}; };
})(), })(),
knowledge: knowledgeConfig, knowledge: knowledgeConfig,
c2: {
enabled: c2Enabled
},
robots: { robots: {
wecom: { wecom: {
enabled: document.getElementById('robot-wecom-enabled')?.checked === true, enabled: document.getElementById('robot-wecom-enabled')?.checked === true,
@@ -1174,6 +1252,15 @@ async function applySettings() {
? window.t('settings.apply.applySuccess') ? window.t('settings.apply.applySuccess')
: '配置已成功应用!'; : '配置已成功应用!';
alert(successMsg); alert(successMsg);
try {
const cfgResp = await apiFetch('/api/config');
if (cfgResp.ok) {
const fresh = await cfgResp.json();
syncC2NavFromConfig(fresh);
}
} catch (e) {
console.warn('refresh C2 nav after apply', e);
}
try { try {
if (typeof initChatAgentModeFromConfig === 'function') { if (typeof initChatAgentModeFromConfig === 'function') {
await initChatAgentModeFromConfig(); await initChatAgentModeFromConfig();
+46 -14
View File
@@ -1658,6 +1658,8 @@ function buildWebshellTimelineItemFromDetail(detail) {
title = ap + ((typeof window.t === 'function') ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : ('第 ' + (data.iteration || 1) + ' 轮迭代')); title = ap + ((typeof window.t === 'function') ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : ('第 ' + (data.iteration || 1) + ' 轮迭代'));
} else if (eventType === 'thinking') { } else if (eventType === 'thinking') {
title = ap + '🤔 ' + ((typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考'); title = ap + '🤔 ' + ((typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考');
} else if (eventType === 'reasoning_chain') {
title = ap + '🔗 ' + ((typeof window.t === 'function') ? window.t('chat.reasoningChain') : '推理过程');
} else if (eventType === 'tool_calls_detected') { } else if (eventType === 'tool_calls_detected') {
title = ap + '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : ('检测到 ' + (data.count || 0) + ' 个工具调用')); title = ap + '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : ('检测到 ' + (data.count || 0) + ' 个工具调用'));
} else if (eventType === 'tool_call') { } else if (eventType === 'tool_call') {
@@ -2847,6 +2849,12 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
if (info && info.orchestration) { if (info && info.orchestration) {
body.orchestration = info.orchestration; body.orchestration = info.orchestration;
} }
if (typeof window.buildReasoningRequestPayload === 'function') {
var rp = window.buildReasoningRequestPayload();
if (rp) {
body.reasoning = rp;
}
}
return apiFetch(info.path, { return apiFetch(info.path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -2898,7 +2906,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
} else if (_et === 'response_delta') { } else if (_et === 'response_delta') {
var deltaText = (_em != null && _em !== '') ? String(_em) : ''; var deltaText = (_em != null && _em !== '') ? String(_em) : '';
if (deltaText) { if (deltaText) {
streamingTarget += deltaText; var normR = (typeof window.normalizeStreamingDeltaJs === 'function')
? window.normalizeStreamingDeltaJs(streamingTarget, deltaText)
: [streamingTarget + deltaText, deltaText];
streamingTarget = normR[0];
webshellStreamingTypingId += 1; webshellStreamingTypingId += 1;
streamingTypingId = webshellStreamingTypingId; streamingTypingId = webshellStreamingTypingId;
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer); runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
@@ -2950,23 +2961,33 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, _ed); appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, _ed);
if (!streamingTarget) assistantDiv.textContent = '…'; if (!streamingTarget) assistantDiv.textContent = '…';
// ─── Thinking (non-stream + stream) ─── // ─── Thinking / reasoning_chain(推理过程,reasoning_content ───
} else if (_et === 'thinking_stream_start' && _ed.streamId) { } else if ((_et === 'thinking_stream_start' || _et === 'reasoning_chain_stream_start') && _ed.streamId) {
var thinkSLabel = wsTOr('chat.aiThinking', 'AI 思考'); var isRcStart = _et === 'reasoning_chain_stream_start';
if (wsThinkingStreams.has(_ed.streamId)) {
var tsExist = wsThinkingStreams.get(_ed.streamId);
tsExist.buf = '';
if (tsExist.body) tsExist.body.textContent = '';
} else {
var thinkSLabel = wsTOr(isRcStart ? 'chat.reasoningChain' : 'chat.aiThinking', isRcStart ? '推理过程' : 'AI 思考');
var thinkEmoji = isRcStart ? '🔗' : '🤔';
var thinkSItem = document.createElement('div'); var thinkSItem = document.createElement('div');
thinkSItem.className = 'webshell-ai-timeline-item webshell-ai-timeline-thinking'; thinkSItem.className = 'webshell-ai-timeline-item webshell-ai-timeline-' + (isRcStart ? 'reasoning_chain' : 'thinking');
thinkSItem.innerHTML = '<span class="webshell-ai-timeline-title">' + escapeHtml(webshellAgentPx(_ed) + '🤔 ' + thinkSLabel) + '</span>'; thinkSItem.innerHTML = '<span class="webshell-ai-timeline-title">' + escapeHtml(webshellAgentPx(_ed) + thinkEmoji + ' ' + thinkSLabel) + '</span>';
var thinkSPre = document.createElement('div'); var thinkSPre = document.createElement('div');
thinkSPre.className = 'webshell-ai-timeline-msg webshell-thinking-stream-body'; thinkSPre.className = 'webshell-ai-timeline-msg webshell-thinking-stream-body';
thinkSItem.appendChild(thinkSPre); thinkSItem.appendChild(thinkSPre);
timelineContainer.appendChild(thinkSItem); timelineContainer.appendChild(thinkSItem);
timelineContainer.classList.add('has-items'); timelineContainer.classList.add('has-items');
wsThinkingStreams.set(_ed.streamId, { el: thinkSItem, body: thinkSPre, buf: '' }); wsThinkingStreams.set(_ed.streamId, { el: thinkSItem, body: thinkSPre, buf: '' });
}
if (!streamingTarget) assistantDiv.textContent = '…'; if (!streamingTarget) assistantDiv.textContent = '…';
} else if (_et === 'thinking_stream_delta' && _ed.streamId) { } else if ((_et === 'thinking_stream_delta' || _et === 'reasoning_chain_stream_delta') && _ed.streamId) {
var tsD = wsThinkingStreams.get(_ed.streamId); var tsD = wsThinkingStreams.get(_ed.streamId);
if (tsD) { if (tsD) {
tsD.buf += (_em || ''); var normT = (typeof window.normalizeStreamingDeltaJs === 'function')
? window.normalizeStreamingDeltaJs(tsD.buf, _em || '') : [tsD.buf + (_em || ''), _em || ''];
tsD.buf = normT[0];
if (typeof formatMarkdown === 'function') { if (typeof formatMarkdown === 'function') {
tsD.body.innerHTML = formatMarkdown(tsD.buf); tsD.body.innerHTML = formatMarkdown(tsD.buf);
} else { } else {
@@ -2974,7 +2995,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
} }
} }
if (!streamingTarget) assistantDiv.textContent = '…'; if (!streamingTarget) assistantDiv.textContent = '…';
} else if (_et === 'thinking_stream_end' && _ed.streamId) { } else if ((_et === 'thinking_stream_end' || _et === 'reasoning_chain_stream_end') && _ed.streamId) {
var tsE = wsThinkingStreams.get(_ed.streamId); var tsE = wsThinkingStreams.get(_ed.streamId);
if (tsE) { if (tsE) {
var fullThink = (_em != null && _em !== '') ? String(_em) : tsE.buf; var fullThink = (_em != null && _em !== '') ? String(_em) : tsE.buf;
@@ -2985,13 +3006,15 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
} }
wsThinkingStreams.delete(_ed.streamId); wsThinkingStreams.delete(_ed.streamId);
} }
} else if (_et === 'thinking' && _em) { } else if ((_et === 'thinking' || _et === 'reasoning_chain') && _em) {
// 如果有 streamId 且已存在流式条目,跳过避免重复 // 如果有 streamId 且已存在流式条目,跳过避免重复
if (_ed.streamId && wsThinkingStreams.has(_ed.streamId)) { if (_ed.streamId && wsThinkingStreams.has(_ed.streamId)) {
// 已由 thinking_stream_* 处理 // 已由 *_stream_* 处理
} else { } else {
var thinkLabel = wsTOr('chat.aiThinking', 'AI 思考'); var isRc = _et === 'reasoning_chain';
appendTimelineItem('thinking', webshellAgentPx(_ed) + '🤔 ' + thinkLabel, _em, _ed); var thinkLabel = wsTOr(isRc ? 'chat.reasoningChain' : 'chat.aiThinking', isRc ? '推理过程' : 'AI 思考');
var thinkEm = isRc ? '🔗' : '🤔';
appendTimelineItem(isRc ? 'reasoning_chain' : 'thinking', webshellAgentPx(_ed) + thinkEm + ' ' + thinkLabel, _em, _ed);
} }
if (!streamingTarget) assistantDiv.textContent = '…'; if (!streamingTarget) assistantDiv.textContent = '…';
@@ -3076,6 +3099,12 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
// ─── Eino sub-agent reply streaming ─── // ─── Eino sub-agent reply streaming ───
} else if (_et === 'eino_agent_reply_stream_start' && _ed.streamId) { } else if (_et === 'eino_agent_reply_stream_start' && _ed.streamId) {
if (einoSubReplyStreams.has(_ed.streamId)) {
var stExist = einoSubReplyStreams.get(_ed.streamId);
stExist.buf = '';
var preExist = stExist.el && stExist.el.querySelector('.webshell-eino-reply-stream-body');
if (preExist) preExist.textContent = '';
} else {
var repTS = wsTOr('chat.einoAgentReplyTitle', '子代理回复'); var repTS = wsTOr('chat.einoAgentReplyTitle', '子代理回复');
var runTS = wsTOr('timeline.running', '执行中...'); var runTS = wsTOr('timeline.running', '执行中...');
var itemS = document.createElement('div'); var itemS = document.createElement('div');
@@ -3084,11 +3113,14 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
timelineContainer.appendChild(itemS); timelineContainer.appendChild(itemS);
timelineContainer.classList.add('has-items'); timelineContainer.classList.add('has-items');
einoSubReplyStreams.set(_ed.streamId, { el: itemS, buf: '' }); einoSubReplyStreams.set(_ed.streamId, { el: itemS, buf: '' });
}
if (!streamingTarget) assistantDiv.textContent = '…'; if (!streamingTarget) assistantDiv.textContent = '…';
} else if (_et === 'eino_agent_reply_stream_delta' && _ed.streamId) { } else if (_et === 'eino_agent_reply_stream_delta' && _ed.streamId) {
var stD = einoSubReplyStreams.get(_ed.streamId); var stD = einoSubReplyStreams.get(_ed.streamId);
if (stD) { if (stD) {
stD.buf += (_em || ''); var normS = (typeof window.normalizeStreamingDeltaJs === 'function')
? window.normalizeStreamingDeltaJs(stD.buf, _em || '') : [stD.buf + (_em || ''), _em || ''];
stD.buf = normS[0];
var preD = stD.el.querySelector('.webshell-eino-reply-stream-body'); var preD = stD.el.querySelector('.webshell-eino-reply-stream-body');
if (!preD) { if (!preD) {
preD = document.createElement('pre'); preD = document.createElement('pre');
+166 -10
View File
@@ -170,6 +170,14 @@
<span data-i18n="nav.vulnerabilities">漏洞管理</span> <span data-i18n="nav.vulnerabilities">漏洞管理</span>
</div> </div>
</div> </div>
<div class="nav-item" data-page="chat-files">
<div class="nav-item-content" data-title="文件管理" onclick="switchPage('chat-files')" data-i18n="nav.chatFiles" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<span data-i18n="nav.chatFiles">文件管理</span>
</div>
</div>
<div class="nav-item" data-page="webshell"> <div class="nav-item" data-page="webshell">
<div class="nav-item-content" data-title="WebShell管理" onclick="switchPage('webshell')" data-i18n="nav.webshell" data-i18n-attr="data-title" data-i18n-skip-text="true"> <div class="nav-item-content" data-title="WebShell管理" onclick="switchPage('webshell')" data-i18n="nav.webshell" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -201,14 +209,6 @@
<div class="nav-submenu-item" data-page="c2-profiles" onclick="switchPage('c2-profiles')" data-i18n="nav.c2Profiles">流量伪装</div> <div class="nav-submenu-item" data-page="c2-profiles" onclick="switchPage('c2-profiles')" data-i18n="nav.c2Profiles">流量伪装</div>
</div> </div>
</div> </div>
<div class="nav-item" data-page="chat-files">
<div class="nav-item-content" data-title="文件管理" onclick="switchPage('chat-files')" data-i18n="nav.chatFiles" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<span data-i18n="nav.chatFiles">文件管理</span>
</div>
</div>
<div class="nav-item nav-item-has-submenu" data-page="mcp"> <div class="nav-item nav-item-has-submenu" data-page="mcp">
<div class="nav-item-content" data-title="MCP" onclick="window.toggleSubmenu('mcp')" data-i18n="nav.mcp" data-i18n-attr="data-title" data-i18n-skip-text="true"> <div class="nav-item-content" data-title="MCP" onclick="window.toggleSubmenu('mcp')" data-i18n="nav.mcp" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -894,6 +894,8 @@
<div id="active-tasks-bar" class="active-tasks-bar"></div> <div id="active-tasks-bar" class="active-tasks-bar"></div>
<div id="chat-messages" class="chat-messages"></div> <div id="chat-messages" class="chat-messages"></div>
<div id="chat-input-container" class="chat-input-container"> <div id="chat-input-container" class="chat-input-container">
<div class="chat-input-primary-row">
<div class="chat-input-leading">
<div class="role-selector-wrapper"> <div class="role-selector-wrapper">
<button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" data-i18n="chat.selectRole" data-i18n-attr="title" title="选择角色"> <button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" data-i18n="chat.selectRole" data-i18n-attr="title" title="选择角色">
<span id="role-selector-icon" class="role-selector-icon">🔵</span> <span id="role-selector-icon" class="role-selector-icon">🔵</span>
@@ -979,6 +981,50 @@
</div> </div>
<input type="hidden" id="agent-mode-select" value="react" autocomplete="off"> <input type="hidden" id="agent-mode-select" value="react" autocomplete="off">
</div> </div>
<div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper" style="display: none;">
<div class="chat-reasoning-inner">
<button type="button" id="chat-reasoning-btn" class="role-selector-btn chat-reasoning-btn" onclick="toggleChatReasoningPanel()" aria-expanded="false" aria-haspopup="dialog" aria-controls="chat-reasoning-panel" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项">
<span class="chat-reasoning-btn-icon" aria-hidden="true">🔎</span>
<span id="chat-reasoning-summary" class="role-selector-text chat-reasoning-btn-summary"></span>
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div id="chat-reasoning-panel" class="chat-reasoning-panel" style="display: none;" role="dialog" aria-labelledby="chat-reasoning-panel-title">
<div class="role-selection-panel-header chat-reasoning-panel-header">
<h3 id="chat-reasoning-panel-title" class="role-selection-panel-title" data-i18n="chat.reasoningPanelTitle">模型推理</h3>
<button type="button" class="role-selection-panel-close" onclick="closeChatReasoningPanel()" data-i18n="common.close" data-i18n-attr="title" title="关闭">
<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>
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
<div class="chat-reasoning-fields">
<div class="chat-reasoning-field">
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
</select>
</div>
<div class="chat-reasoning-field">
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="max">max</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="chat-input-with-files"> <div class="chat-input-with-files">
<div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div> <div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div>
<div id="chat-attachment-progress" class="chat-upload-progress-row" hidden role="status" aria-live="polite"> <div id="chat-attachment-progress" class="chat-upload-progress-row" hidden role="status" aria-live="polite">
@@ -1006,6 +1052,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div id="page-hitl" class="page"> <div id="page-hitl" class="page">
<div class="page-header"> <div class="page-header">
@@ -1053,6 +1100,7 @@
<option value="completed" data-i18n="mcpMonitor.statusCompleted">已完成</option> <option value="completed" data-i18n="mcpMonitor.statusCompleted">已完成</option>
<option value="running" data-i18n="mcpMonitor.statusRunning">执行中</option> <option value="running" data-i18n="mcpMonitor.statusRunning">执行中</option>
<option value="failed" data-i18n="mcpMonitor.statusFailed">失败</option> <option value="failed" data-i18n="mcpMonitor.statusFailed">失败</option>
<option value="cancelled" data-i18n="mcpMonitor.statusCancelled">已终止</option>
</select> </select>
</label> </label>
</div> </div>
@@ -1937,6 +1985,9 @@
<div class="settings-nav-item" data-section="knowledge" onclick="switchSettingsSection('knowledge')"> <div class="settings-nav-item" data-section="knowledge" onclick="switchSettingsSection('knowledge')">
<span data-i18n="settings.nav.knowledge">知识库</span> <span data-i18n="settings.nav.knowledge">知识库</span>
</div> </div>
<div class="settings-nav-item" data-section="c2" onclick="switchSettingsSection('c2')">
<span data-i18n="settings.nav.c2">C2</span>
</div>
<div class="settings-nav-item" data-section="robots" onclick="switchSettingsSection('robots')"> <div class="settings-nav-item" data-section="robots" onclick="switchSettingsSection('robots')">
<span data-i18n="settings.nav.robots">机器人设置</span> <span data-i18n="settings.nav.robots">机器人设置</span>
</div> </div>
@@ -1985,6 +2036,38 @@
<input type="number" id="openai-max-total-tokens" data-i18n="settingsBasic.maxTotalTokensPlaceholder" data-i18n-attr="placeholder" placeholder="120000" min="1000" step="1000" /> <input type="number" id="openai-max-total-tokens" data-i18n="settingsBasic.maxTotalTokensPlaceholder" data-i18n-attr="placeholder" placeholder="120000" min="1000" step="1000" />
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small> <small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
</div> </div>
<div class="form-group">
<label data-i18n="settingsBasic.openaiReasoningTitle">模型推理(Eino</label>
<small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">仅影响 Eino 单代理与多代理;对话页可覆盖(见下方「允许对话覆盖」)。</small>
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; align-items: center;">
<label for="openai-reasoning-mode" style="font-size: 0.8125rem;" data-i18n="chat.reasoningModeLabel">模式</label>
<select id="openai-reasoning-mode" style="min-width: 120px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
</select>
<label for="openai-reasoning-effort" style="font-size: 0.8125rem;" data-i18n="chat.reasoningEffortLabel">强度</label>
<select id="openai-reasoning-effort" style="min-width: 100px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="max">max</option>
</select>
<label for="openai-reasoning-profile" style="font-size: 0.8125rem;" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
<select id="openai-reasoning-profile" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
<option value="auto">auto</option>
<option value="deepseek_compat">deepseek_compat</option>
<option value="openai_compat">openai_compat</option>
<option value="output_config_effort">output_config_effort</option>
</select>
</div>
<label class="checkbox-label" style="margin-top: 8px;">
<input type="checkbox" id="openai-reasoning-allow-client" class="modern-checkbox" checked />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="settingsBasic.openaiReasoningAllowClient">允许对话页覆盖推理选项</span>
</label>
</div>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;"> <div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
<a href="javascript:void(0)" id="test-openai-btn" onclick="testOpenAIConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settingsBasic.testConnection">测试连接</a> <a href="javascript:void(0)" id="test-openai-btn" onclick="testOpenAIConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settingsBasic.testConnection">测试连接</a>
<span id="test-openai-result" style="font-size: 0.8125rem;"></span> <span id="test-openai-result" style="font-size: 0.8125rem;"></span>
@@ -2222,6 +2305,29 @@
</div> </div>
</div> </div>
<!-- C2 总开关 -->
<div id="settings-section-c2" class="settings-section-content">
<div class="settings-section-header">
<h3 data-i18n="settings.c2.title">C2 设置</h3>
</div>
<div class="settings-subsection">
<h4 data-i18n="settings.c2.sectionTitle">内置 C2</h4>
<div class="settings-form">
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="c2-enabled" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="settings.c2.enableLabel">启用内置 C2(监听器、会话、Payload、MCP 工具等)</span>
</label>
<small class="form-hint" data-i18n="settings.c2.enableHint">关闭后不再启动监听器、不注册 C2 相关 MCP 工具,侧栏 C2 入口将隐藏;仅本机使用对话与知识库时可关闭以节省资源。</small>
</div>
</div>
</div>
<div class="settings-actions">
<button class="btn-primary" onclick="applySettings()" data-i18n="settings.apply.button">应用配置</button>
</div>
</div>
<!-- 机器人设置 --> <!-- 机器人设置 -->
<div id="settings-section-robots" class="settings-section-content"> <div id="settings-section-robots" class="settings-section-content">
<div class="settings-section-header"> <div class="settings-section-header">
@@ -2423,6 +2529,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="detail-section detail-abort-section" id="detail-abort-section" style="display: none;">
<div class="detail-section-header">
<h3 data-i18n="mcpDetailModal.abortTitle">运行控制</h3>
</div>
<p class="detail-abort-hint" data-i18n="mcpDetailModal.abortHint">仅中断当前工具调用;对话与多步任务会继续。</p>
<button type="button" class="btn-secondary btn-monitor-abort" id="detail-abort-btn" onclick="abortMCPToolExecutionFromDetail()">终止工具</button>
</div>
<div class="detail-section"> <div class="detail-section">
<div class="detail-section-header"> <div class="detail-section-header">
<h3 data-i18n="mcpDetailModal.requestParams">请求参数</h3> <h3 data-i18n="mcpDetailModal.requestParams">请求参数</h3>
@@ -2463,6 +2576,49 @@
</div> </div>
</div> </div>
<!-- 用户中断并说明(继续迭代) -->
<div id="user-interrupt-modal" class="modal">
<div class="modal-content" style="max-width: 520px;">
<div class="modal-header">
<h2 data-i18n="tasks.interruptModalTitle">中断当前步骤</h2>
<span class="modal-close" onclick="closeUserInterruptModal()">&times;</span>
</div>
<div class="modal-body">
<p class="detail-abort-hint" data-i18n="tasks.interruptModalHint">填写说明后将写入对话并由智能体继续迭代。</p>
<div class="form-group">
<label for="user-interrupt-reason"><span data-i18n="tasks.interruptReasonLabel">中断说明</span></label>
<textarea id="user-interrupt-reason" class="form-control" rows="4" data-i18n="tasks.interruptReasonPlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
</div>
<div class="form-actions" style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;">
<button type="button" class="btn-secondary" onclick="closeUserInterruptModal()" data-i18n="tasks.interruptModalClose">关闭</button>
<button type="button" class="btn-secondary btn-delete" onclick="submitUserInterruptHardCancel()" data-i18n="tasks.interruptHardStop">彻底停止</button>
<button type="button" class="btn-primary" onclick="submitUserInterruptContinue()" data-i18n="tasks.interruptConfirmContinue">中断并继续</button>
</div>
</div>
</div>
</div>
<!-- MCP 工具终止:可填写给模型的说明 -->
<div id="mcp-tool-abort-modal" class="modal">
<div class="modal-content" style="max-width: 520px;">
<div class="modal-header">
<h2 data-i18n="mcpDetailModal.abortNoteModalTitle">终止工具并补充说明</h2>
<span class="modal-close" onclick="closeMcpToolAbortModal()">&times;</span>
</div>
<div class="modal-body">
<p class="detail-abort-hint" data-i18n="mcpDetailModal.abortNoteModalHint">可选说明。</p>
<div class="form-group">
<label for="mcp-tool-abort-note"><span data-i18n="mcpDetailModal.abortNoteLabel">终止说明(可选)</span></label>
<textarea id="mcp-tool-abort-note" class="form-control" rows="4" data-i18n="mcpDetailModal.abortNotePlaceholder" data-i18n-attr="placeholder" placeholder=""></textarea>
</div>
<div class="form-actions" style="display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end;">
<button type="button" class="btn-secondary" onclick="closeMcpToolAbortModal()" data-i18n="mcpDetailModal.abortNoteClose">取消</button>
<button type="button" class="btn-primary" onclick="submitMcpToolAbortModal()" data-i18n="mcpDetailModal.abortNoteSubmit">提交终止</button>
</div>
</div>
</div>
</div>
<!-- 外部MCP配置模态框 --> <!-- 外部MCP配置模态框 -->
<div id="external-mcp-modal" class="modal"> <div id="external-mcp-modal" class="modal">
<div class="modal-content" style="max-width: 900px;"> <div class="modal-content" style="max-width: 900px;">
@@ -2522,7 +2678,7 @@
<h2 data-i18n="attackChainModal.title">攻击链可视化</h2> <h2 data-i18n="attackChainModal.title">攻击链可视化</h2>
<div class="modal-header-actions"> <div class="modal-header-actions">
<button class="btn-primary attack-chain-action-btn" onclick="regenerateAttackChain()" data-i18n="attackChainModal.regenerateTitle" data-i18n-attr="title" data-i18n-skip-text="true" title="重新生成攻击链(包含最新对话内容)"> <button class="btn-primary attack-chain-action-btn" onclick="regenerateAttackChain()" data-i18n="attackChainModal.regenerateTitle" data-i18n-attr="title" data-i18n-skip-text="true" title="重新生成攻击链(包含最新对话内容)">
🔄 <span data-i18n="attackChainModal.regenerate">重新生成</span> <span data-i18n="attackChainModal.regenerate">重新生成</span>
</button> </button>
<button class="btn-secondary attack-chain-action-btn" onclick="exportAttackChain('png')" data-i18n="attackChainModal.exportPng" data-i18n-attr="title" title="导出为PNG"> <button class="btn-secondary attack-chain-action-btn" onclick="exportAttackChain('png')" data-i18n="attackChainModal.exportPng" data-i18n-attr="title" title="导出为PNG">
📥 PNG 📥 PNG
@@ -2531,7 +2687,7 @@
📥 SVG 📥 SVG
</button> </button>
<button class="btn-secondary attack-chain-action-btn" onclick="refreshAttackChain()" data-i18n="attackChainModal.refreshTitle" data-i18n-attr="title" title="刷新当前攻击链(不重新生成)"> <button class="btn-secondary attack-chain-action-btn" onclick="refreshAttackChain()" data-i18n="attackChainModal.refreshTitle" data-i18n-attr="title" title="刷新当前攻击链(不重新生成)">
<span data-i18n="common.refresh">刷新</span> <span data-i18n="common.refresh">刷新</span>
</button> </button>
<span class="modal-close" onclick="closeAttackChainModal()">&times;</span> <span class="modal-close" onclick="closeAttackChainModal()">&times;</span>
</div> </div>