Compare commits

..

27 Commits

Author SHA1 Message Date
公明 87e8f07738 Add files via upload 2026-01-02 02:22:43 +08:00
公明 044480a427 Add files via upload 2026-01-02 02:16:44 +08:00
公明 88e710d7e9 Add files via upload 2026-01-02 01:29:02 +08:00
公明 74b2edad29 Add files via upload 2026-01-02 01:05:02 +08:00
公明 cfc59ed895 Add files via upload 2026-01-02 01:02:01 +08:00
公明 9c5a115814 Delete img/效果.png 2026-01-02 01:01:26 +08:00
公明 a173dce667 Add files via upload 2026-01-02 00:52:25 +08:00
公明 b5d3396159 Add files via upload 2026-01-02 00:45:28 +08:00
公明 dcca3f014d Update README_CN.md 2026-01-02 00:20:20 +08:00
公明 ca8fb8b60b Update README.md 2026-01-02 00:19:36 +08:00
公明 7b9dee7268 Add files via upload 2026-01-02 00:18:39 +08:00
公明 b90a29fdd7 Add files via upload
1、修复删除知识项后总分类数统计错误:将 updateKnowledgeStats 中的 || 改为 != null 检查,并移除会错误更新统计的 updateKnowledgeStatsAfterDelete 调用。
2、为 MCP 状态监控页面添加了批量删除功能(复选框、全选、批量删除按钮)和每页显示数量配置(选择器位于分页控件左侧,设置保存到 localStorage)。
2025-12-31 19:20:58 +08:00
公明 24aa12cf33 Add files via upload 2025-12-31 19:02:15 +08:00
公明 7b8a220123 Update README.md 2025-12-31 09:08:09 +08:00
公明 99552a1812 Update README_CN.md 2025-12-31 09:07:07 +08:00
公明 e971e1eee2 Update README.md 2025-12-31 09:06:32 +08:00
公明 4fb1c7b911 Add files via upload 2025-12-31 09:06:08 +08:00
公明 9ebf9c2252 Delete img/外部MCP接入.png 2025-12-31 09:05:16 +08:00
公明 7fcfbe60c5 Add files via upload 2025-12-31 09:02:04 +08:00
公明 0c4f934b24 Delete img/效果.png 2025-12-31 09:01:51 +08:00
公明 90bafc2f1c Add files via upload 2025-12-31 08:57:48 +08:00
公明 adfd45e11e Add files via upload 2025-12-31 08:57:09 +08:00
公明 63f2a6fc3a Create feature_request.md 2025-12-31 01:31:29 +08:00
公明 4fecdad152 Create bug_report.md 2025-12-31 01:30:34 +08:00
公明 a32ba40353 Add files via upload 2025-12-30 23:21:09 +08:00
公明 d48238f6a0 Add files via upload 2025-12-30 22:02:13 +08:00
公明 98713236b7 Add files via upload 2025-12-29 19:17:16 +08:00
25 changed files with 5581 additions and 253 deletions
+78
View File
@@ -0,0 +1,78 @@
---
name: 🐛 Bug / 异常问题反馈
about: 报告一个 Bug 或异常问题
title: '[BUG] '
labels: ['bug', '待确认']
assignees: ''
---
## 📋 问题描述
<!-- 请清晰、简洁地描述遇到的问题 -->
## 🔄 复现步骤
<!-- 请详细描述如何复现这个问题 -->
1.
2.
3.
4.
## ✅ 期望行为
<!-- 描述你期望的正确行为是什么 -->
## ❌ 实际行为
<!-- 描述实际发生了什么 -->
## 📸 截图/录屏
<!--
⚠️ 重要:请提供完整的截图或录屏,确保包含:
- 完整的错误信息
- 相关的界面元素
- 浏览器控制台错误(如有)
- 终端输出(如有)
如果截图不完整,issue 可能会被关闭。
-->
<!-- 请在此处拖拽或粘贴截图 -->
## 📝 报错日志(脱敏后)
<!--
⚠️ 重要:请提供完整的、脱敏后的报错日志。
脱敏要求:
- 移除所有敏感信息(API Key、密码、Token、真实IP地址、域名等)
- 使用占位符替换,如:`sk-xxx``password: ***``192.168.x.x``example.com`
- 保留完整的错误堆栈信息
- 保留时间戳和日志级别
请从以下位置收集日志:
1. MCP状态监控 页面
2. 服务器终端输出
3. 日志文件(如果配置了文件输出)
4. 浏览器控制台(F12 → Console
-->
```
请在此处粘贴脱敏后的完整报错日志
```
## ✅ 检查清单
<!-- 提交前请确认以下项目 -->
- [ ] 我已阅读并理解项目的 Issue 规范
- [ ] 我已提供完整的、脱敏后的报错日志
- [ ] 我已提供完整的截图(如适用)
- [ ] 我已提供详细的复现步骤
- [ ] 我已填写所有必要的环境信息
- [ ] 我已脱敏所有敏感信息(API Key、密码、IP 等)
- [ ] 我已确认这不是重复的 issue
---
**注意**:如果缺少必要的日志或截图,此 issue 可能会被标记为 `需要更多信息` 或直接关闭。请确保提供完整的信息以便我们能够快速定位和解决问题。
+68
View File
@@ -0,0 +1,68 @@
---
name: ✨ 功能优化建议
about: 提出新功能或优化建议
title: '[FEATURE] '
labels: ['enhancement', '待讨论']
assignees: ''
---
## 💡 功能描述
<!-- 请清晰、简洁地描述你希望添加或优化的功能 -->
## 🎯 使用场景
<!-- 描述这个功能的使用场景,解决什么问题 -->
<!-- 例如:在什么情况下会用到这个功能?它如何改善用户体验? -->
## 🔄 当前行为
<!-- 描述当前系统是如何处理相关需求的,或者为什么需要这个功能 -->
## ✨ 期望行为
<!-- 详细描述你期望的新功能或优化后的行为 -->
## 📸 参考示例(如有)
<!--
如果有其他项目的类似功能实现,可以在此提供截图或链接作为参考
⚠️ 请确保截图完整,包含所有相关界面元素
-->
<!-- 请在此处拖拽或粘贴参考截图 -->
## 🛠️ 实现建议(可选)
<!-- 如果你有具体的实现思路或技术建议,可以在此描述 -->
## 📊 优先级评估
<!-- 请选择你认为的优先级 -->
- [ ] 🔴 高优先级(严重影响使用体验或功能缺失)
- [ ] 🟡 中优先级(能显著改善体验)
- [ ] 🟢 低优先级(锦上添花的功能)
## 🔍 相关功能
<!-- 这个功能是否与现有功能相关? -->
<!-- 例如:是否与工具管理、攻击链分析、知识库等功能相关? -->
## 📝 额外信息
<!-- 任何其他有助于理解需求的信息 -->
- 是否已有替代方案?
- 这个功能是否会影响现有功能?
- 是否有相关的其他 issue 或讨论?
## ✅ 检查清单
<!-- 提交前请确认以下项目 -->
- [ ] 我已清晰描述了功能需求和使用场景
- [ ] 我已提供完整的参考截图(如有)
- [ ] 我已评估了功能的优先级
- [ ] 我已确认这不是重复的 issue
- [ ] 我已考虑了对现有功能的影响
---
**注意**:请提供尽可能详细的信息,包括使用场景、参考示例等,这将有助于我们更好地理解和实现你的需求。
+22 -9
View File
@@ -9,17 +9,26 @@
CyberStrikeAI is an **AI-native security testing platform** built in Go. It integrates 100+ security tools, an intelligent orchestration engine, 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, 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.
> In security, what is truly scarce is not tools, but judgment; judgment is born from experience, and experience is often bound to individuals, difficult to inherit and difficult to reuse. CyberStrikeAI does not attempt to automate attacks, but instead focuses on a harder problem: in complex, dynamic, and uncertain environments, what should be done next, and why. It does not pursue more aggressive automation; rather, it seeks to transform the way security experts think—their decision paths and lessons learned from failure—into a system capability that is constrained, auditable, and evolvable. If experience can exist beyond individuals, then security can finally become something that can be inherited by systems.
## Interface & Integration Preview ## Interface & Integration Preview
- Web console
<img src="./img/效果.png" alt="Preview" width="560"> ### Web Console
- MCP stdio mode <img src="./img/效果.png" alt="Web Console" width="560">
<img src="./img/mcp-stdio2.png" alt="Preview" width="560">
- External MCP servers & attack-chain view ### MCP Integration
<img src="./img/外部MCP接入.png" alt="Preview" width="560"> - **MCP stdio mode**
<img src="./img/攻击链.png" alt="Preview" width="560"> <img src="./img/mcp-stdio2.png" alt="MCP stdio mode" width="560">
- **MCP management**
<img src="./img/MCP管理.png" alt="MCP management" width="560">
### Attack Chain Visualization
<img src="./img/攻击链.png" alt="Attack Chain" width="560">
### Vulnerability Management
<img src="./img/漏洞管理.png" alt="Vulnerability Management" width="560">
### Task Management
<img src="./img/任务.png" alt="Task Management" width="560">
## Highlights ## Highlights
@@ -32,6 +41,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
- 📚 Knowledge base with vector search and hybrid retrieval for security expertise - 📚 Knowledge base with vector search and hybrid retrieval for security expertise
- 📁 Conversation grouping with pinning, rename, and batch management - 📁 Conversation grouping with pinning, rename, and batch management
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics - 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
## Tool Overview ## Tool Overview
@@ -107,6 +117,7 @@ CyberStrikeAI ships with 100+ curated tools covering the whole kill chain:
- **History & audit** Every conversation and tool invocation is stored in SQLite with replay. - **History & audit** Every conversation and tool invocation is stored in SQLite with replay.
- **Conversation groups** Organize conversations into groups, pin important groups, rename or delete groups via context menu. - **Conversation groups** Organize conversations into groups, pin important groups, rename or delete groups via context menu.
- **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.
- **Settings** Tweak provider keys, MCP enablement, tool toggles, and agent iteration limits. - **Settings** Tweak provider keys, MCP enablement, tool toggles, and agent iteration limits.
### Built-in Safeguards ### Built-in Safeguards
@@ -219,6 +230,7 @@ CyberStrikeAI ships with 100+ curated tools covering the whole kill chain:
### Automation Hooks ### Automation Hooks
- **REST APIs** everything the UI uses (auth, conversations, tool runs, monitor, vulnerabilities) is available over JSON. - **REST APIs** everything the UI uses (auth, conversations, tool runs, monitor, vulnerabilities) is available over JSON.
- **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.
- **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.
@@ -316,6 +328,7 @@ Build an attack chain for the latest engagement and export the node list with se
## Changelog (Recent) ## Changelog (Recent)
- 2026-01-01 Added batch task management feature: create task queues with multiple tasks, add/edit/delete tasks before execution, and execute them sequentially. Each task runs as a separate conversation with status tracking (pending/running/completed/failed/cancelled). All queues and tasks are persisted in the database.
- 2025-12-25 Added vulnerability management feature: full CRUD operations for tracking vulnerabilities discovered during testing. Supports severity levels (critical/high/medium/low/info), status workflow (open/confirmed/fixed/false_positive), filtering by conversation/severity/status, and comprehensive statistics dashboard. - 2025-12-25 Added vulnerability management feature: full CRUD operations for tracking vulnerabilities discovered during testing. Supports severity levels (critical/high/medium/low/info), status workflow (open/confirmed/fixed/false_positive), filtering by conversation/severity/status, and comprehensive statistics dashboard.
- 2025-12-25 Added conversation grouping feature: organize conversations into groups, pin groups to top, rename/delete groups via context menu. All group data is persisted in the database. - 2025-12-25 Added conversation grouping feature: organize conversations into groups, pin groups to top, rename/delete groups via context menu. All group data is persisted in the database.
- 2025-12-24 Refactored attack chain generation logic, achieving 2x faster generation speed. Redesigned attack chain frontend visualization for improved user experience. - 2025-12-24 Refactored attack chain generation logic, achieving 2x faster generation speed. Redesigned attack chain frontend visualization for improved user experience.
+22 -8
View File
@@ -8,16 +8,26 @@
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎与完整的测试生命周期管理能力。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。 CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎与完整的测试生命周期管理能力。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
> 在安全领域,真正稀缺的从来不是工具,而是判断。判断来自经验,而经验往往只能依附在个人身上,难以继承、难以复用。CyberStrikeAI 尝试解决的不是“如何自动化攻击”,而是:在复杂、多变、充满不确定性的环境中,下一步“应该做什么”,以及“为什么”。它不追求更激进的自动化,而是试图把安全专家的思考方式、决策路径与失败经验,转化为一个可约束、可复盘、可演进的系统能力。如果经验可以脱离个人而存在,那么安全,才真正具备了被系统性继承的可能。
## 界面与集成预览 ## 界面与集成预览
- Web 控制台
<img src="./img/效果.png" alt="Preview" width="560"> ### Web 控制台
- MCP stdio 模式 <img src="./img/效果.png" alt="Web 控制台" width="560">
<img src="./img/mcp-stdio2.png" alt="Preview" width="560">
- 外部 MCP 服务器 & 攻击链视图 ### MCP 集成
<img src="./img/外部MCP接入.png" alt="Preview" width="560"> - **MCP stdio 模式**
<img src="./img/攻击链.png" alt="Preview" width="560"> <img src="./img/mcp-stdio2.png" alt="MCP stdio 模式" width="560">
- **MCP 管理**
<img src="./img/MCP管理.png" alt="MCP 管理" width="560">
### 攻击链可视化
<img src="./img/攻击链.png" alt="攻击链" width="560">
### 漏洞管理
<img src="./img/漏洞管理.png" alt="漏洞管理" width="560">
### 任务管理
<img src="./img/任务.png" alt="任务管理" width="560">
## 特性速览 ## 特性速览
@@ -30,6 +40,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- 📚 知识库功能:向量检索与混合搜索,为 AI 提供安全专业知识 - 📚 知识库功能:向量检索与混合搜索,为 AI 提供安全专业知识
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作 - 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板 - 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
## 工具概览 ## 工具概览
@@ -105,6 +116,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- **会话历史**:所有对话与工具调用保存在 SQLite,可随时重放。 - **会话历史**:所有对话与工具调用保存在 SQLite,可随时重放。
- **对话分组**:将对话按项目或主题组织到不同分组,支持置顶、重命名、删除等操作,所有数据持久化存储。 - **对话分组**:将对话按项目或主题组织到不同分组,支持置顶、重命名、删除等操作,所有数据持久化存储。
- **漏洞管理**:在测试过程中创建、更新和跟踪发现的漏洞。支持按严重程度(严重/高/中/低/信息)、状态(待确认/已确认/已修复/误报)和对话进行过滤,查看统计信息并导出发现。 - **漏洞管理**:在测试过程中创建、更新和跟踪发现的漏洞。支持按严重程度(严重/高/中/低/信息)、状态(待确认/已确认/已修复/误报)和对话进行过滤,查看统计信息并导出发现。
- **批量任务管理**:创建任务队列,批量添加多个任务,执行前可编辑或删除任务,然后依次顺序执行。每个任务会作为独立对话执行,支持完整的状态跟踪(待执行/执行中/已完成/失败/已取消)和执行历史。
- **可视化配置**:在界面中切换模型、启停工具、设置迭代次数等。 - **可视化配置**:在界面中切换模型、启停工具、设置迭代次数等。
### 默认安全措施 ### 默认安全措施
@@ -217,6 +229,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
### 自动化与安全 ### 自动化与安全
- **REST API**:认证、会话、任务、监控、漏洞管理等接口全部开放,可与 CI/CD 集成。 - **REST API**:认证、会话、任务、监控、漏洞管理等接口全部开放,可与 CI/CD 集成。
- **漏洞管理 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/auth/change-password` 可即时轮换口令;建议在暴露 MCP 端口时配合网络层 ACL。 - **安全管理**`/api/auth/change-password` 可即时轮换口令;建议在暴露 MCP 端口时配合网络层 ACL。
@@ -313,6 +326,7 @@ CyberStrikeAI/
``` ```
## Changelog(近期) ## Changelog(近期)
- 2026-01-01 —— 新增批量任务管理功能:支持创建任务队列,批量添加多个任务,执行前可编辑或删除任务,然后依次顺序执行。每个任务作为独立对话运行,支持状态跟踪(待执行/执行中/已完成/失败/已取消),所有队列和任务数据持久化存储到数据库。
- 2025-12-25 —— 新增漏洞管理功能:完整的漏洞 CRUD 操作,支持跟踪测试过程中发现的漏洞。支持严重程度分级(严重/高/中/低/信息)、状态流转(待确认/已确认/已修复/误报)、按对话/严重程度/状态过滤,以及统计看板。 - 2025-12-25 —— 新增漏洞管理功能:完整的漏洞 CRUD 操作,支持跟踪测试过程中发现的漏洞。支持严重程度分级(严重/高/中/低/信息)、状态流转(待确认/已确认/已修复/误报)、按对话/严重程度/状态过滤,以及统计看板。
- 2025-12-25 —— 新增对话分组功能:支持创建分组、将对话移动到分组、分组置顶、重命名和删除等操作,所有分组数据持久化存储到数据库。 - 2025-12-25 —— 新增对话分组功能:支持创建分组、将对话移动到分组、分组置顶、重命名和删除等操作,所有分组数据持久化存储到数据库。
- 2025-12-24 —— 重构攻击链生成逻辑,生成速度提升一倍。重构攻击链前端页面展示,优化用户体验。 - 2025-12-24 —— 重构攻击链生成逻辑,生成速度提升一倍。重构攻击链前端页面展示,优化用户体验。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

+13
View File
@@ -423,6 +423,18 @@ func setupRoutes(
// Agent Loop 取消与任务列表 // Agent Loop 取消与任务列表
protected.POST("/agent-loop/cancel", agentHandler.CancelAgentLoop) protected.POST("/agent-loop/cancel", agentHandler.CancelAgentLoop)
protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks) protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks)
protected.GET("/agent-loop/tasks/completed", agentHandler.ListCompletedTasks)
// 批量任务管理
protected.POST("/batch-tasks", agentHandler.CreateBatchQueue)
protected.GET("/batch-tasks", agentHandler.ListBatchQueues)
protected.GET("/batch-tasks/:queueId", agentHandler.GetBatchQueue)
protected.POST("/batch-tasks/:queueId/start", agentHandler.StartBatchQueue)
protected.POST("/batch-tasks/:queueId/pause", agentHandler.PauseBatchQueue)
protected.DELETE("/batch-tasks/:queueId", agentHandler.DeleteBatchQueue)
protected.PUT("/batch-tasks/:queueId/tasks/:taskId", agentHandler.UpdateBatchTask)
protected.POST("/batch-tasks/:queueId/tasks", agentHandler.AddBatchTask)
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
// 对话历史 // 对话历史
protected.POST("/conversations", conversationHandler.CreateConversation) protected.POST("/conversations", conversationHandler.CreateConversation)
@@ -448,6 +460,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.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution) protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
protected.GET("/monitor/stats", monitorHandler.GetStats) protected.GET("/monitor/stats", monitorHandler.GetStats)
// 配置管理 // 配置管理
+388
View File
@@ -0,0 +1,388 @@
package database
import (
"database/sql"
"fmt"
"time"
"go.uber.org/zap"
)
// BatchTaskQueueRow 批量任务队列数据库行
type BatchTaskQueueRow struct {
ID string
Status string
CreatedAt time.Time
StartedAt sql.NullTime
CompletedAt sql.NullTime
CurrentIndex int
}
// BatchTaskRow 批量任务数据库行
type BatchTaskRow struct {
ID string
QueueID string
Message string
ConversationID sql.NullString
Status string
StartedAt sql.NullTime
CompletedAt sql.NullTime
Error sql.NullString
Result sql.NullString
}
// CreateBatchQueue 创建批量任务队列
func (db *DB) CreateBatchQueue(queueID string, tasks []map[string]interface{}) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开始事务失败: %w", err)
}
defer tx.Rollback()
now := time.Now()
_, err = tx.Exec(
"INSERT INTO batch_task_queues (id, status, created_at, current_index) VALUES (?, ?, ?, ?)",
queueID, "pending", now, 0,
)
if err != nil {
return fmt.Errorf("创建批量任务队列失败: %w", err)
}
// 插入任务
for _, task := range tasks {
taskID, ok := task["id"].(string)
if !ok {
continue
}
message, ok := task["message"].(string)
if !ok {
continue
}
_, err = tx.Exec(
"INSERT INTO batch_tasks (id, queue_id, message, status) VALUES (?, ?, ?, ?)",
taskID, queueID, message, "pending",
)
if err != nil {
return fmt.Errorf("创建批量任务失败: %w", err)
}
}
return tx.Commit()
}
// GetBatchQueue 获取批量任务队列
func (db *DB) GetBatchQueue(queueID string) (*BatchTaskQueueRow, error) {
var row BatchTaskQueueRow
var createdAt string
err := db.QueryRow(
"SELECT id, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE id = ?",
queueID,
).Scan(&row.ID, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询批量任务队列失败: %w", err)
}
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
if parseErr != nil {
// 尝试其他时间格式
parsedTime, parseErr = time.Parse(time.RFC3339, createdAt)
if parseErr != nil {
db.logger.Warn("解析创建时间失败", zap.String("createdAt", createdAt), zap.Error(parseErr))
parsedTime = time.Now()
}
}
row.CreatedAt = parsedTime
return &row, nil
}
// GetAllBatchQueues 获取所有批量任务队列
func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
rows, err := db.Query(
"SELECT id, status, created_at, started_at, completed_at, current_index FROM batch_task_queues ORDER BY created_at DESC",
)
if err != nil {
return nil, fmt.Errorf("查询批量任务队列列表失败: %w", err)
}
defer rows.Close()
var queues []*BatchTaskQueueRow
for rows.Next() {
var row BatchTaskQueueRow
var createdAt string
if err := rows.Scan(&row.ID, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
return nil, fmt.Errorf("扫描批量任务队列失败: %w", err)
}
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
if parseErr != nil {
parsedTime, parseErr = time.Parse(time.RFC3339, createdAt)
if parseErr != nil {
db.logger.Warn("解析创建时间失败", zap.String("createdAt", createdAt), zap.Error(parseErr))
parsedTime = time.Now()
}
}
row.CreatedAt = parsedTime
queues = append(queues, &row)
}
return queues, nil
}
// ListBatchQueues 列出批量任务队列(支持筛选和分页)
func (db *DB) ListBatchQueues(limit, offset int, status, keyword string) ([]*BatchTaskQueueRow, error) {
query := "SELECT id, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE 1=1"
args := []interface{}{}
// 状态筛选
if status != "" && status != "all" {
query += " AND status = ?"
args = append(args, status)
}
// 关键字搜索(搜索队列ID
if keyword != "" {
query += " AND id LIKE ?"
args = append(args, "%"+keyword+"%")
}
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("查询批量任务队列列表失败: %w", err)
}
defer rows.Close()
var queues []*BatchTaskQueueRow
for rows.Next() {
var row BatchTaskQueueRow
var createdAt string
if err := rows.Scan(&row.ID, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
return nil, fmt.Errorf("扫描批量任务队列失败: %w", err)
}
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
if parseErr != nil {
parsedTime, parseErr = time.Parse(time.RFC3339, createdAt)
if parseErr != nil {
db.logger.Warn("解析创建时间失败", zap.String("createdAt", createdAt), zap.Error(parseErr))
parsedTime = time.Now()
}
}
row.CreatedAt = parsedTime
queues = append(queues, &row)
}
return queues, nil
}
// CountBatchQueues 统计批量任务队列总数(支持筛选条件)
func (db *DB) CountBatchQueues(status, keyword string) (int, error) {
query := "SELECT COUNT(*) FROM batch_task_queues WHERE 1=1"
args := []interface{}{}
// 状态筛选
if status != "" && status != "all" {
query += " AND status = ?"
args = append(args, status)
}
// 关键字搜索
if keyword != "" {
query += " AND id LIKE ?"
args = append(args, "%"+keyword+"%")
}
var count int
err := db.QueryRow(query, args...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("统计批量任务队列总数失败: %w", err)
}
return count, nil
}
// GetBatchTasks 获取批量任务队列的所有任务
func (db *DB) GetBatchTasks(queueID string) ([]*BatchTaskRow, error) {
rows, err := db.Query(
"SELECT id, queue_id, message, conversation_id, status, started_at, completed_at, error, result FROM batch_tasks WHERE queue_id = ? ORDER BY id",
queueID,
)
if err != nil {
return nil, fmt.Errorf("查询批量任务失败: %w", err)
}
defer rows.Close()
var tasks []*BatchTaskRow
for rows.Next() {
var task BatchTaskRow
if err := rows.Scan(
&task.ID, &task.QueueID, &task.Message, &task.ConversationID,
&task.Status, &task.StartedAt, &task.CompletedAt, &task.Error, &task.Result,
); err != nil {
return nil, fmt.Errorf("扫描批量任务失败: %w", err)
}
tasks = append(tasks, &task)
}
return tasks, nil
}
// UpdateBatchQueueStatus 更新批量任务队列状态
func (db *DB) UpdateBatchQueueStatus(queueID, status string) error {
var err error
now := time.Now()
if status == "running" {
_, err = db.Exec(
"UPDATE batch_task_queues SET status = ?, started_at = COALESCE(started_at, ?) WHERE id = ?",
status, now, queueID,
)
} else if status == "completed" || status == "cancelled" {
_, err = db.Exec(
"UPDATE batch_task_queues SET status = ?, completed_at = COALESCE(completed_at, ?) WHERE id = ?",
status, now, queueID,
)
} else {
_, err = db.Exec(
"UPDATE batch_task_queues SET status = ? WHERE id = ?",
status, queueID,
)
}
if err != nil {
return fmt.Errorf("更新批量任务队列状态失败: %w", err)
}
return nil
}
// UpdateBatchTaskStatus 更新批量任务状态
func (db *DB) UpdateBatchTaskStatus(queueID, taskID, status string, conversationID, result, errorMsg string) error {
var err error
now := time.Now()
// 构建更新语句
var updates []string
var args []interface{}
updates = append(updates, "status = ?")
args = append(args, status)
if conversationID != "" {
updates = append(updates, "conversation_id = ?")
args = append(args, conversationID)
}
if result != "" {
updates = append(updates, "result = ?")
args = append(args, result)
}
if errorMsg != "" {
updates = append(updates, "error = ?")
args = append(args, errorMsg)
}
if status == "running" {
updates = append(updates, "started_at = COALESCE(started_at, ?)")
args = append(args, now)
}
if status == "completed" || status == "failed" || status == "cancelled" {
updates = append(updates, "completed_at = COALESCE(completed_at, ?)")
args = append(args, now)
}
args = append(args, queueID, taskID)
// 构建SQL语句
sql := "UPDATE batch_tasks SET "
for i, update := range updates {
if i > 0 {
sql += ", "
}
sql += update
}
sql += " WHERE queue_id = ? AND id = ?"
_, err = db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("更新批量任务状态失败: %w", err)
}
return nil
}
// UpdateBatchQueueCurrentIndex 更新批量任务队列的当前索引
func (db *DB) UpdateBatchQueueCurrentIndex(queueID string, currentIndex int) error {
_, err := db.Exec(
"UPDATE batch_task_queues SET current_index = ? WHERE id = ?",
currentIndex, queueID,
)
if err != nil {
return fmt.Errorf("更新批量任务队列当前索引失败: %w", err)
}
return nil
}
// UpdateBatchTaskMessage 更新批量任务消息
func (db *DB) UpdateBatchTaskMessage(queueID, taskID, message string) error {
_, err := db.Exec(
"UPDATE batch_tasks SET message = ? WHERE queue_id = ? AND id = ?",
message, queueID, taskID,
)
if err != nil {
return fmt.Errorf("更新批量任务消息失败: %w", err)
}
return nil
}
// AddBatchTask 添加任务到批量任务队列
func (db *DB) AddBatchTask(queueID, taskID, message string) error {
_, err := db.Exec(
"INSERT INTO batch_tasks (id, queue_id, message, status) VALUES (?, ?, ?, ?)",
taskID, queueID, message, "pending",
)
if err != nil {
return fmt.Errorf("添加批量任务失败: %w", err)
}
return nil
}
// DeleteBatchTask 删除批量任务
func (db *DB) DeleteBatchTask(queueID, taskID string) error {
_, err := db.Exec(
"DELETE FROM batch_tasks WHERE queue_id = ? AND id = ?",
queueID, taskID,
)
if err != nil {
return fmt.Errorf("删除批量任务失败: %w", err)
}
return nil
}
// DeleteBatchQueue 删除批量任务队列
func (db *DB) DeleteBatchQueue(queueID string) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开始事务失败: %w", err)
}
defer tx.Rollback()
// 删除任务(外键会自动级联删除)
_, err = tx.Exec("DELETE FROM batch_tasks WHERE queue_id = ?", queueID)
if err != nil {
return fmt.Errorf("删除批量任务失败: %w", err)
}
// 删除队列
_, err = tx.Exec("DELETE FROM batch_task_queues WHERE id = ?", queueID)
if err != nil {
return fmt.Errorf("删除批量任务队列失败: %w", err)
}
return tx.Commit()
}
+36
View File
@@ -189,6 +189,32 @@ func (db *DB) initTables() error {
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);` );`
// 创建批量任务队列表
createBatchTaskQueuesTable := `
CREATE TABLE IF NOT EXISTS batch_task_queues (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
created_at DATETIME NOT NULL,
started_at DATETIME,
completed_at DATETIME,
current_index INTEGER NOT NULL DEFAULT 0
);`
// 创建批量任务表
createBatchTasksTable := `
CREATE TABLE IF NOT EXISTS batch_tasks (
id TEXT PRIMARY KEY,
queue_id TEXT NOT NULL,
message TEXT NOT NULL,
conversation_id TEXT,
status TEXT NOT NULL,
started_at DATETIME,
completed_at DATETIME,
error TEXT,
result TEXT,
FOREIGN KEY (queue_id) REFERENCES batch_task_queues(id) ON DELETE CASCADE
);`
// 创建索引 // 创建索引
createIndexes := ` createIndexes := `
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id); CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
@@ -212,6 +238,8 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_severity ON vulnerabilities(severity); CREATE INDEX IF NOT EXISTS idx_vulnerabilities_severity ON vulnerabilities(severity);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_status ON vulnerabilities(status); CREATE INDEX IF NOT EXISTS idx_vulnerabilities_status ON vulnerabilities(status);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_created_at ON vulnerabilities(created_at); CREATE INDEX IF NOT EXISTS idx_vulnerabilities_created_at ON vulnerabilities(created_at);
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_created_at ON batch_task_queues(created_at);
` `
if _, err := db.Exec(createConversationsTable); err != nil { if _, err := db.Exec(createConversationsTable); err != nil {
@@ -258,6 +286,14 @@ func (db *DB) initTables() error {
return fmt.Errorf("创建vulnerabilities表失败: %w", err) return fmt.Errorf("创建vulnerabilities表失败: %w", err)
} }
if _, err := db.Exec(createBatchTaskQueuesTable); err != nil {
return fmt.Errorf("创建batch_task_queues表失败: %w", err)
}
if _, err := db.Exec(createBatchTasksTable); err != nil {
return fmt.Errorf("创建batch_tasks表失败: %w", err)
}
// 为已有表添加新字段(如果不存在)- 必须在创建索引之前 // 为已有表添加新字段(如果不存在)- 必须在创建索引之前
if err := db.migrateConversationsTable(); err != nil { if err := db.migrateConversationsTable(); err != nil {
db.logger.Warn("迁移conversations表失败", zap.Error(err)) db.logger.Warn("迁移conversations表失败", zap.Error(err))
+143 -5
View File
@@ -3,9 +3,11 @@ package database
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"strings"
"time" "time"
"cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -70,13 +72,25 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
} }
// CountToolExecutions 统计工具执行记录总数 // CountToolExecutions 统计工具执行记录总数
func (db *DB) CountToolExecutions(status string) (int, error) { func (db *DB) CountToolExecutions(status, toolName string) (int, error) {
query := `SELECT COUNT(*) FROM tool_executions` query := `SELECT COUNT(*) FROM tool_executions`
args := []interface{}{} args := []interface{}{}
conditions := []string{}
if status != "" { if status != "" {
query += ` WHERE status = ?` conditions = append(conditions, "status = ?")
args = append(args, status) args = append(args, status)
} }
if toolName != "" {
// 支持部分匹配(模糊搜索),不区分大小写
conditions = append(conditions, "LOWER(tool_name) LIKE ?")
args = append(args, "%"+strings.ToLower(toolName)+"%")
}
if len(conditions) > 0 {
query += ` WHERE ` + conditions[0]
for i := 1; i < len(conditions); i++ {
query += ` AND ` + conditions[i]
}
}
var count int var count int
err := db.QueryRow(query, args...).Scan(&count) err := db.QueryRow(query, args...).Scan(&count)
if err != nil { if err != nil {
@@ -87,14 +101,15 @@ func (db *DB) CountToolExecutions(status string) (int, error) {
// LoadToolExecutions 加载所有工具执行记录(支持分页) // LoadToolExecutions 加载所有工具执行记录(支持分页)
func (db *DB) LoadToolExecutions() ([]*mcp.ToolExecution, error) { func (db *DB) LoadToolExecutions() ([]*mcp.ToolExecution, error) {
return db.LoadToolExecutionsWithPagination(0, 1000, "") return db.LoadToolExecutionsWithPagination(0, 1000, "", "")
} }
// LoadToolExecutionsWithPagination 分页加载工具执行记录 // LoadToolExecutionsWithPagination 分页加载工具执行记录
// limit: 最大返回记录数,0 表示使用默认值 1000 // limit: 最大返回记录数,0 表示使用默认值 1000
// offset: 跳过的记录数,用于分页 // offset: 跳过的记录数,用于分页
// status: 状态筛选,空字符串表示不过滤 // status: 状态筛选,空字符串表示不过滤
func (db *DB) LoadToolExecutionsWithPagination(offset, limit int, status string) ([]*mcp.ToolExecution, error) { // toolName: 工具名称筛选,空字符串表示不过滤
func (db *DB) LoadToolExecutionsWithPagination(offset, limit int, status, toolName string) ([]*mcp.ToolExecution, error) {
if limit <= 0 { if limit <= 0 {
limit = 1000 // 默认限制 limit = 1000 // 默认限制
} }
@@ -107,10 +122,22 @@ func (db *DB) LoadToolExecutionsWithPagination(offset, limit int, status string)
FROM tool_executions FROM tool_executions
` `
args := []interface{}{} args := []interface{}{}
conditions := []string{}
if status != "" { if status != "" {
query += ` WHERE status = ?` conditions = append(conditions, "status = ?")
args = append(args, status) args = append(args, status)
} }
if toolName != "" {
// 支持部分匹配(模糊搜索),不区分大小写
conditions = append(conditions, "LOWER(tool_name) LIKE ?")
args = append(args, "%"+strings.ToLower(toolName)+"%")
}
if len(conditions) > 0 {
query += ` WHERE ` + conditions[0]
for i := 1; i < len(conditions); i++ {
query += ` AND ` + conditions[i]
}
}
query += ` ORDER BY start_time DESC LIMIT ? OFFSET ?` query += ` ORDER BY start_time DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset) args = append(args, limit, offset)
@@ -254,6 +281,117 @@ func (db *DB) DeleteToolExecution(id string) error {
return nil return nil
} }
// DeleteToolExecutions 批量删除工具执行记录
func (db *DB) DeleteToolExecutions(ids []string) error {
if len(ids) == 0 {
return nil
}
// 构建 IN 查询的占位符
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
query := `DELETE FROM tool_executions WHERE id IN (` + strings.Join(placeholders, ",") + `)`
_, err := db.Exec(query, args...)
if err != nil {
db.logger.Error("批量删除工具执行记录失败", zap.Error(err), zap.Int("count", len(ids)))
return err
}
return nil
}
// GetToolExecutionsByIds 根据ID列表获取工具执行记录(用于批量删除前获取统计信息)
func (db *DB) GetToolExecutionsByIds(ids []string) ([]*mcp.ToolExecution, error) {
if len(ids) == 0 {
return []*mcp.ToolExecution{}, nil
}
// 构建 IN 查询的占位符
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
query := `
SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms
FROM tool_executions
WHERE id IN (` + strings.Join(placeholders, ",") + `)
`
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var executions []*mcp.ToolExecution
for rows.Next() {
var exec mcp.ToolExecution
var argsJSON string
var resultJSON sql.NullString
var errorText sql.NullString
var endTime sql.NullTime
var durationMs sql.NullInt64
err := rows.Scan(
&exec.ID,
&exec.ToolName,
&argsJSON,
&exec.Status,
&resultJSON,
&errorText,
&exec.StartTime,
&endTime,
&durationMs,
)
if err != nil {
db.logger.Warn("加载执行记录失败", zap.Error(err))
continue
}
// 解析参数
if err := json.Unmarshal([]byte(argsJSON), &exec.Arguments); err != nil {
db.logger.Warn("解析执行参数失败", zap.Error(err))
exec.Arguments = make(map[string]interface{})
}
// 解析结果
if resultJSON.Valid && resultJSON.String != "" {
var result mcp.ToolResult
if err := json.Unmarshal([]byte(resultJSON.String), &result); err != nil {
db.logger.Warn("解析执行结果失败", zap.Error(err))
} else {
exec.Result = &result
}
}
// 设置错误
if errorText.Valid {
exec.Error = errorText.String
}
// 设置结束时间
if endTime.Valid {
exec.EndTime = &endTime.Time
}
// 设置持续时间
if durationMs.Valid {
exec.Duration = time.Duration(durationMs.Int64) * time.Millisecond
}
executions = append(executions, &exec)
}
return executions, nil
}
// SaveToolStats 保存工具统计信息 // SaveToolStats 保存工具统计信息
func (db *DB) SaveToolStats(toolName string, stats *mcp.ToolStats) error { func (db *DB) SaveToolStats(toolName string, stats *mcp.ToolStats) error {
var lastCallTime sql.NullTime var lastCallTime sql.NullTime
+605 -134
View File
@@ -6,9 +6,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"unicode/utf8"
"time" "time"
"unicode/utf8"
"cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/database" "cyberstrike-ai/internal/database"
@@ -64,6 +65,7 @@ type AgentHandler struct {
db *database.DB db *database.DB
logger *zap.Logger logger *zap.Logger
tasks *AgentTaskManager tasks *AgentTaskManager
batchTaskManager *BatchTaskManager
knowledgeManager interface { // 知识库管理器接口 knowledgeManager interface { // 知识库管理器接口
LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error
} }
@@ -71,11 +73,20 @@ type AgentHandler struct {
// NewAgentHandler 创建新的Agent处理器 // NewAgentHandler 创建新的Agent处理器
func NewAgentHandler(agent *agent.Agent, db *database.DB, logger *zap.Logger) *AgentHandler { func NewAgentHandler(agent *agent.Agent, db *database.DB, logger *zap.Logger) *AgentHandler {
batchTaskManager := NewBatchTaskManager()
batchTaskManager.SetDB(db)
// 从数据库加载所有批量任务队列
if err := batchTaskManager.LoadFromDB(); err != nil {
logger.Warn("从数据库加载批量任务队列失败", zap.Error(err))
}
return &AgentHandler{ return &AgentHandler{
agent: agent, agent: agent,
db: db, db: db,
logger: logger, logger: logger,
tasks: NewAgentTaskManager(), tasks: NewAgentTaskManager(),
batchTaskManager: batchTaskManager,
} }
} }
@@ -204,141 +215,17 @@ type StreamEvent struct {
Data interface{} `json:"data,omitempty"` Data interface{} `json:"data,omitempty"`
} }
// AgentLoopStream 处理Agent Loop流式请求 // createProgressCallback 创建进度回调函数,用于保存processDetails
func (h *AgentHandler) AgentLoopStream(c *gin.Context) { // sendEventFunc: 可选的流式事件发送函数,如果为nil则不发送流式事件
var req ChatRequest func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID string, sendEventFunc func(eventType, message string, data interface{})) agent.ProgressCallback {
if err := c.ShouldBindJSON(&req); err != nil {
// 对于流式请求,也发送SSE格式的错误
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
event := StreamEvent{
Type: "error",
Message: "请求参数错误: " + err.Error(),
}
eventJSON, _ := json.Marshal(event)
fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON)
c.Writer.Flush()
return
}
h.logger.Info("收到Agent Loop流式请求",
zap.String("message", req.Message),
zap.String("conversationId", req.ConversationID),
)
// 设置SSE响应头
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") // 禁用nginx缓冲
// 发送初始事件
// 用于跟踪客户端是否已断开连接
clientDisconnected := false
sendEvent := func(eventType, message string, data interface{}) {
// 如果客户端已断开,不再发送事件
if clientDisconnected {
return
}
// 检查请求上下文是否被取消(客户端断开)
select {
case <-c.Request.Context().Done():
clientDisconnected = true
return
default:
}
event := StreamEvent{
Type: eventType,
Message: message,
Data: data,
}
eventJSON, _ := json.Marshal(event)
// 尝试写入事件,如果失败则标记客户端断开
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {
clientDisconnected = true
h.logger.Debug("客户端断开连接,停止发送SSE事件", zap.Error(err))
return
}
// 刷新响应,如果失败则标记客户端断开
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
} else {
c.Writer.Flush()
}
}
// 如果没有对话ID,创建新对话
conversationID := req.ConversationID
if conversationID == "" {
title := safeTruncateString(req.Message, 50)
conv, err := h.db.CreateConversation(title)
if err != nil {
h.logger.Error("创建对话失败", zap.Error(err))
sendEvent("error", "创建对话失败: "+err.Error(), nil)
return
}
conversationID = conv.ID
}
sendEvent("conversation", "会话已创建", map[string]interface{}{
"conversationId": conversationID,
})
// 优先尝试从保存的ReAct数据恢复历史上下文
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
if err != nil {
h.logger.Warn("从ReAct数据加载历史消息失败,使用消息表", zap.Error(err))
// 回退到使用数据库消息表
historyMessages, err := h.db.GetMessages(conversationID)
if err != nil {
h.logger.Warn("获取历史消息失败", zap.Error(err))
agentHistoryMessages = []agent.ChatMessage{}
} else {
// 将数据库消息转换为Agent消息格式
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)))
}
} else {
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
}
// 保存用户消息
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil)
if err != nil {
h.logger.Error("保存用户消息失败", zap.Error(err))
}
// 预先创建助手消息,以便关联过程详情
assistantMsg, err := h.db.AddMessage(conversationID, "assistant", "处理中...", nil)
if err != nil {
h.logger.Error("创建助手消息失败", zap.Error(err))
// 如果创建失败,继续执行但不保存过程详情
assistantMsg = nil
}
// 创建进度回调函数,同时保存到数据库
var assistantMessageID string
if assistantMsg != nil {
assistantMessageID = assistantMsg.ID
}
// 用于保存tool_call事件中的参数,以便在tool_result时使用 // 用于保存tool_call事件中的参数,以便在tool_result时使用
toolCallCache := make(map[string]map[string]interface{}) // toolCallId -> arguments toolCallCache := make(map[string]map[string]interface{}) // toolCallId -> arguments
progressCallback := func(eventType, message string, data interface{}) { return func(eventType, message string, data interface{}) {
sendEvent(eventType, message, data) // 如果提供了sendEventFunc,发送流式事件
if sendEventFunc != nil {
sendEventFunc(eventType, message, data)
}
// 保存tool_call事件中的参数 // 保存tool_call事件中的参数
if eventType == "tool_call" { if eventType == "tool_call" {
@@ -471,6 +358,140 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
} }
} }
} }
}
// AgentLoopStream 处理Agent Loop流式请求
func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
// 对于流式请求,也发送SSE格式的错误
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
event := StreamEvent{
Type: "error",
Message: "请求参数错误: " + err.Error(),
}
eventJSON, _ := json.Marshal(event)
fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON)
c.Writer.Flush()
return
}
h.logger.Info("收到Agent Loop流式请求",
zap.String("message", req.Message),
zap.String("conversationId", req.ConversationID),
)
// 设置SSE响应头
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") // 禁用nginx缓冲
// 发送初始事件
// 用于跟踪客户端是否已断开连接
clientDisconnected := false
sendEvent := func(eventType, message string, data interface{}) {
// 如果客户端已断开,不再发送事件
if clientDisconnected {
return
}
// 检查请求上下文是否被取消(客户端断开)
select {
case <-c.Request.Context().Done():
clientDisconnected = true
return
default:
}
event := StreamEvent{
Type: eventType,
Message: message,
Data: data,
}
eventJSON, _ := json.Marshal(event)
// 尝试写入事件,如果失败则标记客户端断开
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {
clientDisconnected = true
h.logger.Debug("客户端断开连接,停止发送SSE事件", zap.Error(err))
return
}
// 刷新响应,如果失败则标记客户端断开
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
} else {
c.Writer.Flush()
}
}
// 如果没有对话ID,创建新对话
conversationID := req.ConversationID
if conversationID == "" {
title := safeTruncateString(req.Message, 50)
conv, err := h.db.CreateConversation(title)
if err != nil {
h.logger.Error("创建对话失败", zap.Error(err))
sendEvent("error", "创建对话失败: "+err.Error(), nil)
return
}
conversationID = conv.ID
}
sendEvent("conversation", "会话已创建", map[string]interface{}{
"conversationId": conversationID,
})
// 优先尝试从保存的ReAct数据恢复历史上下文
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
if err != nil {
h.logger.Warn("从ReAct数据加载历史消息失败,使用消息表", zap.Error(err))
// 回退到使用数据库消息表
historyMessages, err := h.db.GetMessages(conversationID)
if err != nil {
h.logger.Warn("获取历史消息失败", zap.Error(err))
agentHistoryMessages = []agent.ChatMessage{}
} else {
// 将数据库消息转换为Agent消息格式
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)))
}
} else {
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
}
// 保存用户消息
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil)
if err != nil {
h.logger.Error("保存用户消息失败", zap.Error(err))
}
// 预先创建助手消息,以便关联过程详情
assistantMsg, err := h.db.AddMessage(conversationID, "assistant", "处理中...", nil)
if err != nil {
h.logger.Error("创建助手消息失败", zap.Error(err))
// 如果创建失败,继续执行但不保存过程详情
assistantMsg = nil
}
// 创建进度回调函数,同时保存到数据库
var assistantMessageID string
if assistantMsg != nil {
assistantMessageID = assistantMsg.ID
}
// 创建进度回调函数,复用统一逻辑
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
// 创建一个独立的上下文用于任务执行,不随HTTP请求取消 // 创建一个独立的上下文用于任务执行,不随HTTP请求取消
// 这样即使客户端断开连接(如刷新页面),任务也能继续执行 // 这样即使客户端断开连接(如刷新页面),任务也能继续执行
@@ -533,8 +554,13 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
h.logger.Error("Agent Loop执行失败", zap.Error(err)) h.logger.Error("Agent Loop执行失败", zap.Error(err))
cause := context.Cause(baseCtx) cause := context.Cause(baseCtx)
// 检查是否是用户取消:context的cause是ErrTaskCancelled
// 如果cause是ErrTaskCancelled,无论错误是什么类型(包括context.Canceled),都视为用户取消
// 这样可以正确处理在API调用过程中被取消的情况
isCancelled := errors.Is(cause, ErrTaskCancelled)
switch { switch {
case errors.Is(cause, ErrTaskCancelled): case isCancelled:
taskStatus = "cancelled" taskStatus = "cancelled"
cancelMsg := "任务已被用户取消,后续操作已停止。" cancelMsg := "任务已被用户取消,后续操作已停止。"
@@ -724,6 +750,451 @@ func (h *AgentHandler) ListAgentTasks(c *gin.Context) {
}) })
} }
// ListCompletedTasks 列出最近完成的任务历史
func (h *AgentHandler) ListCompletedTasks(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"tasks": h.tasks.GetCompletedTasks(),
})
}
// BatchTaskRequest 批量任务请求
type BatchTaskRequest struct {
Tasks []string `json:"tasks" binding:"required"` // 任务列表,每行一个任务
}
// CreateBatchQueue 创建批量任务队列
func (h *AgentHandler) CreateBatchQueue(c *gin.Context) {
var req BatchTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.Tasks) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "任务列表不能为空"})
return
}
// 过滤空任务
validTasks := make([]string, 0, len(req.Tasks))
for _, task := range req.Tasks {
if task != "" {
validTasks = append(validTasks, task)
}
}
if len(validTasks) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "没有有效的任务"})
return
}
queue := h.batchTaskManager.CreateBatchQueue(validTasks)
c.JSON(http.StatusOK, gin.H{
"queueId": queue.ID,
"queue": queue,
})
}
// GetBatchQueue 获取批量任务队列
func (h *AgentHandler) GetBatchQueue(c *gin.Context) {
queueID := c.Param("queueId")
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"queue": queue})
}
// ListBatchQueuesResponse 批量任务队列列表响应
type ListBatchQueuesResponse struct {
Queues []*BatchTaskQueue `json:"queues"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// ListBatchQueues 列出所有批量任务队列(支持筛选和分页)
func (h *AgentHandler) ListBatchQueues(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "10")
offsetStr := c.DefaultQuery("offset", "0")
pageStr := c.Query("page")
status := c.Query("status")
keyword := c.Query("keyword")
limit, _ := strconv.Atoi(limitStr)
offset, _ := strconv.Atoi(offsetStr)
page := 1
// 如果提供了page参数,优先使用page计算offset
if pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
offset = (page - 1) * limit
}
}
// 限制pageSize范围
if limit <= 0 || limit > 100 {
limit = 10
}
if offset < 0 {
offset = 0
}
// 默认status为"all"
if status == "" {
status = "all"
}
// 获取队列列表和总数
queues, total, err := h.batchTaskManager.ListQueues(limit, offset, status, keyword)
if err != nil {
h.logger.Error("获取批量任务队列列表失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 计算总页数
totalPages := (total + limit - 1) / limit
if totalPages == 0 {
totalPages = 1
}
// 如果使用offset计算page,需要重新计算
if pageStr == "" {
page = (offset / limit) + 1
}
response := ListBatchQueuesResponse{
Queues: queues,
Total: total,
Page: page,
PageSize: limit,
TotalPages: totalPages,
}
c.JSON(http.StatusOK, response)
}
// StartBatchQueue 开始执行批量任务队列
func (h *AgentHandler) StartBatchQueue(c *gin.Context) {
queueID := c.Param("queueId")
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
return
}
if queue.Status != "pending" && queue.Status != "paused" {
c.JSON(http.StatusBadRequest, gin.H{"error": "队列状态不允许启动"})
return
}
// 在后台执行批量任务
go h.executeBatchQueue(queueID)
h.batchTaskManager.UpdateQueueStatus(queueID, "running")
c.JSON(http.StatusOK, gin.H{"message": "批量任务已开始执行", "queueId": queueID})
}
// PauseBatchQueue 暂停批量任务队列
func (h *AgentHandler) PauseBatchQueue(c *gin.Context) {
queueID := c.Param("queueId")
success := h.batchTaskManager.PauseQueue(queueID)
if !success {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在或无法暂停"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "批量任务已暂停"})
}
// DeleteBatchQueue 删除批量任务队列
func (h *AgentHandler) DeleteBatchQueue(c *gin.Context) {
queueID := c.Param("queueId")
success := h.batchTaskManager.DeleteQueue(queueID)
if !success {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "批量任务队列已删除"})
}
// UpdateBatchTask 更新批量任务消息
func (h *AgentHandler) UpdateBatchTask(c *gin.Context) {
queueID := c.Param("queueId")
taskID := c.Param("taskId")
var req struct {
Message string `json:"message" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
if req.Message == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "任务消息不能为空"})
return
}
err := h.batchTaskManager.UpdateTaskMessage(queueID, taskID, req.Message)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 返回更新后的队列信息
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "任务已更新", "queue": queue})
}
// AddBatchTask 添加任务到批量任务队列
func (h *AgentHandler) AddBatchTask(c *gin.Context) {
queueID := c.Param("queueId")
var req struct {
Message string `json:"message" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
if req.Message == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "任务消息不能为空"})
return
}
task, err := h.batchTaskManager.AddTaskToQueue(queueID, req.Message)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 返回更新后的队列信息
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "任务已添加", "task": task, "queue": queue})
}
// DeleteBatchTask 删除批量任务
func (h *AgentHandler) DeleteBatchTask(c *gin.Context) {
queueID := c.Param("queueId")
taskID := c.Param("taskId")
err := h.batchTaskManager.DeleteTask(queueID, taskID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 返回更新后的队列信息
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "任务已删除", "queue": queue})
}
// executeBatchQueue 执行批量任务队列
func (h *AgentHandler) executeBatchQueue(queueID string) {
h.logger.Info("开始执行批量任务队列", zap.String("queueId", queueID))
for {
// 检查队列状态
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
if !exists || queue.Status == "cancelled" || queue.Status == "completed" || queue.Status == "paused" {
break
}
// 获取下一个任务
task, hasNext := h.batchTaskManager.GetNextTask(queueID)
if !hasNext {
// 所有任务完成
h.batchTaskManager.UpdateQueueStatus(queueID, "completed")
h.logger.Info("批量任务队列执行完成", zap.String("queueId", queueID))
break
}
// 更新任务状态为运行中
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "running", "", "")
// 创建新对话
title := safeTruncateString(task.Message, 50)
conv, err := h.db.CreateConversation(title)
var conversationID string
if err != nil {
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", "创建对话失败: "+err.Error())
h.batchTaskManager.MoveToNextTask(queueID)
continue
}
conversationID = conv.ID
// 保存conversationId到任务中(即使是运行中状态也要保存,以便查看对话)
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "running", "", "", conversationID)
// 保存用户消息
_, err = h.db.AddMessage(conversationID, "user", task.Message, nil)
if err != nil {
h.logger.Error("保存用户消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
}
// 预先创建助手消息,以便关联过程详情
assistantMsg, err := h.db.AddMessage(conversationID, "assistant", "处理中...", nil)
if err != nil {
h.logger.Error("创建助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
// 如果创建失败,继续执行但不保存过程详情
assistantMsg = nil
}
// 创建进度回调函数,复用统一逻辑(批量任务不需要流式事件,所以传入nil)
var assistantMessageID string
if assistantMsg != nil {
assistantMessageID = assistantMsg.ID
}
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, nil)
// 执行任务
h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("conversationId", conversationID))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
// 存储取消函数,以便在取消队列时能够取消当前任务
h.batchTaskManager.SetTaskCancel(queueID, cancel)
result, err := h.agent.AgentLoopWithProgress(ctx, task.Message, []agent.ChatMessage{}, conversationID, progressCallback)
// 任务执行完成,清理取消函数
h.batchTaskManager.SetTaskCancel(queueID, nil)
cancel()
if err != nil {
// 检查是否是取消错误
// 1. 直接检查是否是 context.Canceled(包括包装后的错误)
// 2. 检查错误消息中是否包含"context canceled"或"cancelled"关键字
// 3. 检查 result.Response 中是否包含取消相关的消息
errStr := err.Error()
isCancelled := errors.Is(err, context.Canceled) ||
strings.Contains(strings.ToLower(errStr), "context canceled") ||
strings.Contains(strings.ToLower(errStr), "context cancelled") ||
(result != nil && result.Response != "" && (strings.Contains(result.Response, "任务已被取消") || strings.Contains(result.Response, "任务执行中断")))
if isCancelled {
h.logger.Info("批量任务被取消", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
cancelMsg := "任务已被用户取消,后续操作已停止。"
// 如果result中有更具体的取消消息,使用它
if result != nil && result.Response != "" && (strings.Contains(result.Response, "任务已被取消") || strings.Contains(result.Response, "任务执行中断")) {
cancelMsg = result.Response
}
// 更新助手消息内容
if assistantMessageID != "" {
if _, updateErr := h.db.Exec(
"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))
}
// 保存取消详情到数据库
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil); err != nil {
h.logger.Warn("保存取消详情失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
} else {
// 如果没有预先创建的助手消息,创建一个新的
_, errMsg := h.db.AddMessage(conversationID, "assistant", cancelMsg, nil)
if errMsg != nil {
h.logger.Warn("保存取消消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(errMsg))
}
}
// 保存ReAct数据(如果存在)
if result != nil && (result.LastReActInput != "" || result.LastReActOutput != "") {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存取消任务的ReAct数据失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
}
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "cancelled", cancelMsg, "", conversationID)
} else {
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
errorMsg := "执行失败: " + err.Error()
// 更新助手消息内容
if assistantMessageID != "" {
if _, updateErr := h.db.Exec(
"UPDATE messages SET content = ? WHERE id = ?",
errorMsg,
assistantMessageID,
); updateErr != nil {
h.logger.Warn("更新失败后的助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
}
// 保存错误详情到数据库
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errorMsg, nil); err != nil {
h.logger.Warn("保存错误详情失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
}
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", err.Error())
}
} else {
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
// 更新助手消息内容
if assistantMessageID != "" {
mcpIDsJSON := ""
if len(result.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
if _, updateErr := h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
result.Response,
mcpIDsJSON,
assistantMessageID,
); updateErr != nil {
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
// 如果更新失败,尝试创建新消息
_, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs)
if err != nil {
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
}
}
} else {
// 如果没有预先创建的助手消息,创建一个新的
_, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs)
if err != nil {
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
}
}
// 保存ReAct数据
if result.LastReActInput != "" || result.LastReActOutput != "" {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存ReAct数据失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
} else {
h.logger.Info("已保存ReAct数据", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
}
}
// 保存结果
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "completed", result.Response, "", conversationID)
}
// 移动到下一个任务
h.batchTaskManager.MoveToNextTask(queueID)
// 检查是否被取消或暂停
queue, _ = h.batchTaskManager.GetBatchQueue(queueID)
if queue.Status == "cancelled" || queue.Status == "paused" {
break
}
}
}
// loadHistoryFromReActData 从保存的ReAct数据恢复历史消息上下文 // loadHistoryFromReActData 从保存的ReAct数据恢复历史消息上下文
// 采用与攻击链生成类似的拼接逻辑:优先使用保存的last_react_input和last_react_output,若不存在则回退到消息表 // 采用与攻击链生成类似的拼接逻辑:优先使用保存的last_react_input和last_react_output,若不存在则回退到消息表
func (h *AgentHandler) loadHistoryFromReActData(conversationID string) ([]agent.ChatMessage, error) { func (h *AgentHandler) loadHistoryFromReActData(conversationID string) ([]agent.ChatMessage, error) {
+746
View File
@@ -0,0 +1,746 @@
package handler
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sort"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/database"
)
// BatchTask 批量任务项
type BatchTask struct {
ID string `json:"id"`
Message string `json:"message"`
ConversationID string `json:"conversationId,omitempty"`
Status string `json:"status"` // pending, running, completed, failed, cancelled
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
Error string `json:"error,omitempty"`
Result string `json:"result,omitempty"`
}
// BatchTaskQueue 批量任务队列
type BatchTaskQueue struct {
ID string `json:"id"`
Tasks []*BatchTask `json:"tasks"`
Status string `json:"status"` // pending, running, paused, completed, cancelled
CreatedAt time.Time `json:"createdAt"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
CurrentIndex int `json:"currentIndex"`
mu sync.RWMutex
}
// BatchTaskManager 批量任务管理器
type BatchTaskManager struct {
db *database.DB
queues map[string]*BatchTaskQueue
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
mu sync.RWMutex
}
// NewBatchTaskManager 创建批量任务管理器
func NewBatchTaskManager() *BatchTaskManager {
return &BatchTaskManager{
queues: make(map[string]*BatchTaskQueue),
taskCancels: make(map[string]context.CancelFunc),
}
}
// SetDB 设置数据库连接
func (m *BatchTaskManager) SetDB(db *database.DB) {
m.mu.Lock()
defer m.mu.Unlock()
m.db = db
}
// CreateBatchQueue 创建批量任务队列
func (m *BatchTaskManager) CreateBatchQueue(tasks []string) *BatchTaskQueue {
m.mu.Lock()
defer m.mu.Unlock()
queueID := time.Now().Format("20060102150405") + "-" + generateShortID()
queue := &BatchTaskQueue{
ID: queueID,
Tasks: make([]*BatchTask, 0, len(tasks)),
Status: "pending",
CreatedAt: time.Now(),
CurrentIndex: 0,
}
// 准备数据库保存的任务数据
dbTasks := make([]map[string]interface{}, 0, len(tasks))
for _, message := range tasks {
if message == "" {
continue // 跳过空行
}
taskID := generateShortID()
task := &BatchTask{
ID: taskID,
Message: message,
Status: "pending",
}
queue.Tasks = append(queue.Tasks, task)
dbTasks = append(dbTasks, map[string]interface{}{
"id": taskID,
"message": message,
})
}
// 保存到数据库
if m.db != nil {
if err := m.db.CreateBatchQueue(queueID, dbTasks); err != nil {
// 如果数据库保存失败,记录错误但继续(使用内存缓存)
// 这里可以添加日志记录
}
}
m.queues[queueID] = queue
return queue
}
// GetBatchQueue 获取批量任务队列
func (m *BatchTaskManager) GetBatchQueue(queueID string) (*BatchTaskQueue, bool) {
m.mu.RLock()
queue, exists := m.queues[queueID]
m.mu.RUnlock()
if exists {
return queue, true
}
// 如果内存中不存在,尝试从数据库加载
if m.db != nil {
if queue := m.loadQueueFromDB(queueID); queue != nil {
m.mu.Lock()
m.queues[queueID] = queue
m.mu.Unlock()
return queue, true
}
}
return nil, false
}
// loadQueueFromDB 从数据库加载单个队列
func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
if m.db == nil {
return nil
}
queueRow, err := m.db.GetBatchQueue(queueID)
if err != nil || queueRow == nil {
return nil
}
taskRows, err := m.db.GetBatchTasks(queueID)
if err != nil {
return nil
}
queue := &BatchTaskQueue{
ID: queueRow.ID,
Status: queueRow.Status,
CreatedAt: queueRow.CreatedAt,
CurrentIndex: queueRow.CurrentIndex,
Tasks: make([]*BatchTask, 0, len(taskRows)),
}
if queueRow.StartedAt.Valid {
queue.StartedAt = &queueRow.StartedAt.Time
}
if queueRow.CompletedAt.Valid {
queue.CompletedAt = &queueRow.CompletedAt.Time
}
for _, taskRow := range taskRows {
task := &BatchTask{
ID: taskRow.ID,
Message: taskRow.Message,
Status: taskRow.Status,
}
if taskRow.ConversationID.Valid {
task.ConversationID = taskRow.ConversationID.String
}
if taskRow.StartedAt.Valid {
task.StartedAt = &taskRow.StartedAt.Time
}
if taskRow.CompletedAt.Valid {
task.CompletedAt = &taskRow.CompletedAt.Time
}
if taskRow.Error.Valid {
task.Error = taskRow.Error.String
}
if taskRow.Result.Valid {
task.Result = taskRow.Result.String
}
queue.Tasks = append(queue.Tasks, task)
}
return queue
}
// GetAllQueues 获取所有队列
func (m *BatchTaskManager) GetAllQueues() []*BatchTaskQueue {
m.mu.RLock()
result := make([]*BatchTaskQueue, 0, len(m.queues))
for _, queue := range m.queues {
result = append(result, queue)
}
m.mu.RUnlock()
// 如果数据库可用,确保所有数据库中的队列都已加载到内存
if m.db != nil {
dbQueues, err := m.db.GetAllBatchQueues()
if err == nil {
m.mu.Lock()
for _, queueRow := range dbQueues {
if _, exists := m.queues[queueRow.ID]; !exists {
if queue := m.loadQueueFromDB(queueRow.ID); queue != nil {
m.queues[queueRow.ID] = queue
result = append(result, queue)
}
}
}
m.mu.Unlock()
}
}
return result
}
// ListQueues 列出队列(支持筛选和分页)
func (m *BatchTaskManager) ListQueues(limit, offset int, status, keyword string) ([]*BatchTaskQueue, int, error) {
var queues []*BatchTaskQueue
var total int
// 如果数据库可用,从数据库查询
if m.db != nil {
// 获取总数
count, err := m.db.CountBatchQueues(status, keyword)
if err != nil {
return nil, 0, fmt.Errorf("统计队列总数失败: %w", err)
}
total = count
// 获取队列列表(只获取ID
queueRows, err := m.db.ListBatchQueues(limit, offset, status, keyword)
if err != nil {
return nil, 0, fmt.Errorf("查询队列列表失败: %w", err)
}
// 加载完整的队列信息(从内存或数据库)
m.mu.Lock()
for _, queueRow := range queueRows {
var queue *BatchTaskQueue
// 先从内存查找
if cached, exists := m.queues[queueRow.ID]; exists {
queue = cached
} else {
// 从数据库加载
queue = m.loadQueueFromDB(queueRow.ID)
if queue != nil {
m.queues[queueRow.ID] = queue
}
}
if queue != nil {
queues = append(queues, queue)
}
}
m.mu.Unlock()
} else {
// 没有数据库,从内存中筛选和分页
m.mu.RLock()
allQueues := make([]*BatchTaskQueue, 0, len(m.queues))
for _, queue := range m.queues {
allQueues = append(allQueues, queue)
}
m.mu.RUnlock()
// 筛选
filtered := make([]*BatchTaskQueue, 0)
for _, queue := range allQueues {
// 状态筛选
if status != "" && status != "all" && queue.Status != status {
continue
}
// 关键字搜索
if keyword != "" {
keywordLower := strings.ToLower(keyword)
queueIDLower := strings.ToLower(queue.ID)
if !strings.Contains(queueIDLower, keywordLower) {
// 也可以搜索创建时间
createdAtStr := queue.CreatedAt.Format("2006-01-02 15:04:05")
if !strings.Contains(createdAtStr, keyword) {
continue
}
}
}
filtered = append(filtered, queue)
}
// 按创建时间倒序排序
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
})
total = len(filtered)
// 分页
start := offset
if start > len(filtered) {
start = len(filtered)
}
end := start + limit
if end > len(filtered) {
end = len(filtered)
}
if start < len(filtered) {
queues = filtered[start:end]
}
}
return queues, total, nil
}
// LoadFromDB 从数据库加载所有队列
func (m *BatchTaskManager) LoadFromDB() error {
if m.db == nil {
return nil
}
queueRows, err := m.db.GetAllBatchQueues()
if err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
for _, queueRow := range queueRows {
if _, exists := m.queues[queueRow.ID]; exists {
continue // 已存在,跳过
}
taskRows, err := m.db.GetBatchTasks(queueRow.ID)
if err != nil {
continue // 跳过加载失败的任务
}
queue := &BatchTaskQueue{
ID: queueRow.ID,
Status: queueRow.Status,
CreatedAt: queueRow.CreatedAt,
CurrentIndex: queueRow.CurrentIndex,
Tasks: make([]*BatchTask, 0, len(taskRows)),
}
if queueRow.StartedAt.Valid {
queue.StartedAt = &queueRow.StartedAt.Time
}
if queueRow.CompletedAt.Valid {
queue.CompletedAt = &queueRow.CompletedAt.Time
}
for _, taskRow := range taskRows {
task := &BatchTask{
ID: taskRow.ID,
Message: taskRow.Message,
Status: taskRow.Status,
}
if taskRow.ConversationID.Valid {
task.ConversationID = taskRow.ConversationID.String
}
if taskRow.StartedAt.Valid {
task.StartedAt = &taskRow.StartedAt.Time
}
if taskRow.CompletedAt.Valid {
task.CompletedAt = &taskRow.CompletedAt.Time
}
if taskRow.Error.Valid {
task.Error = taskRow.Error.String
}
if taskRow.Result.Valid {
task.Result = taskRow.Result.String
}
queue.Tasks = append(queue.Tasks, task)
}
m.queues[queueRow.ID] = queue
}
return nil
}
// UpdateTaskStatus 更新任务状态
func (m *BatchTaskManager) UpdateTaskStatus(queueID, taskID, status string, result, errorMsg string) {
m.UpdateTaskStatusWithConversationID(queueID, taskID, status, result, errorMsg, "")
}
// UpdateTaskStatusWithConversationID 更新任务状态(包含conversationId
func (m *BatchTaskManager) UpdateTaskStatusWithConversationID(queueID, taskID, status string, result, errorMsg, conversationID string) {
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
return
}
for _, task := range queue.Tasks {
if task.ID == taskID {
task.Status = status
if result != "" {
task.Result = result
}
if errorMsg != "" {
task.Error = errorMsg
}
if conversationID != "" {
task.ConversationID = conversationID
}
now := time.Now()
if status == "running" && task.StartedAt == nil {
task.StartedAt = &now
}
if status == "completed" || status == "failed" || status == "cancelled" {
task.CompletedAt = &now
}
break
}
}
// 同步到数据库
if m.db != nil {
if err := m.db.UpdateBatchTaskStatus(queueID, taskID, status, conversationID, result, errorMsg); err != nil {
// 记录错误但继续(使用内存缓存)
}
}
}
// UpdateQueueStatus 更新队列状态
func (m *BatchTaskManager) UpdateQueueStatus(queueID, status string) {
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
return
}
queue.Status = status
now := time.Now()
if status == "running" && queue.StartedAt == nil {
queue.StartedAt = &now
}
if status == "completed" || status == "cancelled" {
queue.CompletedAt = &now
}
// 同步到数据库
if m.db != nil {
if err := m.db.UpdateBatchQueueStatus(queueID, status); err != nil {
// 记录错误但继续(使用内存缓存)
}
}
}
// UpdateTaskMessage 更新任务消息(仅限待执行状态)
func (m *BatchTaskManager) UpdateTaskMessage(queueID, taskID, message string) error {
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
return fmt.Errorf("队列不存在")
}
// 检查队列状态,只有待执行状态的队列才能编辑任务
if queue.Status != "pending" {
return fmt.Errorf("只有待执行状态的队列才能编辑任务")
}
// 查找并更新任务
for _, task := range queue.Tasks {
if task.ID == taskID {
// 只有待执行状态的任务才能编辑
if task.Status != "pending" {
return fmt.Errorf("只有待执行状态的任务才能编辑")
}
task.Message = message
// 同步到数据库
if m.db != nil {
if err := m.db.UpdateBatchTaskMessage(queueID, taskID, message); err != nil {
return fmt.Errorf("更新任务消息失败: %w", err)
}
}
return nil
}
}
return fmt.Errorf("任务不存在")
}
// AddTaskToQueue 添加任务到队列(仅限待执行状态)
func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask, error) {
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
return nil, fmt.Errorf("队列不存在")
}
// 检查队列状态,只有待执行状态的队列才能添加任务
if queue.Status != "pending" {
return nil, fmt.Errorf("只有待执行状态的队列才能添加任务")
}
if message == "" {
return nil, fmt.Errorf("任务消息不能为空")
}
// 生成任务ID
taskID := generateShortID()
task := &BatchTask{
ID: taskID,
Message: message,
Status: "pending",
}
// 添加到内存队列
queue.Tasks = append(queue.Tasks, task)
// 同步到数据库
if m.db != nil {
if err := m.db.AddBatchTask(queueID, taskID, message); err != nil {
// 如果数据库保存失败,从内存中移除
queue.Tasks = queue.Tasks[:len(queue.Tasks)-1]
return nil, fmt.Errorf("添加任务失败: %w", err)
}
}
return task, nil
}
// DeleteTask 删除任务(仅限待执行状态)
func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
return fmt.Errorf("队列不存在")
}
// 检查队列状态,只有待执行状态的队列才能删除任务
if queue.Status != "pending" {
return fmt.Errorf("只有待执行状态的队列才能删除任务")
}
// 查找并删除任务
taskIndex := -1
for i, task := range queue.Tasks {
if task.ID == taskID {
// 只有待执行状态的任务才能删除
if task.Status != "pending" {
return fmt.Errorf("只有待执行状态的任务才能删除")
}
taskIndex = i
break
}
}
if taskIndex == -1 {
return fmt.Errorf("任务不存在")
}
// 从内存队列中删除
queue.Tasks = append(queue.Tasks[:taskIndex], queue.Tasks[taskIndex+1:]...)
// 同步到数据库
if m.db != nil {
if err := m.db.DeleteBatchTask(queueID, taskID); err != nil {
// 如果数据库删除失败,恢复内存中的任务
// 这里需要重新插入,但为了简化,我们只记录错误
return fmt.Errorf("删除任务失败: %w", err)
}
}
return nil
}
// GetNextTask 获取下一个待执行的任务
func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
queue, exists := m.queues[queueID]
if !exists {
return nil, false
}
for i := queue.CurrentIndex; i < len(queue.Tasks); i++ {
task := queue.Tasks[i]
if task.Status == "pending" {
queue.CurrentIndex = i
return task, true
}
}
return nil, false
}
// MoveToNextTask 移动到下一个任务
func (m *BatchTaskManager) MoveToNextTask(queueID string) {
m.mu.Lock()
defer m.mu.Unlock()
queue, exists := m.queues[queueID]
if !exists {
return
}
queue.CurrentIndex++
// 同步到数据库
if m.db != nil {
if err := m.db.UpdateBatchQueueCurrentIndex(queueID, queue.CurrentIndex); err != nil {
// 记录错误但继续(使用内存缓存)
}
}
}
// SetTaskCancel 设置当前任务的取消函数
func (m *BatchTaskManager) SetTaskCancel(queueID string, cancel context.CancelFunc) {
m.mu.Lock()
defer m.mu.Unlock()
if cancel != nil {
m.taskCancels[queueID] = cancel
} else {
delete(m.taskCancels, queueID)
}
}
// PauseQueue 暂停队列
func (m *BatchTaskManager) PauseQueue(queueID string) bool {
m.mu.Lock()
queue, exists := m.queues[queueID]
if !exists {
m.mu.Unlock()
return false
}
if queue.Status != "running" {
m.mu.Unlock()
return false
}
queue.Status = "paused"
// 取消当前正在执行的任务(通过取消context)
if cancel, exists := m.taskCancels[queueID]; exists {
cancel()
delete(m.taskCancels, queueID)
}
m.mu.Unlock()
// 同步队列状态到数据库
if m.db != nil {
if err := m.db.UpdateBatchQueueStatus(queueID, "paused"); err != nil {
// 记录错误但继续(使用内存缓存)
}
}
return true
}
// CancelQueue 取消队列(保留此方法以保持向后兼容,但建议使用PauseQueue)
func (m *BatchTaskManager) CancelQueue(queueID string) bool {
m.mu.Lock()
queue, exists := m.queues[queueID]
if !exists {
m.mu.Unlock()
return false
}
if queue.Status == "completed" || queue.Status == "cancelled" {
m.mu.Unlock()
return false
}
queue.Status = "cancelled"
now := time.Now()
queue.CompletedAt = &now
// 取消所有待执行的任务
for _, task := range queue.Tasks {
if task.Status == "pending" {
task.Status = "cancelled"
task.CompletedAt = &now
// 同步到数据库
if m.db != nil {
m.db.UpdateBatchTaskStatus(queueID, task.ID, "cancelled", "", "", "")
}
}
}
// 取消当前正在执行的任务
if cancel, exists := m.taskCancels[queueID]; exists {
cancel()
delete(m.taskCancels, queueID)
}
m.mu.Unlock()
// 同步队列状态到数据库
if m.db != nil {
if err := m.db.UpdateBatchQueueStatus(queueID, "cancelled"); err != nil {
// 记录错误但继续(使用内存缓存)
}
}
return true
}
// DeleteQueue 删除队列
func (m *BatchTaskManager) DeleteQueue(queueID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
_, exists := m.queues[queueID]
if !exists {
return false
}
// 清理取消函数
delete(m.taskCancels, queueID)
// 从数据库删除
if m.db != nil {
if err := m.db.DeleteBatchQueue(queueID); err != nil {
// 记录错误但继续(使用内存缓存)
}
}
delete(m.queues, queueID)
return true
}
// generateShortID 生成短ID
func generateShortID() string {
b := make([]byte, 4)
rand.Read(b)
return time.Now().Format("150405") + "-" + hex.EncodeToString(b)
}
+96 -12
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"cyberstrike-ai/internal/database" "cyberstrike-ai/internal/database"
@@ -66,8 +67,10 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
// 解析状态筛选参数 // 解析状态筛选参数
status := c.Query("status") status := c.Query("status")
// 解析工具筛选参数
toolName := c.Query("tool")
executions, total := h.loadExecutionsWithPagination(page, pageSize, status) executions, total := h.loadExecutionsWithPagination(page, pageSize, status, toolName)
stats := h.loadStats() stats := h.loadStats()
totalPages := (total + pageSize - 1) / pageSize totalPages := (total + pageSize - 1) / pageSize
@@ -87,18 +90,21 @@ func (h *MonitorHandler) Monitor(c *gin.Context) {
} }
func (h *MonitorHandler) loadExecutions() []*mcp.ToolExecution { func (h *MonitorHandler) loadExecutions() []*mcp.ToolExecution {
executions, _ := h.loadExecutionsWithPagination(1, 1000, "") executions, _ := h.loadExecutionsWithPagination(1, 1000, "", "")
return executions return executions
} }
func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status string) ([]*mcp.ToolExecution, int) { func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status, toolName string) ([]*mcp.ToolExecution, int) {
if h.db == nil { if h.db == nil {
allExecutions := h.mcpServer.GetAllExecutions() allExecutions := h.mcpServer.GetAllExecutions()
// 如果指定了状态筛选,先进行筛选 // 如果指定了状态筛选或工具筛选,先进行筛选
if status != "" { if status != "" || toolName != "" {
filtered := make([]*mcp.ToolExecution, 0) filtered := make([]*mcp.ToolExecution, 0)
for _, exec := range allExecutions { for _, exec := range allExecutions {
if exec.Status == status { matchStatus := status == "" || exec.Status == status
// 支持部分匹配(模糊搜索)
matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName))
if matchStatus && matchTool {
filtered = append(filtered, exec) filtered = append(filtered, exec)
} }
} }
@@ -117,15 +123,18 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
} }
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
executions, err := h.db.LoadToolExecutionsWithPagination(offset, pageSize, status) executions, err := h.db.LoadToolExecutionsWithPagination(offset, pageSize, status, toolName)
if err != nil { if err != nil {
h.logger.Warn("从数据库加载执行记录失败,回退到内存数据", zap.Error(err)) h.logger.Warn("从数据库加载执行记录失败,回退到内存数据", zap.Error(err))
allExecutions := h.mcpServer.GetAllExecutions() allExecutions := h.mcpServer.GetAllExecutions()
// 如果指定了状态筛选,先进行筛选 // 如果指定了状态筛选或工具筛选,先进行筛选
if status != "" { if status != "" || toolName != "" {
filtered := make([]*mcp.ToolExecution, 0) filtered := make([]*mcp.ToolExecution, 0)
for _, exec := range allExecutions { for _, exec := range allExecutions {
if exec.Status == status { matchStatus := status == "" || exec.Status == status
// 支持部分匹配(模糊搜索)
matchTool := toolName == "" || strings.Contains(strings.ToLower(exec.ToolName), strings.ToLower(toolName))
if matchStatus && matchTool {
filtered = append(filtered, exec) filtered = append(filtered, exec)
} }
} }
@@ -143,8 +152,8 @@ func (h *MonitorHandler) loadExecutionsWithPagination(page, pageSize int, status
return allExecutions[offset:end], total return allExecutions[offset:end], total
} }
// 获取总数(考虑状态筛选) // 获取总数(考虑状态筛选和工具筛选
total, err := h.db.CountToolExecutions(status) total, err := h.db.CountToolExecutions(status, toolName)
if err != nil { if err != nil {
h.logger.Warn("获取执行记录总数失败", zap.Error(err)) h.logger.Warn("获取执行记录总数失败", zap.Error(err))
// 回退:使用已加载的记录数估算 // 回退:使用已加载的记录数估算
@@ -298,4 +307,79 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"}) c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"})
} }
// DeleteExecutions 批量删除执行记录
func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
var request struct {
IDs []string `json:"ids"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
return
}
if len(request.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "执行记录ID列表不能为空"})
return
}
// 如果使用数据库,先获取执行记录信息,然后删除并更新统计
if h.db != nil {
// 先获取执行记录信息(用于更新统计)
executions, err := h.db.GetToolExecutionsByIds(request.IDs)
if err != nil {
h.logger.Error("获取执行记录失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取执行记录失败: " + err.Error()})
return
}
// 按工具名称分组统计需要减少的数量
toolStats := make(map[string]struct {
totalCalls int
successCalls int
failedCalls int
})
for _, exec := range executions {
if exec.ToolName == "" {
continue
}
stats := toolStats[exec.ToolName]
stats.totalCalls++
if exec.Status == "failed" {
stats.failedCalls++
} else if exec.Status == "completed" {
stats.successCalls++
}
toolStats[exec.ToolName] = stats
}
// 批量删除执行记录
err = h.db.DeleteToolExecutions(request.IDs)
if err != nil {
h.logger.Error("批量删除执行记录失败", zap.Error(err), zap.Int("count", len(request.IDs)))
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量删除执行记录失败: " + err.Error()})
return
}
// 更新统计信息(减少相应的计数)
for toolName, stats := range toolStats {
if err := h.db.DecreaseToolStats(toolName, stats.totalCalls, stats.successCalls, stats.failedCalls); err != nil {
h.logger.Warn("更新统计信息失败", zap.Error(err), zap.String("toolName", toolName))
// 不返回错误,因为记录已经删除成功
}
}
h.logger.Info("批量删除执行记录成功", zap.Int("count", len(request.IDs)))
c.JSON(http.StatusOK, gin.H{"message": "成功删除执行记录", "deleted": len(executions)})
return
}
// 如果不使用数据库,尝试从内存中删除(内部MCP服务器)
// 注意:内存中的记录可能已经被清理,所以这里只记录日志
h.logger.Info("尝试批量删除内存中的执行记录", zap.Int("count", len(request.IDs)))
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"})
}
+87
View File
@@ -23,16 +23,31 @@ type AgentTask struct {
cancel func(error) cancel func(error)
} }
// CompletedTask 已完成的任务(用于历史记录)
type CompletedTask struct {
ConversationID string `json:"conversationId"`
Message string `json:"message,omitempty"`
StartedAt time.Time `json:"startedAt"`
CompletedAt time.Time `json:"completedAt"`
Status string `json:"status"`
}
// AgentTaskManager 管理正在运行的Agent任务 // AgentTaskManager 管理正在运行的Agent任务
type AgentTaskManager struct { type AgentTaskManager struct {
mu sync.RWMutex mu sync.RWMutex
tasks map[string]*AgentTask tasks map[string]*AgentTask
completedTasks []*CompletedTask // 最近完成的任务历史
maxHistorySize int // 最大历史记录数
historyRetention time.Duration // 历史记录保留时间
} }
// NewAgentTaskManager 创建任务管理器 // NewAgentTaskManager 创建任务管理器
func NewAgentTaskManager() *AgentTaskManager { func NewAgentTaskManager() *AgentTaskManager {
return &AgentTaskManager{ return &AgentTaskManager{
tasks: make(map[string]*AgentTask), tasks: make(map[string]*AgentTask),
completedTasks: make([]*CompletedTask, 0),
maxHistorySize: 50, // 最多保留50条历史记录
historyRetention: 24 * time.Hour, // 保留24小时
} }
} }
@@ -118,9 +133,49 @@ func (m *AgentTaskManager) FinishTask(conversationID string, finalStatus string)
task.Status = finalStatus task.Status = finalStatus
} }
// 保存到历史记录
completedTask := &CompletedTask{
ConversationID: task.ConversationID,
Message: task.Message,
StartedAt: task.StartedAt,
CompletedAt: time.Now(),
Status: finalStatus,
}
// 添加到历史记录
m.completedTasks = append(m.completedTasks, completedTask)
// 清理过期和过多的历史记录
m.cleanupHistory()
// 从运行任务中移除
delete(m.tasks, conversationID) delete(m.tasks, conversationID)
} }
// cleanupHistory 清理过期的历史记录
func (m *AgentTaskManager) cleanupHistory() {
now := time.Now()
cutoffTime := now.Add(-m.historyRetention)
// 过滤掉过期的记录
validTasks := make([]*CompletedTask, 0, len(m.completedTasks))
for _, task := range m.completedTasks {
if task.CompletedAt.After(cutoffTime) {
validTasks = append(validTasks, task)
}
}
// 如果仍然超过最大数量,只保留最新的
if len(validTasks) > m.maxHistorySize {
// 按完成时间排序,保留最新的
// 由于是追加的,最新的在最后,所以直接取最后N个
start := len(validTasks) - m.maxHistorySize
validTasks = validTasks[start:]
}
m.completedTasks = validTasks
}
// GetActiveTasks 返回所有正在运行的任务 // GetActiveTasks 返回所有正在运行的任务
func (m *AgentTaskManager) GetActiveTasks() []*AgentTask { func (m *AgentTaskManager) GetActiveTasks() []*AgentTask {
m.mu.RLock() m.mu.RLock()
@@ -137,3 +192,35 @@ func (m *AgentTaskManager) GetActiveTasks() []*AgentTask {
} }
return result return result
} }
// GetCompletedTasks 返回最近完成的任务历史
func (m *AgentTaskManager) GetCompletedTasks() []*CompletedTask {
m.mu.RLock()
defer m.mu.RUnlock()
// 清理过期记录(只读锁,不影响其他操作)
// 注意:这里不能直接调用cleanupHistory,因为需要写锁
// 所以返回时过滤过期记录
now := time.Now()
cutoffTime := now.Add(-m.historyRetention)
result := make([]*CompletedTask, 0, len(m.completedTasks))
for _, task := range m.completedTasks {
if task.CompletedAt.After(cutoffTime) {
result = append(result, task)
}
}
// 按完成时间倒序排序(最新的在前)
// 由于是追加的,最新的在最后,需要反转
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
// 限制返回数量
if len(result) > m.maxHistorySize {
result = result[:m.maxHistorySize]
}
return result
}
+17 -1
View File
@@ -639,7 +639,12 @@ func (m *Manager) UpdateItem(id, category, title, content string) (*KnowledgeIte
// 删除旧目录(如果为空) // 删除旧目录(如果为空)
oldDir := filepath.Dir(item.FilePath) oldDir := filepath.Dir(item.FilePath)
if entries, err := os.ReadDir(oldDir); err == nil && len(entries) == 0 { if entries, err := os.ReadDir(oldDir); err == nil && len(entries) == 0 {
os.Remove(oldDir) // 只有当目录不是知识库根目录时才删除(避免删除根目录)
if oldDir != m.basePath {
if err := os.Remove(oldDir); err != nil {
m.logger.Warn("删除空目录失败", zap.String("dir", oldDir), zap.Error(err))
}
}
} }
} }
@@ -686,6 +691,17 @@ func (m *Manager) DeleteItem(id string) error {
return fmt.Errorf("删除知识项失败: %w", err) return fmt.Errorf("删除知识项失败: %w", err)
} }
// 删除空目录(如果为空)
dir := filepath.Dir(filePath)
if entries, err := os.ReadDir(dir); err == nil && len(entries) == 0 {
// 只有当目录不是知识库根目录时才删除(避免删除根目录)
if dir != m.basePath {
if err := os.Remove(dir); err != nil {
m.logger.Warn("删除空目录失败", zap.String("dir", dir), zap.Error(err))
}
}
}
return nil return nil
} }
+930 -1
View File
@@ -2335,6 +2335,75 @@ header {
color: var(--text-secondary); color: var(--text-secondary);
} }
/* 工具调用状态徽章 */
.tool-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 8px;
white-space: nowrap;
vertical-align: middle;
}
.tool-status-badge.tool-status-running {
background: rgba(0, 102, 255, 0.12);
color: var(--accent-color);
border: 1px solid rgba(0, 102, 255, 0.3);
}
.tool-status-badge.tool-status-running::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-color);
display: inline-block;
animation: tool-running-pulse 1.5s infinite;
}
@keyframes tool-running-pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
.tool-status-badge.tool-status-completed {
background: rgba(40, 167, 69, 0.12);
color: var(--success-color);
border: 1px solid rgba(40, 167, 69, 0.3);
}
.tool-status-badge.tool-status-failed {
background: rgba(220, 53, 69, 0.12);
color: var(--error-color);
border: 1px solid rgba(220, 53, 69, 0.3);
}
/* 工具调用项状态样式 */
.timeline-item-tool_call.tool-call-running {
border-left-color: var(--accent-color);
background: rgba(0, 102, 255, 0.08);
}
.timeline-item-tool_call.tool-call-completed {
border-left-color: var(--success-color);
background: rgba(40, 167, 69, 0.08);
}
.timeline-item-tool_call.tool-call-failed {
border-left-color: var(--error-color);
background: rgba(220, 53, 69, 0.08);
}
/* 活跃任务栏 */ /* 活跃任务栏 */
.active-tasks-bar { .active-tasks-bar {
display: none; display: none;
@@ -2967,6 +3036,9 @@ header {
.pagination-info { .pagination-info {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-secondary); color: var(--text-secondary);
display: flex;
align-items: center;
gap: 16px;
} }
.pagination-controls { .pagination-controls {
@@ -2987,6 +3059,36 @@ header {
cursor: not-allowed; cursor: not-allowed;
} }
.pagination-page-size {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
color: var(--text-secondary);
}
.pagination-page-size select {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
min-width: 60px;
}
.pagination-page-size select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
}
.pagination-page-size select:hover {
border-color: var(--accent-color);
}
.pagination-btn { .pagination-btn {
padding: 6px 12px; padding: 6px 12px;
font-size: 0.875rem; font-size: 0.875rem;
@@ -3192,6 +3294,47 @@ header {
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
min-width: 140px;
max-width: 200px;
}
.monitor-section .section-actions select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
}
.monitor-section .section-actions select:hover {
border-color: var(--accent-color);
}
.monitor-section .section-actions input[type="text"] {
margin-left: 6px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.875rem;
transition: all 0.2s;
min-width: 180px;
max-width: 250px;
}
.monitor-section .section-actions input[type="text"]:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
}
.monitor-section .section-actions input[type="text"]:hover {
border-color: var(--accent-color);
}
.monitor-section .section-actions input[type="text"]::placeholder {
color: var(--text-muted);
} }
.monitor-stats-grid { .monitor-stats-grid {
@@ -3304,6 +3447,50 @@ header {
color: #c82333; color: #c82333;
} }
.monitor-batch-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 16px;
gap: 16px;
}
.monitor-batch-actions .batch-actions-info {
display: flex;
align-items: center;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.monitor-batch-actions .batch-actions-buttons {
display: flex;
align-items: center;
gap: 8px;
}
.monitor-batch-actions .batch-actions-buttons button {
padding: 6px 12px;
font-size: 0.75rem;
}
.monitor-execution-checkbox {
cursor: pointer;
width: 18px;
height: 18px;
accent-color: var(--accent-color);
}
.monitor-table th:first-child,
.monitor-table td:first-child {
text-align: center;
width: 40px;
}
.monitor-vuln-container { .monitor-vuln-container {
display: grid; display: grid;
gap: 16px; gap: 16px;
@@ -4019,7 +4206,7 @@ header {
.attack-chain-container { .attack-chain-container {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
background: #ffffff; // 使 background: #ffffff;
border: none; border: none;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -6000,6 +6187,748 @@ header {
background: rgba(0, 102, 255, 0.1); background: rgba(0, 102, 255, 0.1);
} }
/* 任务管理页面样式 */
.tasks-stats-bar {
display: flex;
gap: 24px;
padding: 16px 20px;
background: linear-gradient(135deg, rgba(0, 102, 255, 0.05) 0%, rgba(0, 102, 255, 0.02) 100%);
border: 1px solid rgba(0, 102, 255, 0.15);
border-radius: 12px;
box-shadow: var(--shadow-sm);
margin-bottom: 24px;
flex-wrap: wrap;
}
.tasks-controls {
margin-bottom: 24px;
}
.tasks-filters {
display: flex;
gap: 16px;
align-items: flex-end;
flex-wrap: wrap;
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
}
.tasks-filters label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
.tasks-filters select {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary);
color: var(--text-primary);
min-width: 150px;
cursor: pointer;
transition: all 0.2s;
}
.tasks-filters select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
}
.tasks-filters select:hover {
border-color: var(--accent-color);
}
.tasks-filters input[type="text"] {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary);
color: var(--text-primary);
width: 100%;
transition: all 0.2s;
}
.tasks-filters input[type="text"]:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
}
.tasks-filters input[type="text"]:hover {
border-color: var(--accent-color);
}
.tasks-batch-actions {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(0, 102, 255, 0.05);
border: 1px solid rgba(0, 102, 255, 0.2);
border-radius: 8px;
margin-top: 12px;
}
.tasks-batch-actions span {
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
}
.auto-refresh-toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
color: var(--text-primary);
cursor: pointer;
user-select: none;
}
.auto-refresh-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--accent-color);
}
.page-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.show-history-toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
color: var(--text-primary);
cursor: pointer;
user-select: none;
margin-left: auto;
}
.show-history-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--accent-color);
}
.tasks-history-section {
margin-top: 32px;
padding-top: 24px;
border-top: 2px solid var(--border-color);
}
.tasks-history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.tasks-history-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-secondary);
}
.task-item-history {
opacity: 0.85;
}
.task-item-history:hover {
opacity: 1;
}
.task-history-badge {
font-size: 0.875rem;
margin-left: 4px;
opacity: 0.7;
}
.task-stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-stat-label {
font-size: 0.8125rem;
color: var(--text-secondary);
font-weight: 500;
}
.task-stat-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--accent-color);
}
.tasks-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.tasks-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-secondary);
}
.tasks-empty p {
font-size: 1rem;
margin-bottom: 16px;
}
.task-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.task-item:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.task-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 200px;
}
.task-checkbox {
display: flex;
align-items: center;
cursor: pointer;
}
.task-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--accent-color);
}
.task-checkbox-placeholder {
width: 18px;
height: 18px;
}
.task-status {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
}
.task-status-running {
background: rgba(0, 123, 255, 0.1);
color: var(--accent-color);
border: 1px solid rgba(0, 123, 255, 0.3);
}
.task-status-cancelling {
background: rgba(255, 193, 7, 0.1);
color: #b8860b;
border: 1px solid rgba(255, 193, 7, 0.3);
}
.task-status-completed {
background: rgba(40, 167, 69, 0.1);
color: var(--success-color);
border: 1px solid rgba(40, 167, 69, 0.3);
}
.task-status-failed,
.task-status-timeout {
background: rgba(220, 53, 69, 0.1);
color: var(--error-color);
border: 1px solid rgba(220, 53, 69, 0.3);
}
.task-status-cancelled {
background: rgba(108, 117, 125, 0.1);
color: var(--text-secondary);
border: 1px solid rgba(108, 117, 125, 0.3);
}
.task-status-unknown {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.task-message {
font-size: 0.9375rem;
color: var(--text-primary);
font-weight: 500;
word-break: break-word;
}
.task-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.task-time {
font-size: 0.8125rem;
color: var(--text-secondary);
white-space: nowrap;
}
.task-duration {
font-size: 0.8125rem;
color: var(--accent-color);
font-weight: 500;
white-space: nowrap;
padding: 4px 8px;
background: rgba(0, 102, 255, 0.1);
border-radius: 4px;
}
.task-details {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
}
.task-id-label {
color: var(--text-secondary);
font-weight: 500;
}
.task-id-value {
color: var(--text-primary);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 4px;
word-break: break-all;
cursor: pointer;
transition: all 0.2s;
}
.task-id-value:hover {
background: var(--bg-tertiary);
color: var(--accent-color);
}
@media (max-width: 768px) {
.tasks-stats-bar {
flex-direction: column;
gap: 12px;
}
.task-stat-item {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.tasks-filters {
flex-direction: column;
align-items: stretch;
}
.tasks-filters select {
min-width: 100%;
}
.tasks-batch-actions {
flex-direction: column;
align-items: stretch;
}
.page-header-actions {
flex-direction: column;
align-items: stretch;
}
.task-header {
flex-direction: column;
align-items: flex-start;
}
.task-actions {
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
}
.task-duration {
font-size: 0.75rem;
padding: 2px 6px;
}
}
/* 批量任务相关样式 */
.batch-queues-section {
margin-top: 16px;
padding-top: 8px;
}
.batch-queues-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.batch-queues-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.batch-queues-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.batch-queue-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
cursor: pointer;
}
.batch-queue-item:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
border-color: var(--accent-color);
}
.batch-queue-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.batch-queue-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
flex-wrap: wrap;
}
.batch-queue-status {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
}
.batch-queue-status-pending {
background: rgba(108, 117, 125, 0.1);
color: var(--text-secondary);
border: 1px solid rgba(108, 117, 125, 0.3);
}
.batch-queue-status-running {
background: rgba(0, 123, 255, 0.1);
color: var(--accent-color);
border: 1px solid rgba(0, 123, 255, 0.3);
}
.batch-queue-status-completed {
background: rgba(40, 167, 69, 0.1);
color: var(--success-color);
border: 1px solid rgba(40, 167, 69, 0.3);
}
.batch-queue-status-cancelled {
background: rgba(108, 117, 125, 0.1);
color: var(--text-secondary);
border: 1px solid rgba(108, 117, 125, 0.3);
}
.batch-queue-id {
font-size: 0.8125rem;
color: var(--text-secondary);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.batch-queue-time {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.batch-queue-progress {
display: flex;
align-items: center;
gap: 12px;
min-width: 200px;
}
.batch-queue-progress-bar {
flex: 1;
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
}
.batch-queue-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-color) 0%, var(--accent-hover) 100%);
transition: width 0.3s ease;
}
.batch-queue-progress-text {
font-size: 0.8125rem;
color: var(--text-secondary);
white-space: nowrap;
min-width: 60px;
text-align: right;
}
.batch-queue-stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.batch-queue-stats span {
white-space: nowrap;
}
.batch-queue-detail-info {
margin-bottom: 24px;
padding: 16px;
background: var(--bg-secondary);
border-radius: 8px;
}
.batch-queue-detail-info .detail-item {
margin-bottom: 8px;
font-size: 0.875rem;
}
.batch-queue-detail-info .detail-item strong {
color: var(--text-primary);
margin-right: 8px;
}
.batch-queue-tasks-list {
max-height: 500px;
overflow-y: auto;
}
.batch-queue-tasks-list h4 {
margin: 0 0 16px 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.batch-task-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
transition: all 0.2s ease;
}
.batch-task-item:hover {
box-shadow: var(--shadow-sm);
}
.batch-task-item-active {
border-color: var(--accent-color);
background: rgba(0, 102, 255, 0.02);
}
.batch-task-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.batch-task-header .btn-small {
margin-left: auto;
flex-shrink: 0;
}
.batch-task-header .batch-task-edit-btn {
margin-left: 8px;
}
.batch-task-header .batch-task-delete-btn {
margin-left: 8px;
}
.batch-task-index {
font-weight: 600;
color: var(--text-secondary);
min-width: 30px;
}
.batch-task-status {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
}
.batch-task-status-pending {
background: rgba(108, 117, 125, 0.1);
color: var(--text-secondary);
}
.batch-task-status-running {
background: rgba(0, 123, 255, 0.1);
color: var(--accent-color);
}
.batch-task-status-completed {
background: rgba(40, 167, 69, 0.1);
color: var(--success-color);
}
.batch-task-status-failed {
background: rgba(220, 53, 69, 0.1);
color: var(--error-color);
}
.batch-task-status-cancelled {
background: rgba(108, 117, 125, 0.1);
color: var(--text-secondary);
}
.batch-task-message {
flex: 1;
font-size: 0.875rem;
color: var(--text-primary);
word-break: break-word;
}
.batch-task-time {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 4px;
}
.batch-task-error {
font-size: 0.8125rem;
color: var(--error-color);
margin-top: 8px;
padding: 8px;
background: rgba(220, 53, 69, 0.05);
border-radius: 4px;
border-left: 3px solid var(--error-color);
}
.batch-task-result {
font-size: 0.8125rem;
color: var(--text-primary);
margin-top: 8px;
padding: 8px;
background: var(--bg-secondary);
border-radius: 4px;
max-height: 150px;
overflow-y: auto;
word-break: break-word;
}
.batch-import-stats {
display: none;
padding: 8px 12px;
background: rgba(0, 102, 255, 0.05);
border: 1px solid rgba(0, 102, 255, 0.2);
border-radius: 6px;
font-size: 0.875rem;
color: var(--accent-color);
}
.batch-import-stat {
font-weight: 500;
}
@media (max-width: 768px) {
.batch-queue-header {
flex-direction: column;
align-items: flex-start;
}
.batch-queue-progress {
width: 100%;
}
.batch-queue-stats {
flex-direction: column;
gap: 8px;
}
.batch-task-header {
flex-direction: column;
align-items: flex-start;
}
}
/* 漏洞管理页面样式 */ /* 漏洞管理页面样式 */
.vulnerability-dashboard { .vulnerability-dashboard {
margin-bottom: 24px; margin-bottom: 24px;
+87 -36
View File
@@ -871,10 +871,6 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
// 渲染过程详情 // 渲染过程详情
function renderProcessDetails(messageId, processDetails) { function renderProcessDetails(messageId, processDetails) {
if (!processDetails || processDetails.length === 0) {
return;
}
const messageElement = document.getElementById(messageId); const messageElement = document.getElementById(messageId);
if (!messageElement) { if (!messageElement) {
return; return;
@@ -942,7 +938,7 @@ function renderProcessDetails(messageId, processDetails) {
} }
} }
// 创建时间线 // 创建时间线(即使没有processDetails也要创建,以便展开详情按钮能正常工作)
const timelineId = detailsId + '-timeline'; const timelineId = detailsId + '-timeline';
let timeline = document.getElementById(timelineId); let timeline = document.getElementById(timelineId);
@@ -958,9 +954,19 @@ function renderProcessDetails(messageId, processDetails) {
detailsContainer.appendChild(contentDiv); detailsContainer.appendChild(contentDiv);
} }
// 如果没有processDetails或为空,显示空状态
if (!processDetails || processDetails.length === 0) {
// 显示空状态提示
timeline.innerHTML = '<div class="progress-timeline-empty">暂无过程详情(可能执行过快或未触发详细事件)</div>';
// 默认折叠
timeline.classList.remove('expanded');
return;
}
// 清空时间线并重新渲染 // 清空时间线并重新渲染
timeline.innerHTML = ''; timeline.innerHTML = '';
// 渲染每个过程详情事件 // 渲染每个过程详情事件
processDetails.forEach(detail => { processDetails.forEach(detail => {
const eventType = detail.eventType || ''; const eventType = detail.eventType || '';
@@ -1449,7 +1455,9 @@ function formatConversationTimestamp(dateObj, todayStart, yesterdayStart) {
if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) { if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) {
return ''; return '';
} }
const referenceToday = todayStart || new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate()); // 如果没有传入 todayStart,使用当前日期作为参考
const now = new Date();
const referenceToday = todayStart || new Date(now.getFullYear(), now.getMonth(), now.getDate());
const referenceYesterday = yesterdayStart || new Date(referenceToday.getTime() - 24 * 60 * 60 * 1000); const referenceYesterday = yesterdayStart || new Date(referenceToday.getTime() - 24 * 60 * 60 * 1000);
const messageDate = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate()); const messageDate = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate());
@@ -1604,18 +1612,20 @@ async function loadConversation(conversationId) {
// 传递消息的创建时间 // 传递消息的创建时间
const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msg.createdAt); const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msg.createdAt);
// 如果有过程详情,显示它们 // 对于助手消息,总是渲染过程详情(即使没有processDetails也要显示展开详情按钮)
if (msg.processDetails && msg.processDetails.length > 0 && msg.role === 'assistant') { if (msg.role === 'assistant') {
// 延迟一下,确保消息已经渲染 // 延迟一下,确保消息已经渲染
setTimeout(() => { setTimeout(() => {
renderProcessDetails(messageId, msg.processDetails); renderProcessDetails(messageId, msg.processDetails || []);
// 检查是否有错误或取消事件,如果有,确保详情默认折叠 // 如果有过程详情,检查是否有错误或取消事件,如果有,确保详情默认折叠
if (msg.processDetails && msg.processDetails.length > 0) {
const hasErrorOrCancelled = msg.processDetails.some(d => const hasErrorOrCancelled = msg.processDetails.some(d =>
d.eventType === 'error' || d.eventType === 'cancelled' d.eventType === 'error' || d.eventType === 'cancelled'
); );
if (hasErrorOrCancelled) { if (hasErrorOrCancelled) {
collapseAllProgressDetails(messageId, null); collapseAllProgressDetails(messageId, null);
} }
}
}, 100); }, 100);
} }
}); });
@@ -3770,12 +3780,24 @@ let contextMenuConversationId = null;
let contextMenuGroupId = null; let contextMenuGroupId = null;
let groupsCache = []; let groupsCache = [];
let conversationGroupMappingCache = {}; let conversationGroupMappingCache = {};
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
// 加载分组列表 // 加载分组列表
async function loadGroups() { async function loadGroups() {
try { try {
const response = await apiFetch('/api/groups'); const response = await apiFetch('/api/groups');
groupsCache = await response.json(); if (!response.ok) {
groupsCache = [];
return;
}
const data = await response.json();
// 确保groupsCache是有效数组
if (Array.isArray(data)) {
groupsCache = data;
} else {
// 如果返回的不是数组,使用空数组(不打印警告,因为可能后端返回了错误格式但我们要优雅处理)
groupsCache = [];
}
const groupsList = document.getElementById('conversation-groups-list'); const groupsList = document.getElementById('conversation-groups-list');
if (!groupsList) return; if (!groupsList) return;
@@ -3899,13 +3921,10 @@ async function loadConversationsWithGroups(searchQuery = '') {
} }
// 如果没有搜索关键词,使用原有逻辑 // 如果没有搜索关键词,使用原有逻辑
// 如果对话在某个分组中,且当前不在分组详情页,则跳过 // "最近对话"列表应该只显示不在任何分组中的对话
if (currentGroupId === null && conversationGroupMappingCache[conv.id]) { // 无论是否在分组详情页,都不应该在"最近对话"中显示分组中的对话
return; if (conversationGroupMappingCache[conv.id]) {
} // 对话在某个分组中,不应该显示在"最近对话"列表中
// 如果当前在分组详情页,只显示该分组的对话
if (currentGroupId !== null && conversationGroupMappingCache[conv.id] !== currentGroupId) {
return; return;
} }
@@ -4048,8 +4067,12 @@ async function showConversationContextMenu(event) {
if (convId) { if (convId) {
try { try {
let isPinned = false; let isPinned = false;
if (currentGroupId) { // 检查对话是否真的在当前分组中
// 如果在分组详情页面,获取分组内置顶状态 const conversationGroupId = conversationGroupMappingCache[convId];
const isInCurrentGroup = currentGroupId && conversationGroupId === currentGroupId;
if (isInCurrentGroup) {
// 对话在当前分组中,获取分组内置顶状态
const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`); const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`);
if (response.ok) { if (response.ok) {
const groupConvs = await response.json(); const groupConvs = await response.json();
@@ -4059,7 +4082,7 @@ async function showConversationContextMenu(event) {
} }
} }
} else { } else {
// 不在分组详情页面,获取全局置顶状态 // 不在分组详情页面,或者对话不在当前分组中,获取全局置顶状态
const response = await apiFetch(`/api/conversations/${convId}`); const response = await apiFetch(`/api/conversations/${convId}`);
if (response.ok) { if (response.ok) {
const conv = await response.json(); const conv = await response.json();
@@ -4314,8 +4337,14 @@ async function pinConversation() {
if (!convId) return; if (!convId) return;
try { try {
// 如果当前分组详情页面,使用分组内置顶 // 检查对话是否真的在当前分组
if (currentGroupId) { // 如果对话已经从分组移出,conversationGroupMappingCache 中不会有该对话的映射
// 或者映射的分组ID不等于当前分组ID
const conversationGroupId = conversationGroupMappingCache[convId];
const isInCurrentGroup = currentGroupId && conversationGroupId === currentGroupId;
// 如果当前在分组详情页面,且对话确实在当前分组中,使用分组内置顶
if (isInCurrentGroup) {
// 获取当前对话在分组中的置顶状态 // 获取当前对话在分组中的置顶状态
const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`); const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`);
const groupConvs = await response.json(); const groupConvs = await response.json();
@@ -4337,7 +4366,7 @@ async function pinConversation() {
// 重新加载分组对话 // 重新加载分组对话
loadGroupConversations(currentGroupId); loadGroupConversations(currentGroupId);
} else { } else {
// 不在分组详情页面,使用全局置顶 // 不在分组详情页面,或者对话不在当前分组中,使用全局置顶
const response = await apiFetch(`/api/conversations/${convId}`); const response = await apiFetch(`/api/conversations/${convId}`);
const conv = await response.json(); const conv = await response.json();
const newPinned = !conv.pinned; const newPinned = !conv.pinned;
@@ -4627,27 +4656,30 @@ async function moveConversationToGroup(convId, groupId) {
const oldGroupId = conversationGroupMappingCache[convId]; const oldGroupId = conversationGroupMappingCache[convId];
conversationGroupMappingCache[convId] = groupId; conversationGroupMappingCache[convId] = groupId;
// 将新移动的对话添加到待保留映射中,防止后端API延迟导致映射丢失
pendingGroupMappings[convId] = groupId;
// 如果移动的是当前对话,更新 currentConversationGroupId // 如果移动的是当前对话,更新 currentConversationGroupId
if (currentConversationId === convId) { if (currentConversationId === convId) {
currentConversationGroupId = groupId; currentConversationGroupId = groupId;
} }
// 重新加载分组映射缓存,确保数据同步
await loadConversationGroupMapping();
// 如果当前在分组详情页面,重新加载分组对话 // 如果当前在分组详情页面,重新加载分组对话
if (currentGroupId) { if (currentGroupId) {
// 如果从当前分组移出,或者移动到当前分组,都需要重新加载 // 如果从当前分组移出,或者移动到当前分组,都需要重新加载
if (currentGroupId === oldGroupId || currentGroupId === groupId) { if (currentGroupId === oldGroupId || currentGroupId === groupId) {
await loadGroupConversations(currentGroupId); await loadGroupConversations(currentGroupId);
} }
} else {
// 如果不在分组详情页面,刷新最近对话列表
loadConversationsWithGroups();
} }
// 如果旧分组和新分组不同,且用户正在查看旧分组,也需要刷新旧分组 // 无论是否在分组详情页面,都需要刷新最近对话列表
// 但上面的逻辑已经处理了这种情况(currentGroupId === oldGroupId // 因为最近对话列表会根据分组映射缓存来过滤显示,需要立即更新
// loadConversationsWithGroups 内部会调用 loadConversationGroupMapping
// loadConversationGroupMapping 会保留 pendingGroupMappings 中的映射
await loadConversationsWithGroups();
// 注意:pendingGroupMappings 中的映射会在下次 loadConversationGroupMapping
// 成功从后端加载时自动清理(在 loadConversationGroupMapping 中处理)
// 刷新分组列表,更新高亮状态 // 刷新分组列表,更新高亮状态
await loadGroups(); await loadGroups();
@@ -4668,6 +4700,8 @@ async function removeConversationFromGroup(convId, groupId) {
// 更新缓存 - 立即删除,确保后续加载时能正确识别 // 更新缓存 - 立即删除,确保后续加载时能正确识别
delete conversationGroupMappingCache[convId]; delete conversationGroupMappingCache[convId];
// 同时从待保留映射中移除
delete pendingGroupMappings[convId];
// 如果移除的是当前对话,清除 currentConversationGroupId // 如果移除的是当前对话,清除 currentConversationGroupId
if (currentConversationId === convId) { if (currentConversationId === convId) {
@@ -4708,14 +4742,24 @@ async function loadConversationGroupMapping() {
groups = groupsCache; groups = groupsCache;
} else { } else {
const response = await apiFetch('/api/groups'); const response = await apiFetch('/api/groups');
if (!response.ok) {
// 如果API请求失败,使用空数组,不打印警告(这是正常错误处理)
groups = [];
} else {
groups = await response.json(); groups = await response.json();
} // 确保groups是有效数组,只在真正异常时才打印警告
// 确保groups是有效数组
if (!Array.isArray(groups)) { if (!Array.isArray(groups)) {
console.warn('loadConversationGroupMapping: groups不是有效数组,使用空数组'); // 只在返回的不是数组且不是null/undefined时才打印警告(可能是后端返回了错误格式)
if (groups !== null && groups !== undefined) {
console.warn('loadConversationGroupMapping: groups不是有效数组,使用空数组', groups);
}
groups = []; groups = [];
} }
}
}
// 保存待保留的映射
const preservedMappings = { ...pendingGroupMappings };
conversationGroupMappingCache = {}; conversationGroupMappingCache = {};
@@ -4726,9 +4770,16 @@ async function loadConversationGroupMapping() {
if (Array.isArray(conversations)) { if (Array.isArray(conversations)) {
conversations.forEach(conv => { conversations.forEach(conv => {
conversationGroupMappingCache[conv.id] = group.id; conversationGroupMappingCache[conv.id] = group.id;
// 如果这个对话在待保留映射中,从待保留映射中移除(因为已经从后端加载了)
if (preservedMappings[conv.id] === group.id) {
delete pendingGroupMappings[conv.id];
}
}); });
} }
} }
// 恢复待保留的映射(这些是后端API尚未同步的映射)
Object.assign(conversationGroupMappingCache, preservedMappings);
} catch (error) { } catch (error) {
console.error('加载对话分组映射失败:', error); console.error('加载对话分组映射失败:', error);
} }
+3 -4
View File
@@ -398,8 +398,8 @@ function updateKnowledgeStats(data, categoryCount) {
} }
} }
// 总分类数(来自分页信息) // 总分类数(来自分页信息,只有在未定义时才使用当前页分类数作为后备值
const totalCategories = knowledgePagination.total || categoryCount; const totalCategories = (knowledgePagination.total != null) ? knowledgePagination.total : categoryCount;
statsContainer.innerHTML = ` statsContainer.innerHTML = `
<div class="knowledge-stat-item"> <div class="knowledge-stat-item">
@@ -1035,8 +1035,7 @@ async function deleteKnowledgeItem(id) {
} }
} }
// 更新统计信息(临时更新,稍后会重新加载) // 不在这里更新统计信息,等待重新加载数据后由正确的逻辑更新
updateKnowledgeStatsAfterDelete();
} }
}, 300); }, 300);
} }
+288 -14
View File
@@ -3,6 +3,9 @@ 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']);
// 存储工具调用ID到DOM元素的映射,用于更新执行状态
const toolCallStatusMap = new Map();
const conversationExecutionTracker = { const conversationExecutionTracker = {
activeConversations: new Set(), activeConversations: new Set(),
update(tasks = []) { update(tasks = []) {
@@ -493,12 +496,26 @@ function handleStreamEvent(event, progressElement, progressId,
const toolName = toolInfo.toolName || '未知工具'; const toolName = toolInfo.toolName || '未知工具';
const index = toolInfo.index || 0; const index = toolInfo.index || 0;
const total = toolInfo.total || 0; const total = toolInfo.total || 0;
addTimelineItem(timeline, 'tool_call', { const toolCallId = toolInfo.toolCallId || null;
// 添加工具调用项,并标记为执行中
const toolCallItemId = addTimelineItem(timeline, 'tool_call', {
title: `🔧 调用工具: ${escapeHtml(toolName)} (${index}/${total})`, title: `🔧 调用工具: ${escapeHtml(toolName)} (${index}/${total})`,
message: event.message, message: event.message,
data: toolInfo, data: toolInfo,
expanded: false expanded: false
}); });
// 如果有toolCallId,存储映射关系以便后续更新状态
if (toolCallId && toolCallItemId) {
toolCallStatusMap.set(toolCallId, {
itemId: toolCallItemId,
timeline: timeline
});
// 添加执行中状态指示器
updateToolCallStatus(toolCallId, 'running');
}
break; break;
case 'tool_result': case 'tool_result':
@@ -507,6 +524,15 @@ function handleStreamEvent(event, progressElement, progressId,
const resultToolName = resultInfo.toolName || '未知工具'; const resultToolName = resultInfo.toolName || '未知工具';
const success = resultInfo.success !== false; const success = resultInfo.success !== false;
const statusIcon = success ? '✅' : '❌'; const statusIcon = success ? '✅' : '❌';
const resultToolCallId = resultInfo.toolCallId || null;
// 如果有关联的toolCallId,更新工具调用项的状态
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
// 从映射中移除(已完成)
toolCallStatusMap.delete(resultToolCallId);
}
addTimelineItem(timeline, 'tool_result', { addTimelineItem(timeline, 'tool_result', {
title: `${statusIcon} 工具 ${escapeHtml(resultToolName)} 执行${success ? '完成' : '失败'}`, title: `${statusIcon} 工具 ${escapeHtml(resultToolName)} 执行${success ? '完成' : '失败'}`,
message: event.message, message: event.message,
@@ -767,9 +793,46 @@ function handleStreamEvent(event, progressElement, progressId,
messagesDiv.scrollTop = messagesDiv.scrollHeight; messagesDiv.scrollTop = messagesDiv.scrollHeight;
} }
// 更新工具调用状态
function updateToolCallStatus(toolCallId, status) {
const mapping = toolCallStatusMap.get(toolCallId);
if (!mapping) return;
const item = document.getElementById(mapping.itemId);
if (!item) return;
const titleElement = item.querySelector('.timeline-item-title');
if (!titleElement) return;
// 移除之前的状态类
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
// 根据状态更新样式和文本
let statusText = '';
if (status === 'running') {
item.classList.add('tool-call-running');
statusText = ' <span class="tool-status-badge tool-status-running">执行中...</span>';
} else if (status === 'completed') {
item.classList.add('tool-call-completed');
statusText = ' <span class="tool-status-badge tool-status-completed">✅ 已完成</span>';
} else if (status === 'failed') {
item.classList.add('tool-call-failed');
statusText = ' <span class="tool-status-badge tool-status-failed">❌ 执行失败</span>';
}
// 更新标题(保留原有文本,追加状态)
const originalText = titleElement.innerHTML;
// 移除之前可能存在的状态标记
const cleanText = originalText.replace(/\s*<span class="tool-status-badge[^>]*>.*?<\/span>/g, '');
titleElement.innerHTML = cleanText + statusText;
}
// 添加时间线项目 // 添加时间线项目
function addTimelineItem(timeline, type, options) { function addTimelineItem(timeline, type, options) {
const item = document.createElement('div'); const item = document.createElement('div');
// 生成唯一ID
const itemId = 'timeline-item-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
item.id = itemId;
item.className = `timeline-item timeline-item-${type}`; item.className = `timeline-item timeline-item-${type}`;
const time = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const time = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
@@ -828,6 +891,9 @@ function addTimelineItem(timeline, type, options) {
if (!expanded && (type === 'tool_call' || type === 'tool_result')) { if (!expanded && (type === 'tool_call' || type === 'tool_result')) {
// 对于工具调用和结果,默认显示摘要 // 对于工具调用和结果,默认显示摘要
} }
// 返回item ID以便后续更新
return itemId;
} }
// 加载活跃任务列表 // 加载活跃任务列表
@@ -942,7 +1008,11 @@ const monitorState = {
lastFetchedAt: null, lastFetchedAt: null,
pagination: { pagination: {
page: 1, page: 1,
pageSize: 20, pageSize: (() => {
// 从 localStorage 读取保存的每页显示数量,默认为 20
const saved = localStorage.getItem('monitorPageSize');
return saved ? parseInt(saved, 10) : 20;
})(),
total: 0, total: 0,
totalPages: 0 totalPages: 0
} }
@@ -953,6 +1023,39 @@ function openMonitorPanel() {
if (typeof switchPage === 'function') { if (typeof switchPage === 'function') {
switchPage('mcp-monitor'); switchPage('mcp-monitor');
} }
// 初始化每页显示数量选择器
initializeMonitorPageSize();
}
// 初始化每页显示数量选择器
function initializeMonitorPageSize() {
const pageSizeSelect = document.getElementById('monitor-page-size');
if (pageSizeSelect) {
pageSizeSelect.value = monitorState.pagination.pageSize;
}
}
// 改变每页显示数量
function changeMonitorPageSize() {
const pageSizeSelect = document.getElementById('monitor-page-size');
if (!pageSizeSelect) {
return;
}
const newPageSize = parseInt(pageSizeSelect.value, 10);
if (isNaN(newPageSize) || newPageSize <= 0) {
return;
}
// 保存到 localStorage
localStorage.setItem('monitorPageSize', newPageSize.toString());
// 更新状态
monitorState.pagination.pageSize = newPageSize;
monitorState.pagination.page = 1; // 重置到第一页
// 刷新数据
refreshMonitorPanel(1);
} }
function closeMonitorPanel() { function closeMonitorPanel() {
@@ -974,12 +1077,17 @@ async function refreshMonitorPanel(page = null) {
// 获取当前的筛选条件 // 获取当前的筛选条件
const statusFilter = document.getElementById('monitor-status-filter'); const statusFilter = document.getElementById('monitor-status-filter');
const currentFilter = statusFilter ? statusFilter.value : 'all'; const toolFilter = document.getElementById('monitor-tool-filter');
const currentStatusFilter = statusFilter ? statusFilter.value : 'all';
const currentToolFilter = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
// 构建请求 URL // 构建请求 URL
let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`; let url = `/api/monitor?page=${currentPage}&page_size=${pageSize}`;
if (currentFilter && currentFilter !== 'all') { if (currentStatusFilter && currentStatusFilter !== 'all') {
url += `&status=${encodeURIComponent(currentFilter)}`; url += `&status=${encodeURIComponent(currentStatusFilter)}`;
}
if (currentToolFilter && currentToolFilter !== 'all') {
url += `&tool=${encodeURIComponent(currentToolFilter)}`;
} }
const response = await apiFetch(url, { method: 'GET' }); const response = await apiFetch(url, { method: 'GET' });
@@ -1003,8 +1111,11 @@ async function refreshMonitorPanel(page = null) {
} }
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt); renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
renderMonitorExecutions(monitorState.executions, currentFilter); renderMonitorExecutions(monitorState.executions, currentStatusFilter);
renderMonitorPagination(); renderMonitorPagination();
// 初始化每页显示数量选择器
initializeMonitorPageSize();
} catch (error) { } catch (error) {
console.error('刷新监控面板失败:', error); console.error('刷新监控面板失败:', error);
if (statsContainer) { if (statsContainer) {
@@ -1016,14 +1127,30 @@ async function refreshMonitorPanel(page = null) {
} }
} }
async function applyMonitorFilters() { // 处理工具搜索输入(防抖)
const statusFilter = document.getElementById('monitor-status-filter'); let toolFilterDebounceTimer = null;
const status = statusFilter ? statusFilter.value : 'all'; function handleToolFilterInput() {
// 当筛选条件改变时,从后端重新获取数据 // 清除之前的定时器
await refreshMonitorPanelWithFilter(status); if (toolFilterDebounceTimer) {
clearTimeout(toolFilterDebounceTimer);
} }
async function refreshMonitorPanelWithFilter(statusFilter = 'all') { // 设置新的定时器,500ms后执行筛选
toolFilterDebounceTimer = setTimeout(() => {
applyMonitorFilters();
}, 500);
}
async function applyMonitorFilters() {
const statusFilter = document.getElementById('monitor-status-filter');
const toolFilter = document.getElementById('monitor-tool-filter');
const status = statusFilter ? statusFilter.value : 'all';
const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
// 当筛选条件改变时,从后端重新获取数据
await refreshMonitorPanelWithFilter(status, tool);
}
async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter = 'all') {
const statsContainer = document.getElementById('monitor-stats'); const statsContainer = document.getElementById('monitor-stats');
const execContainer = document.getElementById('monitor-executions'); const execContainer = document.getElementById('monitor-executions');
@@ -1036,6 +1163,9 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all') {
if (statusFilter && statusFilter !== 'all') { if (statusFilter && statusFilter !== 'all') {
url += `&status=${encodeURIComponent(statusFilter)}`; url += `&status=${encodeURIComponent(statusFilter)}`;
} }
if (toolFilter && toolFilter !== 'all') {
url += `&tool=${encodeURIComponent(toolFilter)}`;
}
const response = await apiFetch(url, { method: 'GET' }); const response = await apiFetch(url, { method: 'GET' });
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
@@ -1060,6 +1190,9 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all') {
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt); renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
renderMonitorExecutions(monitorState.executions, statusFilter); renderMonitorExecutions(monitorState.executions, statusFilter);
renderMonitorPagination(); renderMonitorPagination();
// 初始化每页显示数量选择器
initializeMonitorPageSize();
} catch (error) { } catch (error) {
console.error('刷新监控面板失败:', error); console.error('刷新监控面板失败:', error);
if (statsContainer) { if (statsContainer) {
@@ -1071,6 +1204,7 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all') {
} }
} }
function renderMonitorStats(statsMap = {}, lastFetchedAt = null) { function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
const container = document.getElementById('monitor-stats'); const container = document.getElementById('monitor-stats');
if (!container) { if (!container) {
@@ -1151,11 +1285,19 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
if (!Array.isArray(executions) || executions.length === 0) { if (!Array.isArray(executions) || executions.length === 0) {
// 根据是否有筛选条件显示不同的提示 // 根据是否有筛选条件显示不同的提示
if (statusFilter && statusFilter !== 'all') { const toolFilter = document.getElementById('monitor-tool-filter');
const currentToolFilter = toolFilter ? toolFilter.value : 'all';
const hasFilter = (statusFilter && statusFilter !== 'all') || (currentToolFilter && currentToolFilter !== 'all');
if (hasFilter) {
container.innerHTML = '<div class="monitor-empty">当前筛选条件下暂无记录</div>'; container.innerHTML = '<div class="monitor-empty">当前筛选条件下暂无记录</div>';
} else { } else {
container.innerHTML = '<div class="monitor-empty">暂无执行记录</div>'; container.innerHTML = '<div class="monitor-empty">暂无执行记录</div>';
} }
// 隐藏批量操作栏
const batchActions = document.getElementById('monitor-batch-actions');
if (batchActions) {
batchActions.style.display = 'none';
}
return; return;
} }
@@ -1172,6 +1314,9 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const executionId = escapeHtml(exec.id || ''); const executionId = escapeHtml(exec.id || '');
return ` return `
<tr> <tr>
<td>
<input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" onchange="updateBatchActionsState()" />
</td>
<td>${toolName}</td> <td>${toolName}</td>
<td><span class="${statusClass}">${statusLabel}</span></td> <td><span class="${statusClass}">${statusLabel}</span></td>
<td>${startTime}</td> <td>${startTime}</td>
@@ -1205,6 +1350,9 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
<table class="monitor-table"> <table class="monitor-table">
<thead> <thead>
<tr> <tr>
<th style="width: 40px;">
<input type="checkbox" id="monitor-select-all" onchange="toggleSelectAll(this)" />
</th>
<th>工具</th> <th>工具</th>
<th>状态</th> <th>状态</th>
<th>开始时间</th> <th>开始时间</th>
@@ -1223,6 +1371,9 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
} else { } else {
container.appendChild(tableContainer); container.appendChild(tableContainer);
} }
// 更新批量操作状态
updateBatchActionsState();
} }
// 渲染监控面板分页控件 // 渲染监控面板分页控件
@@ -1248,7 +1399,16 @@ function renderMonitorPagination() {
pagination.innerHTML = ` pagination.innerHTML = `
<div class="pagination-info"> <div class="pagination-info">
显示 ${startItem}-${endItem} / ${total} 条记录 <span>显示 ${startItem}-${endItem} / ${total} 条记录</span>
<label class="pagination-page-size">
每页显示
<select id="monitor-page-size" onchange="changeMonitorPageSize()">
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
</select>
</label>
</div> </div>
<div class="pagination-controls"> <div class="pagination-controls">
<button class="btn-secondary" onclick="refreshMonitorPanel(1)" ${page === 1 || total === 0 ? 'disabled' : ''}>首页</button> <button class="btn-secondary" onclick="refreshMonitorPanel(1)" ${page === 1 || total === 0 ? 'disabled' : ''}>首页</button>
@@ -1260,6 +1420,9 @@ function renderMonitorPagination() {
`; `;
container.appendChild(pagination); container.appendChild(pagination);
// 初始化每页显示数量选择器
initializeMonitorPageSize();
} }
// 删除执行记录 // 删除执行记录
@@ -1294,6 +1457,117 @@ async function deleteExecution(executionId) {
} }
} }
// 更新批量操作状态
function updateBatchActionsState() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
const selectedCount = checkboxes.length;
const batchActions = document.getElementById('monitor-batch-actions');
const selectedCountSpan = document.getElementById('monitor-selected-count');
if (selectedCount > 0) {
if (batchActions) {
batchActions.style.display = 'flex';
}
if (selectedCountSpan) {
selectedCountSpan.textContent = `已选择 ${selectedCount}`;
}
} else {
if (batchActions) {
batchActions.style.display = 'none';
}
}
// 更新全选复选框状态
const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) {
const allCheckboxes = document.querySelectorAll('.monitor-execution-checkbox');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
}
}
// 切换全选
function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateBatchActionsState();
}
// 全选
function selectAllExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => {
cb.checked = true;
});
const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
}
updateBatchActionsState();
}
// 取消全选
function deselectAllExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => {
cb.checked = false;
});
const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
updateBatchActionsState();
}
// 批量删除执行记录
async function batchDeleteExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
if (checkboxes.length === 0) {
alert('请先选择要删除的执行记录');
return;
}
const ids = Array.from(checkboxes).map(cb => cb.value);
const count = ids.length;
// 确认删除
if (!confirm(`确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`)) {
return;
}
try {
const response = await apiFetch('/api/monitor/executions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids: ids })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || '批量删除执行记录失败');
}
const result = await response.json().catch(() => ({}));
const deletedCount = result.deleted || count;
// 删除成功后刷新当前页面
const currentPage = monitorState.pagination.page;
await refreshMonitorPanel(currentPage);
alert(`成功删除 ${deletedCount} 条执行记录`);
} catch (error) {
console.error('批量删除执行记录失败:', error);
alert('批量删除执行记录失败: ' + error.message);
}
}
function formatExecutionDuration(start, end) { function formatExecutionDuration(start, end) {
if (!start) { if (!start) {
return '未知'; return '未知';
+79 -7
View File
@@ -3,14 +3,37 @@ let currentPage = 'chat';
// 初始化路由 // 初始化路由
function initRouter() { function initRouter() {
// 默认显示对话页面
switchPage('chat');
// 从URL hash读取页面(如果有) // 从URL hash读取页面(如果有)
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
if (hash && ['chat', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'settings'].includes(hash)) { if (hash) {
switchPage(hash); const hashParts = hash.split('?');
const pageId = hashParts[0];
if (pageId && ['chat', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'settings', 'tasks'].includes(pageId)) {
switchPage(pageId);
// 如果是chat页面且带有conversation参数,加载对应对话
if (pageId === 'chat' && hashParts.length > 1) {
const params = new URLSearchParams(hashParts[1]);
const conversationId = params.get('conversation');
if (conversationId) {
setTimeout(() => {
// 尝试多种方式调用loadConversation
if (typeof loadConversation === 'function') {
loadConversation(conversationId);
} else if (typeof window.loadConversation === 'function') {
window.loadConversation(conversationId);
} else {
console.warn('loadConversation function not found');
} }
}, 500);
}
}
return;
}
}
// 默认显示对话页面
switchPage('chat');
} }
// 切换页面 // 切换页面
@@ -178,6 +201,12 @@ function initPage(pageId) {
case 'chat': case 'chat':
// 对话页面已由chat.js初始化 // 对话页面已由chat.js初始化
break; break;
case 'tasks':
// 初始化任务管理页面
if (typeof initTasksPage === 'function') {
initTasksPage();
}
break;
case 'mcp-monitor': case 'mcp-monitor':
// 初始化监控面板 // 初始化监控面板
if (typeof refreshMonitorPanel === 'function') { if (typeof refreshMonitorPanel === 'function') {
@@ -211,6 +240,11 @@ function initPage(pageId) {
} }
break; break;
} }
// 清理其他页面的定时器
if (pageId !== 'tasks' && typeof cleanupTasksPage === 'function') {
cleanupTasksPage();
}
} }
// 页面加载完成后初始化路由 // 页面加载完成后初始化路由
@@ -221,10 +255,48 @@ document.addEventListener('DOMContentLoaded', function() {
// 监听hash变化 // 监听hash变化
window.addEventListener('hashchange', function() { window.addEventListener('hashchange', function() {
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
if (hash && ['chat', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'settings'].includes(hash)) { // 处理带参数的hash(如 chat?conversation=xxx
switchPage(hash); const hashParts = hash.split('?');
const pageId = hashParts[0];
if (pageId && ['chat', 'tasks', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'settings'].includes(pageId)) {
switchPage(pageId);
// 如果是chat页面且带有conversation参数,加载对应对话
if (pageId === 'chat' && hashParts.length > 1) {
const params = new URLSearchParams(hashParts[1]);
const conversationId = params.get('conversation');
if (conversationId) {
setTimeout(() => {
// 尝试多种方式调用loadConversation
if (typeof loadConversation === 'function') {
loadConversation(conversationId);
} else if (typeof window.loadConversation === 'function') {
window.loadConversation(conversationId);
} else {
console.warn('loadConversation function not found');
}
}, 200);
}
}
} }
}); });
// 页面加载时也检查hash参数
const hash = window.location.hash.slice(1);
if (hash) {
const hashParts = hash.split('?');
const pageId = hashParts[0];
if (pageId === 'chat' && hashParts.length > 1) {
const params = new URLSearchParams(hashParts[1]);
const conversationId = params.get('conversation');
if (conversationId && typeof loadConversation === 'function') {
setTimeout(() => {
loadConversation(conversationId);
}, 500);
}
}
}
}); });
// 切换侧边栏折叠/展开 // 切换侧边栏折叠/展开
File diff suppressed because it is too large Load Diff
+160 -2
View File
@@ -76,6 +76,17 @@
<span>对话</span> <span>对话</span>
</div> </div>
</div> </div>
<div class="nav-item" data-page="tasks">
<div class="nav-item-content" data-title="任务管理" onclick="switchPage('tasks')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="13 2 13 9 20 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="9" y1="13" x2="15" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="9" y1="17" x2="15" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>任务管理</span>
</div>
</div>
<div class="nav-item" data-page="vulnerabilities"> <div class="nav-item" data-page="vulnerabilities">
<div class="nav-item-content" data-title="漏洞管理" onclick="switchPage('vulnerabilities')"> <div class="nav-item-content" data-title="漏洞管理" onclick="switchPage('vulnerabilities')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -248,7 +259,7 @@
<div id="chat-messages" class="chat-messages"></div> <div id="chat-messages" class="chat-messages"></div>
<div class="chat-input-container"> <div class="chat-input-container">
<div class="chat-input-field"> <div class="chat-input-field">
<textarea id="chat-input" placeholder="输入测试目标或命令... (Shift+Enter 换行,Enter 发送)" rows="1"></textarea> <textarea id="chat-input" placeholder="输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)" rows="1"></textarea>
<div id="mention-suggestions" class="mention-suggestions" role="listbox" aria-label="工具提及候选"></div> <div id="mention-suggestions" class="mention-suggestions" role="listbox" aria-label="工具提及候选"></div>
</div> </div>
<button onclick="sendMessage()">发送</button> <button onclick="sendMessage()">发送</button>
@@ -277,6 +288,10 @@
<div class="section-header"> <div class="section-header">
<h3>最新执行记录</h3> <h3>最新执行记录</h3>
<div class="section-actions"> <div class="section-actions">
<label>
工具搜索
<input type="text" id="monitor-tool-filter" placeholder="输入工具名称..." oninput="handleToolFilterInput()" onkeydown="if(event.key==='Enter') applyMonitorFilters()" />
</label>
<label> <label>
状态筛选 状态筛选
<select id="monitor-status-filter" onchange="applyMonitorFilters()"> <select id="monitor-status-filter" onchange="applyMonitorFilters()">
@@ -288,6 +303,16 @@
</label> </label>
</div> </div>
</div> </div>
<div id="monitor-batch-actions" class="monitor-batch-actions" style="display: none;">
<div class="batch-actions-info">
<span id="monitor-selected-count">已选择 0 项</span>
</div>
<div class="batch-actions-buttons">
<button class="btn-secondary" onclick="selectAllExecutions()">全选</button>
<button class="btn-secondary" onclick="deselectAllExecutions()">取消全选</button>
<button class="btn-secondary btn-delete" onclick="batchDeleteExecutions()">批量删除</button>
</div>
</div>
<div id="monitor-executions" class="monitor-table-container"> <div id="monitor-executions" class="monitor-table-container">
<div class="monitor-empty">加载中...</div> <div class="monitor-empty">加载中...</div>
</div> </div>
@@ -527,6 +552,51 @@
</div> </div>
</div> </div>
<!-- 任务管理页面 -->
<div id="page-tasks" class="page">
<div class="page-header">
<h2>任务管理</h2>
<div class="page-header-actions">
<button class="btn-primary" onclick="showBatchImportModal()">批量导入任务</button>
<label class="auto-refresh-toggle">
<input type="checkbox" id="tasks-auto-refresh" checked onchange="toggleTasksAutoRefresh(this.checked)">
<span>自动刷新</span>
</label>
<button class="btn-secondary" onclick="refreshBatchQueues()">刷新</button>
</div>
</div>
<div class="page-content">
<!-- 批量任务队列列表 -->
<div class="batch-queues-section" id="batch-queues-section" style="display: none;">
<div class="batch-queues-header">
<h3>批量任务队列</h3>
</div>
<!-- 筛选控件 -->
<div class="batch-queues-filters tasks-filters">
<label>
<span>状态筛选</span>
<select id="batch-queues-status-filter" onchange="filterBatchQueues()">
<option value="all">全部</option>
<option value="pending">待执行</option>
<option value="running">执行中</option>
<option value="paused">已暂停</option>
<option value="completed">已完成</option>
<option value="cancelled">已取消</option>
</select>
</label>
<label style="flex: 1; max-width: 300px;">
<span>搜索队列ID或创建时间</span>
<input type="text" id="batch-queues-search" placeholder="输入关键字搜索..."
oninput="filterBatchQueues()">
</label>
</div>
<div id="batch-queues-list" class="batch-queues-list"></div>
<!-- 分页控件 -->
<div id="batch-queues-pagination"></div>
</div>
</div>
</div>
<!-- 系统设置页面 --> <!-- 系统设置页面 -->
<div id="page-settings" class="page"> <div id="page-settings" class="page">
<div class="page-header"> <div class="page-header">
@@ -1001,7 +1071,7 @@
<span class="modal-close" onclick="closeCreateGroupModal()">&times;</span> <span class="modal-close" onclick="closeCreateGroupModal()">&times;</span>
</div> </div>
<div class="modal-body create-group-body"> <div class="modal-body create-group-body">
<p class="create-group-description">分组功能可将对话集中归类管理,并支持自定义指令,让对话更加井然有序。</p> <p class="create-group-description">分组功能可将对话集中归类管理,让对话更加井然有序。</p>
<div class="create-group-input-wrapper"> <div class="create-group-input-wrapper">
<span class="group-icon-input">😊</span> <span class="group-icon-input">😊</span>
<input type="text" id="create-group-name-input" placeholder="请输入分组名称" /> <input type="text" id="create-group-name-input" placeholder="请输入分组名称" />
@@ -1082,6 +1152,93 @@
</div> </div>
</div> </div>
<!-- 批量导入任务模态框 -->
<div id="batch-import-modal" class="modal">
<div class="modal-content" style="max-width: 800px;">
<div class="modal-header">
<h2>批量导入任务</h2>
<span class="modal-close" onclick="closeBatchImportModal()">&times;</span>
</div>
<div class="modal-body">
<div class="form-group">
<label for="batch-tasks-input">任务列表(每行一个任务)<span style="color: red;">*</span></label>
<textarea id="batch-tasks-input" rows="15" placeholder="请输入任务列表,每行一个任务,例如:&#10;扫描 192.168.1.1 的开放端口&#10;检查 https://example.com 是否存在SQL注入&#10;枚举 example.com 的子域名" style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea>
<div class="form-hint" style="margin-top: 8px;">
<strong>提示:</strong>每行输入一个任务指令,系统将依次执行这些任务。空行会被自动忽略。
</div>
</div>
<div class="form-group">
<div id="batch-import-stats" class="batch-import-stats"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeBatchImportModal()">取消</button>
<button class="btn-primary" onclick="createBatchQueue()">创建队列</button>
</div>
</div>
</div>
<!-- 批量任务队列详情模态框 -->
<div id="batch-queue-detail-modal" class="modal">
<div class="modal-content" style="max-width: 900px;">
<div class="modal-header">
<h2 id="batch-queue-detail-title">批量任务队列详情</h2>
<div style="display: flex; align-items: center; gap: 12px;">
<div class="modal-header-actions">
<button class="btn-secondary" id="batch-queue-add-task-btn" onclick="showAddBatchTaskModal()" style="display: none;">添加任务</button>
<button class="btn-primary" id="batch-queue-start-btn" onclick="startBatchQueue()" style="display: none;">开始执行</button>
<button class="btn-secondary" id="batch-queue-pause-btn" onclick="pauseBatchQueue()" style="display: none;">暂停队列</button>
<button class="btn-secondary btn-danger" id="batch-queue-delete-btn" onclick="deleteBatchQueue()" style="display: none;">删除队列</button>
</div>
<span class="modal-close" onclick="closeBatchQueueDetailModal()">&times;</span>
</div>
</div>
<div class="modal-body">
<div id="batch-queue-detail-content"></div>
</div>
</div>
</div>
<!-- 编辑批量任务模态框 -->
<div id="edit-batch-task-modal" class="modal">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
<h2>编辑任务</h2>
<span class="modal-close" onclick="closeEditBatchTaskModal()">&times;</span>
</div>
<div class="modal-body">
<div class="form-group">
<label for="edit-task-message">任务消息</label>
<textarea id="edit-task-message" class="form-control" rows="5" placeholder="请输入任务消息"></textarea>
</div>
<div class="form-actions">
<button class="btn-primary" onclick="saveBatchTask()">保存</button>
<button class="btn-secondary" onclick="closeEditBatchTaskModal()">取消</button>
</div>
</div>
</div>
</div>
<!-- 添加批量任务模态框 -->
<div id="add-batch-task-modal" class="modal">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
<h2>添加任务</h2>
<span class="modal-close" onclick="closeAddBatchTaskModal()">&times;</span>
</div>
<div class="modal-body">
<div class="form-group">
<label for="add-task-message">任务消息</label>
<textarea id="add-task-message" class="form-control" rows="5" placeholder="请输入任务消息"></textarea>
</div>
<div class="form-actions">
<button class="btn-primary" onclick="saveAddBatchTask()">添加</button>
<button class="btn-secondary" onclick="closeAddBatchTaskModal()">取消</button>
</div>
</div>
</div>
</div>
<!-- 漏洞编辑模态框 --> <!-- 漏洞编辑模态框 -->
<div id="vulnerability-modal" class="modal"> <div id="vulnerability-modal" class="modal">
<div class="modal-content" style="max-width: 900px;"> <div class="modal-content" style="max-width: 900px;">
@@ -1157,6 +1314,7 @@
<script src="/static/js/settings.js"></script> <script src="/static/js/settings.js"></script>
<script src="/static/js/knowledge.js"></script> <script src="/static/js/knowledge.js"></script>
<script src="/static/js/vulnerability.js?v=4"></script> <script src="/static/js/vulnerability.js?v=4"></script>
<script src="/static/js/tasks.js"></script>
</body> </body>
</html> </html>