mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-10 08:13:59 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87e8f07738 | |||
| 044480a427 | |||
| 88e710d7e9 | |||
| 74b2edad29 | |||
| cfc59ed895 | |||
| 9c5a115814 | |||
| a173dce667 | |||
| b5d3396159 | |||
| dcca3f014d | |||
| ca8fb8b60b | |||
| 7b9dee7268 | |||
| b90a29fdd7 | |||
| 24aa12cf33 | |||
| 7b8a220123 | |||
| 99552a1812 | |||
| e971e1eee2 | |||
| 4fb1c7b911 | |||
| 9ebf9c2252 | |||
| 7fcfbe60c5 | |||
| 0c4f934b24 | |||
| 90bafc2f1c | |||
| adfd45e11e | |||
| 63f2a6fc3a | |||
| 4fecdad152 | |||
| a32ba40353 | |||
| d48238f6a0 | |||
| 98713236b7 |
@@ -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 可能会被标记为 `需要更多信息` 或直接关闭。请确保提供完整的信息以便我们能够快速定位和解决问题。
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: ✨ 功能优化建议
|
||||||
|
about: 提出新功能或优化建议
|
||||||
|
title: '[FEATURE] '
|
||||||
|
labels: ['enhancement', '待讨论']
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 功能描述
|
||||||
|
<!-- 请清晰、简洁地描述你希望添加或优化的功能 -->
|
||||||
|
|
||||||
|
|
||||||
|
## 🎯 使用场景
|
||||||
|
<!-- 描述这个功能的使用场景,解决什么问题 -->
|
||||||
|
<!-- 例如:在什么情况下会用到这个功能?它如何改善用户体验? -->
|
||||||
|
|
||||||
|
|
||||||
|
## 🔄 当前行为
|
||||||
|
<!-- 描述当前系统是如何处理相关需求的,或者为什么需要这个功能 -->
|
||||||
|
|
||||||
|
|
||||||
|
## ✨ 期望行为
|
||||||
|
<!-- 详细描述你期望的新功能或优化后的行为 -->
|
||||||
|
|
||||||
|
|
||||||
|
## 📸 参考示例(如有)
|
||||||
|
<!--
|
||||||
|
如果有其他项目的类似功能实现,可以在此提供截图或链接作为参考
|
||||||
|
⚠️ 请确保截图完整,包含所有相关界面元素
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- 请在此处拖拽或粘贴参考截图 -->
|
||||||
|
|
||||||
|
|
||||||
|
## 🛠️ 实现建议(可选)
|
||||||
|
<!-- 如果你有具体的实现思路或技术建议,可以在此描述 -->
|
||||||
|
|
||||||
|
|
||||||
|
## 📊 优先级评估
|
||||||
|
<!-- 请选择你认为的优先级 -->
|
||||||
|
- [ ] 🔴 高优先级(严重影响使用体验或功能缺失)
|
||||||
|
- [ ] 🟡 中优先级(能显著改善体验)
|
||||||
|
- [ ] 🟢 低优先级(锦上添花的功能)
|
||||||
|
|
||||||
|
## 🔍 相关功能
|
||||||
|
<!-- 这个功能是否与现有功能相关? -->
|
||||||
|
<!-- 例如:是否与工具管理、攻击链分析、知识库等功能相关? -->
|
||||||
|
|
||||||
|
|
||||||
|
## 📝 额外信息
|
||||||
|
<!-- 任何其他有助于理解需求的信息 -->
|
||||||
|
- 是否已有替代方案?
|
||||||
|
- 这个功能是否会影响现有功能?
|
||||||
|
- 是否有相关的其他 issue 或讨论?
|
||||||
|
|
||||||
|
## ✅ 检查清单
|
||||||
|
<!-- 提交前请确认以下项目 -->
|
||||||
|
|
||||||
|
- [ ] 我已清晰描述了功能需求和使用场景
|
||||||
|
- [ ] 我已提供完整的参考截图(如有)
|
||||||
|
- [ ] 我已评估了功能的优先级
|
||||||
|
- [ ] 我已确认这不是重复的 issue
|
||||||
|
- [ ] 我已考虑了对现有功能的影响
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**注意**:请提供尽可能详细的信息,包括使用场景、参考示例等,这将有助于我们更好地理解和实现你的需求。
|
||||||
|
|
||||||
@@ -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
@@ -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 —— 重构攻击链生成逻辑,生成速度提升一倍。重构攻击链前端页面展示,优化用户体验。
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 273 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB |
BIN
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 |
@@ -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)
|
||||||
|
|
||||||
// 配置管理
|
// 配置管理
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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": "执行记录已删除(如果存在)"})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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()">×</span>
|
<span class="modal-close" onclick="closeCreateGroupModal()">×</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()">×</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="请输入任务列表,每行一个任务,例如: 扫描 192.168.1.1 的开放端口 检查 https://example.com 是否存在SQL注入 枚举 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()">×</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()">×</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()">×</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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user