Compare commits

...

121 Commits

Author SHA1 Message Date
公明 a5c285c8f3 Update version number to v1.3.21 2026-03-10 00:06:37 +08:00
公明 98938aef00 Remove user message check for Qwen model
Removed the logic to ensure at least one user message is included in recent messages to avoid Qwen model error.
2026-03-10 00:04:19 +08:00
公明 71f6a97a90 Add files via upload 2026-03-09 23:00:24 +08:00
公明 2fce15f82a Enhance MCP config with authentication headers
Added authentication headers for MCP server configuration.
2026-03-09 22:40:06 +08:00
公明 52b70d8b16 Add files via upload 2026-03-09 22:38:24 +08:00
公明 5b3709b9ad Add files via upload 2026-03-09 22:37:37 +08:00
公明 639f65602d Add files via upload 2026-03-09 22:36:22 +08:00
公明 52b6c3fe1b Add files via upload 2026-03-09 22:35:39 +08:00
公明 f26ee8e6e7 Add files via upload 2026-03-09 22:19:22 +08:00
公明 379486d36c Add files via upload 2026-03-09 21:35:49 +08:00
公明 317461e259 Add files via upload 2026-03-09 21:30:32 +08:00
公明 b7e724407b Add files via upload 2026-03-09 20:40:36 +08:00
公明 e904dd3481 Update version number to v1.3.20 2026-03-09 02:10:48 +08:00
公明 7b1487383f Add files via upload 2026-03-09 02:06:39 +08:00
公明 8a2177ffab Update version to v1.3.19 in config.yaml 2026-03-08 04:02:59 +08:00
公明 3a7bbfbb88 Delete internal/handler/wecom_test.go 2026-03-08 04:02:05 +08:00
公明 7c01641de9 Add files via upload 2026-03-08 04:01:33 +08:00
公明 1c1086eea4 Merge pull request #53 from 04cb/fix/ensure-user-message-after-compression
Fix Qwen model error by ensuring user message is kept after memory compression
2026-03-07 14:20:37 +08:00
04cb 8f4f40f894 Fix Qwen model error by ensuring user message is kept after memory compression
Qwen models require a user message in the message array, otherwise they return
'No user query found in messages' error. The adjustRecentStartForToolCalls
function now ensures at least one user message is included in recent messages
after compression to prevent this validation error.
2026-03-07 13:31:32 +08:00
公明 7f16ba706a Add files via upload 2026-03-07 13:19:46 +08:00
公明 0b950f95db Add files via upload 2026-03-07 00:17:02 +08:00
公明 d36984a1c1 Add files via upload 2026-03-06 23:21:16 +08:00
公明 da2109a970 Update version number to v1.3.18 2026-03-06 23:18:49 +08:00
公明 1866aa8089 Add files via upload 2026-03-06 22:51:18 +08:00
公明 5af06e539d Update config.yaml 2026-03-06 22:42:19 +08:00
公明 7493e70686 Add files via upload 2026-03-06 22:39:30 +08:00
公明 81f7a601b7 Update config.yaml 2026-03-06 21:06:42 +08:00
公明 27830d1399 Add files via upload 2026-03-06 20:11:22 +08:00
公明 d9a0178f80 Merge pull request #47 from chhs1129/fix-bug-logger-missing-error
Fix: logger shows empty error msg
2026-03-06 10:20:44 +08:00
chhs1129 1dd8cc7f50 Fix: logger shows empty error msg 2026-03-05 09:40:47 -08:00
公明 55045dd4e0 Add files via upload 2026-03-04 00:18:29 +08:00
公明 90508c9084 Update version to v1.3.16 in config.yaml 2026-03-03 20:03:56 +08:00
公明 361480f2d1 Add files via upload 2026-03-03 19:55:24 +08:00
公明 538565117b Add files via upload 2026-03-03 19:36:56 +08:00
公明 1c8742b7b6 Update README_CN.md 2026-03-03 13:52:50 +08:00
公明 2fb6a1d1ef Add disclaimer for ethical use of CyberStrikeAI
Added a disclaimer section emphasizing the ethical use of the tool.
2026-03-03 10:07:43 +08:00
公明 6e390acb3d Update README.md 2026-03-03 10:06:31 +08:00
公明 d6236e285d Update version to v1.3.15 in config.yaml 2026-03-03 01:34:14 +08:00
公明 ad8efffbb4 Add files via upload 2026-03-03 01:31:05 +08:00
公明 352d9b712c Add files via upload 2026-03-03 01:30:23 +08:00
公明 acadbe19c6 Add files via upload 2026-03-03 01:28:30 +08:00
公明 c265e66afb Update config.yaml 2026-03-02 20:41:41 +08:00
公明 647bb4b5e4 Add files via upload 2026-03-02 20:37:27 +08:00
公明 dd311f7a3b Add files via upload 2026-03-02 20:13:16 +08:00
公明 2e482a3baf Update version number to v1.3.13 2026-03-02 01:11:08 +08:00
公明 67d5e7f11e Add files via upload 2026-03-02 01:10:44 +08:00
公明 7e0198a64c Add files via upload 2026-03-02 00:58:40 +08:00
公明 1e50272229 Update version number to v1.3.12 2026-03-02 00:52:38 +08:00
公明 39b47a86fb Add files via upload 2026-03-02 00:49:21 +08:00
公明 74738ee555 Add files via upload 2026-03-01 13:35:11 +08:00
公明 90bc3f4b61 Update config.yaml 2026-02-28 23:34:07 +08:00
公明 ad96be3c64 Add files via upload 2026-02-28 23:31:17 +08:00
公明 8866ff4cdd Add files via upload 2026-02-28 23:09:48 +08:00
公明 3534a956b2 Add files via upload 2026-02-28 23:03:09 +08:00
公明 691793cb38 Update config.yaml 2026-02-28 00:52:51 +08:00
公明 7270e3c3d1 Add files via upload 2026-02-28 00:52:17 +08:00
公明 5e28782b1f Add files via upload 2026-02-28 00:33:35 +08:00
公明 3e61b77b9c Add files via upload 2026-02-28 00:30:53 +08:00
公明 64f9053061 Update config.yaml 2026-02-28 00:29:04 +08:00
公明 426b0e282e Add files via upload 2026-02-28 00:27:09 +08:00
公明 78c6bd0b6a Add files via upload 2026-02-25 19:58:28 +08:00
公明 e54815e018 Update config.yaml 2026-02-21 01:02:38 +08:00
公明 9baa99ea40 Add files via upload 2026-02-21 01:01:51 +08:00
公明 83a8c46db1 Add files via upload 2026-02-21 00:50:59 +08:00
公明 4b2619e1fe Update config.yaml 2026-02-20 18:48:07 +08:00
公明 3fffee80f4 Add files via upload 2026-02-20 18:43:50 +08:00
公明 41d7afcf99 Add files via upload 2026-02-20 18:10:29 +08:00
公明 6431dcb240 Add files via upload 2026-02-20 17:53:38 +08:00
公明 665b1d553a Update config.yaml 2026-02-20 16:53:21 +08:00
公明 fd3a52af01 Add files via upload 2026-02-20 16:40:59 +08:00
公明 8368ee7712 Add FOFA API configuration to config.yaml
Add optional FOFA configuration for information collection.
2026-02-20 16:18:53 +08:00
公明 dd883677b8 Add files via upload 2026-02-20 16:16:48 +08:00
公明 2edd5ffe95 Update README_CN.md 2026-02-11 10:31:40 +08:00
公明 ae588dbfe4 Update README.md 2026-02-11 10:29:54 +08:00
公明 93be113a79 Add files via upload 2026-02-11 01:01:56 +08:00
公明 d3fb14f72d Update requirements.txt 2026-02-11 00:57:56 +08:00
公明 af715e23cb Add files via upload 2026-02-11 00:50:39 +08:00
公明 3aecdc275f Add files via upload 2026-02-11 00:44:12 +08:00
公明 660d95a787 Add files via upload 2026-02-11 00:20:50 +08:00
公明 01271fd8eb Add files via upload 2026-02-10 23:48:27 +08:00
公明 8c6e044f84 Add files via upload 2026-02-10 23:37:43 +08:00
公明 cb2defd0cc Add files via upload 2026-02-09 20:10:59 +08:00
公明 88ab73e422 Add files via upload 2026-02-09 20:10:37 +08:00
公明 5404d95db7 Update config.yaml 2026-02-09 19:44:11 +08:00
公明 32d0e98cfb Add files via upload 2026-02-09 19:36:57 +08:00
公明 e4b1e10a42 Add files via upload 2026-02-09 19:26:57 +08:00
公明 870715fc8f Add files via upload 2026-02-09 19:20:43 +08:00
公明 772a04b715 Add files via upload 2026-02-09 19:15:04 +08:00
公明 2455bde7ab Update requirements.txt 2026-02-09 13:33:30 +08:00
公明 dbdfc18d57 Delete tools/list-files.yaml 2026-02-09 10:43:54 +08:00
公明 82daad3b56 Update config.yaml 2026-02-09 00:15:56 +08:00
公明 9eee820096 Add files via upload 2026-02-09 00:07:25 +08:00
公明 fae912b79c Add files via upload 2026-02-09 00:01:33 +08:00
公明 9b48daf795 Add files via upload 2026-02-08 23:57:46 +08:00
公明 bfbb8b31d3 Add files via upload 2026-02-08 23:43:46 +08:00
公明 8b2dfea884 Add files via upload 2026-02-08 23:38:57 +08:00
公明 7447e82c39 Add files via upload 2026-02-08 23:15:43 +08:00
公明 44b8d0b427 Add files via upload 2026-02-08 22:01:56 +08:00
公明 3a26d77c94 Update config.yaml 2026-02-08 21:29:08 +08:00
公明 0be6746794 Add files via upload 2026-02-08 21:28:20 +08:00
公明 06bfed508a Update requirements.txt 2026-02-08 20:56:15 +08:00
公明 0d617ebd66 Add files via upload 2026-02-08 20:46:51 +08:00
公明 9a52ec25ea Add files via upload 2026-02-08 20:40:50 +08:00
公明 594b7676e1 Add files via upload 2026-02-08 20:38:50 +08:00
公明 fd5d1dff10 Add files via upload 2026-02-08 20:20:43 +08:00
公明 b8218d9f77 Add tool description mode to security config
Add tool description mode configuration options.
2026-02-08 20:11:04 +08:00
公明 7b7c689efd Add files via upload 2026-02-08 20:09:23 +08:00
公明 27b16e0d54 Add files via upload 2026-02-07 00:04:59 +08:00
公明 2b6b678439 Add files via upload 2026-02-04 19:39:29 +08:00
公明 be104d1a05 Add files via upload 2026-02-04 19:37:46 +08:00
公明 f64bda3678 Add files via upload 2026-02-04 19:07:32 +08:00
公明 4b8dbb1bd6 Delete internal/mcp/client.go 2026-02-04 10:14:49 +08:00
公明 783d80ee37 Add files via upload 2026-02-04 02:00:52 +08:00
公明 a27c13b734 Add files via upload 2026-02-04 01:57:01 +08:00
公明 3cbf398636 Add files via upload 2026-02-04 01:39:11 +08:00
公明 84d54b1ea9 Add files via upload 2026-02-04 01:34:25 +08:00
公明 91230f273e Add files via upload 2026-01-29 19:32:33 +08:00
公明 81fca5b2dd Add files via upload 2026-01-29 19:19:38 +08:00
公明 01b6b226eb Add files via upload 2026-01-28 23:58:57 +08:00
公明 efd7a0aadd Add files via upload 2026-01-28 20:34:21 +08:00
公明 895061911c Add files via upload 2026-01-28 20:19:02 +08:00
70 changed files with 19803 additions and 3181 deletions
+59 -29
View File
@@ -14,6 +14,12 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
<div align="center"> <div align="center">
### System Dashboard Overview
<img src="./images/dashboard.png" alt="System Dashboard" width="100%">
*The dashboard provides a comprehensive overview of system runtime status, security vulnerabilities, tool usage, and knowledge base, helping users quickly understand the platform's core features and current state.*
### Core Features Overview ### Core Features Overview
<table> <table>
@@ -77,6 +83,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially - 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions - 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
- 🎯 Skills system: 20+ predefined security testing skills (SQL injection, XSS, API security, etc.) that can be attached to roles or called on-demand by AI agents - 🎯 Skills system: 20+ predefined security testing skills (SQL injection, XSS, API security, etc.) that can be attached to roles or called on-demand by AI agents
- 📱 **Chatbot**: DingTalk and Lark (Feishu) long-lived connections so you can talk to CyberStrikeAI from mobile (see [Robot / Chatbot guide](docs/robot_en.md) for setup and commands)
## Tool Overview ## Tool Overview
@@ -255,21 +262,33 @@ go build -o cyberstrike-ai cmd/server/main.go
``` ```
Replace the paths with your local locations; Cursor will launch the stdio server automatically. Replace the paths with your local locations; Cursor will launch the stdio server automatically.
#### MCP HTTP quick start #### MCP HTTP quick start (Cursor / Claude Code)
1. Ensure `config.yaml` has `mcp.enabled: true` and adjust `mcp.host` / `mcp.port` if you need a non-default binding (localhost:8081 works well for local Cursor usage). The HTTP MCP server runs on a separate port (default `8081`) and supports **header-based authentication** so only clients that send the correct header can call tools.
2. Start the main service (`./run.sh` or `go run cmd/server/main.go`); the MCP endpoint lives at `http://<host>:<port>/mcp`.
3. In Cursor, choose **Add Custom MCP → HTTP** and set `Base URL` to `http://127.0.0.1:8081/mcp`. 1. **Enable MCP in config** In `config.yaml` set `mcp.enabled: true` and optionally `mcp.host` / `mcp.port`. For auth (recommended if the port is reachable from the network), set:
4. Prefer committing the setup via `.cursor/mcp.json` so teammates can reuse it: - `mcp.auth_header` header name (e.g. `X-MCP-Token`);
```json - `mcp.auth_header_value` secret value. **Leave it empty** if you want the server to **auto-generate** a random token on first start and write it back to the config.
{ 2. **Start the service** Run `./run.sh` or `go run cmd/server/main.go`. The MCP endpoint is `http://<host>:<port>/mcp` (e.g. `http://localhost:8081/mcp`).
"mcpServers": { 3. **Copy the JSON from the terminal** When MCP is enabled, the server prints a **ready-to-paste** JSON block. If `auth_header_value` was empty, it will have been generated and saved; the printed JSON includes the URL and headers.
"cyberstrike-ai-http": { 4. **Use in Cursor or Claude Code**:
"transport": "http", - **Cursor**: Paste the block into `~/.cursor/mcp.json` (or your projects `.cursor/mcp.json`) under `mcpServers`, or merge it into your existing `mcpServers`.
"url": "http://127.0.0.1:8081/mcp" - **Claude Code**: Paste into `.mcp.json` or `~/.claude.json` under `mcpServers`.
}
} Example of what the terminal prints (with auth enabled):
} ```json
``` {
"mcpServers": {
"cyberstrike-ai": {
"url": "http://localhost:8081/mcp",
"headers": {
"X-MCP-Token": "<auto-generated-or-your-value>"
},
"type": "http"
}
}
}
```
If you do not set `auth_header` / `auth_header_value`, the endpoint accepts requests without authentication (suitable only for localhost or trusted networks).
#### External MCP federation (HTTP/stdio/SSE) #### External MCP federation (HTTP/stdio/SSE)
CyberStrikeAI supports connecting to external MCP servers via three transport modes: CyberStrikeAI supports connecting to external MCP servers via three transport modes:
@@ -389,6 +408,8 @@ mcp:
enabled: true enabled: true
host: "0.0.0.0" host: "0.0.0.0"
port: 8081 port: 8081
auth_header: "X-MCP-Token" # optional; leave empty for no auth
auth_header_value: "" # optional; leave empty to auto-generate on first start
openai: openai:
api_key: "sk-xxx" api_key: "sk-xxx"
base_url: "https://api.deepseek.com/v1" base_url: "https://api.deepseek.com/v1"
@@ -454,6 +475,10 @@ tools:
enabled: true enabled: true
``` ```
## Related documentation
- [Robot / Chatbot guide (DingTalk & Lark)](docs/robot_en.md): Full setup, commands, and troubleshooting for using CyberStrikeAI from DingTalk or Lark on your phone. **Follow this doc to avoid common pitfalls.**
## Project Layout ## Project Layout
``` ```
@@ -464,6 +489,7 @@ CyberStrikeAI/
├── tools/ # YAML tool recipes (100+ examples provided) ├── tools/ # YAML tool recipes (100+ examples provided)
├── roles/ # Role configurations (12+ predefined security testing roles) ├── roles/ # Role configurations (12+ predefined security testing roles)
├── skills/ # Skills directory (20+ predefined security testing skills) ├── skills/ # Skills directory (20+ predefined security testing skills)
├── docs/ # Documentation (e.g. robot/chbot guide)
├── images/ # Docs screenshots & diagrams ├── images/ # Docs screenshots & diagrams
├── config.yaml # Runtime configuration ├── config.yaml # Runtime configuration
├── run.sh # Convenience launcher ├── run.sh # Convenience launcher
@@ -489,20 +515,6 @@ Compress the 5 MB nuclei report, summarize critical CVEs, and attach the artifac
Build an attack chain for the latest engagement and export the node list with severity >= high. Build an attack chain for the latest engagement and export the node list with severity >= high.
``` ```
## Changelog
### Recent Highlights
- **2026-01-27** OpenAPI documentation with interactive testing interface, supporting conversation management, message interaction, and result querying
- **2026-01-15** Skills system with 20+ predefined security testing skills
- **2026-01-11** Role-based testing with predefined security testing roles
- **2026-01-08** SSE transport mode support for external MCP servers
- **2026-01-01** Batch task management with queue-based execution
- **2025-12-25** Vulnerability management and conversation grouping features
- **2025-12-20** Knowledge base with vector search and hybrid retrieval
## 404Starlink ## 404Starlink
<img src="./images/404StarLinkLogo.png" width="30%"> <img src="./images/404StarLinkLogo.png" width="30%">
@@ -516,8 +528,26 @@ CyberStrikeAI has joined [404Starlink](https://github.com/knownsec/404StarLink)
</a> </a>
</div> </div>
## Stargazers over time
![Stargazers over time](https://starchart.cc/Ed1s0nZ/CyberStrikeAI.svg)
---
## ⚠️ Disclaimer
**This tool is for educational and authorized testing purposes only!**
CyberStrikeAI is a professional security testing platform designed to assist security researchers, penetration testers, and IT professionals in conducting security assessments and vulnerability research **with explicit authorization**.
**By using this tool, you agree to:**
- Use this tool only on systems where you have clear written authorization
- Comply with all applicable laws, regulations, and ethical standards
- Take full responsibility for any unauthorized use or misuse
- Not use this tool for any illegal or malicious purposes
**The developers are not responsible for any misuse!** Please ensure your usage complies with local laws and regulations, and that you have obtained explicit authorization from the target system owner.
--- ---
Need help or want to contribute? Open an issue or PR—community tooling additions are welcome! Need help or want to contribute? Open an issue or PR—community tooling additions are welcome!
+60 -28
View File
@@ -13,6 +13,12 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
<div align="center"> <div align="center">
### 系统仪表盘概览
<img src="./images/dashboard.png" alt="系统仪表盘" width="100%">
*仪表盘提供系统运行状态、安全漏洞、工具使用情况和知识库的全面概览,帮助用户快速了解平台核心功能和当前状态。*
### 核心功能概览 ### 核心功能概览
<table> <table>
@@ -76,6 +82,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪 - 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制 - 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
- 🎯 Skills 技能系统:20+ 预设安全测试技能(SQL 注入、XSS、API 安全等),可附加到角色或由 AI 按需调用 - 🎯 Skills 技能系统:20+ 预设安全测试技能(SQL 注入、XSS、API 安全等),可附加到角色或由 AI 按需调用
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md)
## 工具概览 ## 工具概览
@@ -253,21 +260,33 @@ go build -o cyberstrike-ai cmd/server/main.go
``` ```
将路径替换成你本地的实际地址,Cursor 会自动启动 stdio 版本的 MCP。 将路径替换成你本地的实际地址,Cursor 会自动启动 stdio 版本的 MCP。
#### MCP HTTP 快速集成 #### MCP HTTP 快速集成Cursor / Claude Code
1. 确认 `config.yaml` 中 `mcp.enabled: true`,按照需要调整 `mcp.host` / `mcp.port`(本地建议 `127.0.0.1:8081` HTTP MCP 服务在独立端口(默认 `8081`)运行,支持 **Header 鉴权**:仅携带正确 header 的客户端可调用工具
2. 启动主服务(`./run.sh` 或 `go run cmd/server/main.go`),MCP 端点默认暴露在 `http://<host>:<port>/mcp`。
3. 在 Cursor 内 `Add Custom MCP → HTTP`,将 `Base URL` 设置 `http://127.0.0.1:8081/mcp`。 1. **在配置中启用 MCP** – 在 `config.yaml` 中设置 `mcp.enabled: true`,并按需设置 `mcp.host` / `mcp.port`。若需鉴权(端口对外暴露时建议开启),可设置:
4. 也可以在项目根目录创建 `.cursor/mcp.json` 以便团队共享: - `mcp.auth_header`:鉴权用的 header 名(如 `X-MCP-Token`);
```json - `mcp.auth_header_value`:鉴权密钥。**留空**时,首次启动会自动生成随机密钥并写回配置文件。
{ 2. **启动服务** 执行 `./run.sh` 或 `go run cmd/server/main.go`。MCP 端点为 `http://<host>:<port>/mcp`(例如 `http://localhost:8081/mcp`)。
"mcpServers": { 3. **从终端复制 JSON** – 启用 MCP 后,启动时会在终端打印一段 **可直接复制的 JSON**。若 `auth_header_value` 留空,会自动生成并写入配置,打印内容中会包含 URL 与 headers。
"cyberstrike-ai-http": { 4. **在 Cursor 或 Claude Code 中使用**
"transport": "http", - **Cursor**:将整段 JSON 粘贴到 `~/.cursor/mcp.json` 或项目下的 `.cursor/mcp.json` 的 `mcpServers` 中(或合并进现有 `mcpServers`)。
"url": "http://127.0.0.1:8081/mcp" - **Claude Code**:粘贴到 `.mcp.json` 或 `~/.claude.json` 的 `mcpServers` 中。
}
} 终端打印示例(开启鉴权时):
} ```json
``` {
"mcpServers": {
"cyberstrike-ai": {
"url": "http://localhost:8081/mcp",
"headers": {
"X-MCP-Token": "<自动生成或你配置的值>"
},
"type": "http"
}
}
}
```
若不配置 `auth_header` / `auth_header_value`,则端点不鉴权(仅适合本机或可信网络)。
#### 外部 MCP 联邦(HTTP/stdio/SSE #### 外部 MCP 联邦(HTTP/stdio/SSE
CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器: CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
@@ -388,6 +407,8 @@ mcp:
enabled: true enabled: true
host: "0.0.0.0" host: "0.0.0.0"
port: 8081 port: 8081
auth_header: "X-MCP-Token" # 可选;留空则不鉴权
auth_header_value: "" # 可选;留空则首次启动自动生成并写回
openai: openai:
api_key: "sk-xxx" api_key: "sk-xxx"
base_url: "https://api.deepseek.com/v1" base_url: "https://api.deepseek.com/v1"
@@ -453,6 +474,10 @@ tools:
enabled: true enabled: true
``` ```
## 相关文档
- [机器人使用说明(钉钉 / 飞书)](docs/robot.md):在手机端通过钉钉、飞书与 CyberStrikeAI 对话的完整配置步骤、命令与排查说明,**建议按该文档操作以避免走弯路**。
## 项目结构 ## 项目结构
``` ```
@@ -463,6 +488,7 @@ CyberStrikeAI/
├── tools/ # YAML 工具目录(含 100+ 示例) ├── tools/ # YAML 工具目录(含 100+ 示例)
├── roles/ # 角色配置文件目录(含 12+ 预设安全测试角色) ├── roles/ # 角色配置文件目录(含 12+ 预设安全测试角色)
├── skills/ # Skills 目录(含 20+ 预设安全测试技能) ├── skills/ # Skills 目录(含 20+ 预设安全测试技能)
├── docs/ # 说明文档(如机器人使用说明)
├── images/ # 文档配图 ├── images/ # 文档配图
├── config.yaml # 运行配置 ├── config.yaml # 运行配置
├── run.sh # 启动脚本 ├── run.sh # 启动脚本
@@ -488,19 +514,6 @@ CyberStrikeAI/
构建最新一次测试的攻击链,只导出风险 >= 高的节点列表。 构建最新一次测试的攻击链,只导出风险 >= 高的节点列表。
``` ```
## 更新日志
### 近期亮点
- **2026-01-27** 新增 OpenAPI 文档,提供交互式测试界面,支持对话管理、消息交互和结果查询
- **2026-01-15** 新增 Skills 技能系统,内置 20+ 预设安全测试技能
- **2026-01-11** – 新增角色化测试功能,支持预设安全测试角色
- **2026-01-08** 新增 SSE 传输模式支持,外部 MCP 联邦支持三种模式
- **2026-01-01** – 新增批量任务管理功能,支持队列式任务执行
- **2025-12-25** 新增漏洞管理和对话分组功能
- **2025-12-20** – 新增知识库功能,支持向量检索和混合搜索
## 404星链计划 ## 404星链计划
<img src="./images/404StarLinkLogo.png" width="30%"> <img src="./images/404StarLinkLogo.png" width="30%">
@@ -513,6 +526,25 @@ CyberStrikeAI 现已加入 [404星链计划](https://github.com/knownsec/404Star
</a> </a>
</div> </div>
## Stargazers over time
![Stargazers over time](https://starchart.cc/Ed1s0nZ/CyberStrikeAI.svg)
---
## ⚠️ 免责声明
**本工具仅供教育和授权测试使用!**
CyberStrikeAI 是一个专业的安全测试平台,旨在帮助安全研究人员、渗透测试人员和IT专业人员在**获得明确授权**的情况下进行安全评估和漏洞研究。
**使用本工具即表示您同意:**
- 仅在您拥有明确书面授权的系统上使用此工具
- 遵守所有适用的法律法规和道德准则
- 对任何未经授权的使用或滥用行为承担全部责任
- 不会将本工具用于任何非法或恶意目的
**开发者不对任何滥用行为负责!** 请确保您的使用符合当地法律法规,并获得目标系统所有者的明确授权。
--- ---
欢迎提交 Issue/PR 贡献新的工具模版或优化建议! 欢迎提交 Issue/PR 贡献新的工具模版或优化建议!
+9
View File
@@ -19,6 +19,15 @@ func main() {
return return
} }
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
if err := config.EnsureMCPAuth(*configPath, cfg); err != nil {
fmt.Printf("MCP 鉴权配置失败: %v\n", err)
return
}
if cfg.MCP.Enabled {
config.PrintMCPConfigJSON(cfg.MCP)
}
// 初始化日志 // 初始化日志
log := logger.New(cfg.Log.Level, cfg.Log.Output) log := logger.New(cfg.Log.Level, cfg.Log.Output)
+60 -2
View File
@@ -9,6 +9,9 @@
# 系统设置 # 系统设置
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.3.21"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -41,6 +44,16 @@ openai:
model: deepseek-chat # 模型名称(必填) model: deepseek-chat # 模型名称(必填)
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置) max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
# ============================================
# 信息收集(FOFA)配置(可选)
# ============================================
# 用于「信息收集」页面调用 FOFA API(后端代理,避免前端暴露 key)
# 也可通过环境变量配置:FOFA_EMAIL / FOFA_API_KEY(优先级更高)
fofa:
base_url: "https://fofa.info/api/v1/search/all" # 可选,留空则使用默认
email: "" # FOFA 账号邮箱(可选,建议在系统设置中填写)
api_key: "" # FOFA API Key(可选,建议在系统设置中填写)
# Agent 配置 # Agent 配置
# 达到最大迭代次数时,AI 会自动总结测试结果 # 达到最大迭代次数时,AI 会自动总结测试结果
agent: agent:
@@ -67,6 +80,10 @@ database:
# 推荐方式:在 tools/ 目录下为每个工具创建独立的配置文件 # 推荐方式:在 tools/ 目录下为每个工具创建独立的配置文件
security: security:
tools_dir: tools # 工具配置文件目录(相对于配置文件所在目录) tools_dir: tools # 工具配置文件目录(相对于配置文件所在目录)
# 工具描述模式:加载 tools 下工具时,暴露给 AI/API 使用的描述来源
# short - 优先使用 short_description(简短描述,省 token),为空时用 description
# full - 使用 description(详细描述)
tool_description_mode: full
# ============================================ # ============================================
# MCP 相关配置 # MCP 相关配置
@@ -76,8 +93,10 @@ security:
# MCP (Model Context Protocol) 用于工具注册和调用 # MCP (Model Context Protocol) 用于工具注册和调用
mcp: mcp:
enabled: false # 是否启用 MCP 服务器(http模式) enabled: false # 是否启用 MCP 服务器(http模式)
host: 0.0.0.0 # MCP 服务器监听地址 host: 0.0.0.0 # MCP 服务器监听地址
port: 8081 # MCP 服务器端口 port: 8081 # MCP 服务器端口
auth_header: "X-MCP-Token" # 鉴权:请求需携带该 header 且值与 auth_header_value 一致方可调用。留空表示不鉴权
auth_header_value: "" # 鉴权密钥值(与 auth_header 配合使用,建议使用随机字符串)
# 外部 MCP 配置 # 外部 MCP 配置
external_mcp: external_mcp:
@@ -99,6 +118,45 @@ knowledge:
top_k: 5 # 检索返回的Top-K结果数量 top_k: 5 # 检索返回的Top-K结果数量
similarity_threshold: 0.7 # 相似度阈值(0-1),低于此值的结果将被过滤 similarity_threshold: 0.7 # 相似度阈值(0-1),低于此值的结果将被过滤
hybrid_weight: 0.7 # 混合检索权重(0-1),向量检索的权重,1.0表示纯向量检索,0.0表示纯关键词检索 hybrid_weight: 0.7 # 混合检索权重(0-1),向量检索的权重,1.0表示纯向量检索,0.0表示纯关键词检索
# ============================================
# 索引配置(用于解决 API 限制问题)
# ============================================
indexing:
# 分块配置
chunk_size: 512 # 每个块的最大 token 数(默认 512),长文本会被分割成多个块
chunk_overlap: 50 # 块之间的重叠 token 数(默认 50),保持上下文连贯性
max_chunks_per_item: 0 # 单个知识项的最大块数量(0 表示不限制),防止单个文件消耗过多 API 配额
# 速率限制配置(解决 429 错误)
max_rpm: 0 # 每分钟最大请求数(默认 0 表示不限制),如 OpenAI 默认 200 RPM
rate_limit_delay_ms: 300 # 请求间隔毫秒数(默认 300),用于避免 API 速率限制,设为 0 不限制
# 建议值:200 次/分钟≈300ms, 100 次/分钟≈600ms
# 重试配置
max_retries: 3 # 最大重试次数(默认 3),遇到速率限制或服务器错误时自动重试
retry_delay_ms: 1000 # 重试间隔毫秒数(默认 1000),每次重试会递增延迟
# ============================================
# 机器人配置(企业微信、钉钉、飞书)
# ============================================
# 用于在手机端通过企业微信/钉钉/飞书与 CyberStrikeAI 对话,无需部署在服务器上也可使用
# 在系统设置 -> 机器人设置 中可配置
robots:
wecom: # 企业微信
enabled: false
token: ""
encoding_aes_key: ""
corp_id: ""
secret: ""
agent_id: 0
dingtalk: # 钉钉
enabled: false
client_id:
client_secret:
lark: # 飞书
enabled: false
app_id: ""
app_secret: ""
verify_token: ""
# ============================================ # ============================================
# Skills 相关配置 # Skills 相关配置
+335
View File
@@ -0,0 +1,335 @@
## CyberStrikeAI 前端国际化方案
本文档说明 CyberStrikeAI Web 前端(`web/templates/index.html` + `web/static/js/*.js`)的国际化设计与开发规范,确保在不引入打包工具和不改动后端路由的前提下,实现可扩展、低返工的多语言支持。
当前目标:
- **支持中英文切换(zh-CN / en-US**
- 后续可方便扩展更多语言(如 ja-JP、ko-KR 等)
---
## 一、总体设计原则
- **前端主导的客户端国际化**:所有 UI 文案在浏览器端根据当前语言动态渲染,后端 Go 仅负责结构和数据,不参与语言分发。
- **单一 HTML 模板**:继续使用一份 `index.html` 模板,不为不同语言复制模板文件。
- **文案与逻辑分离**:所有可见文本通过「键值表」管理(多语言 JSON),HTML / JS 只写 key,不直接写中文/英文常量。
- **渐进式改造**:先覆盖 header / 登录 / 侧边栏 / 系统设置等关键区域,其他页面按模块逐步迁移,避免一次性大改动。
- **可回退默认语言**:即使目标语言未完全翻译,也能回退到默认中文,不出现原始 key。
---
## 二、技术选型与目录结构
### 2.1 技术选型
- **i18n 引擎**:使用 [i18next](https://www.i18next.com/) 的浏览器 UMD 版本(通过 CDN 引入),无需打包器。
- **资源格式**:每种语言一份 JSON 文件,采用「域 + 语义」的层级 key 方案,例如:
- `common.ok`
- `nav.dashboard`
- `header.apiDocs`
- `settings.robot.wecom.token`
### 2.2 目录结构
- `web/templates/index.html`
- 页面骨架 + 所有静态文案位置,将逐步改为 `data-i18n` 标记。
- `web/static/js/i18n.js`
- 前端 i18n 初始化与 DOM 应用逻辑(本方案新增)。
- `web/static/i18n/`(新增目录)
- `zh-CN.json`:中文文案(默认语言)
- `en-US.json`:英文文案
- 未来可新增:`ja-JP.json``ko-KR.json` 等。
---
## 三、文案组织规范
### 3.1 Key 命名约定
- 采用「**模块.语义**」形式,最多 2–3 级,确保可读性:
- 导航:`nav.dashboard``nav.chat``nav.settings`
- 头部:`header.title``header.apiDocs``header.logout`
- 登录:`login.title``login.subtitle``login.passwordLabel``login.submit`
- 仪表盘:`dashboard.title``dashboard.refresh``dashboard.runningTasks`
- 系统设置:`settings.title``settings.nav.basic``settings.nav.robot``settings.apply`
- 机器人配置:`settings.robot.wecom.enabled``settings.robot.wecom.token` 等。
- 尽量按「界面区域」而不是「文件名」划分域,便于非开发人员理解。
### 3.2 JSON 示例
`web/static/i18n/zh-CN.json` 示例:
```json
{
"common": {
"ok": "确定",
"cancel": "取消"
},
"nav": {
"dashboard": "仪表盘",
"chat": "对话",
"infoCollect": "信息收集",
"tasks": "任务管理",
"vulnerabilities": "漏洞管理",
"settings": "系统设置"
},
"header": {
"title": "CyberStrikeAI",
"apiDocs": "API 文档",
"logout": "退出登录",
"language": "界面语言"
},
"login": {
"title": "登录 CyberStrikeAI",
"subtitle": "请输入配置中的访问密码",
"passwordLabel": "密码",
"passwordPlaceholder": "输入登录密码",
"submit": "登录"
}
}
```
英文文件 `en-US.json` 保持相同 key,不同 value
```json
{
"common": {
"ok": "OK",
"cancel": "Cancel"
},
"nav": {
"dashboard": "Dashboard",
"chat": "Chat",
"infoCollect": "Recon",
"tasks": "Tasks",
"vulnerabilities": "Vulnerabilities",
"settings": "Settings"
},
"header": {
"title": "CyberStrikeAI",
"apiDocs": "API Docs",
"logout": "Sign out",
"language": "Interface language"
},
"login": {
"title": "Sign in to CyberStrikeAI",
"subtitle": "Enter the access password from config",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"submit": "Sign in"
}
}
```
> 约定:**新增界面时,必须先定义 i18n key,再在 HTML/JS 中使用 key**,禁止直接写死中文/英文。
---
## 四、HTML 标记规范(data-i18n
### 4.1 基本规则
- 使用 `data-i18n` 将元素文本与某个 key 绑定:
```html
<span data-i18n="nav.dashboard">仪表盘</span>
```
- 默认行为:脚本会替换元素的 `textContent`
- 同时翻译属性时,额外使用 `data-i18n-attr`,逗号分隔多个属性名:
```html
<button
class="openapi-doc-btn"
onclick="window.open('/api-docs', '_blank')"
data-i18n="header.apiDocs"
data-i18n-attr="title"
title="API 文档">
<span data-i18n="header.apiDocs">API 文档</span>
</button>
```
### 4.2 默认文本的作用
- HTML 内的中文默认值作为「**无 JS / 初始化前**」的占位内容:
- 页面在 JS 尚未加载完成时不会出现空白或 key。
- JS 初始化后会用当前语言覆盖这些文本。
---
## 五、JavaScript 中的文案规范
### 5.1 全局翻译函数 `t()`
`i18n.js` 暴露以下全局函数:
- `window.t(key: string): string`
- 返回当前语言下的翻译文本,若缺失则回退到默认语言,再不行则返回 key 本身。
- `window.changeLanguage(lang: string): Promise<void>`
- 切换语言并刷新页面文案(不会刷新整页)。
示例(以 `web/static/js/settings.js` 为例):
```js
// 之前
alert('加载配置失败: ' + error.message);
// 之后
alert(t('settings.loadConfigFailed') + ': ' + error.message);
```
> 规范:**JS 内所有面向用户的提示、按钮文字、对话框标题都应通过 `t()` 获取**,不直接写死中文/英文。
### 5.2 渐进迁移建议
- 优先改造:
- 频繁弹出的错误提示 / 成功提示;
- 登录相关、系统设置相关文案。
- 低优先级:
- 仅面向运维人员的调试提示,可以暂时保留英文/中文常量。
---
## 六、i18n 初始化与语言切换实现
### 6.1 语言选择策略
- 默认语言:`zh-CN`
- 优先级(从高到低):
1. `localStorage` 中的用户选择(key`csai_lang`)。
2. 浏览器 `navigator.language``zh` 开头 → `zh-CN`,否则 `en-US`)。
3. 默认 `zh-CN`
### 6.2 初始化流程(`i18n.js`
1. 读取初始语言。
2. 初始化 i18next
- `lng` 为当前语言;
- `fallbackLng``zh-CN`
- 资源先留空,采用按需加载。
3. 通过 `fetch` 拉取 `/static/i18n/{lng}.json``i18next.addResources`
4. 更新:
- `<html lang="...">` 属性;
- 所有带 `data-i18n` / `data-i18n-attr` 的元素。
5. 暴露 `window.t``window.changeLanguage`
### 6.3 DOM 应用逻辑
伪代码:
```js
function applyTranslations(root = document) {
const elements = root.querySelectorAll('[data-i18n]');
elements.forEach(el => {
const key = el.getAttribute('data-i18n');
if (!key) return;
const text = i18next.t(key);
if (text) {
el.textContent = text;
}
const attrList = el.getAttribute('data-i18n-attr');
if (attrList) {
attrList.split(',').map(s => s.trim()).forEach(attr => {
if (!attr) return;
const val = i18next.t(key);
if (val) el.setAttribute(attr, val);
});
}
});
}
```
> 对于由 JS 动态插入的元素,需要在插入后再次调用 `applyTranslations(新容器)`。
---
## 七、语言切换 UI 规范
### 7.1 位置与形态
- 位置:`index.html` header 右侧 `API 文档` 按钮附近(靠近用户头像)。
- 交互形式:
- 一个紧凑的语言切换组件,例如:
- `🌐` 图标 + 当前语言文本(`中文` / `English`)的下拉按钮;
- 下拉内容列出所有可用语言。
### 7.2 示例结构
```html
<div class="lang-switcher">
<button class="btn-secondary lang-switcher-btn" onclick="toggleLangDropdown()" data-i18n="header.language">
<span class="lang-switcher-icon">🌐</span>
<span id="current-lang-label">中文</span>
</button>
<div id="lang-dropdown" class="lang-dropdown" style="display: none;">
<div class="lang-option" data-lang="zh-CN" onclick="onLanguageSelect('zh-CN')">中文</div>
<div class="lang-option" data-lang="en-US" onclick="onLanguageSelect('en-US')">English</div>
</div>
</div>
```
对应 JS(在 `i18n.js` 中):
```js
function onLanguageSelect(lang) {
changeLanguage(lang).then(updateLangLabel).catch(console.error);
closeLangDropdown();
}
function updateLangLabel() {
const labelEl = document.getElementById('current-lang-label');
if (!labelEl) return;
const lang = i18next.language || 'zh-CN';
labelEl.textContent = lang.startsWith('zh') ? '中文' : 'English';
}
```
> 规范:**语言切换只更新文案,不刷新整页,也不修改 URL hash**。
---
## 八、开发流程建议
### 8.1 新增 / 修改界面的流程
1. 设计界面时,先列出所有文案。
2. 在对应语言 JSON 中补充/修改 key 与翻译。
3. 在 HTML 中使用 `data-i18n`,在 JS 中使用 `t('...')`
4. 在浏览器中切换中英文,确认两种语言显示都正确。
### 8.2 渐进式改造顺序(推荐)
1. **阶段 1(已规划)**
- 引入 i18next 与 `i18n.js`
- 新建 `zh-CN.json` / `en-US.json`(先覆盖 header / 登录 / 左侧导航)。
- 实现 header 区域语言切换组件。
2. **阶段 2**(已完成)
- 系统设置页面(包括机器人配置页面)全部文案 i18n 化。
- `settings.js` 中的提示与错误信息改用 `t()`
3. **阶段 3**(进行中)
- 仪表盘、任务管理、漏洞管理、MCP、Skills、Roles 等页面按模块逐步迁移。
4. **阶段 4**
- 清理 JS / HTML 中残留的硬编码中文,统一通过 i18n。
---
## 九、后续扩展新语言
当需要新增语言时:
1.`web/static/i18n/` 中新增 `{lang}.json`,复制现有英文/中文文件结构,补充对应翻译。
2. 在语言切换下拉中添加对应选项,例如:
- `data-lang="ja-JP"` / 文本 `日本語`
3. 无需修改 `i18n.js` 或现有 HTML/JS 逻辑,即可支持新语言。
---
## 十、注意事项与坑点
- **不要复制多份 HTML 模板** 来做多语言,那样维护成本极高,本方案统一由前端 i18n 控制。
- **避免 key 直接用中文/英文句子**,统一采用「模块.语义」短 key,便于 diff 与搜索。
- 避免在 CSS 中写死文本(如 `content: "xxx"`),如确有需要,应通过 JS 设置并走 i18n。
- 对于后端返回的可本地化错误文本(未来可能支持),优先由后端根据 `Accept-Language` 返回对应语言,前端只负责展示。
+257
View File
@@ -0,0 +1,257 @@
# CyberStrikeAI 机器人使用说明
[English](robot_en.md)
本文档说明如何通过**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
---
## 一、在 CyberStrikeAI 里从哪里配置
1. 登录 CyberStrikeAI Web 端
2. 左侧导航进入 **系统设置**
3. 在左侧设置分类中点击 **机器人设置**(位于「基本设置」与「安全设置」之间)
4. 按平台勾选并填写(钉钉填 Client ID / Client Secret,飞书填 App ID / App Secret
5. 点击 **应用配置** 保存
6. **重启 CyberStrikeAI 应用**(只保存不重启,机器人不会连上)
配置会写入 `config.yaml``robots` 段,也可在配置文件中直接编辑。**修改钉钉/飞书配置后必须重启,长连接才会生效。**
---
## 二、支持的平台(长连接 / 回调)
| 平台 | 说明 |
|----------|------|
| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 |
| 飞书 | 使用长连接,程序主动连接飞书接收消息 |
| 企业微信 | 使用 HTTP 回调接收消息,被动回包 + 主动调用企业微信发送消息 API |
下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 CyberStrikeAI 的哪一栏。
---
## 三、各平台配置项与详细步骤
### 3.1 钉钉
**先搞清楚:两种钉钉机器人不一样**
| 类型 | 从哪里创建 | 能否做「用户发消息→机器人回复」 | 本程序是否支持 |
|------|------------|----------------------------------|----------------|
| **自定义机器人** | 钉钉群里:群设置 → 添加机器人 → 自定义(Webhook) | ❌ 不能,只能你往群里发消息 | ❌ 不支持 |
| **企业内部应用机器人** | [钉钉开放平台](https://open.dingtalk.com) 创建应用并开通机器人 | ✅ 能 | ✅ 支持 |
如果你手里是「自定义机器人」的 Webhook 地址(`oapi.dingtalk.com/robot/send?access_token=xxx`)和加签密钥(`SEC...`),**不能直接填到本程序**,必须按下面步骤在开放平台创建「企业内部应用」并拿到 **Client ID**、**Client Secret**。
---
**钉钉配置完整步骤(按顺序做)**
1. **打开钉钉开放平台**
浏览器访问 [https://open.dingtalk.com](https://open.dingtalk.com),用**企业管理员**账号登录。
2. **进入应用开发**
左侧选 **应用开发****企业内部开发** → 点击 **创建应用**(或选择已有应用)。填写应用名称等基本信息后创建。
3. **拿到 Client ID 和 Client Secret**
- 左侧点 **凭证与基础信息**(在「基础信息」下)。
- 页面上有 **Client ID(原 AppKey****Client Secret(原 AppSecret**
- 点击复制,**不要手打**,注意:数字 **0** 和字母 **o**、数字 **1** 和字母 **l** 容易抄错(例如 `ding9gf9tiozuc504aer` 中间是数字 **504** 不是 5o4)。
4. **开通机器人并选 Stream 模式**
- 左侧 **应用能力****机器人**
- 打开「机器人配置」开关。
- 填写机器人名称、简介等(必填项按提示填)。
- **关键**:消息接收方式要选 **「Stream 模式」**(流式接入)。若只有「HTTP 回调」或未选 Stream,本程序收不到消息。
- 保存。
5. **权限与发布**
- 左侧 **权限管理**:搜索「机器人」「消息」等,勾选**接收消息**、**发送消息**等机器人相关权限,并确认授权。
- 左侧 **版本管理与发布**:若有未发布配置,点击 **发布新版本** / **上线**,否则修改不生效。
6. **填回 CyberStrikeAI**
- 回到 CyberStrikeAI → 系统设置 → 机器人设置 → 钉钉。
- 勾选「启用钉钉机器人」。
- **Client ID (AppKey)** 粘贴第 3 步复制的 Client ID。
- **Client Secret** 粘贴第 3 步复制的 Client Secret。
- 点击 **应用配置**,然后**重启 CyberStrikeAI**。
---
**CyberStrikeAI 钉钉栏位对照**
| CyberStrikeAI 中填写项 | 在钉钉开放平台的来源 |
|------------------------|------------------------|
| 启用钉钉机器人 | 勾选即启用 |
| Client ID (AppKey) | 凭证与基础信息 → **Client ID(原 AppKey** |
| Client Secret | 凭证与基础信息 → **Client Secret(原 AppSecret** |
---
### 3.2 飞书 (Lark)
| 配置项 | 说明 |
|--------|------|
| 启用飞书机器人 | 勾选后启动飞书长连接 |
| App ID | 飞书开放平台应用凭证中的 App ID |
| App Secret | 飞书开放平台应用凭证中的 App Secret |
| Verify Token | 事件订阅用(可选) |
**飞书配置简要步骤**:登录 [飞书开放平台](https://open.feishu.cn) → 创建企业自建应用 → 在「凭证与基础信息」中获取 **App ID**、**App Secret** → 在「应用能力」中开通**机器人**并启用相应权限 → 发布应用 → 将 App ID、App Secret 填到 CyberStrikeAI 机器人设置 → 保存并**重启应用**。
---
### 3.3 企业微信 (WeCom)
> 企业微信目前采用「HTTP 回调 + 主动发送消息 API」的方式工作:
> - 用户发消息 → 企业微信以加密 XML **回调到你的服务器**(本程序的 `/api/robot/wecom`);
> - CyberStrikeAI 解密并调用 AI → 使用企业微信的 `message/send` 接口**主动发消息给用户**。
**配置概览:**
- 在企业微信管理后台创建或选择一个**自建应用**。
- 在该应用的「接收消息」处配置回调 URL、Token、EncodingAESKey。
- 在 CyberStrikeAI 的 `config.yaml` 中填入:
- `robots.wecom.corp_id`:企业 IDCorpID
- `robots.wecom.agent_id`:应用的 AgentId
- `robots.wecom.token`:消息回调使用的 Token
- `robots.wecom.encoding_aes_key`:消息回调使用的 EncodingAESKey
- `robots.wecom.secret`:该应用的 Secret(用于调用企业微信主动发送消息接口)
> **重要:IP 白名单(errcode 60020**
> CyberStrikeAI 使用 `https://qyapi.weixin.qq.com/cgi-bin/message/send` 主动发送 AI 回复。
> 若企业微信日志或本程序日志中出现 `errcode 60020 not allow to access from your ip`
>
> - 说明你的服务器出口 IP **没有加入企业微信的 IP 白名单**;
> - 请在企业微信管理后台中找到该自建应用的**「安全设置 / IP 白名单」**(具体入口可能因版本略有不同),将运行 CyberStrikeAI 的服务器公网 IP(如 `110.xxx.xxx.xxx`)加入白名单;
> - 保存后等待生效,再次发送消息测试。
>
> 如果 IP 未加入白名单,企业微信会拒绝主动发送消息,表现为:
> - 回调接口 `/api/robot/wecom` 能正常收到并处理消息;
> - 但手机端**始终收不到 AI 回复**,日志中有 `not allow to access from your ip` 提示。
---
## 四、机器人命令
在钉钉/飞书中向机器人发送以下**文本命令**(仅支持文本):
| 命令 | 说明 |
|------|------|
| **帮助** | 显示命令帮助与说明 |
| **列表****对话列表** | 列出所有对话的标题与对话 ID |
| **切换 \<对话ID\>****继续 \<对话ID\>** | 指定对话 ID,后续消息在该对话中继续 |
| **新对话** | 开启一个新对话,后续消息在新对话中 |
| **清空** | 清空当前对话上下文(效果等同「新对话」) |
| **当前** | 显示当前对话 ID 与标题 |
| **停止** | 中断当前正在执行的任务 |
| **角色****角色列表** | 列出所有可用角色(渗透测试、CTF、Web 应用扫描等) |
| **角色 \<角色名\>****切换角色 \<角色名\>** | 切换当前使用的角色 |
| **删除 \<对话ID\>** | 删除指定对话 |
| **版本** | 显示当前 CyberStrikeAI 版本号 |
除以上命令外,**直接输入任意文字**会作为用户消息发给 AI,与 Web 端对话逻辑一致(渗透测试/安全分析等)。
---
## 五、如何使用(要 @ 机器人吗?)
- **单聊(推荐)**:在钉钉/飞书里**搜索并打开该机器人**,进入与机器人的**私聊**,直接输入「帮助」或任意文字即可,**不需要 @**。
- **群聊**:若机器人被添加到群里,在群内只有 **@机器人** 后发送的消息才会被机器人收到并回复;不 @ 的群消息不会触发机器人。
总结:和机器人**单聊时直接发**;在**群里用时需要 @机器人** 再发内容。
---
## 六、推荐使用流程(避免漏步骤)
1. **在开放平台**:按第三节完成钉钉或飞书应用创建、凭证复制、机器人开通(钉钉务必选 **Stream 模式**)、权限与发布。
2. **在 CyberStrikeAI**:系统设置 → 机器人设置 → 勾选对应平台,粘贴 Client ID/App ID、Client Secret/App Secret → 点击 **应用配置**
3. **重启 CyberStrikeAI 进程**(否则长连接不会建立)。
4. **在手机钉钉/飞书**:找到该机器人(单聊直接发,群聊需 @机器人),发「帮助」或任意内容测试。
若发消息没反应,先看 **第九节排查****第十节常见弯路**
---
## 七、配置文件示例
`config.yaml` 中机器人相关片段示例:
```yaml
robots:
dingtalk:
enabled: true
client_id: "your_dingtalk_app_key"
client_secret: "your_dingtalk_app_secret"
lark:
enabled: true
app_id: "your_lark_app_id"
app_secret: "your_lark_app_secret"
verify_token: ""
```
修改后需**重启应用**,长连接在应用启动时建立。
---
## 八、如何验证是否可用(无需钉钉/飞书客户端)
在未安装钉钉或飞书时,可用**测试接口**验证机器人逻辑是否正常:
1. 先登录 CyberStrikeAI Web 端(保证有登录态)。
2. 使用 curl 调用测试接口(需携带登录后的 Cookie):
```bash
# 将 YOUR_COOKIE 替换为登录后获得的 Cookie(浏览器 F12 → 网络 → 任意请求 → 请求头中的 Cookie)
curl -X POST "http://localhost:8080/api/robot/test" \
-H "Content-Type: application/json" \
-H "Cookie: YOUR_COOKIE" \
-d '{"platform":"dingtalk","user_id":"test_user","text":"帮助"}'
```
若返回 JSON 中含有 `"reply":"【CyberStrikeAI 机器人命令】..."`,说明命令处理正常。可再试 `"text":"列表"``"text":"当前"` 等。
接口说明:`POST /api/robot/test`(需登录),请求体 `{"platform":"可选","user_id":"可选","text":"必填"}`,响应 `{"reply":"回复内容"}`
---
## 九、钉钉发消息没反应时排查
按顺序检查:
0. **笔记本合盖睡眠 / 断网后**
钉钉、飞书均使用长连接收消息,睡眠或断网后连接会断开。程序会**自动重连**(约 5 秒~60 秒内重试)。唤醒或恢复网络后稍等一会儿再发消息;若仍无反应,可重启 CyberStrikeAI 进程。
1. **Client ID / Client Secret 是否与开放平台完全一致**
从「凭证与基础信息」里**复制粘贴**,不要手打。注意数字 **0** 与字母 **o**、数字 **1** 与字母 **l**(例如 `ding9gf9tiozuc504aer` 中间是 **504** 不是 5o4)。
2. **是否在保存配置后重启了应用**
机器人长连接在**应用启动时**建立。在 Web 端点击「应用配置」只写入配置文件,**必须重启 CyberStrikeAI 进程**后钉钉连接才会生效。
3. **看程序日志**
- 启动后应看到:`钉钉 Stream 正在连接…``钉钉 Stream 已启动(无需公网),等待收消息`
- 若出现 `钉钉 Stream 长连接退出` 且带错误信息,多为 **Client ID / Client Secret 错误**或**开放平台未开通流式接入**
- 在钉钉里发一条消息后,若有收到,应有日志:`钉钉收到消息`;若没有,说明钉钉未把消息推到本程序(回头检查开放平台「机器人」是否开通、是否选用 **Stream 模式**)。
4. **开放平台侧**
应用需已**发布**;在「机器人」能力中需开启**流式接入(Stream)** 用于接收消息(仅 HTTP 回调不够);权限管理里需有机器人接收、发送消息等权限。
---
## 十、常见弯路(避免踩坑)
- **用错了机器人类型**:在钉钉**群里**添加的「自定义」机器人(Webhook + 加签)**不能**用来做对话,本程序只支持**开放平台「企业内部应用」**里的机器人。
- **只保存没重启**:在 CyberStrikeAI 里改完机器人配置后必须**重启应用**,否则长连接不会建立。
- **Client ID 抄错**:开放平台是 `504` 就填 `504`,不要填成 `5o4`;尽量用复制粘贴。
- **钉钉只开了 HTTP 回调没开 Stream**:本程序通过 **Stream 长连接**收消息,开放平台里机器人的消息接收方式必须选 **Stream 模式**
- **应用没发布**:开放平台里修改了机器人或权限后,要在「版本管理与发布」里**发布新版本**,否则不生效。
---
## 十一、注意事项
- 钉钉、飞书均**仅处理文本消息**;其他类型(如图片、语音)会提示暂不支持或忽略。
- 会话与 Web 端共用同一套对话数据:在机器人里创建的对话会在 Web 端「对话」列表中看到,反之亦然。
- 机器人执行逻辑与 **`/api/agent-loop/stream`** 一致(含进度回调、过程详情写入数据库),仅不向客户端推送 SSE,最后将完整回复一次性发回钉钉/飞书/企业微信。
+254
View File
@@ -0,0 +1,254 @@
# CyberStrikeAI Robot / Chatbot Guide
[中文](robot.md)
This document explains how to chat with CyberStrikeAI from **DingTalk**, **Lark (Feishu)**, and **WeCom (Enterprise WeChat)** using long-lived connections or HTTP callbacks—no need to open a browser on the server. Following the steps below helps avoid common mistakes.
---
## 1. Where to configure in CyberStrikeAI
1. Log in to the CyberStrikeAI web UI.
2. Open **System Settings** in the left sidebar.
3. Click **Robot settings** (between “Basic” and “Security”).
4. Enable the platform and fill in credentials (DingTalk: Client ID / Client Secret; Lark: App ID / App Secret).
5. Click **Apply configuration** to save.
6. **Restart the CyberStrikeAI process** (saving alone does not establish the connection).
Settings are written to the `robots` section of `config.yaml`; you can also edit the file directly. **After changing DingTalk or Lark config, you must restart for the long-lived connection to take effect.**
---
## 2. Supported platforms (long-lived / callback)
| Platform | Description |
|----------------|-------------|
| DingTalk | Stream long-lived connection; the app connects to DingTalk to receive messages |
| Lark (Feishu) | Long-lived connection; the app connects to Lark to receive messages |
| WeCom (Qiye WX)| HTTP callback to receive messages; CyberStrikeAI replies via WeComs message sending API |
Section 3 below describes, per platform, what to do in the developer console and which fields to copy into CyberStrikeAI.
---
## 3. Configuration and step-by-step setup
### 3.1 DingTalk
**Important: two types of DingTalk bots**
| Type | Where its created | Can do “user sends message → bot replies”? | Supported here? |
|------|-------------------|-------------------------------------------|------------------|
| **Custom bot (Webhook)** | In a DingTalk group: Group settings → Add robot → Custom (Webhook) | No; you can only post to the group | No |
| **Enterprise internal app bot** | [DingTalk Open Platform](https://open.dingtalk.com): create an app and enable the bot | Yes | Yes |
If you only have a **custom bot** Webhook URL (`oapi.dingtalk.com/robot/send?access_token=...`) and sign secret (`SEC...`), **do not** put them into CyberStrikeAI. You must create an **enterprise internal app** in the open platform and obtain **Client ID** and **Client Secret** as below.
---
**DingTalk setup (in order)**
1. **Open DingTalk Open Platform**
Go to [https://open.dingtalk.com](https://open.dingtalk.com) and log in with an **enterprise admin** account.
2. **Create or select an app**
In the left menu: **Application development****Enterprise internal development****Create application** (or choose an existing app). Fill in the app name and create.
3. **Get Client ID and Client Secret**
- In the left menu open **Credentials and basic info** (under “Basic information”).
- Copy **Client ID (formerly AppKey)** and **Client Secret (formerly AppSecret)**.
- Use copy/paste; avoid typing by hand. Watch for **0** vs **o** and **1** vs **l** (e.g. `ding9gf9tiozuc504aer` has the digits **504**, not 5o4).
4. **Enable the bot and choose Stream mode**
- Left menu: **Application capabilities****Robot**.
- Turn on “Robot configuration”.
- Fill in robot name, description, etc. as required.
- **Critical**: set message reception to **“Stream mode”** (流式接入). If you only enable “HTTP callback” or do not select Stream, CyberStrikeAI will not receive messages.
- Save.
5. **Permissions and release**
- Left menu: **Permission management** — search for “robot”, “message”, etc., and enable **receive message**, **send message**, and other bot-related permissions; confirm.
- Left menu: **Version management and release** — if there are unpublished changes, click **Release new version** / **Publish**; otherwise changes do not take effect.
6. **Fill in CyberStrikeAI**
- In CyberStrikeAI: System settings → Robot settings → DingTalk.
- Enable “Enable DingTalk robot”.
- Paste the Client ID and Client Secret from step 3.
- Click **Apply configuration**, then **restart CyberStrikeAI**.
---
**Field mapping (DingTalk)**
| Field in CyberStrikeAI | Source in DingTalk Open Platform |
|------------------------|----------------------------------|
| Enable DingTalk robot | Check to enable |
| Client ID (AppKey) | Credentials and basic info → **Client ID (formerly AppKey)** |
| Client Secret | Credentials and basic info → **Client Secret (formerly AppSecret)** |
---
### 3.2 Lark (Feishu)
| Field | Description |
|-------|-------------|
| Enable Lark robot | Check to start the Lark long-lived connection |
| App ID | From Lark open platform app credentials |
| App Secret | From Lark open platform app credentials |
| Verify Token | Optional; for event subscription |
**Lark setup in short**: Log in to [Lark Open Platform](https://open.feishu.cn) → Create an enterprise app → In “Credentials and basic info” get **App ID** and **App Secret** → In “Application capabilities” enable **Robot** and the right permissions → Publish the app → Enter App ID and App Secret in CyberStrikeAI robot settings → Save and **restart** the app.
---
### 3.3 WeCom (Enterprise WeChat)
> WeCom uses a **“HTTP callback + active message send API”** model:
> - User sends a message → WeCom sends an **encrypted XML callback** to your server (CyberStrikeAIs `/api/robot/wecom`).
> - CyberStrikeAI decrypts it, calls the AI, then uses WeComs `message/send` API to **actively push the reply** to the user.
**Configuration overview:**
- In the WeCom admin console, create or select a **custom app** (自建应用).
- In that apps settings, configure the message **callback URL**, **Token**, and **EncodingAESKey**.
- In CyberStrikeAIs `config.yaml`, fill in:
- `robots.wecom.corp_id`: your CorpID (企业 ID)
- `robots.wecom.agent_id`: the apps AgentId
- `robots.wecom.token`: the Token used for message callbacks
- `robots.wecom.encoding_aes_key`: the EncodingAESKey used for callbacks
- `robots.wecom.secret`: the apps Secret (used when calling WeCom APIs to send messages)
> **Important: IP allowlist (errcode 60020)**
> CyberStrikeAI calls `https://qyapi.weixin.qq.com/cgi-bin/message/send` to actively send AI replies.
> If logs show `errcode 60020 not allow to access from your ip`:
>
> - Your servers outbound IP is **not in WeComs IP allowlist**.
> - In the WeCom admin console, open the custom apps **Security / IP allowlist** settings (name may vary slightly), and add the public IP of the machine running CyberStrikeAI (e.g. `110.xxx.xxx.xxx`).
> - Save and wait for it to take effect, then test again.
>
> If the IP is not whitelisted, WeCom will reject active message sending. You will see that `/api/robot/wecom` receives and processes callbacks, but users **never see AI replies**, and logs contain `not allow to access from your ip`.
---
## 4. Bot commands
Send these **text commands** to the bot in DingTalk or Lark (text only):
| Command | Description |
|---------|-------------|
| **帮助** (help) | Show command help |
| **列表** or **对话列表** (list) | List all conversation titles and IDs |
| **切换 \<conversationID\>** or **继续 \<conversationID\>** | Continue in the given conversation |
| **新对话** (new) | Start a new conversation |
| **清空** (clear) | Clear current context (same effect as new conversation) |
| **当前** (current) | Show current conversation ID and title |
| **停止** (stop) | Abort the currently running task |
| **角色** or **角色列表** (roles) | List all available roles (penetration testing, CTF, Web scan, etc.) |
| **角色 \<roleName\>** or **切换角色 \<roleName\>** | Switch to the specified role |
| **删除 \<conversationID\>** | Delete the specified conversation |
| **版本** (version) | Show current CyberStrikeAI version |
Any other text is sent to the AI as a user message, same as in the web UI (e.g. penetration testing, security analysis).
---
## 5. How to use (do I need to @ the bot?)
- **Direct chat (recommended)**: In DingTalk or Lark, **search for the bot and open a direct chat**. Type “帮助” or any message; **no @ needed**.
- **Group chat**: If the bot is in a group, only messages that **@ the bot** are received and answered; other group messages are ignored.
Summary: **Direct chat** — just send; **in a group** — @ the bot first, then send.
---
## 6. Recommended flow (so you dont skip steps)
1. **In the open platform**: Complete app creation, copy credentials, enable the bot (DingTalk: **Stream mode**), set permissions, and publish (Section 3).
2. **In CyberStrikeAI**: System settings → Robot settings → Enable the platform, paste Client ID/App ID and Client Secret/App Secret → **Apply configuration**.
3. **Restart the CyberStrikeAI process** (otherwise the long-lived connection is not established).
4. **On your phone**: Open DingTalk or Lark, find the bot (direct chat or @ in a group), send “帮助” or any message to test.
If the bot does not respond, see **Section 9 (troubleshooting)** and **Section 10 (common pitfalls)**.
---
## 7. Config file example
Example `robots` section in `config.yaml`:
```yaml
robots:
dingtalk:
enabled: true
client_id: "your_dingtalk_app_key"
client_secret: "your_dingtalk_app_secret"
lark:
enabled: true
app_id: "your_lark_app_id"
app_secret: "your_lark_app_secret"
verify_token: ""
```
**Restart the app** after changes; the long-lived connection is created at startup.
---
## 8. Testing without DingTalk/Lark installed
You can verify bot logic with the **test API** (no DingTalk/Lark client needed):
1. Log in to the CyberStrikeAI web UI (so you have a session).
2. Call the test endpoint with curl (include your session Cookie):
```bash
# Replace YOUR_COOKIE with the Cookie from your browser (F12 → Network → any request → Request headers → Cookie)
curl -X POST "http://localhost:8080/api/robot/test" \
-H "Content-Type: application/json" \
-H "Cookie: YOUR_COOKIE" \
-d '{"platform":"dingtalk","user_id":"test_user","text":"帮助"}'
```
If the JSON response contains `"reply":"【CyberStrikeAI 机器人命令】..."`, command handling works. You can also try `"text":"列表"` or `"text":"当前"`.
API: `POST /api/robot/test` (requires login). Body: `{"platform":"optional","user_id":"optional","text":"required"}`. Response: `{"reply":"..."}`.
---
## 9. DingTalk: no response when sending messages
Check in this order:
0. **After laptop sleep or network drop**
DingTalk and Lark both use long-lived connections; they break when the machine sleeps or the network drops. The app **auto-reconnects** (retries within about 560 seconds). After wake or network recovery, wait a moment before sending; if there is still no response, restart the CyberStrikeAI process.
1. **Client ID / Client Secret match the open platform exactly**
Copy from “Credentials and basic info”; avoid typing. Watch **0** vs **o** and **1** vs **l** (e.g. `ding9gf9tiozuc504aer` has **504**, not 5o4).
2. **Did you restart after saving?**
The long-lived connection is created at **startup**. “Apply configuration” only updates the config file; you **must restart the CyberStrikeAI process** for the DingTalk connection to start.
3. **Application logs**
- On startup you should see: `钉钉 Stream 正在连接…`, `钉钉 Stream 已启动(无需公网),等待收消息`.
- If you see `钉钉 Stream 长连接退出` with an error, its usually wrong **Client ID / Client Secret** or **Stream not enabled** in the open platform.
- After sending a message in DingTalk, you should see `钉钉收到消息` in the logs; if not, the platform is not pushing to this app (check that the bot is enabled and **Stream mode** is selected).
4. **Open platform**
The app must be **published**. Under “Robot” you must enable **Stream** for receiving messages (HTTP callback only is not enough). Permission management must include robot receive/send message permissions.
---
## 10. Common pitfalls
- **Wrong bot type**: The “Custom” bot added in a DingTalk **group** (Webhook + sign secret) **cannot** be used for two-way chat. Only the **enterprise internal app** bot from the open platform is supported.
- **Saved but not restarted**: After changing robot settings in CyberStrikeAI you **must restart** the app, or the long-lived connection will not be established.
- **Client ID typo**: If the platform shows `504`, use `504` (not `5o4`); prefer copy/paste.
- **DingTalk: only HTTP callback, no Stream**: This app receives messages via **Stream**. In the open platform, message reception must be **Stream mode**.
- **App not published**: After changing the bot or permissions in the open platform, **publish a new version** under “Version management and release”, or changes wont apply.
---
## 11. Notes
- DingTalk and Lark: **text messages only**; other types (e.g. image, voice) are not supported and may be ignored.
- Conversations are shared with the web UI: conversations created from the bot appear in the web “Conversations” list and vice versa.
- Bot execution uses the same logic as **`/api/agent-loop/stream`** (progress callbacks, process details stored in the DB); only the final reply is sent back to DingTalk/Lark in one message (no SSE to the client).
+17 -1
View File
@@ -1,13 +1,21 @@
module cyberstrike-ai module cyberstrike-ai
go 1.21 go 1.24.0
toolchain go1.24.4
require ( require (
github.com/creack/pty v1.1.24
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.5.0 github.com/google/uuid v1.5.0
github.com/gorilla/websocket v1.5.0
github.com/larksuite/oapi-sdk-go/v3 v3.4.22
github.com/mattn/go-sqlite3 v1.14.18 github.com/mattn/go-sqlite3 v1.14.18
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/pkoukk/tiktoken-go v0.1.8 github.com/pkoukk/tiktoken-go v0.1.8
go.uber.org/zap v1.26.0 go.uber.org/zap v1.26.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -21,6 +29,8 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect
@@ -30,11 +40,17 @@ require (
github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.14.0 // indirect golang.org/x/crypto v0.14.0 // indirect
golang.org/x/net v0.17.0 // indirect golang.org/x/net v0.17.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.13.0 // indirect golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect google.golang.org/protobuf v1.30.0 // indirect
) )
// 修复钉钉 Stream SDK 在长连接断开(熄屏/网络中断)后 "panic: send on closed channel" 问题
// 详见: https://github.com/open-dingtalk/dingtalk-stream-sdk-go/issues/28
replace github.com/open-dingtalk/dingtalk-stream-sdk-go => github.com/uouuou/dingtalk-stream-sdk-go v0.0.0-20250626025113-079132acc406
+54 -2
View File
@@ -4,6 +4,8 @@ github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZX
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -25,23 +27,38 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/larksuite/oapi-sdk-go/v3 v3.4.22 h1:57daKuslQPX9X3hC2idc5bu8bl2krfsBGWGJ6b5FlD8=
github.com/larksuite/oapi-sdk-go/v3 v3.4.22/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -68,6 +85,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/uouuou/dingtalk-stream-sdk-go v0.0.0-20250626025113-079132acc406 h1:b72HNsEnmTRn7vhWGOfbWHAkA5RbRCk0Pbc56V2WAuY=
github.com/uouuou/dingtalk-stream-sdk-go v0.0.0-20250626025113-079132acc406/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -77,18 +100,47 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

+27 -13
View File
@@ -529,8 +529,10 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
maxIterations := a.maxIterations maxIterations := a.maxIterations
for i := 0; i < maxIterations; i++ { for i := 0; i < maxIterations; i++ {
// 每轮调用前先尝试压缩,防止历史消息持续膨胀 // 先获取本轮可用工具并统计 tools token,再压缩,以便压缩时预留 tools 占用的空间
messages = a.applyMemoryCompression(ctx, messages) tools := a.getAvailableTools(roleTools)
toolsTokens := a.countToolsTokens(tools)
messages = a.applyMemoryCompression(ctx, messages, toolsTokens)
// 检查是否是最后一次迭代 // 检查是否是最后一次迭代
isLastIteration := (i == maxIterations-1) isLastIteration := (i == maxIterations-1)
@@ -562,17 +564,17 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
default: default:
} }
// 获取可用工具 // 记录当前上下文的 Token 用量(messages + tools),展示压缩器运行状态
tools := a.getAvailableTools(roleTools)
// 记录当前上下文的Token用量,展示压缩器运行状态
if a.memoryCompressor != nil { if a.memoryCompressor != nil {
totalTokens, systemCount, regularCount := a.memoryCompressor.totalTokensFor(messages) messagesTokens, systemCount, regularCount := a.memoryCompressor.totalTokensFor(messages)
totalTokens := messagesTokens + toolsTokens
a.logger.Info("memory compressor context stats", a.logger.Info("memory compressor context stats",
zap.Int("iteration", i+1), zap.Int("iteration", i+1),
zap.Int("messagesCount", len(messages)), zap.Int("messagesCount", len(messages)),
zap.Int("systemMessages", systemCount), zap.Int("systemMessages", systemCount),
zap.Int("regularMessages", regularCount), zap.Int("regularMessages", regularCount),
zap.Int("messagesTokens", messagesTokens),
zap.Int("toolsTokens", toolsTokens),
zap.Int("totalTokens", totalTokens), zap.Int("totalTokens", totalTokens),
zap.Int("maxTotalTokens", a.memoryCompressor.maxTotalTokens), zap.Int("maxTotalTokens", a.memoryCompressor.maxTotalTokens),
) )
@@ -788,7 +790,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
Role: "user", Role: "user",
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。", Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
}) })
messages = a.applyMemoryCompression(ctx, messages) messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
// 立即调用OpenAI获取总结 // 立即调用OpenAI获取总结
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复 summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 { if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
@@ -828,7 +830,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
Role: "user", Role: "user",
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。", Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
}) })
messages = a.applyMemoryCompression(ctx, messages) messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
// 立即调用OpenAI获取总结 // 立即调用OpenAI获取总结
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复 summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 { if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
@@ -867,7 +869,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
Content: fmt.Sprintf("已达到最大迭代次数(%d轮)。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。", a.maxIterations), Content: fmt.Sprintf("已达到最大迭代次数(%d轮)。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。", a.maxIterations),
} }
messages = append(messages, finalSummaryPrompt) messages = append(messages, finalSummaryPrompt)
messages = a.applyMemoryCompression(ctx, messages) messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复 summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 { if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
@@ -1425,13 +1427,13 @@ func (a *Agent) formatToolError(toolName string, args map[string]interface{}, er
return errorMsg return errorMsg
} }
// applyMemoryCompression 在调用LLM前对消息进行压缩,避免超过token限制 // applyMemoryCompression 在调用LLM前对消息进行压缩,避免超过 token 限制。reservedTokens 为预留给 tools 的 token 数,传 0 表示不预留。
func (a *Agent) applyMemoryCompression(ctx context.Context, messages []ChatMessage) []ChatMessage { func (a *Agent) applyMemoryCompression(ctx context.Context, messages []ChatMessage, reservedTokens int) []ChatMessage {
if a.memoryCompressor == nil { if a.memoryCompressor == nil {
return messages return messages
} }
compressed, changed, err := a.memoryCompressor.CompressHistory(ctx, messages) compressed, changed, err := a.memoryCompressor.CompressHistory(ctx, messages, reservedTokens)
if err != nil { if err != nil {
a.logger.Warn("上下文压缩失败,将使用原始消息继续", zap.Error(err)) a.logger.Warn("上下文压缩失败,将使用原始消息继续", zap.Error(err))
return messages return messages
@@ -1447,6 +1449,18 @@ func (a *Agent) applyMemoryCompression(ctx context.Context, messages []ChatMessa
return messages return messages
} }
// countToolsTokens 统计 tools 序列化后的 token 数,用于日志与压缩时预留空间。mc 为 nil 时返回 0。
func (a *Agent) countToolsTokens(tools []Tool) int {
if len(tools) == 0 || a.memoryCompressor == nil {
return 0
}
data, err := json.Marshal(tools)
if err != nil {
return 0
}
return a.memoryCompressor.CountTextTokens(string(data))
}
// handleMissingToolError 当LLM调用不存在的工具时,向其追加提示消息并允许继续迭代 // handleMissingToolError 当LLM调用不存在的工具时,向其追加提示消息并允许继续迭代
func (a *Agent) handleMissingToolError(errMsg string, messages *[]ChatMessage) (bool, string) { func (a *Agent) handleMissingToolError(errMsg string, messages *[]ChatMessage) (bool, string) {
lowerMsg := strings.ToLower(errMsg) lowerMsg := strings.ToLower(errMsg)
+15 -3
View File
@@ -158,8 +158,8 @@ func (mc *MemoryCompressor) UpdateConfig(cfg *config.OpenAIConfig) {
} }
} }
// CompressHistory 根据Token限制压缩历史消息。 // CompressHistory 根据 Token 限制压缩历史消息。reservedTokens 为预留给 tools 等非消息内容的 token 数,压缩时使用 (maxTotalTokens - reservedTokens) 作为消息上限。
func (mc *MemoryCompressor) CompressHistory(ctx context.Context, messages []ChatMessage) ([]ChatMessage, bool, error) { func (mc *MemoryCompressor) CompressHistory(ctx context.Context, messages []ChatMessage, reservedTokens int) ([]ChatMessage, bool, error) {
if len(messages) == 0 { if len(messages) == 0 {
return messages, false, nil return messages, false, nil
} }
@@ -171,8 +171,13 @@ func (mc *MemoryCompressor) CompressHistory(ctx context.Context, messages []Chat
return messages, false, nil return messages, false, nil
} }
effectiveMax := mc.maxTotalTokens
if reservedTokens > 0 && reservedTokens < mc.maxTotalTokens {
effectiveMax = mc.maxTotalTokens - reservedTokens
}
totalTokens := mc.countTotalTokens(systemMsgs, regularMsgs) totalTokens := mc.countTotalTokens(systemMsgs, regularMsgs)
if totalTokens <= int(float64(mc.maxTotalTokens)*0.9) { if totalTokens <= int(float64(effectiveMax)*0.9) {
return messages, false, nil return messages, false, nil
} }
@@ -184,6 +189,8 @@ func (mc *MemoryCompressor) CompressHistory(ctx context.Context, messages []Chat
mc.logger.Info("memory compression triggered", mc.logger.Info("memory compression triggered",
zap.Int("total_tokens", totalTokens), zap.Int("total_tokens", totalTokens),
zap.Int("max_total_tokens", mc.maxTotalTokens), zap.Int("max_total_tokens", mc.maxTotalTokens),
zap.Int("reserved_tokens", reservedTokens),
zap.Int("effective_max", effectiveMax),
zap.Int("system_messages", len(systemMsgs)), zap.Int("system_messages", len(systemMsgs)),
zap.Int("regular_messages", len(regularMsgs)), zap.Int("regular_messages", len(regularMsgs)),
zap.Int("old_messages", len(oldMsgs)), zap.Int("old_messages", len(oldMsgs)),
@@ -282,6 +289,11 @@ func (mc *MemoryCompressor) countTokens(text string) int {
return count return count
} }
// CountTextTokens 对外暴露的文本 Token 计数,用于统计 tools 等非消息内容的 token(如 agent 侧序列化 tools 后计数)。
func (mc *MemoryCompressor) CountTextTokens(text string) int {
return mc.countTokens(text)
}
// totalTokensFor provides token statistics without mutating the message list. // totalTokensFor provides token statistics without mutating the message list.
func (mc *MemoryCompressor) totalTokensFor(messages []ChatMessage) (totalTokens int, systemCount int, regularCount int) { func (mc *MemoryCompressor) totalTokensFor(messages []ChatMessage) (totalTokens int, systemCount int, regularCount int) {
if len(messages) == 0 { if len(messages) == 0 {
+121 -4
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/agent"
@@ -14,6 +15,7 @@ import (
"cyberstrike-ai/internal/database" "cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/handler" "cyberstrike-ai/internal/handler"
"cyberstrike-ai/internal/knowledge" "cyberstrike-ai/internal/knowledge"
"cyberstrike-ai/internal/robot"
"cyberstrike-ai/internal/logger" "cyberstrike-ai/internal/logger"
"cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin" "cyberstrike-ai/internal/mcp/builtin"
@@ -43,6 +45,10 @@ type App struct {
knowledgeIndexer *knowledge.Indexer // 知识库索引器(用于动态初始化) knowledgeIndexer *knowledge.Indexer // 知识库索引器(用于动态初始化)
knowledgeHandler *handler.KnowledgeHandler // 知识库处理器(用于动态初始化) knowledgeHandler *handler.KnowledgeHandler // 知识库处理器(用于动态初始化)
agentHandler *handler.AgentHandler // Agent处理器(用于更新知识库管理器) agentHandler *handler.AgentHandler // Agent处理器(用于更新知识库管理器)
robotHandler *handler.RobotHandler // 机器人处理器(钉钉/飞书/企业微信)
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启
larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启
} }
// New 创建新应用 // New 创建新应用
@@ -192,7 +198,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
knowledgeRetriever = knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, log.Logger) knowledgeRetriever = knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, log.Logger)
// 创建索引器 // 创建索引器
knowledgeIndexer = knowledge.NewIndexer(knowledgeDB, embedder, log.Logger) knowledgeIndexer = knowledge.NewIndexer(knowledgeDB, embedder, log.Logger, &cfg.Knowledge.Indexing)
// 注册知识检索工具到MCP服务器 // 注册知识检索工具到MCP服务器
knowledge.RegisterKnowledgeTool(mcpServer, knowledgeRetriever, knowledgeManager, log.Logger) knowledge.RegisterKnowledgeTool(mcpServer, knowledgeRetriever, knowledgeManager, log.Logger)
@@ -318,12 +324,15 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger) roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
roleHandler.SetSkillsManager(skillsManager) // 设置Skills管理器到RoleHandler roleHandler.SetSkillsManager(skillsManager) // 设置Skills管理器到RoleHandler
skillsHandler := handler.NewSkillsHandler(skillsManager, cfg, configPath, log.Logger) skillsHandler := handler.NewSkillsHandler(skillsManager, cfg, configPath, log.Logger)
fofaHandler := handler.NewFofaHandler(cfg, log.Logger)
terminalHandler := handler.NewTerminalHandler(log.Logger)
if db != nil { if db != nil {
skillsHandler.SetDB(db) // 设置数据库连接以便获取调用统计 skillsHandler.SetDB(db) // 设置数据库连接以便获取调用统计
} }
// 创建OpenAPI处理器 // 创建OpenAPI处理器
conversationHandler := handler.NewConversationHandler(db, log.Logger) conversationHandler := handler.NewConversationHandler(db, log.Logger)
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, resultStorage, conversationHandler, agentHandler) openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, resultStorage, conversationHandler, agentHandler)
// 创建 App 实例(部分字段稍后填充) // 创建 App 实例(部分字段稍后填充)
@@ -343,7 +352,10 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
knowledgeIndexer: knowledgeIndexer, knowledgeIndexer: knowledgeIndexer,
knowledgeHandler: knowledgeHandler, knowledgeHandler: knowledgeHandler,
agentHandler: agentHandler, agentHandler: agentHandler,
robotHandler: robotHandler,
} }
// 飞书/钉钉长连接(无需公网),启用时在后台启动;后续前端应用配置时会通过 RestartRobotConnections 重启
app.startRobotConnections()
// 设置漏洞工具注册器(内置工具,必须设置) // 设置漏洞工具注册器(内置工具,必须设置)
vulnerabilityRegistrar := func() error { vulnerabilityRegistrar := func() error {
@@ -400,6 +412,9 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
configHandler.SetRetrieverUpdater(knowledgeRetriever) configHandler.SetRetrieverUpdater(knowledgeRetriever)
} }
// 设置机器人连接重启器,前端应用配置后无需重启服务即可使钉钉/飞书新配置生效
configHandler.SetRobotRestarter(app)
// 设置路由(使用 App 实例以便动态获取 handler // 设置路由(使用 App 实例以便动态获取 handler
setupRoutes( setupRoutes(
router, router,
@@ -407,6 +422,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
agentHandler, agentHandler,
monitorHandler, monitorHandler,
conversationHandler, conversationHandler,
robotHandler,
groupHandler, groupHandler,
configHandler, configHandler,
externalMCPHandler, externalMCPHandler,
@@ -415,6 +431,8 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
vulnerabilityHandler, vulnerabilityHandler,
roleHandler, roleHandler,
skillsHandler, skillsHandler,
fofaHandler,
terminalHandler,
mcpServer, mcpServer,
authManager, authManager,
openAPIHandler, openAPIHandler,
@@ -424,6 +442,21 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
} }
// mcpHandlerWithAuth 在鉴权通过后转发到 MCP 处理;若配置了 auth_header 则校验请求头,否则直接放行
func (a *App) mcpHandlerWithAuth(w http.ResponseWriter, r *http.Request) {
cfg := a.config.MCP
if cfg.AuthHeader != "" {
if r.Header.Get(cfg.AuthHeader) != cfg.AuthHeaderValue {
a.logger.Logger.Debug("MCP 鉴权失败:header 缺失或值不匹配", zap.String("header", cfg.AuthHeader))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
return
}
}
a.mcpServer.HandleHTTP(w, r)
}
// Run 启动应用 // Run 启动应用
func (a *App) Run() error { func (a *App) Run() error {
// 启动MCP服务器(如果启用) // 启动MCP服务器(如果启用)
@@ -433,7 +466,7 @@ func (a *App) Run() error {
a.logger.Info("启动MCP服务器", zap.String("address", mcpAddr)) a.logger.Info("启动MCP服务器", zap.String("address", mcpAddr))
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/mcp", a.mcpServer.HandleHTTP) mux.HandleFunc("/mcp", a.mcpHandlerWithAuth)
if err := http.ListenAndServe(mcpAddr, mux); err != nil { if err := http.ListenAndServe(mcpAddr, mux); err != nil {
a.logger.Error("MCP服务器启动失败", zap.Error(err)) a.logger.Error("MCP服务器启动失败", zap.Error(err))
@@ -450,6 +483,18 @@ func (a *App) Run() error {
// Shutdown 关闭应用 // Shutdown 关闭应用
func (a *App) Shutdown() { func (a *App) Shutdown() {
// 停止钉钉/飞书长连接
a.robotMu.Lock()
if a.dingCancel != nil {
a.dingCancel()
a.dingCancel = nil
}
if a.larkCancel != nil {
a.larkCancel()
a.larkCancel = nil
}
a.robotMu.Unlock()
// 停止所有外部MCP客户端 // 停止所有外部MCP客户端
if a.externalMCPMgr != nil { if a.externalMCPMgr != nil {
a.externalMCPMgr.StopAll() a.externalMCPMgr.StopAll()
@@ -463,6 +508,40 @@ func (a *App) Shutdown() {
} }
} }
// startRobotConnections 根据当前配置启动钉钉/飞书长连接(不先关闭已有连接,仅用于首次启动)
func (a *App) startRobotConnections() {
a.robotMu.Lock()
defer a.robotMu.Unlock()
cfg := a.config
if cfg.Robots.Lark.Enabled && cfg.Robots.Lark.AppID != "" && cfg.Robots.Lark.AppSecret != "" {
ctx, cancel := context.WithCancel(context.Background())
a.larkCancel = cancel
go robot.StartLark(ctx, cfg.Robots.Lark, a.robotHandler, a.logger.Logger)
}
if cfg.Robots.Dingtalk.Enabled && cfg.Robots.Dingtalk.ClientID != "" && cfg.Robots.Dingtalk.ClientSecret != "" {
ctx, cancel := context.WithCancel(context.Background())
a.dingCancel = cancel
go robot.StartDing(ctx, cfg.Robots.Dingtalk, a.robotHandler, a.logger.Logger)
}
}
// RestartRobotConnections 重启钉钉/飞书长连接,使前端应用配置后立即生效(实现 handler.RobotRestarter
func (a *App) RestartRobotConnections() {
a.robotMu.Lock()
if a.dingCancel != nil {
a.dingCancel()
a.dingCancel = nil
}
if a.larkCancel != nil {
a.larkCancel()
a.larkCancel = nil
}
a.robotMu.Unlock()
// 给旧 goroutine 一点时间退出
time.Sleep(200 * time.Millisecond)
a.startRobotConnections()
}
// setupRoutes 设置路由 // setupRoutes 设置路由
func setupRoutes( func setupRoutes(
router *gin.Engine, router *gin.Engine,
@@ -470,6 +549,7 @@ func setupRoutes(
agentHandler *handler.AgentHandler, agentHandler *handler.AgentHandler,
monitorHandler *handler.MonitorHandler, monitorHandler *handler.MonitorHandler,
conversationHandler *handler.ConversationHandler, conversationHandler *handler.ConversationHandler,
robotHandler *handler.RobotHandler,
groupHandler *handler.GroupHandler, groupHandler *handler.GroupHandler,
configHandler *handler.ConfigHandler, configHandler *handler.ConfigHandler,
externalMCPHandler *handler.ExternalMCPHandler, externalMCPHandler *handler.ExternalMCPHandler,
@@ -478,6 +558,8 @@ func setupRoutes(
vulnerabilityHandler *handler.VulnerabilityHandler, vulnerabilityHandler *handler.VulnerabilityHandler,
roleHandler *handler.RoleHandler, roleHandler *handler.RoleHandler,
skillsHandler *handler.SkillsHandler, skillsHandler *handler.SkillsHandler,
fofaHandler *handler.FofaHandler,
terminalHandler *handler.TerminalHandler,
mcpServer *mcp.Server, mcpServer *mcp.Server,
authManager *security.AuthManager, authManager *security.AuthManager,
openAPIHandler *handler.OpenAPIHandler, openAPIHandler *handler.OpenAPIHandler,
@@ -494,9 +576,18 @@ func setupRoutes(
authRoutes.GET("/validate", security.AuthMiddleware(authManager), authHandler.Validate) authRoutes.GET("/validate", security.AuthMiddleware(authManager), authHandler.Validate)
} }
// 机器人回调(无需登录,供企业微信/钉钉/飞书服务器调用)
api.GET("/robot/wecom", robotHandler.HandleWecomGET)
api.POST("/robot/wecom", robotHandler.HandleWecomPOST)
api.POST("/robot/dingtalk", robotHandler.HandleDingtalkPOST)
api.POST("/robot/lark", robotHandler.HandleLarkPOST)
protected := api.Group("") protected := api.Group("")
protected.Use(security.AuthMiddleware(authManager)) protected.Use(security.AuthMiddleware(authManager))
{ {
// 机器人测试(需登录):POST /api/robot/testbody: {"platform":"dingtalk","user_id":"test","text":"帮助"},用于验证机器人逻辑
protected.POST("/robot/test", robotHandler.HandleRobotTest)
// Agent Loop // Agent Loop
protected.POST("/agent-loop", agentHandler.AgentLoop) protected.POST("/agent-loop", agentHandler.AgentLoop)
// Agent Loop 流式输出 // Agent Loop 流式输出
@@ -506,6 +597,11 @@ func setupRoutes(
protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks) protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks)
protected.GET("/agent-loop/tasks/completed", agentHandler.ListCompletedTasks) protected.GET("/agent-loop/tasks/completed", agentHandler.ListCompletedTasks)
// 信息收集 - FOFA 查询(后端代理)
protected.POST("/fofa/search", fofaHandler.Search)
// 信息收集 - 自然语言解析为 FOFA 语法(需人工确认后再查询)
protected.POST("/fofa/parse", fofaHandler.ParseNaturalLanguage)
// 批量任务管理 // 批量任务管理
protected.POST("/batch-tasks", agentHandler.CreateBatchQueue) protected.POST("/batch-tasks", agentHandler.CreateBatchQueue)
protected.GET("/batch-tasks", agentHandler.ListBatchQueues) protected.GET("/batch-tasks", agentHandler.ListBatchQueues)
@@ -550,6 +646,11 @@ func setupRoutes(
protected.PUT("/config", configHandler.UpdateConfig) protected.PUT("/config", configHandler.UpdateConfig)
protected.POST("/config/apply", configHandler.ApplyConfig) protected.POST("/config/apply", configHandler.ApplyConfig)
// 系统设置 - 终端(执行命令,提高运维效率)
protected.POST("/terminal/run", terminalHandler.RunCommand)
protected.POST("/terminal/run/stream", terminalHandler.RunCommandStream)
protected.GET("/terminal/ws", terminalHandler.RunCommandWS)
// 外部MCP管理 // 外部MCP管理
protected.GET("/external-mcp", externalMCPHandler.GetExternalMCPs) protected.GET("/external-mcp", externalMCPHandler.GetExternalMCPs)
protected.GET("/external-mcp/stats", externalMCPHandler.GetExternalMCPStats) protected.GET("/external-mcp/stats", externalMCPHandler.GetExternalMCPStats)
@@ -694,6 +795,18 @@ func setupRoutes(
} }
app.knowledgeHandler.Search(c) app.knowledgeHandler.Search(c)
}) })
knowledgeRoutes.GET("/stats", func(c *gin.Context) {
if app.knowledgeHandler == nil {
c.JSON(http.StatusOK, gin.H{
"enabled": false,
"total_categories": 0,
"total_items": 0,
"message": "知识库功能未启用,请前往系统设置启用知识检索功能",
})
return
}
app.knowledgeHandler.GetStats(c)
})
} }
// 漏洞管理 // 漏洞管理
@@ -746,7 +859,11 @@ func setupRoutes(
// 前端页面 // 前端页面
router.GET("/", func(c *gin.Context) { router.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", nil) version := app.config.Version
if version == "" {
version = "v1.0.0"
}
c.HTML(http.StatusOK, "index.html", gin.H{"Version": version})
}) })
} }
@@ -1000,7 +1117,7 @@ func initializeKnowledge(
knowledgeRetriever := knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, logger) knowledgeRetriever := knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, logger)
// 创建索引器 // 创建索引器
knowledgeIndexer := knowledge.NewIndexer(knowledgeDB, embedder, logger) knowledgeIndexer := knowledge.NewIndexer(knowledgeDB, embedder, logger, &cfg.Knowledge.Indexing)
// 注册知识检索工具到MCP服务器 // 注册知识检索工具到MCP服务器
knowledge.RegisterKnowledgeTool(mcpServer, knowledgeRetriever, knowledgeManager, logger) knowledge.RegisterKnowledgeTool(mcpServer, knowledgeRetriever, knowledgeManager, logger)
+228 -33
View File
@@ -3,6 +3,8 @@ package config
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -13,19 +15,54 @@ import (
) )
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Version string `yaml:"version,omitempty" json:"version,omitempty"` // 前端显示的版本号,如 v1.3.3
Log LogConfig `yaml:"log"` Server ServerConfig `yaml:"server"`
MCP MCPConfig `yaml:"mcp"` Log LogConfig `yaml:"log"`
OpenAI OpenAIConfig `yaml:"openai"` MCP MCPConfig `yaml:"mcp"`
Agent AgentConfig `yaml:"agent"` OpenAI OpenAIConfig `yaml:"openai"`
Security SecurityConfig `yaml:"security"` FOFA FofaConfig `yaml:"fofa,omitempty" json:"fofa,omitempty"`
Database DatabaseConfig `yaml:"database"` Agent AgentConfig `yaml:"agent"`
Auth AuthConfig `yaml:"auth"` Security SecurityConfig `yaml:"security"`
ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"` Database DatabaseConfig `yaml:"database"`
Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"` Auth AuthConfig `yaml:"auth"`
RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式) ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"`
Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色 Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"`
SkillsDir string `yaml:"skills_dir,omitempty" json:"skills_dir,omitempty"` // Skills配置文件目录 Robots RobotsConfig `yaml:"robots,omitempty" json:"robots,omitempty"` // 企业微信/钉钉/飞书等机器人配置
RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式)
Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色
SkillsDir string `yaml:"skills_dir,omitempty" json:"skills_dir,omitempty"` // Skills配置文件目录
}
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
type RobotsConfig struct {
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
}
// RobotWecomConfig 企业微信机器人配置
type RobotWecomConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Token string `yaml:"token" json:"token"` // 回调 URL 校验 Token
EncodingAESKey string `yaml:"encoding_aes_key" json:"encoding_aes_key"` // EncodingAESKey
CorpID string `yaml:"corp_id" json:"corp_id"` // 企业 ID
Secret string `yaml:"secret" json:"secret"` // 应用 Secret
AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId
}
// RobotDingtalkConfig 钉钉机器人配置
type RobotDingtalkConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey)
ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret
}
// RobotLarkConfig 飞书机器人配置
type RobotLarkConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
} }
type ServerConfig struct { type ServerConfig struct {
@@ -39,9 +76,11 @@ type LogConfig struct {
} }
type MCPConfig struct { type MCPConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Host string `yaml:"host"` Host string `yaml:"host"`
Port int `yaml:"port"` Port int `yaml:"port"`
AuthHeader string `yaml:"auth_header,omitempty"` // 鉴权 header 名,留空表示不鉴权
AuthHeaderValue string `yaml:"auth_header_value,omitempty"` // 鉴权 header 值,需与请求中该 header 一致
} }
type OpenAIConfig struct { type OpenAIConfig struct {
@@ -51,9 +90,17 @@ type OpenAIConfig struct {
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"` MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
} }
type FofaConfig struct {
// Email 为 FOFA 账号邮箱;APIKey 为 FOFA API Key(建议使用只读权限的 Key)
Email string `yaml:"email,omitempty" json:"email,omitempty"`
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` // 默认 https://fofa.info/api/v1/search/all
}
type SecurityConfig struct { type SecurityConfig struct {
Tools []ToolConfig `yaml:"tools,omitempty"` // 向后兼容:支持在主配置文件中定义工具 Tools []ToolConfig `yaml:"tools,omitempty"` // 向后兼容:支持在主配置文件中定义工具
ToolsDir string `yaml:"tools_dir,omitempty"` // 工具配置文件目录(新方式) ToolsDir string `yaml:"tools_dir,omitempty"` // 工具配置文件目录(新方式)
ToolDescriptionMode string `yaml:"tool_description_mode,omitempty"` // 工具描述模式: "short" | "full",默认 short
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -83,13 +130,14 @@ type ExternalMCPConfig struct {
// ExternalMCPServerConfig 外部MCP服务器配置 // ExternalMCPServerConfig 外部MCP服务器配置
type ExternalMCPServerConfig struct { type ExternalMCPServerConfig struct {
// stdio模式配置 // stdio模式配置
Command string `yaml:"command,omitempty" json:"command,omitempty"` Command string `yaml:"command,omitempty" json:"command,omitempty"`
Args []string `yaml:"args,omitempty" json:"args,omitempty"` Args []string `yaml:"args,omitempty" json:"args,omitempty"`
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` // 环境变量(用于stdio模式) Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` // 环境变量(用于stdio模式)
// HTTP模式配置 // HTTP模式配置
Transport string `yaml:"transport,omitempty" json:"transport,omitempty"` // "http" 或 "stdio" Transport string `yaml:"transport,omitempty" json:"transport,omitempty"` // "stdio" | "sse" | "http"(Streamable) | "simple_http"(自建/简单POST端点,如本机 http://127.0.0.1:8081/mcp)
URL string `yaml:"url,omitempty" json:"url,omitempty"` URL string `yaml:"url,omitempty" json:"url,omitempty"`
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` // HTTP/SSE 请求头(如 x-api-key
// 通用配置 // 通用配置
Description string `yaml:"description,omitempty" json:"description,omitempty"` Description string `yaml:"description,omitempty" json:"description,omitempty"`
@@ -108,8 +156,8 @@ type ToolConfig struct {
ShortDescription string `yaml:"short_description,omitempty"` // 简短描述(用于工具列表,减少token消耗) ShortDescription string `yaml:"short_description,omitempty"` // 简短描述(用于工具列表,减少token消耗)
Description string `yaml:"description"` // 详细描述(用于工具文档) Description string `yaml:"description"` // 详细描述(用于工具文档)
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
Parameters []ParameterConfig `yaml:"parameters,omitempty"` // 参数定义(可选) Parameters []ParameterConfig `yaml:"parameters,omitempty"` // 参数定义(可选)
ArgMapping string `yaml:"arg_mapping,omitempty"` // 参数映射方式: "auto", "manual", "template"(可选) ArgMapping string `yaml:"arg_mapping,omitempty"` // 参数映射方式: "auto", "manual", "template"(可选)
AllowedExitCodes []int `yaml:"allowed_exit_codes,omitempty"` // 允许的退出码列表(某些工具在成功时也返回非零退出码) AllowedExitCodes []int `yaml:"allowed_exit_codes,omitempty"` // 允许的退出码列表(某些工具在成功时也返回非零退出码)
} }
@@ -340,6 +388,124 @@ func PrintGeneratedPasswordWarning(password string, persisted bool, persistErr s
fmt.Println("----------------------------------------------------------------") fmt.Println("----------------------------------------------------------------")
} }
// generateRandomToken 生成用于 MCP 鉴权的随机字符串(64 位十六进制)
func generateRandomToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// persistMCPAuth 将 MCP 的 auth_header / auth_header_value 写回配置文件
func persistMCPAuth(path string, mcp *MCPConfig) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
inMcpBlock := false
mcpIndent := -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if !inMcpBlock {
if strings.HasPrefix(trimmed, "mcp:") {
inMcpBlock = true
mcpIndent = len(line) - len(strings.TrimLeft(line, " "))
}
continue
}
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
leadingSpaces := len(line) - len(strings.TrimLeft(line, " "))
if leadingSpaces <= mcpIndent {
inMcpBlock = false
mcpIndent = -1
if strings.HasPrefix(trimmed, "mcp:") {
inMcpBlock = true
mcpIndent = leadingSpaces
}
continue
}
prefix := line[:leadingSpaces]
rest := strings.TrimSpace(line[leadingSpaces:])
comment := ""
if idx := strings.Index(line, "#"); idx >= 0 {
comment = strings.TrimRight(line[idx:], " ")
}
withComment := ""
if comment != "" {
if !strings.HasPrefix(comment, " ") {
withComment = " "
}
withComment += comment
}
if strings.HasPrefix(rest, "auth_header_value:") {
lines[i] = fmt.Sprintf("%sauth_header_value: %q%s", prefix, mcp.AuthHeaderValue, withComment)
} else if strings.HasPrefix(rest, "auth_header:") {
lines[i] = fmt.Sprintf("%sauth_header: %q%s", prefix, mcp.AuthHeader, withComment)
}
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644)
}
// EnsureMCPAuth 在 MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
func EnsureMCPAuth(path string, cfg *Config) error {
if !cfg.MCP.Enabled || strings.TrimSpace(cfg.MCP.AuthHeaderValue) != "" {
return nil
}
token, err := generateRandomToken()
if err != nil {
return fmt.Errorf("生成 MCP 鉴权密钥失败: %w", err)
}
cfg.MCP.AuthHeaderValue = token
if strings.TrimSpace(cfg.MCP.AuthHeader) == "" {
cfg.MCP.AuthHeader = "X-MCP-Token"
}
return persistMCPAuth(path, &cfg.MCP)
}
// PrintMCPConfigJSON 向终端输出 MCP 配置的 JSON,可直接复制到 Cursor / Claude Code 的 mcp 配置中使用
func PrintMCPConfigJSON(mcp MCPConfig) {
if !mcp.Enabled {
return
}
hostForURL := strings.TrimSpace(mcp.Host)
if hostForURL == "" || hostForURL == "0.0.0.0" {
hostForURL = "localhost"
}
url := fmt.Sprintf("http://%s:%d/mcp", hostForURL, mcp.Port)
headers := map[string]string{}
if mcp.AuthHeader != "" {
headers[mcp.AuthHeader] = mcp.AuthHeaderValue
}
serverEntry := map[string]interface{}{
"url": url,
}
if len(headers) > 0 {
serverEntry["headers"] = headers
}
// Claude Code 需要 type: "http"
serverEntry["type"] = "http"
out := map[string]interface{}{
"mcpServers": map[string]interface{}{
"cyberstrike-ai": serverEntry,
},
}
b, _ := json.MarshalIndent(out, "", " ")
fmt.Println("[CyberStrikeAI] MCP 配置(可复制到 Cursor / Claude Code 使用):")
fmt.Println(" Cursor: 放入 ~/.cursor/mcp.json 的 mcpServers,或项目 .cursor/mcp.json")
fmt.Println(" Claude Code: 放入 .mcp.json 或 ~/.claude.json 的 mcpServers")
fmt.Println("----------------------------------------------------------------")
fmt.Println(string(b))
fmt.Println("----------------------------------------------------------------")
}
// LoadToolsFromDir 从目录加载所有工具配置文件 // LoadToolsFromDir 从目录加载所有工具配置文件
func LoadToolsFromDir(dir string) ([]ToolConfig, error) { func LoadToolsFromDir(dir string) ([]ToolConfig, error) {
var tools []ToolConfig var tools []ToolConfig
@@ -467,7 +633,7 @@ func LoadRoleFromFile(path string) (*RoleConfig, error) {
icon := role.Icon icon := role.Icon
// 去除可能的引号 // 去除可能的引号
icon = strings.Trim(icon, `"`) icon = strings.Trim(icon, `"`)
// 检查是否是 Unicode 转义格式 \U0001F3C68位十六进制)或 \uXXXX(4位十六进制) // 检查是否是 Unicode 转义格式 \U0001F3C68位十六进制)或 \uXXXX(4位十六进制)
if len(icon) >= 3 && icon[0] == '\\' { if len(icon) >= 3 && icon[0] == '\\' {
if icon[1] == 'U' && len(icon) >= 10 { if icon[1] == 'U' && len(icon) >= 10 {
@@ -538,9 +704,18 @@ func Default() *Config {
}, },
Retrieval: RetrievalConfig{ Retrieval: RetrievalConfig{
TopK: 5, TopK: 5,
SimilarityThreshold: 0.7, SimilarityThreshold: 0.65, // 降低阈值到 0.65,减少漏检
HybridWeight: 0.7, HybridWeight: 0.7,
}, },
Indexing: IndexingConfig{
ChunkSize: 768, // 增加到 768,更好的上下文保持
ChunkOverlap: 50,
MaxChunksPerItem: 20, // 限制单个知识项最多 20 个块,避免消耗过多配额
MaxRPM: 100, // 默认 100 RPM,避免 429 错误
RateLimitDelayMs: 600, // 600ms 间隔,对应 100 RPM
MaxRetries: 3,
RetryDelayMs: 1000,
},
}, },
} }
} }
@@ -551,6 +726,26 @@ type KnowledgeConfig struct {
BasePath string `yaml:"base_path" json:"base_path"` // 知识库路径 BasePath string `yaml:"base_path" json:"base_path"` // 知识库路径
Embedding EmbeddingConfig `yaml:"embedding" json:"embedding"` Embedding EmbeddingConfig `yaml:"embedding" json:"embedding"`
Retrieval RetrievalConfig `yaml:"retrieval" json:"retrieval"` Retrieval RetrievalConfig `yaml:"retrieval" json:"retrieval"`
Indexing IndexingConfig `yaml:"indexing,omitempty" json:"indexing,omitempty"` // 索引构建配置
}
// IndexingConfig 索引构建配置(用于控制知识库索引构建时的行为)
type IndexingConfig struct {
// 分块配置
ChunkSize int `yaml:"chunk_size,omitempty" json:"chunk_size,omitempty"` // 每个块的最大 token 数(估算),默认 512
ChunkOverlap int `yaml:"chunk_overlap,omitempty" json:"chunk_overlap,omitempty"` // 块之间的重叠 token 数,默认 50
MaxChunksPerItem int `yaml:"max_chunks_per_item,omitempty" json:"max_chunks_per_item,omitempty"` // 单个知识项的最大块数量,0 表示不限制
// 速率限制配置(用于避免 API 速率限制)
RateLimitDelayMs int `yaml:"rate_limit_delay_ms,omitempty" json:"rate_limit_delay_ms,omitempty"` // 请求间隔时间(毫秒),0 表示不使用固定延迟
MaxRPM int `yaml:"max_rpm,omitempty" json:"max_rpm,omitempty"` // 每分钟最大请求数,0 表示不限制
// 重试配置(用于处理临时错误)
MaxRetries int `yaml:"max_retries,omitempty" json:"max_retries,omitempty"` // 最大重试次数,默认 3
RetryDelayMs int `yaml:"retry_delay_ms,omitempty" json:"retry_delay_ms,omitempty"` // 重试间隔(毫秒),默认 1000
// 批处理配置(用于批量嵌入,当前未使用,保留扩展)
BatchSize int `yaml:"batch_size,omitempty" json:"batch_size,omitempty"` // 批量处理大小,0 表示逐个处理
} }
// EmbeddingConfig 嵌入配置 // EmbeddingConfig 嵌入配置
@@ -576,12 +771,12 @@ type RolesConfig struct {
// RoleConfig 单个角色配置 // RoleConfig 单个角色配置
type RoleConfig struct { type RoleConfig struct {
Name string `yaml:"name" json:"name"` // 角色名称 Name string `yaml:"name" json:"name"` // 角色名称
Description string `yaml:"description" json:"description"` // 角色描述 Description string `yaml:"description" json:"description"` // 角色描述
UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前) UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前)
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选) Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName" Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName"
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代) MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
Skills []string `yaml:"skills,omitempty" json:"skills,omitempty"` // 关联的skills列表(skill名称列表,在执行任务前会读取这些skills的内容) Skills []string `yaml:"skills,omitempty" json:"skills,omitempty"` // 关联的skills列表(skill名称列表,在执行任务前会读取这些skills的内容)
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用 Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用
} }
+259 -8
View File
@@ -2,10 +2,14 @@ package handler
import ( import (
"context" "context"
"crypto/rand"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -108,11 +112,132 @@ func (h *AgentHandler) SetSkillsManager(manager *skills.Manager) {
h.skillsManager = manager h.skillsManager = manager
} }
// ChatAttachment 聊天附件(用户上传的文件)
type ChatAttachment struct {
FileName string `json:"fileName"` // 文件名
Content string `json:"content"` // 文本内容或 base64(由 MimeType 决定是否解码)
MimeType string `json:"mimeType,omitempty"`
}
// ChatRequest 聊天请求 // ChatRequest 聊天请求
type ChatRequest struct { type ChatRequest struct {
Message string `json:"message" binding:"required"` Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"` ConversationID string `json:"conversationId,omitempty"`
Role string `json:"role,omitempty"` // 角色名称 Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
}
const (
maxAttachments = 10
chatUploadsDirName = "chat_uploads" // 对话附件保存的根目录(相对当前工作目录)
)
// saveAttachmentsToDateAndConversationDir 将附件保存到 chat_uploads/YYYY-MM-DD/{conversationID}/,返回每个文件的保存路径(与 attachments 顺序一致)
// conversationID 为空时使用 "_new" 作为目录名(新对话尚未有 ID)
func saveAttachmentsToDateAndConversationDir(attachments []ChatAttachment, conversationID string, logger *zap.Logger) (savedPaths []string, err error) {
if len(attachments) == 0 {
return nil, nil
}
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("获取当前工作目录失败: %w", err)
}
dateDir := filepath.Join(cwd, chatUploadsDirName, time.Now().Format("2006-01-02"))
convDirName := strings.TrimSpace(conversationID)
if convDirName == "" {
convDirName = "_new"
} else {
convDirName = strings.ReplaceAll(convDirName, string(filepath.Separator), "_")
}
targetDir := filepath.Join(dateDir, convDirName)
if err = os.MkdirAll(targetDir, 0755); err != nil {
return nil, fmt.Errorf("创建上传目录失败: %w", err)
}
savedPaths = make([]string, 0, len(attachments))
for i, a := range attachments {
raw, decErr := attachmentContentToBytes(a)
if decErr != nil {
return nil, fmt.Errorf("附件 %s 解码失败: %w", a.FileName, decErr)
}
baseName := filepath.Base(a.FileName)
if baseName == "" || baseName == "." {
baseName = "file"
}
baseName = strings.ReplaceAll(baseName, string(filepath.Separator), "_")
ext := filepath.Ext(baseName)
nameNoExt := strings.TrimSuffix(baseName, ext)
suffix := fmt.Sprintf("_%s_%s", time.Now().Format("150405"), shortRand(6))
var unique string
if ext != "" {
unique = nameNoExt + suffix + ext
} else {
unique = baseName + suffix
}
fullPath := filepath.Join(targetDir, unique)
if err = os.WriteFile(fullPath, raw, 0644); err != nil {
return nil, fmt.Errorf("写入文件 %s 失败: %w", a.FileName, err)
}
absPath, _ := filepath.Abs(fullPath)
savedPaths = append(savedPaths, absPath)
if logger != nil {
logger.Debug("对话附件已保存", zap.Int("index", i+1), zap.String("fileName", a.FileName), zap.String("path", absPath))
}
}
return savedPaths, nil
}
func shortRand(n int) string {
const letters = "0123456789abcdef"
b := make([]byte, n)
_, _ = rand.Read(b)
for i := range b {
b[i] = letters[int(b[i])%len(letters)]
}
return string(b)
}
func attachmentContentToBytes(a ChatAttachment) ([]byte, error) {
content := a.Content
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil && len(decoded) > 0 {
return decoded, nil
}
return []byte(content), nil
}
// userMessageContentForStorage 返回要存入数据库的用户消息内容:有附件时在正文后追加附件名(及路径),刷新后仍能显示,继续对话时大模型也能从历史中拿到路径
func userMessageContentForStorage(message string, attachments []ChatAttachment, savedPaths []string) string {
if len(attachments) == 0 {
return message
}
var b strings.Builder
b.WriteString(message)
for i, a := range attachments {
b.WriteString("\n📎 ")
b.WriteString(a.FileName)
if i < len(savedPaths) && savedPaths[i] != "" {
b.WriteString(": ")
b.WriteString(savedPaths[i])
}
}
return b.String()
}
// appendAttachmentsToMessage 仅将附件的保存路径追加到用户消息末尾,不再内联附件内容,避免上下文过长
func appendAttachmentsToMessage(msg string, attachments []ChatAttachment, savedPaths []string) string {
if len(attachments) == 0 {
return msg
}
var b strings.Builder
b.WriteString(msg)
b.WriteString("\n\n[用户上传的文件已保存到以下路径(请按需读取文件内容,而不是依赖内联内容)]\n")
for i, a := range attachments {
if i < len(savedPaths) && savedPaths[i] != "" {
b.WriteString(fmt.Sprintf("- %s: %s\n", a.FileName, savedPaths[i]))
} else {
b.WriteString(fmt.Sprintf("- %s: (路径未知,可能保存失败)\n", a.FileName))
}
}
return b.String()
} }
// ChatResponse 聊天响应 // ChatResponse 聊天响应
@@ -181,6 +306,12 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages))) h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
} }
// 校验附件数量(非流式)
if len(req.Attachments) > maxAttachments {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("附件最多 %d 个", maxAttachments)})
return
}
// 应用角色用户提示词和工具配置 // 应用角色用户提示词和工具配置
finalMessage := req.Message finalMessage := req.Message
var roleTools []string // 角色配置的工具列表 var roleTools []string // 角色配置的工具列表
@@ -206,9 +337,20 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
} }
} }
} }
var savedPaths []string
if len(req.Attachments) > 0 {
savedPaths, err = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
if err != nil {
h.logger.Error("保存对话附件失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存上传文件失败: " + err.Error()})
return
}
}
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths)
// 保存用户消息(保存原始消息,不包含角色提示词) // 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil) userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
if err != nil { if err != nil {
h.logger.Error("保存用户消息失败", zap.Error(err)) h.logger.Error("保存用户消息失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存用户消息失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存用户消息失败: " + err.Error()})
@@ -259,6 +401,96 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
}) })
} }
// ProcessMessageForRobot 供机器人(企业微信/钉钉/飞书)调用:与 /api/agent-loop/stream 相同执行路径(含 progressCallback、过程详情),仅不发送 SSE,最后返回完整回复
func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationID, message, role string) (response string, convID string, err error) {
if conversationID == "" {
title := safeTruncateString(message, 50)
conv, createErr := h.db.CreateConversation(title)
if createErr != nil {
return "", "", fmt.Errorf("创建对话失败: %w", createErr)
}
conversationID = conv.ID
} else {
if _, getErr := h.db.GetConversation(conversationID); getErr != nil {
return "", "", fmt.Errorf("对话不存在")
}
}
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
if err != nil {
historyMessages, getErr := h.db.GetMessages(conversationID)
if getErr != nil {
agentHistoryMessages = []agent.ChatMessage{}
} else {
agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages))
for _, msg := range historyMessages {
agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{Role: msg.Role, Content: msg.Content})
}
}
}
finalMessage := message
var roleTools, roleSkills []string
if role != "" && role != "默认" && h.config.Roles != nil {
if r, exists := h.config.Roles[role]; exists && r.Enabled {
if r.UserPrompt != "" {
finalMessage = r.UserPrompt + "\n\n" + message
}
roleTools = r.Tools
roleSkills = r.Skills
}
}
if _, err = h.db.AddMessage(conversationID, "user", message, nil); err != nil {
return "", "", fmt.Errorf("保存用户消息失败: %w", err)
}
// 与 agent-loop/stream 一致:先创建助手消息占位,用 progressCallback 写过程详情(不发送 SSE)
assistantMsg, err := h.db.AddMessage(conversationID, "assistant", "处理中...", nil)
if err != nil {
h.logger.Warn("机器人:创建助手消息占位失败", zap.Error(err))
}
var assistantMessageID string
if assistantMsg != nil {
assistantMessageID = assistantMsg.ID
}
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, nil)
result, err := h.agent.AgentLoopWithProgress(ctx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, roleSkills)
if err != nil {
errMsg := "执行失败: " + err.Error()
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
}
return "", conversationID, err
}
// 更新助手消息内容与 MCP 执行 ID(与 stream 一致)
if assistantMessageID != "" {
mcpIDsJSON := ""
if len(result.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
_, err = h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
result.Response, mcpIDsJSON, assistantMessageID,
)
if err != nil {
h.logger.Warn("机器人:更新助手消息失败", zap.Error(err))
}
} else {
if _, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs); err != nil {
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
}
}
if result.LastReActInput != "" || result.LastReActOutput != "" {
_ = h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput)
}
return result.Response, conversationID, nil
}
// StreamEvent 流式事件 // StreamEvent 流式事件
type StreamEvent struct { type StreamEvent struct {
Type string `json:"type"` // conversation, progress, tool_call, tool_result, response, error, cancelled, done Type string `json:"type"` // conversation, progress, tool_call, tool_result, response, error, cancelled, done
@@ -528,6 +760,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages))) h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
} }
// 校验附件数量
if len(req.Attachments) > maxAttachments {
sendEvent("error", fmt.Sprintf("附件最多 %d 个", maxAttachments), nil)
return
}
// 应用角色用户提示词和工具配置 // 应用角色用户提示词和工具配置
finalMessage := req.Message finalMessage := req.Message
var roleTools []string // 角色配置的工具列表 var roleTools []string // 角色配置的工具列表
@@ -555,10 +793,22 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
} }
} }
} }
var savedPaths []string
if len(req.Attachments) > 0 {
savedPaths, err = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
if err != nil {
h.logger.Error("保存对话附件失败", zap.Error(err))
sendEvent("error", "保存上传文件失败: "+err.Error(), nil)
return
}
}
// 仅将附件保存路径追加到 finalMessage,避免将文件内容内联到大模型上下文中
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths)
// 如果roleTools为空,表示使用所有工具(默认角色或未配置工具的角色) // 如果roleTools为空,表示使用所有工具(默认角色或未配置工具的角色)
// 保存用户消息(保存原始消息,不包含角色提示词) // 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil) userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
if err != nil { if err != nil {
h.logger.Error("保存用户消息失败", zap.Error(err)) h.logger.Error("保存用户消息失败", zap.Error(err))
} }
@@ -1194,7 +1444,8 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
// 执行任务(使用包含角色提示词的finalMessage和角色工具列表) // 执行任务(使用包含角色提示词的finalMessage和角色工具列表)
h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("role", queue.Role), zap.String("conversationId", conversationID)) h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("role", queue.Role), zap.String("conversationId", conversationID))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) // 单个子任务超时时间:从30分钟调整为6小时,适配长时间渗透/扫描任务
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour)
// 存储取消函数,以便在取消队列时能够取消当前任务 // 存储取消函数,以便在取消队列时能够取消当前任务
h.batchTaskManager.SetTaskCancel(queueID, cancel) h.batchTaskManager.SetTaskCancel(queueID, cancel)
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具) // 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
+240 -167
View File
@@ -44,6 +44,11 @@ type AppUpdater interface {
UpdateKnowledgeComponents(handler *KnowledgeHandler, manager interface{}, retriever interface{}, indexer interface{}) UpdateKnowledgeComponents(handler *KnowledgeHandler, manager interface{}, retriever interface{}, indexer interface{})
} }
// RobotRestarter 机器人连接重启器(用于配置应用后重启钉钉/飞书长连接)
type RobotRestarter interface {
RestartRobotConnections()
}
// ConfigHandler 配置处理器 // ConfigHandler 配置处理器
type ConfigHandler struct { type ConfigHandler struct {
configPath string configPath string
@@ -59,6 +64,7 @@ type ConfigHandler struct {
retrieverUpdater RetrieverUpdater // 检索器更新器(可选) retrieverUpdater RetrieverUpdater // 检索器更新器(可选)
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选) knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
appUpdater AppUpdater // App更新器(可选) appUpdater AppUpdater // App更新器(可选)
robotRestarter RobotRestarter // 机器人连接重启器(可选),ApplyConfig 时重启钉钉/飞书
logger *zap.Logger logger *zap.Logger
mu sync.RWMutex mu sync.RWMutex
lastEmbeddingConfig *config.EmbeddingConfig // 上一次的嵌入模型配置(用于检测变更) lastEmbeddingConfig *config.EmbeddingConfig // 上一次的嵌入模型配置(用于检测变更)
@@ -142,13 +148,22 @@ func (h *ConfigHandler) SetAppUpdater(updater AppUpdater) {
h.appUpdater = updater h.appUpdater = updater
} }
// SetRobotRestarter 设置机器人连接重启器(ApplyConfig 时用于重启钉钉/飞书长连接)
func (h *ConfigHandler) SetRobotRestarter(restarter RobotRestarter) {
h.mu.Lock()
defer h.mu.Unlock()
h.robotRestarter = restarter
}
// GetConfigResponse 获取配置响应 // GetConfigResponse 获取配置响应
type GetConfigResponse struct { type GetConfigResponse struct {
OpenAI config.OpenAIConfig `json:"openai"` OpenAI config.OpenAIConfig `json:"openai"`
FOFA config.FofaConfig `json:"fofa"`
MCP config.MCPConfig `json:"mcp"` MCP config.MCPConfig `json:"mcp"`
Tools []ToolConfigInfo `json:"tools"` Tools []ToolConfigInfo `json:"tools"`
Agent config.AgentConfig `json:"agent"` Agent config.AgentConfig `json:"agent"`
Knowledge config.KnowledgeConfig `json:"knowledge"` Knowledge config.KnowledgeConfig `json:"knowledge"`
Robots config.RobotsConfig `json:"robots,omitempty"`
} }
// ToolConfigInfo 工具配置信息 // ToolConfigInfo 工具配置信息
@@ -174,18 +189,10 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
configToolMap[tool.Name] = true configToolMap[tool.Name] = true
tools = append(tools, ToolConfigInfo{ tools = append(tools, ToolConfigInfo{
Name: tool.Name, Name: tool.Name,
Description: tool.ShortDescription, Description: h.pickToolDescription(tool.ShortDescription, tool.Description),
Enabled: tool.Enabled, Enabled: tool.Enabled,
IsExternal: false, IsExternal: false,
}) })
// 如果没有简短描述,使用详细描述的前100个字符
if tools[len(tools)-1].Description == "" {
desc := tool.Description
if len(desc) > 100 {
desc = desc[:100] + "..."
}
tools[len(tools)-1].Description = desc
}
} }
// 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具) // 从MCP服务器获取所有已注册的工具(包括直接注册的工具,如知识检索工具)
@@ -201,8 +208,8 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
if description == "" { if description == "" {
description = mcpTool.Description description = mcpTool.Description
} }
if len(description) > 100 { if len(description) > 10000 {
description = description[:100] + "..." description = description[:10000] + "..."
} }
tools = append(tools, ToolConfigInfo{ tools = append(tools, ToolConfigInfo{
Name: mcpTool.Name, Name: mcpTool.Name,
@@ -215,70 +222,21 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
// 获取外部MCP工具 // 获取外部MCP工具
if h.externalMCPMgr != nil { if h.externalMCPMgr != nil {
// 增加超时时间到30秒,因为通过代理连接远程服务器可能需要更长时间 ctx := context.Background()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) externalTools := h.getExternalMCPTools(ctx)
defer cancel() for _, toolInfo := range externalTools {
tools = append(tools, toolInfo)
externalTools, err := h.externalMCPMgr.GetAllTools(ctx)
if err == nil {
externalMCPConfigs := h.externalMCPMgr.GetConfigs()
for _, externalTool := range externalTools {
var mcpName, actualToolName string
if idx := strings.Index(externalTool.Name, "::"); idx > 0 {
mcpName = externalTool.Name[:idx]
actualToolName = externalTool.Name[idx+2:]
} else {
continue
}
enabled := false
if cfg, exists := externalMCPConfigs[mcpName]; exists {
// 首先检查外部MCP是否启用
if !cfg.ExternalMCPEnable && !(cfg.Enabled && !cfg.Disabled) {
enabled = false // MCP未启用,所有工具都禁用
} else {
// MCP已启用,检查单个工具的启用状态
// 如果ToolEnabled为空或未设置该工具,默认为启用(向后兼容)
if cfg.ToolEnabled == nil {
enabled = true // 未设置工具状态,默认为启用
} else if toolEnabled, exists := cfg.ToolEnabled[actualToolName]; exists {
enabled = toolEnabled // 使用配置的工具状态
} else {
enabled = true // 工具未在配置中,默认为启用
}
}
}
client, exists := h.externalMCPMgr.GetClient(mcpName)
if !exists || !client.IsConnected() {
enabled = false
}
description := externalTool.ShortDescription
if description == "" {
description = externalTool.Description
}
if len(description) > 100 {
description = description[:100] + "..."
}
tools = append(tools, ToolConfigInfo{
Name: actualToolName,
Description: description,
Enabled: enabled,
IsExternal: true,
ExternalMCP: mcpName,
})
}
} }
} }
c.JSON(http.StatusOK, GetConfigResponse{ c.JSON(http.StatusOK, GetConfigResponse{
OpenAI: h.config.OpenAI, OpenAI: h.config.OpenAI,
FOFA: h.config.FOFA,
MCP: h.config.MCP, MCP: h.config.MCP,
Tools: tools, Tools: tools,
Agent: h.config.Agent, Agent: h.config.Agent,
Knowledge: h.config.Knowledge, Knowledge: h.config.Knowledge,
Robots: h.config.Robots,
}) })
} }
@@ -342,18 +300,10 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
configToolMap[tool.Name] = true configToolMap[tool.Name] = true
toolInfo := ToolConfigInfo{ toolInfo := ToolConfigInfo{
Name: tool.Name, Name: tool.Name,
Description: tool.ShortDescription, Description: h.pickToolDescription(tool.ShortDescription, tool.Description),
Enabled: tool.Enabled, Enabled: tool.Enabled,
IsExternal: false, IsExternal: false,
} }
// 如果没有简短描述,使用详细描述的前100个字符
if toolInfo.Description == "" {
desc := tool.Description
if len(desc) > 100 {
desc = desc[:100] + "..."
}
toolInfo.Description = desc
}
// 根据角色配置标注工具状态 // 根据角色配置标注工具状态
if roleName != "" { if roleName != "" {
@@ -405,8 +355,8 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
if description == "" { if description == "" {
description = mcpTool.Description description = mcpTool.Description
} }
if len(description) > 100 { if len(description) > 10000 {
description = description[:100] + "..." description = description[:10000] + "..."
} }
toolInfo := ToolConfigInfo{ toolInfo := ToolConfigInfo{
@@ -451,99 +401,43 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
// 获取外部MCP工具 // 获取外部MCP工具
if h.externalMCPMgr != nil { if h.externalMCPMgr != nil {
// 增加超时时间到30秒,因为通过代理连接远程服务器可能需要更长时间 // 创建context用于获取外部工具
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx := context.Background()
defer cancel() externalTools := h.getExternalMCPTools(ctx)
externalTools, err := h.externalMCPMgr.GetAllTools(ctx) // 应用搜索过滤和角色配置
if err != nil { for _, toolInfo := range externalTools {
h.logger.Warn("获取外部MCP工具失败", zap.Error(err)) // 搜索过滤
} else { if searchTermLower != "" {
// 获取外部MCP配置,用于判断启用状态 nameLower := strings.ToLower(toolInfo.Name)
externalMCPConfigs := h.externalMCPMgr.GetConfigs() descLower := strings.ToLower(toolInfo.Description)
if !strings.Contains(nameLower, searchTermLower) && !strings.Contains(descLower, searchTermLower) {
for _, externalTool := range externalTools { continue // 不匹配,跳过
// 解析工具名称:mcpName::toolName
var mcpName, actualToolName string
if idx := strings.Index(externalTool.Name, "::"); idx > 0 {
mcpName = externalTool.Name[:idx]
actualToolName = externalTool.Name[idx+2:]
} else {
continue // 跳过格式不正确的工具
} }
// 获取外部工具的启用状态
enabled := false
if cfg, exists := externalMCPConfigs[mcpName]; exists {
// 首先检查外部MCP是否启用
if !cfg.ExternalMCPEnable && !(cfg.Enabled && !cfg.Disabled) {
enabled = false // MCP未启用,所有工具都禁用
} else {
// MCP已启用,检查单个工具的启用状态
// 如果ToolEnabled为空或未设置该工具,默认为启用(向后兼容)
if cfg.ToolEnabled == nil {
enabled = true // 未设置工具状态,默认为启用
} else if toolEnabled, exists := cfg.ToolEnabled[actualToolName]; exists {
enabled = toolEnabled // 使用配置的工具状态
} else {
enabled = true // 工具未在配置中,默认为启用
}
}
}
// 检查外部MCP是否已连接
client, exists := h.externalMCPMgr.GetClient(mcpName)
if !exists || !client.IsConnected() {
enabled = false // 未连接时视为禁用
}
description := externalTool.ShortDescription
if description == "" {
description = externalTool.Description
}
if len(description) > 100 {
description = description[:100] + "..."
}
// 如果有关键词,进行搜索过滤
if searchTermLower != "" {
nameLower := strings.ToLower(actualToolName)
descLower := strings.ToLower(description)
if !strings.Contains(nameLower, searchTermLower) && !strings.Contains(descLower, searchTermLower) {
continue // 不匹配,跳过
}
}
toolInfo := ToolConfigInfo{
Name: actualToolName, // 显示实际工具名称,不带前缀
Description: description,
Enabled: enabled,
IsExternal: true,
ExternalMCP: mcpName,
}
// 根据角色配置标注工具状态
if roleName != "" {
if roleUsesAllTools {
// 角色使用所有工具,标注启用的工具为role_enabled=true
toolInfo.RoleEnabled = &enabled
} else {
// 角色配置了工具列表,检查工具是否在列表中
// 外部工具使用 "mcpName::toolName" 格式作为key
externalToolKey := externalTool.Name // 这是 "mcpName::toolName" 格式
if roleToolsSet[externalToolKey] {
roleEnabled := enabled // 工具必须在角色列表中且本身启用
toolInfo.RoleEnabled = &roleEnabled
} else {
// 不在角色列表中,标记为false
roleEnabled := false
toolInfo.RoleEnabled = &roleEnabled
}
}
}
allTools = append(allTools, toolInfo)
} }
// 根据角色配置标注工具状态
if roleName != "" {
if roleUsesAllTools {
// 角色使用所有工具,标注启用的工具为role_enabled=true
roleEnabled := toolInfo.Enabled
toolInfo.RoleEnabled = &roleEnabled
} else {
// 角色配置了工具列表,检查工具是否在列表中
// 外部工具使用 "mcpName::toolName" 格式作为key
externalToolKey := fmt.Sprintf("%s::%s", toolInfo.ExternalMCP, toolInfo.Name)
if roleToolsSet[externalToolKey] {
roleEnabled := toolInfo.Enabled // 工具必须在角色列表中且本身启用
toolInfo.RoleEnabled = &roleEnabled
} else {
// 不在角色列表中,标记为false
roleEnabled := false
toolInfo.RoleEnabled = &roleEnabled
}
}
}
allTools = append(allTools, toolInfo)
} }
} }
@@ -595,10 +489,12 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
// UpdateConfigRequest 更新配置请求 // UpdateConfigRequest 更新配置请求
type UpdateConfigRequest struct { type UpdateConfigRequest struct {
OpenAI *config.OpenAIConfig `json:"openai,omitempty"` OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
FOFA *config.FofaConfig `json:"fofa,omitempty"`
MCP *config.MCPConfig `json:"mcp,omitempty"` MCP *config.MCPConfig `json:"mcp,omitempty"`
Tools []ToolEnableStatus `json:"tools,omitempty"` Tools []ToolEnableStatus `json:"tools,omitempty"`
Agent *config.AgentConfig `json:"agent,omitempty"` Agent *config.AgentConfig `json:"agent,omitempty"`
Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"` Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"`
Robots *config.RobotsConfig `json:"robots,omitempty"`
} }
// ToolEnableStatus 工具启用状态 // ToolEnableStatus 工具启用状态
@@ -629,6 +525,12 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
) )
} }
// 更新FOFA配置
if req.FOFA != nil {
h.config.FOFA = *req.FOFA
h.logger.Info("更新FOFA配置", zap.String("email", h.config.FOFA.Email))
}
// 更新MCP配置 // 更新MCP配置
if req.MCP != nil { if req.MCP != nil {
h.config.MCP = *req.MCP h.config.MCP = *req.MCP
@@ -669,6 +571,16 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
) )
} }
// 更新机器人配置
if req.Robots != nil {
h.config.Robots = *req.Robots
h.logger.Info("更新机器人配置",
zap.Bool("wecom_enabled", h.config.Robots.Wecom.Enabled),
zap.Bool("dingtalk_enabled", h.config.Robots.Dingtalk.Enabled),
zap.Bool("lark_enabled", h.config.Robots.Lark.Enabled),
)
}
// 更新工具启用状态 // 更新工具启用状态
if req.Tools != nil { if req.Tools != nil {
// 分离内部工具和外部工具 // 分离内部工具和外部工具
@@ -938,6 +850,12 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
} }
} }
// 重启钉钉/飞书长连接,使前端修改的机器人配置立即生效(无需重启服务)
if h.robotRestarter != nil {
h.robotRestarter.RestartRobotConnections()
h.logger.Info("已触发机器人连接重启(钉钉/飞书)")
}
h.logger.Info("配置已应用", h.logger.Info("配置已应用",
zap.Int("tools_count", len(h.config.Security.Tools)), zap.Int("tools_count", len(h.config.Security.Tools)),
) )
@@ -968,7 +886,9 @@ func (h *ConfigHandler) saveConfig() error {
updateAgentConfig(root, h.config.Agent.MaxIterations) updateAgentConfig(root, h.config.Agent.MaxIterations)
updateMCPConfig(root, h.config.MCP) updateMCPConfig(root, h.config.MCP)
updateOpenAIConfig(root, h.config.OpenAI) updateOpenAIConfig(root, h.config.OpenAI)
updateFOFAConfig(root, h.config.FOFA)
updateKnowledgeConfig(root, h.config.Knowledge) updateKnowledgeConfig(root, h.config.Knowledge)
updateRobotsConfig(root, h.config.Robots)
// 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用) // 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用)
// 读取原始配置以保持向后兼容 // 读取原始配置以保持向后兼容
originalConfigs := make(map[string]map[string]bool) originalConfigs := make(map[string]map[string]bool)
@@ -1112,6 +1032,14 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
setStringInMap(openaiNode, "model", cfg.Model) setStringInMap(openaiNode, "model", cfg.Model)
} }
func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
root := doc.Content[0]
fofaNode := ensureMap(root, "fofa")
setStringInMap(fofaNode, "base_url", cfg.BaseURL)
setStringInMap(fofaNode, "email", cfg.Email)
setStringInMap(fofaNode, "api_key", cfg.APIKey)
}
func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) { func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
root := doc.Content[0] root := doc.Content[0]
knowledgeNode := ensureMap(root, "knowledge") knowledgeNode := ensureMap(root, "knowledge")
@@ -1134,6 +1062,40 @@ func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
setIntInMap(retrievalNode, "top_k", cfg.Retrieval.TopK) setIntInMap(retrievalNode, "top_k", cfg.Retrieval.TopK)
setFloatInMap(retrievalNode, "similarity_threshold", cfg.Retrieval.SimilarityThreshold) setFloatInMap(retrievalNode, "similarity_threshold", cfg.Retrieval.SimilarityThreshold)
setFloatInMap(retrievalNode, "hybrid_weight", cfg.Retrieval.HybridWeight) setFloatInMap(retrievalNode, "hybrid_weight", cfg.Retrieval.HybridWeight)
// 更新索引配置
indexingNode := ensureMap(knowledgeNode, "indexing")
setIntInMap(indexingNode, "chunk_size", cfg.Indexing.ChunkSize)
setIntInMap(indexingNode, "chunk_overlap", cfg.Indexing.ChunkOverlap)
setIntInMap(indexingNode, "max_chunks_per_item", cfg.Indexing.MaxChunksPerItem)
setIntInMap(indexingNode, "max_rpm", cfg.Indexing.MaxRPM)
setIntInMap(indexingNode, "rate_limit_delay_ms", cfg.Indexing.RateLimitDelayMs)
setIntInMap(indexingNode, "max_retries", cfg.Indexing.MaxRetries)
setIntInMap(indexingNode, "retry_delay_ms", cfg.Indexing.RetryDelayMs)
}
func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
root := doc.Content[0]
robotsNode := ensureMap(root, "robots")
wecomNode := ensureMap(robotsNode, "wecom")
setBoolInMap(wecomNode, "enabled", cfg.Wecom.Enabled)
setStringInMap(wecomNode, "token", cfg.Wecom.Token)
setStringInMap(wecomNode, "encoding_aes_key", cfg.Wecom.EncodingAESKey)
setStringInMap(wecomNode, "corp_id", cfg.Wecom.CorpID)
setStringInMap(wecomNode, "secret", cfg.Wecom.Secret)
setIntInMap(wecomNode, "agent_id", int(cfg.Wecom.AgentID))
dingtalkNode := ensureMap(robotsNode, "dingtalk")
setBoolInMap(dingtalkNode, "enabled", cfg.Dingtalk.Enabled)
setStringInMap(dingtalkNode, "client_id", cfg.Dingtalk.ClientID)
setStringInMap(dingtalkNode, "client_secret", cfg.Dingtalk.ClientSecret)
larkNode := ensureMap(robotsNode, "lark")
setBoolInMap(larkNode, "enabled", cfg.Lark.Enabled)
setStringInMap(larkNode, "app_id", cfg.Lark.AppID)
setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret)
setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken)
} }
func ensureMap(parent *yaml.Node, path ...string) *yaml.Node { func ensureMap(parent *yaml.Node, path ...string) *yaml.Node {
@@ -1259,3 +1221,114 @@ func setFloatInMap(mapNode *yaml.Node, key string, value float64) {
valueNode.Value = fmt.Sprintf("%g", value) valueNode.Value = fmt.Sprintf("%g", value)
} }
} }
// getExternalMCPTools 获取外部MCP工具列表(公共方法)
// 返回 ToolConfigInfo 列表,已处理启用状态和描述信息
func (h *ConfigHandler) getExternalMCPTools(ctx context.Context) []ToolConfigInfo {
var result []ToolConfigInfo
if h.externalMCPMgr == nil {
return result
}
// 使用较短的超时时间(5秒)进行快速失败,避免阻塞页面加载
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
externalTools, err := h.externalMCPMgr.GetAllTools(timeoutCtx)
if err != nil {
// 记录警告但不阻塞,继续返回已缓存的工具(如果有)
h.logger.Warn("获取外部MCP工具失败(可能连接断开),尝试返回缓存的工具",
zap.Error(err),
zap.String("hint", "如果外部MCP工具未显示,请检查连接状态或点击刷新按钮"),
)
}
// 如果获取到了工具(即使有错误),继续处理
if len(externalTools) == 0 {
return result
}
externalMCPConfigs := h.externalMCPMgr.GetConfigs()
for _, externalTool := range externalTools {
// 解析工具名称:mcpName::toolName
mcpName, actualToolName := h.parseExternalToolName(externalTool.Name)
if mcpName == "" || actualToolName == "" {
continue // 跳过格式不正确的工具
}
// 计算启用状态
enabled := h.calculateExternalToolEnabled(mcpName, actualToolName, externalMCPConfigs)
// 处理描述信息
description := h.pickToolDescription(externalTool.ShortDescription, externalTool.Description)
result = append(result, ToolConfigInfo{
Name: actualToolName,
Description: description,
Enabled: enabled,
IsExternal: true,
ExternalMCP: mcpName,
})
}
return result
}
// parseExternalToolName 解析外部工具名称(格式:mcpName::toolName
func (h *ConfigHandler) parseExternalToolName(fullName string) (mcpName, toolName string) {
idx := strings.Index(fullName, "::")
if idx > 0 {
return fullName[:idx], fullName[idx+2:]
}
return "", ""
}
// calculateExternalToolEnabled 计算外部工具的启用状态
func (h *ConfigHandler) calculateExternalToolEnabled(mcpName, toolName string, configs map[string]config.ExternalMCPServerConfig) bool {
cfg, exists := configs[mcpName]
if !exists {
return false
}
// 首先检查外部MCP是否启用
if !cfg.ExternalMCPEnable && !(cfg.Enabled && !cfg.Disabled) {
return false // MCP未启用,所有工具都禁用
}
// MCP已启用,检查单个工具的启用状态
// 如果ToolEnabled为空或未设置该工具,默认为启用(向后兼容)
if cfg.ToolEnabled == nil {
// 未设置工具状态,默认为启用
} else if toolEnabled, exists := cfg.ToolEnabled[toolName]; exists {
// 使用配置的工具状态
if !toolEnabled {
return false
}
}
// 工具未在配置中,默认为启用
// 最后检查外部MCP是否已连接
client, exists := h.externalMCPMgr.GetClient(mcpName)
if !exists || !client.IsConnected() {
return false // 未连接时视为禁用
}
return true
}
// pickToolDescription 根据 security.tool_description_mode 选择 short 或 full 描述并限制长度
func (h *ConfigHandler) pickToolDescription(shortDesc, fullDesc string) string {
useFull := strings.TrimSpace(strings.ToLower(h.config.Security.ToolDescriptionMode)) == "full"
description := shortDesc
if useFull {
description = fullDesc
} else if description == "" {
description = fullDesc
}
if len(description) > 10000 {
description = description[:10000] + "..."
}
return description
}
+56 -49
View File
@@ -8,6 +8,7 @@ import (
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -36,12 +37,12 @@ func NewExternalMCPHandler(manager *mcp.ExternalMCPManager, cfg *config.Config,
func (h *ExternalMCPHandler) GetExternalMCPs(c *gin.Context) { func (h *ExternalMCPHandler) GetExternalMCPs(c *gin.Context) {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
configs := h.manager.GetConfigs() configs := h.manager.GetConfigs()
// 获取所有外部MCP的工具数量 // 获取所有外部MCP的工具数量
toolCounts := h.manager.GetToolCounts() toolCounts := h.manager.GetToolCounts()
// 转换为响应格式 // 转换为响应格式
result := make(map[string]ExternalMCPResponse) result := make(map[string]ExternalMCPResponse)
for name, cfg := range configs { for name, cfg := range configs {
@@ -54,13 +55,13 @@ func (h *ExternalMCPHandler) GetExternalMCPs(c *gin.Context) {
} else { } else {
status = "disabled" status = "disabled"
} }
toolCount := toolCounts[name] toolCount := toolCounts[name]
errorMsg := "" errorMsg := ""
if status == "error" { if status == "error" {
errorMsg = h.manager.GetError(name) errorMsg = h.manager.GetError(name)
} }
result[name] = ExternalMCPResponse{ result[name] = ExternalMCPResponse{
Config: cfg, Config: cfg,
Status: status, Status: status,
@@ -68,7 +69,7 @@ func (h *ExternalMCPHandler) GetExternalMCPs(c *gin.Context) {
Error: errorMsg, Error: errorMsg,
} }
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"servers": result, "servers": result,
"stats": h.manager.GetStats(), "stats": h.manager.GetStats(),
@@ -78,17 +79,17 @@ func (h *ExternalMCPHandler) GetExternalMCPs(c *gin.Context) {
// GetExternalMCP 获取单个外部MCP配置 // GetExternalMCP 获取单个外部MCP配置
func (h *ExternalMCPHandler) GetExternalMCP(c *gin.Context) { func (h *ExternalMCPHandler) GetExternalMCP(c *gin.Context) {
name := c.Param("name") name := c.Param("name")
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
configs := h.manager.GetConfigs() configs := h.manager.GetConfigs()
cfg, exists := configs[name] cfg, exists := configs[name]
if !exists { if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "外部MCP配置不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "外部MCP配置不存在"})
return return
} }
client, clientExists := h.manager.GetClient(name) client, clientExists := h.manager.GetClient(name)
status := "disconnected" status := "disconnected"
if clientExists { if clientExists {
@@ -98,7 +99,7 @@ func (h *ExternalMCPHandler) GetExternalMCP(c *gin.Context) {
} else { } else {
status = "disabled" status = "disabled"
} }
// 获取工具数量 // 获取工具数量
toolCount := 0 toolCount := 0
if clientExists && client.IsConnected() { if clientExists && client.IsConnected() {
@@ -106,13 +107,13 @@ func (h *ExternalMCPHandler) GetExternalMCP(c *gin.Context) {
toolCount = count toolCount = count
} }
} }
// 获取错误信息 // 获取错误信息
errorMsg := "" errorMsg := ""
if status == "error" { if status == "error" {
errorMsg = h.manager.GetError(name) errorMsg = h.manager.GetError(name)
} }
c.JSON(http.StatusOK, ExternalMCPResponse{ c.JSON(http.StatusOK, ExternalMCPResponse{
Config: cfg, Config: cfg,
Status: status, Status: status,
@@ -128,38 +129,38 @@ func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return return
} }
name := c.Param("name") name := c.Param("name")
if name == "" { if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称不能为空"}) c.JSON(http.StatusBadRequest, gin.H{"error": "名称不能为空"})
return return
} }
// 验证配置 // 验证配置
if err := h.validateConfig(req.Config); err != nil { if err := h.validateConfig(req.Config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
// 添加或更新配置 // 添加或更新配置
if err := h.manager.AddOrUpdateConfig(name, req.Config); err != nil { if err := h.manager.AddOrUpdateConfig(name, req.Config); err != nil {
h.logger.Error("添加或更新外部MCP配置失败", zap.Error(err)) h.logger.Error("添加或更新外部MCP配置失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "添加或更新配置失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "添加或更新配置失败: " + err.Error()})
return return
} }
// 更新内存中的配置 // 更新内存中的配置
if h.config.ExternalMCP.Servers == nil { if h.config.ExternalMCP.Servers == nil {
h.config.ExternalMCP.Servers = make(map[string]config.ExternalMCPServerConfig) h.config.ExternalMCP.Servers = make(map[string]config.ExternalMCPServerConfig)
} }
// 如果用户提供了 disabled 或 enabled 字段,保留它们以保持向后兼容 // 如果用户提供了 disabled 或 enabled 字段,保留它们以保持向后兼容
// 同时将值迁移到 external_mcp_enable // 同时将值迁移到 external_mcp_enable
cfg := req.Config cfg := req.Config
if req.Config.Disabled { if req.Config.Disabled {
// 用户设置了 disabled: true // 用户设置了 disabled: true
cfg.ExternalMCPEnable = false cfg.ExternalMCPEnable = false
@@ -185,16 +186,16 @@ func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
cfg.Enabled = true cfg.Enabled = true
cfg.Disabled = false cfg.Disabled = false
} }
h.config.ExternalMCP.Servers[name] = cfg h.config.ExternalMCP.Servers[name] = cfg
// 保存到配置文件 // 保存到配置文件
if err := h.saveConfig(); err != nil { if err := h.saveConfig(); err != nil {
h.logger.Error("保存配置失败", zap.Error(err)) h.logger.Error("保存配置失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()})
return return
} }
h.logger.Info("外部MCP配置已更新", zap.String("name", name)) h.logger.Info("外部MCP配置已更新", zap.String("name", name))
c.JSON(http.StatusOK, gin.H{"message": "配置已更新"}) c.JSON(http.StatusOK, gin.H{"message": "配置已更新"})
} }
@@ -202,28 +203,28 @@ func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) {
// DeleteExternalMCP 删除外部MCP配置 // DeleteExternalMCP 删除外部MCP配置
func (h *ExternalMCPHandler) DeleteExternalMCP(c *gin.Context) { func (h *ExternalMCPHandler) DeleteExternalMCP(c *gin.Context) {
name := c.Param("name") name := c.Param("name")
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
// 移除配置 // 移除配置
if err := h.manager.RemoveConfig(name); err != nil { if err := h.manager.RemoveConfig(name); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "配置不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "配置不存在"})
return return
} }
// 从内存配置中删除 // 从内存配置中删除
if h.config.ExternalMCP.Servers != nil { if h.config.ExternalMCP.Servers != nil {
delete(h.config.ExternalMCP.Servers, name) delete(h.config.ExternalMCP.Servers, name)
} }
// 保存到配置文件 // 保存到配置文件
if err := h.saveConfig(); err != nil { if err := h.saveConfig(); err != nil {
h.logger.Error("保存配置失败", zap.Error(err)) h.logger.Error("保存配置失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()})
return return
} }
h.logger.Info("外部MCP配置已删除", zap.String("name", name)) h.logger.Info("外部MCP配置已删除", zap.String("name", name))
c.JSON(http.StatusOK, gin.H{"message": "配置已删除"}) c.JSON(http.StatusOK, gin.H{"message": "配置已删除"})
} }
@@ -231,10 +232,10 @@ func (h *ExternalMCPHandler) DeleteExternalMCP(c *gin.Context) {
// StartExternalMCP 启动外部MCP // StartExternalMCP 启动外部MCP
func (h *ExternalMCPHandler) StartExternalMCP(c *gin.Context) { func (h *ExternalMCPHandler) StartExternalMCP(c *gin.Context) {
name := c.Param("name") name := c.Param("name")
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
// 更新配置为启用 // 更新配置为启用
if h.config.ExternalMCP.Servers == nil { if h.config.ExternalMCP.Servers == nil {
h.config.ExternalMCP.Servers = make(map[string]config.ExternalMCPServerConfig) h.config.ExternalMCP.Servers = make(map[string]config.ExternalMCPServerConfig)
@@ -242,32 +243,32 @@ func (h *ExternalMCPHandler) StartExternalMCP(c *gin.Context) {
cfg := h.config.ExternalMCP.Servers[name] cfg := h.config.ExternalMCP.Servers[name]
cfg.ExternalMCPEnable = true cfg.ExternalMCPEnable = true
h.config.ExternalMCP.Servers[name] = cfg h.config.ExternalMCP.Servers[name] = cfg
// 保存到配置文件 // 保存到配置文件
if err := h.saveConfig(); err != nil { if err := h.saveConfig(); err != nil {
h.logger.Error("保存配置失败", zap.Error(err)) h.logger.Error("保存配置失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()})
return return
} }
// 启动客户端(立即创建客户端并设置状态为connecting,实际连接在后台进行) // 启动客户端(立即创建客户端并设置状态为connecting,实际连接在后台进行)
h.logger.Info("开始启动外部MCP", zap.String("name", name)) h.logger.Info("开始启动外部MCP", zap.String("name", name))
if err := h.manager.StartClient(name); err != nil { if err := h.manager.StartClient(name); err != nil {
h.logger.Error("启动外部MCP失败", zap.String("name", name), zap.Error(err)) h.logger.Error("启动外部MCP失败", zap.String("name", name), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(), "error": err.Error(),
"status": "error", "status": "error",
}) })
return return
} }
// 获取客户端状态(应该是connecting) // 获取客户端状态(应该是connecting)
client, exists := h.manager.GetClient(name) client, exists := h.manager.GetClient(name)
status := "connecting" status := "connecting"
if exists { if exists {
status = client.GetStatus() status = client.GetStatus()
} }
// 立即返回,不等待连接完成 // 立即返回,不等待连接完成
// 客户端会在后台异步连接,用户可以通过状态查询接口查看连接状态 // 客户端会在后台异步连接,用户可以通过状态查询接口查看连接状态
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -279,16 +280,16 @@ func (h *ExternalMCPHandler) StartExternalMCP(c *gin.Context) {
// StopExternalMCP 停止外部MCP // StopExternalMCP 停止外部MCP
func (h *ExternalMCPHandler) StopExternalMCP(c *gin.Context) { func (h *ExternalMCPHandler) StopExternalMCP(c *gin.Context) {
name := c.Param("name") name := c.Param("name")
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
// 停止客户端 // 停止客户端
if err := h.manager.StopClient(name); err != nil { if err := h.manager.StopClient(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
// 更新配置 // 更新配置
if h.config.ExternalMCP.Servers == nil { if h.config.ExternalMCP.Servers == nil {
h.config.ExternalMCP.Servers = make(map[string]config.ExternalMCPServerConfig) h.config.ExternalMCP.Servers = make(map[string]config.ExternalMCPServerConfig)
@@ -296,14 +297,14 @@ func (h *ExternalMCPHandler) StopExternalMCP(c *gin.Context) {
cfg := h.config.ExternalMCP.Servers[name] cfg := h.config.ExternalMCP.Servers[name]
cfg.ExternalMCPEnable = false cfg.ExternalMCPEnable = false
h.config.ExternalMCP.Servers[name] = cfg h.config.ExternalMCP.Servers[name] = cfg
// 保存到配置文件 // 保存到配置文件
if err := h.saveConfig(); err != nil { if err := h.saveConfig(); err != nil {
h.logger.Error("保存配置失败", zap.Error(err)) h.logger.Error("保存配置失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()})
return return
} }
h.logger.Info("外部MCP已停止", zap.String("name", name)) h.logger.Info("外部MCP已停止", zap.String("name", name))
c.JSON(http.StatusOK, gin.H{"message": "外部MCP已停止"}) c.JSON(http.StatusOK, gin.H{"message": "外部MCP已停止"})
} }
@@ -327,7 +328,7 @@ func (h *ExternalMCPHandler) validateConfig(cfg config.ExternalMCPServerConfig)
return fmt.Errorf("需要指定commandstdio模式)或urlhttp/sse模式)") return fmt.Errorf("需要指定commandstdio模式)或urlhttp/sse模式)")
} }
} }
switch transport { switch transport {
case "http": case "http":
if cfg.URL == "" { if cfg.URL == "" {
@@ -344,7 +345,7 @@ func (h *ExternalMCPHandler) validateConfig(cfg config.ExternalMCPServerConfig)
default: default:
return fmt.Errorf("不支持的传输模式: %s,支持的模式: http, stdio, sse", transport) return fmt.Errorf("不支持的传输模式: %s,支持的模式: http, stdio, sse", transport)
} }
return nil return nil
} }
@@ -428,17 +429,17 @@ func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, origi
root := doc.Content[0] root := doc.Content[0]
externalMCPNode := ensureMap(root, "external_mcp") externalMCPNode := ensureMap(root, "external_mcp")
serversNode := ensureMap(externalMCPNode, "servers") serversNode := ensureMap(externalMCPNode, "servers")
// 清空现有服务器配置 // 清空现有服务器配置
serversNode.Content = nil serversNode.Content = nil
// 添加新的服务器配置 // 添加新的服务器配置
for name, serverCfg := range cfg.Servers { for name, serverCfg := range cfg.Servers {
// 添加服务器名称键 // 添加服务器名称键
nameNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name} nameNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name}
serverNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} serverNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
serversNode.Content = append(serversNode.Content, nameNode, serverNode) serversNode.Content = append(serversNode.Content, nameNode, serverNode)
// 设置服务器配置字段 // 设置服务器配置字段
if serverCfg.Command != "" { if serverCfg.Command != "" {
setStringInMap(serverNode, "command", serverCfg.Command) setStringInMap(serverNode, "command", serverCfg.Command)
@@ -459,6 +460,13 @@ func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, origi
if serverCfg.URL != "" { if serverCfg.URL != "" {
setStringInMap(serverNode, "url", serverCfg.URL) setStringInMap(serverNode, "url", serverCfg.URL)
} }
// 保存 headers 字段(HTTP/SSE 请求头)
if serverCfg.Headers != nil && len(serverCfg.Headers) > 0 {
headersNode := ensureMap(serverNode, "headers")
for k, v := range serverCfg.Headers {
setStringInMap(headersNode, k, v)
}
}
if serverCfg.Description != "" { if serverCfg.Description != "" {
setStringInMap(serverNode, "description", serverCfg.Description) setStringInMap(serverNode, "description", serverCfg.Description)
} }
@@ -476,7 +484,7 @@ func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, origi
} }
// 保留旧的 enabled/disabled 字段以保持向后兼容 // 保留旧的 enabled/disabled 字段以保持向后兼容
originalFields, hasOriginal := originalConfigs[name] originalFields, hasOriginal := originalConfigs[name]
// 如果原始配置中有 enabled 字段,保留它 // 如果原始配置中有 enabled 字段,保留它
if hasOriginal { if hasOriginal {
if enabledVal, hasEnabled := originalFields["enabled"]; hasEnabled { if enabledVal, hasEnabled := originalFields["enabled"]; hasEnabled {
@@ -494,7 +502,7 @@ func updateExternalMCPConfig(doc *yaml.Node, cfg config.ExternalMCPConfig, origi
} }
} }
} }
// 如果用户在当前请求中明确设置了这些字段,也保存它们 // 如果用户在当前请求中明确设置了这些字段,也保存它们
if serverCfg.Enabled { if serverCfg.Enabled {
setBoolInMap(serverNode, "enabled", serverCfg.Enabled) setBoolInMap(serverNode, "enabled", serverCfg.Enabled)
@@ -528,8 +536,7 @@ type AddOrUpdateExternalMCPRequest struct {
// ExternalMCPResponse 外部MCP响应 // ExternalMCPResponse 外部MCP响应
type ExternalMCPResponse struct { type ExternalMCPResponse struct {
Config config.ExternalMCPServerConfig `json:"config"` Config config.ExternalMCPServerConfig `json:"config"`
Status string `json:"status"` // "connected", "disconnected", "disabled", "error", "connecting" Status string `json:"status"` // "connected", "disconnected", "disabled", "error", "connecting"
ToolCount int `json:"tool_count"` // 工具数量 ToolCount int `json:"tool_count"` // 工具数量
Error string `json:"error,omitempty"` // 错误信息(仅在status为error时存在) Error string `json:"error,omitempty"` // 错误信息(仅在status为error时存在)
} }
+78 -78
View File
@@ -11,6 +11,7 @@ import (
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -18,7 +19,7 @@ import (
func setupTestRouter() (*gin.Engine, *ExternalMCPHandler, string) { func setupTestRouter() (*gin.Engine, *ExternalMCPHandler, string) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
// 创建临时配置文件 // 创建临时配置文件
tmpFile, err := os.CreateTemp("", "test-config-*.yaml") tmpFile, err := os.CreateTemp("", "test-config-*.yaml")
if err != nil { if err != nil {
@@ -27,7 +28,7 @@ func setupTestRouter() (*gin.Engine, *ExternalMCPHandler, string) {
tmpFile.WriteString("server:\n host: 0.0.0.0\n port: 8080\n") tmpFile.WriteString("server:\n host: 0.0.0.0\n port: 8080\n")
tmpFile.Close() tmpFile.Close()
configPath := tmpFile.Name() configPath := tmpFile.Name()
logger := zap.NewNop() logger := zap.NewNop()
manager := mcp.NewExternalMCPManager(logger) manager := mcp.NewExternalMCPManager(logger)
cfg := &config.Config{ cfg := &config.Config{
@@ -35,9 +36,9 @@ func setupTestRouter() (*gin.Engine, *ExternalMCPHandler, string) {
Servers: make(map[string]config.ExternalMCPServerConfig), Servers: make(map[string]config.ExternalMCPServerConfig),
}, },
} }
handler := NewExternalMCPHandler(manager, cfg, configPath, logger) handler := NewExternalMCPHandler(manager, cfg, configPath, logger)
api := router.Group("/api") api := router.Group("/api")
api.GET("/external-mcp", handler.GetExternalMCPs) api.GET("/external-mcp", handler.GetExternalMCPs)
api.GET("/external-mcp/stats", handler.GetExternalMCPStats) api.GET("/external-mcp/stats", handler.GetExternalMCPStats)
@@ -46,7 +47,7 @@ func setupTestRouter() (*gin.Engine, *ExternalMCPHandler, string) {
api.DELETE("/external-mcp/:name", handler.DeleteExternalMCP) api.DELETE("/external-mcp/:name", handler.DeleteExternalMCP)
api.POST("/external-mcp/:name/start", handler.StartExternalMCP) api.POST("/external-mcp/:name/start", handler.StartExternalMCP)
api.POST("/external-mcp/:name/stop", handler.StopExternalMCP) api.POST("/external-mcp/:name/stop", handler.StopExternalMCP)
return router, handler, configPath return router, handler, configPath
} }
@@ -58,7 +59,7 @@ func cleanupTestConfig(configPath string) {
func TestExternalMCPHandler_AddOrUpdateExternalMCP_Stdio(t *testing.T) { func TestExternalMCPHandler_AddOrUpdateExternalMCP_Stdio(t *testing.T) {
router, _, configPath := setupTestRouter() router, _, configPath := setupTestRouter()
defer cleanupTestConfig(configPath) defer cleanupTestConfig(configPath)
// 测试添加stdio模式的配置 // 测试添加stdio模式的配置
configJSON := `{ configJSON := `{
"command": "python3", "command": "python3",
@@ -67,41 +68,41 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_Stdio(t *testing.T) {
"timeout": 300, "timeout": 300,
"enabled": true "enabled": true
}` }`
var configObj config.ExternalMCPServerConfig var configObj config.ExternalMCPServerConfig
if err := json.Unmarshal([]byte(configJSON), &configObj); err != nil { if err := json.Unmarshal([]byte(configJSON), &configObj); err != nil {
t.Fatalf("解析配置JSON失败: %v", err) t.Fatalf("解析配置JSON失败: %v", err)
} }
reqBody := AddOrUpdateExternalMCPRequest{ reqBody := AddOrUpdateExternalMCPRequest{
Config: configObj, Config: configObj,
} }
body, _ := json.Marshal(reqBody) body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/external-mcp/test-stdio", bytes.NewBuffer(body)) req := httptest.NewRequest("PUT", "/api/external-mcp/test-stdio", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String()) t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String())
} }
// 验证配置已添加 // 验证配置已添加
req2 := httptest.NewRequest("GET", "/api/external-mcp/test-stdio", nil) req2 := httptest.NewRequest("GET", "/api/external-mcp/test-stdio", nil)
w2 := httptest.NewRecorder() w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2) router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK { if w2.Code != http.StatusOK {
t.Fatalf("期望状态码200,实际%d: %s", w2.Code, w2.Body.String()) t.Fatalf("期望状态码200,实际%d: %s", w2.Code, w2.Body.String())
} }
var response ExternalMCPResponse var response ExternalMCPResponse
if err := json.Unmarshal(w2.Body.Bytes(), &response); err != nil { if err := json.Unmarshal(w2.Body.Bytes(), &response); err != nil {
t.Fatalf("解析响应失败: %v", err) t.Fatalf("解析响应失败: %v", err)
} }
if response.Config.Command != "python3" { if response.Config.Command != "python3" {
t.Errorf("期望command为python3,实际%s", response.Config.Command) t.Errorf("期望command为python3,实际%s", response.Config.Command)
} }
@@ -122,48 +123,48 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_Stdio(t *testing.T) {
func TestExternalMCPHandler_AddOrUpdateExternalMCP_HTTP(t *testing.T) { func TestExternalMCPHandler_AddOrUpdateExternalMCP_HTTP(t *testing.T) {
router, _, configPath := setupTestRouter() router, _, configPath := setupTestRouter()
defer cleanupTestConfig(configPath) defer cleanupTestConfig(configPath)
// 测试添加HTTP模式的配置 // 测试添加HTTP模式的配置
configJSON := `{ configJSON := `{
"transport": "http", "transport": "http",
"url": "http://127.0.0.1:8081/mcp", "url": "http://127.0.0.1:8081/mcp",
"enabled": true "enabled": true
}` }`
var configObj config.ExternalMCPServerConfig var configObj config.ExternalMCPServerConfig
if err := json.Unmarshal([]byte(configJSON), &configObj); err != nil { if err := json.Unmarshal([]byte(configJSON), &configObj); err != nil {
t.Fatalf("解析配置JSON失败: %v", err) t.Fatalf("解析配置JSON失败: %v", err)
} }
reqBody := AddOrUpdateExternalMCPRequest{ reqBody := AddOrUpdateExternalMCPRequest{
Config: configObj, Config: configObj,
} }
body, _ := json.Marshal(reqBody) body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/external-mcp/test-http", bytes.NewBuffer(body)) req := httptest.NewRequest("PUT", "/api/external-mcp/test-http", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String()) t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String())
} }
// 验证配置已添加 // 验证配置已添加
req2 := httptest.NewRequest("GET", "/api/external-mcp/test-http", nil) req2 := httptest.NewRequest("GET", "/api/external-mcp/test-http", nil)
w2 := httptest.NewRecorder() w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2) router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK { if w2.Code != http.StatusOK {
t.Fatalf("期望状态码200,实际%d: %s", w2.Code, w2.Body.String()) t.Fatalf("期望状态码200,实际%d: %s", w2.Code, w2.Body.String())
} }
var response ExternalMCPResponse var response ExternalMCPResponse
if err := json.Unmarshal(w2.Body.Bytes(), &response); err != nil { if err := json.Unmarshal(w2.Body.Bytes(), &response); err != nil {
t.Fatalf("解析响应失败: %v", err) t.Fatalf("解析响应失败: %v", err)
} }
if response.Config.Transport != "http" { if response.Config.Transport != "http" {
t.Errorf("期望transport为http,实际%s", response.Config.Transport) t.Errorf("期望transport为http,实际%s", response.Config.Transport)
} }
@@ -178,7 +179,7 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_HTTP(t *testing.T) {
func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) { func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) {
router, _, configPath := setupTestRouter() router, _, configPath := setupTestRouter()
defer cleanupTestConfig(configPath) defer cleanupTestConfig(configPath)
testCases := []struct { testCases := []struct {
name string name string
configJSON string configJSON string
@@ -187,7 +188,7 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) {
{ {
name: "缺少command和url", name: "缺少command和url",
configJSON: `{"enabled": true}`, configJSON: `{"enabled": true}`,
expectedErr: "需要指定commandstdio模式)或urlhttp模式)", expectedErr: "需要指定commandstdio模式)或urlhttp/sse模式)",
}, },
{ {
name: "stdio模式缺少command", name: "stdio模式缺少command",
@@ -205,34 +206,34 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) {
expectedErr: "不支持的传输模式", expectedErr: "不支持的传输模式",
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
var configObj config.ExternalMCPServerConfig var configObj config.ExternalMCPServerConfig
if err := json.Unmarshal([]byte(tc.configJSON), &configObj); err != nil { if err := json.Unmarshal([]byte(tc.configJSON), &configObj); err != nil {
t.Fatalf("解析配置JSON失败: %v", err) t.Fatalf("解析配置JSON失败: %v", err)
} }
reqBody := AddOrUpdateExternalMCPRequest{ reqBody := AddOrUpdateExternalMCPRequest{
Config: configObj, Config: configObj,
} }
body, _ := json.Marshal(reqBody) body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/external-mcp/test-invalid", bytes.NewBuffer(body)) req := httptest.NewRequest("PUT", "/api/external-mcp/test-invalid", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusBadRequest {
t.Errorf("期望状态码400,实际%d: %s", w.Code, w.Body.String()) t.Errorf("期望状态码400,实际%d: %s", w.Code, w.Body.String())
} }
var response map[string]interface{} var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("解析响应失败: %v", err) t.Fatalf("解析响应失败: %v", err)
} }
errorMsg := response["error"].(string) errorMsg := response["error"].(string)
// 对于stdio模式缺少command的情况,错误信息可能略有不同 // 对于stdio模式缺少command的情况,错误信息可能略有不同
if tc.name == "stdio模式缺少command" { if tc.name == "stdio模式缺少command" {
@@ -249,28 +250,28 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidConfig(t *testing.T) {
func TestExternalMCPHandler_DeleteExternalMCP(t *testing.T) { func TestExternalMCPHandler_DeleteExternalMCP(t *testing.T) {
router, handler, configPath := setupTestRouter() router, handler, configPath := setupTestRouter()
defer cleanupTestConfig(configPath) defer cleanupTestConfig(configPath)
// 先添加一个配置 // 先添加一个配置
configObj := config.ExternalMCPServerConfig{ configObj := config.ExternalMCPServerConfig{
Command: "python3", Command: "python3",
Enabled: true, Enabled: true,
} }
handler.manager.AddOrUpdateConfig("test-delete", configObj) handler.manager.AddOrUpdateConfig("test-delete", configObj)
// 删除配置 // 删除配置
req := httptest.NewRequest("DELETE", "/api/external-mcp/test-delete", nil) req := httptest.NewRequest("DELETE", "/api/external-mcp/test-delete", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String()) t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String())
} }
// 验证配置已删除 // 验证配置已删除
req2 := httptest.NewRequest("GET", "/api/external-mcp/test-delete", nil) req2 := httptest.NewRequest("GET", "/api/external-mcp/test-delete", nil)
w2 := httptest.NewRecorder() w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2) router.ServeHTTP(w2, req2)
if w2.Code != http.StatusNotFound { if w2.Code != http.StatusNotFound {
t.Errorf("期望状态码404,实际%d: %s", w2.Code, w2.Body.String()) t.Errorf("期望状态码404,实际%d: %s", w2.Code, w2.Body.String())
} }
@@ -278,7 +279,7 @@ func TestExternalMCPHandler_DeleteExternalMCP(t *testing.T) {
func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) { func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
router, handler, _ := setupTestRouter() router, handler, _ := setupTestRouter()
// 添加多个配置 // 添加多个配置
handler.manager.AddOrUpdateConfig("test1", config.ExternalMCPServerConfig{ handler.manager.AddOrUpdateConfig("test1", config.ExternalMCPServerConfig{
Command: "python3", Command: "python3",
@@ -288,20 +289,20 @@ func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
URL: "http://127.0.0.1:8081/mcp", URL: "http://127.0.0.1:8081/mcp",
Enabled: false, Enabled: false,
}) })
req := httptest.NewRequest("GET", "/api/external-mcp", nil) req := httptest.NewRequest("GET", "/api/external-mcp", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String()) t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String())
} }
var response map[string]interface{} var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("解析响应失败: %v", err) t.Fatalf("解析响应失败: %v", err)
} }
servers := response["servers"].(map[string]interface{}) servers := response["servers"].(map[string]interface{})
if len(servers) != 2 { if len(servers) != 2 {
t.Errorf("期望2个服务器,实际%d", len(servers)) t.Errorf("期望2个服务器,实际%d", len(servers))
@@ -312,7 +313,7 @@ func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
if _, ok := servers["test2"]; !ok { if _, ok := servers["test2"]; !ok {
t.Error("期望包含test2") t.Error("期望包含test2")
} }
stats := response["stats"].(map[string]interface{}) stats := response["stats"].(map[string]interface{})
if int(stats["total"].(float64)) != 2 { if int(stats["total"].(float64)) != 2 {
t.Errorf("期望总数为2,实际%d", int(stats["total"].(float64))) t.Errorf("期望总数为2,实际%d", int(stats["total"].(float64)))
@@ -321,7 +322,7 @@ func TestExternalMCPHandler_GetExternalMCPs(t *testing.T) {
func TestExternalMCPHandler_GetExternalMCPStats(t *testing.T) { func TestExternalMCPHandler_GetExternalMCPStats(t *testing.T) {
router, handler, _ := setupTestRouter() router, handler, _ := setupTestRouter()
// 添加配置 // 添加配置
handler.manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{ handler.manager.AddOrUpdateConfig("enabled1", config.ExternalMCPServerConfig{
Command: "python3", Command: "python3",
@@ -336,20 +337,20 @@ func TestExternalMCPHandler_GetExternalMCPStats(t *testing.T) {
Enabled: false, Enabled: false,
Disabled: true, Disabled: true,
}) })
req := httptest.NewRequest("GET", "/api/external-mcp/stats", nil) req := httptest.NewRequest("GET", "/api/external-mcp/stats", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String()) t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String())
} }
var stats map[string]interface{} var stats map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil {
t.Fatalf("解析响应失败: %v", err) t.Fatalf("解析响应失败: %v", err)
} }
if int(stats["total"].(float64)) != 3 { if int(stats["total"].(float64)) != 3 {
t.Errorf("期望总数为3,实际%d", int(stats["total"].(float64))) t.Errorf("期望总数为3,实际%d", int(stats["total"].(float64)))
} }
@@ -364,19 +365,19 @@ func TestExternalMCPHandler_GetExternalMCPStats(t *testing.T) {
func TestExternalMCPHandler_StartStopExternalMCP(t *testing.T) { func TestExternalMCPHandler_StartStopExternalMCP(t *testing.T) {
router, handler, configPath := setupTestRouter() router, handler, configPath := setupTestRouter()
defer cleanupTestConfig(configPath) defer cleanupTestConfig(configPath)
// 添加一个禁用的配置 // 添加一个禁用的配置
handler.manager.AddOrUpdateConfig("test-start-stop", config.ExternalMCPServerConfig{ handler.manager.AddOrUpdateConfig("test-start-stop", config.ExternalMCPServerConfig{
Command: "python3", Command: "python3",
Enabled: false, Enabled: false,
Disabled: true, Disabled: true,
}) })
// 测试启动(可能会失败,因为没有真实的服务器) // 测试启动(可能会失败,因为没有真实的服务器)
req := httptest.NewRequest("POST", "/api/external-mcp/test-start-stop/start", nil) req := httptest.NewRequest("POST", "/api/external-mcp/test-start-stop/start", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
// 启动可能会失败,但应该返回合理的状态码 // 启动可能会失败,但应该返回合理的状态码
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
// 如果启动失败,应该是400或500 // 如果启动失败,应该是400或500
@@ -384,12 +385,12 @@ func TestExternalMCPHandler_StartStopExternalMCP(t *testing.T) {
t.Errorf("期望状态码200/400/500,实际%d: %s", w.Code, w.Body.String()) t.Errorf("期望状态码200/400/500,实际%d: %s", w.Code, w.Body.String())
} }
} }
// 测试停止 // 测试停止
req2 := httptest.NewRequest("POST", "/api/external-mcp/test-start-stop/stop", nil) req2 := httptest.NewRequest("POST", "/api/external-mcp/test-start-stop/stop", nil)
w2 := httptest.NewRecorder() w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2) router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK { if w2.Code != http.StatusOK {
t.Errorf("期望状态码200,实际%d: %s", w2.Code, w2.Body.String()) t.Errorf("期望状态码200,实际%d: %s", w2.Code, w2.Body.String())
} }
@@ -397,11 +398,11 @@ func TestExternalMCPHandler_StartStopExternalMCP(t *testing.T) {
func TestExternalMCPHandler_GetExternalMCP_NotFound(t *testing.T) { func TestExternalMCPHandler_GetExternalMCP_NotFound(t *testing.T) {
router, _, _ := setupTestRouter() router, _, _ := setupTestRouter()
req := httptest.NewRequest("GET", "/api/external-mcp/nonexistent", nil) req := httptest.NewRequest("GET", "/api/external-mcp/nonexistent", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound { if w.Code != http.StatusNotFound {
t.Errorf("期望状态码404,实际%d: %s", w.Code, w.Body.String()) t.Errorf("期望状态码404,实际%d: %s", w.Code, w.Body.String())
} }
@@ -410,11 +411,11 @@ func TestExternalMCPHandler_GetExternalMCP_NotFound(t *testing.T) {
func TestExternalMCPHandler_DeleteExternalMCP_NotFound(t *testing.T) { func TestExternalMCPHandler_DeleteExternalMCP_NotFound(t *testing.T) {
router, _, configPath := setupTestRouter() router, _, configPath := setupTestRouter()
defer cleanupTestConfig(configPath) defer cleanupTestConfig(configPath)
req := httptest.NewRequest("DELETE", "/api/external-mcp/nonexistent", nil) req := httptest.NewRequest("DELETE", "/api/external-mcp/nonexistent", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
// 删除不存在的配置可能返回200(幂等操作)或404,都是合理的 // 删除不存在的配置可能返回200(幂等操作)或404,都是合理的
if w.Code != http.StatusNotFound && w.Code != http.StatusOK { if w.Code != http.StatusNotFound && w.Code != http.StatusOK {
t.Errorf("期望状态码404或200,实际%d: %s", w.Code, w.Body.String()) t.Errorf("期望状态码404或200,实际%d: %s", w.Code, w.Body.String())
@@ -423,23 +424,23 @@ func TestExternalMCPHandler_DeleteExternalMCP_NotFound(t *testing.T) {
func TestExternalMCPHandler_AddOrUpdateExternalMCP_EmptyName(t *testing.T) { func TestExternalMCPHandler_AddOrUpdateExternalMCP_EmptyName(t *testing.T) {
router, _, _ := setupTestRouter() router, _, _ := setupTestRouter()
configObj := config.ExternalMCPServerConfig{ configObj := config.ExternalMCPServerConfig{
Command: "python3", Command: "python3",
Enabled: true, Enabled: true,
} }
reqBody := AddOrUpdateExternalMCPRequest{ reqBody := AddOrUpdateExternalMCPRequest{
Config: configObj, Config: configObj,
} }
body, _ := json.Marshal(reqBody) body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/external-mcp/", bytes.NewBuffer(body)) req := httptest.NewRequest("PUT", "/api/external-mcp/", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
// 空名称应该返回404或400 // 空名称应该返回404或400
if w.Code != http.StatusNotFound && w.Code != http.StatusBadRequest { if w.Code != http.StatusNotFound && w.Code != http.StatusBadRequest {
t.Errorf("期望状态码404或400,实际%d: %s", w.Code, w.Body.String()) t.Errorf("期望状态码404或400,实际%d: %s", w.Code, w.Body.String())
@@ -448,15 +449,15 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_EmptyName(t *testing.T) {
func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidJSON(t *testing.T) { func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidJSON(t *testing.T) {
router, _, _ := setupTestRouter() router, _, _ := setupTestRouter()
// 发送无效的JSON // 发送无效的JSON
body := []byte(`{"config": invalid json}`) body := []byte(`{"config": invalid json}`)
req := httptest.NewRequest("PUT", "/api/external-mcp/test", bytes.NewBuffer(body)) req := httptest.NewRequest("PUT", "/api/external-mcp/test", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusBadRequest {
t.Errorf("期望状态码400,实际%d: %s", w.Code, w.Body.String()) t.Errorf("期望状态码400,实际%d: %s", w.Code, w.Body.String())
} }
@@ -465,49 +466,49 @@ func TestExternalMCPHandler_AddOrUpdateExternalMCP_InvalidJSON(t *testing.T) {
func TestExternalMCPHandler_UpdateExistingConfig(t *testing.T) { func TestExternalMCPHandler_UpdateExistingConfig(t *testing.T) {
router, handler, configPath := setupTestRouter() router, handler, configPath := setupTestRouter()
defer cleanupTestConfig(configPath) defer cleanupTestConfig(configPath)
// 先添加配置 // 先添加配置
config1 := config.ExternalMCPServerConfig{ config1 := config.ExternalMCPServerConfig{
Command: "python3", Command: "python3",
Enabled: true, Enabled: true,
} }
handler.manager.AddOrUpdateConfig("test-update", config1) handler.manager.AddOrUpdateConfig("test-update", config1)
// 更新配置 // 更新配置
config2 := config.ExternalMCPServerConfig{ config2 := config.ExternalMCPServerConfig{
URL: "http://127.0.0.1:8081/mcp", URL: "http://127.0.0.1:8081/mcp",
Enabled: true, Enabled: true,
} }
reqBody := AddOrUpdateExternalMCPRequest{ reqBody := AddOrUpdateExternalMCPRequest{
Config: config2, Config: config2,
} }
body, _ := json.Marshal(reqBody) body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/external-mcp/test-update", bytes.NewBuffer(body)) req := httptest.NewRequest("PUT", "/api/external-mcp/test-update", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String()) t.Fatalf("期望状态码200,实际%d: %s", w.Code, w.Body.String())
} }
// 验证配置已更新 // 验证配置已更新
req2 := httptest.NewRequest("GET", "/api/external-mcp/test-update", nil) req2 := httptest.NewRequest("GET", "/api/external-mcp/test-update", nil)
w2 := httptest.NewRecorder() w2 := httptest.NewRecorder()
router.ServeHTTP(w2, req2) router.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK { if w2.Code != http.StatusOK {
t.Fatalf("期望状态码200,实际%d: %s", w2.Code, w2.Body.String()) t.Fatalf("期望状态码200,实际%d: %s", w2.Code, w2.Body.String())
} }
var response ExternalMCPResponse var response ExternalMCPResponse
if err := json.Unmarshal(w2.Body.Bytes(), &response); err != nil { if err := json.Unmarshal(w2.Body.Bytes(), &response); err != nil {
t.Fatalf("解析响应失败: %v", err) t.Fatalf("解析响应失败: %v", err)
} }
if response.Config.URL != "http://127.0.0.1:8081/mcp" { if response.Config.URL != "http://127.0.0.1:8081/mcp" {
t.Errorf("期望url为'http://127.0.0.1:8081/mcp',实际%s", response.Config.URL) t.Errorf("期望url为'http://127.0.0.1:8081/mcp',实际%s", response.Config.URL)
} }
@@ -515,4 +516,3 @@ func TestExternalMCPHandler_UpdateExistingConfig(t *testing.T) {
t.Errorf("期望command为空,实际%s", response.Config.Command) t.Errorf("期望command为空,实际%s", response.Config.Command)
} }
} }
+467
View File
@@ -0,0 +1,467 @@
package handler
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"cyberstrike-ai/internal/config"
openaiClient "cyberstrike-ai/internal/openai"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type FofaHandler struct {
cfg *config.Config
logger *zap.Logger
client *http.Client
openAIClient *openaiClient.Client
}
func NewFofaHandler(cfg *config.Config, logger *zap.Logger) *FofaHandler {
// LLM 请求通常比 FOFA 查询更慢一点,单独给一个更宽松的超时。
llmHTTPClient := &http.Client{Timeout: 2 * time.Minute}
var llmCfg *config.OpenAIConfig
if cfg != nil {
llmCfg = &cfg.OpenAI
}
return &FofaHandler{
cfg: cfg,
logger: logger,
client: &http.Client{Timeout: 30 * time.Second},
openAIClient: openaiClient.NewClient(llmCfg, llmHTTPClient, logger),
}
}
type fofaSearchRequest struct {
Query string `json:"query" binding:"required"`
Size int `json:"size,omitempty"`
Page int `json:"page,omitempty"`
Fields string `json:"fields,omitempty"`
Full bool `json:"full,omitempty"`
}
type fofaParseRequest struct {
Text string `json:"text" binding:"required"`
}
type fofaParseResponse struct {
Query string `json:"query"`
Explanation string `json:"explanation,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
type fofaAPIResponse struct {
Error bool `json:"error"`
ErrMsg string `json:"errmsg"`
Size int `json:"size"`
Page int `json:"page"`
Total int `json:"total"`
Mode string `json:"mode"`
Query string `json:"query"`
Results [][]interface{} `json:"results"`
}
type fofaSearchResponse struct {
Query string `json:"query"`
Size int `json:"size"`
Page int `json:"page"`
Total int `json:"total"`
Fields []string `json:"fields"`
ResultsCount int `json:"results_count"`
Results []map[string]interface{} `json:"results"`
}
func (h *FofaHandler) resolveCredentials() (email, apiKey string) {
// 优先环境变量(便于容器部署),其次配置文件
email = strings.TrimSpace(os.Getenv("FOFA_EMAIL"))
apiKey = strings.TrimSpace(os.Getenv("FOFA_API_KEY"))
if email != "" && apiKey != "" {
return email, apiKey
}
if h.cfg != nil {
if email == "" {
email = strings.TrimSpace(h.cfg.FOFA.Email)
}
if apiKey == "" {
apiKey = strings.TrimSpace(h.cfg.FOFA.APIKey)
}
}
return email, apiKey
}
func (h *FofaHandler) resolveBaseURL() string {
if h.cfg != nil {
if v := strings.TrimSpace(h.cfg.FOFA.BaseURL); v != "" {
return v
}
}
return "https://fofa.info/api/v1/search/all"
}
// ParseNaturalLanguage 将自然语言解析为 FOFA 查询语法(仅生成,不执行查询)
func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) {
var req fofaParseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
req.Text = strings.TrimSpace(req.Text)
if req.Text == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "text 不能为空"})
return
}
if h.cfg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "系统配置未初始化"})
return
}
if strings.TrimSpace(h.cfg.OpenAI.APIKey) == "" || strings.TrimSpace(h.cfg.OpenAI.Model) == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "未配置 AI 模型:请在系统设置中填写 openai.api_key 与 openai.model(支持 OpenAI 兼容 API,如 DeepSeek",
"need": []string{"openai.api_key", "openai.model"},
})
return
}
if h.openAIClient == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "AI 客户端未初始化"})
return
}
systemPrompt := strings.TrimSpace(`
你是FOFA 查询语法生成器任务把用户输入的自然语言搜索意图转换成 FOFA 查询语法
输出要求非常重要
1) 只输出 JSON不要 markdown不要代码块不要额外解释文本
2) JSON 结构必须是
{
"query": "stringFOFA查询语法(可直接粘贴到 FOFA 或本系统查询框)",
"explanation": "string,可选,解释你如何映射字段/逻辑",
"warnings": ["string"...] 可选列出歧义/风险/需要人工确认的点
}
3) 如果用户输入本身已经是 FOFA 查询语法或非常接近 FOFA 语法的表达式应当原样返回 query
- 不要擅自改写字段名操作符括号结构
- 不要改写任何字符串值尤其是地理位置类值不要做缩写/同义词替换/翻译/音译
查询语法要点来自 FOFA 语法参考
- 逻辑连接符&&||必要时用 () 包住子表达式以确认优先级括号优先级最高
- 当同一层级同时出现 && ||混用 () 明确优先级避免歧义
- 比较/匹配
- = 匹配当字段="" 可查询不存在该字段值为空的情况
- == 完全匹配当字段=="" 可查询字段存在且值为空的情况
- != 不匹配当字段!="" 可查询值不为空的情况
- *= 模糊匹配可使用 * ? 进行搜索
- 直接输入关键词不带字段会在标题HTML内容HTTP头URL字段中搜索但当意图明确时优先用字段表达更可控更准确
字段示例速查来自用户提供的案例可直接套用/拼接
- 高级搜索操作符示例
- title="beijing" = 匹配
- title=="" == 完全匹配字段存在且值为空
- title="" = 匹配可能表示字段不存在或值为空
- title!="" != 不匹配可用于值不为空
- title*="*Home*" *= 模糊匹配 * ?
- (app="Apache" || app="Nginx") && country="CN" 混用 && / || 时用括号
- 基础类General
- ip="1.1.1.1"
- ip="220.181.111.1/24"
- ip="2600:9000:202a:2600:18:4ab7:f600:93a1"
- port="6379"
- domain="qq.com"
- host=".fofa.info"
- os="centos"
- server="Microsoft-IIS/10"
- asn="19551"
- org="LLC Baxet"
- is_domain=true / is_domain=false
- is_ipv6=true / is_ipv6=false
- 标记类Special Label
- app="Microsoft-Exchange"
- fid="sSXXGNUO2FefBTcCLIT/2Q=="
- product="NGINX"
- product="Roundcube-Webmail" && product.version="1.6.10"
- category="服务"
- type="service" / type="subdomain"
- cloud_name="Aliyundun"
- is_cloud=true / is_cloud=false
- is_fraud=true / is_fraud=false
- is_honeypot=true / is_honeypot=false
- 协议类type=service
- protocol="quic"
- banner="users"
- banner_hash="7330105010150477363"
- banner_fid="zRpqmn0FXQRjZpH8MjMX55zpMy9SgsW8"
- base_protocol="udp" / base_protocol="tcp"
- 网站类type=subdomain
- title="beijing"
- header="elastic"
- header_hash="1258854265"
- body="网络空间测绘"
- body_hash="-2090962452"
- js_name="js/jquery.js"
- js_md5="82ac3f14327a8b7ba49baa208d4eaa15"
- cname="customers.spektrix.com"
- cname_domain="siteforce.com"
- icon_hash="-247388890"
- status_code="402"
- icp="京ICP证030173号"
- sdk_hash="Are3qNnP2Eqn7q5kAoUO3l+w3mgVIytO"
- 地理位置Location
- country="CN" country="中国"
- region="Zhejiang" region="浙江"仅支持中国地区中文
- city="Hangzhou"
- 证书类Certificate
- cert="baidu"
- cert.subject="Oracle Corporation"
- cert.issuer="DigiCert"
- cert.subject.org="Oracle Corporation"
- cert.subject.cn="baidu.com"
- cert.issuer.org="cPanel, Inc."
- cert.issuer.cn="Synology Inc. CA"
- cert.domain="huawei.com"
- cert.is_equal=true / cert.is_equal=false
- cert.is_valid=true / cert.is_valid=false
- cert.is_match=true / cert.is_match=false
- cert.is_expired=true / cert.is_expired=false
- jarm="2ad2ad0002ad2ad22c2ad2ad2ad2ad2eac92ec34bcc0cf7520e97547f83e81"
- tls.version="TLS 1.3"
- tls.ja3s="15af977ce25de452b96affa2addb1036"
- cert.sn="356078156165546797850343536942784588840297"
- cert.not_after.after="2025-03-01" / cert.not_after.before="2025-03-01"
- cert.not_before.after="2025-03-01" / cert.not_before.before="2025-03-01"
- 时间类Last update time
- after="2023-01-01"
- before="2023-12-01"
- after="2023-01-01" && before="2023-12-01"
- 独立IP语法需配合 ip_filter / ip_exclude
- ip_filter(banner="SSH-2.0-OpenSSH_6.7p2") && ip_filter(icon_hash="-1057022626")
- ip_filter(banner="SSH-2.0-OpenSSH_6.7p2" && asn="3462") && ip_exclude(title="EdgeOS")
- port_size="6" / port_size_gt="6" / port_size_lt="12"
- ip_ports="80,161"
- ip_country="CN"
- ip_region="Zhejiang"
- ip_city="Hangzhou"
- ip_after="2021-03-18"
- ip_before="2019-09-09"
生成约束与注意事项
- 字符串值一律用英文双引号包裹例如 title="登录"country="CN"
- 字符串值保持字面一致不要缩写例如 city="beijing" 不要变成 city="BJ"不要用别名例如 Beijing/Peking不要擅自翻译/音译/改写大小写
- 地理位置字段country/region/city更倾向于按用户给定值输出不确定合法取值时不要猜测把备选写进 warnings
- 不要捏造不存在的 FOFA 字段不确定时把不确定点写进 warnings并输出一个保守的 query
- 当用户描述里有多个与/或条件优先加 () 明确优先级例如(app="Apache" || app="Nginx") && country="CN"
- 当用户缺少关键条件导致范围过大或歧义如地点/协议/端口/服务类型未说明允许 query 为空字符串并在 warnings 里明确需要补充的信息
`)
userPrompt := fmt.Sprintf("自然语言意图:%s", req.Text)
requestBody := map[string]interface{}{
"model": h.cfg.OpenAI.Model,
"messages": []map[string]interface{}{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
"temperature": 0.1,
"max_tokens": 1200,
}
// OpenAI 返回结构:只需要 choices[0].message.content
var apiResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 90*time.Second)
defer cancel()
if err := h.openAIClient.ChatCompletion(ctx, requestBody, &apiResponse); err != nil {
var apiErr *openaiClient.APIError
if errors.As(err, &apiErr) {
h.logger.Warn("FOFA自然语言解析:LLM返回错误", zap.Int("status", apiErr.StatusCode))
c.JSON(http.StatusBadGateway, gin.H{"error": "AI 解析失败(上游返回非 200),请检查模型配置或稍后重试"})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": "AI 解析失败: " + err.Error()})
return
}
if len(apiResponse.Choices) == 0 {
c.JSON(http.StatusBadGateway, gin.H{"error": "AI 未返回有效结果"})
return
}
content := strings.TrimSpace(apiResponse.Choices[0].Message.Content)
// 兼容模型偶尔返回 ```json ... ``` 的情况
content = strings.TrimPrefix(content, "```json")
content = strings.TrimPrefix(content, "```")
content = strings.TrimSuffix(content, "```")
content = strings.TrimSpace(content)
var parsed fofaParseResponse
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
// 直接回传一部分原文,方便排查,但避免太大
snippet := content
if len(snippet) > 1200 {
snippet = snippet[:1200]
}
c.JSON(http.StatusBadGateway, gin.H{
"error": "AI 返回内容无法解析为 JSON,请稍后重试或换个描述方式",
"snippet": snippet,
})
return
}
parsed.Query = strings.TrimSpace(parsed.Query)
if parsed.Query == "" {
// query 允许为空(表示需求不明确),但前端需要明确提示
if len(parsed.Warnings) == 0 {
parsed.Warnings = []string{"需求信息不足,未能生成可用的 FOFA 查询语法,请补充关键条件(如国家/端口/产品/域名等)。"}
}
}
c.JSON(http.StatusOK, parsed)
}
// Search FOFA 查询(后端代理,避免前端暴露 key)
func (h *FofaHandler) Search(c *gin.Context) {
var req fofaSearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
req.Query = strings.TrimSpace(req.Query)
if req.Query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query 不能为空"})
return
}
if req.Size <= 0 {
req.Size = 100
}
if req.Page <= 0 {
req.Page = 1
}
// FOFA 接口 size 上限和账户权限相关,这里只做一个合理的保护
if req.Size > 10000 {
req.Size = 10000
}
if req.Fields == "" {
req.Fields = "host,ip,port,domain,title,protocol,country,province,city,server"
}
email, apiKey := h.resolveCredentials()
if email == "" || apiKey == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "FOFA 未配置:请在系统设置中填写 FOFA Email/API Key,或设置环境变量 FOFA_EMAIL/FOFA_API_KEY",
"need": []string{"fofa.email", "fofa.api_key"},
"env_key": []string{"FOFA_EMAIL", "FOFA_API_KEY"},
})
return
}
baseURL := h.resolveBaseURL()
qb64 := base64.StdEncoding.EncodeToString([]byte(req.Query))
u, err := url.Parse(baseURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "FOFA base_url 无效: " + err.Error()})
return
}
params := u.Query()
params.Set("email", email)
params.Set("key", apiKey)
params.Set("qbase64", qb64)
params.Set("size", fmt.Sprintf("%d", req.Size))
params.Set("page", fmt.Sprintf("%d", req.Page))
params.Set("fields", strings.TrimSpace(req.Fields))
if req.Full {
params.Set("full", "true")
} else {
// 明确传 false,便于排查
params.Set("full", "false")
}
u.RawQuery = params.Encode()
httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, u.String(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建请求失败: " + err.Error()})
return
}
resp, err := h.client.Do(httpReq)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "请求 FOFA 失败: " + err.Error()})
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("FOFA 返回非 2xx: %d", resp.StatusCode)})
return
}
var apiResp fofaAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "解析 FOFA 响应失败: " + err.Error()})
return
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.ErrMsg)
if msg == "" {
msg = "FOFA 返回错误"
}
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return
}
fields := splitAndCleanCSV(req.Fields)
results := make([]map[string]interface{}, 0, len(apiResp.Results))
for _, row := range apiResp.Results {
item := make(map[string]interface{}, len(fields))
for i, f := range fields {
if i < len(row) {
item[f] = row[i]
} else {
item[f] = nil
}
}
results = append(results, item)
}
c.JSON(http.StatusOK, fofaSearchResponse{
Query: req.Query,
Size: apiResp.Size,
Page: apiResp.Page,
Total: apiResp.Total,
Fields: fields,
ResultsCount: len(results),
Results: results,
})
}
func splitAndCleanCSV(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
+67 -30
View File
@@ -15,11 +15,11 @@ import (
// KnowledgeHandler 知识库处理器 // KnowledgeHandler 知识库处理器
type KnowledgeHandler struct { type KnowledgeHandler struct {
manager *knowledge.Manager manager *knowledge.Manager
retriever *knowledge.Retriever retriever *knowledge.Retriever
indexer *knowledge.Indexer indexer *knowledge.Indexer
db *database.DB db *database.DB
logger *zap.Logger logger *zap.Logger
} }
// NewKnowledgeHandler 创建新的知识库处理器 // NewKnowledgeHandler 创建新的知识库处理器
@@ -55,7 +55,7 @@ func (h *KnowledgeHandler) GetCategories(c *gin.Context) {
func (h *KnowledgeHandler) GetItems(c *gin.Context) { func (h *KnowledgeHandler) GetItems(c *gin.Context) {
category := c.Query("category") category := c.Query("category")
searchKeyword := c.Query("search") // 搜索关键字 searchKeyword := c.Query("search") // 搜索关键字
// 如果提供了搜索关键字,执行关键字搜索(在所有数据中搜索) // 如果提供了搜索关键字,执行关键字搜索(在所有数据中搜索)
if searchKeyword != "" { if searchKeyword != "" {
items, err := h.manager.SearchItemsByKeyword(searchKeyword, category) items, err := h.manager.SearchItemsByKeyword(searchKeyword, category)
@@ -75,7 +75,7 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
groupedByCategory[cat] = append(groupedByCategory[cat], item) groupedByCategory[cat] = append(groupedByCategory[cat], item)
} }
// 转换为CategoryWithItems格式 // 转换为 CategoryWithItems 格式
categoriesWithItems := make([]*knowledge.CategoryWithItems, 0, len(groupedByCategory)) categoriesWithItems := make([]*knowledge.CategoryWithItems, 0, len(groupedByCategory))
for cat, catItems := range groupedByCategory { for cat, catItems := range groupedByCategory {
categoriesWithItems = append(categoriesWithItems, &knowledge.CategoryWithItems{ categoriesWithItems = append(categoriesWithItems, &knowledge.CategoryWithItems{
@@ -102,12 +102,12 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
}) })
return return
} }
// 分页模式:categoryPage=true 表示按分类分页,否则按项分页(向后兼容) // 分页模式:categoryPage=true 表示按分类分页,否则按项分页(向后兼容)
categoryPageMode := c.Query("categoryPage") != "false" // 默认使用分类分页 categoryPageMode := c.Query("categoryPage") != "false" // 默认使用分类分页
// 分页参数 // 分页参数
limit := 50 // 默认每页50条(分类分页时为分类数,项分页时为项数) limit := 50 // 默认每页 50 条(分类分页时为分类数,项分页时为项数)
offset := 0 offset := 0
if limitStr := c.Query("limit"); limitStr != "" { if limitStr := c.Query("limit"); limitStr != "" {
if parsed, err := parseInt(limitStr); err == nil && parsed > 0 && parsed <= 500 { if parsed, err := parseInt(limitStr); err == nil && parsed > 0 && parsed <= 500 {
@@ -120,7 +120,7 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
} }
} }
// 如果指定了category参数,且使用分类分页模式,则只返回该分类 // 如果指定了 category 参数,且使用分类分页模式,则只返回该分类
if category != "" && categoryPageMode { if category != "" && categoryPageMode {
// 单分类模式:返回该分类的所有知识项(不分页) // 单分类模式:返回该分类的所有知识项(不分页)
items, total, err := h.manager.GetItemsSummary(category, 0, 0) items, total, err := h.manager.GetItemsSummary(category, 0, 0)
@@ -150,9 +150,9 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
if categoryPageMode { if categoryPageMode {
// 按分类分页模式(默认) // 按分类分页模式(默认)
// limit表示每页分类数,推荐5-10个分类 // limit 表示每页分类数,推荐 5-10 个分类
if limit <= 0 || limit > 100 { if limit <= 0 || limit > 100 {
limit = 10 // 默认每页10个分类 limit = 10 // 默认每页 10 个分类
} }
categoriesWithItems, totalCategories, err := h.manager.GetCategoriesWithItems(limit, offset) categoriesWithItems, totalCategories, err := h.manager.GetCategoriesWithItems(limit, offset)
@@ -172,7 +172,7 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
} }
// 按项分页模式(向后兼容) // 按项分页模式(向后兼容)
// 是否包含完整内容(默认false,只返回摘要) // 是否包含完整内容(默认 false,只返回摘要)
includeContent := c.Query("includeContent") == "true" includeContent := c.Query("includeContent") == "true"
if includeContent { if includeContent {
@@ -192,9 +192,9 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"items": items, "items": items,
"total": total, "total": total,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
}) })
} else { } else {
@@ -207,9 +207,9 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"items": items, "items": items,
"total": total, "total": total,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
}) })
} }
@@ -341,12 +341,12 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) {
consecutiveFailures := 0 consecutiveFailures := 0
var firstFailureItemID string var firstFailureItemID string
var firstFailureError error var firstFailureError error
for i, itemID := range itemsToIndex { for i, itemID := range itemsToIndex {
if err := h.indexer.IndexItem(ctx, itemID); err != nil { if err := h.indexer.IndexItem(ctx, itemID); err != nil {
failedCount++ failedCount++
consecutiveFailures++ consecutiveFailures++
// 只在第一个失败时记录详细日志 // 只在第一个失败时记录详细日志
if consecutiveFailures == 1 { if consecutiveFailures == 1 {
firstFailureItemID = itemID firstFailureItemID = itemID
@@ -357,8 +357,8 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) {
zap.Error(err), zap.Error(err),
) )
} }
// 如果连续失败2次,立即停止增量索引 // 如果连续失败 2 次,立即停止增量索引
if consecutiveFailures >= 2 { if consecutiveFailures >= 2 {
h.logger.Error("连续索引失败次数过多,立即停止增量索引", h.logger.Error("连续索引失败次数过多,立即停止增量索引",
zap.Int("consecutiveFailures", consecutiveFailures), zap.Int("consecutiveFailures", consecutiveFailures),
@@ -371,14 +371,14 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) {
} }
continue continue
} }
// 成功时重置连续失败计数 // 成功时重置连续失败计数
if consecutiveFailures > 0 { if consecutiveFailures > 0 {
consecutiveFailures = 0 consecutiveFailures = 0
firstFailureItemID = "" firstFailureItemID = ""
firstFailureError = nil firstFailureError = nil
} }
// 减少进度日志频率 // 减少进度日志频率
if (i+1)%10 == 0 || i+1 == len(itemsToIndex) { if (i+1)%10 == 0 || i+1 == len(itemsToIndex) {
h.logger.Info("索引进度", zap.Int("current", i+1), zap.Int("total", len(itemsToIndex)), zap.Int("failed", failedCount)) h.logger.Info("索引进度", zap.Int("current", i+1), zap.Int("total", len(itemsToIndex)), zap.Int("failed", failedCount))
@@ -388,7 +388,7 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) {
}() }()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("扫描完成,开始索引 %d 个新添加或更新的知识项", len(itemsToIndex)), "message": fmt.Sprintf("扫描完成,开始索引 %d 个新添加或更新的知识项", len(itemsToIndex)),
"items_to_index": len(itemsToIndex), "items_to_index": len(itemsToIndex),
}) })
} }
@@ -397,7 +397,7 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) {
func (h *KnowledgeHandler) GetRetrievalLogs(c *gin.Context) { func (h *KnowledgeHandler) GetRetrievalLogs(c *gin.Context) {
conversationID := c.Query("conversationId") conversationID := c.Query("conversationId")
messageID := c.Query("messageId") messageID := c.Query("messageId")
limit := 50 // 默认50条 limit := 50 // 默认 50
if limitStr := c.Query("limit"); limitStr != "" { if limitStr := c.Query("limit"); limitStr != "" {
if parsed, err := parseInt(limitStr); err == nil && parsed > 0 { if parsed, err := parseInt(limitStr); err == nil && parsed > 0 {
@@ -441,18 +441,40 @@ func (h *KnowledgeHandler) GetIndexStatus(c *gin.Context) {
if h.indexer != nil { if h.indexer != nil {
lastError, lastErrorTime := h.indexer.GetLastError() lastError, lastErrorTime := h.indexer.GetLastError()
if lastError != "" { if lastError != "" {
// 如果错误是最近发生的(5分钟内),则返回错误信息 // 如果错误是最近发生的(5 分钟内),则返回错误信息
if time.Since(lastErrorTime) < 5*time.Minute { if time.Since(lastErrorTime) < 5*time.Minute {
status["last_error"] = lastError status["last_error"] = lastError
status["last_error_time"] = lastErrorTime.Format(time.RFC3339) status["last_error_time"] = lastErrorTime.Format(time.RFC3339)
} }
} }
// 获取重建索引状态
isRebuilding, totalItems, current, failed, lastItemID, lastChunks, startTime := h.indexer.GetRebuildStatus()
if isRebuilding {
status["is_rebuilding"] = true
status["rebuild_total"] = totalItems
status["rebuild_current"] = current
status["rebuild_failed"] = failed
status["rebuild_start_time"] = startTime.Format(time.RFC3339)
if lastItemID != "" {
status["rebuild_last_item_id"] = lastItemID
}
if lastChunks > 0 {
status["rebuild_last_chunks"] = lastChunks
}
// 重建中时,is_complete 为 false
status["is_complete"] = false
// 计算重建进度百分比
if totalItems > 0 {
status["progress_percent"] = float64(current) / float64(totalItems) * 100
}
}
} }
c.JSON(http.StatusOK, status) c.JSON(http.StatusOK, status)
} }
// Search 搜索知识库(用于API调用,Agent内部使用Retriever // Search 搜索知识库(用于 API 调用,Agent 内部使用 Retriever
func (h *KnowledgeHandler) Search(c *gin.Context) { func (h *KnowledgeHandler) Search(c *gin.Context) {
var req knowledge.SearchRequest var req knowledge.SearchRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -470,10 +492,25 @@ func (h *KnowledgeHandler) Search(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"results": results}) c.JSON(http.StatusOK, gin.H{"results": results})
} }
// GetStats 获取知识库统计信息
func (h *KnowledgeHandler) GetStats(c *gin.Context) {
totalCategories, totalItems, err := h.manager.GetStats()
if err != nil {
h.logger.Error("获取知识库统计信息失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"enabled": true,
"total_categories": totalCategories,
"total_items": totalItems,
})
}
// 辅助函数:解析整数 // 辅助函数:解析整数
func parseInt(s string) (int, error) { func parseInt(s string) (int, error) {
var result int var result int
_, err := fmt.Sscanf(s, "%d", &result) _, err := fmt.Sscanf(s, "%d", &result)
return result, err return result, err
} }
+2 -1
View File
@@ -1309,7 +1309,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
}, },
"/api/conversations/{id}/results": map[string]interface{}{ "/api/conversations/{id}/results": map[string]interface{}{
"get": map[string]interface{}{ "get": map[string]interface{}{
"tags": []string{"结果查询"}, "tags": []string{"对话管理"},
"summary": "获取对话结果", "summary": "获取对话结果",
"description": "获取指定对话的执行结果,包括消息、漏洞信息和执行结果", "description": "获取指定对话的执行结果,包括消息、漏洞信息和执行结果",
"operationId": "getConversationResults", "operationId": "getConversationResults",
@@ -4411,6 +4411,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
}, },
} }
enrichSpecWithI18nKeys(spec)
c.JSON(http.StatusOK, spec) c.JSON(http.StatusOK, spec)
} }
+139
View File
@@ -0,0 +1,139 @@
package handler
// apiDocI18n 为 OpenAPI 文档提供 x-i18n-* 扩展键,供前端 apiDocs 国际化使用。
// 前端通过 apiDocs.tags.* / apiDocs.summary.* / apiDocs.response.* 翻译。
var apiDocI18nTagToKey = map[string]string{
"认证": "auth", "对话管理": "conversationManagement", "对话交互": "conversationInteraction",
"批量任务": "batchTasks", "对话分组": "conversationGroups", "漏洞管理": "vulnerabilityManagement",
"角色管理": "roleManagement", "Skills管理": "skillsManagement", "监控": "monitoring",
"配置管理": "configManagement", "外部MCP管理": "externalMCPManagement", "攻击链": "attackChain",
"知识库": "knowledgeBase", "MCP": "mcp",
}
var apiDocI18nSummaryToKey = map[string]string{
"用户登录": "login", "用户登出": "logout", "修改密码": "changePassword", "验证Token": "validateToken",
"创建对话": "createConversation", "列出对话": "listConversations", "查看对话详情": "getConversationDetail",
"更新对话": "updateConversation", "删除对话": "deleteConversation", "获取对话结果": "getConversationResult",
"发送消息并获取AI回复(非流式)": "sendMessageNonStream", "发送消息并获取AI回复(流式)": "sendMessageStream",
"取消任务": "cancelTask", "列出运行中的任务": "listRunningTasks", "列出已完成的任务": "listCompletedTasks",
"创建批量任务队列": "createBatchQueue", "列出批量任务队列": "listBatchQueues", "获取批量任务队列": "getBatchQueue",
"删除批量任务队列": "deleteBatchQueue", "启动批量任务队列": "startBatchQueue", "暂停批量任务队列": "pauseBatchQueue",
"添加任务到队列": "addTaskToQueue", "SQL注入扫描": "sqlInjectionScan", "端口扫描": "portScan",
"更新批量任务": "updateBatchTask", "删除批量任务": "deleteBatchTask",
"创建分组": "createGroup", "列出分组": "listGroups", "获取分组": "getGroup", "更新分组": "updateGroup",
"删除分组": "deleteGroup", "获取分组中的对话": "getGroupConversations", "添加对话到分组": "addConversationToGroup",
"从分组移除对话": "removeConversationFromGroup",
"列出漏洞": "listVulnerabilities", "创建漏洞": "createVulnerability", "获取漏洞统计": "getVulnerabilityStats",
"获取漏洞": "getVulnerability", "更新漏洞": "updateVulnerability", "删除漏洞": "deleteVulnerability",
"列出角色": "listRoles", "创建角色": "createRole", "获取角色": "getRole", "更新角色": "updateRole", "删除角色": "deleteRole",
"获取可用Skills列表": "getAvailableSkills", "列出Skills": "listSkills", "创建Skill": "createSkill",
"获取Skill统计": "getSkillStats", "清空Skill统计": "clearSkillStats", "获取Skill": "getSkill",
"更新Skill": "updateSkill", "删除Skill": "deleteSkill", "获取绑定角色": "getBoundRoles",
"获取监控信息": "getMonitorInfo", "获取执行记录": "getExecutionRecords", "删除执行记录": "deleteExecutionRecord",
"批量删除执行记录": "batchDeleteExecutionRecords", "获取统计信息": "getStats",
"获取配置": "getConfig", "更新配置": "updateConfig", "获取工具配置": "getToolConfig", "应用配置": "applyConfig",
"列出外部MCP": "listExternalMCP", "获取外部MCP统计": "getExternalMCPStats", "获取外部MCP": "getExternalMCP",
"添加或更新外部MCP": "addOrUpdateExternalMCP", "stdio模式配置": "stdioModeConfig", "SSE模式配置": "sseModeConfig",
"删除外部MCP": "deleteExternalMCP", "启动外部MCP": "startExternalMCP", "停止外部MCP": "stopExternalMCP",
"获取攻击链": "getAttackChain", "重新生成攻击链": "regenerateAttackChain",
"设置对话置顶": "pinConversation", "设置分组置顶": "pinGroup", "设置分组中对话的置顶": "pinGroupConversation",
"获取分类": "getCategories", "列出知识项": "listKnowledgeItems", "创建知识项": "createKnowledgeItem",
"获取知识项": "getKnowledgeItem", "更新知识项": "updateKnowledgeItem", "删除知识项": "deleteKnowledgeItem",
"获取索引状态": "getIndexStatus", "重建索引": "rebuildIndex", "扫描知识库": "scanKnowledgeBase",
"搜索知识库": "searchKnowledgeBase", "基础搜索": "basicSearch", "按风险类型搜索": "searchByRiskType",
"获取检索日志": "getRetrievalLogs", "删除检索日志": "deleteRetrievalLog",
"MCP端点": "mcpEndpoint", "列出所有工具": "listAllTools", "调用工具": "invokeTool", "初始化连接": "initConnection",
"成功响应": "successResponse", "错误响应": "errorResponse",
}
var apiDocI18nResponseDescToKey = map[string]string{
"获取成功": "getSuccess", "未授权": "unauthorized", "未授权,需要有效的Token": "unauthorizedToken",
"创建成功": "createSuccess", "请求参数错误": "badRequest", "对话不存在": "conversationNotFound",
"对话不存在或结果不存在": "conversationOrResultNotFound", "请求参数错误(如task为空)": "badRequestTaskEmpty",
"请求参数错误或分组名称已存在": "badRequestGroupNameExists", "分组不存在": "groupNotFound",
"请求参数错误(如配置格式不正确、缺少必需字段等)": "badRequestConfig",
"请求参数错误(如query为空)": "badRequestQueryEmpty", "方法不允许(仅支持POST请求)": "methodNotAllowed",
"登录成功": "loginSuccess", "密码错误": "invalidPassword", "登出成功": "logoutSuccess",
"密码修改成功": "passwordChanged", "Token有效": "tokenValid", "Token无效或已过期": "tokenInvalid",
"对话创建成功": "conversationCreated", "服务器内部错误": "internalError", "更新成功": "updateSuccess",
"删除成功": "deleteSuccess", "队列不存在": "queueNotFound", "启动成功": "startSuccess",
"暂停成功": "pauseSuccess", "添加成功": "addSuccess",
"任务不存在": "taskNotFound", "对话或分组不存在": "conversationOrGroupNotFound",
"取消请求已提交": "cancelSubmitted", "未找到正在执行的任务": "noRunningTask",
"消息发送成功,返回AI回复": "messageSent", "流式响应(Server-Sent Events": "streamResponse",
}
// enrichSpecWithI18nKeys 在 spec 的每个 operation 上写入 x-i18n-tags、x-i18n-summary
// 在每个 response 上写入 x-i18n-description,供前端按 key 做国际化。
func enrichSpecWithI18nKeys(spec map[string]interface{}) {
paths, _ := spec["paths"].(map[string]interface{})
if paths == nil {
return
}
for _, pathItem := range paths {
pm, _ := pathItem.(map[string]interface{})
if pm == nil {
continue
}
for _, method := range []string{"get", "post", "put", "delete", "patch"} {
opVal, ok := pm[method]
if !ok {
continue
}
op, _ := opVal.(map[string]interface{})
if op == nil {
continue
}
// x-i18n-tags: 与 tags 一一对应的 i18n 键数组(spec 中 tags 为 []string
switch tags := op["tags"].(type) {
case []string:
if len(tags) > 0 {
keys := make([]string, 0, len(tags))
for _, s := range tags {
if k := apiDocI18nTagToKey[s]; k != "" {
keys = append(keys, k)
} else {
keys = append(keys, s)
}
}
op["x-i18n-tags"] = keys
}
case []interface{}:
if len(tags) > 0 {
keys := make([]interface{}, 0, len(tags))
for _, t := range tags {
if s, ok := t.(string); ok {
if k := apiDocI18nTagToKey[s]; k != "" {
keys = append(keys, k)
} else {
keys = append(keys, s)
}
}
}
if len(keys) > 0 {
op["x-i18n-tags"] = keys
}
}
}
// x-i18n-summary
if summary, _ := op["summary"].(string); summary != "" {
if k := apiDocI18nSummaryToKey[summary]; k != "" {
op["x-i18n-summary"] = k
}
}
// responses -> 每个 status -> x-i18n-description
if respMap, _ := op["responses"].(map[string]interface{}); respMap != nil {
for _, rv := range respMap {
if r, _ := rv.(map[string]interface{}); r != nil {
if desc, _ := r["description"].(string); desc != "" {
if k := apiDocI18nResponseDescToKey[desc]; k != "" {
r["x-i18n-description"] = k
}
}
}
}
}
}
}
}
+897
View File
@@ -0,0 +1,897 @@
package handler
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
const (
robotCmdHelp = "帮助"
robotCmdList = "列表"
robotCmdListAlt = "对话列表"
robotCmdSwitch = "切换"
robotCmdContinue = "继续"
robotCmdNew = "新对话"
robotCmdClear = "清空"
robotCmdCurrent = "当前"
robotCmdStop = "停止"
robotCmdRoles = "角色"
robotCmdRolesList = "角色列表"
robotCmdSwitchRole = "切换角色"
robotCmdDelete = "删除"
robotCmdVersion = "版本"
)
// RobotHandler 企业微信/钉钉/飞书等机器人回调处理
type RobotHandler struct {
config *config.Config
db *database.DB
agentHandler *AgentHandler
logger *zap.Logger
mu sync.RWMutex
sessions map[string]string // key: "platform_userID", value: conversationID
sessionRoles map[string]string // key: "platform_userID", value: roleName(默认"默认"
cancelMu sync.Mutex // 保护 runningCancels
runningCancels map[string]context.CancelFunc // key: "platform_userID", 用于停止命令中断任务
}
// NewRobotHandler 创建机器人处理器
func NewRobotHandler(cfg *config.Config, db *database.DB, agentHandler *AgentHandler, logger *zap.Logger) *RobotHandler {
return &RobotHandler{
config: cfg,
db: db,
agentHandler: agentHandler,
logger: logger,
sessions: make(map[string]string),
sessionRoles: make(map[string]string),
runningCancels: make(map[string]context.CancelFunc),
}
}
// sessionKey 生成会话 key
func (h *RobotHandler) sessionKey(platform, userID string) string {
return platform + "_" + userID
}
// getOrCreateConversation 获取或创建当前会话,title 用于新对话的标题(取用户首条消息前50字)
func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (convID string, isNew bool) {
h.mu.RLock()
convID = h.sessions[h.sessionKey(platform, userID)]
h.mu.RUnlock()
if convID != "" {
return convID, false
}
t := strings.TrimSpace(title)
if t == "" {
t = "新对话 " + time.Now().Format("01-02 15:04")
} else {
t = safeTruncateString(t, 50)
}
conv, err := h.db.CreateConversation(t)
if err != nil {
h.logger.Warn("创建机器人会话失败", zap.Error(err))
return "", false
}
convID = conv.ID
h.mu.Lock()
h.sessions[h.sessionKey(platform, userID)] = convID
h.mu.Unlock()
return convID, true
}
// setConversation 切换当前会话
func (h *RobotHandler) setConversation(platform, userID, convID string) {
h.mu.Lock()
h.sessions[h.sessionKey(platform, userID)] = convID
h.mu.Unlock()
}
// getRole 获取当前用户使用的角色,未设置时返回"默认"
func (h *RobotHandler) getRole(platform, userID string) string {
h.mu.RLock()
role := h.sessionRoles[h.sessionKey(platform, userID)]
h.mu.RUnlock()
if role == "" {
return "默认"
}
return role
}
// setRole 设置当前用户使用的角色
func (h *RobotHandler) setRole(platform, userID, roleName string) {
h.mu.Lock()
h.sessionRoles[h.sessionKey(platform, userID)] = roleName
h.mu.Unlock()
}
// clearConversation 清空当前会话(切换到新对话)
func (h *RobotHandler) clearConversation(platform, userID string) (newConvID string) {
title := "新对话 " + time.Now().Format("01-02 15:04")
conv, err := h.db.CreateConversation(title)
if err != nil {
h.logger.Warn("创建新对话失败", zap.Error(err))
return ""
}
h.setConversation(platform, userID, conv.ID)
return conv.ID
}
// HandleMessage 处理用户输入,返回回复文本(供各平台 webhook 调用)
func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply string) {
text = strings.TrimSpace(text)
if text == "" {
return "请输入内容或发送「帮助」/ help 查看命令。"
}
// 先尝试作为命令处理(支持中英文)
if cmdReply, ok := h.handleRobotCommand(platform, userID, text); ok {
return cmdReply
}
// 普通消息:走 Agent
convID, _ := h.getOrCreateConversation(platform, userID, text)
if convID == "" {
return "无法创建或获取对话,请稍后再试。"
}
// 若对话标题为「新对话 xx:xx」格式(由「新对话」命令创建),将标题更新为首条消息内容,与 Web 端体验一致
if conv, err := h.db.GetConversation(convID); err == nil && strings.HasPrefix(conv.Title, "新对话 ") {
newTitle := safeTruncateString(text, 50)
if newTitle != "" {
_ = h.db.UpdateConversationTitle(convID, newTitle)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
sk := h.sessionKey(platform, userID)
h.cancelMu.Lock()
h.runningCancels[sk] = cancel
h.cancelMu.Unlock()
defer func() {
cancel()
h.cancelMu.Lock()
delete(h.runningCancels, sk)
h.cancelMu.Unlock()
}()
role := h.getRole(platform, userID)
resp, newConvID, err := h.agentHandler.ProcessMessageForRobot(ctx, convID, text, role)
if err != nil {
h.logger.Warn("机器人 Agent 执行失败", zap.String("platform", platform), zap.String("userID", userID), zap.Error(err))
if errors.Is(err, context.Canceled) {
return "任务已取消。"
}
return "处理失败: " + err.Error()
}
if newConvID != convID {
h.setConversation(platform, userID, newConvID)
}
return resp
}
func (h *RobotHandler) cmdHelp() string {
return "**【CyberStrikeAI 机器人命令】**\n\n" +
"- `帮助` `help` — 显示本帮助 | Show this help\n" +
"- `列表` `list` — 列出所有对话标题与 ID | List conversations\n" +
"- `切换 <ID>` `switch <ID>` — 指定对话继续 | Switch to conversation\n" +
"- `新对话` `new` — 开启新对话 | Start new conversation\n" +
"- `清空` `clear` — 清空当前上下文 | Clear context\n" +
"- `当前` `current` — 显示当前对话 ID 与标题 | Show current conversation\n" +
"- `停止` `stop` — 中断当前任务 | Stop running task\n" +
"- `角色` `roles` — 列出所有可用角色 | List roles\n" +
"- `角色 <名>` `role <name>` — 切换当前角色 | Switch role\n" +
"- `删除 <ID>` `delete <ID>` — 删除指定对话 | Delete conversation\n" +
"- `版本` `version` — 显示当前版本号 | Show version\n\n" +
"---\n" +
"除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。\n" +
"Otherwise, send any text for AI penetration testing / security analysis."
}
func (h *RobotHandler) cmdList() string {
convs, err := h.db.ListConversations(50, 0, "")
if err != nil {
return "获取对话列表失败: " + err.Error()
}
if len(convs) == 0 {
return "暂无对话。发送任意内容将自动创建新对话。"
}
var b strings.Builder
b.WriteString("【对话列表】\n")
for i, c := range convs {
if i >= 20 {
b.WriteString("… 仅显示前 20 条\n")
break
}
b.WriteString(fmt.Sprintf("· %s\n ID: %s\n", c.Title, c.ID))
}
return strings.TrimSuffix(b.String(), "\n")
}
func (h *RobotHandler) cmdSwitch(platform, userID, convID string) string {
if convID == "" {
return "请指定对话 ID,例如:切换 xxx-xxx-xxx"
}
conv, err := h.db.GetConversation(convID)
if err != nil {
return "对话不存在或 ID 错误。"
}
h.setConversation(platform, userID, conv.ID)
return fmt.Sprintf("已切换到对话:「%s」\nID: %s", conv.Title, conv.ID)
}
func (h *RobotHandler) cmdNew(platform, userID string) string {
newID := h.clearConversation(platform, userID)
if newID == "" {
return "创建新对话失败,请重试。"
}
return "已开启新对话,可直接发送内容。"
}
func (h *RobotHandler) cmdClear(platform, userID string) string {
return h.cmdNew(platform, userID)
}
func (h *RobotHandler) cmdStop(platform, userID string) string {
sk := h.sessionKey(platform, userID)
h.cancelMu.Lock()
cancel, ok := h.runningCancels[sk]
if ok {
delete(h.runningCancels, sk)
cancel()
}
h.cancelMu.Unlock()
if !ok {
return "当前没有正在执行的任务。"
}
return "已停止当前任务。"
}
func (h *RobotHandler) cmdCurrent(platform, userID string) string {
h.mu.RLock()
convID := h.sessions[h.sessionKey(platform, userID)]
h.mu.RUnlock()
if convID == "" {
return "当前没有进行中的对话。发送任意内容将创建新对话。"
}
conv, err := h.db.GetConversation(convID)
if err != nil {
return "当前对话 ID: " + convID + "(获取标题失败)"
}
role := h.getRole(platform, userID)
return fmt.Sprintf("当前对话:「%s」\nID: %s\n当前角色: %s", conv.Title, conv.ID, role)
}
func (h *RobotHandler) cmdRoles() string {
if h.config.Roles == nil || len(h.config.Roles) == 0 {
return "暂无可用角色。"
}
names := make([]string, 0, len(h.config.Roles))
for name, role := range h.config.Roles {
if role.Enabled {
names = append(names, name)
}
}
if len(names) == 0 {
return "暂无可用角色。"
}
sort.Slice(names, func(i, j int) bool {
if names[i] == "默认" {
return true
}
if names[j] == "默认" {
return false
}
return names[i] < names[j]
})
var b strings.Builder
b.WriteString("【角色列表】\n")
for _, name := range names {
role := h.config.Roles[name]
desc := role.Description
if desc == "" {
desc = "无描述"
}
b.WriteString(fmt.Sprintf("· %s — %s\n", name, desc))
}
return strings.TrimSuffix(b.String(), "\n")
}
func (h *RobotHandler) cmdSwitchRole(platform, userID, roleName string) string {
if roleName == "" {
return "请指定角色名称,例如:角色 渗透测试"
}
if h.config.Roles == nil {
return "暂无可用角色。"
}
role, exists := h.config.Roles[roleName]
if !exists {
return fmt.Sprintf("角色「%s」不存在。发送「角色」查看可用角色。", roleName)
}
if !role.Enabled {
return fmt.Sprintf("角色「%s」已禁用。", roleName)
}
h.setRole(platform, userID, roleName)
return fmt.Sprintf("已切换到角色:「%s」\n%s", roleName, role.Description)
}
func (h *RobotHandler) cmdDelete(platform, userID, convID string) string {
if convID == "" {
return "请指定对话 ID,例如:删除 xxx-xxx-xxx"
}
sk := h.sessionKey(platform, userID)
h.mu.RLock()
currentConvID := h.sessions[sk]
h.mu.RUnlock()
if convID == currentConvID {
// 删除当前对话时,先清空会话绑定
h.mu.Lock()
delete(h.sessions, sk)
h.mu.Unlock()
}
if err := h.db.DeleteConversation(convID); err != nil {
return "删除失败: " + err.Error()
}
return fmt.Sprintf("已删除对话 ID: %s", convID)
}
func (h *RobotHandler) cmdVersion() string {
v := h.config.Version
if v == "" {
v = "未知"
}
return "CyberStrikeAI " + v
}
// handleRobotCommand 处理机器人内置命令;若匹配到命令返回 (回复内容, true),否则返回 ("", false)
func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string, bool) {
switch {
case text == robotCmdHelp || text == "help" || text == "" || text == "?":
return h.cmdHelp(), true
case text == robotCmdList || text == robotCmdListAlt || text == "list":
return h.cmdList(), true
case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "):
var id string
switch {
case strings.HasPrefix(text, robotCmdSwitch+" "):
id = strings.TrimSpace(text[len(robotCmdSwitch)+1:])
case strings.HasPrefix(text, robotCmdContinue+" "):
id = strings.TrimSpace(text[len(robotCmdContinue)+1:])
case strings.HasPrefix(text, "switch "):
id = strings.TrimSpace(text[7:])
default:
id = strings.TrimSpace(text[9:])
}
return h.cmdSwitch(platform, userID, id), true
case text == robotCmdNew || text == "new":
return h.cmdNew(platform, userID), true
case text == robotCmdClear || text == "clear":
return h.cmdClear(platform, userID), true
case text == robotCmdCurrent || text == "current":
return h.cmdCurrent(platform, userID), true
case text == robotCmdStop || text == "stop":
return h.cmdStop(platform, userID), true
case text == robotCmdRoles || text == robotCmdRolesList || text == "roles":
return h.cmdRoles(), true
case strings.HasPrefix(text, robotCmdRoles+" ") || strings.HasPrefix(text, robotCmdSwitchRole+" ") || strings.HasPrefix(text, "role "):
var roleName string
switch {
case strings.HasPrefix(text, robotCmdRoles+" "):
roleName = strings.TrimSpace(text[len(robotCmdRoles)+1:])
case strings.HasPrefix(text, robotCmdSwitchRole+" "):
roleName = strings.TrimSpace(text[len(robotCmdSwitchRole)+1:])
default:
roleName = strings.TrimSpace(text[5:])
}
return h.cmdSwitchRole(platform, userID, roleName), true
case strings.HasPrefix(text, robotCmdDelete+" ") || strings.HasPrefix(text, "delete "):
var convID string
if strings.HasPrefix(text, robotCmdDelete+" ") {
convID = strings.TrimSpace(text[len(robotCmdDelete)+1:])
} else {
convID = strings.TrimSpace(text[7:])
}
return h.cmdDelete(platform, userID, convID), true
case text == robotCmdVersion || text == "version":
return h.cmdVersion(), true
default:
return "", false
}
}
// —————— 企业微信 ——————
// wecomXML 企业微信回调 XML(明文模式下的简化结构;加密模式需先解密再解析)
type wecomXML struct {
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"`
Content string `xml:"Content"`
MsgID string `xml:"MsgId"`
AgentID int64 `xml:"AgentID"`
Encrypt string `xml:"Encrypt"` // 加密模式下消息在此
}
// wecomReplyXML 被动回复 XML(仅用于兼容,当前使用手动构造 XML)
type wecomReplyXML struct {
XMLName xml.Name `xml:"xml"`
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"`
Content string `xml:"Content"`
}
// HandleWecomGET 企业微信 URL 校验(GET
func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
if !h.config.Robots.Wecom.Enabled {
c.String(http.StatusNotFound, "")
return
}
// Gin 的 Query() 会自动 URL 解码,拿到的就是正确的 base64 字符串
echostr := c.Query("echostr")
msgSignature := c.Query("msg_signature")
timestamp := c.Query("timestamp")
nonce := c.Query("nonce")
// 验证签名:将 token、timestamp、nonce、echostr 四个参数排序后拼接计算 SHA1
signature := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, echostr)
if signature != msgSignature {
h.logger.Warn("企业微信 URL 验证签名失败", zap.String("expected", msgSignature), zap.String("got", signature))
c.String(http.StatusBadRequest, "invalid signature")
return
}
if echostr == "" {
c.String(http.StatusBadRequest, "missing echostr")
return
}
// 如果配置了 EncodingAESKey,说明是加密模式,需要解密 echostr
if h.config.Robots.Wecom.EncodingAESKey != "" {
decrypted, err := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, echostr)
if err != nil {
h.logger.Warn("企业微信 echostr 解密失败", zap.Error(err))
c.String(http.StatusBadRequest, "decrypt failed")
return
}
c.String(http.StatusOK, string(decrypted))
return
}
// 明文模式直接返回 echostr
c.String(http.StatusOK, echostr)
}
// signWecomRequest 生成企业微信请求签名
// 企业微信签名算法:将 token、timestamp、nonce、echostr 四个值排序后拼接成字符串,再计算 SHA1
func (h *RobotHandler) signWecomRequest(token, timestamp, nonce, echostr string) string {
strs := []string{token, timestamp, nonce, echostr}
sort.Strings(strs)
s := strings.Join(strs, "")
hash := sha1.Sum([]byte(s))
return fmt.Sprintf("%x", hash)
}
// wecomDecrypt 企业微信消息解密(AES-256-CBC,PKCS7,明文格式:16字节随机+4字节长度+消息+corpID)
func wecomDecrypt(encodingAESKey, encryptedB64 string) ([]byte, error) {
key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return nil, err
}
if len(key) != 32 {
return nil, fmt.Errorf("encoding_aes_key 解码后应为 32 字节")
}
ciphertext, err := base64.StdEncoding.DecodeString(encryptedB64)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
iv := key[:16]
mode := cipher.NewCBCDecrypter(block, iv)
if len(ciphertext)%aes.BlockSize != 0 {
return nil, fmt.Errorf("密文长度不是块大小的倍数")
}
plain := make([]byte, len(ciphertext))
mode.CryptBlocks(plain, ciphertext)
// 去除 PKCS7 填充
n := int(plain[len(plain)-1])
if n < 1 || n > 32 {
return nil, fmt.Errorf("无效的 PKCS7 填充")
}
plain = plain[:len(plain)-n]
// 企业微信格式:16 字节随机 + 4 字节长度(大端) + 消息 + corpID
if len(plain) < 20 {
return nil, fmt.Errorf("明文过短")
}
msgLen := binary.BigEndian.Uint32(plain[16:20])
if int(20+msgLen) > len(plain) {
return nil, fmt.Errorf("消息长度越界")
}
return plain[20 : 20+msgLen], nil
}
// wecomEncrypt 企业微信消息加密(AES-256-CBC,PKCS7,明文格式:16字节随机+4字节长度+消息+corpID)
func wecomEncrypt(encodingAESKey, message, corpID string) (string, error) {
key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return "", err
}
if len(key) != 32 {
return "", fmt.Errorf("encoding_aes_key 解码后应为 32 字节")
}
// 构造明文:16 字节随机 + 4 字节长度 (大端) + 消息 + corpID
random := make([]byte, 16)
if _, err := rand.Read(random); err != nil {
// 降级方案:使用时间戳生成随机数
for i := range random {
random[i] = byte(time.Now().UnixNano() % 256)
}
}
msgLen := len(message)
msgBytes := []byte(message)
corpBytes := []byte(corpID)
plain := make([]byte, 16+4+msgLen+len(corpBytes))
copy(plain[:16], random)
binary.BigEndian.PutUint32(plain[16:20], uint32(msgLen))
copy(plain[20:20+msgLen], msgBytes)
copy(plain[20+msgLen:], corpBytes)
// PKCS7 填充
padding := aes.BlockSize - len(plain)%aes.BlockSize
pad := bytes.Repeat([]byte{byte(padding)}, padding)
plain = append(plain, pad...)
// AES-256-CBC 加密
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
iv := key[:16]
ciphertext := make([]byte, len(plain))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext, plain)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// HandleWecomPOST 企业微信消息回调(POST),支持明文与加密模式
func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
if !h.config.Robots.Wecom.Enabled {
h.logger.Debug("企业微信机器人未启用,跳过请求")
c.String(http.StatusOK, "")
return
}
// 从 URL 获取签名参数(加密模式回复时需要用到)
timestamp := c.Query("timestamp")
nonce := c.Query("nonce")
msgSignature := c.Query("msg_signature")
// 先读取请求体,后续解析/签名验证都会用到
bodyRaw, err := io.ReadAll(c.Request.Body)
if err != nil {
h.logger.Warn("企业微信 POST 读取请求体失败", zap.Error(err))
c.String(http.StatusOK, "")
return
}
h.logger.Debug("企业微信 POST 收到请求", zap.String("body", string(bodyRaw)))
// 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段
if msgSignature != "" {
var tmp wecomXML
if err := xml.Unmarshal(bodyRaw, &tmp); err == nil {
expected := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, tmp.Encrypt)
if expected != msgSignature {
h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature))
c.String(http.StatusOK, "")
return
}
}
}
var body wecomXML
if err := xml.Unmarshal(bodyRaw, &body); err != nil {
h.logger.Warn("企业微信 POST 解析 XML 失败", zap.Error(err))
c.String(http.StatusOK, "")
return
}
h.logger.Debug("企业微信 XML 解析成功", zap.String("ToUserName", body.ToUserName), zap.String("FromUserName", body.FromUserName), zap.String("MsgType", body.MsgType), zap.String("Content", body.Content), zap.String("Encrypt", body.Encrypt))
// 保存企业 ID(用于明文模式回复)
enterpriseID := body.ToUserName
// 加密模式:先解密再解析内层 XML
if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" {
h.logger.Debug("企业微信进入加密模式解密流程")
decrypted, err := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, body.Encrypt)
if err != nil {
h.logger.Warn("企业微信消息解密失败", zap.Error(err))
c.String(http.StatusOK, "")
return
}
h.logger.Debug("企业微信解密成功", zap.String("decrypted", string(decrypted)))
if err := xml.Unmarshal(decrypted, &body); err != nil {
h.logger.Warn("企业微信解密后 XML 解析失败", zap.Error(err))
c.String(http.StatusOK, "")
return
}
h.logger.Debug("企业微信内层 XML 解析成功", zap.String("FromUserName", body.FromUserName), zap.String("Content", body.Content))
}
userID := body.FromUserName
text := strings.TrimSpace(body.Content)
// 限制回复内容长度(企业微信限制 2048 字节)
maxReplyLen := 2000
limitReply := func(s string) string {
if len(s) > maxReplyLen {
return s[:maxReplyLen] + "\n\n(内容过长,已截断)"
}
return s
}
if body.MsgType != "text" {
h.logger.Debug("企业微信收到非文本消息", zap.String("MsgType", body.MsgType))
h.sendWecomReply(c, userID, enterpriseID, limitReply("暂仅支持文本消息,请发送文字。"), timestamp, nonce)
return
}
// 文本消息:先判断是否为内置命令(如 帮助/列表/新对话 等),这类命令处理很快,可以直接走被动回复,避免依赖主动发送 API。
if cmdReply, ok := h.handleRobotCommand("wecom", userID, text); ok {
h.logger.Debug("企业微信收到命令消息,走被动回复", zap.String("userID", userID), zap.String("text", text))
h.sendWecomReply(c, userID, enterpriseID, limitReply(cmdReply), timestamp, nonce)
return
}
h.logger.Debug("企业微信开始处理消息(异步 AI)", zap.String("userID", userID), zap.String("text", text))
// 企业微信被动回复有 5 秒超时限制,而 AI 调用通常超过该时长。
// 这里采用推荐做法:立即返回 success(或空串),然后通过主动发送接口推送完整回复。
c.String(http.StatusOK, "success")
// 异步处理消息并通过企业微信主动消息接口发送结果
go func() {
reply := h.HandleMessage("wecom", userID, text)
reply = limitReply(reply)
h.logger.Debug("企业微信消息处理完成", zap.String("userID", userID), zap.String("reply", reply))
// 调用企业微信 API 主动发送消息
h.sendWecomMessageViaAPI(userID, enterpriseID, reply)
}()
}
// sendWecomReply 发送企业微信回复(加密模式自动加密)
// 参数:toUser=用户 ID, fromUser=企业 ID(明文模式)/CorpID(加密模式), content=回复内容,timestamp/nonce=请求参数
func (h *RobotHandler) sendWecomReply(c *gin.Context, toUser, fromUser, content, timestamp, nonce string) {
// 加密模式:判断 EncodingAESKey 是否配置
if h.config.Robots.Wecom.EncodingAESKey != "" {
// 加密模式使用 CorpID 进行加密
corpID := h.config.Robots.Wecom.CorpID
if corpID == "" {
h.logger.Warn("企业微信加密模式缺少 CorpID 配置")
c.String(http.StatusOK, "")
return
}
// 构造完整的明文 XML 回复(格式严格按企业微信文档要求)
plainResp := fmt.Sprintf(`<xml>
<ToUserName><![CDATA[%s]]></ToUserName>
<FromUserName><![CDATA[%s]]></FromUserName>
<CreateTime>%d</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[%s]]></Content>
</xml>`, toUser, fromUser, time.Now().Unix(), content)
encrypted, err := wecomEncrypt(h.config.Robots.Wecom.EncodingAESKey, plainResp, corpID)
if err != nil {
h.logger.Warn("企业微信回复加密失败", zap.Error(err))
c.String(http.StatusOK, "")
return
}
// 使用请求中的 timestamp/nonce 生成签名(企业微信要求回复时使用与请求相同的 timestamp 和 nonce
msgSignature := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, encrypted)
h.logger.Debug("企业微信发送加密回复",
zap.String("Encrypt", encrypted[:50]+"..."),
zap.String("MsgSignature", msgSignature),
zap.String("TimeStamp", timestamp),
zap.String("Nonce", nonce))
// 加密模式仅返回 4 个核心字段(企业微信官方要求)
xmlResp := fmt.Sprintf(`<xml><Encrypt><![CDATA[%s]]></Encrypt><MsgSignature><![CDATA[%s]]></MsgSignature><TimeStamp><![CDATA[%s]]></TimeStamp><Nonce><![CDATA[%s]]></Nonce></xml>`, encrypted, msgSignature, timestamp, nonce)
// also log the final response body so we can cross-check with the
// network traffic or developer console
h.logger.Debug("企业微信加密回复包", zap.String("xml", xmlResp))
// for additional confidence, decrypt the payload ourselves and log it
if dec, err2 := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, encrypted); err2 == nil {
h.logger.Debug("企业微信加密回复解密检查", zap.String("plain", string(dec)))
} else {
h.logger.Warn("企业微信加密回复解密检查失败", zap.Error(err2))
}
// 使用 c.Writer.Write 直接写入响应,避免 c.String 的转义问题
c.Writer.WriteHeader(http.StatusOK)
// use text/xml as that's what WeCom examples show
c.Writer.Header().Set("Content-Type", "text/xml; charset=utf-8")
_, _ = c.Writer.Write([]byte(xmlResp))
h.logger.Debug("企业微信加密回复已发送")
return
}
// 明文模式
h.logger.Debug("企业微信发送明文回复", zap.String("ToUserName", toUser), zap.String("FromUserName", fromUser), zap.String("Content", content[:50]+"..."))
// 手动构造 XML 响应(使用 CDATA 包裹所有字段,并包含 AgentID)
xmlResp := fmt.Sprintf(`<xml>
<ToUserName><![CDATA[%s]]></ToUserName>
<FromUserName><![CDATA[%s]]></FromUserName>
<CreateTime>%d</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[%s]]></Content>
</xml>`, toUser, fromUser, time.Now().Unix(), content)
// log the exact plaintext response for debugging
h.logger.Debug("企业微信明文回复包", zap.String("xml", xmlResp))
// use text/xml as recommended by WeCom docs
c.Header("Content-Type", "text/xml; charset=utf-8")
c.String(http.StatusOK, xmlResp)
h.logger.Debug("企业微信明文回复已发送")
}
// —————— 测试接口(需登录,用于验证机器人逻辑,无需钉钉/飞书客户端) ——————
// RobotTestRequest 模拟机器人消息请求
type RobotTestRequest struct {
Platform string `json:"platform"` // 如 "dingtalk"、"lark"、"wecom"
UserID string `json:"user_id"`
Text string `json:"text"`
}
// HandleRobotTest 供本地验证:POST JSON { "platform", "user_id", "text" },返回 { "reply": "..." }
func (h *RobotHandler) HandleRobotTest(c *gin.Context) {
var req RobotTestRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体需为 JSON,包含 platform、user_id、text"})
return
}
platform := strings.TrimSpace(req.Platform)
if platform == "" {
platform = "test"
}
userID := strings.TrimSpace(req.UserID)
if userID == "" {
userID = "test_user"
}
reply := h.HandleMessage(platform, userID, req.Text)
c.JSON(http.StatusOK, gin.H{"reply": reply})
}
// sendWecomMessageViaAPI 通过企业微信 API 主动发送消息(用于异步处理后的结果发送)
func (h *RobotHandler) sendWecomMessageViaAPI(toUser, toParty, content string) {
if !h.config.Robots.Wecom.Enabled {
return
}
secret := h.config.Robots.Wecom.Secret
corpID := h.config.Robots.Wecom.CorpID
agentID := h.config.Robots.Wecom.AgentID
if secret == "" || corpID == "" {
h.logger.Warn("企业微信主动 API 缺少 secret 或 corpID 配置")
return
}
// 第 1 步:获取 access_token
tokenURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s", corpID, secret)
resp, err := http.Get(tokenURL)
if err != nil {
h.logger.Warn("企业微信获取 token 失败", zap.Error(err))
return
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
h.logger.Warn("企业微信 token 响应解析失败", zap.Error(err))
return
}
if tokenResp.ErrCode != 0 {
h.logger.Warn("企业微信 token 获取错误", zap.String("errmsg", tokenResp.ErrMsg), zap.Int("errcode", tokenResp.ErrCode))
return
}
// 第 2 步:构造发送消息请求
msgReq := map[string]interface{}{
"touser": toUser,
"msgtype": "text",
"agentid": agentID,
"text": map[string]interface{}{
"content": content,
},
}
msgBody, err := json.Marshal(msgReq)
if err != nil {
h.logger.Warn("企业微信消息序列化失败", zap.Error(err))
return
}
// 第 3 步:发送消息
sendURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=%s", tokenResp.AccessToken)
msgResp, err := http.Post(sendURL, "application/json", bytes.NewReader(msgBody))
if err != nil {
h.logger.Warn("企业微信主动发送消息失败", zap.Error(err))
return
}
defer msgResp.Body.Close()
var sendResp struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
InvalidUser string `json:"invaliduser"`
MsgID string `json:"msgid"`
}
if err := json.NewDecoder(msgResp.Body).Decode(&sendResp); err != nil {
h.logger.Warn("企业微信发送响应解析失败", zap.Error(err))
return
}
if sendResp.ErrCode == 0 {
h.logger.Debug("企业微信主动发送消息成功", zap.String("msgid", sendResp.MsgID))
} else {
h.logger.Warn("企业微信主动发送消息失败", zap.String("errmsg", sendResp.ErrMsg), zap.Int("errcode", sendResp.ErrCode), zap.String("invaliduser", sendResp.InvalidUser))
}
}
// —————— 钉钉 ——————
// HandleDingtalkPOST 钉钉事件回调(流式接入等);当前为占位,返回 200
func (h *RobotHandler) HandleDingtalkPOST(c *gin.Context) {
if !h.config.Robots.Dingtalk.Enabled {
c.JSON(http.StatusOK, gin.H{})
return
}
// 钉钉流式/事件回调格式需按官方文档解析并异步回复,此处仅返回 200
c.JSON(http.StatusOK, gin.H{"message": "ok"})
}
// —————— 飞书 ——————
// HandleLarkPOST 飞书事件回调;当前为占位,返回 200;验证时需返回 challenge
func (h *RobotHandler) HandleLarkPOST(c *gin.Context) {
if !h.config.Robots.Lark.Enabled {
c.JSON(http.StatusOK, gin.H{})
return
}
var body struct {
Challenge string `json:"challenge"`
}
if err := c.ShouldBindJSON(&body); err == nil && body.Challenge != "" {
c.JSON(http.StatusOK, gin.H{"challenge": body.Challenge})
return
}
c.JSON(http.StatusOK, gin.H{})
}
+257
View File
@@ -0,0 +1,257 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
const (
terminalMaxCommandLen = 4096
terminalMaxOutputLen = 256 * 1024 // 256KB
terminalTimeout = 120 * time.Second
)
// TerminalHandler 处理系统设置中的终端命令执行
type TerminalHandler struct {
logger *zap.Logger
}
// maskTerminalCommand 对可能包含敏感信息的终端命令做脱敏,避免在日志中直接记录密码等内容
func maskTerminalCommand(cmd string) string {
trimmed := strings.TrimSpace(cmd)
lower := strings.ToLower(trimmed)
if strings.Contains(lower, "sudo") || strings.Contains(lower, "password") {
return "[masked sensitive terminal command]"
}
if len(trimmed) > 256 {
return trimmed[:256] + "..."
}
return trimmed
}
// NewTerminalHandler 创建终端处理器
func NewTerminalHandler(logger *zap.Logger) *TerminalHandler {
return &TerminalHandler{logger: logger}
}
// RunCommandRequest 执行命令请求
type RunCommandRequest struct {
Command string `json:"command"`
Shell string `json:"shell,omitempty"`
Cwd string `json:"cwd,omitempty"`
}
// RunCommandResponse 执行命令响应
type RunCommandResponse struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Error string `json:"error,omitempty"`
}
// RunCommand 执行终端命令(需登录)
func (h *TerminalHandler) RunCommand(c *gin.Context) {
var req RunCommandRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效,需要 command 字段"})
return
}
cmdStr := strings.TrimSpace(req.Command)
if cmdStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "command 不能为空"})
return
}
if len(cmdStr) > terminalMaxCommandLen {
c.JSON(http.StatusBadRequest, gin.H{"error": "命令过长"})
return
}
shell := req.Shell
if shell == "" {
if runtime.GOOS == "windows" {
shell = "cmd"
} else {
shell = "sh"
}
}
ctx, cancel := context.WithTimeout(c.Request.Context(), terminalTimeout)
defer cancel()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.CommandContext(ctx, "cmd", "/c", cmdStr)
} else {
cmd = exec.CommandContext(ctx, shell, "-c", cmdStr)
// 无 TTY 时设置 COLUMNS/TERM,使 ping 等工具的 usage 排版与真实终端一致
cmd.Env = append(os.Environ(), "COLUMNS=256", "LINES=40", "TERM=xterm-256color")
}
if req.Cwd != "" {
absCwd, err := filepath.Abs(req.Cwd)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录无效"})
return
}
cur, _ := os.Getwd()
curAbs, _ := filepath.Abs(cur)
rel, err := filepath.Rel(curAbs, absCwd)
if err != nil || strings.HasPrefix(rel, "..") || rel == ".." {
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录必须在当前进程目录下"})
return
}
cmd.Dir = absCwd
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
stdoutBytes := stdout.Bytes()
stderrBytes := stderr.Bytes()
// 限制输出长度,防止内存占用过大(复制后截断,避免修改原 buffer)
truncSuffix := []byte("\n...(输出已截断)\n")
if len(stdoutBytes) > terminalMaxOutputLen {
tmp := make([]byte, terminalMaxOutputLen+len(truncSuffix))
n := copy(tmp, stdoutBytes[:terminalMaxOutputLen])
copy(tmp[n:], truncSuffix)
stdoutBytes = tmp
}
if len(stderrBytes) > terminalMaxOutputLen {
tmp := make([]byte, terminalMaxOutputLen+len(truncSuffix))
n := copy(tmp, stderrBytes[:terminalMaxOutputLen])
copy(tmp[n:], truncSuffix)
stderrBytes = tmp
}
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
exitCode = -1
}
if ctx.Err() == context.DeadlineExceeded {
so := strings.ReplaceAll(string(stdoutBytes), "\r\n", "\n")
so = strings.ReplaceAll(so, "\r", "\n")
se := strings.ReplaceAll(string(stderrBytes), "\r\n", "\n")
se = strings.ReplaceAll(se, "\r", "\n")
resp := RunCommandResponse{
Stdout: so,
Stderr: se,
ExitCode: -1,
Error: "命令执行超时(" + terminalTimeout.String() + "",
}
c.JSON(http.StatusOK, resp)
return
}
h.logger.Debug("终端命令执行异常", zap.String("command", maskTerminalCommand(cmdStr)), zap.Error(err))
}
// 统一为 \n,避免前端因 \r 出现错位/对角线排版
stdoutStr := strings.ReplaceAll(string(stdoutBytes), "\r\n", "\n")
stdoutStr = strings.ReplaceAll(stdoutStr, "\r", "\n")
stderrStr := strings.ReplaceAll(string(stderrBytes), "\r\n", "\n")
stderrStr = strings.ReplaceAll(stderrStr, "\r", "\n")
resp := RunCommandResponse{
Stdout: stdoutStr,
Stderr: stderrStr,
ExitCode: exitCode,
}
if err != nil && exitCode != 0 {
resp.Error = err.Error()
}
c.JSON(http.StatusOK, resp)
}
// streamEvent SSE 事件
type streamEvent struct {
T string `json:"t"` // "out" | "err" | "exit"
D string `json:"d,omitempty"`
C int `json:"c"` // exit code(不用 omitempty,否则 0 不序列化导致前端显示 [exit undefined]
}
// RunCommandStream 流式执行命令,输出实时推送到前端(SSE)
func (h *TerminalHandler) RunCommandStream(c *gin.Context) {
var req RunCommandRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效,需要 command 字段"})
return
}
cmdStr := strings.TrimSpace(req.Command)
if cmdStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "command 不能为空"})
return
}
if len(cmdStr) > terminalMaxCommandLen {
c.JSON(http.StatusBadRequest, gin.H{"error": "命令过长"})
return
}
shell := req.Shell
if shell == "" {
if runtime.GOOS == "windows" {
shell = "cmd"
} else {
shell = "sh"
}
}
ctx, cancel := context.WithTimeout(c.Request.Context(), terminalTimeout)
defer cancel()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.CommandContext(ctx, "cmd", "/c", cmdStr)
} else {
cmd = exec.CommandContext(ctx, shell, "-c", cmdStr)
cmd.Env = append(os.Environ(), "COLUMNS=256", "LINES=40", "TERM=xterm-256color")
}
if req.Cwd != "" {
absCwd, err := filepath.Abs(req.Cwd)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录无效"})
return
}
cur, _ := os.Getwd()
curAbs, _ := filepath.Abs(cur)
rel, err := filepath.Rel(curAbs, absCwd)
if err != nil || strings.HasPrefix(rel, "..") || rel == ".." {
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录必须在当前进程目录下"})
return
}
cmd.Dir = absCwd
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
c.Writer.WriteHeader(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
cancel()
return
}
sendEvent := func(ev streamEvent) {
body, _ := json.Marshal(ev)
c.SSEvent("", string(body))
flusher.Flush()
}
runCommandStreamImpl(cmd, sendEvent, ctx)
}
+46
View File
@@ -0,0 +1,46 @@
//go:build !windows
package handler
import (
"bufio"
"context"
"os/exec"
"strings"
"github.com/creack/pty"
)
const ptyCols = 256
const ptyRows = 40
// runCommandStreamImpl 在 Unix 下用 PTY 执行,使 ping 等命令按终端宽度排版(isatty 为真)
func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) {
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows})
if err != nil {
sendEvent(streamEvent{T: "exit", C: -1})
return
}
defer ptmx.Close()
normalize := func(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
return strings.ReplaceAll(s, "\r", "\n")
}
sc := bufio.NewScanner(ptmx)
for sc.Scan() {
sendEvent(streamEvent{T: "out", D: normalize(sc.Text())})
}
exitCode := 0
if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
exitCode = -1
}
}
if ctx.Err() == context.DeadlineExceeded {
exitCode = -1
}
sendEvent(streamEvent{T: "exit", C: exitCode})
}
@@ -0,0 +1,65 @@
//go:build windows
package handler
import (
"bufio"
"context"
"os/exec"
"strings"
"sync"
)
// runCommandStreamImpl 在 Windows 下用 stdout/stderr 管道执行
func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
sendEvent(streamEvent{T: "exit", C: -1})
return
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
sendEvent(streamEvent{T: "exit", C: -1})
return
}
if err := cmd.Start(); err != nil {
sendEvent(streamEvent{T: "exit", C: -1})
return
}
normalize := func(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
return strings.ReplaceAll(s, "\r", "\n")
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
sc := bufio.NewScanner(stdoutPipe)
for sc.Scan() {
sendEvent(streamEvent{T: "out", D: normalize(sc.Text())})
}
}()
go func() {
defer wg.Done()
sc := bufio.NewScanner(stderrPipe)
for sc.Scan() {
sendEvent(streamEvent{T: "err", D: normalize(sc.Text())})
}
}()
wg.Wait()
exitCode := 0
if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
exitCode = -1
}
}
if ctx.Err() == context.DeadlineExceeded {
exitCode = -1
}
sendEvent(streamEvent{T: "exit", C: exitCode})
}
+95
View File
@@ -0,0 +1,95 @@
//go:build !windows
package handler
import (
"net/http"
"os"
"os/exec"
"time"
"github.com/creack/pty"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
// wsUpgrader 仅用于系统设置中的终端 WebSocket,会复用已有的登录保护(JWT 中间件在上层路由组)
var wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// 由于已在 Gin 路由层做了认证,这里放宽 Origin,方便在同一域名下通过 HTTPS/WSS 访问
return true
},
}
// RunCommandWS 提供真正交互式 Shell:基于 WebSocket + PTY 的长会话
// 前端建立 WebSocket 连接后,所有键盘输入都会透传到 Shell,Shell 的输出也会实时写回前端。
func (h *TerminalHandler) RunCommandWS(c *gin.Context) {
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
// 启动交互式 Shell,这里优先使用 bash,找不到则退回 sh
shell := "bash"
if _, err := exec.LookPath(shell); err != nil {
shell = "sh"
}
cmd := exec.Command(shell)
cmd.Env = append(os.Environ(),
"COLUMNS=256",
"LINES=40",
"TERM=xterm-256color",
)
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows})
if err != nil {
return
}
defer ptmx.Close()
// Shell -> WebSocket:将 PTY 输出实时发给前端
doneChan := make(chan struct{})
go func() {
buf := make([]byte, 4096)
for {
n, err := ptmx.Read(buf)
if n > 0 {
_ = conn.WriteMessage(websocket.BinaryMessage, buf[:n])
}
if err != nil {
break
}
}
close(doneChan)
}()
// WebSocket -> Shell:将前端输入写入 PTY(包括 sudo 密码、Ctrl+C 等)
conn.SetReadLimit(64 * 1024)
_ = conn.SetReadDeadline(time.Now().Add(terminalTimeout))
conn.SetPongHandler(func(string) error {
_ = conn.SetReadDeadline(time.Now().Add(terminalTimeout))
return nil
})
for {
msgType, data, err := conn.ReadMessage()
if err != nil {
_ = cmd.Process.Kill()
break
}
if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {
continue
}
if len(data) == 0 {
continue
}
if _, err := ptmx.Write(data); err != nil {
_ = cmd.Process.Kill()
break
}
}
<-doneChan
}
+149 -31
View File
@@ -6,39 +6,75 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/openai" "cyberstrike-ai/internal/openai"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/time/rate"
) )
// Embedder 文本嵌入器 // Embedder 文本嵌入器
type Embedder struct { type Embedder struct {
openAIClient *openai.Client openAIClient *openai.Client
config *config.KnowledgeConfig config *config.KnowledgeConfig
openAIConfig *config.OpenAIConfig // 用于获取API Key openAIConfig *config.OpenAIConfig // 用于获取 API Key
logger *zap.Logger logger *zap.Logger
rateLimiter *rate.Limiter // 速率限制器
rateLimitDelay time.Duration // 请求间隔时间
maxRetries int // 最大重试次数
retryDelay time.Duration // 重试间隔
mu sync.Mutex // 保护 rateLimiter
} }
// NewEmbedder 创建新的嵌入器 // NewEmbedder 创建新的嵌入器
func NewEmbedder(cfg *config.KnowledgeConfig, openAIConfig *config.OpenAIConfig, openAIClient *openai.Client, logger *zap.Logger) *Embedder { func NewEmbedder(cfg *config.KnowledgeConfig, openAIConfig *config.OpenAIConfig, openAIClient *openai.Client, logger *zap.Logger) *Embedder {
// 初始化速率限制器
var rateLimiter *rate.Limiter
var rateLimitDelay time.Duration
// 如果配置了 MaxRPM,根据 RPM 计算速率限制
if cfg.Indexing.MaxRPM > 0 {
rpm := cfg.Indexing.MaxRPM
rateLimiter = rate.NewLimiter(rate.Every(time.Minute/time.Duration(rpm)), rpm)
logger.Info("知识库索引速率限制已启用", zap.Int("maxRPM", rpm))
} else if cfg.Indexing.RateLimitDelayMs > 0 {
// 如果没有配置 MaxRPM 但配置了固定延迟,使用固定延迟模式
rateLimitDelay = time.Duration(cfg.Indexing.RateLimitDelayMs) * time.Millisecond
logger.Info("知识库索引固定延迟已启用", zap.Duration("delay", rateLimitDelay))
}
// 重试配置
maxRetries := 3
retryDelay := 1000 * time.Millisecond
if cfg.Indexing.MaxRetries > 0 {
maxRetries = cfg.Indexing.MaxRetries
}
if cfg.Indexing.RetryDelayMs > 0 {
retryDelay = time.Duration(cfg.Indexing.RetryDelayMs) * time.Millisecond
}
return &Embedder{ return &Embedder{
openAIClient: openAIClient, openAIClient: openAIClient,
config: cfg, config: cfg,
openAIConfig: openAIConfig, openAIConfig: openAIConfig,
logger: logger, logger: logger,
rateLimiter: rateLimiter,
rateLimitDelay: rateLimitDelay,
maxRetries: maxRetries,
retryDelay: retryDelay,
} }
} }
// EmbeddingRequest OpenAI嵌入请求 // EmbeddingRequest OpenAI 嵌入请求
type EmbeddingRequest struct { type EmbeddingRequest struct {
Model string `json:"model"` Model string `json:"model"`
Input []string `json:"input"` Input []string `json:"input"`
} }
// EmbeddingResponse OpenAI嵌入响应 // EmbeddingResponse OpenAI 嵌入响应
type EmbeddingResponse struct { type EmbeddingResponse struct {
Data []EmbeddingData `json:"data"` Data []EmbeddingData `json:"data"`
Error *EmbeddingError `json:"error,omitempty"` Error *EmbeddingError `json:"error,omitempty"`
@@ -56,12 +92,69 @@ type EmbeddingError struct {
Type string `json:"type"` Type string `json:"type"`
} }
// EmbedText 对文本进行嵌入 // waitRateLimiter 等待速率限制器
func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error) { func (e *Embedder) waitRateLimiter() {
if e.openAIClient == nil { e.mu.Lock()
return nil, fmt.Errorf("OpenAI客户端未初始化") defer e.mu.Unlock()
if e.rateLimiter != nil {
// 等待令牌
ctx := context.Background()
if err := e.rateLimiter.Wait(ctx); err != nil {
e.logger.Warn("速率限制器等待失败", zap.Error(err))
}
} }
if e.rateLimitDelay > 0 {
time.Sleep(e.rateLimitDelay)
}
}
// EmbedText 对文本进行嵌入(带重试和速率限制)
func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error) {
if e.openAIClient == nil {
return nil, fmt.Errorf("OpenAI 客户端未初始化")
}
var lastErr error
for attempt := 0; attempt < e.maxRetries; attempt++ {
// 速率限制
if attempt > 0 {
// 重试时等待更长时间
waitTime := e.retryDelay * time.Duration(attempt)
e.logger.Debug("重试前等待", zap.Int("attempt", attempt+1), zap.Duration("waitTime", waitTime))
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(waitTime):
}
} else {
e.waitRateLimiter()
}
result, err := e.doEmbedText(ctx, text)
if err == nil {
return result, nil
}
lastErr = err
// 检查是否是可重试的错误(429 速率限制、5xx 服务器错误、网络错误)
if !e.isRetryableError(err) {
return nil, err
}
e.logger.Debug("嵌入请求失败,准备重试",
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", e.maxRetries),
zap.Error(err))
}
return nil, fmt.Errorf("达到最大重试次数 (%d): %v", e.maxRetries, lastErr)
}
// doEmbedText 执行实际的嵌入请求(内部方法)
func (e *Embedder) doEmbedText(ctx context.Context, text string) ([]float32, error) {
// 使用配置的嵌入模型 // 使用配置的嵌入模型
model := e.config.Embedding.Model model := e.config.Embedding.Model
if model == "" { if model == "" {
@@ -73,7 +166,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
Input: []string{text}, Input: []string{text},
} }
// 清理baseURL:去除前后空格和尾部斜杠 // 清理 baseURL:去除前后空格和尾部斜杠
baseURL := strings.TrimSpace(e.config.Embedding.BaseURL) baseURL := strings.TrimSpace(e.config.Embedding.BaseURL)
baseURL = strings.TrimSuffix(baseURL, "/") baseURL = strings.TrimSuffix(baseURL, "/")
if baseURL == "" { if baseURL == "" {
@@ -83,24 +176,24 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
// 构建请求 // 构建请求
body, err := json.Marshal(req) body, err := json.Marshal(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err) return nil, fmt.Errorf("序列化请求失败%w", err)
} }
requestURL := baseURL + "/embeddings" requestURL := baseURL + "/embeddings"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, strings.NewReader(string(body))) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, strings.NewReader(string(body)))
if err != nil { if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err) return nil, fmt.Errorf("创建请求失败%w", err)
} }
httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Content-Type", "application/json")
// 使用配置的API Key,如果没有则使用OpenAI配置的 // 使用配置的 API Key,如果没有则使用 OpenAI 配置的
apiKey := strings.TrimSpace(e.config.Embedding.APIKey) apiKey := strings.TrimSpace(e.config.Embedding.APIKey)
if apiKey == "" && e.openAIConfig != nil { if apiKey == "" && e.openAIConfig != nil {
apiKey = e.openAIConfig.APIKey apiKey = e.openAIConfig.APIKey
} }
if apiKey == "" { if apiKey == "" {
return nil, fmt.Errorf("API Key未配置") return nil, fmt.Errorf("API Key 未配置")
} }
httpReq.Header.Set("Authorization", "Bearer "+apiKey) httpReq.Header.Set("Authorization", "Bearer "+apiKey)
@@ -110,7 +203,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
} }
resp, err := httpClient.Do(httpReq) resp, err := httpClient.Do(httpReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("发送请求失败: %w", err) return nil, fmt.Errorf("发送请求失败%w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -132,7 +225,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
if len(requestBodyPreview) > 200 { if len(requestBodyPreview) > 200 {
requestBodyPreview = requestBodyPreview[:200] + "..." requestBodyPreview = requestBodyPreview[:200] + "..."
} }
e.logger.Debug("嵌入API请求", e.logger.Debug("嵌入 API 请求",
zap.String("url", httpReq.URL.String()), zap.String("url", httpReq.URL.String()),
zap.String("model", model), zap.String("model", model),
zap.String("requestBody", requestBodyPreview), zap.String("requestBody", requestBodyPreview),
@@ -148,12 +241,12 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
if len(bodyPreview) > 500 { if len(bodyPreview) > 500 {
bodyPreview = bodyPreview[:500] + "..." bodyPreview = bodyPreview[:500] + "..."
} }
return nil, fmt.Errorf("解析响应失败 (URL: %s, 状态码: %d, 响应长度: %d字节): %w\n请求体: %s\n响应内容预览: %s", return nil, fmt.Errorf("解析响应失败 (URL: %s, 状态码%d, 响应长度%d字节): %w\n请求体%s\n响应内容预览%s",
requestURL, resp.StatusCode, len(bodyBytes), err, requestBodyPreview, bodyPreview) requestURL, resp.StatusCode, len(bodyBytes), err, requestBodyPreview, bodyPreview)
} }
if embeddingResp.Error != nil { if embeddingResp.Error != nil {
return nil, fmt.Errorf("OpenAI API错误 (状态码: %d): 类型=%s, 消息=%s", return nil, fmt.Errorf("OpenAI API 错误 (状态码%d): 类型=%s, 消息=%s",
resp.StatusCode, embeddingResp.Error.Type, embeddingResp.Error.Message) resp.StatusCode, embeddingResp.Error.Type, embeddingResp.Error.Message)
} }
@@ -162,7 +255,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
if len(bodyPreview) > 500 { if len(bodyPreview) > 500 {
bodyPreview = bodyPreview[:500] + "..." bodyPreview = bodyPreview[:500] + "..."
} }
return nil, fmt.Errorf("HTTP请求失败 (URL: %s, 状态码: %d): 响应内容=%s", requestURL, resp.StatusCode, bodyPreview) return nil, fmt.Errorf("HTTP 请求失败 (URL: %s, 状态码%d): 响应内容=%s", requestURL, resp.StatusCode, bodyPreview)
} }
if len(embeddingResp.Data) == 0 { if len(embeddingResp.Data) == 0 {
@@ -170,11 +263,11 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
if len(bodyPreview) > 500 { if len(bodyPreview) > 500 {
bodyPreview = bodyPreview[:500] + "..." bodyPreview = bodyPreview[:500] + "..."
} }
return nil, fmt.Errorf("未收到嵌入数据 (状态码: %d, 响应长度: %d字节)\n响应内容: %s", return nil, fmt.Errorf("未收到嵌入数据 (状态码%d, 响应长度%d字节)\n响应内容%s",
resp.StatusCode, len(bodyBytes), bodyPreview) resp.StatusCode, len(bodyBytes), bodyPreview)
} }
// 转换为float32 // 转换为 float32
embedding := make([]float32, len(embeddingResp.Data[0].Embedding)) embedding := make([]float32, len(embeddingResp.Data[0].Embedding))
for i, v := range embeddingResp.Data[0].Embedding { for i, v := range embeddingResp.Data[0].Embedding {
embedding[i] = float32(v) embedding[i] = float32(v)
@@ -183,23 +276,48 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
return embedding, nil return embedding, nil
} }
// isRetryableError 判断是否是可重试的错误
func (e *Embedder) isRetryableError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
// 429 速率限制错误
if strings.Contains(errStr, "429") || strings.Contains(errStr, "rate limit") {
return true
}
// 5xx 服务器错误
if strings.Contains(errStr, "500") || strings.Contains(errStr, "502") ||
strings.Contains(errStr, "503") || strings.Contains(errStr, "504") {
return true
}
// 网络错误
if strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection") ||
strings.Contains(errStr, "network") || strings.Contains(errStr, "EOF") {
return true
}
return false
}
// EmbedTexts 批量嵌入文本 // EmbedTexts 批量嵌入文本
func (e *Embedder) EmbedTexts(ctx context.Context, texts []string) ([][]float32, error) { func (e *Embedder) EmbedTexts(ctx context.Context, texts []string) ([][]float32, error) {
if len(texts) == 0 { if len(texts) == 0 {
return nil, nil return nil, nil
} }
// OpenAI API支持批量,但为了简单起见,我们逐个处理
// 实际可以使用批量API以提高效率
embeddings := make([][]float32, len(texts)) embeddings := make([][]float32, len(texts))
for i, text := range texts { for i, text := range texts {
embedding, err := e.EmbedText(ctx, text) embedding, err := e.EmbedText(ctx, text)
if err != nil { if err != nil {
return nil, fmt.Errorf("嵌入文本[%d]失败: %w", i, err) return nil, fmt.Errorf("嵌入文本 [%d] 失败%w", i, err)
} }
embeddings[i] = embedding embeddings[i] = embedding
} }
return embeddings, nil return embeddings, nil
} }
+382 -98
View File
@@ -10,56 +10,133 @@ import (
"sync" "sync"
"time" "time"
"cyberstrike-ai/internal/config"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
) )
// Indexer 索引器,负责将知识项分块并向量化 // Indexer 索引器,负责将知识项分块并向量化
type Indexer struct { type Indexer struct {
db *sql.DB db *sql.DB
embedder *Embedder embedder *Embedder
logger *zap.Logger logger *zap.Logger
chunkSize int // 每个块的最大token数(估算) chunkSize int // 每个块的最大 token 数(估算)
overlap int // 块之间的重叠token数 overlap int // 块之间的重叠 token
maxChunks int // 单个知识项的最大块数量(0 表示不限制)
// 错误跟踪 // 错误跟踪
mu sync.RWMutex mu sync.RWMutex
lastError string // 最近一次错误信息 lastError string // 最近一次错误信息
lastErrorTime time.Time // 最近一次错误时间 lastErrorTime time.Time // 最近一次错误时间
errorCount int // 连续错误计数 errorCount int // 连续错误计数
// 重建索引状态跟踪
rebuildMu sync.RWMutex
isRebuilding bool // 是否正在重建索引
rebuildTotalItems int // 重建总项数
rebuildCurrent int // 当前已处理项数
rebuildFailed int // 重建失败项数
rebuildStartTime time.Time // 重建开始时间
rebuildLastItemID string // 最近处理的项 ID
rebuildLastChunks int // 最近处理的项的分块数
} }
// NewIndexer 创建新的索引器 // NewIndexer 创建新的索引器
func NewIndexer(db *sql.DB, embedder *Embedder, logger *zap.Logger) *Indexer { func NewIndexer(db *sql.DB, embedder *Embedder, logger *zap.Logger, indexingCfg *config.IndexingConfig) *Indexer {
chunkSize := 512
overlap := 50
maxChunks := 0
if indexingCfg != nil {
if indexingCfg.ChunkSize > 0 {
chunkSize = indexingCfg.ChunkSize
}
if indexingCfg.ChunkOverlap >= 0 {
overlap = indexingCfg.ChunkOverlap
}
if indexingCfg.MaxChunksPerItem > 0 {
maxChunks = indexingCfg.MaxChunksPerItem
}
}
return &Indexer{ return &Indexer{
db: db, db: db,
embedder: embedder, embedder: embedder,
logger: logger, logger: logger,
chunkSize: 512, // 默认512 tokens chunkSize: chunkSize,
overlap: 50, // 默认50 tokens重叠 overlap: overlap,
maxChunks: maxChunks,
} }
} }
// ChunkText 将文本分块(支持重叠) // ChunkText 将文本分块(支持重叠,保留标题上下文
func (idx *Indexer) ChunkText(text string) []string { func (idx *Indexer) ChunkText(text string) []string {
// 按Markdown标题分割 // 按 Markdown 标题分割,获取带标题的块
chunks := idx.splitByMarkdownHeaders(text) sections := idx.splitByMarkdownHeadersWithContent(text)
// 如果块太大,进一步分割 // 处理每个块
result := make([]string, 0) result := make([]string, 0)
for _, chunk := range chunks { for _, section := range sections {
if idx.estimateTokens(chunk) <= idx.chunkSize { // 构建父级标题路径(不包含最后一级标题,因为内容中已经包含)
result = append(result, chunk) // 例如:["# A", "## B", "### C"] -> "[# A > ## B]"
var parentHeaderPath string
if len(section.HeaderPath) > 1 {
parentHeaderPath = strings.Join(section.HeaderPath[:len(section.HeaderPath)-1], " > ")
}
// 提取内容的第一行作为标题(如 "# Prompt Injection"
firstLine, remainingContent := extractFirstLine(section.Content)
// 如果剩余内容为空或只有空白,说明这个块只有标题没有正文,跳过
if strings.TrimSpace(remainingContent) == "" {
continue
}
// 如果块太大,进一步分割
if idx.estimateTokens(section.Content) <= idx.chunkSize {
// 块大小合适,添加父级标题前缀
if parentHeaderPath != "" {
result = append(result, fmt.Sprintf("[%s] %s", parentHeaderPath, section.Content))
} else {
result = append(result, section.Content)
}
} else { } else {
// 按段落分割 // 块太大,按子标题或段落分割,保持标题上下文
subChunks := idx.splitByParagraphs(chunk) // 首先尝试按子标题分割(保留子标题结构)
for _, subChunk := range subChunks { subSections := idx.splitBySubHeaders(section.Content, firstLine, parentHeaderPath)
if idx.estimateTokens(subChunk) <= idx.chunkSize { if len(subSections) > 1 {
result = append(result, subChunk) // 成功按子标题分割,递归处理每个子块
} else { for _, sub := range subSections {
// 按句子分割(支持重叠) if idx.estimateTokens(sub) <= idx.chunkSize {
chunksWithOverlap := idx.splitBySentencesWithOverlap(subChunk) result = append(result, sub)
result = append(result, chunksWithOverlap...) } else {
// 子块仍然太大,按段落分割(保留标题前缀)
paragraphs := idx.splitByParagraphsWithHeader(sub, parentHeaderPath)
for _, para := range paragraphs {
if idx.estimateTokens(para) <= idx.chunkSize {
result = append(result, para)
} else {
// 段落仍太大,按句子分割
sentenceChunks := idx.splitBySentencesWithOverlap(para)
for _, chunk := range sentenceChunks {
result = append(result, chunk)
}
}
}
}
}
} else {
// 没有子标题,按段落分割(保留标题前缀)
paragraphs := idx.splitByParagraphsWithHeader(section.Content, parentHeaderPath)
for _, para := range paragraphs {
if idx.estimateTokens(para) <= idx.chunkSize {
result = append(result, para)
} else {
// 段落仍太大,按句子分割
sentenceChunks := idx.splitBySentencesWithOverlap(para)
for _, chunk := range sentenceChunks {
result = append(result, chunk)
}
}
} }
} }
} }
@@ -68,43 +145,183 @@ func (idx *Indexer) ChunkText(text string) []string {
return result return result
} }
// splitByMarkdownHeaders 按Markdown标题分割 // extractFirstLine 提取第一行内容和剩余内容
func (idx *Indexer) splitByMarkdownHeaders(text string) []string { func extractFirstLine(content string) (firstLine, remaining string) {
// 匹配Markdown标题 (# ## ### 等) lines := strings.SplitN(content, "\n", 2)
if len(lines) == 0 {
return "", ""
}
if len(lines) == 1 {
return lines[0], ""
}
return lines[0], lines[1]
}
// splitBySubHeaders 尝试按子标题分割内容(用于处理大块内容)
// headerPrefix 是父级标题路径,用于添加到每个子块
func (idx *Indexer) splitBySubHeaders(content, headerPrefix, parentPath string) []string {
// 匹配 Markdown 子标题(## 及以上)
subHeaderRegex := regexp.MustCompile(`(?m)^#{2,6}\s+.+$`)
matches := subHeaderRegex.FindAllStringIndex(content, -1)
if len(matches) == 0 {
// 没有子标题,返回原始内容
return []string{content}
}
result := make([]string, 0, len(matches))
for i, match := range matches {
start := match[0]
nextStart := len(content)
if i+1 < len(matches) {
nextStart = matches[i+1][0]
}
subContent := strings.TrimSpace(content[start:nextStart])
// 添加父级路径前缀
if parentPath != "" {
result = append(result, fmt.Sprintf("[%s] %s", parentPath, subContent))
} else {
result = append(result, subContent)
}
}
return result
}
// splitByParagraphsWithHeader 按段落分割,每个段落添加标题前缀(用于保持上下文)
func (idx *Indexer) splitByParagraphsWithHeader(content, parentPath string) []string {
// 提取第一行作为标题
firstLine, _ := extractFirstLine(content)
paragraphs := strings.Split(content, "\n\n")
result := make([]string, 0)
for i, p := range paragraphs {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
continue
}
// 过滤掉只有标题的段落(没有实际内容)
if strings.TrimSpace(trimmed) == strings.TrimSpace(firstLine) {
continue
}
// 第一个段落已经包含标题,不需要重复添加
if i == 0 && strings.Contains(trimmed, firstLine) {
if parentPath != "" {
result = append(result, fmt.Sprintf("[%s] %s", parentPath, trimmed))
} else {
result = append(result, trimmed)
}
} else {
// 其他段落添加标题前缀以保持上下文
if parentPath != "" {
result = append(result, fmt.Sprintf("[%s] %s\n%s", parentPath, firstLine, trimmed))
} else {
result = append(result, fmt.Sprintf("%s\n%s", firstLine, trimmed))
}
}
}
return result
}
// Section 表示一个带标题路径的文本块
type Section struct {
HeaderPath []string // 标题路径(如 ["# SQL 注入", "## 检测方法"]
Content string // 块内容
}
// splitByMarkdownHeadersWithContent 按 Markdown 标题分割,返回带标题路径的块
// 每个块的内容包含自己的标题,用于向量化检索
//
// 例如,对于以下 Markdown:
// # Prompt Injection
// 引言内容
// ## Summary
// 目录内容
//
// 返回:
// [{HeaderPath: ["# Prompt Injection"], Content: "# Prompt Injection\n引言内容"},
// {HeaderPath: ["# Prompt Injection", "## Summary"], Content: "## Summary\n目录内容"}]
func (idx *Indexer) splitByMarkdownHeadersWithContent(text string) []Section {
// 匹配 Markdown 标题 (# ## ### 等)
headerRegex := regexp.MustCompile(`(?m)^#{1,6}\s+.+$`) headerRegex := regexp.MustCompile(`(?m)^#{1,6}\s+.+$`)
// 找到所有标题位置 // 找到所有标题位置
matches := headerRegex.FindAllStringIndex(text, -1) matches := headerRegex.FindAllStringIndex(text, -1)
if len(matches) == 0 { if len(matches) == 0 {
return []string{text} // 没有标题,返回整个文本
return []Section{{HeaderPath: []string{}, Content: text}}
} }
chunks := make([]string, 0) sections := make([]Section, 0, len(matches))
lastPos := 0 currentHeaderPath := []string{}
for _, match := range matches { for i, match := range matches {
start := match[0] start := match[0]
if start > lastPos { end := match[1]
chunks = append(chunks, strings.TrimSpace(text[lastPos:start])) nextStart := len(text)
}
lastPos = start
}
// 添加最后一部分 // 找到下一个标题的位置
if lastPos < len(text) { if i+1 < len(matches) {
chunks = append(chunks, strings.TrimSpace(text[lastPos:])) nextStart = matches[i+1][0]
}
// 提取当前标题
headerLine := strings.TrimSpace(text[start:end])
// 计算标题层级(# 的数量)
level := 0
for _, ch := range headerLine {
if ch == '#' {
level++
} else {
break
}
}
// 更新标题路径:移除比当前层级深或等于的子标题,然后添加当前标题
newPath := make([]string, 0, len(currentHeaderPath)+1)
for _, h := range currentHeaderPath {
hLevel := 0
for _, ch := range h {
if ch == '#' {
hLevel++
} else {
break
}
}
if hLevel < level {
newPath = append(newPath, h)
}
}
newPath = append(newPath, headerLine)
currentHeaderPath = newPath
// 提取当前标题到下一个标题之间的内容(包含当前标题)
content := strings.TrimSpace(text[start:nextStart])
// 创建块,使用当前标题路径(包含当前标题)
sections = append(sections, Section{
HeaderPath: append([]string(nil), currentHeaderPath...),
Content: content,
})
} }
// 过滤空块 // 过滤空块
result := make([]string, 0) result := make([]Section, 0, len(sections))
for _, chunk := range chunks { for _, section := range sections {
if strings.TrimSpace(chunk) != "" { if strings.TrimSpace(section.Content) != "" {
result = append(result, chunk) result = append(result, section)
} }
} }
if len(result) == 0 { if len(result) == 0 {
return []string{text} return []Section{{HeaderPath: []string{}, Content: text}}
} }
return result return result
@@ -124,8 +341,12 @@ func (idx *Indexer) splitByParagraphs(text string) []string {
// splitBySentences 按句子分割(用于内部,不包含重叠逻辑) // splitBySentences 按句子分割(用于内部,不包含重叠逻辑)
func (idx *Indexer) splitBySentences(text string) []string { func (idx *Indexer) splitBySentences(text string) []string {
// 简单的句子分割(按句号、问号、感叹号) // 简单的句子分割(按句号、问号、感叹号,支持中英文
sentenceRegex := regexp.MustCompile(`[.!?]+\s+`) // . ! ? = 英文标点
// \u3002 = 。(中文句号)
// \uFF01 = (中文叹号)
// \uFF1F = (中文问号)
sentenceRegex := regexp.MustCompile(`[.!?\x{3002}\x{FF01}\x{FF1F}]+`)
sentences := sentenceRegex.Split(text, -1) sentences := sentenceRegex.Split(text, -1)
result := make([]string, 0) result := make([]string, 0)
for _, s := range sentences { for _, s := range sentences {
@@ -221,13 +442,13 @@ func (idx *Indexer) splitBySentencesSimple(text string) []string {
return result return result
} }
// extractLastTokens 从文本末尾提取指定token数量的内容 // extractLastTokens 从文本末尾提取指定 token 数量的内容
func (idx *Indexer) extractLastTokens(text string, tokenCount int) string { func (idx *Indexer) extractLastTokens(text string, tokenCount int) string {
if tokenCount <= 0 || text == "" { if tokenCount <= 0 || text == "" {
return "" return ""
} }
// 估算字符数(1 token ≈ 4字符) // 估算字符数(1 token ≈ 4 字符)
charCount := tokenCount * 4 charCount := tokenCount * 4
runes := []rune(text) runes := []rune(text)
@@ -236,12 +457,11 @@ func (idx *Indexer) extractLastTokens(text string, tokenCount int) string {
} }
// 从末尾提取指定数量的字符 // 从末尾提取指定数量的字符
// 尝试在句子边界处截断,避免截断句子中间
startPos := len(runes) - charCount startPos := len(runes) - charCount
extracted := string(runes[startPos:]) extracted := string(runes[startPos:])
// 尝试找到第一个句子边界(句号、问号、感叹号后的空格 // 尝试找到第一个句子边界(支持中英文标点
sentenceBoundary := regexp.MustCompile(`[.!?]+\s+`) sentenceBoundary := regexp.MustCompile(`[.!?\x{3002}\x{FF01}\x{FF1F}]+`)
matches := sentenceBoundary.FindStringIndex(extracted) matches := sentenceBoundary.FindStringIndex(extracted)
if len(matches) > 0 && matches[0] > 0 { if len(matches) > 0 && matches[0] > 0 {
// 在句子边界处截断,保留完整句子 // 在句子边界处截断,保留完整句子
@@ -251,41 +471,51 @@ func (idx *Indexer) extractLastTokens(text string, tokenCount int) string {
return strings.TrimSpace(extracted) return strings.TrimSpace(extracted)
} }
// estimateTokens 估算token数(简单估算:1 token ≈ 4字符) // estimateTokens 估算 token 数(简单估算:1 token ≈ 4 字符)
func (idx *Indexer) estimateTokens(text string) int { func (idx *Indexer) estimateTokens(text string) int {
return len([]rune(text)) / 4 return len([]rune(text)) / 4
} }
// IndexItem 索引知识项(分块并向量化) // IndexItem 索引知识项(分块并向量化)
func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error { func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error {
// 获取知识项(包含categorytitle,用于向量化) // 获取知识项(包含 categorytitle,用于向量化)
var content, category, title string var content, category, title string
err := idx.db.QueryRow("SELECT content, category, title FROM knowledge_base_items WHERE id = ?", itemID).Scan(&content, &category, &title) err := idx.db.QueryRow("SELECT content, category, title FROM knowledge_base_items WHERE id = ?", itemID).Scan(&content, &category, &title)
if err != nil { if err != nil {
return fmt.Errorf("获取知识项失败: %w", err) return fmt.Errorf("获取知识项失败%w", err)
} }
// 删除旧的向量(在 RebuildIndex 中已经统一清空,这里保留是为了单独调用 IndexItem 时的兼容性) // 删除旧的向量(在 RebuildIndex 中已经统一清空,这里保留是为了单独调用 IndexItem 时的兼容性)
_, err = idx.db.Exec("DELETE FROM knowledge_embeddings WHERE item_id = ?", itemID) _, err = idx.db.Exec("DELETE FROM knowledge_embeddings WHERE item_id = ?", itemID)
if err != nil { if err != nil {
return fmt.Errorf("删除旧向量失败: %w", err) return fmt.Errorf("删除旧向量失败%w", err)
} }
// 分块 // 分块
chunks := idx.ChunkText(content) chunks := idx.ChunkText(content)
// 应用最大块数限制
if idx.maxChunks > 0 && len(chunks) > idx.maxChunks {
idx.logger.Info("知识项块数量超过限制,已截断",
zap.String("itemId", itemID),
zap.Int("originalChunks", len(chunks)),
zap.Int("maxChunks", idx.maxChunks))
chunks = chunks[:idx.maxChunks]
}
idx.logger.Info("知识项分块完成", zap.String("itemId", itemID), zap.Int("chunks", len(chunks))) idx.logger.Info("知识项分块完成", zap.String("itemId", itemID), zap.Int("chunks", len(chunks)))
// 跟踪该知识项的错误 // 跟踪该知识项的错误
itemErrorCount := 0 itemErrorCount := 0
var firstError error var firstError error
firstErrorChunkIndex := -1 firstErrorChunkIndex := -1
// 向量化每个块(包含categorytitle信息,以便向量检索时能匹配到风险类型) // 向量化每个块(包含 categorytitle 信息,以便向量检索时能匹配到风险类型)
for i, chunk := range chunks { for i, chunk := range chunks {
// 将categorytitle信息包含到向量化的文本中 // 将 categorytitle 信息包含到向量化的文本中
// 格式:"[风险类型: {category}] [标题: {title}]\n{chunk内容}" // 格式:"[风险类型{category}] [标题{title}]\n{chunk 内容}"
// 这样向量嵌入就会包含风险类型信息,即使SQL过滤失败,向量相似度也能帮助匹配 // 这样向量嵌入就会包含风险类型信息,即使 SQL 过滤失败,向量相似度也能帮助匹配
textForEmbedding := fmt.Sprintf("[风险类型: %s] [标题: %s]\n%s", category, title, chunk) textForEmbedding := fmt.Sprintf("[风险类型%s] [标题%s]\n%s", category, title, chunk)
embedding, err := idx.embedder.EmbedText(ctx, textForEmbedding) embedding, err := idx.embedder.EmbedText(ctx, textForEmbedding)
if err != nil { if err != nil {
@@ -305,18 +535,30 @@ func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error {
zap.String("chunkPreview", chunkPreview), zap.String("chunkPreview", chunkPreview),
zap.Error(err), zap.Error(err),
) )
// 更新全局错误跟踪 // 更新全局错误跟踪
errorMsg := fmt.Sprintf("向量化失败 (知识项: %s): %v", itemID, err) errorMsg := fmt.Sprintf("向量化失败 (知识项%s): %v", itemID, err)
idx.mu.Lock() idx.mu.Lock()
idx.lastError = errorMsg idx.lastError = errorMsg
idx.lastErrorTime = time.Now() idx.lastErrorTime = time.Now()
idx.mu.Unlock() idx.mu.Unlock()
} }
// 如果连续失败2个块,立即停止处理该知识项(降低阈值,更快停止) // 如果连续失败 5 个块,立即停止处理该知识项
// 这样可以避免继续浪费API调用,同时也能更快地检测到配置问题 // 这样可以避免继续浪费 API 调用,同时也能更快地检测到配置问题
if itemErrorCount >= 2 { // 对于大文档(超过 10 个块),允许失败比例不超过 50%
maxConsecutiveFailures := 5
if len(chunks) > 10 && itemErrorCount > len(chunks)/2 {
idx.logger.Error("知识项向量化失败比例过高,停止处理",
zap.String("itemId", itemID),
zap.Int("totalChunks", len(chunks)),
zap.Int("failedChunks", itemErrorCount),
zap.Int("firstErrorChunkIndex", firstErrorChunkIndex),
zap.Error(firstError),
)
return fmt.Errorf("知识项向量化失败比例过高 (%d/%d个块失败): %v", itemErrorCount, len(chunks), firstError)
}
if itemErrorCount >= maxConsecutiveFailures {
idx.logger.Error("知识项连续向量化失败,停止处理", idx.logger.Error("知识项连续向量化失败,停止处理",
zap.String("itemId", itemID), zap.String("itemId", itemID),
zap.Int("totalChunks", len(chunks)), zap.Int("totalChunks", len(chunks)),
@@ -344,6 +586,13 @@ func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error {
} }
idx.logger.Info("知识项索引完成", zap.String("itemId", itemID), zap.Int("chunks", len(chunks))) idx.logger.Info("知识项索引完成", zap.String("itemId", itemID), zap.Int("chunks", len(chunks)))
// 更新重建状态中的最近处理信息
idx.rebuildMu.Lock()
idx.rebuildLastItemID = itemID
idx.rebuildLastChunks = len(chunks)
idx.rebuildMu.Unlock()
return nil return nil
} }
@@ -352,23 +601,38 @@ func (idx *Indexer) HasIndex() (bool, error) {
var count int var count int
err := idx.db.QueryRow("SELECT COUNT(*) FROM knowledge_embeddings").Scan(&count) err := idx.db.QueryRow("SELECT COUNT(*) FROM knowledge_embeddings").Scan(&count)
if err != nil { if err != nil {
return false, fmt.Errorf("检查索引失败: %w", err) return false, fmt.Errorf("检查索引失败%w", err)
} }
return count > 0, nil return count > 0, nil
} }
// RebuildIndex 重建所有索引 // RebuildIndex 重建所有索引
func (idx *Indexer) RebuildIndex(ctx context.Context) error { func (idx *Indexer) RebuildIndex(ctx context.Context) error {
// 设置重建状态
idx.rebuildMu.Lock()
idx.isRebuilding = true
idx.rebuildTotalItems = 0
idx.rebuildCurrent = 0
idx.rebuildFailed = 0
idx.rebuildStartTime = time.Now()
idx.rebuildLastItemID = ""
idx.rebuildLastChunks = 0
idx.rebuildMu.Unlock()
// 重置错误跟踪 // 重置错误跟踪
idx.mu.Lock() idx.mu.Lock()
idx.lastError = "" idx.lastError = ""
idx.lastErrorTime = time.Time{} idx.lastErrorTime = time.Time{}
idx.errorCount = 0 idx.errorCount = 0
idx.mu.Unlock() idx.mu.Unlock()
rows, err := idx.db.Query("SELECT id FROM knowledge_base_items") rows, err := idx.db.Query("SELECT id FROM knowledge_base_items")
if err != nil { if err != nil {
return fmt.Errorf("查询知识项失败: %w", err) // 重置重建状态
idx.rebuildMu.Lock()
idx.isRebuilding = false
idx.rebuildMu.Unlock()
return fmt.Errorf("查询知识项失败:%w", err)
} }
defer rows.Close() defer rows.Close()
@@ -376,34 +640,36 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
for rows.Next() { for rows.Next() {
var id string var id string
if err := rows.Scan(&id); err != nil { if err := rows.Scan(&id); err != nil {
return fmt.Errorf("扫描知识项ID失败: %w", err) // 重置重建状态
idx.rebuildMu.Lock()
idx.isRebuilding = false
idx.rebuildMu.Unlock()
return fmt.Errorf("扫描知识项 ID 失败:%w", err)
} }
itemIDs = append(itemIDs, id) itemIDs = append(itemIDs, id)
} }
idx.rebuildMu.Lock()
idx.rebuildTotalItems = len(itemIDs)
idx.rebuildMu.Unlock()
idx.logger.Info("开始重建索引", zap.Int("totalItems", len(itemIDs))) idx.logger.Info("开始重建索引", zap.Int("totalItems", len(itemIDs)))
// 在开始重建前,先清空所有旧的向量,确保进度从0开始 // 注意:不再清空所有旧索引,而是按增量方式更新
// 这样 GetIndexStatus 可以准确反映重建进度 // 每个知识项在 IndexItem 中会先删除自己的旧向量,然后插入新向量
_, err = idx.db.Exec("DELETE FROM knowledge_embeddings") // 这样配置更新后只重新索引变化的知识项,保留其他知识项的索引
if err != nil {
idx.logger.Warn("清空旧索引失败", zap.Error(err))
// 继续执行,即使清空失败也尝试重建
} else {
idx.logger.Info("已清空旧索引,开始重建")
}
failedCount := 0 failedCount := 0
consecutiveFailures := 0 consecutiveFailures := 0
maxConsecutiveFailures := 2 // 连续失败2次后立即停止(降低阈值,更快停止 maxConsecutiveFailures := 5 // 连续失败 5 次后立即停止(允许偶尔的临时错误
firstFailureItemID := "" firstFailureItemID := ""
var firstFailureError error var firstFailureError error
for i, itemID := range itemIDs { for i, itemID := range itemIDs {
if err := idx.IndexItem(ctx, itemID); err != nil { if err := idx.IndexItem(ctx, itemID); err != nil {
failedCount++ failedCount++
consecutiveFailures++ consecutiveFailures++
// 只在第一个失败时记录详细日志 // 只在第一个失败时记录详细日志
if consecutiveFailures == 1 { if consecutiveFailures == 1 {
firstFailureItemID = itemID firstFailureItemID = itemID
@@ -414,15 +680,15 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
zap.Error(err), zap.Error(err),
) )
} }
// 如果连续失败过多,可能是配置问题,立即停止索引 // 如果连续失败过多,可能是配置问题,立即停止索引
if consecutiveFailures >= maxConsecutiveFailures { if consecutiveFailures >= maxConsecutiveFailures {
errorMsg := fmt.Sprintf("连续 %d 个知识项索引失败,可能存在配置问题(如嵌入模型配置错误、API密钥无效、余额不足等)。第一个失败项: %s, 错误: %v", consecutiveFailures, firstFailureItemID, firstFailureError) errorMsg := fmt.Sprintf("连续 %d 个知识项索引失败,可能存在配置问题(如嵌入模型配置错误、API 密钥无效、余额不足等)。第一个失败项%s, 错误%v", consecutiveFailures, firstFailureItemID, firstFailureError)
idx.mu.Lock() idx.mu.Lock()
idx.lastError = errorMsg idx.lastError = errorMsg
idx.lastErrorTime = time.Now() idx.lastErrorTime = time.Now()
idx.mu.Unlock() idx.mu.Unlock()
idx.logger.Error("连续索引失败次数过多,立即停止索引", idx.logger.Error("连续索引失败次数过多,立即停止索引",
zap.Int("consecutiveFailures", consecutiveFailures), zap.Int("consecutiveFailures", consecutiveFailures),
zap.Int("totalItems", len(itemIDs)), zap.Int("totalItems", len(itemIDs)),
@@ -430,17 +696,17 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
zap.String("firstFailureItemId", firstFailureItemID), zap.String("firstFailureItemId", firstFailureItemID),
zap.Error(firstFailureError), zap.Error(firstFailureError),
) )
return fmt.Errorf("连续索引失败次数过多: %v", firstFailureError) return fmt.Errorf("连续索引失败次数过多%v", firstFailureError)
} }
// 如果失败的知识项过多,记录警告但继续处理(降低阈值到30%) // 如果失败的知识项过多,记录警告但继续处理(降低阈值到 30%
if failedCount > len(itemIDs)*3/10 && failedCount == len(itemIDs)*3/10+1 { if failedCount > len(itemIDs)*3/10 && failedCount == len(itemIDs)*3/10+1 {
errorMsg := fmt.Sprintf("索引失败的知识项过多 (%d/%d),可能存在配置问题。第一个失败项: %s, 错误: %v", failedCount, len(itemIDs), firstFailureItemID, firstFailureError) errorMsg := fmt.Sprintf("索引失败的知识项过多 (%d/%d),可能存在配置问题。第一个失败项%s, 错误%v", failedCount, len(itemIDs), firstFailureItemID, firstFailureError)
idx.mu.Lock() idx.mu.Lock()
idx.lastError = errorMsg idx.lastError = errorMsg
idx.lastErrorTime = time.Now() idx.lastErrorTime = time.Now()
idx.mu.Unlock() idx.mu.Unlock()
idx.logger.Error("索引失败的知识项过多,可能存在配置问题", idx.logger.Error("索引失败的知识项过多,可能存在配置问题",
zap.Int("failedCount", failedCount), zap.Int("failedCount", failedCount),
zap.Int("totalItems", len(itemIDs)), zap.Int("totalItems", len(itemIDs)),
@@ -450,20 +716,31 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
} }
continue continue
} }
// 成功时重置连续失败计数和第一个失败信息 // 成功时重置连续失败计数和第一个失败信息
if consecutiveFailures > 0 { if consecutiveFailures > 0 {
consecutiveFailures = 0 consecutiveFailures = 0
firstFailureItemID = "" firstFailureItemID = ""
firstFailureError = nil firstFailureError = nil
} }
// 减少进度日志频率(每10个或每10%记录一次) // 更新重建进度
idx.rebuildMu.Lock()
idx.rebuildCurrent = i + 1
idx.rebuildFailed = failedCount
idx.rebuildMu.Unlock()
// 减少进度日志频率(每 10 个或每 10% 记录一次)
if (i+1)%10 == 0 || (len(itemIDs) > 0 && (i+1)*100/len(itemIDs)%10 == 0 && (i+1)*100/len(itemIDs) > 0) { if (i+1)%10 == 0 || (len(itemIDs) > 0 && (i+1)*100/len(itemIDs)%10 == 0 && (i+1)*100/len(itemIDs) > 0) {
idx.logger.Info("索引进度", zap.Int("current", i+1), zap.Int("total", len(itemIDs)), zap.Int("failed", failedCount)) idx.logger.Info("索引进度", zap.Int("current", i+1), zap.Int("total", len(itemIDs)), zap.Int("failed", failedCount))
} }
} }
// 重置重建状态
idx.rebuildMu.Lock()
idx.isRebuilding = false
idx.rebuildMu.Unlock()
idx.logger.Info("索引重建完成", zap.Int("totalItems", len(itemIDs)), zap.Int("failedCount", failedCount)) idx.logger.Info("索引重建完成", zap.Int("totalItems", len(itemIDs)), zap.Int("failedCount", failedCount))
return nil return nil
} }
@@ -474,3 +751,10 @@ func (idx *Indexer) GetLastError() (string, time.Time) {
defer idx.mu.RUnlock() defer idx.mu.RUnlock()
return idx.lastError, idx.lastErrorTime return idx.lastError, idx.lastErrorTime
} }
// GetRebuildStatus 获取重建索引状态
func (idx *Indexer) GetRebuildStatus() (isRebuilding bool, totalItems int, current int, failed int, lastItemID string, lastChunks int, startTime time.Time) {
idx.rebuildMu.RLock()
defer idx.rebuildMu.RUnlock()
return idx.isRebuilding, idx.rebuildTotalItems, idx.rebuildCurrent, idx.rebuildFailed, idx.rebuildLastItemID, idx.rebuildLastChunks, idx.rebuildStartTime
}
+37 -3
View File
@@ -153,6 +153,25 @@ func (m *Manager) GetCategories() ([]string, error) {
return categories, nil return categories, nil
} }
// GetStats 获取知识库统计信息
func (m *Manager) GetStats() (int, int, error) {
// 获取分类总数
categories, err := m.GetCategories()
if err != nil {
return 0, 0, fmt.Errorf("获取分类失败: %w", err)
}
totalCategories := len(categories)
// 获取知识项总数
var totalItems int
err = m.db.QueryRow("SELECT COUNT(*) FROM knowledge_base_items").Scan(&totalItems)
if err != nil {
return totalCategories, 0, fmt.Errorf("获取知识项总数失败: %w", err)
}
return totalCategories, totalItems, nil
}
// GetCategoriesWithItems 按分类分页获取知识项(每个分类包含其下的所有知识项) // GetCategoriesWithItems 按分类分页获取知识项(每个分类包含其下的所有知识项)
// limit: 每页分类数量(0表示不限制) // limit: 每页分类数量(0表示不限制)
// offset: 偏移量(按分类偏移) // offset: 偏移量(按分类偏移)
@@ -359,7 +378,7 @@ func (m *Manager) SearchItemsByKeyword(keyword string, category string) ([]*Know
// SQLite的LIKE不区分大小写,使用COLLATE NOCASE或LOWER()函数 // SQLite的LIKE不区分大小写,使用COLLATE NOCASE或LOWER()函数
// 使用%keyword%进行模糊匹配 // 使用%keyword%进行模糊匹配
searchPattern := "%" + keyword + "%" searchPattern := "%" + keyword + "%"
query = ` query = `
SELECT id, category, title, file_path, created_at, updated_at SELECT id, category, title, file_path, created_at, updated_at
FROM knowledge_base_items FROM knowledge_base_items
@@ -638,7 +657,7 @@ 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 isEmpty, _ := isEmptyDir(oldDir); isEmpty {
// 只有当目录不是知识库根目录时才删除(避免删除根目录) // 只有当目录不是知识库根目录时才删除(避免删除根目录)
if oldDir != m.basePath { if oldDir != m.basePath {
if err := os.Remove(oldDir); err != nil { if err := os.Remove(oldDir); err != nil {
@@ -693,7 +712,7 @@ func (m *Manager) DeleteItem(id string) error {
// 删除空目录(如果为空) // 删除空目录(如果为空)
dir := filepath.Dir(filePath) dir := filepath.Dir(filePath)
if entries, err := os.ReadDir(dir); err == nil && len(entries) == 0 { if isEmpty, _ := isEmptyDir(dir); isEmpty {
// 只有当目录不是知识库根目录时才删除(避免删除根目录) // 只有当目录不是知识库根目录时才删除(避免删除根目录)
if dir != m.basePath { if dir != m.basePath {
if err := os.Remove(dir); err != nil { if err := os.Remove(dir); err != nil {
@@ -705,6 +724,21 @@ func (m *Manager) DeleteItem(id string) error {
return nil return nil
} }
// isEmptyDir 检查目录是否为空(忽略隐藏文件和 . 开头的文件)
func isEmptyDir(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
for _, entry := range entries {
// 忽略隐藏文件(以 . 开头)
if !strings.HasPrefix(entry.Name(), ".") {
return false, nil
}
}
return true, nil
}
// LogRetrieval 记录检索日志 // LogRetrieval 记录检索日志
func (m *Manager) LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error { func (m *Manager) LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error {
id := uuid.New().String() id := uuid.New().String()
+52 -37
View File
@@ -69,8 +69,8 @@ func cosineSimilarity(a, b []float32) float64 {
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB)) return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
} }
// bm25Score 计算BM25分数(改进版,更接近标准BM25 // bm25Score 计算 BM25 分数(带缓存的改进版本
// 注意:这是单文档版本的BM25,缺少全局IDF,但比之前的简化版本更准确 // 注意:由于缺少全局文档统计,使用简化 IDF 计算
func (r *Retriever) bm25Score(query, text string) float64 { func (r *Retriever) bm25Score(query, text string) float64 {
queryTerms := strings.Fields(strings.ToLower(query)) queryTerms := strings.Fields(strings.ToLower(query))
if len(queryTerms) == 0 { if len(queryTerms) == 0 {
@@ -83,44 +83,56 @@ func (r *Retriever) bm25Score(query, text string) float64 {
return 0.0 return 0.0
} }
// BM25参数 // BM25 参数(标准值)
k1 := 1.5 // 词频饱和度参数 k1 := 1.2 // 词频饱和度参数(标准范围 1.2-2.0
b := 0.75 // 长度归一化参数 b := 0.75 // 长度归一化参数(标准值)
avgDocLength := 100.0 // 估算的平均文档长度(用于归一化 avgDocLength := 150.0 // 估算的平均文档长度(基于典型知识块大小
docLength := float64(len(textTerms)) docLength := float64(len(textTerms))
score := 0.0 // 计算词频映射
for _, term := range queryTerms { textTermFreq := make(map[string]int, len(textTerms))
// 计算词频(TF for _, term := range textTerms {
termFreq := 0 textTermFreq[term]++
for _, textTerm := range textTerms {
if textTerm == term {
termFreq++
}
}
if termFreq > 0 {
// BM25公式的核心部分
// TF部分:termFreq / (termFreq + k1 * (1 - b + b * (docLength / avgDocLength)))
tf := float64(termFreq)
lengthNorm := 1 - b + b*(docLength/avgDocLength)
tfScore := tf / (tf + k1*lengthNorm)
// 简化IDF:使用词长度作为权重(短词通常更重要)
// 实际BM25需要全局文档统计,这里用简化版本
idfWeight := 1.0
if len(term) > 2 {
// 长词稍微降低权重(但实际BM25中,罕见词IDF更高)
idfWeight = 1.0 + math.Log(1.0+float64(len(term))/10.0)
}
score += tfScore * idfWeight
}
} }
// 归一化到0-1范围 score := 0.0
matchedQueryTerms := 0
for _, term := range queryTerms {
termFreq, exists := textTermFreq[term]
if !exists || termFreq == 0 {
continue
}
matchedQueryTerms++
// BM25 TF 计算公式
tf := float64(termFreq)
lengthNorm := 1 - b + b*(docLength/avgDocLength)
tfScore := tf / (tf + k1*lengthNorm)
// 改进的 IDF 计算:使用词长度和出现频率估算
// 短词(2-3 字符)通常更重要,长词 IDF 略低
idfWeight := 1.0
termLen := len(term)
if termLen <= 2 {
// 极短词(如 go, js)给予更高权重
idfWeight = 1.2 + math.Log(1.0+float64(termFreq)/20.0)
} else if termLen <= 4 {
// 短词(4 字符)标准权重
idfWeight = 1.0 + math.Log(1.0+float64(termFreq)/15.0)
} else {
// 长词稍微降低权重
idfWeight = 0.9 + math.Log(1.0+float64(termFreq)/10.0)
}
score += tfScore * idfWeight
}
// 归一化:考虑匹配的查询词比例
if len(queryTerms) > 0 { if len(queryTerms) > 0 {
score = score / float64(len(queryTerms)) // 使用匹配比例作为额外因子
matchRatio := float64(matchedQueryTerms) / float64(len(queryTerms))
score = (score / float64(len(queryTerms))) * (1 + matchRatio) / 2
} }
return math.Min(score, 1.0) return math.Min(score, 1.0)
@@ -173,7 +185,7 @@ func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*Retrieva
SELECT e.id, e.item_id, e.chunk_index, e.chunk_text, e.embedding, i.category, i.title SELECT e.id, e.item_id, e.chunk_index, e.chunk_text, e.embedding, i.category, i.title
FROM knowledge_embeddings e FROM knowledge_embeddings e
JOIN knowledge_base_items i ON e.item_id = i.id JOIN knowledge_base_items i ON e.item_id = i.id
WHERE i.category = ? COLLATE NOCASE WHERE TRIM(i.category) = TRIM(?) COLLATE NOCASE
`, req.RiskType) `, req.RiskType)
} else { } else {
rows, err = r.db.Query(` rows, err = r.db.Query(`
@@ -357,7 +369,10 @@ func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*Retrieva
zap.Float64("threshold", threshold), zap.Float64("threshold", threshold),
zap.Float64("maxSimilarity", maxSimilarity), zap.Float64("maxSimilarity", maxSimilarity),
) )
} else if len(filteredCandidates) > topK { }
// 统一在最终返回前严格限制 Top-K 数量
if len(filteredCandidates) > topK {
// 如果过滤后结果太多,只取Top-K // 如果过滤后结果太多,只取Top-K
filteredCandidates = filteredCandidates[:topK] filteredCandidates = filteredCandidates[:topK]
} }
+24 -42
View File
@@ -5,6 +5,14 @@ import (
"time" "time"
) )
// formatTime 格式化时间为 RFC3339 格式,零时间返回空字符串
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
// KnowledgeItem 知识库项 // KnowledgeItem 知识库项
type KnowledgeItem struct { type KnowledgeItem struct {
ID string `json:"id"` ID string `json:"id"`
@@ -22,12 +30,12 @@ type KnowledgeItemSummary struct {
Category string `json:"category"` Category string `json:"category"`
Title string `json:"title"` Title string `json:"title"`
FilePath string `json:"filePath"` FilePath string `json:"filePath"`
Content string `json:"content,omitempty"` // 可选:内容预览(如果提供,通常只包含前150字符) Content string `json:"content,omitempty"` // 可选:内容预览(如果提供,通常只包含前 150 字符)
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
// MarshalJSON 自定义JSON序列化,确保时间格式正确 // MarshalJSON 自定义 JSON 序列化,确保时间格式正确
func (k *KnowledgeItemSummary) MarshalJSON() ([]byte, error) { func (k *KnowledgeItemSummary) MarshalJSON() ([]byte, error) {
type Alias KnowledgeItemSummary type Alias KnowledgeItemSummary
aux := &struct { aux := &struct {
@@ -37,25 +45,12 @@ func (k *KnowledgeItemSummary) MarshalJSON() ([]byte, error) {
}{ }{
Alias: (*Alias)(k), Alias: (*Alias)(k),
} }
aux.CreatedAt = formatTime(k.CreatedAt)
// 格式化创建时间 aux.UpdatedAt = formatTime(k.UpdatedAt)
if k.CreatedAt.IsZero() {
aux.CreatedAt = ""
} else {
aux.CreatedAt = k.CreatedAt.Format(time.RFC3339)
}
// 格式化更新时间
if k.UpdatedAt.IsZero() {
aux.UpdatedAt = ""
} else {
aux.UpdatedAt = k.UpdatedAt.Format(time.RFC3339)
}
return json.Marshal(aux) return json.Marshal(aux)
} }
// MarshalJSON 自定义JSON序列化,确保时间格式正确 // MarshalJSON 自定义 JSON 序列化,确保时间格式正确
func (k *KnowledgeItem) MarshalJSON() ([]byte, error) { func (k *KnowledgeItem) MarshalJSON() ([]byte, error) {
type Alias KnowledgeItem type Alias KnowledgeItem
aux := &struct { aux := &struct {
@@ -65,21 +60,8 @@ func (k *KnowledgeItem) MarshalJSON() ([]byte, error) {
}{ }{
Alias: (*Alias)(k), Alias: (*Alias)(k),
} }
aux.CreatedAt = formatTime(k.CreatedAt)
// 格式化创建时间 aux.UpdatedAt = formatTime(k.UpdatedAt)
if k.CreatedAt.IsZero() {
aux.CreatedAt = ""
} else {
aux.CreatedAt = k.CreatedAt.Format(time.RFC3339)
}
// 格式化更新时间
if k.UpdatedAt.IsZero() {
aux.UpdatedAt = ""
} else {
aux.UpdatedAt = k.UpdatedAt.Format(time.RFC3339)
}
return json.Marshal(aux) return json.Marshal(aux)
} }
@@ -89,7 +71,7 @@ type KnowledgeChunk struct {
ItemID string `json:"itemId"` ItemID string `json:"itemId"`
ChunkIndex int `json:"chunkIndex"` ChunkIndex int `json:"chunkIndex"`
ChunkText string `json:"chunkText"` ChunkText string `json:"chunkText"`
Embedding []float32 `json:"-"` // 向量嵌入,不序列化到JSON Embedding []float32 `json:"-"` // 向量嵌入,不序列化到 JSON
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
@@ -108,11 +90,11 @@ type RetrievalLog struct {
MessageID string `json:"messageId,omitempty"` MessageID string `json:"messageId,omitempty"`
Query string `json:"query"` Query string `json:"query"`
RiskType string `json:"riskType,omitempty"` RiskType string `json:"riskType,omitempty"`
RetrievedItems []string `json:"retrievedItems"` // 检索到的知识项ID列表 RetrievedItems []string `json:"retrievedItems"` // 检索到的知识项 ID 列表
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
// MarshalJSON 自定义JSON序列化,确保时间格式正确 // MarshalJSON 自定义 JSON 序列化,确保时间格式正确
func (r *RetrievalLog) MarshalJSON() ([]byte, error) { func (r *RetrievalLog) MarshalJSON() ([]byte, error) {
type Alias RetrievalLog type Alias RetrievalLog
return json.Marshal(&struct { return json.Marshal(&struct {
@@ -120,21 +102,21 @@ func (r *RetrievalLog) MarshalJSON() ([]byte, error) {
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
}{ }{
Alias: (*Alias)(r), Alias: (*Alias)(r),
CreatedAt: r.CreatedAt.Format(time.RFC3339), CreatedAt: formatTime(r.CreatedAt),
}) })
} }
// CategoryWithItems 分类及其下的知识项(用于按分类分页) // CategoryWithItems 分类及其下的知识项(用于按分类分页)
type CategoryWithItems struct { type CategoryWithItems struct {
Category string `json:"category"` // 分类名称 Category string `json:"category"` // 分类名称
ItemCount int `json:"itemCount"` // 该分类下的知识项总数 ItemCount int `json:"itemCount"` // 该分类下的知识项总数
Items []*KnowledgeItemSummary `json:"items"` // 该分类下的知识项列表 Items []*KnowledgeItemSummary `json:"items"` // 该分类下的知识项列表
} }
// SearchRequest 搜索请求 // SearchRequest 搜索请求
type SearchRequest struct { type SearchRequest struct {
Query string `json:"query"` Query string `json:"query"`
RiskType string `json:"riskType,omitempty"` // 可选:指定风险类型 RiskType string `json:"riskType,omitempty"` // 可选:指定风险类型
TopK int `json:"topK,omitempty"` // 返回Top-K结果,默认5 TopK int `json:"topK,omitempty"` // 返回 Top-K 结果,默认 5
Threshold float64 `json:"threshold,omitempty"` // 相似度阈值,默认0.7 Threshold float64 `json:"threshold,omitempty"` // 相似度阈值,默认 0.7
} }
+10 -2
View File
@@ -55,6 +55,14 @@ func New(level, output string) *Logger {
} }
func (l *Logger) Fatal(msg string, fields ...interface{}) { func (l *Logger) Fatal(msg string, fields ...interface{}) {
l.Logger.Fatal(msg, zap.Any("fields", fields)) zapFields := make([]zap.Field, 0, len(fields))
for _, f := range fields {
switch v := f.(type) {
case error:
zapFields = append(zapFields, zap.Error(v))
default:
zapFields = append(zapFields, zap.Any("field", v))
}
}
l.Logger.Fatal(msg, zapFields...)
} }
File diff suppressed because it is too large Load Diff
+551
View File
@@ -0,0 +1,551 @@
// Package mcp 外部 MCP 客户端 - 基于官方 go-sdk 实现,保证协议兼容性
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/config"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.uber.org/zap"
)
const (
clientName = "CyberStrikeAI"
clientVersion = "1.0.0"
)
// sdkClient 基于官方 MCP Go SDK 的外部 MCP 客户端,实现 ExternalMCPClient 接口
type sdkClient struct {
session *mcp.ClientSession
client *mcp.Client
logger *zap.Logger
mu sync.RWMutex
status string // "disconnected", "connecting", "connected", "error"
}
// newSDKClientFromSession 用已连接成功的 session 构造(供 createSDKClient 内部使用)
func newSDKClientFromSession(session *mcp.ClientSession, client *mcp.Client, logger *zap.Logger) *sdkClient {
return &sdkClient{
session: session,
client: client,
logger: logger,
status: "connected",
}
}
// lazySDKClient 延迟连接:Initialize() 时才调用官方 SDK 建立连接,对外实现 ExternalMCPClient
type lazySDKClient struct {
serverCfg config.ExternalMCPServerConfig
logger *zap.Logger
inner ExternalMCPClient // 连接成功后为 *sdkClient
mu sync.RWMutex
status string
}
func newLazySDKClient(serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) *lazySDKClient {
return &lazySDKClient{
serverCfg: serverCfg,
logger: logger,
status: "connecting",
}
}
func (c *lazySDKClient) setStatus(s string) {
c.mu.Lock()
defer c.mu.Unlock()
c.status = s
}
func (c *lazySDKClient) GetStatus() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.inner != nil {
return c.inner.GetStatus()
}
return c.status
}
func (c *lazySDKClient) IsConnected() bool {
c.mu.RLock()
inner := c.inner
c.mu.RUnlock()
if inner != nil {
return inner.IsConnected()
}
return false
}
func (c *lazySDKClient) Initialize(ctx context.Context) error {
c.mu.Lock()
if c.inner != nil {
c.mu.Unlock()
return nil
}
c.mu.Unlock()
inner, err := createSDKClient(ctx, c.serverCfg, c.logger)
if err != nil {
c.setStatus("error")
return err
}
c.mu.Lock()
c.inner = inner
c.mu.Unlock()
c.setStatus("connected")
return nil
}
func (c *lazySDKClient) ListTools(ctx context.Context) ([]Tool, error) {
c.mu.RLock()
inner := c.inner
c.mu.RUnlock()
if inner == nil {
return nil, fmt.Errorf("未连接")
}
return inner.ListTools(ctx)
}
func (c *lazySDKClient) CallTool(ctx context.Context, name string, args map[string]interface{}) (*ToolResult, error) {
c.mu.RLock()
inner := c.inner
c.mu.RUnlock()
if inner == nil {
return nil, fmt.Errorf("未连接")
}
return inner.CallTool(ctx, name, args)
}
func (c *lazySDKClient) Close() error {
c.mu.Lock()
inner := c.inner
c.inner = nil
c.mu.Unlock()
c.setStatus("disconnected")
if inner != nil {
return inner.Close()
}
return nil
}
func (c *sdkClient) setStatus(s string) {
c.mu.Lock()
defer c.mu.Unlock()
c.status = s
}
func (c *sdkClient) GetStatus() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.status
}
func (c *sdkClient) IsConnected() bool {
return c.GetStatus() == "connected"
}
func (c *sdkClient) Initialize(ctx context.Context) error {
// sdkClient 由 createSDKClient 在 Connect 成功后才创建,因此 Initialize 时已经连接
// 此方法仅用于满足 ExternalMCPClient 接口,实际连接在 createSDKClient 中完成
return nil
}
func (c *sdkClient) ListTools(ctx context.Context) ([]Tool, error) {
if c.session == nil {
return nil, fmt.Errorf("未连接")
}
res, err := c.session.ListTools(ctx, nil)
if err != nil {
return nil, err
}
if res == nil {
return nil, nil
}
return sdkToolsToOur(res.Tools), nil
}
func (c *sdkClient) CallTool(ctx context.Context, name string, args map[string]interface{}) (*ToolResult, error) {
if c.session == nil {
return nil, fmt.Errorf("未连接")
}
params := &mcp.CallToolParams{
Name: name,
Arguments: args,
}
res, err := c.session.CallTool(ctx, params)
if err != nil {
return nil, err
}
return sdkCallToolResultToOurs(res), nil
}
func (c *sdkClient) Close() error {
c.setStatus("disconnected")
if c.session != nil {
err := c.session.Close()
c.session = nil
return err
}
return nil
}
// sdkToolsToOur 将 SDK 的 []*mcp.Tool 转为我们的 []Tool
func sdkToolsToOur(tools []*mcp.Tool) []Tool {
if len(tools) == 0 {
return nil
}
out := make([]Tool, 0, len(tools))
for _, t := range tools {
if t == nil {
continue
}
schema := make(map[string]interface{})
if t.InputSchema != nil {
// SDK InputSchema 可能为 *jsonschema.Schema 或 map,统一转为 map
if m, ok := t.InputSchema.(map[string]interface{}); ok {
schema = m
} else {
_ = json.Unmarshal(mustJSON(t.InputSchema), &schema)
}
}
desc := t.Description
shortDesc := desc
if t.Annotations != nil && t.Annotations.Title != "" {
shortDesc = t.Annotations.Title
}
out = append(out, Tool{
Name: t.Name,
Description: desc,
ShortDescription: shortDesc,
InputSchema: schema,
})
}
return out
}
// sdkCallToolResultToOurs 将 SDK 的 *mcp.CallToolResult 转为我们的 *ToolResult
func sdkCallToolResultToOurs(res *mcp.CallToolResult) *ToolResult {
if res == nil {
return &ToolResult{Content: []Content{}}
}
content := sdkContentToOurs(res.Content)
return &ToolResult{
Content: content,
IsError: res.IsError,
}
}
func sdkContentToOurs(list []mcp.Content) []Content {
if len(list) == 0 {
return nil
}
out := make([]Content, 0, len(list))
for _, c := range list {
switch v := c.(type) {
case *mcp.TextContent:
out = append(out, Content{Type: "text", Text: v.Text})
default:
out = append(out, Content{Type: "text", Text: fmt.Sprintf("%v", c)})
}
}
return out
}
func mustJSON(v interface{}) []byte {
b, _ := json.Marshal(v)
return b
}
// simpleHTTPClient 简单 JSON-RPC over HTTP:每次请求一次 POST、响应在 body。实现 ExternalMCPClient。
// 用于自建 MCP(如 http://127.0.0.1:8081/mcp)或其它仅支持简单 POST 的端点。
type simpleHTTPClient struct {
url string
client *http.Client
logger *zap.Logger
mu sync.RWMutex
status string
}
func newSimpleHTTPClient(ctx context.Context, url string, timeout time.Duration, headers map[string]string, logger *zap.Logger) (ExternalMCPClient, error) {
c := &simpleHTTPClient{
url: url,
client: httpClientWithTimeoutAndHeaders(timeout, headers),
logger: logger,
status: "connecting",
}
if err := c.initialize(ctx); err != nil {
return nil, err
}
c.mu.Lock()
c.status = "connected"
c.mu.Unlock()
return c, nil
}
func (c *simpleHTTPClient) setStatus(s string) {
c.mu.Lock()
defer c.mu.Unlock()
c.status = s
}
func (c *simpleHTTPClient) GetStatus() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.status
}
func (c *simpleHTTPClient) IsConnected() bool {
return c.GetStatus() == "connected"
}
func (c *simpleHTTPClient) Initialize(context.Context) error {
return nil // 已在 newSimpleHTTPClient 中完成
}
func (c *simpleHTTPClient) initialize(ctx context.Context) error {
params := InitializeRequest{
ProtocolVersion: ProtocolVersion,
Capabilities: make(map[string]interface{}),
ClientInfo: ClientInfo{Name: clientName, Version: clientVersion},
}
paramsJSON, _ := json.Marshal(params)
req := &Message{
ID: MessageID{value: "1"},
Method: "initialize",
Version: "2.0",
Params: paramsJSON,
}
resp, err := c.sendRequest(ctx, req)
if err != nil {
return fmt.Errorf("initialize: %w", err)
}
if resp.Error != nil {
return fmt.Errorf("initialize: %s (code %d)", resp.Error.Message, resp.Error.Code)
}
// 发送 notifications/initialized(协议要求)
notify := &Message{
ID: MessageID{value: nil},
Method: "notifications/initialized",
Version: "2.0",
Params: json.RawMessage("{}"),
}
_ = c.sendNotification(notify)
return nil
}
func (c *simpleHTTPClient) sendRequest(ctx context.Context, msg *Message) (*Message, error) {
body, err := json.Marshal(msg)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b))
}
var out Message
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
func (c *simpleHTTPClient) sendNotification(msg *Message) error {
body, _ := json.Marshal(msg)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(httpReq)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
func (c *simpleHTTPClient) ListTools(ctx context.Context) ([]Tool, error) {
req := &Message{
ID: MessageID{value: uuid.New().String()},
Method: "tools/list",
Version: "2.0",
Params: json.RawMessage("{}"),
}
resp, err := c.sendRequest(ctx, req)
if err != nil {
return nil, err
}
if resp.Error != nil {
return nil, fmt.Errorf("tools/list: %s (code %d)", resp.Error.Message, resp.Error.Code)
}
var listResp ListToolsResponse
if err := json.Unmarshal(resp.Result, &listResp); err != nil {
return nil, err
}
return listResp.Tools, nil
}
func (c *simpleHTTPClient) CallTool(ctx context.Context, name string, args map[string]interface{}) (*ToolResult, error) {
params := CallToolRequest{Name: name, Arguments: args}
paramsJSON, _ := json.Marshal(params)
req := &Message{
ID: MessageID{value: uuid.New().String()},
Method: "tools/call",
Version: "2.0",
Params: paramsJSON,
}
resp, err := c.sendRequest(ctx, req)
if err != nil {
return nil, err
}
if resp.Error != nil {
return nil, fmt.Errorf("tools/call: %s (code %d)", resp.Error.Message, resp.Error.Code)
}
var callResp CallToolResponse
if err := json.Unmarshal(resp.Result, &callResp); err != nil {
return nil, err
}
return &ToolResult{Content: callResp.Content, IsError: callResp.IsError}, nil
}
func (c *simpleHTTPClient) Close() error {
c.setStatus("disconnected")
return nil
}
// createSDKClient 根据配置创建并连接外部 MCP 客户端(使用官方 SDK),返回实现 ExternalMCPClient 的 *sdkClient
// 若连接失败返回 (nil, error)。ctx 用于连接超时与取消。
func createSDKClient(ctx context.Context, serverCfg config.ExternalMCPServerConfig, logger *zap.Logger) (ExternalMCPClient, error) {
timeout := time.Duration(serverCfg.Timeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
transport := serverCfg.Transport
if transport == "" {
if serverCfg.Command != "" {
transport = "stdio"
} else if serverCfg.URL != "" {
transport = "http"
} else {
return nil, fmt.Errorf("配置缺少 command 或 url")
}
}
client := mcp.NewClient(&mcp.Implementation{
Name: clientName,
Version: clientVersion,
}, nil)
var t mcp.Transport
switch transport {
case "stdio":
if serverCfg.Command == "" {
return nil, fmt.Errorf("stdio 模式需要配置 command")
}
// 必须用 exec.Command 而非 CommandContextdoConnect 返回后 ctx 会被 cancel
// 若用 CommandContext(ctx) 会立刻杀掉子进程,导致 ListTools 等后续请求失败、显示 0 工具
cmd := exec.Command(serverCfg.Command, serverCfg.Args...)
if len(serverCfg.Env) > 0 {
cmd.Env = append(cmd.Env, envMapToSlice(serverCfg.Env)...)
}
t = &mcp.CommandTransport{Command: cmd}
case "sse":
if serverCfg.URL == "" {
return nil, fmt.Errorf("sse 模式需要配置 url")
}
httpClient := httpClientWithTimeoutAndHeaders(timeout, serverCfg.Headers)
t = &mcp.SSEClientTransport{
Endpoint: serverCfg.URL,
HTTPClient: httpClient,
}
case "http":
if serverCfg.URL == "" {
return nil, fmt.Errorf("http 模式需要配置 url")
}
httpClient := httpClientWithTimeoutAndHeaders(timeout, serverCfg.Headers)
t = &mcp.StreamableClientTransport{
Endpoint: serverCfg.URL,
HTTPClient: httpClient,
}
case "simple_http":
// 简单 JSON-RPC HTTP:每次请求一次 POST、响应在 body。用于自建 MCP 或兼容旧端点(如 http://127.0.0.1:8081/mcp
if serverCfg.URL == "" {
return nil, fmt.Errorf("simple_http 模式需要配置 url")
}
return newSimpleHTTPClient(ctx, serverCfg.URL, timeout, serverCfg.Headers, logger)
default:
return nil, fmt.Errorf("不支持的传输模式: %s", transport)
}
session, err := client.Connect(ctx, t, nil)
if err != nil {
return nil, fmt.Errorf("连接失败: %w", err)
}
return newSDKClientFromSession(session, client, logger), nil
}
func envMapToSlice(env map[string]string) []string {
m := make(map[string]string)
for _, s := range os.Environ() {
if i := strings.IndexByte(s, '='); i > 0 {
m[s[:i]] = s[i+1:]
}
}
for k, v := range env {
m[k] = v
}
out := make([]string, 0, len(m))
for k, v := range m {
out = append(out, k+"="+v)
}
return out
}
func httpClientWithTimeoutAndHeaders(timeout time.Duration, headers map[string]string) *http.Client {
transport := http.DefaultTransport
if len(headers) > 0 {
transport = &headerRoundTripper{
headers: headers,
base: http.DefaultTransport,
}
}
return &http.Client{
Timeout: timeout,
Transport: transport,
}
}
type headerRoundTripper struct {
headers map[string]string
base http.RoundTripper
}
func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
for k, v := range h.headers {
req.Header.Set(k, v)
}
return h.base.RoundTrip(req)
}
+203 -35
View File
@@ -25,6 +25,8 @@ type ExternalMCPManager struct {
errors map[string]string // 错误信息 errors map[string]string // 错误信息
toolCounts map[string]int // 工具数量缓存 toolCounts map[string]int // 工具数量缓存
toolCountsMu sync.RWMutex // 工具数量缓存的锁 toolCountsMu sync.RWMutex // 工具数量缓存的锁
toolCache map[string][]Tool // 工具列表缓存:MCP名称 -> 工具列表
toolCacheMu sync.RWMutex // 工具列表缓存的锁
stopRefresh chan struct{} // 停止后台刷新的信号 stopRefresh chan struct{} // 停止后台刷新的信号
refreshWg sync.WaitGroup // 等待后台刷新goroutine完成 refreshWg sync.WaitGroup // 等待后台刷新goroutine完成
mu sync.RWMutex mu sync.RWMutex
@@ -46,6 +48,7 @@ func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage
stats: make(map[string]*ToolStats), stats: make(map[string]*ToolStats),
errors: make(map[string]string), errors: make(map[string]string),
toolCounts: make(map[string]int), toolCounts: make(map[string]int),
toolCache: make(map[string][]Tool),
stopRefresh: make(chan struct{}), stopRefresh: make(chan struct{}),
} }
// 启动后台刷新工具数量的goroutine // 启动后台刷新工具数量的goroutine
@@ -119,6 +122,11 @@ func (m *ExternalMCPManager) RemoveConfig(name string) error {
delete(m.toolCounts, name) delete(m.toolCounts, name)
m.toolCountsMu.Unlock() m.toolCountsMu.Unlock()
// 清理工具列表缓存
m.toolCacheMu.Lock()
delete(m.toolCache, name)
m.toolCacheMu.Unlock()
return nil return nil
} }
@@ -196,8 +204,15 @@ func (m *ExternalMCPManager) StartClient(name string) error {
m.mu.Lock() m.mu.Lock()
delete(m.errors, name) delete(m.errors, name)
m.mu.Unlock() m.mu.Unlock()
// 连接成功,立即刷新工具数量 // 立即刷新工具数量和工具列表缓存
m.triggerToolCountRefresh() m.triggerToolCountRefresh()
m.refreshToolCache(name, client)
// 2 秒后再刷新一次,覆盖 SSE/Streamable 等需稍等就绪的远端
go func() {
time.Sleep(2 * time.Second)
m.triggerToolCountRefresh()
m.refreshToolCache(name, client)
}()
} }
}() }()
@@ -253,6 +268,11 @@ func (m *ExternalMCPManager) GetError(name string) string {
} }
// GetAllTools 获取所有外部MCP的工具 // GetAllTools 获取所有外部MCP的工具
// 优先从已连接的客户端获取,如果连接断开则返回缓存的工具列表
// 策略:
// - error 状态:不使用缓存,直接跳过(配置错误或服务不可用)
// - disconnected/connecting 状态:使用缓存(临时断开)
// - connected 状态:正常获取,失败时降级使用缓存
func (m *ExternalMCPManager) GetAllTools(ctx context.Context) ([]Tool, error) { func (m *ExternalMCPManager) GetAllTools(ctx context.Context) ([]Tool, error) {
m.mu.RLock() m.mu.RLock()
clients := make(map[string]ExternalMCPClient) clients := make(map[string]ExternalMCPClient)
@@ -262,17 +282,21 @@ func (m *ExternalMCPManager) GetAllTools(ctx context.Context) ([]Tool, error) {
m.mu.RUnlock() m.mu.RUnlock()
var allTools []Tool var allTools []Tool
for name, client := range clients { var hasError bool
if !client.IsConnected() { var lastError error
continue
}
tools, err := client.ListTools(ctx) // 使用较短的超时时间进行快速检查(3秒),避免阻塞
quickCtx, quickCancel := context.WithTimeout(ctx, 3*time.Second)
defer quickCancel()
for name, client := range clients {
tools, err := m.getToolsForClient(name, client, quickCtx)
if err != nil { if err != nil {
m.logger.Warn("获取外部MCP工具列表失败", // 记录错误,但继续处理其他客户端
zap.String("name", name), hasError = true
zap.Error(err), if lastError == nil {
) lastError = err
}
continue continue
} }
@@ -283,9 +307,97 @@ func (m *ExternalMCPManager) GetAllTools(ctx context.Context) ([]Tool, error) {
} }
} }
// 如果有错误但至少返回了一些工具,不返回错误(部分成功)
if hasError && len(allTools) == 0 {
return nil, fmt.Errorf("获取外部MCP工具失败: %w", lastError)
}
return allTools, nil return allTools, nil
} }
// getToolsForClient 获取指定客户端的工具列表
// 返回工具列表和错误(如果完全无法获取)
func (m *ExternalMCPManager) getToolsForClient(name string, client ExternalMCPClient, ctx context.Context) ([]Tool, error) {
status := client.GetStatus()
// error 状态:不使用缓存,直接返回错误
if status == "error" {
m.logger.Debug("跳过连接失败的外部MCP(不使用缓存)",
zap.String("name", name),
zap.String("status", status),
)
return nil, fmt.Errorf("外部MCP连接失败: %s", name)
}
// 已连接:尝试获取最新工具列表
if client.IsConnected() {
tools, err := client.ListTools(ctx)
if err != nil {
// 获取失败,尝试使用缓存
return m.getCachedTools(name, "连接正常但获取失败", err)
}
// 获取成功,更新缓存
m.updateToolCache(name, tools)
return tools, nil
}
// 未连接:根据状态决定是否使用缓存
if status == "disconnected" || status == "connecting" {
return m.getCachedTools(name, fmt.Sprintf("客户端临时断开(状态: %s", status), nil)
}
// 其他未知状态,不使用缓存
m.logger.Debug("跳过外部MCP(未知状态)",
zap.String("name", name),
zap.String("status", status),
)
return nil, fmt.Errorf("外部MCP状态未知: %s (状态: %s)", name, status)
}
// getCachedTools 获取缓存的工具列表
func (m *ExternalMCPManager) getCachedTools(name, reason string, originalErr error) ([]Tool, error) {
m.toolCacheMu.RLock()
cachedTools, hasCache := m.toolCache[name]
m.toolCacheMu.RUnlock()
if hasCache && len(cachedTools) > 0 {
m.logger.Debug("使用缓存的工具列表",
zap.String("name", name),
zap.String("reason", reason),
zap.Int("count", len(cachedTools)),
zap.Error(originalErr),
)
return cachedTools, nil
}
// 无缓存,返回错误
if originalErr != nil {
return nil, fmt.Errorf("获取外部MCP工具失败且无缓存: %w", originalErr)
}
return nil, fmt.Errorf("外部MCP无缓存工具: %s", name)
}
// updateToolCache 更新工具列表缓存
func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
m.toolCacheMu.Lock()
m.toolCache[name] = tools
m.toolCacheMu.Unlock()
// 如果返回空列表,记录警告
if len(tools) == 0 {
m.logger.Warn("外部MCP返回空工具列表",
zap.String("name", name),
zap.String("hint", "服务可能暂时不可用,工具列表为空"),
)
} else {
m.logger.Debug("工具列表缓存已更新",
zap.String("name", name),
zap.Int("count", len(tools)),
)
}
}
// CallTool 调用外部MCP工具(返回执行ID) // CallTool 调用外部MCP工具(返回执行ID)
func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) { func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
// 解析工具名称:name::toolName // 解析工具名称:name::toolName
@@ -302,8 +414,18 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
return nil, "", fmt.Errorf("外部MCP客户端不存在: %s", mcpName) return nil, "", fmt.Errorf("外部MCP客户端不存在: %s", mcpName)
} }
// 检查连接状态,如果未连接或状态为error,不允许调用
if !client.IsConnected() { if !client.IsConnected() {
return nil, "", fmt.Errorf("外部MCP客户端未连接: %s", mcpName) status := client.GetStatus()
if status == "error" {
// 获取错误信息(如果有)
errorMsg := m.GetError(mcpName)
if errorMsg != "" {
return nil, "", fmt.Errorf("外部MCP连接失败: %s (错误: %s)", mcpName, errorMsg)
}
return nil, "", fmt.Errorf("外部MCP连接失败: %s", mcpName)
}
return nil, "", fmt.Errorf("外部MCP客户端未连接: %s (状态: %s)", mcpName, status)
} }
// 创建执行记录 // 创建执行记录
@@ -630,11 +752,20 @@ func (m *ExternalMCPManager) refreshToolCounts() {
cancel() cancel()
if err != nil { if err != nil {
m.logger.Debug("获取外部MCP工具数量失败", errStr := err.Error()
zap.String("name", n), // SSE 连接 EOF:远端可能关闭了流或未按规范在流上推送响应,仅首次用 Warn 提示
zap.Error(err), if strings.Contains(errStr, "EOF") || strings.Contains(errStr, "client is closing") {
) m.logger.Warn("获取外部MCP工具数量失败(SSE 流已关闭或服务端未在流上返回 tools/list 响应)",
// 如果获取失败,保留旧值(在更新时处理) zap.String("name", n),
zap.String("hint", "若为 SSE 连接,请确认服务端保持 GET 流打开并按 MCP 规范以 event: message 推送 JSON-RPC 响应"),
zap.Error(err),
)
} else {
m.logger.Warn("获取外部MCP工具数量失败,请检查连接或服务端 tools/list",
zap.String("name", n),
zap.Error(err),
)
}
resultChan <- countResult{name: n, count: -1} // -1 表示使用旧值 resultChan <- countResult{name: n, count: -1} // -1 表示使用旧值
return return
} }
@@ -680,6 +811,40 @@ func (m *ExternalMCPManager) refreshToolCounts() {
m.toolCountsMu.Unlock() m.toolCountsMu.Unlock()
} }
// refreshToolCache 刷新指定MCP的工具列表缓存
func (m *ExternalMCPManager) refreshToolCache(name string, client ExternalMCPClient) {
if !client.IsConnected() {
return
}
// 检查状态,如果是error状态,不更新缓存
status := client.GetStatus()
if status == "error" {
m.logger.Debug("跳过刷新工具列表缓存(连接失败)",
zap.String("name", name),
zap.String("status", status),
)
return
}
// 使用较短的超时时间(5秒)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tools, err := client.ListTools(ctx)
if err != nil {
m.logger.Debug("刷新工具列表缓存失败",
zap.String("name", name),
zap.Error(err),
)
// 刷新失败时不更新缓存,保留旧缓存(如果有)
return
}
// 使用统一的缓存更新方法
m.updateToolCache(name, tools)
}
// startToolCountRefresh 启动后台刷新工具数量的goroutine // startToolCountRefresh 启动后台刷新工具数量的goroutine
func (m *ExternalMCPManager) startToolCountRefresh() { func (m *ExternalMCPManager) startToolCountRefresh() {
m.refreshWg.Add(1) m.refreshWg.Add(1)
@@ -707,21 +872,13 @@ func (m *ExternalMCPManager) triggerToolCountRefresh() {
go m.refreshToolCounts() go m.refreshToolCounts()
} }
// createClient 创建客户端(不连接) // createClient 创建客户端(不连接)。统一使用官方 MCP Go SDK 的 lazy 客户端,连接在 Initialize 时完成。
func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConfig) ExternalMCPClient { func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConfig) ExternalMCPClient {
timeout := time.Duration(serverCfg.Timeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
// 根据传输模式创建客户端
transport := serverCfg.Transport transport := serverCfg.Transport
if transport == "" { if transport == "" {
// 如果没有指定transport,根据是否有command或url判断
if serverCfg.Command != "" { if serverCfg.Command != "" {
transport = "stdio" transport = "stdio"
} else if serverCfg.URL != "" { } else if serverCfg.URL != "" {
// 默认使用http,但可以通过transport字段指定sse
transport = "http" transport = "http"
} else { } else {
return nil return nil
@@ -733,17 +890,23 @@ func (m *ExternalMCPManager) createClient(serverCfg config.ExternalMCPServerConf
if serverCfg.URL == "" { if serverCfg.URL == "" {
return nil return nil
} }
return NewHTTPMCPClient(serverCfg.URL, timeout, m.logger) return newLazySDKClient(serverCfg, m.logger)
case "simple_http":
// 简单 HTTP(一次 POST 一次响应),用于自建 MCP 等
if serverCfg.URL == "" {
return nil
}
return newLazySDKClient(serverCfg, m.logger)
case "stdio": case "stdio":
if serverCfg.Command == "" { if serverCfg.Command == "" {
return nil return nil
} }
return NewStdioMCPClient(serverCfg.Command, serverCfg.Args, serverCfg.Env, timeout, m.logger) return newLazySDKClient(serverCfg, m.logger)
case "sse": case "sse":
if serverCfg.URL == "" { if serverCfg.URL == "" {
return nil return nil
} }
return NewSSEMCPClient(serverCfg.URL, timeout, m.logger) return newLazySDKClient(serverCfg, m.logger)
default: default:
return nil return nil
} }
@@ -773,12 +936,7 @@ func (m *ExternalMCPManager) doConnect(name string, serverCfg config.ExternalMCP
// setClientStatus 设置客户端状态(通过类型断言) // setClientStatus 设置客户端状态(通过类型断言)
func (m *ExternalMCPManager) setClientStatus(client ExternalMCPClient, status string) { func (m *ExternalMCPManager) setClientStatus(client ExternalMCPClient, status string) {
switch c := client.(type) { if c, ok := client.(*lazySDKClient); ok {
case *HTTPMCPClient:
c.setStatus(status)
case *StdioMCPClient:
c.setStatus(status)
case *SSEMCPClient:
c.setStatus(status) c.setStatus(status)
} }
} }
@@ -819,8 +977,13 @@ func (m *ExternalMCPManager) connectClient(name string, serverCfg config.Externa
zap.String("name", name), zap.String("name", name),
) )
// 连接成功,触发工具数量刷新 // 连接成功,触发工具数量刷新和工具列表缓存刷新
m.triggerToolCountRefresh() m.triggerToolCountRefresh()
m.mu.RLock()
if client, exists := m.clients[name]; exists {
m.refreshToolCache(name, client)
}
m.mu.RUnlock()
return nil return nil
} }
@@ -926,6 +1089,11 @@ func (m *ExternalMCPManager) StopAll() {
m.toolCounts = make(map[string]int) m.toolCounts = make(map[string]int)
m.toolCountsMu.Unlock() m.toolCountsMu.Unlock()
// 清理所有工具列表缓存
m.toolCacheMu.Lock()
m.toolCache = make(map[string][]Tool)
m.toolCacheMu.Unlock()
// 停止后台刷新(使用 select 避免重复关闭 channel // 停止后台刷新(使用 select 避免重复关闭 channel
select { select {
case <-m.stopRefresh: case <-m.stopRefresh:
+15 -37
View File
@@ -151,48 +151,26 @@ func TestExternalMCPManager_LoadConfigs(t *testing.T) {
} }
} }
func TestHTTPMCPClient_Initialize(t *testing.T) { // TestLazySDKClient_InitializeFails 验证无效配置时 SDK 客户端 Initialize 失败并设置 error 状态
// 注意:这个测试需要一个真实的HTTP MCP服务器 func TestLazySDKClient_InitializeFails(t *testing.T) {
// 如果没有服务器,这个测试会失败
// 在实际测试中,可以使用mock服务器
logger := zap.NewNop() logger := zap.NewNop()
client := NewHTTPMCPClient("http://127.0.0.1:8081/mcp", 5*time.Second, logger) // 使用不存在的 HTTP 地址,Initialize 应失败
cfg := config.ExternalMCPServerConfig{
Transport: "http",
URL: "http://127.0.0.1:19999/nonexistent",
Timeout: 2,
}
c := newLazySDKClient(cfg, logger)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
err := c.Initialize(ctx)
// 这个测试可能会失败,如果没有真实的服务器 if err == nil {
// 在实际环境中,应该使用mock服务器 t.Fatal("expected error when connecting to invalid server")
err := client.Initialize(ctx)
if err != nil {
t.Logf("初始化失败(可能是没有服务器): %v", err)
} }
if c.GetStatus() != "error" {
status := client.GetStatus() t.Errorf("expected status error, got %s", c.GetStatus())
if status == "" {
t.Error("状态不应该为空")
} }
c.Close()
client.Close()
}
func TestStdioMCPClient_Initialize(t *testing.T) {
// 注意:这个测试需要一个真实的stdio MCP服务器
// 如果没有服务器,这个测试会失败
logger := zap.NewNop()
client := NewStdioMCPClient("echo", []string{"test"}, nil, 5*time.Second, logger)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 这个测试可能会失败,因为echo不是MCP服务器
// 在实际环境中,应该使用真实的MCP服务器或mock
err := client.Initialize(ctx)
if err != nil {
t.Logf("初始化失败(echo不是MCP服务器): %v", err)
}
client.Close()
} }
func TestExternalMCPManager_StartStopClient(t *testing.T) { func TestExternalMCPManager_StartStopClient(t *testing.T) {
+76 -10
View File
@@ -1,6 +1,7 @@
package mcp package mcp
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -125,6 +126,13 @@ func (s *Server) HandleHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// 官方 MCP SSE 规范:带 sessionid 的 POST 表示消息发往该 SSE 会话,响应通过 SSE 流返回
if sessionID := r.URL.Query().Get("sessionid"); sessionID != "" {
s.serveSSESessionMessage(w, r, sessionID)
return
}
// 简单 POST:请求体为 JSON-RPC,响应在 body 中返回
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
s.sendError(w, nil, -32700, "Parse error", err.Error()) s.sendError(w, nil, -32700, "Parse error", err.Error())
@@ -137,14 +145,56 @@ func (s *Server) HandleHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// 处理消息
response := s.handleMessage(&msg) response := s.handleMessage(&msg)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }
// handleSSE 处理SSE连接(用于MCP HTTP传输的事件通道) // serveSSESessionMessage 处理发往 SSE 会话的 POST:读取 JSON-RPC 请求,处理后将响应通过该会话的 SSE 流推送
func (s *Server) serveSSESessionMessage(w http.ResponseWriter, r *http.Request, sessionID string) {
s.mu.RLock()
client, exists := s.sseClients[sessionID]
s.mu.RUnlock()
if !exists || client == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
var msg Message
if err := json.Unmarshal(body, &msg); err != nil {
http.Error(w, "failed to parse body", http.StatusBadRequest)
return
}
response := s.handleMessage(&msg)
if response == nil {
w.WriteHeader(http.StatusAccepted)
return
}
respBytes, err := json.Marshal(response)
if err != nil {
http.Error(w, "failed to encode response", http.StatusInternalServerError)
return
}
select {
case client.send <- respBytes:
w.WriteHeader(http.StatusAccepted)
default:
http.Error(w, "session send buffer full", http.StatusServiceUnavailable)
}
}
// handleSSE 处理 SSE 连接,兼容官方 MCP 2024-11-05 SSE 规范:
// 1. 首个事件必须为 event: endpointdata 为客户端 POST 消息的 URL(含 sessionid
// 2. 后续事件为 event: messagedata 为 JSON-RPC 响应
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { if !ok {
@@ -157,16 +207,25 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Accel-Buffering", "no")
sessionID := uuid.New().String()
client := &sseClient{ client := &sseClient{
id: uuid.New().String(), id: sessionID,
send: make(chan []byte, 8), send: make(chan []byte, 32),
} }
s.addSSEClient(client) s.addSSEClient(client)
defer s.removeSSEClient(client.id) defer s.removeSSEClient(client.id)
// 发送初始ready事件,告知客户端连接成功 // 官方规范:首个事件为 endpoint,data 为消息端点 URL(客户端将向该 URL POST 请求)
fmt.Fprintf(w, "event: message\ndata: {\"type\":\"ready\",\"status\":\"ok\"}\n\n") scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if r.URL.Scheme != "" {
scheme = r.URL.Scheme
}
endpointURL := fmt.Sprintf("%s://%s%s?sessionid=%s", scheme, r.Host, r.URL.Path, sessionID)
fmt.Fprintf(w, "event: endpoint\ndata: %s\n\n", endpointURL)
flusher.Flush() flusher.Flush()
ticker := time.NewTicker(15 * time.Second) ticker := time.NewTicker(15 * time.Second)
@@ -183,7 +242,6 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "event: message\ndata: %s\n\n", msg) fmt.Fprintf(w, "event: message\ndata: %s\n\n", msg)
flusher.Flush() flusher.Flush()
case <-ticker.C: case <-ticker.C:
// 心跳保持连接
fmt.Fprintf(w, ": ping\n\n") fmt.Fprintf(w, ": ping\n\n")
flusher.Flush() flusher.Flush()
} }
@@ -311,6 +369,7 @@ func (s *Server) handleListTools(msg *Message) *Message {
tools = append(tools, tool) tools = append(tools, tool)
} }
s.mu.RUnlock() s.mu.RUnlock()
s.logger.Debug("tools/list 请求", zap.Int("返回工具数", len(tools)))
response := ListToolsResponse{Tools: tools} response := ListToolsResponse{Tools: tools}
result, _ := json.Marshal(response) result, _ := json.Marshal(response)
@@ -1110,10 +1169,11 @@ func (s *Server) RegisterResource(resource *Resource) {
} }
// HandleStdio 处理标准输入输出(用于 stdio 传输模式) // HandleStdio 处理标准输入输出(用于 stdio 传输模式)
// MCP 协议使用换行分隔的 JSON-RPC 消息 // MCP 协议使用换行分隔的 JSON-RPC 消息;管道下需每次写入后 Flush,否则客户端会读不到响应
func (s *Server) HandleStdio() error { func (s *Server) HandleStdio() error {
decoder := json.NewDecoder(os.Stdin) decoder := json.NewDecoder(os.Stdin)
encoder := json.NewEncoder(os.Stdout) stdout := bufio.NewWriter(os.Stdout)
encoder := json.NewEncoder(stdout)
// 注意:不设置缩进,MCP 协议期望紧凑的 JSON 格式 // 注意:不设置缩进,MCP 协议期望紧凑的 JSON 格式
for { for {
@@ -1134,6 +1194,9 @@ func (s *Server) HandleStdio() error {
if err := encoder.Encode(errorMsg); err != nil { if err := encoder.Encode(errorMsg); err != nil {
return fmt.Errorf("发送错误响应失败: %w", err) return fmt.Errorf("发送错误响应失败: %w", err)
} }
if err := stdout.Flush(); err != nil {
return fmt.Errorf("刷新 stdout 失败: %w", err)
}
continue continue
} }
@@ -1149,6 +1212,9 @@ func (s *Server) HandleStdio() error {
if err := encoder.Encode(response); err != nil { if err := encoder.Encode(response); err != nil {
return fmt.Errorf("发送响应失败: %w", err) return fmt.Errorf("发送响应失败: %w", err)
} }
if err := stdout.Flush(); err != nil {
return fmt.Errorf("刷新 stdout 失败: %w", err)
}
} }
return nil return nil
+44 -34
View File
@@ -1,11 +1,22 @@
package mcp package mcp
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time" "time"
) )
// ExternalMCPClient 外部 MCP 客户端接口(由 client_sdk.go 基于官方 SDK 实现)
type ExternalMCPClient interface {
Initialize(ctx context.Context) error
ListTools(ctx context.Context) ([]Tool, error)
CallTool(ctx context.Context, name string, args map[string]interface{}) (*ToolResult, error)
Close() error
IsConnected() bool
GetStatus() string
}
// MCP消息类型 // MCP消息类型
const ( const (
MessageTypeRequest = "request" MessageTypeRequest = "request"
@@ -29,21 +40,21 @@ func (m *MessageID) UnmarshalJSON(data []byte) error {
m.value = nil m.value = nil
return nil return nil
} }
// 尝试解析为字符串 // 尝试解析为字符串
var str string var str string
if err := json.Unmarshal(data, &str); err == nil { if err := json.Unmarshal(data, &str); err == nil {
m.value = str m.value = str
return nil return nil
} }
// 尝试解析为数字 // 尝试解析为数字
var num json.Number var num json.Number
if err := json.Unmarshal(data, &num); err == nil { if err := json.Unmarshal(data, &num); err == nil {
m.value = num m.value = num
return nil return nil
} }
return fmt.Errorf("invalid id type") return fmt.Errorf("invalid id type")
} }
@@ -81,15 +92,15 @@ type Message struct {
// Error 表示MCP错误 // Error 表示MCP错误
type Error struct { type Error struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Data interface{} `json:"data,omitempty"` Data interface{} `json:"data,omitempty"`
} }
// Tool 表示MCP工具定义 // Tool 表示MCP工具定义
type Tool struct { type Tool struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` // 详细描述 Description string `json:"description"` // 详细描述
ShortDescription string `json:"shortDescription,omitempty"` // 简短描述(用于工具列表,减少token消耗) ShortDescription string `json:"shortDescription,omitempty"` // 简短描述(用于工具列表,减少token消耗)
InputSchema map[string]interface{} `json:"inputSchema"` InputSchema map[string]interface{} `json:"inputSchema"`
} }
@@ -127,9 +138,9 @@ type ClientInfo struct {
// InitializeResponse 初始化响应 // InitializeResponse 初始化响应
type InitializeResponse struct { type InitializeResponse struct {
ProtocolVersion string `json:"protocolVersion"` ProtocolVersion string `json:"protocolVersion"`
Capabilities ServerCapabilities `json:"capabilities"` Capabilities ServerCapabilities `json:"capabilities"`
ServerInfo ServerInfo `json:"serverInfo"` ServerInfo ServerInfo `json:"serverInfo"`
} }
// ServerCapabilities 服务器能力 // ServerCapabilities 服务器能力
@@ -178,31 +189,31 @@ type CallToolResponse struct {
// ToolExecution 工具执行记录 // ToolExecution 工具执行记录
type ToolExecution struct { type ToolExecution struct {
ID string `json:"id"` ID string `json:"id"`
ToolName string `json:"toolName"` ToolName string `json:"toolName"`
Arguments map[string]interface{} `json:"arguments"` Arguments map[string]interface{} `json:"arguments"`
Status string `json:"status"` // pending, running, completed, failed Status string `json:"status"` // pending, running, completed, failed
Result *ToolResult `json:"result,omitempty"` Result *ToolResult `json:"result,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
StartTime time.Time `json:"startTime"` StartTime time.Time `json:"startTime"`
EndTime *time.Time `json:"endTime,omitempty"` EndTime *time.Time `json:"endTime,omitempty"`
Duration time.Duration `json:"duration,omitempty"` Duration time.Duration `json:"duration,omitempty"`
} }
// ToolStats 工具统计信息 // ToolStats 工具统计信息
type ToolStats struct { type ToolStats struct {
ToolName string `json:"toolName"` ToolName string `json:"toolName"`
TotalCalls int `json:"totalCalls"` TotalCalls int `json:"totalCalls"`
SuccessCalls int `json:"successCalls"` SuccessCalls int `json:"successCalls"`
FailedCalls int `json:"failedCalls"` FailedCalls int `json:"failedCalls"`
LastCallTime *time.Time `json:"lastCallTime,omitempty"` LastCallTime *time.Time `json:"lastCallTime,omitempty"`
} }
// Prompt 提示词模板 // Prompt 提示词模板
type Prompt struct { type Prompt struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Arguments []PromptArgument `json:"arguments,omitempty"` Arguments []PromptArgument `json:"arguments,omitempty"`
} }
// PromptArgument 提示词参数 // PromptArgument 提示词参数
@@ -257,11 +268,11 @@ type ResourceContent struct {
// SamplingRequest 采样请求 // SamplingRequest 采样请求
type SamplingRequest struct { type SamplingRequest struct {
Messages []SamplingMessage `json:"messages"` Messages []SamplingMessage `json:"messages"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
MaxTokens int `json:"maxTokens,omitempty"` MaxTokens int `json:"maxTokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"` Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"topP,omitempty"` TopP float64 `json:"topP,omitempty"`
} }
// SamplingMessage 采样消息 // SamplingMessage 采样消息
@@ -272,9 +283,9 @@ type SamplingMessage struct {
// SamplingResponse 采样响应 // SamplingResponse 采样响应
type SamplingResponse struct { type SamplingResponse struct {
Content []SamplingContent `json:"content"` Content []SamplingContent `json:"content"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
StopReason string `json:"stopReason,omitempty"` StopReason string `json:"stopReason,omitempty"`
} }
// SamplingContent 采样内容 // SamplingContent 采样内容
@@ -282,4 +293,3 @@ type SamplingContent struct {
Type string `json:"type"` Type string `json:"type"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
} }
+6
View File
@@ -0,0 +1,6 @@
package robot
// MessageHandler 供飞书/钉钉长连接调用的消息处理接口(由 handler.RobotHandler 实现)
type MessageHandler interface {
HandleMessage(platform, userID, text string) string
}
+137
View File
@@ -0,0 +1,137 @@
package robot
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"time"
"cyberstrike-ai/internal/config"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
dingutils "github.com/open-dingtalk/dingtalk-stream-sdk-go/utils"
"go.uber.org/zap"
)
const (
dingReconnectInitial = 5 * time.Second // 首次重连间隔
dingReconnectMax = 60 * time.Second // 最大重连间隔
)
// StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。
// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
func StartDing(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) {
if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" {
return
}
go runDingLoop(ctx, cfg, h, logger)
}
// runDingLoop 循环维持钉钉长连接:断开且 ctx 未取消时按退避间隔重连。
func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, h MessageHandler, logger *zap.Logger) {
backoff := dingReconnectInitial
for {
streamClient := client.NewStreamClient(
client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)),
client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get",
chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) {
go handleDingMessage(ctx, msg, h, logger)
return nil, nil
}).OnEventReceived),
)
logger.Info("钉钉 Stream 正在连接…", zap.String("client_id", cfg.ClientID))
err := streamClient.Start(ctx)
if ctx.Err() != nil {
logger.Info("钉钉 Stream 已按配置重启关闭")
return
}
if err != nil {
logger.Warn("钉钉 Stream 长连接断开(如睡眠/断网),将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
// 下次重连间隔递增,上限 60 秒,避免频繁重试
if backoff < dingReconnectMax {
backoff *= 2
if backoff > dingReconnectMax {
backoff = dingReconnectMax
}
}
}
}
}
func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, h MessageHandler, logger *zap.Logger) {
if msg == nil || msg.SessionWebhook == "" {
return
}
content := ""
if msg.Text.Content != "" {
content = strings.TrimSpace(msg.Text.Content)
}
if content == "" && msg.Msgtype == "richText" {
if cMap, ok := msg.Content.(map[string]interface{}); ok {
if rich, ok := cMap["richText"].([]interface{}); ok {
for _, c := range rich {
if m, ok := c.(map[string]interface{}); ok {
if txt, ok := m["text"].(string); ok {
content = strings.TrimSpace(txt)
break
}
}
}
}
}
}
if content == "" {
logger.Debug("钉钉消息内容为空,已忽略", zap.String("msgtype", msg.Msgtype))
return
}
logger.Info("钉钉收到消息", zap.String("sender", msg.SenderId), zap.String("content", content))
userID := msg.SenderId
if userID == "" {
userID = msg.ConversationId
}
reply := h.HandleMessage("dingtalk", userID, content)
// 使用 markdown 类型以便正确展示标题、列表、代码块等格式
title := reply
if idx := strings.IndexAny(reply, "\n"); idx > 0 {
title = strings.TrimSpace(reply[:idx])
}
if len(title) > 50 {
title = title[:50] + "…"
}
if title == "" {
title = "回复"
}
body := map[string]interface{}{
"msgtype": "markdown",
"markdown": map[string]string{
"title": title,
"text": reply,
},
}
bodyBytes, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, msg.SessionWebhook, bytes.NewReader(bodyBytes))
if err != nil {
logger.Warn("钉钉构造回复请求失败", zap.Error(err))
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
logger.Warn("钉钉回复请求失败", zap.Error(err))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Warn("钉钉回复非 200", zap.Int("status", resp.StatusCode))
return
}
logger.Debug("钉钉回复成功", zap.String("content_preview", reply))
}
+111
View File
@@ -0,0 +1,111 @@
package robot
import (
"context"
"encoding/json"
"strings"
"time"
"cyberstrike-ai/internal/config"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
"go.uber.org/zap"
)
const (
larkReconnectInitial = 5 * time.Second // 首次重连间隔
larkReconnectMax = 60 * time.Second // 最大重连间隔
)
type larkTextContent struct {
Text string `json:"text"`
}
// StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。
// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
func StartLark(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) {
if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" {
return
}
go runLarkLoop(ctx, cfg, h, logger)
}
// runLarkLoop 循环维持飞书长连接:断开且 ctx 未取消时按退避间隔重连。
func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, h MessageHandler, logger *zap.Logger) {
backoff := larkReconnectInitial
for {
larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret)
eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
go handleLarkMessage(ctx, event, h, larkClient, logger)
return nil
})
wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret,
larkws.WithEventHandler(eventHandler),
larkws.WithLogLevel(larkcore.LogLevelInfo),
)
logger.Info("飞书长连接正在连接…", zap.String("app_id", cfg.AppID))
err := wsClient.Start(ctx)
if ctx.Err() != nil {
logger.Info("飞书长连接已按配置重启关闭")
return
}
if err != nil {
logger.Warn("飞书长连接断开(如睡眠/断网),将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
if backoff < larkReconnectMax {
backoff *= 2
if backoff > larkReconnectMax {
backoff = larkReconnectMax
}
}
}
}
}
func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, h MessageHandler, client *lark.Client, logger *zap.Logger) {
if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
return
}
msg := event.Event.Message
msgType := larkcore.StringValue(msg.MessageType)
if msgType != larkim.MsgTypeText {
logger.Debug("飞书暂仅处理文本消息", zap.String("msg_type", msgType))
return
}
var textBody larkTextContent
if err := json.Unmarshal([]byte(larkcore.StringValue(msg.Content)), &textBody); err != nil {
logger.Warn("飞书消息 Content 解析失败", zap.Error(err))
return
}
text := strings.TrimSpace(textBody.Text)
if text == "" {
return
}
userID := ""
if event.Event.Sender.SenderId.UserId != nil {
userID = *event.Event.Sender.SenderId.UserId
}
messageID := larkcore.StringValue(msg.MessageId)
reply := h.HandleMessage("lark", userID, text)
contentBytes, _ := json.Marshal(larkTextContent{Text: reply})
_, err := client.Im.Message.Reply(ctx, larkim.NewReplyMessageReqBuilder().
MessageId(messageID).
Body(larkim.NewReplyMessageReqBodyBuilder().
MsgType(larkim.MsgTypeText).
Content(string(contentBytes)).
Build()).
Build())
if err != nil {
logger.Warn("飞书回复失败", zap.String("message_id", messageID), zap.Error(err))
return
}
logger.Debug("飞书已回复", zap.String("message_id", messageID))
}
+43 -8
View File
@@ -224,22 +224,25 @@ func (e *Executor) RegisterTools(mcpServer *mcp.Server) {
toolName := toolConfig.Name toolName := toolConfig.Name
toolConfigCopy := toolConfig toolConfigCopy := toolConfig
// 使用简短描述(如果存在),否则使用详细描述的前100个字符 // 根据配置决定暴露给 AI/API 的描述:short_description 或 description
useFullDescription := strings.TrimSpace(strings.ToLower(e.config.ToolDescriptionMode)) == "full"
shortDesc := toolConfigCopy.ShortDescription shortDesc := toolConfigCopy.ShortDescription
if shortDesc == "" { if shortDesc == "" {
// 如果没有简短描述,从详细描述中提取第一行或前100个字符 // 如果没有简短描述,从详细描述中提取第一行或前10000个字符
desc := toolConfigCopy.Description desc := toolConfigCopy.Description
if len(desc) > 100 { if len(desc) > 10000 {
// 尝试找到第一个换行符 if idx := strings.Index(desc, "\n"); idx > 0 && idx < 10000 {
if idx := strings.Index(desc, "\n"); idx > 0 && idx < 100 {
shortDesc = strings.TrimSpace(desc[:idx]) shortDesc = strings.TrimSpace(desc[:idx])
} else { } else {
shortDesc = desc[:100] + "..." shortDesc = desc[:10000] + "..."
} }
} else { } else {
shortDesc = desc shortDesc = desc
} }
} }
if useFullDescription {
shortDesc = "" // 使用 description 时清空 ShortDescription,下游会回退到 Description
}
tool := mcp.Tool{ tool := mcp.Tool{
Name: toolConfigCopy.Name, Name: toolConfigCopy.Name,
@@ -303,7 +306,23 @@ func (e *Executor) buildCommandArgs(toolName string, toolConfig *config.ToolConf
} }
} }
// 先处理标志参数(对于大多数命令,标志应该在位置参数之前 // 对于需要子命令的工具(如 gobuster dir),position 0 必须紧跟在命令名后、所有 flag 之前
for _, param := range positionalParams {
if param.Name == "additional_args" || param.Name == "scan_type" || param.Name == "action" {
continue
}
if param.Position != nil && *param.Position == 0 {
value := e.getParamValue(args, param)
if value == nil && param.Default != nil {
value = param.Default
}
if value != nil {
cmdArgs = append(cmdArgs, e.formatParamValue(param, value))
}
break
}
}
// 处理标志参数 // 处理标志参数
for _, param := range flagParams { for _, param := range flagParams {
// 跳过特殊参数,它们会在后面单独处理 // 跳过特殊参数,它们会在后面单独处理
@@ -416,7 +435,11 @@ func (e *Executor) buildCommandArgs(toolName string, toolConfig *config.ToolConf
} }
// 按位置顺序处理参数,确保即使某些位置没有参数或使用默认值,也能正确传递 // 按位置顺序处理参数,确保即使某些位置没有参数或使用默认值,也能正确传递
// position 0 已在前面插入(子命令优先),此处从 1 开始
for i := 0; i <= maxPosition; i++ { for i := 0; i <= maxPosition; i++ {
if i == 0 {
continue
}
for _, param := range positionalParams { for _, param := range positionalParams {
// 跳过特殊参数,它们会在后面单独处理 // 跳过特殊参数,它们会在后面单独处理
// action 参数仅用于工具内部逻辑,不传递给命令 // action 参数仅用于工具内部逻辑,不传递给命令
@@ -1190,7 +1213,15 @@ func (e *Executor) buildInputSchema(toolConfig *config.ToolConfig) map[string]in
required := []string{} required := []string{}
for _, param := range toolConfig.Parameters { for _, param := range toolConfig.Parameters {
// 转换类型为OpenAI/JSON Schema标准类型 // 跳过 name 为空的参数(避免 YAML 中 name: null 或空导致非法 schema
if strings.TrimSpace(param.Name) == "" {
e.logger.Debug("跳过无名称的参数",
zap.String("tool", toolConfig.Name),
zap.String("type", param.Type),
)
continue
}
// 转换类型为OpenAI/JSON Schema标准类型(空类型默认为 string)
openAIType := e.convertToOpenAIType(param.Type) openAIType := e.convertToOpenAIType(param.Type)
prop := map[string]interface{}{ prop := map[string]interface{}{
@@ -1232,6 +1263,10 @@ func (e *Executor) buildInputSchema(toolConfig *config.ToolConfig) map[string]in
// convertToOpenAIType 将配置中的类型转换为OpenAI/JSON Schema标准类型 // convertToOpenAIType 将配置中的类型转换为OpenAI/JSON Schema标准类型
func (e *Executor) convertToOpenAIType(configType string) string { func (e *Executor) convertToOpenAIType(configType string) string {
// 空或 null 类型统一视为 string,避免非法 schema 导致工具调用失败
if strings.TrimSpace(configType) == "" {
return "string"
}
switch configType { switch configType {
case "bool": case "bool":
return "boolean" return "boolean"
+3
View File
@@ -12,3 +12,6 @@ uro>=1.0.2
bloodhound>=1.6.1 bloodhound>=1.6.1
impacket>=0.11.0 impacket>=0.11.0
# MCP (Model Context Protocol) SDK
mcp>=1.0.0
+1 -1
View File
@@ -407,7 +407,7 @@ go run cmd/test-config/main.go
### Q: 参数顺序如何控制? ### Q: 参数顺序如何控制?
A: 使用 `position` 字段控制位置参数的顺序。标志参数会按照在 `parameters` 列表中的顺序添加`additional_args` 会追加到命令末尾。 A: 使用 `position` 字段控制位置参数的顺序。**位置 0 的参数(如 gobuster 的 `dir` 子命令)会紧跟在命令名后、所有标志参数之前**,以便兼容需要“子命令 + 选项”形式的 CLI。其余标志参数按在 `parameters` 列表中的顺序添加,再按 position 1、2… 添加其余位置参数`additional_args` 会追加到命令末尾。
## 工具配置模板 ## 工具配置模板
-52
View File
@@ -1,52 +0,0 @@
name: "list-files"
command: "ls"
enabled: true
short_description: "列出目录文件工具"
description: |
列出服务器上指定目录中的文件。
**主要功能:**
- 列出文件
- 显示详细信息
- 递归列出
**使用场景:**
- 目录浏览
- 文件查找
- 系统检查
parameters:
- name: "directory"
type: "string"
description: "要列出的目录(相对于服务器基础目录)"
required: false
default: "."
position: 0
format: "positional"
- name: "long_format"
type: "bool"
description: "显示详细信息(长格式)"
required: false
flag: "-l"
format: "flag"
default: false
- name: "recursive"
type: "bool"
description: "递归列出"
required: false
flag: "-R"
format: "flag"
default: false
- name: "additional_args"
type: "string"
description: |
额外的list-files参数。用于传递未在参数列表中定义的list-files选项。
**示例值:**
- 根据工具特性添加常用参数示例
**注意事项:**
- 多个参数用空格分隔
- 确保参数格式正确,避免命令注入
- 此参数会直接追加到命令末尾
required: false
format: "positional"
+1 -1
View File
@@ -45,7 +45,7 @@ parameters:
- 确保目标地址格式正确 - 确保目标地址格式正确
- 必需参数,不能为空 - 必需参数,不能为空
required: true required: true
position: 0 # 位置参数,放在命令最后 position: 1 # 位置参数,必须放在命令最后nmap [options] target),用 1 确保在 flag 之后、最后添加
format: "positional" format: "positional"
- name: "ports" - name: "ports"
type: "string" type: "string"
+2 -1
View File
@@ -46,8 +46,9 @@ parameters:
**注意事项:** **注意事项:**
- 必需参数,不能为空 - 必需参数,不能为空
- 如果指定进程ID,需要配合 -d 参数使用 - 如果指定进程ID,需要配合 -d 参数使用
- 注意:radare2 要求文件路径必须是最后一个参数,因此 target 使用 position 1
required: true required: true
position: 0 position: 1
format: "positional" format: "positional"
- name: "commands" - name: "commands"
type: "string" type: "string"
+1949 -96
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+150 -61
View File
@@ -3,13 +3,74 @@
let apiSpec = null; let apiSpec = null;
let currentToken = null; let currentToken = null;
function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
}
function waitForI18n() {
return new Promise(function (resolve) {
if (window.t) return resolve();
var n = 0;
var iv = setInterval(function () {
if (window.t) { clearInterval(iv); resolve(); return; }
n++;
if (n >= 100) { clearInterval(iv); resolve(); }
}, 50);
});
}
// 从 OpenAPI spec 的 x-i18n-tags 构建 tag -> i18n key 映射(方案 A:后端提供键)
var apiSpecTagToKey = {};
function buildApiSpecTagToKey() {
apiSpecTagToKey = {};
if (!apiSpec || !apiSpec.paths) return;
Object.keys(apiSpec.paths).forEach(function (path) {
var pathItem = apiSpec.paths[path];
if (!pathItem || typeof pathItem !== 'object') return;
['get', 'post', 'put', 'delete', 'patch'].forEach(function (method) {
var op = pathItem[method];
if (!op || !op.tags || !op['x-i18n-tags']) return;
var tags = op.tags;
var keys = op['x-i18n-tags'];
for (var i = 0; i < tags.length && i < keys.length; i++) {
apiSpecTagToKey[tags[i]] = typeof keys[i] === 'string' ? keys[i] : keys[i];
}
});
});
}
function translateApiDocTag(tag) {
if (!tag) return tag;
var key = apiSpecTagToKey[tag];
return key ? _t('apiDocs.tags.' + key) : tag;
}
function translateApiDocSummaryFromOp(op) {
var key = op && op['x-i18n-summary'];
if (key) return _t('apiDocs.summary.' + key);
return op && op.summary ? op.summary : '';
}
function translateApiDocResponseDescFromResp(resp) {
if (!resp) return '';
var key = resp['x-i18n-description'];
if (key) return _t('apiDocs.response.' + key);
return resp.description || '';
}
// 初始化 // 初始化
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
await waitForI18n();
await loadToken(); await loadToken();
await loadAPISpec(); await loadAPISpec();
if (apiSpec) { if (apiSpec) {
renderAPIDocs(); renderAPIDocs();
} }
document.addEventListener('languagechange', function () {
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(document);
}
if (apiSpec) {
renderAPIDocs();
}
});
}); });
// 加载token // 加载token
@@ -43,22 +104,25 @@ async function loadAPISpec() {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
showError('需要登录才能查看API文档。请先在前端页面登录,然后刷新此页面。'); showError(_t('apiDocs.errorLoginRequired'));
return; return;
} }
throw new Error('加载API规范失败: ' + response.status); throw new Error(_t('apiDocs.errorLoadSpec') + response.status);
} }
apiSpec = await response.json(); apiSpec = await response.json();
buildApiSpecTagToKey();
} catch (error) { } catch (error) {
console.error('加载API规范失败:', error); console.error('加载API规范失败:', error);
showError('加载API文档失败: ' + error.message); showError(_t('apiDocs.errorLoadFailed') + error.message);
} }
} }
// 显示错误 // 显示错误
function showError(message) { function showError(message) {
const main = document.getElementById('api-docs-main'); const main = document.getElementById('api-docs-main');
const loadFailed = _t('apiDocs.loadFailed');
const backToLogin = _t('apiDocs.backToLogin');
main.innerHTML = ` main.innerHTML = `
<div class="empty-state"> <div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -66,10 +130,10 @@ function showError(message) {
<line x1="15" y1="9" x2="9" y2="15"/> <line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/> <line x1="9" y1="9" x2="15" y2="15"/>
</svg> </svg>
<h3>加载失败</h3> <h3>${escapeHtml(loadFailed)}</h3>
<p>${message}</p> <p>${escapeHtml(message)}</p>
<div style="margin-top: 16px;"> <div style="margin-top: 16px;">
<a href="/" style="color: var(--accent-color); text-decoration: none;">返回首页登录</a> <a href="/" style="color: var(--accent-color); text-decoration: none;">${escapeHtml(backToLogin)}</a>
</div> </div>
</div> </div>
`; `;
@@ -78,7 +142,7 @@ function showError(message) {
// 渲染API文档 // 渲染API文档
function renderAPIDocs() { function renderAPIDocs() {
if (!apiSpec || !apiSpec.paths) { if (!apiSpec || !apiSpec.paths) {
showError('API规范格式错误'); showError(_t('apiDocs.errorSpecInvalid'));
return; return;
} }
@@ -109,7 +173,7 @@ function renderAuthInfo() {
tokenStatus.style.display = 'block'; tokenStatus.style.display = 'block';
tokenStatus.style.background = 'rgba(255, 152, 0, 0.1)'; tokenStatus.style.background = 'rgba(255, 152, 0, 0.1)';
tokenStatus.style.borderLeftColor = '#ff9800'; tokenStatus.style.borderLeftColor = '#ff9800';
tokenStatus.innerHTML = '<p style="margin: 0; font-size: 0.8125rem; color: #ff9800;"><strong>⚠ 未检测到 Token</strong> - 请先在前端页面登录,然后刷新此页面。测试接口时需要在请求头中添加 Authorization: Bearer token</p>'; tokenStatus.innerHTML = '<p style="margin: 0; font-size: 0.8125rem; color: #ff9800;">' + escapeHtml(_t('apiDocs.tokenNotDetected')) + '</p>';
} }
} }
@@ -127,11 +191,14 @@ function renderSidebar() {
const groupList = document.getElementById('api-group-list'); const groupList = document.getElementById('api-group-list');
const allGroups = Array.from(groups).sort(); const allGroups = Array.from(groups).sort();
while (groupList.children.length > 1) {
groupList.removeChild(groupList.lastChild);
}
allGroups.forEach(group => { allGroups.forEach(group => {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = 'api-group-item'; li.className = 'api-group-item';
li.innerHTML = `<a href="#" class="api-group-link" data-group="${group}">${group}</a>`; const groupLabel = translateApiDocTag(group);
li.innerHTML = `<a href="#" class="api-group-link" data-group="${escapeHtml(group)}">${escapeHtml(groupLabel)}</a>`;
groupList.appendChild(li); groupList.appendChild(li);
}); });
@@ -176,7 +243,7 @@ function renderEndpoints(filterGroup = null) {
}); });
if (endpoints.length === 0) { if (endpoints.length === 0) {
main.innerHTML = '<div class="empty-state"><h3>暂无API</h3><p>该分组下没有API端点</p></div>'; main.innerHTML = '<div class="empty-state"><h3>' + escapeHtml(_t('apiDocs.noApis')) + '</h3><p>' + escapeHtml(_t('apiDocs.noEndpointsInGroup')) + '</p></div>';
return; return;
} }
@@ -192,8 +259,8 @@ function createEndpointCard(endpoint) {
const methodClass = endpoint.method.toLowerCase(); const methodClass = endpoint.method.toLowerCase();
const tags = endpoint.tags || []; const tags = endpoint.tags || [];
const tagHtml = tags.map(tag => `<span class="api-tag">${tag}</span>`).join(''); const tagHtml = tags.map(tag => `<span class="api-tag">${escapeHtml(translateApiDocTag(tag))}</span>`).join('');
const summaryText = translateApiDocSummaryFromOp(endpoint);
card.innerHTML = ` card.innerHTML = `
<div class="api-endpoint-header"> <div class="api-endpoint-header">
<div class="api-endpoint-title"> <div class="api-endpoint-title">
@@ -204,21 +271,21 @@ function createEndpointCard(endpoint) {
</div> </div>
<div class="api-endpoint-body"> <div class="api-endpoint-body">
<div class="api-section"> <div class="api-section">
<div class="api-section-title">描述</div> <div class="api-section-title">${escapeHtml(_t('apiDocs.sectionDescription'))}</div>
${endpoint.summary ? `<div class="api-description" style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(endpoint.summary)}</div>` : ''} ${summaryText ? `<div class="api-description" style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(summaryText)}</div>` : ''}
${endpoint.description ? ` ${endpoint.description ? `
<div class="api-description-toggle"> <div class="api-description-toggle">
<button class="description-toggle-btn" onclick="toggleDescription(this)"> <button class="description-toggle-btn" onclick="toggleDescription(this)">
<svg class="description-toggle-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="description-toggle-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/> <polyline points="6 9 12 15 18 9"/>
</svg> </svg>
<span>查看详细说明</span> <span>${escapeHtml(_t('apiDocs.viewDetailDesc'))}</span>
</button> </button>
<div class="api-description-detail" style="display: none;"> <div class="api-description-detail" style="display: none;">
${formatDescription(endpoint.description)} ${formatDescription(endpoint.description)}
</div> </div>
</div> </div>
` : endpoint.summary ? '' : '<div class="api-description">无描述</div>'} ` : endpoint.summary ? '' : '<div class="api-description">' + escapeHtml(_t('apiDocs.noDescription')) + '</div>'}
</div> </div>
${renderParameters(endpoint)} ${renderParameters(endpoint)}
@@ -236,8 +303,10 @@ function renderParameters(endpoint) {
const params = endpoint.parameters || []; const params = endpoint.parameters || [];
if (params.length === 0) return ''; if (params.length === 0) return '';
const requiredLabel = escapeHtml(_t('apiDocs.required'));
const optionalLabel = escapeHtml(_t('apiDocs.optional'));
const rows = params.map(param => { const rows = params.map(param => {
const required = param.required ? '<span class="api-param-required">必需</span>' : '<span class="api-param-optional">可选</span>'; const required = param.required ? '<span class="api-param-required">' + requiredLabel + '</span>' : '<span class="api-param-optional">' + optionalLabel + '</span>';
// 处理描述文本,将换行符转换为<br> // 处理描述文本,将换行符转换为<br>
let descriptionHtml = '-'; let descriptionHtml = '-';
if (param.description) { if (param.description) {
@@ -255,17 +324,20 @@ function renderParameters(endpoint) {
`; `;
}).join(''); }).join('');
const paramName = escapeHtml(_t('apiDocs.paramName'));
const typeLabel = escapeHtml(_t('apiDocs.type'));
const descLabel = escapeHtml(_t('apiDocs.description'));
return ` return `
<div class="api-section"> <div class="api-section">
<div class="api-section-title">参数</div> <div class="api-section-title">${escapeHtml(_t('apiDocs.sectionParams'))}</div>
<div class="api-table-wrapper"> <div class="api-table-wrapper">
<table class="api-params-table"> <table class="api-params-table">
<thead> <thead>
<tr> <tr>
<th>参数名</th> <th>${paramName}</th>
<th>类型</th> <th>${typeLabel}</th>
<th>描述</th> <th>${descLabel}</th>
<th>必需</th> <th>${requiredLabel}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -297,11 +369,13 @@ function renderRequestBody(endpoint) {
let paramsTable = ''; let paramsTable = '';
if (schema.properties) { if (schema.properties) {
const requiredFields = schema.required || []; const requiredFields = schema.required || [];
const reqLabel = escapeHtml(_t('apiDocs.required'));
const optLabel = escapeHtml(_t('apiDocs.optional'));
const rows = Object.keys(schema.properties).map(key => { const rows = Object.keys(schema.properties).map(key => {
const prop = schema.properties[key]; const prop = schema.properties[key];
const required = requiredFields.includes(key) const required = requiredFields.includes(key)
? '<span class="api-param-required">必需</span>' ? '<span class="api-param-required">' + reqLabel + '</span>'
: '<span class="api-param-optional">可选</span>'; : '<span class="api-param-optional">' + optLabel + '</span>';
// 处理嵌套类型 // 处理嵌套类型
let typeDisplay = prop.type || 'object'; let typeDisplay = prop.type || 'object';
@@ -338,16 +412,20 @@ function renderRequestBody(endpoint) {
}).join(''); }).join('');
if (rows) { if (rows) {
const pName = escapeHtml(_t('apiDocs.paramName'));
const tLabel = escapeHtml(_t('apiDocs.type'));
const dLabel = escapeHtml(_t('apiDocs.description'));
const exLabel = escapeHtml(_t('apiDocs.example'));
paramsTable = ` paramsTable = `
<div class="api-table-wrapper" style="margin-top: 12px;"> <div class="api-table-wrapper" style="margin-top: 12px;">
<table class="api-params-table"> <table class="api-params-table">
<thead> <thead>
<tr> <tr>
<th>参数名</th> <th>${pName}</th>
<th>类型</th> <th>${tLabel}</th>
<th>描述</th> <th>${dLabel}</th>
<th>必需</th> <th>${reqLabel}</th>
<th>示例</th> <th>${exLabel}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -389,12 +467,12 @@ function renderRequestBody(endpoint) {
return ` return `
<div class="api-section"> <div class="api-section">
<div class="api-section-title">请求体</div> <div class="api-section-title">${escapeHtml(_t('apiDocs.sectionRequestBody'))}</div>
${endpoint.requestBody.description ? `<div class="api-description">${endpoint.requestBody.description}</div>` : ''} ${endpoint.requestBody.description ? `<div class="api-description">${endpoint.requestBody.description}</div>` : ''}
${paramsTable} ${paramsTable}
${example ? ` ${example ? `
<div style="margin-top: 16px;"> <div style="margin-top: 16px;">
<div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">示例JSON:</div> <div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(_t('apiDocs.exampleJson'))}</div>
<div class="api-response-example"> <div class="api-response-example">
<pre>${escapeHtml(example)}</pre> <pre>${escapeHtml(example)}</pre>
</div> </div>
@@ -414,11 +492,11 @@ function renderResponses(endpoint) {
if (schema.example) { if (schema.example) {
example = JSON.stringify(schema.example, null, 2); example = JSON.stringify(schema.example, null, 2);
} }
const descText = translateApiDocResponseDescFromResp(response);
return ` return `
<div style="margin-bottom: 16px;"> <div style="margin-bottom: 16px;">
<strong style="color: ${status.startsWith('2') ? 'var(--success-color)' : status.startsWith('4') ? 'var(--error-color)' : 'var(--warning-color)'}">${status}</strong> <strong style="color: ${status.startsWith('2') ? 'var(--success-color)' : status.startsWith('4') ? 'var(--error-color)' : 'var(--warning-color)'}">${status}</strong>
${response.description ? `<span style="color: var(--text-secondary); margin-left: 8px;">${response.description}</span>` : ''} ${descText ? `<span style="color: var(--text-secondary); margin-left: 8px;">${escapeHtml(descText)}</span>` : ''}
${example ? ` ${example ? `
<div class="api-response-example" style="margin-top: 8px;"> <div class="api-response-example" style="margin-top: 8px;">
<pre>${escapeHtml(example)}</pre> <pre>${escapeHtml(example)}</pre>
@@ -432,7 +510,7 @@ function renderResponses(endpoint) {
return ` return `
<div class="api-section"> <div class="api-section">
<div class="api-section-title">响应</div> <div class="api-section-title">${escapeHtml(_t('apiDocs.sectionResponse'))}</div>
${responseItems} ${responseItems}
</div> </div>
`; `;
@@ -462,8 +540,8 @@ function renderTestSection(endpoint) {
const bodyInputId = `test-body-${escapeId(path)}-${method}`; const bodyInputId = `test-body-${escapeId(path)}-${method}`;
bodyInput = ` bodyInput = `
<div class="api-test-input-group"> <div class="api-test-input-group">
<label>请求体 (JSON)</label> <label>${escapeHtml(_t('apiDocs.requestBodyJson'))}</label>
<textarea id="${bodyInputId}" class="test-body-input" placeholder='请输入JSON格式的请求体'>${defaultBody}</textarea> <textarea id="${bodyInputId}" class="test-body-input" placeholder='${escapeHtml(_t('apiDocs.requestBodyPlaceholder'))}'>${defaultBody}</textarea>
</div> </div>
`; `;
} }
@@ -491,7 +569,7 @@ function renderTestSection(endpoint) {
const inputId = `test-query-${param.name}-${escapeId(path)}-${method}`; const inputId = `test-query-${param.name}-${escapeId(path)}-${method}`;
const defaultValue = param.schema?.default !== undefined ? param.schema.default : ''; const defaultValue = param.schema?.default !== undefined ? param.schema.default : '';
const placeholder = param.description || param.name; const placeholder = param.description || param.name;
const required = param.required ? '<span style="color: var(--error-color);">*</span>' : '<span style="color: var(--text-muted);">可选</span>'; const required = param.required ? '<span style="color: var(--error-color);">*</span>' : '<span style="color: var(--text-muted);">' + escapeHtml(_t('apiDocs.optional')) + '</span>';
return ` return `
<div class="api-test-input-group"> <div class="api-test-input-group">
<label>${param.name} ${required}</label> <label>${param.name} ${required}</label>
@@ -505,29 +583,40 @@ function renderTestSection(endpoint) {
}).join(''); }).join('');
} }
const testSectionTitle = escapeHtml(_t('apiDocs.testSection'));
const queryParamsTitle = escapeHtml(_t('apiDocs.queryParams'));
const sendRequestLabel = escapeHtml(_t('apiDocs.sendRequest'));
const copyCurlLabel = escapeHtml(_t('apiDocs.copyCurl'));
const clearResultLabel = escapeHtml(_t('apiDocs.clearResult'));
const copyCurlTitle = escapeHtml(_t('apiDocs.copyCurlTitle'));
const clearResultTitle = escapeHtml(_t('apiDocs.clearResultTitle'));
return ` return `
<div class="api-test-section"> <div class="api-test-section">
<div class="api-section-title">测试接口</div> <div class="api-section-title">${testSectionTitle}</div>
<div class="api-test-form"> <div class="api-test-form">
${pathParamsInput} ${pathParamsInput}
${queryParamsInput ? `<div style="margin-top: 16px;"><div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">查询参数:</div>${queryParamsInput}</div>` : ''} ${queryParamsInput ? `<div style="margin-top: 16px;"><div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${queryParamsTitle}</div>${queryParamsInput}</div>` : ''}
${bodyInput} ${bodyInput}
<div class="api-test-buttons"> <div class="api-test-buttons">
<button class="api-test-btn primary" onclick="testAPI('${method}', '${escapeHtml(path)}', '${endpoint.operationId || ''}')"> <button class="api-test-btn primary" onclick="testAPI('${method}', '${escapeHtml(path)}', '${endpoint.operationId || ''}')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/> <polygon points="5 3 19 12 5 21 5 3"/>
</svg> </svg>
发送请求 ${sendRequestLabel}
</button> </button>
<button class="api-test-btn secondary" onclick="copyCurlCommand(event, '${method}', '${escapeHtml(path)}')" title="复制curl命令"> <button class="api-test-btn copy-curl" onclick="copyCurlCommand(event, '${method}', '${escapeHtml(path)}')" title="${copyCurlTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="2"/> <rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2"/> <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2"/>
</svg> </svg>
复制curl ${copyCurlLabel}
</button> </button>
<button class="api-test-btn secondary" onclick="clearTestResult('${escapeId(path)}-${method}')"> <button class="api-test-btn clear-result" onclick="clearTestResult('${escapeId(path)}-${method}')" title="${clearResultTitle}">
清除结果 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
${clearResultLabel}
</button> </button>
</div> </div>
<div id="test-result-${escapeId(path)}-${method}" class="api-test-result" style="display: none;"></div> <div id="test-result-${escapeId(path)}-${method}" class="api-test-result" style="display: none;"></div>
@@ -544,7 +633,7 @@ async function testAPI(method, path, operationId) {
resultDiv.style.display = 'block'; resultDiv.style.display = 'block';
resultDiv.className = 'api-test-result loading'; resultDiv.className = 'api-test-result loading';
resultDiv.textContent = '发送请求中...'; resultDiv.textContent = _t('apiDocs.sendingRequest');
try { try {
// 替换路径参数 // 替换路径参数
@@ -557,7 +646,7 @@ async function testAPI(method, path, operationId) {
if (input && input.value) { if (input && input.value) {
actualPath = actualPath.replace(param, encodeURIComponent(input.value)); actualPath = actualPath.replace(param, encodeURIComponent(input.value));
} else { } else {
throw new Error(`路径参数 ${paramName} 不能为空`); throw new Error(_t('apiDocs.errorPathParamRequired', { name: paramName }));
} }
}); });
@@ -576,7 +665,7 @@ async function testAPI(method, path, operationId) {
if (input && input.value !== '' && input.value !== null && input.value !== undefined) { if (input && input.value !== '' && input.value !== null && input.value !== undefined) {
queryParams.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(input.value)}`); queryParams.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(input.value)}`);
} else if (param.required) { } else if (param.required) {
throw new Error(`查询参数 ${param.name} 不能为空`); throw new Error(_t('apiDocs.errorQueryParamRequired', { name: param.name }));
} }
}); });
} }
@@ -598,8 +687,7 @@ async function testAPI(method, path, operationId) {
if (currentToken) { if (currentToken) {
options.headers['Authorization'] = 'Bearer ' + currentToken; options.headers['Authorization'] = 'Bearer ' + currentToken;
} else { } else {
// 如果没有token,提示用户 throw new Error(_t('apiDocs.errorTokenRequired'));
throw new Error('未检测到 Token。请先在前端页面登录,然后刷新此页面。或者手动在请求头中添加 Authorization: Bearer your_token');
} }
// 添加请求体 // 添加请求体
@@ -610,7 +698,7 @@ async function testAPI(method, path, operationId) {
try { try {
options.body = JSON.stringify(JSON.parse(bodyInput.value.trim())); options.body = JSON.stringify(JSON.parse(bodyInput.value.trim()));
} catch (e) { } catch (e) {
throw new Error('请求体JSON格式错误: ' + e.message); throw new Error(_t('apiDocs.errorJsonInvalid') + e.message);
} }
} }
} }
@@ -632,7 +720,7 @@ async function testAPI(method, path, operationId) {
} catch (error) { } catch (error) {
resultDiv.className = 'api-test-result error'; resultDiv.className = 'api-test-result error';
resultDiv.textContent = '请求失败: ' + error.message; resultDiv.textContent = _t('apiDocs.requestFailed') + error.message;
} }
} }
@@ -723,17 +811,17 @@ function copyCurlCommand(event, method, path) {
// 复制到剪贴板 // 复制到剪贴板
const button = event ? event.target.closest('button') : null; const button = event ? event.target.closest('button') : null;
navigator.clipboard.writeText(curlCommand).then(() => { navigator.clipboard.writeText(curlCommand).then(() => {
// 显示成功提示
if (button) { if (button) {
const originalText = button.innerHTML; const originalText = button.innerHTML;
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>已复制'; const copiedLabel = escapeHtml(_t('apiDocs.copied'));
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>' + copiedLabel;
button.style.color = 'var(--success-color)'; button.style.color = 'var(--success-color)';
setTimeout(() => { setTimeout(() => {
button.innerHTML = originalText; button.innerHTML = originalText;
button.style.color = ''; button.style.color = '';
}, 2000); }, 2000);
} else { } else {
alert('curl命令已复制到剪贴板!'); alert(_t('apiDocs.curlCopied'));
} }
}).catch(err => { }).catch(err => {
console.error('复制失败:', err); console.error('复制失败:', err);
@@ -748,24 +836,25 @@ function copyCurlCommand(event, method, path) {
document.execCommand('copy'); document.execCommand('copy');
if (button) { if (button) {
const originalText = button.innerHTML; const originalText = button.innerHTML;
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>已复制'; const copiedLabel = escapeHtml(_t('apiDocs.copied'));
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>' + copiedLabel;
button.style.color = 'var(--success-color)'; button.style.color = 'var(--success-color)';
setTimeout(() => { setTimeout(() => {
button.innerHTML = originalText; button.innerHTML = originalText;
button.style.color = ''; button.style.color = '';
}, 2000); }, 2000);
} else { } else {
alert('curl命令已复制到剪贴板!'); alert(_t('apiDocs.curlCopied'));
} }
} catch (e) { } catch (e) {
alert('复制失败,请手动复制:\n\n' + curlCommand); alert(_t('apiDocs.copyFailedManual') + curlCommand);
} }
document.body.removeChild(textarea); document.body.removeChild(textarea);
}); });
} catch (error) { } catch (error) {
console.error('生成curl命令失败:', error); console.error('生成curl命令失败:', error);
alert('生成curl命令失败: ' + error.message); alert(_t('apiDocs.curlGenFailed') + error.message);
} }
} }
@@ -931,10 +1020,10 @@ function toggleDescription(button) {
if (detail.style.display === 'none') { if (detail.style.display === 'none') {
detail.style.display = 'block'; detail.style.display = 'block';
icon.style.transform = 'rotate(180deg)'; icon.style.transform = 'rotate(180deg)';
span.textContent = '隐藏详细说明'; span.textContent = typeof window.t === 'function' ? window.t('apiDocs.hideDetailDesc') : '隐藏详细说明';
} else { } else {
detail.style.display = 'none'; detail.style.display = 'none';
icon.style.transform = 'rotate(0deg)'; icon.style.transform = 'rotate(0deg)';
span.textContent = '查看详细说明'; span.textContent = typeof window.t === 'function' ? window.t('apiDocs.viewDetailDesc') : '查看详细说明';
} }
} }
+34 -14
View File
@@ -123,12 +123,20 @@ async function ensureAuthenticated() {
return true; return true;
} }
function handleUnauthorized({ message = '认证已过期,请重新登录', silent = false } = {}) { function handleUnauthorized({ message = null, silent = false } = {}) {
clearAuthStorage(); clearAuthStorage();
authPromise = null; authPromise = null;
authPromiseResolvers = []; authPromiseResolvers = [];
let finalMessage = message;
if (!finalMessage) {
if (typeof window !== 'undefined' && typeof window.t === 'function') {
finalMessage = window.t('auth.sessionExpired');
} else {
finalMessage = '认证已过期,请重新登录';
}
}
if (!silent) { if (!silent) {
showLoginOverlay(message); showLoginOverlay(finalMessage);
} else { } else {
showLoginOverlay(); showLoginOverlay();
} }
@@ -147,7 +155,10 @@ async function apiFetch(url, options = {}) {
const response = await fetch(url, opts); const response = await fetch(url, opts);
if (response.status === 401) { if (response.status === 401) {
handleUnauthorized(); handleUnauthorized();
throw new Error('未授权访问'); const msg = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('auth.unauthorized')
: '未授权访问';
throw new Error(msg);
} }
return response; return response;
} }
@@ -165,7 +176,10 @@ async function submitLogin(event) {
const password = passwordInput.value.trim(); const password = passwordInput.value.trim();
if (!password) { if (!password) {
if (errorBox) { if (errorBox) {
errorBox.textContent = '请输入密码'; const msgEmpty = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('auth.enterPassword')
: '请输入密码';
errorBox.textContent = msgEmpty;
errorBox.style.display = 'block'; errorBox.style.display = 'block';
} }
return; return;
@@ -186,7 +200,10 @@ async function submitLogin(event) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
if (!response.ok || !result.token) { if (!response.ok || !result.token) {
if (errorBox) { if (errorBox) {
errorBox.textContent = result.error || '登录失败,请检查密码'; const fallback = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('auth.loginFailedCheck')
: '登录失败,请检查密码';
errorBox.textContent = result.error || fallback;
errorBox.style.display = 'block'; errorBox.style.display = 'block';
} }
return; return;
@@ -203,7 +220,10 @@ async function submitLogin(event) {
} catch (error) { } catch (error) {
console.error('登录失败:', error); console.error('登录失败:', error);
if (errorBox) { if (errorBox) {
errorBox.textContent = '登录失败,请稍后重试'; const fallback = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('auth.loginFailedRetry')
: '登录失败,请稍后重试';
errorBox.textContent = fallback;
errorBox.style.display = 'block'; errorBox.style.display = 'block';
} }
} finally { } finally {
@@ -230,13 +250,13 @@ async function bootstrapApp() {
// 通用工具函数 // 通用工具函数
function getStatusText(status) { function getStatusText(status) {
const statusMap = { if (typeof window.t !== 'function') {
'pending': '等待中', const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败' };
'running': '执行中', return fallback[status] || status;
'completed': '已完成', }
'failed': '失败' const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed' };
}; const key = keyMap[status];
return statusMap[status] || status; return key ? window.t(key) : status;
} }
function formatDuration(ms) { function formatDuration(ms) {
@@ -375,7 +395,7 @@ async function logout() {
// 无论如何都清除本地认证信息 // 无论如何都清除本地认证信息
clearAuthStorage(); clearAuthStorage();
hideLoginOverlay(); hideLoginOverlay();
showLoginOverlay('已退出登录'); showLoginOverlay(typeof window.t === 'function' ? window.t('auth.loggedOut') : '已退出登录');
} }
} }
+476 -123
View File
File diff suppressed because it is too large Load Diff
+350
View File
@@ -0,0 +1,350 @@
// 仪表盘页面:拉取运行中任务、漏洞统计、批量任务、工具与 Skills 统计并渲染
async function refreshDashboard() {
const runningEl = document.getElementById('dashboard-running-tasks');
const vulnTotalEl = document.getElementById('dashboard-vuln-total');
const severityIds = ['critical', 'high', 'medium', 'low', 'info'];
if (runningEl) runningEl.textContent = '…';
if (vulnTotalEl) vulnTotalEl.textContent = '…';
severityIds.forEach(s => {
const el = document.getElementById('dashboard-severity-' + s);
if (el) el.textContent = '0';
const barEl = document.getElementById('dashboard-bar-' + s);
if (barEl) barEl.style.width = '0%';
});
setDashboardOverviewPlaceholder('…');
setEl('dashboard-kpi-tools-calls', '…');
setEl('dashboard-kpi-success-rate', '…');
var chartPlaceholder = document.getElementById('dashboard-tools-pie-placeholder');
if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); }
var barChartEl = document.getElementById('dashboard-tools-bar-chart');
if (barChartEl) { barChartEl.style.display = 'none'; barChartEl.innerHTML = ''; }
if (typeof apiFetch === 'undefined') {
if (runningEl) runningEl.textContent = '-';
if (vulnTotalEl) vulnTotalEl.textContent = '-';
setDashboardOverviewPlaceholder('-');
return;
}
try {
const [tasksRes, vulnRes, batchRes, monitorRes, knowledgeRes, skillsRes] = await Promise.all([
apiFetch('/api/agent-loop/tasks').then(r => r.ok ? r.json() : null).catch(() => null),
apiFetch('/api/vulnerabilities/stats').then(r => r.ok ? r.json() : null).catch(() => null),
apiFetch('/api/batch-tasks?limit=500&page=1').then(r => r.ok ? r.json() : null).catch(() => null),
apiFetch('/api/monitor/stats').then(r => r.ok ? r.json() : null).catch(() => null),
apiFetch('/api/knowledge/stats').then(r => r.ok ? r.json() : null).catch(() => null),
apiFetch('/api/skills/stats').then(r => r.ok ? r.json() : null).catch(() => null)
]);
if (tasksRes && Array.isArray(tasksRes.tasks)) {
if (runningEl) runningEl.textContent = String(tasksRes.tasks.length);
} else {
if (runningEl) runningEl.textContent = '-';
}
if (vulnRes && typeof vulnRes.total === 'number') {
if (vulnTotalEl) vulnTotalEl.textContent = String(vulnRes.total);
const bySeverity = vulnRes.by_severity || {};
const total = vulnRes.total || 0;
severityIds.forEach(sev => {
const count = bySeverity[sev] || 0;
const el = document.getElementById('dashboard-severity-' + sev);
if (el) el.textContent = String(count);
const barEl = document.getElementById('dashboard-bar-' + sev);
if (barEl) barEl.style.width = total > 0 ? (count / total * 100) + '%' : '0%';
});
} else {
if (vulnTotalEl) vulnTotalEl.textContent = '-';
severityIds.forEach(sev => {
const barEl = document.getElementById('dashboard-bar-' + sev);
if (barEl) barEl.style.width = '0%';
});
}
// 批量任务队列:按状态统计(优化版)
if (batchRes && Array.isArray(batchRes.queues)) {
const queues = batchRes.queues;
let pending = 0, running = 0, done = 0;
queues.forEach(q => {
const s = (q.status || '').toLowerCase();
if (s === 'pending' || s === 'paused') pending++;
else if (s === 'running') running++;
else if (s === 'completed' || s === 'cancelled') done++;
});
const total = pending + running + done;
setEl('dashboard-batch-pending', String(pending));
setEl('dashboard-batch-running', String(running));
setEl('dashboard-batch-done', String(done));
setEl('dashboard-batch-total', total > 0 ? (typeof window.t === 'function' ? window.t('dashboard.totalCount', { count: total }) : `${total}`) : (typeof window.t === 'function' ? window.t('dashboard.noTasks') : '暂无任务'));
// 更新进度条
if (total > 0) {
const pendingPct = (pending / total * 100).toFixed(1);
const runningPct = (running / total * 100).toFixed(1);
const donePct = (done / total * 100).toFixed(1);
updateProgressBar('dashboard-batch-progress-pending', pendingPct);
updateProgressBar('dashboard-batch-progress-running', runningPct);
updateProgressBar('dashboard-batch-progress-done', donePct);
} else {
updateProgressBar('dashboard-batch-progress-pending', '0');
updateProgressBar('dashboard-batch-progress-running', '0');
updateProgressBar('dashboard-batch-progress-done', '0');
}
} else {
setEl('dashboard-batch-pending', '-');
setEl('dashboard-batch-running', '-');
setEl('dashboard-batch-done', '-');
setEl('dashboard-batch-total', '-');
updateProgressBar('dashboard-batch-progress-pending', '0');
updateProgressBar('dashboard-batch-progress-running', '0');
updateProgressBar('dashboard-batch-progress-done', '0');
}
// 工具调用:monitor/stats 为 { toolName: { totalCalls, successCalls, failedCalls, ... } }(优化版)
if (monitorRes && typeof monitorRes === 'object') {
const names = Object.keys(monitorRes);
let totalCalls = 0, totalSuccess = 0, totalFailed = 0;
names.forEach(k => {
const v = monitorRes[k];
const n = v && (v.totalCalls ?? v.TotalCalls);
if (typeof n === 'number') totalCalls += n;
const s = v && (v.successCalls ?? v.SuccessCalls);
if (typeof s === 'number') totalSuccess += s;
const f = v && (v.failedCalls ?? v.FailedCalls);
if (typeof f === 'number') totalFailed += f;
});
setEl('dashboard-tools-count', String(names.length));
setEl('dashboard-tools-calls', formatNumber(totalCalls));
setEl('dashboard-kpi-tools-calls', String(totalCalls));
var rateStr = totalCalls > 0 ? ((totalSuccess / totalCalls) * 100).toFixed(1) + '%' : '-';
setEl('dashboard-kpi-success-rate', rateStr);
setEl('dashboard-tools-success-rate', rateStr !== '-' ? `成功率 ${rateStr}` : '-');
renderDashboardToolsBar(monitorRes);
} else {
setEl('dashboard-tools-count', '-');
setEl('dashboard-tools-calls', '-');
setEl('dashboard-kpi-tools-calls', '-');
setEl('dashboard-kpi-success-rate', '-');
setEl('dashboard-tools-success-rate', '-');
renderDashboardToolsBar(null);
}
// 知识:{ enabled, total_categories, total_items, ... }(优化版)
const knowledgeItemsEl = document.getElementById('dashboard-knowledge-items');
const knowledgeCategoriesEl = document.getElementById('dashboard-knowledge-categories');
const knowledgeStatusEl = document.getElementById('dashboard-knowledge-status');
if (knowledgeRes && typeof knowledgeRes === 'object') {
if (knowledgeRes.enabled === false) {
// 功能未启用:用状态标签展示,数值保持为 "-"
if (knowledgeStatusEl) knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.notEnabled') : '未启用');
if (knowledgeItemsEl) knowledgeItemsEl.textContent = '-';
if (knowledgeCategoriesEl) knowledgeCategoriesEl.textContent = '-';
} else {
const categories = knowledgeRes.total_categories ?? 0;
const items = knowledgeRes.total_items ?? 0;
if (knowledgeItemsEl) knowledgeItemsEl.textContent = formatNumber(items);
if (knowledgeCategoriesEl) knowledgeCategoriesEl.textContent = formatNumber(categories);
// 根据数据量给个轻量状态文案
if (knowledgeStatusEl) {
if (items > 0 || categories > 0) {
knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.enabled') : '已启用');
} else {
knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.toConfigure') : '待配置');
}
}
}
} else {
if (knowledgeItemsEl) knowledgeItemsEl.textContent = '-';
if (knowledgeCategoriesEl) knowledgeCategoriesEl.textContent = '-';
if (knowledgeStatusEl) knowledgeStatusEl.textContent = '-';
}
// Skills{ total_skills, total_calls, ... }(优化版)
if (skillsRes && typeof skillsRes === 'object') {
const totalSkills = skillsRes.total_skills ?? 0;
const totalCalls = skillsRes.total_calls ?? 0;
setEl('dashboard-skills-count', formatNumber(totalSkills));
setEl('dashboard-skills-calls', formatNumber(totalCalls));
// 设置状态标签
const statusEl = document.getElementById('dashboard-skills-status');
if (statusEl) {
if (totalCalls === 0) {
statusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.toUse') : '待使用');
statusEl.style.background = 'rgba(0, 0, 0, 0.05)';
statusEl.style.color = 'var(--text-secondary)';
} else if (totalCalls < 10) {
statusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.active') : '活跃');
statusEl.style.background = 'rgba(16, 185, 129, 0.1)';
statusEl.style.color = '#10b981';
} else {
statusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.highFreq') : '高频');
statusEl.style.background = 'rgba(59, 130, 246, 0.1)';
statusEl.style.color = '#3b82f6';
}
}
} else {
setEl('dashboard-skills-count', '-');
setEl('dashboard-skills-calls', '-');
const statusEl = document.getElementById('dashboard-skills-status');
if (statusEl) statusEl.textContent = '-';
}
} catch (e) {
console.warn('仪表盘拉取统计失败', e);
if (runningEl) runningEl.textContent = '-';
if (vulnTotalEl) vulnTotalEl.textContent = '-';
setDashboardOverviewPlaceholder('-');
setEl('dashboard-kpi-success-rate', '-');
setEl('dashboard-kpi-tools-calls', '-');
renderDashboardToolsBar(null);
var ph = document.getElementById('dashboard-tools-pie-placeholder');
if (ph) { ph.style.removeProperty('display'); ph.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); }
}
}
function setEl(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function setDashboardOverviewPlaceholder(t) {
['dashboard-batch-pending', 'dashboard-batch-running', 'dashboard-batch-done', 'dashboard-batch-total',
'dashboard-tools-count', 'dashboard-tools-calls', 'dashboard-tools-success-rate',
'dashboard-skills-count', 'dashboard-skills-calls', 'dashboard-skills-status',
'dashboard-knowledge-items', 'dashboard-knowledge-categories', 'dashboard-knowledge-status'].forEach(id => setEl(id, t));
updateProgressBar('dashboard-batch-progress-pending', '0');
updateProgressBar('dashboard-batch-progress-running', '0');
updateProgressBar('dashboard-batch-progress-done', '0');
}
// 格式化数字,添加千位分隔符
function formatNumber(num) {
if (typeof num !== 'number' || isNaN(num)) return '-';
if (num === 0) return '0';
return num.toLocaleString('zh-CN');
}
// 更新进度条宽度
function updateProgressBar(id, percentage) {
const el = document.getElementById(id);
if (el) {
const pct = parseFloat(percentage) || 0;
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
}
}
// Top 30 工具执行次数柱状图颜色(30 色不重复,柔和、易区分)
var DASHBOARD_BAR_COLORS = [
'#93c5fd', '#a78bfa', '#6ee7b7', '#fde047', '#fda4af',
'#7dd3fc', '#a5b4fc', '#5eead4', '#fdba74', '#e9d5ff',
'#67e8f9', '#c4b5fd', '#86efac', '#fcd34d', '#f9a8d4',
'#bae6fd', '#c7d2fe', '#99f6e4', '#fed7aa', '#ddd6fe',
'#22d3ee', '#8b5cf6', '#4ade80', '#fbbf24', '#fb7185',
'#38bdf8', '#818cf8', '#2dd4bf', '#fb923c', '#e0e7ff'
];
function esc(s) {
if (typeof s !== 'string') return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
}
function renderDashboardToolsBar(monitorRes) {
const placeholder = document.getElementById('dashboard-tools-pie-placeholder');
const barChartEl = document.getElementById('dashboard-tools-bar-chart');
if (!placeholder || !barChartEl) return;
if (!monitorRes || typeof monitorRes !== 'object') {
placeholder.style.removeProperty('display');
placeholder.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据');
barChartEl.style.display = 'none';
barChartEl.innerHTML = '';
return;
}
const entries = Object.keys(monitorRes).map(function (k) {
const v = monitorRes[k];
const totalCalls = v && (v.totalCalls ?? v.TotalCalls);
return { name: k, totalCalls: typeof totalCalls === 'number' ? totalCalls : 0 };
}).filter(function (e) { return e.totalCalls > 0; })
.sort(function (a, b) { return b.totalCalls - a.totalCalls; })
.slice(0, 30);
if (entries.length === 0) {
placeholder.style.removeProperty('display');
placeholder.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据');
barChartEl.style.display = 'none';
barChartEl.innerHTML = '';
return;
}
placeholder.style.display = 'none';
barChartEl.style.display = 'block';
const maxCalls = Math.max.apply(null, entries.map(function (e) { return e.totalCalls; }));
var html = '';
entries.forEach(function (e, i) {
var pct = maxCalls > 0 ? (e.totalCalls / maxCalls) * 100 : 0;
var label = e.name.length > 12 ? e.name.slice(0, 10) + '…' : e.name;
var color = DASHBOARD_BAR_COLORS[i % DASHBOARD_BAR_COLORS.length];
var fullName = esc(e.name);
html += '<div class="dashboard-tools-bar-item" data-tooltip="' + fullName + '">';
html += '<span class="dashboard-tools-bar-label">' + esc(label) + '</span>';
html += '<div class="dashboard-tools-bar-track"><div class="dashboard-tools-bar-fill" style="width:' + pct + '%;background:' + color + '"></div></div>';
html += '<span class="dashboard-tools-bar-value">' + e.totalCalls + '</span>';
html += '</div>';
});
barChartEl.innerHTML = html;
attachDashboardBarTooltips(barChartEl);
}
var dashboardBarTooltipEl = null;
var dashboardBarTooltipTimer = null;
function attachDashboardBarTooltips(barChartEl) {
if (!barChartEl) return;
if (!dashboardBarTooltipEl) {
dashboardBarTooltipEl = document.createElement('div');
dashboardBarTooltipEl.className = 'dashboard-tools-bar-tooltip';
dashboardBarTooltipEl.setAttribute('role', 'tooltip');
document.body.appendChild(dashboardBarTooltipEl);
}
barChartEl.removeEventListener('mouseover', dashboardBarTooltipOnOver);
barChartEl.removeEventListener('mouseout', dashboardBarTooltipOnOut);
barChartEl.addEventListener('mouseover', dashboardBarTooltipOnOver);
barChartEl.addEventListener('mouseout', dashboardBarTooltipOnOut);
}
function dashboardBarTooltipOnOver(ev) {
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-tools-bar-item');
if (!item || !dashboardBarTooltipEl) return;
var text = item.getAttribute('data-tooltip');
if (!text) return;
clearTimeout(dashboardBarTooltipTimer);
dashboardBarTooltipTimer = setTimeout(function () {
dashboardBarTooltipEl.textContent = text;
dashboardBarTooltipEl.style.display = 'block';
requestAnimationFrame(function () {
var rect = item.getBoundingClientRect();
var ttRect = dashboardBarTooltipEl.getBoundingClientRect();
var x = rect.left + (rect.width / 2) - (ttRect.width / 2);
var y = rect.top - ttRect.height - 6;
if (y < 8) y = rect.bottom + 6;
var pad = 8;
if (x < pad) x = pad;
if (x + ttRect.width > window.innerWidth - pad) x = window.innerWidth - ttRect.width - pad;
dashboardBarTooltipEl.style.left = x + 'px';
dashboardBarTooltipEl.style.top = y + 'px';
});
}, 180);
}
function dashboardBarTooltipOnOut(ev) {
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-tools-bar-item');
var related = ev.relatedTarget && ev.relatedTarget.closest && ev.relatedTarget.closest('.dashboard-tools-bar-item');
if (item && item === related) return;
clearTimeout(dashboardBarTooltipTimer);
dashboardBarTooltipTimer = null;
if (dashboardBarTooltipEl) dashboardBarTooltipEl.style.display = 'none';
}
+213
View File
@@ -0,0 +1,213 @@
// 前端国际化初始化(基于 i18next 浏览器版本)
(function () {
const DEFAULT_LANG = 'zh-CN';
const STORAGE_KEY = 'csai_lang';
const RESOURCES_PREFIX = '/static/i18n';
const loadedLangs = {};
function detectInitialLang() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return stored;
}
} catch (e) {
console.warn('无法读取语言设置:', e);
}
const navLang = (navigator.language || navigator.userLanguage || '').toLowerCase();
if (navLang.startsWith('zh')) {
return 'zh-CN';
}
if (navLang.startsWith('en')) {
return 'en-US';
}
return DEFAULT_LANG;
}
async function loadLanguageResources(lang) {
if (loadedLangs[lang]) {
return;
}
try {
const resp = await fetch(RESOURCES_PREFIX + '/' + lang + '.json', {
cache: 'no-cache'
});
if (!resp.ok) {
console.warn('加载语言包失败:', lang, resp.status);
return;
}
const data = await resp.json();
if (typeof i18next !== 'undefined') {
i18next.addResourceBundle(lang, 'translation', data, true, true);
}
loadedLangs[lang] = true;
} catch (e) {
console.error('加载语言包异常:', lang, e);
}
}
function applyTranslations(root) {
if (typeof i18next === 'undefined') return;
const container = root || document;
if (!container) return;
const elements = container.querySelectorAll('[data-i18n]');
elements.forEach(function (el) {
const key = el.getAttribute('data-i18n');
if (!key) return;
const skipText = el.getAttribute('data-i18n-skip-text') === 'true';
const isFormControl = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA');
const attrList = el.getAttribute('data-i18n-attr');
const text = i18next.t(key);
// 仅当元素无子元素(仅文本或空)时才替换文本,避免覆盖卡片内的数字、子节点等;input/textarea 永不设置 textContent
const hasNoElementChildren = !el.querySelector('*');
if (!skipText && !isFormControl && hasNoElementChildren && text && typeof text === 'string') {
el.textContent = text;
}
if (attrList) {
const titleKey = el.getAttribute('data-i18n-title');
attrList.split(',').map(function (s) { return s.trim(); }).forEach(function (attr) {
if (!attr) return;
var val = text;
if (attr === 'title' && titleKey) {
var titleText = i18next.t(titleKey);
if (titleText && typeof titleText === 'string') val = titleText;
}
if (val && typeof val === 'string') {
el.setAttribute(attr, val);
}
});
}
});
// 对话输入框:若 value 与 placeholder 相同,清空 value 以便正确显示占位提示
try {
const chatInput = document.getElementById('chat-input');
if (chatInput && chatInput.tagName === 'TEXTAREA') {
const ph = (chatInput.getAttribute('placeholder') || '').trim();
if (ph && chatInput.value.trim() === ph) {
chatInput.value = '';
}
}
} catch (e) { /* ignore */ }
// 更新 html lang 属性
try {
if (document && document.documentElement) {
document.documentElement.lang = i18next.language || DEFAULT_LANG;
}
} catch (e) {
// ignore
}
}
function updateLangLabel() {
const label = document.getElementById('current-lang-label');
if (!label || typeof i18next === 'undefined') return;
const lang = (i18next.language || DEFAULT_LANG).toLowerCase();
if (lang.indexOf('zh') === 0) {
label.textContent = i18next.t('lang.zhCN');
} else {
label.textContent = i18next.t('lang.enUS');
}
}
function closeLangDropdown() {
const dropdown = document.getElementById('lang-dropdown');
if (dropdown) {
dropdown.style.display = 'none';
}
}
function handleGlobalClickForLangDropdown(ev) {
const dropdown = document.getElementById('lang-dropdown');
const btn = document.querySelector('.lang-switcher-btn');
if (!dropdown || dropdown.style.display !== 'block') return;
const target = ev.target;
if (btn && btn.contains(target)) {
return;
}
if (!dropdown.contains(target)) {
closeLangDropdown();
}
}
async function changeLanguage(lang) {
if (typeof i18next === 'undefined') return;
const current = i18next.language || DEFAULT_LANG;
if (lang === current) return;
await loadLanguageResources(lang);
await i18next.changeLanguage(lang);
try {
localStorage.setItem(STORAGE_KEY, lang);
} catch (e) {
console.warn('无法保存语言设置:', e);
}
applyTranslations(document);
updateLangLabel();
try {
window.__locale = lang;
} catch (e) { /* ignore */ }
try {
document.dispatchEvent(new CustomEvent('languagechange', { detail: { lang: lang } }));
} catch (e) { /* ignore */ }
}
async function initI18n() {
if (typeof i18next === 'undefined') {
console.warn('i18next 未加载,跳过前端国际化初始化');
return;
}
const initialLang = detectInitialLang();
await i18next.init({
lng: initialLang,
fallbackLng: DEFAULT_LANG,
debug: false,
resources: {}
});
await loadLanguageResources(initialLang);
applyTranslations(document);
updateLangLabel();
try {
window.__locale = i18next.language || initialLang;
} catch (e) { /* ignore */ }
// 导出全局函数供其他脚本调用(支持插值参数,如 _t('key', { count: 2 })
window.t = function (key, opts) {
if (typeof i18next === 'undefined') return key;
return i18next.t(key, opts);
};
window.changeLanguage = changeLanguage;
window.applyTranslations = applyTranslations;
// 语言切换下拉支持
window.toggleLangDropdown = function () {
const dropdown = document.getElementById('lang-dropdown');
if (!dropdown) return;
if (dropdown.style.display === 'block') {
dropdown.style.display = 'none';
} else {
dropdown.style.display = 'block';
}
};
window.onLanguageSelect = function (lang) {
changeLanguage(lang);
closeLangDropdown();
};
document.addEventListener('click', handleGlobalClickForLangDropdown);
}
document.addEventListener('DOMContentLoaded', function () {
// i18n 初始化在 DOM Ready 后执行
initI18n().catch(function (e) {
console.error('初始化国际化失败:', e);
});
});
})();
File diff suppressed because it is too large Load Diff
+150 -102
View File
@@ -1,4 +1,37 @@
// 知识库管理相关功能 // 知识库管理相关功能
function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
}
// 返回「知识库未启用」提示区块的 HTML(使用 data-i18n 以便语言切换时自动更新)
function getKnowledgeNotEnabledHTML() {
return `
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
<div style="font-size: 48px; margin-bottom: 20px;">📚</div>
<h3 data-i18n="knowledge.notEnabledTitle" style="margin-bottom: 10px; color: #666;"></h3>
<p data-i18n="knowledge.notEnabledHint" style="color: #999; margin-bottom: 20px;"></p>
<button data-i18n="knowledge.goToSettings" onclick="switchToSettings()" style="
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
"></button>
</div>
`;
}
// 渲染「知识库未启用」状态到容器,并应用当前语言
function renderKnowledgeNotEnabledState(container) {
if (!container) return;
container.innerHTML = getKnowledgeNotEnabledHTML();
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(container);
}
}
let knowledgeCategories = []; let knowledgeCategories = [];
let knowledgeItems = []; let knowledgeItems = [];
let currentEditingItemId = null; let currentEditingItemId = null;
@@ -32,26 +65,8 @@ async function loadKnowledgeCategories() {
// 检查知识库功能是否启用 // 检查知识库功能是否启用
if (data.enabled === false) { if (data.enabled === false) {
// 功能未启用,显示友好提示 // 功能未启用,显示友好提示(使用 data-i18n,切换语言时会自动更新)
const container = document.getElementById('knowledge-items-list'); renderKnowledgeNotEnabledState(document.getElementById('knowledge-items-list'));
if (container) {
container.innerHTML = `
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
<div style="font-size: 48px; margin-bottom: 20px;">📚</div>
<h3 style="margin-bottom: 10px; color: #666;">知识库功能未启用</h3>
<p style="color: #999; margin-bottom: 20px;">${data.message || '请前往系统设置启用知识检索功能'}</p>
<button onclick="switchToSettings()" style="
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
">前往设置</button>
</div>
`;
}
return []; return [];
} }
@@ -116,25 +131,10 @@ async function loadKnowledgeItems(category = '', page = 1, pageSize = 10) {
// 检查知识库功能是否启用 // 检查知识库功能是否启用
if (data.enabled === false) { if (data.enabled === false) {
// 功能未启用,显示友好提示(如果还没有显示的话) // 功能未启用,显示友好提示(如果还没有显示的话;使用 data-i18n,切换语言时会自动更新
const container = document.getElementById('knowledge-items-list'); const container = document.getElementById('knowledge-items-list');
if (container && !container.querySelector('.empty-state')) { if (container && !container.querySelector('.empty-state')) {
container.innerHTML = ` renderKnowledgeNotEnabledState(container);
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
<div style="font-size: 48px; margin-bottom: 20px;">📚</div>
<h3 style="margin-bottom: 10px; color: #666;">知识库功能未启用</h3>
<p style="color: #999; margin-bottom: 20px;">${data.message || '请前往系统设置启用知识检索功能'}</p>
<button onclick="switchToSettings()" style="
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
">前往设置</button>
</div>
`;
} }
knowledgeItems = []; knowledgeItems = [];
knowledgePagination.total = 0; knowledgePagination.total = 0;
@@ -459,6 +459,9 @@ async function updateIndexProgress() {
const isComplete = status.is_complete || false; const isComplete = status.is_complete || false;
const lastError = status.last_error || ''; const lastError = status.last_error || '';
// 检查是否正在重建索引(优先使用重建状态)
const isRebuilding = status.is_rebuilding || false;
if (totalItems === 0) { if (totalItems === 0) {
// 没有知识项,隐藏进度条 // 没有知识项,隐藏进度条
progressContainer.style.display = 'none'; progressContainer.style.display = 'none';
@@ -524,6 +527,45 @@ async function updateIndexProgress() {
return; return;
} }
// 优先处理重建状态
if (isRebuilding) {
const rebuildTotal = status.rebuild_total || totalItems;
const rebuildCurrent = status.rebuild_current || 0;
const rebuildFailed = status.rebuild_failed || 0;
const rebuildLastItemID = status.rebuild_last_item_id || '';
const rebuildLastChunks = status.rebuild_last_chunks || 0;
const rebuildStartTime = status.rebuild_start_time || '';
// 计算进度百分比(使用重建进度)
let rebuildProgress = progressPercent;
if (rebuildTotal > 0) {
rebuildProgress = (rebuildCurrent / rebuildTotal) * 100;
}
progressContainer.innerHTML = `
<div class="knowledge-index-progress">
<div class="progress-header">
<span class="progress-icon">🔨</span>
<span class="progress-text">正在重建索引${rebuildCurrent}/${rebuildTotal} (${rebuildProgress.toFixed(1)}%) - 失败${rebuildFailed}</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${rebuildProgress}%"></div>
</div>
<div class="progress-hint">
${rebuildLastItemID ? `正在处理:${escapeHtml(rebuildLastItemID.substring(0, 36))}... (${rebuildLastChunks} chunks)` : '正在处理...'}
${rebuildStartTime ? `<br>开始时间:${new Date(rebuildStartTime).toLocaleString()}` : ''}
</div>
</div>
`;
// 重建中时继续轮询
if (!indexProgressInterval) {
indexProgressInterval = setInterval(updateIndexProgress, 2000);
}
return;
}
if (isComplete) { if (isComplete) {
progressContainer.innerHTML = ` progressContainer.innerHTML = `
<div class="knowledge-index-progress-complete"> <div class="knowledge-index-progress-complete">
@@ -711,25 +753,7 @@ async function searchKnowledgeItems() {
// 检查知识库功能是否启用 // 检查知识库功能是否启用
if (data.enabled === false) { if (data.enabled === false) {
const container = document.getElementById('knowledge-items-list'); renderKnowledgeNotEnabledState(document.getElementById('knowledge-items-list'));
if (container) {
container.innerHTML = `
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
<div style="font-size: 48px; margin-bottom: 20px;">📚</div>
<h3 style="margin-bottom: 10px; color: #666;">知识库功能未启用</h3>
<p style="color: #999; margin-bottom: 20px;">${data.message || '请前往系统设置启用知识检索功能'}</p>
<button onclick="switchToSettings()" style="
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
">前往设置</button>
</div>
`;
}
return; return;
} }
@@ -1270,7 +1294,7 @@ async function loadRetrievalLogs(conversationId = '', messageId = '') {
renderRetrievalLogs([]); renderRetrievalLogs([]);
// 只在非空筛选条件下才显示错误通知(避免在没有数据时显示错误) // 只在非空筛选条件下才显示错误通知(避免在没有数据时显示错误)
if (conversationId || messageId) { if (conversationId || messageId) {
showNotification('加载检索日志失败: ' + error.message, 'error'); showNotification(_t('retrievalLogs.loadError') + ': ' + error.message, 'error');
} }
} }
} }
@@ -1284,7 +1308,7 @@ function renderRetrievalLogs(logs) {
updateRetrievalStats(logs); updateRetrievalStats(logs);
if (logs.length === 0) { if (logs.length === 0) {
container.innerHTML = '<div class="empty-state">暂无检索记录</div>'; container.innerHTML = '<div class="empty-state">' + _t('retrievalLogs.noRecords') + '</div>';
retrievalLogsData = []; retrievalLogsData = [];
return; return;
} }
@@ -1344,7 +1368,7 @@ function renderRetrievalLogs(logs) {
</div> </div>
<div class="retrieval-log-main-info"> <div class="retrieval-log-main-info">
<div class="retrieval-log-query"> <div class="retrieval-log-query">
${escapeHtml(log.query || '无查询内容')} ${escapeHtml(log.query || _t('retrievalLogs.noQuery'))}
</div> </div>
<div class="retrieval-log-meta"> <div class="retrieval-log-meta">
<span class="retrieval-log-time" title="${formatTime(log.createdAt)}"> <span class="retrieval-log-time" title="${formatTime(log.createdAt)}">
@@ -1354,33 +1378,33 @@ function renderRetrievalLogs(logs) {
</div> </div>
</div> </div>
<div class="retrieval-log-result-badge ${hasResults ? 'success' : 'empty'}"> <div class="retrieval-log-result-badge ${hasResults ? 'success' : 'empty'}">
${hasResults ? (itemCount > 0 ? `${itemCount}` : '有结果') : '无结果'} ${hasResults ? (itemCount > 0 ? itemCount + ' ' + _t('retrievalLogs.itemsUnit') : _t('retrievalLogs.hasResults')) : _t('retrievalLogs.noResults')}
</div> </div>
</div> </div>
<div class="retrieval-log-card-body"> <div class="retrieval-log-card-body">
<div class="retrieval-log-details-grid"> <div class="retrieval-log-details-grid">
${log.conversationId ? ` ${log.conversationId ? `
<div class="retrieval-log-detail-item"> <div class="retrieval-log-detail-item">
<span class="detail-label">对话ID</span> <span class="detail-label">${_t('retrievalLogs.conversationId')}</span>
<code class="detail-value" title="点击复制" onclick="navigator.clipboard.writeText('${escapeHtml(log.conversationId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);" style="cursor: pointer;">${escapeHtml(log.conversationId)}</code> <code class="detail-value" title="${_t('retrievalLogs.clickToCopy')}" data-copy-title-copied="${_t('common.copied')}" data-copy-title-click="${_t('retrievalLogs.clickToCopy')}" onclick="var t=this; navigator.clipboard.writeText('${escapeHtml(log.conversationId)}').then(function(){ t.title=t.getAttribute('data-copy-title-copied')||'Copied!'; setTimeout(function(){ t.title=t.getAttribute('data-copy-title-click')||'Click to copy'; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.conversationId)}</code>
</div> </div>
` : ''} ` : ''}
${log.messageId ? ` ${log.messageId ? `
<div class="retrieval-log-detail-item"> <div class="retrieval-log-detail-item">
<span class="detail-label">消息ID</span> <span class="detail-label">${_t('retrievalLogs.messageId')}</span>
<code class="detail-value" title="点击复制" onclick="navigator.clipboard.writeText('${escapeHtml(log.messageId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);" style="cursor: pointer;">${escapeHtml(log.messageId)}</code> <code class="detail-value" title="${_t('retrievalLogs.clickToCopy')}" data-copy-title-copied="${_t('common.copied')}" data-copy-title-click="${_t('retrievalLogs.clickToCopy')}" onclick="var el=this; navigator.clipboard.writeText('${escapeHtml(log.messageId)}').then(function(){ el.title=el.getAttribute('data-copy-title-copied')||el.title; setTimeout(function(){ el.title=el.getAttribute('data-copy-title-click')||el.title; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.messageId)}</code>
</div> </div>
` : ''} ` : ''}
<div class="retrieval-log-detail-item"> <div class="retrieval-log-detail-item">
<span class="detail-label">检索结果</span> <span class="detail-label">${_t('retrievalLogs.retrievalResult')}</span>
<span class="detail-value ${hasResults ? 'text-success' : 'text-muted'}"> <span class="detail-value ${hasResults ? 'text-success' : 'text-muted'}">
${hasResults ? (itemCount > 0 ? `找到 ${itemCount} 个相关知识项` : '找到相关知识项(数量未知)') : '未找到匹配的知识项'} ${hasResults ? (itemCount > 0 ? _t('retrievalLogs.foundCount', { count: itemCount }) : _t('retrievalLogs.foundUnknown')) : _t('retrievalLogs.noMatch')}
</span> </span>
</div> </div>
</div> </div>
${hasResults && log.retrievedItems && log.retrievedItems.length > 0 ? ` ${hasResults && log.retrievedItems && log.retrievedItems.length > 0 ? `
<div class="retrieval-log-items-preview"> <div class="retrieval-log-items-preview">
<div class="retrieval-log-items-label">检索到的知识项:</div> <div class="retrieval-log-items-label">${_t('retrievalLogs.retrievedItemsLabel')}</div>
<div class="retrieval-log-items-list"> <div class="retrieval-log-items-list">
${log.retrievedItems.slice(0, 3).map((itemId, idx) => ` ${log.retrievedItems.slice(0, 3).map((itemId, idx) => `
<span class="retrieval-log-item-tag">${idx + 1}</span> <span class="retrieval-log-item-tag">${idx + 1}</span>
@@ -1395,13 +1419,13 @@ function renderRetrievalLogs(logs) {
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
查看详情 ${_t('retrievalLogs.viewDetails')}
</button> </button>
<button class="btn-secondary btn-sm retrieval-log-delete-btn" onclick="deleteRetrievalLog('${escapeHtml(log.id)}', ${index})" style="margin-top: 12px; margin-left: 8px; display: inline-flex; align-items: center; gap: 4px; color: var(--error-color, #dc3545); border-color: var(--error-color, #dc3545);" onmouseover="this.style.backgroundColor='rgba(220, 53, 69, 0.1)'; this.style.color='#dc3545';" onmouseout="this.style.backgroundColor=''; this.style.color='var(--error-color, #dc3545)';" title="删除"> <button class="btn-secondary btn-sm retrieval-log-delete-btn" onclick="deleteRetrievalLog('${escapeHtml(log.id)}', ${index})" style="margin-top: 12px; margin-left: 8px; display: inline-flex; align-items: center; gap: 4px; color: var(--error-color, #dc3545); border-color: var(--error-color, #dc3545);" onmouseover="this.style.backgroundColor='rgba(220, 53, 69, 0.1)'; this.style.color='#dc3545';" onmouseout="this.style.backgroundColor=''; this.style.color='var(--error-color, #dc3545)';" title="${_t('common.delete')}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
删除 ${_t('common.delete')}
</button> </button>
</div> </div>
</div> </div>
@@ -1438,22 +1462,25 @@ function updateRetrievalStats(logs) {
statsContainer.innerHTML = ` statsContainer.innerHTML = `
<div class="retrieval-stat-item"> <div class="retrieval-stat-item">
<span class="retrieval-stat-label">总检索次数</span> <span class="retrieval-stat-label" data-i18n="retrievalLogs.totalRetrievals">总检索次数</span>
<span class="retrieval-stat-value">${totalLogs}</span> <span class="retrieval-stat-value">${totalLogs}</span>
</div> </div>
<div class="retrieval-stat-item"> <div class="retrieval-stat-item">
<span class="retrieval-stat-label">成功检索</span> <span class="retrieval-stat-label" data-i18n="retrievalLogs.successRetrievals">成功检索</span>
<span class="retrieval-stat-value text-success">${successfulLogs}</span> <span class="retrieval-stat-value text-success">${successfulLogs}</span>
</div> </div>
<div class="retrieval-stat-item"> <div class="retrieval-stat-item">
<span class="retrieval-stat-label">成功率</span> <span class="retrieval-stat-label" data-i18n="retrievalLogs.successRate">成功率</span>
<span class="retrieval-stat-value">${successRate}%</span> <span class="retrieval-stat-value">${successRate}%</span>
</div> </div>
<div class="retrieval-stat-item"> <div class="retrieval-stat-item">
<span class="retrieval-stat-label">检索到知识项</span> <span class="retrieval-stat-label" data-i18n="retrievalLogs.retrievedItems">检索到知识项</span>
<span class="retrieval-stat-value">${totalItems}</span> <span class="retrieval-stat-value">${totalItems}</span>
</div> </div>
`; `;
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(statsContainer);
}
} }
// 获取相对时间 // 获取相对时间
@@ -1549,7 +1576,7 @@ function refreshRetrievalLogs() {
// 删除检索日志 // 删除检索日志
async function deleteRetrievalLog(id, index) { async function deleteRetrievalLog(id, index) {
if (!confirm('确定要删除这条检索记录吗?')) { if (!confirm(_t('retrievalLogs.deleteConfirm'))) {
return; return;
} }
@@ -1635,7 +1662,7 @@ async function deleteRetrievalLog(id, index) {
} }
} }
showNotification('❌ 删除检索日志失败: ' + error.message, 'error'); showNotification(_t('retrievalLogs.deleteError') + ': ' + error.message, 'error');
} }
} }
@@ -1657,12 +1684,11 @@ function updateRetrievalStatsAfterDelete() {
const badge = card.querySelector('.retrieval-log-result-badge'); const badge = card.querySelector('.retrieval-log-result-badge');
if (badge && badge.classList.contains('success')) { if (badge && badge.classList.contains('success')) {
const text = badge.textContent.trim(); const text = badge.textContent.trim();
const match = text.match(/(\d+)\s*项/); const match = text.match(/(\d+)/);
if (match) { if (match) {
return sum + parseInt(match[1]); return sum + parseInt(match[1], 10);
} else if (text === '有结果') {
return sum + 1; // 简化处理,假设为1
} }
return sum + 1; // 有结果但数量未知(如 "Has results" / "有结果"
} }
return sum; return sum;
}, 0); }, 0);
@@ -1671,28 +1697,31 @@ function updateRetrievalStatsAfterDelete() {
statsContainer.innerHTML = ` statsContainer.innerHTML = `
<div class="retrieval-stat-item"> <div class="retrieval-stat-item">
<span class="retrieval-stat-label">总检索次数</span> <span class="retrieval-stat-label" data-i18n="retrievalLogs.totalRetrievals">总检索次数</span>
<span class="retrieval-stat-value">${totalLogs}</span> <span class="retrieval-stat-value">${totalLogs}</span>
</div> </div>
<div class="retrieval-stat-item"> <div class="retrieval-stat-item">
<span class="retrieval-stat-label">成功检索</span> <span class="retrieval-stat-label" data-i18n="retrievalLogs.successRetrievals">成功检索</span>
<span class="retrieval-stat-value text-success">${successfulLogs}</span> <span class="retrieval-stat-value text-success">${successfulLogs}</span>
</div> </div>
<div class="retrieval-stat-item"> <div class="retrieval-stat-item">
<span class="retrieval-stat-label">成功率</span> <span class="retrieval-stat-label" data-i18n="retrievalLogs.successRate">成功率</span>
<span class="retrieval-stat-value">${successRate}%</span> <span class="retrieval-stat-value">${successRate}%</span>
</div> </div>
<div class="retrieval-stat-item"> <div class="retrieval-stat-item">
<span class="retrieval-stat-label">检索到知识项</span> <span class="retrieval-stat-label" data-i18n="retrievalLogs.retrievedItems">检索到知识项</span>
<span class="retrieval-stat-value">${totalItems}</span> <span class="retrieval-stat-value">${totalItems}</span>
</div> </div>
`; `;
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(statsContainer);
}
} }
// 显示检索日志详情 // 显示检索日志详情
async function showRetrievalLogDetails(index) { async function showRetrievalLogDetails(index) {
if (!retrievalLogsData || index < 0 || index >= retrievalLogsData.length) { if (!retrievalLogsData || index < 0 || index >= retrievalLogsData.length) {
showNotification('无法获取检索详情', 'error'); showNotification(_t('retrievalLogs.detailError'), 'error');
return; return;
} }
@@ -1741,16 +1770,19 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content" style="max-width: 900px; max-height: 90vh; overflow-y: auto;"> <div class="modal-content" style="max-width: 900px; max-height: 90vh; overflow-y: auto;">
<div class="modal-header"> <div class="modal-header">
<h2>检索详情</h2> <h2 data-i18n="retrievalLogs.detailsTitle">检索详情</h2>
<span class="modal-close" onclick="closeRetrievalLogDetailsModal()">&times;</span> <span class="modal-close" onclick="closeRetrievalLogDetailsModal()">&times;</span>
</div> </div>
<div class="modal-body" id="retrieval-log-details-content"> <div class="modal-body" id="retrieval-log-details-content">
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn-secondary" onclick="closeRetrievalLogDetailsModal()">关闭</button> <button class="btn-secondary" onclick="closeRetrievalLogDetailsModal()" data-i18n="common.close">关闭</button>
</div> </div>
</div> </div>
`; `;
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(modal);
}
document.body.appendChild(modal); document.body.appendChild(modal);
} }
@@ -1774,57 +1806,57 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
return ` return `
<div class="retrieval-detail-item-card" style="margin-bottom: 16px; padding: 16px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-secondary);"> <div class="retrieval-detail-item-card" style="margin-bottom: 16px; padding: 16px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-secondary);">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
<h4 style="margin: 0; color: var(--text-primary);">${idx + 1}. ${escapeHtml(item.title || '未命名')}</h4> <h4 style="margin: 0; color: var(--text-primary);">${idx + 1}. ${escapeHtml(item.title || _t('retrievalLogs.untitled'))}</h4>
<span style="font-size: 0.875rem; color: var(--text-secondary);">${escapeHtml(item.category || '未分类')}</span> <span style="font-size: 0.875rem; color: var(--text-secondary);">${escapeHtml(item.category || _t('retrievalLogs.uncategorized'))}</span>
</div> </div>
${item.filePath ? `<div style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 8px;">📁 ${escapeHtml(item.filePath)}</div>` : ''} ${item.filePath ? `<div style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 8px;">📁 ${escapeHtml(item.filePath)}</div>` : ''}
<div style="font-size: 0.875rem; color: var(--text-secondary); line-height: 1.6;"> <div style="font-size: 0.875rem; color: var(--text-secondary); line-height: 1.6;">
${escapeHtml(previewText || '无内容预览')} ${escapeHtml(previewText || _t('retrievalLogs.noContentPreview'))}
</div> </div>
</div> </div>
`; `;
}).join(''); }).join('');
} else { } else {
itemsHtml = '<div style="padding: 16px; text-align: center; color: var(--text-muted);">未找到知识项详情</div>'; itemsHtml = '<div style="padding: 16px; text-align: center; color: var(--text-muted);">' + _t('retrievalLogs.noItemDetails') + '</div>';
} }
content.innerHTML = ` content.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 20px;"> <div style="display: flex; flex-direction: column; gap: 20px;">
<div class="retrieval-detail-section"> <div class="retrieval-detail-section">
<h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">查询信息</h3> <h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">${_t('retrievalLogs.queryInfo')}</h3>
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px; border-left: 3px solid var(--accent-color);"> <div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px; border-left: 3px solid var(--accent-color);">
<div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">查询内容:</div> <div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${_t('retrievalLogs.queryContent')}</div>
<div style="color: var(--text-primary); line-height: 1.6; word-break: break-word;">${escapeHtml(log.query || '无查询内容')}</div> <div style="color: var(--text-primary); line-height: 1.6; word-break: break-word;">${escapeHtml(log.query || _t('retrievalLogs.noQuery'))}</div>
</div> </div>
</div> </div>
<div class="retrieval-detail-section"> <div class="retrieval-detail-section">
<h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">检索信息</h3> <h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">${_t('retrievalLogs.retrievalInfo')}</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
${log.riskType ? ` ${log.riskType ? `
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;"> <div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">风险类型</div> <div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.riskType')}</div>
<div style="font-weight: 500; color: var(--text-primary);">${escapeHtml(log.riskType)}</div> <div style="font-weight: 500; color: var(--text-primary);">${escapeHtml(log.riskType)}</div>
</div> </div>
` : ''} ` : ''}
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;"> <div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">检索时间</div> <div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.retrievalTime')}</div>
<div style="font-weight: 500; color: var(--text-primary);" title="${fullTime}">${timeAgo}</div> <div style="font-weight: 500; color: var(--text-primary);" title="${fullTime}">${timeAgo}</div>
</div> </div>
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;"> <div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">检索结果</div> <div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.retrievalResult')}</div>
<div style="font-weight: 500; color: var(--text-primary);">${retrievedItems.length} 个知识项</div> <div style="font-weight: 500; color: var(--text-primary);">${_t('retrievalLogs.itemsCount', { count: retrievedItems.length })}</div>
</div> </div>
</div> </div>
</div> </div>
${log.conversationId || log.messageId ? ` ${log.conversationId || log.messageId ? `
<div class="retrieval-detail-section"> <div class="retrieval-detail-section">
<h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">关联信息</h3> <h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">${_t('retrievalLogs.relatedInfo')}</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
${log.conversationId ? ` ${log.conversationId ? `
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;"> <div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">对话ID</div> <div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.conversationId')}</div>
<code style="font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; cursor: pointer;" <code style="font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; cursor: pointer;"
onclick="navigator.clipboard.writeText('${escapeHtml(log.conversationId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);" onclick="navigator.clipboard.writeText('${escapeHtml(log.conversationId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
title="点击复制">${escapeHtml(log.conversationId)}</code> title="点击复制">${escapeHtml(log.conversationId)}</code>
@@ -1832,7 +1864,7 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
` : ''} ` : ''}
${log.messageId ? ` ${log.messageId ? `
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;"> <div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">消息ID</div> <div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.messageId')}</div>
<code style="font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; cursor: pointer;" <code style="font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; cursor: pointer;"
onclick="navigator.clipboard.writeText('${escapeHtml(log.messageId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);" onclick="navigator.clipboard.writeText('${escapeHtml(log.messageId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
title="点击复制">${escapeHtml(log.messageId)}</code> title="点击复制">${escapeHtml(log.messageId)}</code>
@@ -1868,6 +1900,22 @@ window.addEventListener('click', function(event) {
} }
}); });
// 语言切换时重新渲染检索历史列表与统计,使动态内容随语言更新;知识管理页的「未启用」区块已使用 data-i18n,会由 applyTranslations(document) 自动更新
document.addEventListener('languagechange', function () {
var cur = typeof window.currentPage === 'function' ? window.currentPage() : (window.currentPage || '');
if (cur === 'knowledge-retrieval-logs') {
if (retrievalLogsData && retrievalLogsData.length >= 0) {
renderRetrievalLogs(retrievalLogsData);
}
} else if (cur === 'knowledge-management') {
// 仅对「知识库未启用」状态:已有 data-i18napplyTranslations 已处理;此处可选地重新应用一次以兼容旧 DOM
var listEl = document.getElementById('knowledge-items-list');
if (listEl && typeof window.applyTranslations === 'function') {
window.applyTranslations(listEl);
}
}
});
// 页面切换时加载数据 // 页面切换时加载数据
if (typeof switchPage === 'function') { if (typeof switchPage === 'function') {
const originalSwitchPage = switchPage; const originalSwitchPage = switchPage;
File diff suppressed because it is too large Load Diff
+227 -148
View File
@@ -3,6 +3,30 @@ 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']);
// 将后端下发的进度文案转为当前语言的翻译(已知中文 key 映射)
function translateProgressMessage(message) {
if (!message || typeof message !== 'string') return message;
if (typeof window.t !== 'function') return message;
const trim = message.trim();
const map = {
'正在调用AI模型...': 'progress.callingAI',
'最后一次迭代:正在生成总结和下一步计划...': 'progress.lastIterSummary',
'总结生成完成': 'progress.summaryDone',
'正在生成最终回复...': 'progress.generatingFinalReply',
'达到最大迭代次数,正在生成总结...': 'progress.maxIterSummary'
};
if (map[trim]) return window.t(map[trim]);
const callingToolPrefix = '正在调用工具: ';
if (trim.indexOf(callingToolPrefix) === 0) {
const name = trim.slice(callingToolPrefix.length);
return window.t('progress.callingTool', { name: name });
}
return message;
}
if (typeof window !== 'undefined') {
window.translateProgressMessage = translateProgressMessage;
}
// 存储工具调用ID到DOM元素的映射,用于更新执行状态 // 存储工具调用ID到DOM元素的映射,用于更新执行状态
const toolCallStatusMap = new Map(); const toolCallStatusMap = new Map();
@@ -57,11 +81,15 @@ function markProgressCancelling(progressId) {
} }
} }
function finalizeProgressTask(progressId, finalLabel = '已完成') { function finalizeProgressTask(progressId, finalLabel) {
const stopBtn = document.getElementById(`${progressId}-stop-btn`); const stopBtn = document.getElementById(`${progressId}-stop-btn`);
if (stopBtn) { if (stopBtn) {
stopBtn.disabled = true; stopBtn.disabled = true;
stopBtn.textContent = finalLabel; if (finalLabel !== undefined && finalLabel !== '') {
stopBtn.textContent = finalLabel;
} else {
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成';
}
} }
progressTaskState.delete(progressId); progressTaskState.delete(progressId);
} }
@@ -76,7 +104,7 @@ async function requestCancel(conversationId) {
}); });
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
throw new Error(result.error || '取消失败'); throw new Error(result.error || (typeof window.t === 'function' ? window.t('tasks.cancelFailed') : '取消失败'));
} }
return result; return result;
} }
@@ -94,12 +122,15 @@ function addProgressMessage() {
const bubble = document.createElement('div'); const bubble = document.createElement('div');
bubble.className = 'message-bubble progress-container'; bubble.className = 'message-bubble progress-container';
const progressTitleText = typeof window.t === 'function' ? window.t('chat.progressInProgress') : '渗透测试进行中...';
const stopTaskText = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
const collapseDetailText = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
bubble.innerHTML = ` bubble.innerHTML = `
<div class="progress-header"> <div class="progress-header">
<span class="progress-title">🔍 渗透测试进行中...</span> <span class="progress-title">🔍 ${progressTitleText}</span>
<div class="progress-actions"> <div class="progress-actions">
<button class="progress-stop" id="${id}-stop-btn" onclick="cancelProgressTask('${id}')">停止任务</button> <button class="progress-stop" id="${id}-stop-btn" onclick="cancelProgressTask('${id}')">${stopTaskText}</button>
<button class="progress-toggle" onclick="toggleProgressDetails('${id}')">收起详情</button> <button class="progress-toggle" onclick="toggleProgressDetails('${id}')">${collapseDetailText}</button>
</div> </div>
</div> </div>
<div class="progress-timeline expanded" id="${id}-timeline"></div> <div class="progress-timeline expanded" id="${id}-timeline"></div>
@@ -123,10 +154,10 @@ function toggleProgressDetails(progressId) {
if (timeline.classList.contains('expanded')) { if (timeline.classList.contains('expanded')) {
timeline.classList.remove('expanded'); timeline.classList.remove('expanded');
toggleBtn.textContent = '展开详情'; toggleBtn.textContent = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
} else { } else {
timeline.classList.add('expanded'); timeline.classList.add('expanded');
toggleBtn.textContent = '收起详情'; toggleBtn.textContent = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
} }
} }
@@ -143,7 +174,7 @@ function collapseAllProgressDetails(assistantMessageId, progressId) {
timeline.classList.remove('expanded'); timeline.classList.remove('expanded');
const btn = document.querySelector(`#${assistantMessageId} .process-detail-btn`); const btn = document.querySelector(`#${assistantMessageId} .process-detail-btn`);
if (btn) { if (btn) {
btn.innerHTML = '<span>展开详情</span>'; btn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + '</span>';
} }
} }
} }
@@ -158,7 +189,7 @@ function collapseAllProgressDetails(assistantMessageId, progressId) {
if (timeline) { if (timeline) {
timeline.classList.remove('expanded'); timeline.classList.remove('expanded');
if (toggleBtn) { if (toggleBtn) {
toggleBtn.textContent = '展开详情'; toggleBtn.textContent = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
} }
} }
}); });
@@ -170,7 +201,7 @@ function collapseAllProgressDetails(assistantMessageId, progressId) {
if (progressTimeline) { if (progressTimeline) {
progressTimeline.classList.remove('expanded'); progressTimeline.classList.remove('expanded');
if (progressToggleBtn) { if (progressToggleBtn) {
progressToggleBtn.textContent = '展开详情'; progressToggleBtn.textContent = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
} }
} }
} }
@@ -246,7 +277,7 @@ function integrateProgressToMCPSection(progressId, assistantMessageId) {
// 设置详情内容(如果有错误,默认折叠;否则默认折叠) // 设置详情内容(如果有错误,默认折叠;否则默认折叠)
detailsContainer.innerHTML = ` detailsContainer.innerHTML = `
<div class="process-details-content"> <div class="process-details-content">
${hasContent ? `<div class="progress-timeline" id="${detailsId}-timeline">${timelineHTML}</div>` : '<div class="progress-timeline-empty">暂无过程详情</div>'} ${hasContent ? `<div class="progress-timeline" id="${detailsId}-timeline">${timelineHTML}</div>` : '<div class="progress-timeline-empty">' + (typeof window.t === 'function' ? window.t('chat.noProcessDetail') : '暂无过程详情(可能执行过快或未触发详细事件)') + '</div>'}
</div> </div>
`; `;
@@ -258,10 +289,9 @@ function integrateProgressToMCPSection(progressId, assistantMessageId) {
timeline.classList.remove('expanded'); timeline.classList.remove('expanded');
} }
// 更新按钮文本为"展开详情"(因为默认折叠)
const processDetailBtn = buttonsContainer.querySelector('.process-detail-btn'); const processDetailBtn = buttonsContainer.querySelector('.process-detail-btn');
if (processDetailBtn) { if (processDetailBtn) {
processDetailBtn.innerHTML = '<span>展开详情</span>'; processDetailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + '</span>';
} }
} }
@@ -279,22 +309,23 @@ function toggleProcessDetails(progressId, assistantMessageId) {
const timeline = detailsContainer.querySelector('.progress-timeline'); const timeline = detailsContainer.querySelector('.progress-timeline');
const btn = document.querySelector(`#${assistantMessageId} .process-detail-btn`); const btn = document.querySelector(`#${assistantMessageId} .process-detail-btn`);
const expandT = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
const collapseT = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
if (content && timeline) { if (content && timeline) {
if (timeline.classList.contains('expanded')) { if (timeline.classList.contains('expanded')) {
timeline.classList.remove('expanded'); timeline.classList.remove('expanded');
if (btn) btn.innerHTML = '<span>展开详情</span>'; if (btn) btn.innerHTML = '<span>' + expandT + '</span>';
} else { } else {
timeline.classList.add('expanded'); timeline.classList.add('expanded');
if (btn) btn.innerHTML = '<span>收起详情</span>'; if (btn) btn.innerHTML = '<span>' + collapseT + '</span>';
} }
} else if (timeline) { } else if (timeline) {
// 如果只有timeline,直接切换
if (timeline.classList.contains('expanded')) { if (timeline.classList.contains('expanded')) {
timeline.classList.remove('expanded'); timeline.classList.remove('expanded');
if (btn) btn.innerHTML = '<span>展开详情</span>'; if (btn) btn.innerHTML = '<span>' + expandT + '</span>';
} else { } else {
timeline.classList.add('expanded'); timeline.classList.add('expanded');
if (btn) btn.innerHTML = '<span>收起详情</span>'; if (btn) btn.innerHTML = '<span>' + collapseT + '</span>';
} }
} }
@@ -319,7 +350,7 @@ async function cancelProgressTask(progressId) {
stopBtn.disabled = false; stopBtn.disabled = false;
}, 1500); }, 1500);
} }
alert('任务信息尚未同步,请稍后再试。'); alert(typeof window.t === 'function' ? window.t('tasks.taskInfoNotSynced') : '任务信息尚未同步,请稍后再试。');
return; return;
} }
@@ -330,7 +361,7 @@ async function cancelProgressTask(progressId) {
markProgressCancelling(progressId); markProgressCancelling(progressId);
if (stopBtn) { if (stopBtn) {
stopBtn.disabled = true; stopBtn.disabled = true;
stopBtn.textContent = '取消中...'; stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
} }
try { try {
@@ -338,10 +369,10 @@ async function cancelProgressTask(progressId) {
loadActiveTasks(); loadActiveTasks();
} catch (error) { } catch (error) {
console.error('取消任务失败:', error); console.error('取消任务失败:', error);
alert('取消任务失败: ' + error.message); alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
if (stopBtn) { if (stopBtn) {
stopBtn.disabled = false; stopBtn.disabled = false;
stopBtn.textContent = '停止任务'; stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
} }
const currentState = progressTaskState.get(progressId); const currentState = progressTaskState.get(progressId);
if (currentState) { if (currentState) {
@@ -391,15 +422,17 @@ function convertProgressToDetails(progressId, assistantMessageId) {
// 如果有错误,默认折叠;否则默认展开 // 如果有错误,默认折叠;否则默认展开
const shouldExpand = !hasError; const shouldExpand = !hasError;
const expandedClass = shouldExpand ? 'expanded' : ''; const expandedClass = shouldExpand ? 'expanded' : '';
const toggleText = shouldExpand ? '收起详情' : '展开详情'; const collapseDetailText = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
const expandDetailText = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
// 总是显示详情组件,即使没有内容也显示 const toggleText = shouldExpand ? collapseDetailText : expandDetailText;
const penetrationDetailText = typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情';
const noProcessDetailText = typeof window.t === 'function' ? window.t('chat.noProcessDetail') : '暂无过程详情(可能执行过快或未触发详细事件)';
bubble.innerHTML = ` bubble.innerHTML = `
<div class="progress-header"> <div class="progress-header">
<span class="progress-title">📋 渗透测试详情</span> <span class="progress-title">📋 ${penetrationDetailText}</span>
${hasContent ? `<button class="progress-toggle" onclick="toggleProgressDetails('${detailsId}')">${toggleText}</button>` : ''} ${hasContent ? `<button class="progress-toggle" onclick="toggleProgressDetails('${detailsId}')">${toggleText}</button>` : ''}
</div> </div>
${hasContent ? `<div class="progress-timeline ${expandedClass}" id="${detailsId}-timeline">${timelineHTML}</div>` : '<div class="progress-timeline-empty">暂无过程详情(可能执行过快或未触发详细事件)</div>'} ${hasContent ? `<div class="progress-timeline ${expandedClass}" id="${detailsId}-timeline">${timelineHTML}</div>` : '<div class="progress-timeline-empty">' + noProcessDetailText + '</div>'}
`; `;
contentWrapper.appendChild(bubble); contentWrapper.appendChild(bubble);
@@ -466,41 +499,37 @@ function handleStreamEvent(event, progressElement, progressId,
case 'iteration': case 'iteration':
// 添加迭代标记 // 添加迭代标记
addTimelineItem(timeline, 'iteration', { addTimelineItem(timeline, 'iteration', {
title: `${event.data?.iteration || 1} 轮迭代`, title: typeof window.t === 'function' ? window.t('chat.iterationRound', { n: event.data?.iteration || 1 }) : '第 ' + (event.data?.iteration || 1) + ' 轮迭代',
message: event.message, message: event.message,
data: event.data data: event.data
}); });
break; break;
case 'thinking': case 'thinking':
// 显示AI思考内容
addTimelineItem(timeline, 'thinking', { addTimelineItem(timeline, 'thinking', {
title: '🤔 AI思考', title: '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'),
message: event.message, message: event.message,
data: event.data data: event.data
}); });
break; break;
case 'tool_calls_detected': case 'tool_calls_detected':
// 工具调用检测
addTimelineItem(timeline, 'tool_calls_detected', { addTimelineItem(timeline, 'tool_calls_detected', {
title: `🔧 检测到 ${event.data?.count || 0} 个工具调用`, title: '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: event.data?.count || 0 }) : '检测到 ' + (event.data?.count || 0) + ' 个工具调用'),
message: event.message, message: event.message,
data: event.data data: event.data
}); });
break; break;
case 'tool_call': case 'tool_call':
// 显示工具调用信息
const toolInfo = event.data || {}; const toolInfo = event.data || {};
const toolName = toolInfo.toolName || '未知工具'; const toolName = toolInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const index = toolInfo.index || 0; const index = toolInfo.index || 0;
const total = toolInfo.total || 0; const total = toolInfo.total || 0;
const toolCallId = toolInfo.toolCallId || null; const toolCallId = toolInfo.toolCallId || null;
const toolCallTitle = typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')';
// 添加工具调用项,并标记为执行中
const toolCallItemId = addTimelineItem(timeline, 'tool_call', { const toolCallItemId = addTimelineItem(timeline, 'tool_call', {
title: `🔧 调用工具: ${escapeHtml(toolName)} (${index}/${total})`, title: '🔧 ' + toolCallTitle,
message: event.message, message: event.message,
data: toolInfo, data: toolInfo,
expanded: false expanded: false
@@ -519,22 +548,18 @@ function handleStreamEvent(event, progressElement, progressId,
break; break;
case 'tool_result': case 'tool_result':
// 显示工具执行结果
const resultInfo = event.data || {}; const resultInfo = event.data || {};
const resultToolName = resultInfo.toolName || '未知工具'; const resultToolName = resultInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const success = resultInfo.success !== false; const success = resultInfo.success !== false;
const statusIcon = success ? '✅' : '❌'; const statusIcon = success ? '✅' : '❌';
const resultToolCallId = resultInfo.toolCallId || null; const resultToolCallId = resultInfo.toolCallId || null;
const resultExecText = success ? (typeof window.t === 'function' ? window.t('chat.toolExecComplete', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行完成') : (typeof window.t === 'function' ? window.t('chat.toolExecFailed', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行失败');
// 如果有关联的toolCallId,更新工具调用项的状态
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) { if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed'); updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
// 从映射中移除(已完成)
toolCallStatusMap.delete(resultToolCallId); toolCallStatusMap.delete(resultToolCallId);
} }
addTimelineItem(timeline, 'tool_result', { addTimelineItem(timeline, 'tool_result', {
title: `${statusIcon} 工具 ${escapeHtml(resultToolName)} 执行${success ? '完成' : '失败'}`, title: statusIcon + ' ' + resultExecText,
message: event.message, message: event.message,
data: resultInfo, data: resultInfo,
expanded: false expanded: false
@@ -542,36 +567,30 @@ function handleStreamEvent(event, progressElement, progressId,
break; break;
case 'progress': case 'progress':
// 更新进度状态
const progressTitle = document.querySelector(`#${progressId} .progress-title`); const progressTitle = document.querySelector(`#${progressId} .progress-title`);
if (progressTitle) { if (progressTitle) {
progressTitle.textContent = '🔍 ' + event.message; const progressMsg = translateProgressMessage(event.message);
progressTitle.textContent = '🔍 ' + progressMsg;
} }
break; break;
case 'cancelled': case 'cancelled':
// 显示错误 const taskCancelledText = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
addTimelineItem(timeline, 'cancelled', { addTimelineItem(timeline, 'cancelled', {
title: '⛔ 任务已取消', title: '⛔ ' + taskCancelledText,
message: event.message, message: event.message,
data: event.data data: event.data
}); });
// 更新进度标题为取消状态
const cancelTitle = document.querySelector(`#${progressId} .progress-title`); const cancelTitle = document.querySelector(`#${progressId} .progress-title`);
if (cancelTitle) { if (cancelTitle) {
cancelTitle.textContent = '⛔ 任务已取消'; cancelTitle.textContent = '⛔ ' + taskCancelledText;
} }
// 更新进度容器为已完成状态(添加completed类)
const cancelProgressContainer = document.querySelector(`#${progressId} .progress-container`); const cancelProgressContainer = document.querySelector(`#${progressId} .progress-container`);
if (cancelProgressContainer) { if (cancelProgressContainer) {
cancelProgressContainer.classList.add('completed'); cancelProgressContainer.classList.add('completed');
} }
// 完成进度任务(标记为已取消)
if (progressTaskState.has(progressId)) { if (progressTaskState.has(progressId)) {
finalizeProgressTask(progressId, '已取消'); finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCancelled') : '已取消');
} }
// 如果取消事件包含messageId,说明有助手消息,需要显示取消内容 // 如果取消事件包含messageId,说明有助手消息,需要显示取消内容
@@ -670,7 +689,7 @@ function handleStreamEvent(event, progressElement, progressId,
case 'error': case 'error':
// 显示错误 // 显示错误
addTimelineItem(timeline, 'error', { addTimelineItem(timeline, 'error', {
title: '❌ 错误', title: '❌ ' + (typeof window.t === 'function' ? window.t('chat.error') : '错误'),
message: event.message, message: event.message,
data: event.data data: event.data
}); });
@@ -678,7 +697,7 @@ function handleStreamEvent(event, progressElement, progressId,
// 更新进度标题为错误状态 // 更新进度标题为错误状态
const errorTitle = document.querySelector(`#${progressId} .progress-title`); const errorTitle = document.querySelector(`#${progressId} .progress-title`);
if (errorTitle) { if (errorTitle) {
errorTitle.textContent = '❌ 执行失败'; errorTitle.textContent = '❌ ' + (typeof window.t === 'function' ? window.t('chat.executionFailed') : '执行失败');
} }
// 更新进度容器为已完成状态(添加completed类) // 更新进度容器为已完成状态(添加completed类)
@@ -689,7 +708,7 @@ function handleStreamEvent(event, progressElement, progressId,
// 完成进度任务(标记为失败) // 完成进度任务(标记为失败)
if (progressTaskState.has(progressId)) { if (progressTaskState.has(progressId)) {
finalizeProgressTask(progressId, '已失败'); finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusFailed') : '执行失败');
} }
// 如果错误事件包含messageId,说明有助手消息,需要显示错误内容 // 如果错误事件包含messageId,说明有助手消息,需要显示错误内容
@@ -743,7 +762,7 @@ function handleStreamEvent(event, progressElement, progressId,
// 完成,更新进度标题(如果进度消息还存在) // 完成,更新进度标题(如果进度消息还存在)
const doneTitle = document.querySelector(`#${progressId} .progress-title`); const doneTitle = document.querySelector(`#${progressId} .progress-title`);
if (doneTitle) { if (doneTitle) {
doneTitle.textContent = '✅ 渗透测试完成'; doneTitle.textContent = '✅ ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestComplete') : '渗透测试完成');
} }
// 更新对话ID // 更新对话ID
if (event.data && event.data.conversationId) { if (event.data && event.data.conversationId) {
@@ -753,7 +772,7 @@ function handleStreamEvent(event, progressElement, progressId,
updateProgressConversation(progressId, event.data.conversationId); updateProgressConversation(progressId, event.data.conversationId);
} }
if (progressTaskState.has(progressId)) { if (progressTaskState.has(progressId)) {
finalizeProgressTask(progressId, '已完成'); finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
} }
// 检查时间线中是否有错误项 // 检查时间线中是否有错误项
@@ -807,17 +826,19 @@ function updateToolCallStatus(toolCallId, status) {
// 移除之前的状态类 // 移除之前的状态类
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed'); item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
// 根据状态更新样式和文本 const runningLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...';
const completedLabel = typeof window.t === 'function' ? window.t('timeline.completed') : '已完成';
const failedLabel = typeof window.t === 'function' ? window.t('timeline.execFailed') : '执行失败';
let statusText = ''; let statusText = '';
if (status === 'running') { if (status === 'running') {
item.classList.add('tool-call-running'); item.classList.add('tool-call-running');
statusText = ' <span class="tool-status-badge tool-status-running">执行中...</span>'; statusText = ' <span class="tool-status-badge tool-status-running">' + escapeHtml(runningLabel) + '</span>';
} else if (status === 'completed') { } else if (status === 'completed') {
item.classList.add('tool-call-completed'); item.classList.add('tool-call-completed');
statusText = ' <span class="tool-status-badge tool-status-completed">✅ 已完成</span>'; statusText = ' <span class="tool-status-badge tool-status-completed">✅ ' + escapeHtml(completedLabel) + '</span>';
} else if (status === 'failed') { } else if (status === 'failed') {
item.classList.add('tool-call-failed'); item.classList.add('tool-call-failed');
statusText = ' <span class="tool-status-badge tool-status-failed">❌ 执行失败</span>'; statusText = ' <span class="tool-status-badge tool-status-failed">❌ ' + escapeHtml(failedLabel) + '</span>';
} }
// 更新标题(保留原有文本,追加状态) // 更新标题(保留原有文本,追加状态)
@@ -854,7 +875,8 @@ function addTimelineItem(timeline, type, options) {
eventTime = new Date(); eventTime = new Date();
} }
const time = eventTime.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const timeLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const time = eventTime.toLocaleTimeString(timeLocale, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
let content = ` let content = `
<div class="timeline-item-header"> <div class="timeline-item-header">
@@ -869,11 +891,12 @@ function addTimelineItem(timeline, type, options) {
} else if (type === 'tool_call' && options.data) { } else if (type === 'tool_call' && options.data) {
const data = options.data; const data = options.data;
const args = data.argumentsObj || (data.arguments ? JSON.parse(data.arguments) : {}); const args = data.argumentsObj || (data.arguments ? JSON.parse(data.arguments) : {});
const paramsLabel = typeof window.t === 'function' ? window.t('timeline.params') : '参数:';
content += ` content += `
<div class="timeline-item-content"> <div class="timeline-item-content">
<div class="tool-details"> <div class="tool-details">
<div class="tool-arg-section"> <div class="tool-arg-section">
<strong>参数:</strong> <strong>${escapeHtml(paramsLabel)}</strong>
<pre class="tool-args">${escapeHtml(JSON.stringify(args, null, 2))}</pre> <pre class="tool-args">${escapeHtml(JSON.stringify(args, null, 2))}</pre>
</div> </div>
</div> </div>
@@ -882,22 +905,25 @@ function addTimelineItem(timeline, type, options) {
} else if (type === 'tool_result' && options.data) { } else if (type === 'tool_result' && options.data) {
const data = options.data; const data = options.data;
const isError = data.isError || !data.success; const isError = data.isError || !data.success;
const result = data.result || data.error || '无结果'; const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果';
// 确保 result 是字符串 const result = data.result || data.error || noResultText;
const resultStr = typeof result === 'string' ? result : JSON.stringify(result); const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
const execResultLabel = typeof window.t === 'function' ? window.t('timeline.executionResult') : '执行结果:';
const execIdLabel = typeof window.t === 'function' ? window.t('timeline.executionId') : '执行ID:';
content += ` content += `
<div class="timeline-item-content"> <div class="timeline-item-content">
<div class="tool-result-section ${isError ? 'error' : 'success'}"> <div class="tool-result-section ${isError ? 'error' : 'success'}">
<strong>执行结果:</strong> <strong>${escapeHtml(execResultLabel)}</strong>
<pre class="tool-result">${escapeHtml(resultStr)}</pre> <pre class="tool-result">${escapeHtml(resultStr)}</pre>
${data.executionId ? `<div class="tool-execution-id">执行ID: <code>${escapeHtml(data.executionId)}</code></div>` : ''} ${data.executionId ? `<div class="tool-execution-id">${escapeHtml(execIdLabel)} <code>${escapeHtml(data.executionId)}</code></div>` : ''}
</div> </div>
</div> </div>
`; `;
} else if (type === 'cancelled') { } else if (type === 'cancelled') {
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
content += ` content += `
<div class="timeline-item-content"> <div class="timeline-item-content">
${escapeHtml(options.message || '任务已取消')} ${escapeHtml(options.message || taskCancelledLabel)}
</div> </div>
`; `;
} }
@@ -923,7 +949,7 @@ async function loadActiveTasks(showErrors = false) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
throw new Error(result.error || '获取活跃任务失败'); throw new Error(result.error || (typeof window.t === 'function' ? window.t('tasks.loadActiveTasksFailed') : '获取活跃任务失败'));
} }
renderActiveTasks(result.tasks || []); renderActiveTasks(result.tasks || []);
@@ -931,7 +957,8 @@ async function loadActiveTasks(showErrors = false) {
console.error('获取活跃任务失败:', error); console.error('获取活跃任务失败:', error);
if (showErrors && bar) { if (showErrors && bar) {
bar.style.display = 'block'; bar.style.display = 'block';
bar.innerHTML = `<div class="active-task-error">无法获取任务状态:${escapeHtml(error.message)}</div>`; const cannotGetStatus = typeof window.t === 'function' ? window.t('tasks.cannotGetTaskStatus') : '无法获取任务状态:';
bar.innerHTML = `<div class="active-task-error">${escapeHtml(cannotGetStatus)}${escapeHtml(error.message)}</div>`;
} }
} }
} }
@@ -960,30 +987,33 @@ function renderActiveTasks(tasks) {
item.className = 'active-task-item'; item.className = 'active-task-item';
const startedTime = task.startedAt ? new Date(task.startedAt) : null; const startedTime = task.startedAt ? new Date(task.startedAt) : null;
const taskTimeLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const timeText = startedTime && !isNaN(startedTime.getTime()) const timeText = startedTime && !isNaN(startedTime.getTime())
? startedTime.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) ? startedTime.toLocaleTimeString(taskTimeLocale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: ''; : '';
// 根据任务状态显示不同的文本 const _t = function (k) { return typeof window.t === 'function' ? window.t(k) : k; };
const statusMap = { const statusMap = {
'running': '执行中', 'running': _t('tasks.statusRunning'),
'cancelling': '取消中', 'cancelling': _t('tasks.statusCancelling'),
'failed': '执行失败', 'failed': _t('tasks.statusFailed'),
'timeout': '执行超时', 'timeout': _t('tasks.statusTimeout'),
'cancelled': '已取消', 'cancelled': _t('tasks.statusCancelled'),
'completed': '已完成' 'completed': _t('tasks.statusCompleted')
}; };
const statusText = statusMap[task.status] || '执行中'; const statusText = statusMap[task.status] || _t('tasks.statusRunning');
const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status); const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status);
const unnamedTaskText = _t('tasks.unnamedTask');
const stopTaskBtnText = _t('tasks.stopTask');
item.innerHTML = ` item.innerHTML = `
<div class="active-task-info"> <div class="active-task-info">
<span class="active-task-status">${statusText}</span> <span class="active-task-status">${statusText}</span>
<span class="active-task-message">${escapeHtml(task.message || '未命名任务')}</span> <span class="active-task-message">${escapeHtml(task.message || unnamedTaskText)}</span>
</div> </div>
<div class="active-task-actions"> <div class="active-task-actions">
${timeText ? `<span class="active-task-time">${timeText}</span>` : ''} ${timeText ? `<span class="active-task-time">${timeText}</span>` : ''}
${!isFinalStatus ? '<button class="active-task-cancel">停止任务</button>' : ''} ${!isFinalStatus ? '<button class="active-task-cancel">' + stopTaskBtnText + '</button>' : ''}
</div> </div>
`; `;
@@ -994,7 +1024,7 @@ function renderActiveTasks(tasks) {
cancelBtn.onclick = () => cancelActiveTask(task.conversationId, cancelBtn); cancelBtn.onclick = () => cancelActiveTask(task.conversationId, cancelBtn);
if (task.status === 'cancelling') { if (task.status === 'cancelling') {
cancelBtn.disabled = true; cancelBtn.disabled = true;
cancelBtn.textContent = '取消中...'; cancelBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
} }
} }
} }
@@ -1007,14 +1037,14 @@ async function cancelActiveTask(conversationId, button) {
if (!conversationId) return; if (!conversationId) return;
const originalText = button.textContent; const originalText = button.textContent;
button.disabled = true; button.disabled = true;
button.textContent = '取消中...'; button.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
try { try {
await requestCancel(conversationId); await requestCancel(conversationId);
loadActiveTasks(); loadActiveTasks();
} catch (error) { } catch (error) {
console.error('取消任务失败:', error); console.error('取消任务失败:', error);
alert('取消任务失败: ' + error.message); alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
button.disabled = false; button.disabled = false;
button.textContent = originalText; button.textContent = originalText;
} }
@@ -1138,10 +1168,10 @@ async function refreshMonitorPanel(page = null) {
} catch (error) { } catch (error) {
console.error('刷新监控面板失败:', error); console.error('刷新监控面板失败:', error);
if (statsContainer) { if (statsContainer) {
statsContainer.innerHTML = `<div class="monitor-error">无法加载统计信息${escapeHtml(error.message)}</div>`; statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}${escapeHtml(error.message)}</div>`;
} }
if (execContainer) { if (execContainer) {
execContainer.innerHTML = `<div class="monitor-error">无法加载执行记录${escapeHtml(error.message)}</div>`; execContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadExecutionsError') : '无法加载执行记录')}${escapeHtml(error.message)}</div>`;
} }
} }
} }
@@ -1215,10 +1245,10 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
} catch (error) { } catch (error) {
console.error('刷新监控面板失败:', error); console.error('刷新监控面板失败:', error);
if (statsContainer) { if (statsContainer) {
statsContainer.innerHTML = `<div class="monitor-error">无法加载统计信息${escapeHtml(error.message)}</div>`; statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}${escapeHtml(error.message)}</div>`;
} }
if (execContainer) { if (execContainer) {
execContainer.innerHTML = `<div class="monitor-error">无法加载执行记录${escapeHtml(error.message)}</div>`; execContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadExecutionsError') : '无法加载执行记录')}${escapeHtml(error.message)}</div>`;
} }
} }
} }
@@ -1232,7 +1262,8 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
const entries = Object.values(statsMap); const entries = Object.values(statsMap);
if (entries.length === 0) { if (entries.length === 0) {
container.innerHTML = '<div class="monitor-empty">暂无统计数据</div>'; const noStats = typeof window.t === 'function' ? window.t('mcpMonitor.noStatsData') : '暂无统计数据';
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>';
return; return;
} }
@@ -1252,24 +1283,32 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
); );
const successRate = totals.total > 0 ? ((totals.success / totals.total) * 100).toFixed(1) : '0.0'; const successRate = totals.total > 0 ? ((totals.success / totals.total) * 100).toFixed(1) : '0.0';
const lastUpdatedText = lastFetchedAt ? lastFetchedAt.toLocaleString('zh-CN') : 'N/A'; const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
const lastCallText = totals.lastCallTime ? totals.lastCallTime.toLocaleString('zh-CN') : '暂无调用'; const lastUpdatedText = lastFetchedAt ? (lastFetchedAt.toLocaleString ? lastFetchedAt.toLocaleString(locale || 'en-US') : String(lastFetchedAt)) : 'N/A';
const noCallsYet = typeof window.t === 'function' ? window.t('mcpMonitor.noCallsYet') : '暂无调用';
const lastCallText = totals.lastCallTime ? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale || 'en-US') : String(totals.lastCallTime)) : noCallsYet;
const totalCallsLabel = typeof window.t === 'function' ? window.t('mcpMonitor.totalCalls') : '总调用次数';
const successFailedLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successFailed', { success: totals.success, failed: totals.failed }) : `成功 ${totals.success} / 失败 ${totals.failed}`;
const successRateLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successRate') : '成功率';
const statsFromAll = typeof window.t === 'function' ? window.t('mcpMonitor.statsFromAllTools') : '统计自全部工具调用';
const lastCallLabel = typeof window.t === 'function' ? window.t('mcpMonitor.lastCall') : '最近一次调用';
const lastRefreshLabel = typeof window.t === 'function' ? window.t('mcpMonitor.lastRefreshTime') : '最后刷新时间';
let html = ` let html = `
<div class="monitor-stat-card"> <div class="monitor-stat-card">
<h4>总调用次数</h4> <h4>${escapeHtml(totalCallsLabel)}</h4>
<div class="monitor-stat-value">${totals.total}</div> <div class="monitor-stat-value">${totals.total}</div>
<div class="monitor-stat-meta">成功 ${totals.success} / 失败 ${totals.failed}</div> <div class="monitor-stat-meta">${escapeHtml(successFailedLabel)}</div>
</div> </div>
<div class="monitor-stat-card"> <div class="monitor-stat-card">
<h4>成功率</h4> <h4>${escapeHtml(successRateLabel)}</h4>
<div class="monitor-stat-value">${successRate}%</div> <div class="monitor-stat-value">${successRate}%</div>
<div class="monitor-stat-meta">统计自全部工具调用</div> <div class="monitor-stat-meta">${escapeHtml(statsFromAll)}</div>
</div> </div>
<div class="monitor-stat-card"> <div class="monitor-stat-card">
<h4>最近一次调用</h4> <h4>${escapeHtml(lastCallLabel)}</h4>
<div class="monitor-stat-value" style="font-size:1rem;">${lastCallText}</div> <div class="monitor-stat-value" style="font-size:1rem;">${escapeHtml(lastCallText)}</div>
<div class="monitor-stat-meta">最后刷新时间${lastUpdatedText}</div> <div class="monitor-stat-meta">${escapeHtml(lastRefreshLabel)}${escapeHtml(lastUpdatedText)}</div>
</div> </div>
`; `;
@@ -1280,14 +1319,16 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0)) .sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
.slice(0, 4); .slice(0, 4);
const unknownToolLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknownTool') : '未知工具';
topTools.forEach(tool => { topTools.forEach(tool => {
const toolSuccessRate = tool.totalCalls > 0 ? ((tool.successCalls || 0) / tool.totalCalls * 100).toFixed(1) : '0.0'; const toolSuccessRate = tool.totalCalls > 0 ? ((tool.successCalls || 0) / tool.totalCalls * 100).toFixed(1) : '0.0';
const toolMeta = typeof window.t === 'function' ? window.t('mcpMonitor.successFailedRate', { success: tool.successCalls || 0, failed: tool.failedCalls || 0, rate: toolSuccessRate }) : `成功 ${tool.successCalls || 0} / 失败 ${tool.failedCalls || 0} · 成功率 ${toolSuccessRate}%`;
html += ` html += `
<div class="monitor-stat-card"> <div class="monitor-stat-card">
<h4>${escapeHtml(tool.toolName || '未知工具')}</h4> <h4>${escapeHtml(tool.toolName || unknownToolLabel)}</h4>
<div class="monitor-stat-value">${tool.totalCalls || 0}</div> <div class="monitor-stat-value">${tool.totalCalls || 0}</div>
<div class="monitor-stat-meta"> <div class="monitor-stat-meta">
成功 ${tool.successCalls || 0} / 失败 ${tool.failedCalls || 0} · 成功率 ${toolSuccessRate}% ${escapeHtml(toolMeta)}
</div> </div>
</div> </div>
`; `;
@@ -1307,10 +1348,12 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const toolFilter = document.getElementById('monitor-tool-filter'); const toolFilter = document.getElementById('monitor-tool-filter');
const currentToolFilter = toolFilter ? toolFilter.value : 'all'; const currentToolFilter = toolFilter ? toolFilter.value : 'all';
const hasFilter = (statusFilter && statusFilter !== 'all') || (currentToolFilter && currentToolFilter !== 'all'); const hasFilter = (statusFilter && statusFilter !== 'all') || (currentToolFilter && currentToolFilter !== 'all');
const noRecordsFilter = typeof window.t === 'function' ? window.t('mcpMonitor.noRecordsWithFilter') : '当前筛选条件下暂无记录';
const noExecutions = typeof window.t === 'function' ? window.t('mcpMonitor.noExecutions') : '暂无执行记录';
if (hasFilter) { if (hasFilter) {
container.innerHTML = '<div class="monitor-empty">当前筛选条件下暂无记录</div>'; container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noRecordsFilter) + '</div>';
} else { } else {
container.innerHTML = '<div class="monitor-empty">暂无执行记录</div>'; container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noExecutions) + '</div>';
} }
// 隐藏批量操作栏 // 隐藏批量操作栏
const batchActions = document.getElementById('monitor-batch-actions'); const batchActions = document.getElementById('monitor-batch-actions');
@@ -1322,14 +1365,22 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
// 由于筛选已经在后端完成,这里直接使用所有传入的执行记录 // 由于筛选已经在后端完成,这里直接使用所有传入的执行记录
// 不再需要前端再次筛选,因为后端已经返回了筛选后的数据 // 不再需要前端再次筛选,因为后端已经返回了筛选后的数据
const unknownLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknown') : '未知';
const unknownToolLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknownTool') : '未知工具';
const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情';
const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除';
const deleteExecTitle = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecTitle') : '删除此执行记录';
const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed' };
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
const rows = executions const rows = executions
.map(exec => { .map(exec => {
const status = (exec.status || 'unknown').toLowerCase(); const status = (exec.status || 'unknown').toLowerCase();
const statusClass = `monitor-status-chip ${status}`; const statusClass = `monitor-status-chip ${status}`;
const statusLabel = getStatusText(status); const statusKey = statusKeyMap[status];
const startTime = exec.startTime ? new Date(exec.startTime).toLocaleString('zh-CN') : '未知'; const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status);
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
const duration = formatExecutionDuration(exec.startTime, exec.endTime); const duration = formatExecutionDuration(exec.startTime, exec.endTime);
const toolName = escapeHtml(exec.toolName || '未知工具'); const toolName = escapeHtml(exec.toolName || unknownToolLabel);
const executionId = escapeHtml(exec.id || ''); const executionId = escapeHtml(exec.id || '');
return ` return `
<tr> <tr>
@@ -1337,13 +1388,13 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
<input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" onchange="updateBatchActionsState()" /> <input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" onchange="updateBatchActionsState()" />
</td> </td>
<td>${toolName}</td> <td>${toolName}</td>
<td><span class="${statusClass}">${statusLabel}</span></td> <td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td>
<td>${startTime}</td> <td>${escapeHtml(startTime)}</td>
<td>${duration}</td> <td>${escapeHtml(duration)}</td>
<td> <td>
<div class="monitor-execution-actions"> <div class="monitor-execution-actions">
<button class="btn-secondary" onclick="showMCPDetail('${executionId}')">查看详情</button> <button class="btn-secondary" onclick="showMCPDetail('${executionId}')">${escapeHtml(viewDetailLabel)}</button>
<button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="删除此执行记录">删除</button> <button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -1365,6 +1416,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
// 创建表格容器 // 创建表格容器
const tableContainer = document.createElement('div'); const tableContainer = document.createElement('div');
tableContainer.className = 'monitor-table-container'; tableContainer.className = 'monitor-table-container';
const colTool = typeof window.t === 'function' ? window.t('mcpMonitor.columnTool') : '工具';
const colStatus = typeof window.t === 'function' ? window.t('mcpMonitor.columnStatus') : '状态';
const colStartTime = typeof window.t === 'function' ? window.t('mcpMonitor.columnStartTime') : '开始时间';
const colDuration = typeof window.t === 'function' ? window.t('mcpMonitor.columnDuration') : '耗时';
const colActions = typeof window.t === 'function' ? window.t('mcpMonitor.columnActions') : '操作';
tableContainer.innerHTML = ` tableContainer.innerHTML = `
<table class="monitor-table"> <table class="monitor-table">
<thead> <thead>
@@ -1372,11 +1428,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
<th style="width: 40px;"> <th style="width: 40px;">
<input type="checkbox" id="monitor-select-all" onchange="toggleSelectAll(this)" /> <input type="checkbox" id="monitor-select-all" onchange="toggleSelectAll(this)" />
</th> </th>
<th>工具</th> <th>${escapeHtml(colTool)}</th>
<th>状态</th> <th>${escapeHtml(colStatus)}</th>
<th>开始时间</th> <th>${escapeHtml(colStartTime)}</th>
<th>耗时</th> <th>${escapeHtml(colDuration)}</th>
<th>操作</th> <th>${escapeHtml(colActions)}</th>
</tr> </tr>
</thead> </thead>
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
@@ -1415,12 +1471,18 @@ function renderMonitorPagination() {
// 处理没有数据的情况 // 处理没有数据的情况
const startItem = total === 0 ? 0 : (page - 1) * pageSize + 1; const startItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
const endItem = total === 0 ? 0 : Math.min(page * pageSize, total); const endItem = total === 0 ? 0 : Math.min(page * pageSize, total);
const paginationInfoText = typeof window.t === 'function' ? window.t('mcpMonitor.paginationInfo', { start: startItem, end: endItem, total: total }) : `显示 ${startItem}-${endItem} / 共 ${total} 条记录`;
const perPageLabel = typeof window.t === 'function' ? window.t('mcpMonitor.perPageLabel') : '每页显示';
const firstPageLabel = typeof window.t === 'function' ? window.t('mcp.firstPage') : '首页';
const prevPageLabel = typeof window.t === 'function' ? window.t('mcp.prevPage') : '上一页';
const pageInfoText = typeof window.t === 'function' ? window.t('mcp.pageInfo', { page: page, total: totalPages || 1 }) : `${page} / ${totalPages || 1}`;
const nextPageLabel = typeof window.t === 'function' ? window.t('mcp.nextPage') : '下一页';
const lastPageLabel = typeof window.t === 'function' ? window.t('mcp.lastPage') : '末页';
pagination.innerHTML = ` pagination.innerHTML = `
<div class="pagination-info"> <div class="pagination-info">
<span>显示 ${startItem}-${endItem} / ${total} 条记录</span> <span>${escapeHtml(paginationInfoText)}</span>
<label class="pagination-page-size"> <label class="pagination-page-size">
每页显示 ${escapeHtml(perPageLabel)}
<select id="monitor-page-size" onchange="changeMonitorPageSize()"> <select id="monitor-page-size" onchange="changeMonitorPageSize()">
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option> <option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option> <option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
@@ -1430,11 +1492,11 @@ function renderMonitorPagination() {
</label> </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' : ''}>${escapeHtml(firstPageLabel)}</button>
<button class="btn-secondary" onclick="refreshMonitorPanel(${page - 1})" ${page === 1 || total === 0 ? 'disabled' : ''}>上一页</button> <button class="btn-secondary" onclick="refreshMonitorPanel(${page - 1})" ${page === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(prevPageLabel)}</button>
<span class="pagination-page"> ${page} / ${totalPages || 1} </span> <span class="pagination-page">${escapeHtml(pageInfoText)}</span>
<button class="btn-secondary" onclick="refreshMonitorPanel(${page + 1})" ${page >= totalPages || total === 0 ? 'disabled' : ''}>下一页</button> <button class="btn-secondary" onclick="refreshMonitorPanel(${page + 1})" ${page >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(nextPageLabel)}</button>
<button class="btn-secondary" onclick="refreshMonitorPanel(${totalPages || 1})" ${page >= totalPages || total === 0 ? 'disabled' : ''}>末页</button> <button class="btn-secondary" onclick="refreshMonitorPanel(${totalPages || 1})" ${page >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(lastPageLabel)}</button>
</div> </div>
`; `;
@@ -1450,8 +1512,8 @@ async function deleteExecution(executionId) {
return; return;
} }
// 确认删除 const deleteConfirmMsg = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecConfirmSingle') : '确定要删除此执行记录吗?此操作不可恢复。';
if (!confirm('确定要删除此执行记录吗?此操作不可恢复。')) { if (!confirm(deleteConfirmMsg)) {
return; return;
} }
@@ -1462,17 +1524,20 @@ async function deleteExecution(executionId) {
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({})); const error = await response.json().catch(() => ({}));
throw new Error(error.error || '删除执行记录失败'); const deleteFailedMsg = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecFailed') : '删除执行记录失败';
throw new Error(error.error || deleteFailedMsg);
} }
// 删除成功后刷新当前页面 // 删除成功后刷新当前页面
const currentPage = monitorState.pagination.page; const currentPage = monitorState.pagination.page;
await refreshMonitorPanel(currentPage); await refreshMonitorPanel(currentPage);
alert('执行记录已删除'); const execDeletedMsg = typeof window.t === 'function' ? window.t('mcpMonitor.execDeleted') : '执行记录已删除';
alert(execDeletedMsg);
} catch (error) { } catch (error) {
console.error('删除执行记录失败:', error); console.error('删除执行记录失败:', error);
alert('删除执行记录失败: ' + error.message); const deleteFailedMsg = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecFailed') : '删除执行记录失败';
alert(deleteFailedMsg + ': ' + error.message);
} }
} }
@@ -1487,14 +1552,14 @@ function updateBatchActionsState() {
if (batchActions) { if (batchActions) {
batchActions.style.display = 'flex'; batchActions.style.display = 'flex';
} }
if (selectedCountSpan) {
selectedCountSpan.textContent = `已选择 ${selectedCount}`;
}
} else { } else {
if (batchActions) { if (batchActions) {
batchActions.style.display = 'none'; batchActions.style.display = 'none';
} }
} }
if (selectedCountSpan) {
selectedCountSpan.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: selectedCount }) : '已选择 ' + selectedCount + ' 项';
}
// 更新全选复选框状态 // 更新全选复选框状态
const selectAllCheckbox = document.getElementById('monitor-select-all'); const selectAllCheckbox = document.getElementById('monitor-select-all');
@@ -1547,15 +1612,15 @@ function deselectAllExecutions() {
async function batchDeleteExecutions() { async function batchDeleteExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked'); const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
if (checkboxes.length === 0) { if (checkboxes.length === 0) {
alert('请先选择要删除的执行记录'); const selectFirstMsg = typeof window.t === 'function' ? window.t('mcpMonitor.selectExecFirst') : '请先选择要删除的执行记录';
alert(selectFirstMsg);
return; return;
} }
const ids = Array.from(checkboxes).map(cb => cb.value); const ids = Array.from(checkboxes).map(cb => cb.value);
const count = ids.length; const count = ids.length;
const batchConfirmMsg = typeof window.t === 'function' ? window.t('mcpMonitor.batchDeleteConfirm', { count: count }) : `确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`;
// 确认删除 if (!confirm(batchConfirmMsg)) {
if (!confirm(`确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`)) {
return; return;
} }
@@ -1570,7 +1635,8 @@ async function batchDeleteExecutions() {
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({})); const error = await response.json().catch(() => ({}));
throw new Error(error.error || '批量删除执行记录失败'); const batchFailedMsg = typeof window.t === 'function' ? window.t('mcp.batchDeleteFailed') : '批量删除执行记录失败';
throw new Error(error.error || batchFailedMsg);
} }
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
@@ -1580,33 +1646,46 @@ async function batchDeleteExecutions() {
const currentPage = monitorState.pagination.page; const currentPage = monitorState.pagination.page;
await refreshMonitorPanel(currentPage); await refreshMonitorPanel(currentPage);
alert(`成功删除 ${deletedCount} 条执行记录`); const batchSuccessMsg = typeof window.t === 'function' ? window.t('mcpMonitor.batchDeleteSuccess', { count: deletedCount }) : `成功删除 ${deletedCount} 条执行记录`;
alert(batchSuccessMsg);
} catch (error) { } catch (error) {
console.error('批量删除执行记录失败:', error); console.error('批量删除执行记录失败:', error);
alert('批量删除执行记录失败: ' + error.message); const batchFailedMsg = typeof window.t === 'function' ? window.t('mcp.batchDeleteFailed') : '批量删除执行记录失败';
alert(batchFailedMsg + ': ' + error.message);
} }
} }
function formatExecutionDuration(start, end) { function formatExecutionDuration(start, end) {
const unknownLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknown') : '未知';
if (!start) { if (!start) {
return '未知'; return unknownLabel;
} }
const startTime = new Date(start); const startTime = new Date(start);
const endTime = end ? new Date(end) : new Date(); const endTime = end ? new Date(end) : new Date();
if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) { if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) {
return '未知'; return unknownLabel;
} }
const diffMs = Math.max(0, endTime - startTime); const diffMs = Math.max(0, endTime - startTime);
const seconds = Math.floor(diffMs / 1000); const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) { if (seconds < 60) {
return `${seconds}`; return typeof window.t === 'function' ? window.t('mcpMonitor.durationSeconds', { n: seconds }) : seconds + ' 秒';
} }
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
if (minutes < 60) { if (minutes < 60) {
const remain = seconds % 60; const remain = seconds % 60;
return remain > 0 ? `${minutes}${remain}` : `${minutes}`; if (remain > 0) {
return typeof window.t === 'function' ? window.t('mcpMonitor.durationMinutes', { minutes: minutes, seconds: remain }) : minutes + ' 分 ' + remain + ' 秒';
}
return typeof window.t === 'function' ? window.t('mcpMonitor.durationMinutesOnly', { minutes: minutes }) : minutes + ' 分';
} }
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const remainMinutes = minutes % 60; const remainMinutes = minutes % 60;
return remainMinutes > 0 ? `${hours} 小时 ${remainMinutes}` : `${hours} 小时`; if (remainMinutes > 0) {
return typeof window.t === 'function' ? window.t('mcpMonitor.durationHours', { hours: hours, minutes: remainMinutes }) : hours + ' 小时 ' + remainMinutes + ' 分';
}
return typeof window.t === 'function' ? window.t('mcpMonitor.durationHoursOnly', { hours: hours }) : hours + ' 小时';
} }
document.addEventListener('languagechange', function () {
updateBatchActionsState();
});
+48 -38
View File
@@ -1,4 +1,7 @@
// 角色管理相关功能 // 角色管理相关功能
function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
}
let currentRole = localStorage.getItem('currentRole') || ''; let currentRole = localStorage.getItem('currentRole') || '';
let roles = []; let roles = [];
let rolesSearchKeyword = ''; // 角色搜索关键词 let rolesSearchKeyword = ''; // 角色搜索关键词
@@ -54,7 +57,7 @@ async function loadRoles() {
return roles; return roles;
} catch (error) { } catch (error) {
console.error('加载角色失败:', error); console.error('加载角色失败:', error);
showNotification('加载角色失败: ' + error.message, 'error'); showNotification(_t('roles.loadFailed') + ': ' + error.message, 'error');
return []; return [];
} }
} }
@@ -108,11 +111,13 @@ function updateRoleSelectorDisplay() {
} }
} }
roleSelectorIcon.textContent = icon; roleSelectorIcon.textContent = icon;
roleSelectorText.textContent = selectedRole.name || '默认'; const displayName = (selectedRole.name === '默认' || !selectedRole.name) && typeof window.t === 'function'
? window.t('chat.defaultRole') : (selectedRole.name || (typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认'));
roleSelectorText.textContent = displayName;
} else { } else {
// 默认角色 // 默认角色
roleSelectorIcon.textContent = '🔵'; roleSelectorIcon.textContent = '🔵';
roleSelectorText.textContent = '默认'; roleSelectorText.textContent = typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认';
} }
} }
@@ -165,9 +170,9 @@ function renderRoleSelectionSidebar() {
const icon = getRoleIcon(role); const icon = getRoleIcon(role);
// 处理默认角色的描述 // 处理默认角色的描述
let description = role.description || '暂无描述'; let description = role.description || _t('roles.noDescription');
if (isDefaultRole && !role.description) { if (isDefaultRole && !role.description) {
description = '默认角色,不额外携带用户提示词,使用默认MCP'; description = _t('roles.defaultRoleDescription');
} }
roleItem.innerHTML = ` roleItem.innerHTML = `
@@ -280,7 +285,7 @@ function renderRolesList() {
if (filteredRoles.length === 0) { if (filteredRoles.length === 0) {
rolesList.innerHTML = '<div class="empty-state">' + rolesList.innerHTML = '<div class="empty-state">' +
(rolesSearchKeyword ? '没有找到匹配的角色' : '暂无角色') + (rolesSearchKeyword ? _t('roles.noMatchingRoles') : _t('roles.noRoles')) +
'</div>'; '</div>';
return; return;
} }
@@ -310,7 +315,7 @@ function renderRolesList() {
let toolsDisplay = ''; let toolsDisplay = '';
let toolsCount = 0; let toolsCount = 0;
if (role.name === '默认') { if (role.name === '默认') {
toolsDisplay = '使用所有工具'; toolsDisplay = _t('roleModal.usingAllTools');
} else if (role.tools && role.tools.length > 0) { } else if (role.tools && role.tools.length > 0) {
toolsCount = role.tools.length; toolsCount = role.tools.length;
// 显示前5个工具名称 // 显示前5个工具名称
@@ -322,13 +327,13 @@ function renderRolesList() {
if (toolsCount <= 5) { if (toolsCount <= 5) {
toolsDisplay = toolNames.join(', '); toolsDisplay = toolNames.join(', ');
} else { } else {
toolsDisplay = toolNames.join(', ') + `${toolsCount}`; toolsDisplay = toolNames.join(', ') + _t('roleModal.andNMore', { count: toolsCount });
} }
} else if (role.mcps && role.mcps.length > 0) { } else if (role.mcps && role.mcps.length > 0) {
toolsCount = role.mcps.length; toolsCount = role.mcps.length;
toolsDisplay = `${toolsCount}`; toolsDisplay = _t('roleModal.andNMore', { count: toolsCount });
} else { } else {
toolsDisplay = '使用所有工具'; toolsDisplay = _t('roleModal.usingAllTools');
} }
return ` return `
@@ -339,17 +344,17 @@ function renderRolesList() {
${escapeHtml(role.name)} ${escapeHtml(role.name)}
</h3> </h3>
<span class="role-card-badge ${role.enabled !== false ? 'enabled' : 'disabled'}"> <span class="role-card-badge ${role.enabled !== false ? 'enabled' : 'disabled'}">
${role.enabled !== false ? '已启用' : '已禁用'} ${role.enabled !== false ? _t('roles.enabled') : _t('roles.disabled')}
</span> </span>
</div> </div>
<div class="role-card-description">${escapeHtml(role.description || '无描述')}</div> <div class="role-card-description">${escapeHtml(role.description || _t('roles.noDescriptionShort'))}</div>
<div class="role-card-tools"> <div class="role-card-tools">
<span class="role-card-tools-label">工具:</span> <span class="role-card-tools-label">${_t('roleModal.toolsLabel')}</span>
<span class="role-card-tools-value">${toolsDisplay}</span> <span class="role-card-tools-value">${toolsDisplay}</span>
</div> </div>
<div class="role-card-actions"> <div class="role-card-actions">
<button class="btn-secondary btn-small" onclick="editRole('${escapeHtml(role.name)}')">编辑</button> <button class="btn-secondary btn-small" onclick="editRole('${escapeHtml(role.name)}')">${_t('common.edit')}</button>
${role.name !== '默认' ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteRole('${escapeHtml(role.name)}')">删除</button>` : ''} ${role.name !== '默认' ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteRole('${escapeHtml(role.name)}')">${_t('common.delete')}</button>` : ''}
</div> </div>
</div> </div>
`; `;
@@ -501,7 +506,7 @@ async function loadRoleTools(page = 1, searchKeyword = '') {
console.error('加载工具列表失败:', error); console.error('加载工具列表失败:', error);
const toolsList = document.getElementById('role-tools-list'); const toolsList = document.getElementById('role-tools-list');
if (toolsList) { if (toolsList) {
toolsList.innerHTML = `<div class="tools-error">加载工具列表失败: ${escapeHtml(error.message)}</div>`; toolsList.innerHTML = `<div class="tools-error">${_t('roleModal.loadToolsFailed')}: ${escapeHtml(error.message)}</div>`;
} }
} }
} }
@@ -519,7 +524,7 @@ function renderRoleToolsList() {
listContainer.innerHTML = ''; listContainer.innerHTML = '';
if (allRoleTools.length === 0) { if (allRoleTools.length === 0) {
listContainer.innerHTML = '<div class="tools-empty">暂无工具</div>'; listContainer.innerHTML = '<div class="tools-empty">' + _t('roleModal.noTools') + '</div>';
toolsList.appendChild(listContainer); toolsList.appendChild(listContainer);
return; return;
} }
@@ -592,16 +597,16 @@ function renderRoleToolsPagination() {
const startItem = (page - 1) * roleToolsPagination.pageSize + 1; const startItem = (page - 1) * roleToolsPagination.pageSize + 1;
const endItem = Math.min(page * roleToolsPagination.pageSize, total); const endItem = Math.min(page * roleToolsPagination.pageSize, total);
const paginationShowText = _t('roleModal.paginationShow', { start: startItem, end: endItem, total: total }) +
(roleToolsSearchKeyword ? _t('roleModal.paginationSearch', { keyword: roleToolsSearchKeyword }) : '');
pagination.innerHTML = ` pagination.innerHTML = `
<div class="pagination-info"> <div class="pagination-info">${paginationShowText}</div>
显示 ${startItem}-${endItem} / ${total} 个工具${roleToolsSearchKeyword ? ` (搜索: "${escapeHtml(roleToolsSearchKeyword)}")` : ''}
</div>
<div class="pagination-controls"> <div class="pagination-controls">
<button class="btn-secondary" onclick="loadRoleTools(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>首页</button> <button class="btn-secondary" onclick="loadRoleTools(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${_t('roleModal.firstPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${page - 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>上一页</button> <button class="btn-secondary" onclick="loadRoleTools(${page - 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${_t('roleModal.prevPage')}</button>
<span class="pagination-page"> ${page} / ${totalPages} </span> <span class="pagination-page">${_t('roleModal.pageOf', { page: page, total: totalPages })}</span>
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>下一页</button> <button class="btn-secondary" onclick="loadRoleTools(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${_t('roleModal.nextPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>末页</button> <button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${_t('roleModal.lastPage')}</button>
</div> </div>
`; `;
@@ -725,8 +730,8 @@ function updateRoleToolsStats() {
// 总工具数(所有工具,包括已启用和未启用的) // 总工具数(所有工具,包括已启用和未启用的)
const totalTools = roleToolsPagination.total || 0; const totalTools = roleToolsPagination.total || 0;
statsEl.innerHTML = ` statsEl.innerHTML = `
<span title="当前页选中的工具数"> 当前页已选中: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span> <span title="${_t('roleModal.currentPageSelectedTitle')}"> ${_t('roleModal.currentPageSelected', { current: currentPageEnabled, total: currentPageTotal })}</span>
<span title="所有已启用工具中选中的工具总数(基于MCP管理)">📊 总计已选中: <strong>${totalEnabled}</strong> / ${totalTools} <em>(使用所有已启用工具)</em></span> <span title="${_t('roleModal.totalSelectedTitle')}">📊 ${_t('roleModal.totalSelected', { current: totalEnabled, total: totalTools })} <em>${_t('roleModal.usingAllEnabledTools')}</em></span>
`; `;
return; return;
} }
@@ -777,8 +782,8 @@ function updateRoleToolsStats() {
const totalTools = roleToolsPagination.total || 0; const totalTools = roleToolsPagination.total || 0;
statsEl.innerHTML = ` statsEl.innerHTML = `
<span title="当前页选中的工具数(只统计已启用的工具)"> 当前页已选中: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span> <span title="${_t('roleModal.currentPageSelectedTitle')}"> ${_t('roleModal.currentPageSelected', { current: currentPageEnabled, total: currentPageTotal })}</span>
<span title="角色已关联的工具总数(基于角色实际配置)">📊 总计已选中: <strong>${totalSelected}</strong> / ${totalTools}</span> <span title="${_t('roleModal.totalSelectedTitle')}">📊 ${_t('roleModal.totalSelected', { current: totalSelected, total: totalTools })}</span>
`; `;
} }
@@ -836,7 +841,7 @@ async function showAddRoleModal() {
const modal = document.getElementById('role-modal'); const modal = document.getElementById('role-modal');
if (!modal) return; if (!modal) return;
document.getElementById('role-modal-title').textContent = '添加角色'; document.getElementById('role-modal-title').textContent = _t('roleModal.addRole');
document.getElementById('role-name').value = ''; document.getElementById('role-name').value = '';
document.getElementById('role-name').disabled = false; document.getElementById('role-name').disabled = false;
document.getElementById('role-description').value = ''; document.getElementById('role-description').value = '';
@@ -916,14 +921,14 @@ async function showAddRoleModal() {
async function editRole(roleName) { async function editRole(roleName) {
const role = roles.find(r => r.name === roleName); const role = roles.find(r => r.name === roleName);
if (!role) { if (!role) {
showNotification('角色不存在', 'error'); showNotification(_t('roleModal.roleNotFound'), 'error');
return; return;
} }
const modal = document.getElementById('role-modal'); const modal = document.getElementById('role-modal');
if (!modal) return; if (!modal) return;
document.getElementById('role-modal-title').textContent = '编辑角色'; document.getElementById('role-modal-title').textContent = _t('roleModal.editRole');
document.getElementById('role-name').value = role.name; document.getElementById('role-name').value = role.name;
document.getElementById('role-name').disabled = true; // 编辑时不允许修改名称 document.getElementById('role-name').disabled = true; // 编辑时不允许修改名称
document.getElementById('role-description').value = role.description || ''; document.getElementById('role-description').value = role.description || '';
@@ -1184,7 +1189,7 @@ async function loadAllToolsToStateMap() {
async function saveRole() { async function saveRole() {
const name = document.getElementById('role-name').value.trim(); const name = document.getElementById('role-name').value.trim();
if (!name) { if (!name) {
showNotification('角色名称不能为空', 'error'); showNotification(_t('roleModal.roleNameRequired'), 'error');
return; return;
} }
@@ -1225,7 +1230,7 @@ async function saveRole() {
// 如果是首次添加角色且没有选择工具,默认使用全部工具 // 如果是首次添加角色且没有选择工具,默认使用全部工具
if (isFirstUserRole && allSelectedTools.length === 0) { if (isFirstUserRole && allSelectedTools.length === 0) {
roleUsesAllTools = true; roleUsesAllTools = true;
showNotification('检测到这是首次添加角色且未选择工具,将默认使用全部工具', 'info'); showNotification(_t('roleModal.firstRoleNoToolsHint'), 'info');
} else if (roleUsesAllTools) { } else if (roleUsesAllTools) {
// 如果当前使用所有工具,需要检查用户是否取消了一些工具 // 如果当前使用所有工具,需要检查用户是否取消了一些工具
// 检查状态映射中是否有未选中的已启用工具 // 检查状态映射中是否有未选中的已启用工具
@@ -1356,7 +1361,7 @@ async function saveRole() {
// 删除角色 // 删除角色
async function deleteRole(roleName) { async function deleteRole(roleName) {
if (roleName === '默认') { if (roleName === '默认') {
showNotification('不能删除默认角色', 'error'); showNotification(_t('roleModal.cannotDeleteDefaultRole'), 'error');
return; return;
} }
@@ -1428,6 +1433,11 @@ document.addEventListener('DOMContentLoaded', () => {
updateRoleSelectorDisplay(); updateRoleSelectorDisplay();
}); });
// 语言切换后刷新角色选择器显示(默认/自定义角色名)
document.addEventListener('languagechange', () => {
updateRoleSelectorDisplay();
});
// 获取当前选中的角色(供chat.js使用) // 获取当前选中的角色(供chat.js使用)
function getCurrentRole() { function getCurrentRole() {
return currentRole || ''; return currentRole || '';
@@ -1467,7 +1477,7 @@ async function loadRoleSkills() {
allRoleSkills = []; allRoleSkills = [];
const skillsList = document.getElementById('role-skills-list'); const skillsList = document.getElementById('role-skills-list');
if (skillsList) { if (skillsList) {
skillsList.innerHTML = '<div class="skills-error">加载skills列表失败: ' + error.message + '</div>'; skillsList.innerHTML = '<div class="skills-error">' + _t('roleModal.loadSkillsFailed') + ': ' + error.message + '</div>';
} }
} }
} }
@@ -1488,7 +1498,7 @@ function renderRoleSkills() {
if (filteredSkills.length === 0) { if (filteredSkills.length === 0) {
skillsList.innerHTML = '<div class="skills-empty">' + skillsList.innerHTML = '<div class="skills-empty">' +
(roleSkillsSearchKeyword ? '没有找到匹配的skills' : '暂无可用skills') + (roleSkillsSearchKeyword ? _t('roleModal.noMatchingSkills') : _t('roleModal.noSkillsAvailable')) +
'</div>'; '</div>';
updateRoleSkillsStats(); updateRoleSkillsStats();
return; return;
@@ -1594,7 +1604,7 @@ function updateRoleSkillsStats() {
filteredSkills.includes(skill) filteredSkills.includes(skill)
).length; ).length;
statsEl.textContent = `已选择 ${selectedCount} / ${filteredSkills.length}`; statsEl.textContent = _t('roleModal.skillsSelectedCount', { count: selectedCount, total: filteredSkills.length });
} }
// HTML转义函数 // HTML转义函数
+27 -7
View File
@@ -1,5 +1,5 @@
// 页面路由管理 // 页面路由管理
let currentPage = 'chat'; let currentPage = 'dashboard';
// 初始化路由 // 初始化路由
function initRouter() { function initRouter() {
@@ -8,7 +8,7 @@ function initRouter() {
if (hash) { if (hash) {
const hashParts = hash.split('?'); const hashParts = hash.split('?');
const pageId = hashParts[0]; const pageId = hashParts[0];
if (pageId && ['chat', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) { if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) {
switchPage(pageId); switchPage(pageId);
// 如果是chat页面且带有conversation参数,加载对应对话 // 如果是chat页面且带有conversation参数,加载对应对话
@@ -32,8 +32,8 @@ function initRouter() {
} }
} }
// 默认显示对话页面 // 默认显示仪表盘
switchPage('chat'); switchPage('dashboard');
} }
// 切换页面 // 切换页面
@@ -237,9 +237,20 @@ function showSubmenuPopup(navItem, menuId) {
// 初始化页面 // 初始化页面
function initPage(pageId) { function initPage(pageId) {
switch(pageId) { switch(pageId) {
case 'dashboard':
if (typeof refreshDashboard === 'function') {
refreshDashboard();
}
break;
case 'chat': case 'chat':
// 对话页面已由chat.js初始化 // 对话页面已由chat.js初始化
break; break;
case 'info-collect':
// 信息收集页面
if (typeof initInfoCollectPage === 'function') {
initInfoCollectPage();
}
break;
case 'tasks': case 'tasks':
// 初始化任务管理页面 // 初始化任务管理页面
if (typeof initTasksPage === 'function') { if (typeof initTasksPage === 'function') {
@@ -254,16 +265,25 @@ function initPage(pageId) {
break; break;
case 'mcp-management': case 'mcp-management':
// 初始化MCP管理 // 初始化MCP管理
// 先加载外部MCP列表(快速),然后加载工具列表
if (typeof loadExternalMCPs === 'function') { if (typeof loadExternalMCPs === 'function') {
loadExternalMCPs(); loadExternalMCPs().catch(err => {
console.warn('加载外部MCP列表失败:', err);
});
} }
// 加载工具列表(MCP工具配置已移到MCP管理页面) // 加载工具列表(MCP工具配置已移到MCP管理页面)
// 使用异步加载,避免阻塞页面渲染
if (typeof loadToolsList === 'function') { if (typeof loadToolsList === 'function') {
// 确保工具分页设置已初始化 // 确保工具分页设置已初始化
if (typeof getToolsPageSize === 'function' && typeof toolsPagination !== 'undefined') { if (typeof getToolsPageSize === 'function' && typeof toolsPagination !== 'undefined') {
toolsPagination.pageSize = getToolsPageSize(); toolsPagination.pageSize = getToolsPageSize();
} }
loadToolsList(1, ''); // 延迟加载,让页面先渲染
setTimeout(() => {
loadToolsList(1, '').catch(err => {
console.error('加载工具列表失败:', err);
});
}, 100);
} }
break; break;
case 'vulnerabilities': case 'vulnerabilities':
@@ -341,7 +361,7 @@ document.addEventListener('DOMContentLoaded', function() {
const hashParts = hash.split('?'); const hashParts = hash.split('?');
const pageId = hashParts[0]; const pageId = hashParts[0];
if (pageId && ['chat', 'tasks', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) { if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) {
switchPage(pageId); switchPage(pageId);
// 如果是chat页面且带有conversation参数,加载对应对话 // 如果是chat页面且带有conversation参数,加载对应对话
+294 -88
View File
@@ -46,6 +46,9 @@ function switchSettingsSection(section) {
if (activeContent) { if (activeContent) {
activeContent.classList.add('active'); activeContent.classList.add('active');
} }
if (section === 'terminal' && typeof initTerminal === 'function') {
setTimeout(initTerminal, 0);
}
} }
// 打开设置 // 打开设置
@@ -102,6 +105,15 @@ async function loadConfig(loadTools = true) {
document.getElementById('openai-api-key').value = currentConfig.openai.api_key || ''; document.getElementById('openai-api-key').value = currentConfig.openai.api_key || '';
document.getElementById('openai-base-url').value = currentConfig.openai.base_url || ''; document.getElementById('openai-base-url').value = currentConfig.openai.base_url || '';
document.getElementById('openai-model').value = currentConfig.openai.model || ''; document.getElementById('openai-model').value = currentConfig.openai.model || '';
// 填充FOFA配置
const fofa = currentConfig.fofa || {};
const fofaEmailEl = document.getElementById('fofa-email');
const fofaKeyEl = document.getElementById('fofa-api-key');
const fofaBaseUrlEl = document.getElementById('fofa-base-url');
if (fofaEmailEl) fofaEmailEl.value = fofa.email || '';
if (fofaKeyEl) fofaKeyEl.value = fofa.api_key || '';
if (fofaBaseUrlEl) fofaBaseUrlEl.value = fofa.base_url || '';
// 填充Agent配置 // 填充Agent配置
document.getElementById('agent-max-iterations').value = currentConfig.agent.max_iterations || 30; document.getElementById('agent-max-iterations').value = currentConfig.agent.max_iterations || 30;
@@ -160,7 +172,76 @@ async function loadConfig(loadTools = true) {
// 允许0.0值,只有undefined/null时才使用默认值 // 允许0.0值,只有undefined/null时才使用默认值
retrievalWeightInput.value = (hybridWeight !== undefined && hybridWeight !== null) ? hybridWeight : 0.7; retrievalWeightInput.value = (hybridWeight !== undefined && hybridWeight !== null) ? hybridWeight : 0.7;
} }
// 索引配置
const indexing = knowledge.indexing || {};
const chunkSizeInput = document.getElementById('knowledge-indexing-chunk-size');
if (chunkSizeInput) {
chunkSizeInput.value = indexing.chunk_size || 512;
}
const chunkOverlapInput = document.getElementById('knowledge-indexing-chunk-overlap');
if (chunkOverlapInput) {
chunkOverlapInput.value = indexing.chunk_overlap ?? 50;
}
const maxChunksPerItemInput = document.getElementById('knowledge-indexing-max-chunks-per-item');
if (maxChunksPerItemInput) {
maxChunksPerItemInput.value = indexing.max_chunks_per_item ?? 0;
}
const maxRpmInput = document.getElementById('knowledge-indexing-max-rpm');
if (maxRpmInput) {
maxRpmInput.value = indexing.max_rpm ?? 0;
}
const rateLimitDelayInput = document.getElementById('knowledge-indexing-rate-limit-delay-ms');
if (rateLimitDelayInput) {
rateLimitDelayInput.value = indexing.rate_limit_delay_ms ?? 300;
}
const maxRetriesInput = document.getElementById('knowledge-indexing-max-retries');
if (maxRetriesInput) {
maxRetriesInput.value = indexing.max_retries ?? 3;
}
const retryDelayInput = document.getElementById('knowledge-indexing-retry-delay-ms');
if (retryDelayInput) {
retryDelayInput.value = indexing.retry_delay_ms ?? 1000;
}
} }
// 填充机器人配置
const robots = currentConfig.robots || {};
const wecom = robots.wecom || {};
const dingtalk = robots.dingtalk || {};
const lark = robots.lark || {};
const wecomEnabled = document.getElementById('robot-wecom-enabled');
if (wecomEnabled) wecomEnabled.checked = wecom.enabled === true;
const wecomToken = document.getElementById('robot-wecom-token');
if (wecomToken) wecomToken.value = wecom.token || '';
const wecomAes = document.getElementById('robot-wecom-encoding-aes-key');
if (wecomAes) wecomAes.value = wecom.encoding_aes_key || '';
const wecomCorp = document.getElementById('robot-wecom-corp-id');
if (wecomCorp) wecomCorp.value = wecom.corp_id || '';
const wecomSecret = document.getElementById('robot-wecom-secret');
if (wecomSecret) wecomSecret.value = wecom.secret || '';
const wecomAgentId = document.getElementById('robot-wecom-agent-id');
if (wecomAgentId) wecomAgentId.value = wecom.agent_id || '0';
const dingtalkEnabled = document.getElementById('robot-dingtalk-enabled');
if (dingtalkEnabled) dingtalkEnabled.checked = dingtalk.enabled === true;
const dingtalkClientId = document.getElementById('robot-dingtalk-client-id');
if (dingtalkClientId) dingtalkClientId.value = dingtalk.client_id || '';
const dingtalkClientSecret = document.getElementById('robot-dingtalk-client-secret');
if (dingtalkClientSecret) dingtalkClientSecret.value = dingtalk.client_secret || '';
const larkEnabled = document.getElementById('robot-lark-enabled');
if (larkEnabled) larkEnabled.checked = lark.enabled === true;
const larkAppId = document.getElementById('robot-lark-app-id');
if (larkAppId) larkAppId.value = lark.app_id || '';
const larkAppSecret = document.getElementById('robot-lark-app-secret');
if (larkAppSecret) larkAppSecret.value = lark.app_secret || '';
const larkVerify = document.getElementById('robot-lark-verify-token');
if (larkVerify) larkVerify.value = lark.verify_token || '';
// 只有在需要时才加载工具列表(MCP管理页面需要,系统设置页面不需要) // 只有在需要时才加载工具列表(MCP管理页面需要,系统设置页面不需要)
if (loadTools) { if (loadTools) {
@@ -174,7 +255,10 @@ async function loadConfig(loadTools = true) {
} }
} catch (error) { } catch (error) {
console.error('加载配置失败:', error); console.error('加载配置失败:', error);
alert('加载配置失败: ' + error.message); const baseMsg = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('settings.apply.loadFailed')
: '加载配置失败';
alert(baseMsg + ': ' + error.message);
} }
} }
@@ -183,6 +267,14 @@ let toolsSearchKeyword = '';
// 加载工具列表(分页) // 加载工具列表(分页)
async function loadToolsList(page = 1, searchKeyword = '') { async function loadToolsList(page = 1, searchKeyword = '') {
const toolsList = document.getElementById('tools-list');
// 显示加载状态
if (toolsList) {
// 清空整个容器,包括可能存在的分页控件
toolsList.innerHTML = '<div class="tools-list-items"><div class="loading" style="padding: 20px; text-align: center; color: var(--text-muted);">⏳ ' + (typeof window.t === 'function' ? window.t('mcp.loadingTools') : '正在加载工具列表...') + '</div></div>';
}
try { try {
// 在加载新页面之前,先保存当前页的状态到全局映射 // 在加载新页面之前,先保存当前页的状态到全局映射
saveCurrentPageToolStates(); saveCurrentPageToolStates();
@@ -193,7 +285,15 @@ async function loadToolsList(page = 1, searchKeyword = '') {
url += `&search=${encodeURIComponent(searchKeyword)}`; url += `&search=${encodeURIComponent(searchKeyword)}`;
} }
const response = await apiFetch(url); // 使用较短的超时时间(10秒),避免长时间等待
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await apiFetch(url, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
throw new Error('获取工具列表失败'); throw new Error('获取工具列表失败');
} }
@@ -224,9 +324,12 @@ async function loadToolsList(page = 1, searchKeyword = '') {
renderToolsPagination(); renderToolsPagination();
} catch (error) { } catch (error) {
console.error('加载工具列表失败:', error); console.error('加载工具列表失败:', error);
const toolsList = document.getElementById('tools-list');
if (toolsList) { if (toolsList) {
toolsList.innerHTML = `<div class="error">加载工具列表失败: ${escapeHtml(error.message)}</div>`; const isTimeout = error.name === 'AbortError' || error.message.includes('timeout');
const errorMsg = isTimeout
? (typeof window.t === 'function' ? window.t('mcp.loadToolsTimeout') : '加载工具列表超时,可能是外部MCP连接较慢。请点击"刷新"按钮重试,或检查外部MCP连接状态。')
: (typeof window.t === 'function' ? window.t('mcp.loadToolsFailed') : '加载工具列表失败') + ': ' + escapeHtml(error.message);
toolsList.innerHTML = `<div class="error" style="padding: 20px; text-align: center;">${errorMsg}</div>`;
} }
} }
} }
@@ -281,13 +384,25 @@ function renderToolsList() {
const toolsList = document.getElementById('tools-list'); const toolsList = document.getElementById('tools-list');
if (!toolsList) return; if (!toolsList) return;
// 只渲染列表部分,分页控件单独渲染 // 移除可能存在的分页控件(会在 renderToolsPagination 中重新添加)
const listContainer = toolsList.querySelector('.tools-list-items') || document.createElement('div'); const oldPagination = toolsList.querySelector('.tools-pagination');
listContainer.className = 'tools-list-items'; if (oldPagination) {
oldPagination.remove();
}
// 获取或创建列表容器
let listContainer = toolsList.querySelector('.tools-list-items');
if (!listContainer) {
listContainer = document.createElement('div');
listContainer.className = 'tools-list-items';
toolsList.appendChild(listContainer);
}
// 清空列表容器内容(移除加载提示)
listContainer.innerHTML = ''; listContainer.innerHTML = '';
if (allTools.length === 0) { if (allTools.length === 0) {
listContainer.innerHTML = '<div class="empty">暂无工具</div>'; listContainer.innerHTML = '<div class="empty">' + (typeof window.t === 'function' ? window.t('mcp.noTools') : '暂无工具') + '</div>';
if (!toolsList.contains(listContainer)) { if (!toolsList.contains(listContainer)) {
toolsList.appendChild(listContainer); toolsList.appendChild(listContainer);
} }
@@ -316,8 +431,8 @@ function renderToolsList() {
let externalBadge = ''; let externalBadge = '';
if (toolState.is_external || tool.is_external) { if (toolState.is_external || tool.is_external) {
const externalMcpName = toolState.external_mcp || tool.external_mcp || ''; const externalMcpName = toolState.external_mcp || tool.external_mcp || '';
const badgeText = externalMcpName ? `外部 (${escapeHtml(externalMcpName)})` : '外部'; const badgeText = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalFrom', { name: escapeHtml(externalMcpName) }) : `外部 (${escapeHtml(externalMcpName)})`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部');
const badgeTitle = externalMcpName ? `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}` : '外部MCP工具'; const badgeTitle = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalToolFrom', { name: escapeHtml(externalMcpName) }) : `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部MCP工具');
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`; externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
} }
@@ -331,7 +446,7 @@ function renderToolsList() {
${escapeHtml(tool.name)} ${escapeHtml(tool.name)}
${externalBadge} ${externalBadge}
</div> </div>
<div class="tool-item-desc">${escapeHtml(tool.description || '无描述')}</div> <div class="tool-item-desc">${escapeHtml(tool.description || (typeof window.t === 'function' ? window.t('mcp.noDescription') : '无描述'))}</div>
</div> </div>
`; `;
listContainer.appendChild(toolItem); listContainer.appendChild(toolItem);
@@ -369,12 +484,19 @@ function renderToolsPagination() {
const endItem = Math.min(page * toolsPagination.pageSize, total); const endItem = Math.min(page * toolsPagination.pageSize, total);
const savedPageSize = getToolsPageSize(); const savedPageSize = getToolsPageSize();
const t = typeof window.t === 'function' ? window.t : (k) => k;
const paginationT = (key, opts) => {
if (typeof window.t === 'function') return window.t(key, opts);
if (key === 'mcp.paginationInfo' && opts) return `显示 ${opts.start}-${opts.end} / 共 ${opts.total} 个工具`;
if (key === 'mcp.pageInfo' && opts) return `${opts.page} / ${opts.total}`;
return key;
};
pagination.innerHTML = ` pagination.innerHTML = `
<div class="pagination-info"> <div class="pagination-info">
显示 ${startItem}-${endItem} / ${total} 个工具${toolsSearchKeyword ? ` (搜索: "${escapeHtml(toolsSearchKeyword)}")` : ''} ${paginationT('mcp.paginationInfo', { start: startItem, end: endItem, total: total })}${toolsSearchKeyword ? ` (${t('common.search')}: "${escapeHtml(toolsSearchKeyword)}")` : ''}
</div> </div>
<div class="pagination-page-size"> <div class="pagination-page-size">
<label for="tools-page-size-pagination">每页:</label> <label for="tools-page-size-pagination">${t('mcp.perPage')}</label>
<select id="tools-page-size-pagination" onchange="changeToolsPageSize()"> <select id="tools-page-size-pagination" onchange="changeToolsPageSize()">
<option value="10" ${savedPageSize === 10 ? 'selected' : ''}>10</option> <option value="10" ${savedPageSize === 10 ? 'selected' : ''}>10</option>
<option value="20" ${savedPageSize === 20 ? 'selected' : ''}>20</option> <option value="20" ${savedPageSize === 20 ? 'selected' : ''}>20</option>
@@ -383,11 +505,11 @@ function renderToolsPagination() {
</select> </select>
</div> </div>
<div class="pagination-controls"> <div class="pagination-controls">
<button class="btn-secondary" onclick="loadToolsList(1, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>首页</button> <button class="btn-secondary" onclick="loadToolsList(1, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${t('mcp.firstPage')}</button>
<button class="btn-secondary" onclick="loadToolsList(${page - 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>上一页</button> <button class="btn-secondary" onclick="loadToolsList(${page - 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${t('mcp.prevPage')}</button>
<span class="pagination-page"> ${page} / ${totalPages} </span> <span class="pagination-page">${paginationT('mcp.pageInfo', { page: page, total: totalPages })}</span>
<button class="btn-secondary" onclick="loadToolsList(${page + 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>下一页</button> <button class="btn-secondary" onclick="loadToolsList(${page + 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${t('mcp.nextPage')}</button>
<button class="btn-secondary" onclick="loadToolsList(${totalPages}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>末页</button> <button class="btn-secondary" onclick="loadToolsList(${totalPages}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${t('mcp.lastPage')}</button>
</div> </div>
`; `;
@@ -581,9 +703,10 @@ async function updateToolsStats() {
totalEnabled = currentPageEnabled; totalEnabled = currentPageEnabled;
} }
const tStats = typeof window.t === 'function' ? window.t : (k) => k;
statsEl.innerHTML = ` statsEl.innerHTML = `
<span title="当前页启用的工具数"> 当前页已启用: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span> <span title="${tStats('mcp.currentPageEnabled')}"> ${tStats('mcp.currentPageEnabled')}: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
<span title="所有工具中启用的工具总数">📊 总计已启用: <strong>${totalEnabled}</strong> / ${totalTools}</span> <span title="${tStats('mcp.totalEnabled')}">📊 ${tStats('mcp.totalEnabled')}: <strong>${totalEnabled}</strong> / ${totalTools}</span>
`; `;
} }
@@ -625,7 +748,10 @@ async function applySettings() {
} }
if (hasError) { if (hasError) {
alert('请填写所有必填字段(标记为 * 的字段)'); const msg = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('settings.apply.fillRequired')
: '请填写所有必填字段(标记为 * 的字段)';
alert(msg);
return; return;
} }
@@ -653,19 +779,55 @@ async function applySettings() {
const val = parseFloat(document.getElementById('knowledge-retrieval-hybrid-weight')?.value); const val = parseFloat(document.getElementById('knowledge-retrieval-hybrid-weight')?.value);
return isNaN(val) ? 0.7 : val; // 允许0.0值,只有NaN时才使用默认值 return isNaN(val) ? 0.7 : val; // 允许0.0值,只有NaN时才使用默认值
})() })()
},
indexing: {
chunk_size: parseInt(document.getElementById("knowledge-indexing-chunk-size")?.value) || 512,
chunk_overlap: parseInt(document.getElementById("knowledge-indexing-chunk-overlap")?.value) ?? 50,
max_chunks_per_item: parseInt(document.getElementById("knowledge-indexing-max-chunks-per-item")?.value) ?? 0,
max_rpm: parseInt(document.getElementById("knowledge-indexing-max-rpm")?.value) ?? 0,
rate_limit_delay_ms: parseInt(document.getElementById("knowledge-indexing-rate-limit-delay-ms")?.value) ?? 300,
max_retries: parseInt(document.getElementById("knowledge-indexing-max-retries")?.value) ?? 3,
retry_delay_ms: parseInt(document.getElementById("knowledge-indexing-retry-delay-ms")?.value) ?? 1000
} }
}; };
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
const config = { const config = {
openai: { openai: {
api_key: apiKey, api_key: apiKey,
base_url: baseUrl, base_url: baseUrl,
model: model model: model
}, },
fofa: {
email: document.getElementById('fofa-email')?.value.trim() || '',
api_key: document.getElementById('fofa-api-key')?.value.trim() || '',
base_url: document.getElementById('fofa-base-url')?.value.trim() || ''
},
agent: { agent: {
max_iterations: parseInt(document.getElementById('agent-max-iterations').value) || 30 max_iterations: parseInt(document.getElementById('agent-max-iterations').value) || 30
}, },
knowledge: knowledgeConfig, knowledge: knowledgeConfig,
robots: {
wecom: {
enabled: document.getElementById('robot-wecom-enabled')?.checked === true,
token: document.getElementById('robot-wecom-token')?.value.trim() || '',
encoding_aes_key: document.getElementById('robot-wecom-encoding-aes-key')?.value.trim() || '',
corp_id: document.getElementById('robot-wecom-corp-id')?.value.trim() || '',
secret: document.getElementById('robot-wecom-secret')?.value.trim() || '',
agent_id: parseInt(wecomAgentIdVal, 10) || 0
},
dingtalk: {
enabled: document.getElementById('robot-dingtalk-enabled')?.checked === true,
client_id: document.getElementById('robot-dingtalk-client-id')?.value.trim() || '',
client_secret: document.getElementById('robot-dingtalk-client-secret')?.value.trim() || ''
},
lark: {
enabled: document.getElementById('robot-lark-enabled')?.checked === true,
app_id: document.getElementById('robot-lark-app-id')?.value.trim() || '',
app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '',
verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || ''
}
},
tools: [] tools: []
}; };
@@ -748,7 +910,10 @@ async function applySettings() {
if (!updateResponse.ok) { if (!updateResponse.ok) {
const error = await updateResponse.json(); const error = await updateResponse.json();
throw new Error(error.error || '更新配置失败'); const fallback = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('settings.apply.applyFailed')
: '应用配置失败';
throw new Error(error.error || fallback);
} }
// 应用配置 // 应用配置
@@ -758,14 +923,23 @@ async function applySettings() {
if (!applyResponse.ok) { if (!applyResponse.ok) {
const error = await applyResponse.json(); const error = await applyResponse.json();
throw new Error(error.error || '应用配置失败'); const fallback = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('settings.apply.applyFailed')
: '应用配置失败';
throw new Error(error.error || fallback);
} }
alert('配置已成功应用!'); const successMsg = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('settings.apply.applySuccess')
: '配置已成功应用!';
alert(successMsg);
closeSettings(); closeSettings();
} catch (error) { } catch (error) {
console.error('应用配置失败:', error); console.error('应用配置失败:', error);
alert('应用配置失败: ' + error.message); const baseMsg = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('settings.apply.applyFailed')
: '应用配置失败';
alert(baseMsg + ': ' + error.message);
} }
} }
@@ -876,7 +1050,7 @@ async function saveToolsConfig() {
throw new Error(error.error || '应用配置失败'); throw new Error(error.error || '应用配置失败');
} }
alert('工具配置已成功保存!'); alert(typeof window.t === 'function' ? window.t('mcp.toolsConfigSaved') : '工具配置已成功保存!');
// 重新加载工具列表以反映最新状态 // 重新加载工具列表以反映最新状态
if (typeof loadToolsList === 'function') { if (typeof loadToolsList === 'function') {
@@ -884,7 +1058,7 @@ async function saveToolsConfig() {
} }
} catch (error) { } catch (error) {
console.error('保存工具配置失败:', error); console.error('保存工具配置失败:', error);
alert('保存工具配置失败: ' + error.message); alert((typeof window.t === 'function' ? window.t('mcp.saveToolsConfigFailed') : '保存工具配置失败') + ': ' + error.message);
} }
} }
@@ -931,7 +1105,7 @@ async function changePassword() {
} }
if (hasError) { if (hasError) {
alert('请正确填写当前密码和新密码,新密码至少 8 位且需要两次输入一致。'); alert(typeof window.t === 'function' ? window.t('settings.security.fillPasswordHint') : '请正确填写当前密码和新密码,新密码至少 8 位且需要两次输入一致。');
return; return;
} }
@@ -956,13 +1130,14 @@ async function changePassword() {
throw new Error(result.error || '修改密码失败'); throw new Error(result.error || '修改密码失败');
} }
alert('密码已更新,请使用新密码重新登录。'); const pwdMsg = typeof window.t === 'function' ? window.t('settings.security.passwordUpdated') : '密码已更新,请使用新密码重新登录。';
alert(pwdMsg);
resetPasswordForm(); resetPasswordForm();
handleUnauthorized({ message: '密码已更新,请使用新密码重新登录。', silent: false }); handleUnauthorized({ message: pwdMsg, silent: false });
closeSettings(); closeSettings();
} catch (error) { } catch (error) {
console.error('修改密码失败:', error); console.error('修改密码失败:', error);
alert('修改密码失败: ' + error.message); alert((typeof window.t === 'function' ? window.t('settings.security.changePasswordFailed') : '修改密码失败') + ': ' + error.message);
} finally { } finally {
if (submitBtn) { if (submitBtn) {
submitBtn.disabled = false; submitBtn.disabled = false;
@@ -974,15 +1149,17 @@ async function changePassword() {
let currentEditingMCPName = null; let currentEditingMCPName = null;
// 加载外部MCP列表 // 拉取外部MCP列表数据(供轮询使用,返回 { servers, stats }
async function fetchExternalMCPs() {
const response = await apiFetch('/api/external-mcp');
if (!response.ok) throw new Error('获取外部MCP列表失败');
return response.json();
}
// 加载外部MCP列表并渲染
async function loadExternalMCPs() { async function loadExternalMCPs() {
try { try {
const response = await apiFetch('/api/external-mcp'); const data = await fetchExternalMCPs();
if (!response.ok) {
throw new Error('获取外部MCP列表失败');
}
const data = await response.json();
renderExternalMCPList(data.servers || {}); renderExternalMCPList(data.servers || {});
renderExternalMCPStats(data.stats || {}); renderExternalMCPStats(data.stats || {});
} catch (error) { } catch (error) {
@@ -994,13 +1171,37 @@ async function loadExternalMCPs() {
} }
} }
// 轮询列表直到指定 MCP 的工具数量已更新(每秒拉一次,拿到即停,无固定延迟)
// name 为 null 时仅按 maxAttempts 次数轮询,不判断 tool_count
async function pollExternalMCPToolCount(name, maxAttempts = 10) {
const pollIntervalMs = 1000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await new Promise(r => setTimeout(r, pollIntervalMs));
try {
const data = await fetchExternalMCPs();
renderExternalMCPList(data.servers || {});
renderExternalMCPStats(data.stats || {});
if (name != null) {
const server = data.servers && data.servers[name];
if (server && server.tool_count > 0) break;
}
} catch (e) {
console.warn('轮询工具数量失败:', e);
}
}
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
window.refreshMentionTools();
}
}
// 渲染外部MCP列表 // 渲染外部MCP列表
function renderExternalMCPList(servers) { function renderExternalMCPList(servers) {
const list = document.getElementById('external-mcp-list'); const list = document.getElementById('external-mcp-list');
if (!list) return; if (!list) return;
if (Object.keys(servers).length === 0) { if (Object.keys(servers).length === 0) {
list.innerHTML = '<div class="empty">📋 暂无外部MCP配置<br><span style="font-size: 0.875rem; margin-top: 8px; display: block;">点击"添加外部MCP"按钮开始配置</span></div>'; const emptyT = typeof window.t === 'function' ? window.t : (k) => k;
list.innerHTML = '<div class="empty">📋 ' + emptyT('mcp.noExternalMCP') + '<br><span style="font-size: 0.875rem; margin-top: 8px; display: block;">' + emptyT('mcp.clickToAddExternal') + '</span></div>';
return; return;
} }
@@ -1011,10 +1212,11 @@ function renderExternalMCPList(servers) {
status === 'connecting' ? 'status-connecting' : status === 'connecting' ? 'status-connecting' :
status === 'error' ? 'status-error' : status === 'error' ? 'status-error' :
status === 'disabled' ? 'status-disabled' : 'status-disconnected'; status === 'disabled' ? 'status-disabled' : 'status-disconnected';
const statusText = status === 'connected' ? '已连接' : const statusT = typeof window.t === 'function' ? window.t : (k) => k;
status === 'connecting' ? '连接中...' : const statusText = status === 'connected' ? statusT('mcp.connected') :
status === 'error' ? '连接失败' : status === 'connecting' ? statusT('mcp.connecting') :
status === 'disabled' ? '已禁用' : '未连接'; status === 'error' ? statusT('mcp.connectionFailed') :
status === 'disabled' ? statusT('mcp.disabled') : statusT('mcp.disconnected');
const transport = server.config.transport || (server.config.command ? 'stdio' : 'http'); const transport = server.config.transport || (server.config.command ? 'stdio' : 'http');
const transportIcon = transport === 'stdio' ? '⚙️' : '🌐'; const transportIcon = transport === 'stdio' ? '⚙️' : '🌐';
@@ -1027,15 +1229,15 @@ function renderExternalMCPList(servers) {
</div> </div>
<div class="external-mcp-item-actions"> <div class="external-mcp-item-actions">
${status === 'connected' || status === 'disconnected' || status === 'error' ? ${status === 'connected' || status === 'disconnected' || status === 'error' ?
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" onclick="toggleExternalMCP('${escapeHtml(name)}', '${status}')" title="${status === 'connected' ? '停止连接' : '启动连接'}"> `<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" onclick="toggleExternalMCP('${escapeHtml(name)}', '${status}')" title="${status === 'connected' ? statusT('mcp.stopConnection') : statusT('mcp.startConnection')}">
${status === 'connected' ? '⏸ 停止' : '▶ 启动'} ${status === 'connected' ? '⏸ ' + statusT('mcp.stop') : '▶ ' + statusT('mcp.start')}
</button>` : </button>` :
status === 'connecting' ? status === 'connecting' ?
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" disabled style="opacity: 0.6; cursor: not-allowed;"> `<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" disabled style="opacity: 0.6; cursor: not-allowed;">
连接中... 连接中...
</button>` : ''} </button>` : ''}
<button class="btn-small" onclick="editExternalMCP('${escapeHtml(name)}')" title="编辑配置" ${status === 'connecting' ? 'disabled' : ''}> 编辑</button> <button class="btn-small" onclick="editExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.editConfig')}" ${status === 'connecting' ? 'disabled' : ''}> ${statusT('common.edit')}</button>
<button class="btn-small btn-danger" onclick="deleteExternalMCP('${escapeHtml(name)}')" title="删除配置" ${status === 'connecting' ? 'disabled' : ''}>🗑 删除</button> <button class="btn-small btn-danger" onclick="deleteExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.deleteConfig')}" ${status === 'connecting' ? 'disabled' : ''}>🗑 ${statusT('common.delete')}</button>
</div> </div>
</div> </div>
${status === 'error' && server.error ? ` ${status === 'error' && server.error ? `
@@ -1044,31 +1246,31 @@ function renderExternalMCPList(servers) {
</div>` : ''} </div>` : ''}
<div class="external-mcp-item-details"> <div class="external-mcp-item-details">
<div> <div>
<strong>传输模式</strong> <strong>${statusT('mcp.transportMode')}</strong>
<span>${transportIcon} ${escapeHtml(transport.toUpperCase())}</span> <span>${transportIcon} ${escapeHtml(transport.toUpperCase())}</span>
</div> </div>
${server.tool_count !== undefined && server.tool_count > 0 ? ` ${server.tool_count !== undefined && server.tool_count > 0 ? `
<div> <div>
<strong>工具数量</strong> <strong>${statusT('mcp.toolCount')}</strong>
<span style="font-weight: 600; color: var(--accent-color);">🔧 ${server.tool_count} 个工具</span> <span style="font-weight: 600; color: var(--accent-color);">🔧 ${server.tool_count} 个工具</span>
</div>` : server.tool_count === 0 && status === 'connected' ? ` </div>` : server.tool_count === 0 && status === 'connected' ? `
<div> <div>
<strong>工具数量</strong> <strong>${statusT('mcp.toolCount')}</strong>
<span style="color: var(--text-muted);">暂无工具</span> <span style="color: var(--text-muted);">${statusT('mcp.noTools')}</span>
</div>` : ''} </div>` : ''}
${server.config.description ? ` ${server.config.description ? `
<div> <div>
<strong>描述</strong> <strong>${statusT('mcp.description')}</strong>
<span>${escapeHtml(server.config.description)}</span> <span>${escapeHtml(server.config.description)}</span>
</div>` : ''} </div>` : ''}
${server.config.timeout ? ` ${server.config.timeout ? `
<div> <div>
<strong>超时时间</strong> <strong>${statusT('mcp.timeout')}</strong>
<span>${server.config.timeout} </span> <span>${server.config.timeout} </span>
</div>` : ''} </div>` : ''}
${transport === 'stdio' && server.config.command ? ` ${transport === 'stdio' && server.config.command ? `
<div> <div>
<strong>命令</strong> <strong>${statusT('mcp.command')}</strong>
<span style="font-family: monospace; font-size: 0.8125rem;">${escapeHtml(server.config.command)}</span> <span style="font-family: monospace; font-size: 0.8125rem;">${escapeHtml(server.config.command)}</span>
</div>` : ''} </div>` : ''}
${transport === 'http' && server.config.url ? ` ${transport === 'http' && server.config.url ? `
@@ -1094,18 +1296,19 @@ function renderExternalMCPStats(stats) {
const disabled = stats.disabled || 0; const disabled = stats.disabled || 0;
const connected = stats.connected || 0; const connected = stats.connected || 0;
const statsT = typeof window.t === 'function' ? window.t : (k) => k;
statsEl.innerHTML = ` statsEl.innerHTML = `
<span title="总配置数">📊 总数: <strong>${total}</strong></span> <span title="${statsT('mcp.totalCount')}">📊 ${statsT('mcp.totalCount')}: <strong>${total}</strong></span>
<span title="已启用的配置数"> 已启用: <strong>${enabled}</strong></span> <span title="${statsT('mcp.enabledCount')}"> ${statsT('mcp.enabledCount')}: <strong>${enabled}</strong></span>
<span title="已停用的配置数"> 已停用: <strong>${disabled}</strong></span> <span title="${statsT('mcp.disabledCount')}"> ${statsT('mcp.disabledCount')}: <strong>${disabled}</strong></span>
<span title="当前已连接的配置数">🔗 已连接: <strong>${connected}</strong></span> <span title="${statsT('mcp.connectedCount')}">🔗 ${statsT('mcp.connectedCount')}: <strong>${connected}</strong></span>
`; `;
} }
// 显示添加外部MCP模态框 // 显示添加外部MCP模态框
function showAddExternalMCPModal() { function showAddExternalMCPModal() {
currentEditingMCPName = null; currentEditingMCPName = null;
document.getElementById('external-mcp-modal-title').textContent = '添加外部MCP'; document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.addExternalMCP') : '添加外部MCP');
document.getElementById('external-mcp-json').value = ''; document.getElementById('external-mcp-json').value = '';
document.getElementById('external-mcp-json-error').style.display = 'none'; document.getElementById('external-mcp-json-error').style.display = 'none';
document.getElementById('external-mcp-json-error').textContent = ''; document.getElementById('external-mcp-json-error').textContent = '';
@@ -1130,7 +1333,7 @@ async function editExternalMCP(name) {
const server = await response.json(); const server = await response.json();
currentEditingMCPName = name; currentEditingMCPName = name;
document.getElementById('external-mcp-modal-title').textContent = '编辑外部MCP'; document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.editExternalMCP') : '编辑外部MCP');
// 将配置转换为对象格式(key为名称) // 将配置转换为对象格式(key为名称)
const config = { ...server.config }; const config = { ...server.config };
@@ -1152,7 +1355,7 @@ async function editExternalMCP(name) {
document.getElementById('external-mcp-modal').style.display = 'block'; document.getElementById('external-mcp-modal').style.display = 'block';
} catch (error) { } catch (error) {
console.error('编辑外部MCP失败:', error); console.error('编辑外部MCP失败:', error);
alert('编辑失败: ' + error.message); alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '编辑失败') + ': ' + error.message);
} }
} }
@@ -1164,7 +1367,7 @@ function formatExternalMCPJSON() {
try { try {
const jsonStr = jsonTextarea.value.trim(); const jsonStr = jsonTextarea.value.trim();
if (!jsonStr) { if (!jsonStr) {
errorDiv.textContent = 'JSON不能为空'; errorDiv.textContent = (typeof window.t === 'function' ? window.t('mcp.jsonEmpty') : 'JSON不能为空');
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
return; return;
@@ -1176,7 +1379,7 @@ function formatExternalMCPJSON() {
errorDiv.style.display = 'none'; errorDiv.style.display = 'none';
jsonTextarea.classList.remove('error'); jsonTextarea.classList.remove('error');
} catch (error) { } catch (error) {
errorDiv.textContent = 'JSON格式错误: ' + error.message; errorDiv.textContent = (typeof window.t === 'function' ? window.t('mcp.jsonError') : 'JSON格式错误') + ': ' + error.message;
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
} }
@@ -1184,6 +1387,7 @@ function formatExternalMCPJSON() {
// 加载示例 // 加载示例
function loadExternalMCPExample() { function loadExternalMCPExample() {
const desc = (typeof window.t === 'function' ? window.t('externalMcpModal.exampleDescription') : '示例描述');
const example = { const example = {
"hexstrike-ai": { "hexstrike-ai": {
command: "python3", command: "python3",
@@ -1192,7 +1396,7 @@ function loadExternalMCPExample() {
"--server", "--server",
"http://example.com" "http://example.com"
], ],
description: "示例描述", description: desc,
timeout: 300 timeout: 300
}, },
"cyberstrike-ai-http": { "cyberstrike-ai-http": {
@@ -1217,7 +1421,7 @@ async function saveExternalMCP() {
const errorDiv = document.getElementById('external-mcp-json-error'); const errorDiv = document.getElementById('external-mcp-json-error');
if (!jsonStr) { if (!jsonStr) {
errorDiv.textContent = 'JSON配置不能为空'; errorDiv.textContent = (typeof window.t === 'function' ? window.t('mcp.jsonEmpty') : 'JSON不能为空');
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
jsonTextarea.focus(); jsonTextarea.focus();
@@ -1228,16 +1432,17 @@ async function saveExternalMCP() {
try { try {
configObj = JSON.parse(jsonStr); configObj = JSON.parse(jsonStr);
} catch (error) { } catch (error) {
errorDiv.textContent = 'JSON格式错误: ' + error.message; errorDiv.textContent = (typeof window.t === 'function' ? window.t('mcp.jsonError') : 'JSON格式错误') + ': ' + error.message;
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
jsonTextarea.focus(); jsonTextarea.focus();
return; return;
} }
const t = (typeof window.t === 'function' ? window.t : function (k, opts) { return k; });
// 验证必须是对象格式 // 验证必须是对象格式
if (typeof configObj !== 'object' || Array.isArray(configObj) || configObj === null) { if (typeof configObj !== 'object' || Array.isArray(configObj) || configObj === null) {
errorDiv.textContent = '配置错误: 必须是JSON对象格式,key为配置名称,value为配置内容'; errorDiv.textContent = t('mcp.configMustBeObject');
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
return; return;
@@ -1246,7 +1451,7 @@ async function saveExternalMCP() {
// 获取所有配置名称 // 获取所有配置名称
const names = Object.keys(configObj); const names = Object.keys(configObj);
if (names.length === 0) { if (names.length === 0) {
errorDiv.textContent = '配置错误: 至少需要一个配置项'; errorDiv.textContent = t('mcp.configNeedOne');
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
return; return;
@@ -1255,7 +1460,7 @@ async function saveExternalMCP() {
// 验证每个配置 // 验证每个配置
for (const name of names) { for (const name of names) {
if (!name || name.trim() === '') { if (!name || name.trim() === '') {
errorDiv.textContent = '配置错误: 配置名称不能为空'; errorDiv.textContent = t('mcp.configNameEmpty');
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
return; return;
@@ -1263,7 +1468,7 @@ async function saveExternalMCP() {
const config = configObj[name]; const config = configObj[name];
if (typeof config !== 'object' || Array.isArray(config) || config === null) { if (typeof config !== 'object' || Array.isArray(config) || config === null) {
errorDiv.textContent = `配置错误: "${name}" 的配置必须是对象`; errorDiv.textContent = t('mcp.configMustBeObj', { name: name });
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
return; return;
@@ -1275,28 +1480,28 @@ async function saveExternalMCP() {
// 验证配置内容 // 验证配置内容
const transport = config.transport || (config.command ? 'stdio' : config.url ? 'http' : ''); const transport = config.transport || (config.command ? 'stdio' : config.url ? 'http' : '');
if (!transport) { if (!transport) {
errorDiv.textContent = `配置错误: "${name}" 需要指定commandstdio模式)或urlhttp/sse模式)`; errorDiv.textContent = t('mcp.configNeedCommand', { name: name });
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
return; return;
} }
if (transport === 'stdio' && !config.command) { if (transport === 'stdio' && !config.command) {
errorDiv.textContent = `配置错误: "${name}" stdio模式需要command字段`; errorDiv.textContent = t('mcp.configStdioNeedCommand', { name: name });
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
return; return;
} }
if (transport === 'http' && !config.url) { if (transport === 'http' && !config.url) {
errorDiv.textContent = `配置错误: "${name}" http模式需要url字段`; errorDiv.textContent = t('mcp.configHttpNeedUrl', { name: name });
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
return; return;
} }
if (transport === 'sse' && !config.url) { if (transport === 'sse' && !config.url) {
errorDiv.textContent = `配置错误: "${name}" sse模式需要url字段`; errorDiv.textContent = t('mcp.configSseNeedUrl', { name: name });
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
return; return;
@@ -1311,7 +1516,7 @@ async function saveExternalMCP() {
// 如果是编辑模式,只更新当前编辑的配置 // 如果是编辑模式,只更新当前编辑的配置
if (currentEditingMCPName) { if (currentEditingMCPName) {
if (!configObj[currentEditingMCPName]) { if (!configObj[currentEditingMCPName]) {
errorDiv.textContent = `配置错误: 编辑模式下,JSON必须包含配置名称 "${currentEditingMCPName}"`; errorDiv.textContent = (typeof window.t === 'function' ? window.t('mcp.configEditMustContainName', { name: currentEditingMCPName }) : '配置错误: 编辑模式下,JSON必须包含配置名称 "' + currentEditingMCPName + '"');
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
return; return;
@@ -1350,14 +1555,15 @@ async function saveExternalMCP() {
closeExternalMCPModal(); closeExternalMCPModal();
await loadExternalMCPs(); await loadExternalMCPs();
// 刷新对话界面的工具列表,使新添加的MCP工具立即可用
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') { if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
window.refreshMentionTools(); window.refreshMentionTools();
} }
alert('保存成功'); // 轮询几次以拉取后端异步更新的工具数量(无固定延迟,拿到即停)
pollExternalMCPToolCount(null, 5);
alert(typeof window.t === 'function' ? window.t('mcp.saveSuccess') : '保存成功');
} catch (error) { } catch (error) {
console.error('保存外部MCP失败:', error); console.error('保存外部MCP失败:', error);
errorDiv.textContent = '保存失败: ' + error.message; errorDiv.textContent = (typeof window.t === 'function' ? window.t('mcp.operationFailed') : '保存失败') + ': ' + error.message;
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
jsonTextarea.classList.add('error'); jsonTextarea.classList.add('error');
} }
@@ -1365,7 +1571,7 @@ async function saveExternalMCP() {
// 删除外部MCP // 删除外部MCP
async function deleteExternalMCP(name) { async function deleteExternalMCP(name) {
if (!confirm(`确定要删除外部MCP "${name}" 吗?`)) { if (!confirm((typeof window.t === 'function' ? window.t('mcp.deleteExternalConfirm', { name: name }) : `确定要删除外部MCP "${name}" 吗?`))) {
return; return;
} }
@@ -1384,10 +1590,10 @@ async function deleteExternalMCP(name) {
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') { if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
window.refreshMentionTools(); window.refreshMentionTools();
} }
alert('删除成功'); alert(typeof window.t === 'function' ? window.t('mcp.deleteSuccess') : '删除成功');
} catch (error) { } catch (error) {
console.error('删除外部MCP失败:', error); console.error('删除外部MCP失败:', error);
alert('删除失败: ' + error.message); alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '删除失败') + ': ' + error.message);
} }
} }
@@ -1427,12 +1633,12 @@ async function toggleExternalMCP(name, currentStatus) {
const status = statusData.status || 'disconnected'; const status = statusData.status || 'disconnected';
if (status === 'connected') { if (status === 'connected') {
// 已经连接,立即刷新
await loadExternalMCPs(); await loadExternalMCPs();
// 刷新对话界面的工具列表
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') { if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
window.refreshMentionTools(); window.refreshMentionTools();
} }
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
pollExternalMCPToolCount(name, 10);
return; return;
} }
} }
@@ -1452,7 +1658,7 @@ async function toggleExternalMCP(name, currentStatus) {
} }
} catch (error) { } catch (error) {
console.error('切换外部MCP状态失败:', error); console.error('切换外部MCP状态失败:', error);
alert('操作失败: ' + error.message); alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '操作失败') + ': ' + error.message);
// 恢复按钮状态 // 恢复按钮状态
if (button) { if (button) {
@@ -1490,12 +1696,12 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
const button = document.getElementById(buttonId); const button = document.getElementById(buttonId);
if (status === 'connected') { if (status === 'connected') {
// 连接成功,刷新列表
await loadExternalMCPs(); await loadExternalMCPs();
// 刷新对话界面的工具列表
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') { if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
window.refreshMentionTools(); window.refreshMentionTools();
} }
// 轮询直到该 MCP 工具数量已更新(每秒拉一次,无固定延迟)
pollExternalMCPToolCount(name, 10);
return; return;
} else if (status === 'error' || status === 'disconnected') { } else if (status === 'error' || status === 'disconnected') {
// 连接失败,刷新列表并显示错误 // 连接失败,刷新列表并显示错误
@@ -1505,7 +1711,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
window.refreshMentionTools(); window.refreshMentionTools();
} }
if (status === 'error') { if (status === 'error') {
alert('连接失败,请检查配置和网络连接'); alert(typeof window.t === 'function' ? window.t('mcp.connectionFailedCheck') : '连接失败,请检查配置和网络连接');
} }
return; return;
} else if (status === 'connecting') { } else if (status === 'connecting') {
@@ -1527,7 +1733,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') { if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
window.refreshMentionTools(); window.refreshMentionTools();
} }
alert('连接超时,请检查配置和网络连接'); alert(typeof window.t === 'function' ? window.t('mcp.connectionTimeout') : '连接超时,请检查配置和网络连接');
} }
// 在打开设置时加载外部MCP列表 // 在打开设置时加载外部MCP列表
+96 -67
View File
@@ -1,4 +1,7 @@
// Skills管理相关功能 // Skills管理相关功能
function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
}
let skillsList = []; let skillsList = [];
let currentEditingSkillName = null; let currentEditingSkillName = null;
let isSavingSkill = false; // 防止重复提交 let isSavingSkill = false; // 防止重复提交
@@ -24,7 +27,7 @@ function getSkillsPageSize() {
const saved = localStorage.getItem('skillsPageSize'); const saved = localStorage.getItem('skillsPageSize');
if (saved) { if (saved) {
const size = parseInt(saved); const size = parseInt(saved);
if ([20, 50, 100].includes(size)) { if ([10, 20, 50, 100].includes(size)) {
return size; return size;
} }
} }
@@ -65,7 +68,7 @@ async function loadSkills(page = 1, pageSize = null) {
const response = await apiFetch(url); const response = await apiFetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error('获取skills列表失败'); throw new Error(_t('skills.loadListFailed'));
} }
const data = await response.json(); const data = await response.json();
skillsList = data.skills || []; skillsList = data.skills || [];
@@ -76,10 +79,10 @@ async function loadSkills(page = 1, pageSize = null) {
updateSkillsManagementStats(); updateSkillsManagementStats();
} catch (error) { } catch (error) {
console.error('加载skills列表失败:', error); console.error('加载skills列表失败:', error);
showNotification('加载skills列表失败: ' + error.message, 'error'); showNotification(_t('skills.loadListFailed') + ': ' + error.message, 'error');
const skillsListEl = document.getElementById('skills-list'); const skillsListEl = document.getElementById('skills-list');
if (skillsListEl) { if (skillsListEl) {
skillsListEl.innerHTML = '<div class="empty-state">加载失败: ' + error.message + '</div>'; skillsListEl.innerHTML = '<div class="empty-state">' + _t('skills.loadFailedShort') + ': ' + escapeHtml(error.message) + '</div>';
} }
} }
} }
@@ -94,7 +97,7 @@ function renderSkillsList() {
if (filteredSkills.length === 0) { if (filteredSkills.length === 0) {
skillsListEl.innerHTML = '<div class="empty-state">' + skillsListEl.innerHTML = '<div class="empty-state">' +
(skillsSearchKeyword ? '没有找到匹配的skills' : '暂无skills,点击"创建Skill"创建第一个skill') + (skillsSearchKeyword ? _t('skills.noMatch') : _t('skills.noSkills')) +
'</div>'; '</div>';
// 搜索时隐藏分页 // 搜索时隐藏分页
const paginationContainer = document.getElementById('skills-pagination'); const paginationContainer = document.getElementById('skills-pagination');
@@ -109,12 +112,12 @@ function renderSkillsList() {
<div class="skill-card"> <div class="skill-card">
<div class="skill-card-header"> <div class="skill-card-header">
<h3 class="skill-card-title">${escapeHtml(skill.name || '')}</h3> <h3 class="skill-card-title">${escapeHtml(skill.name || '')}</h3>
<div class="skill-card-description">${escapeHtml(skill.description || _t('skills.noDescription'))}</div>
</div> </div>
<div class="skill-card-description">${escapeHtml(skill.description || '无描述')}</div>
<div class="skill-card-actions"> <div class="skill-card-actions">
<button class="btn-secondary btn-small" onclick="viewSkill('${escapeHtml(skill.name)}')">查看</button> <button class="btn-secondary btn-small" onclick="viewSkill('${escapeHtml(skill.name)}')">${_t('common.view')}</button>
<button class="btn-secondary btn-small" onclick="editSkill('${escapeHtml(skill.name)}')">编辑</button> <button class="btn-secondary btn-small" onclick="editSkill('${escapeHtml(skill.name)}')">${_t('common.edit')}</button>
<button class="btn-secondary btn-small btn-danger" onclick="deleteSkill('${escapeHtml(skill.name)}')">删除</button> <button class="btn-secondary btn-small btn-danger" onclick="deleteSkill('${escapeHtml(skill.name)}')">${_t('common.delete')}</button>
</div> </div>
</div> </div>
`; `;
@@ -154,13 +157,21 @@ function renderSkillsPagination() {
let paginationHTML = '<div class="pagination">'; let paginationHTML = '<div class="pagination">';
const paginationShowText = _t('skillsPage.paginationShow', { start, end, total });
const perPageLabelText = _t('skillsPage.perPageLabel');
const firstPageText = _t('skillsPage.firstPage');
const prevPageText = _t('skillsPage.prevPage');
const pageOfText = _t('skillsPage.pageOf', { current: currentPage, total: totalPages || 1 });
const nextPageText = _t('skillsPage.nextPage');
const lastPageText = _t('skillsPage.lastPage');
// 左侧:显示范围信息和每页数量选择器(参考MCP样式) // 左侧:显示范围信息和每页数量选择器(参考MCP样式)
paginationHTML += ` paginationHTML += `
<div class="pagination-info"> <div class="pagination-info">
<span>显示 ${start}-${end} / ${total} </span> <span>${escapeHtml(paginationShowText)}</span>
<label class="pagination-page-size"> <label class="pagination-page-size">
每页显示 ${escapeHtml(perPageLabelText)}
<select id="skills-page-size-pagination" onchange="changeSkillsPageSize()"> <select id="skills-page-size-pagination" onchange="changeSkillsPageSize()">
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option> <option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option> <option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option> <option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
@@ -172,11 +183,11 @@ function renderSkillsPagination() {
// 右侧:分页按钮(参考MCP样式:首页、上一页、第X/Y页、下一页、末页) // 右侧:分页按钮(参考MCP样式:首页、上一页、第X/Y页、下一页、末页)
paginationHTML += ` paginationHTML += `
<div class="pagination-controls"> <div class="pagination-controls">
<button class="btn-secondary" onclick="loadSkills(1, ${pageSize})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>首页</button> <button class="btn-secondary" onclick="loadSkills(1, ${pageSize})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(firstPageText)}</button>
<button class="btn-secondary" onclick="loadSkills(${currentPage - 1}, ${pageSize})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>上一页</button> <button class="btn-secondary" onclick="loadSkills(${currentPage - 1}, ${pageSize})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(prevPageText)}</button>
<span class="pagination-page"> ${currentPage} / ${totalPages || 1} </span> <span class="pagination-page">${escapeHtml(pageOfText)}</span>
<button class="btn-secondary" onclick="loadSkills(${currentPage + 1}, ${pageSize})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>下一页</button> <button class="btn-secondary" onclick="loadSkills(${currentPage + 1}, ${pageSize})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(nextPageText)}</button>
<button class="btn-secondary" onclick="loadSkills(${totalPages || 1}, ${pageSize})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>末页</button> <button class="btn-secondary" onclick="loadSkills(${totalPages || 1}, ${pageSize})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(lastPageText)}</button>
</div> </div>
`; `;
@@ -290,7 +301,7 @@ async function searchSkills() {
try { try {
const response = await apiFetch(`/api/skills?search=${encodeURIComponent(skillsSearchKeyword)}&limit=10000&offset=0`); const response = await apiFetch(`/api/skills?search=${encodeURIComponent(skillsSearchKeyword)}&limit=10000&offset=0`);
if (!response.ok) { if (!response.ok) {
throw new Error('获取skills列表失败'); throw new Error(_t('skills.loadListFailed'));
} }
const data = await response.json(); const data = await response.json();
skillsList = data.skills || []; skillsList = data.skills || [];
@@ -305,7 +316,7 @@ async function searchSkills() {
updateSkillsManagementStats(); updateSkillsManagementStats();
} catch (error) { } catch (error) {
console.error('搜索skills失败:', error); console.error('搜索skills失败:', error);
showNotification('搜索失败: ' + error.message, 'error'); showNotification(_t('skills.searchFailed') + ': ' + error.message, 'error');
} }
} else { } else {
// 没有搜索关键词时,恢复分页加载 // 没有搜索关键词时,恢复分页加载
@@ -331,7 +342,7 @@ function clearSkillsSearch() {
// 刷新skills // 刷新skills
async function refreshSkills() { async function refreshSkills() {
await loadSkills(skillsPagination.currentPage, skillsPagination.pageSize); await loadSkills(skillsPagination.currentPage, skillsPagination.pageSize);
showNotification('已刷新', 'success'); showNotification(_t('skills.refreshed'), 'success');
} }
// 显示添加skill模态框 // 显示添加skill模态框
@@ -339,7 +350,7 @@ function showAddSkillModal() {
const modal = document.getElementById('skill-modal'); const modal = document.getElementById('skill-modal');
if (!modal) return; if (!modal) return;
document.getElementById('skill-modal-title').textContent = '添加Skill'; document.getElementById('skill-modal-title').textContent = _t('skills.addSkill');
document.getElementById('skill-name').value = ''; document.getElementById('skill-name').value = '';
document.getElementById('skill-name').disabled = false; document.getElementById('skill-name').disabled = false;
document.getElementById('skill-description').value = ''; document.getElementById('skill-description').value = '';
@@ -353,7 +364,7 @@ async function editSkill(skillName) {
try { try {
const response = await apiFetch(`/api/skills/${encodeURIComponent(skillName)}`); const response = await apiFetch(`/api/skills/${encodeURIComponent(skillName)}`);
if (!response.ok) { if (!response.ok) {
throw new Error('获取skill详情失败'); throw new Error(_t('skills.loadDetailFailed'));
} }
const data = await response.json(); const data = await response.json();
const skill = data.skill; const skill = data.skill;
@@ -361,7 +372,7 @@ async function editSkill(skillName) {
const modal = document.getElementById('skill-modal'); const modal = document.getElementById('skill-modal');
if (!modal) return; if (!modal) return;
document.getElementById('skill-modal-title').textContent = '编辑Skill'; document.getElementById('skill-modal-title').textContent = _t('skills.editSkill');
document.getElementById('skill-name').value = skill.name; document.getElementById('skill-name').value = skill.name;
document.getElementById('skill-name').disabled = true; // 编辑时不允许修改名称 document.getElementById('skill-name').disabled = true; // 编辑时不允许修改名称
document.getElementById('skill-description').value = skill.description || ''; document.getElementById('skill-description').value = skill.description || '';
@@ -371,7 +382,7 @@ async function editSkill(skillName) {
modal.style.display = 'flex'; modal.style.display = 'flex';
} catch (error) { } catch (error) {
console.error('加载skill详情失败:', error); console.error('加载skill详情失败:', error);
showNotification('加载skill详情失败: ' + error.message, 'error'); showNotification(_t('skills.loadDetailFailed') + ': ' + error.message, 'error');
} }
} }
@@ -380,7 +391,7 @@ async function viewSkill(skillName) {
try { try {
const response = await apiFetch(`/api/skills/${encodeURIComponent(skillName)}`); const response = await apiFetch(`/api/skills/${encodeURIComponent(skillName)}`);
if (!response.ok) { if (!response.ok) {
throw new Error('获取skill详情失败'); throw new Error(_t('skills.loadDetailFailed'));
} }
const data = await response.json(); const data = await response.json();
const skill = data.skill; const skill = data.skill;
@@ -389,22 +400,29 @@ async function viewSkill(skillName) {
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'modal'; modal.className = 'modal';
modal.id = 'skill-view-modal'; modal.id = 'skill-view-modal';
const viewTitle = _t('skills.viewSkillTitle', { name: skill.name });
const descLabel = _t('skills.descriptionLabel');
const pathLabel = _t('skills.pathLabel');
const modTimeLabel = _t('skills.modTimeLabel');
const contentLabel = _t('skills.contentLabel');
const closeBtn = _t('common.close');
const editBtn = _t('common.edit');
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content" style="max-width: 900px; max-height: 90vh;"> <div class="modal-content" style="max-width: 900px; max-height: 90vh;">
<div class="modal-header"> <div class="modal-header">
<h2>查看Skill: ${escapeHtml(skill.name)}</h2> <h2>${escapeHtml(viewTitle)}</h2>
<span class="modal-close" onclick="closeSkillViewModal()">&times;</span> <span class="modal-close" onclick="closeSkillViewModal()">&times;</span>
</div> </div>
<div class="modal-body" style="overflow-y: auto; max-height: calc(90vh - 120px);"> <div class="modal-body" style="overflow-y: auto; max-height: calc(90vh - 120px);">
${skill.description ? `<div style="margin-bottom: 16px;"><strong>描述:</strong> ${escapeHtml(skill.description)}</div>` : ''} ${skill.description ? `<div style="margin-bottom: 16px;"><strong>${escapeHtml(descLabel)}</strong> ${escapeHtml(skill.description)}</div>` : ''}
<div style="margin-bottom: 8px;"><strong>路径:</strong> ${escapeHtml(skill.path || '')}</div> <div style="margin-bottom: 8px;"><strong>${escapeHtml(pathLabel)}</strong> ${escapeHtml(skill.path || '')}</div>
<div style="margin-bottom: 16px;"><strong>修改时间:</strong> ${escapeHtml(skill.mod_time || '')}</div> <div style="margin-bottom: 16px;"><strong>${escapeHtml(modTimeLabel)}</strong> ${escapeHtml(skill.mod_time || '')}</div>
<div style="margin-bottom: 8px;"><strong>内容:</strong></div> <div style="margin-bottom: 8px;"><strong>${escapeHtml(contentLabel)}</strong></div>
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;">${escapeHtml(skill.content || '')}</pre> <pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;">${escapeHtml(skill.content || '')}</pre>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn-secondary" onclick="closeSkillViewModal()">关闭</button> <button class="btn-secondary" onclick="closeSkillViewModal()">${escapeHtml(closeBtn)}</button>
<button class="btn-primary" onclick="editSkill('${escapeHtml(skill.name)}'); closeSkillViewModal();">编辑</button> <button class="btn-primary" onclick="editSkill('${escapeHtml(skill.name)}'); closeSkillViewModal();">${escapeHtml(editBtn)}</button>
</div> </div>
</div> </div>
`; `;
@@ -412,7 +430,7 @@ async function viewSkill(skillName) {
modal.style.display = 'flex'; modal.style.display = 'flex';
} catch (error) { } catch (error) {
console.error('查看skill失败:', error); console.error('查看skill失败:', error);
showNotification('查看skill失败: ' + error.message, 'error'); showNotification(_t('skills.viewFailed') + ': ' + error.message, 'error');
} }
} }
@@ -442,18 +460,18 @@ async function saveSkill() {
const content = document.getElementById('skill-content').value.trim(); const content = document.getElementById('skill-content').value.trim();
if (!name) { if (!name) {
showNotification('skill名称不能为空', 'error'); showNotification(_t('skills.nameRequired'), 'error');
return; return;
} }
if (!content) { if (!content) {
showNotification('skill内容不能为空', 'error'); showNotification(_t('skills.contentRequired'), 'error');
return; return;
} }
// 验证skill名称 // 验证skill名称
if (!/^[a-zA-Z0-9_-]+$/.test(name)) { if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
showNotification('skill名称只能包含字母、数字、连字符和下划线', 'error'); showNotification(_t('skills.nameInvalid'), 'error');
return; return;
} }
@@ -461,7 +479,7 @@ async function saveSkill() {
const saveBtn = document.querySelector('#skill-modal .btn-primary'); const saveBtn = document.querySelector('#skill-modal .btn-primary');
if (saveBtn) { if (saveBtn) {
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.textContent = '保存中...'; saveBtn.textContent = _t('skills.saving');
} }
try { try {
@@ -483,20 +501,20 @@ async function saveSkill() {
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || '保存skill失败'); throw new Error(error.error || _t('skills.saveFailed'));
} }
showNotification(isEdit ? 'skill已更新' : 'skill已创建', 'success'); showNotification(isEdit ? _t('skills.saveSuccess') : _t('skills.createdSuccess'), 'success');
closeSkillModal(); closeSkillModal();
await loadSkills(skillsPagination.currentPage, skillsPagination.pageSize); await loadSkills(skillsPagination.currentPage, skillsPagination.pageSize);
} catch (error) { } catch (error) {
console.error('保存skill失败:', error); console.error('保存skill失败:', error);
showNotification('保存skill失败: ' + error.message, 'error'); showNotification(_t('skills.saveFailed') + ': ' + error.message, 'error');
} finally { } finally {
isSavingSkill = false; isSavingSkill = false;
if (saveBtn) { if (saveBtn) {
saveBtn.disabled = false; saveBtn.disabled = false;
saveBtn.textContent = '保存'; saveBtn.textContent = _t('common.save');
} }
} }
} }
@@ -517,10 +535,10 @@ async function deleteSkill(skillName) {
} }
// 构建确认消息 // 构建确认消息
let confirmMessage = `确定要删除skill "${skillName}" 吗?此操作不可恢复。`; let confirmMessage = _t('skills.deleteConfirm', { name: skillName });
if (boundRoles.length > 0) { if (boundRoles.length > 0) {
const rolesList = boundRoles.join('、'); const rolesList = boundRoles.join('、');
confirmMessage = `确定要删除skill "${skillName}" 吗?\n\n⚠️ 该skill当前已被以下 ${boundRoles.length} 个角色绑定:\n${rolesList}\n\n删除后,系统将自动从这些角色中移除该skill的绑定。\n\n此操作不可恢复,是否继续?`; confirmMessage = _t('skills.deleteConfirmWithRoles', { name: skillName, count: boundRoles.length, roles: rolesList });
} }
if (!confirm(confirmMessage)) { if (!confirm(confirmMessage)) {
@@ -534,14 +552,14 @@ async function deleteSkill(skillName) {
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || '删除skill失败'); throw new Error(error.error || _t('skills.deleteFailed'));
} }
const data = await response.json(); const data = await response.json();
let successMessage = 'skill已删除'; let successMessage = _t('skills.deleteSuccess');
if (data.affected_roles && data.affected_roles.length > 0) { if (data.affected_roles && data.affected_roles.length > 0) {
const rolesList = data.affected_roles.join('、'); const rolesList = data.affected_roles.join('、');
successMessage = `skill已删除,已自动从 ${data.affected_roles.length} 个角色中移除绑定:${rolesList}`; successMessage = _t('skills.deleteSuccessWithRoles', { count: data.affected_roles.length, roles: rolesList });
} }
showNotification(successMessage, 'success'); showNotification(successMessage, 'success');
@@ -553,7 +571,7 @@ async function deleteSkill(skillName) {
await loadSkills(pageToLoad, skillsPagination.pageSize); await loadSkills(pageToLoad, skillsPagination.pageSize);
} catch (error) { } catch (error) {
console.error('删除skill失败:', error); console.error('删除skill失败:', error);
showNotification('删除skill失败: ' + error.message, 'error'); showNotification(_t('skills.deleteFailed') + ': ' + error.message, 'error');
} }
} }
@@ -564,7 +582,7 @@ async function loadSkillsMonitor() {
try { try {
const response = await apiFetch('/api/skills/stats'); const response = await apiFetch('/api/skills/stats');
if (!response.ok) { if (!response.ok) {
throw new Error('获取skills统计信息失败'); throw new Error(_t('skills.loadStatsFailed'));
} }
const data = await response.json(); const data = await response.json();
@@ -580,14 +598,14 @@ async function loadSkillsMonitor() {
renderSkillsMonitor(); renderSkillsMonitor();
} catch (error) { } catch (error) {
console.error('加载skills监控数据失败:', error); console.error('加载skills监控数据失败:', error);
showNotification('加载skills监控数据失败: ' + error.message, 'error'); showNotification(_t('skills.loadStatsFailed') + ': ' + error.message, 'error');
const statsEl = document.getElementById('skills-stats'); const statsEl = document.getElementById('skills-stats');
if (statsEl) { if (statsEl) {
statsEl.innerHTML = '<div class="monitor-error">无法加载统计信息:' + escapeHtml(error.message) + '</div>'; statsEl.innerHTML = '<div class="monitor-error">' + _t('skills.loadStatsErrorShort') + ': ' + escapeHtml(error.message) + '</div>';
} }
const monitorListEl = document.getElementById('skills-monitor-list'); const monitorListEl = document.getElementById('skills-monitor-list');
if (monitorListEl) { if (monitorListEl) {
monitorListEl.innerHTML = '<div class="monitor-error">无法加载调用统计:' + escapeHtml(error.message) + '</div>'; monitorListEl.innerHTML = '<div class="monitor-error">' + _t('skills.loadCallStatsError') + ': ' + escapeHtml(error.message) + '</div>';
} }
} }
} }
@@ -603,23 +621,23 @@ function renderSkillsMonitor() {
statsEl.innerHTML = ` statsEl.innerHTML = `
<div class="monitor-stat-card"> <div class="monitor-stat-card">
<div class="monitor-stat-label">总Skills数</div> <div class="monitor-stat-label">${_t('skills.totalSkillsCount')}</div>
<div class="monitor-stat-value">${skillsStats.total}</div> <div class="monitor-stat-value">${skillsStats.total}</div>
</div> </div>
<div class="monitor-stat-card"> <div class="monitor-stat-card">
<div class="monitor-stat-label">总调用次数</div> <div class="monitor-stat-label">${_t('skills.totalCallsCount')}</div>
<div class="monitor-stat-value">${skillsStats.totalCalls}</div> <div class="monitor-stat-value">${skillsStats.totalCalls}</div>
</div> </div>
<div class="monitor-stat-card"> <div class="monitor-stat-card">
<div class="monitor-stat-label">成功调用</div> <div class="monitor-stat-label">${_t('skills.successfulCalls')}</div>
<div class="monitor-stat-value" style="color: #28a745;">${skillsStats.totalSuccess}</div> <div class="monitor-stat-value" style="color: #28a745;">${skillsStats.totalSuccess}</div>
</div> </div>
<div class="monitor-stat-card"> <div class="monitor-stat-card">
<div class="monitor-stat-label">失败调用</div> <div class="monitor-stat-label">${_t('skills.failedCalls')}</div>
<div class="monitor-stat-value" style="color: #dc3545;">${skillsStats.totalFailed}</div> <div class="monitor-stat-value" style="color: #dc3545;">${skillsStats.totalFailed}</div>
</div> </div>
<div class="monitor-stat-card"> <div class="monitor-stat-card">
<div class="monitor-stat-label">成功率</div> <div class="monitor-stat-label">${_t('skills.successRate')}</div>
<div class="monitor-stat-value">${successRate}%</div> <div class="monitor-stat-value">${successRate}%</div>
</div> </div>
`; `;
@@ -633,7 +651,7 @@ function renderSkillsMonitor() {
// 如果没有统计数据,显示空状态 // 如果没有统计数据,显示空状态
if (stats.length === 0) { if (stats.length === 0) {
monitorListEl.innerHTML = '<div class="monitor-empty">暂无Skills调用记录</div>'; monitorListEl.innerHTML = '<div class="monitor-empty">' + _t('skills.noCallRecords') + '</div>';
return; return;
} }
@@ -651,12 +669,12 @@ function renderSkillsMonitor() {
<table class="monitor-table"> <table class="monitor-table">
<thead> <thead>
<tr> <tr>
<th style="text-align: left !important;">Skill名称</th> <th style="text-align: left !important;">${_t('skills.skillName')}</th>
<th style="text-align: center;">总调用</th> <th style="text-align: center;">${_t('skills.totalCalls')}</th>
<th style="text-align: center;">成功</th> <th style="text-align: center;">${_t('skills.success')}</th>
<th style="text-align: center;">失败</th> <th style="text-align: center;">${_t('skills.failure')}</th>
<th style="text-align: center;">成功率</th> <th style="text-align: center;">${_t('skills.successRate')}</th>
<th style="text-align: left;">最后调用时间</th> <th style="text-align: left;">${_t('skills.lastCallTime')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -686,12 +704,12 @@ function renderSkillsMonitor() {
// 刷新skills监控 // 刷新skills监控
async function refreshSkillsMonitor() { async function refreshSkillsMonitor() {
await loadSkillsMonitor(); await loadSkillsMonitor();
showNotification('已刷新', 'success'); showNotification(_t('skills.refreshed'), 'success');
} }
// 清空skills统计数据 // 清空skills统计数据
async function clearSkillsStats() { async function clearSkillsStats() {
if (!confirm('确定要清空所有Skills统计数据吗?此操作不可恢复。')) { if (!confirm(_t('skills.clearStatsConfirm'))) {
return; return;
} }
@@ -702,15 +720,15 @@ async function clearSkillsStats() {
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || '清空统计数据失败'); throw new Error(error.error || _t('skills.clearStatsFailed'));
} }
showNotification('已清空所有Skills统计数据', 'success'); showNotification(_t('skills.statsCleared'), 'success');
// 重新加载统计数据 // 重新加载统计数据
await loadSkillsMonitor(); await loadSkillsMonitor();
} catch (error) { } catch (error) {
console.error('清空统计数据失败:', error); console.error('清空统计数据失败:', error);
showNotification('清空统计数据失败: ' + error.message, 'error'); showNotification(_t('skills.clearStatsFailed') + ': ' + error.message, 'error');
} }
} }
@@ -721,3 +739,14 @@ function escapeHtml(text) {
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
// 语言切换时重新渲染当前页(技能列表与分页使用 _t,需随语言更新)
document.addEventListener('languagechange', function () {
const page = document.getElementById('page-skills-management');
if (page && page.classList.contains('active')) {
renderSkillsList();
if (!skillsSearchKeyword) {
renderSkillsPagination();
}
}
});
+135 -131
View File
@@ -1,4 +1,7 @@
// 任务管理页面功能 // 任务管理页面功能
function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
}
// HTML转义函数(如果未定义) // HTML转义函数(如果未定义)
if (typeof escapeHtml === 'undefined') { if (typeof escapeHtml === 'undefined') {
@@ -106,7 +109,7 @@ async function loadTasks() {
const listContainer = document.getElementById('tasks-list'); const listContainer = document.getElementById('tasks-list');
if (!listContainer) return; if (!listContainer) return;
listContainer.innerHTML = '<div class="loading-spinner">加载中...</div>'; listContainer.innerHTML = '<div class="loading-spinner">' + _t('tasks.loadingTasks') + '</div>';
try { try {
// 并行加载运行中的任务和已完成的任务历史 // 并行加载运行中的任务和已完成的任务历史
@@ -117,7 +120,7 @@ async function loadTasks() {
// 处理运行中的任务 // 处理运行中的任务
if (activeResponse.status === 'rejected' || !activeResponse.value || !activeResponse.value.ok) { if (activeResponse.status === 'rejected' || !activeResponse.value || !activeResponse.value.ok) {
throw new Error('获取任务列表失败'); throw new Error(_t('tasks.loadTaskListFailed'));
} }
const activeResult = await activeResponse.value.json(); const activeResult = await activeResponse.value.json();
@@ -177,8 +180,8 @@ async function loadTasks() {
console.error('加载任务失败:', error); console.error('加载任务失败:', error);
listContainer.innerHTML = ` listContainer.innerHTML = `
<div class="tasks-empty"> <div class="tasks-empty">
<p>加载失败: ${escapeHtml(error.message)}</p> <p>${_t('tasks.loadFailedRetry')}: ${escapeHtml(error.message)}</p>
<button class="btn-secondary" onclick="loadTasks()">重试</button> <button class="btn-secondary" onclick="loadTasks()">${_t('tasks.retry')}</button>
</div> </div>
`; `;
} }
@@ -296,21 +299,21 @@ function toggleShowHistory(show) {
// 计算执行时长 // 计算执行时长
function calculateDuration(startedAt) { function calculateDuration(startedAt) {
if (!startedAt) return '未知'; if (!startedAt) return _t('tasks.unknown');
const start = new Date(startedAt); const start = new Date(startedAt);
const now = new Date(); const now = new Date();
const diff = Math.floor((now - start) / 1000); // 秒 const diff = Math.floor((now - start) / 1000);
if (diff < 60) { if (diff < 60) {
return `${diff}`; return diff + _t('tasks.durationSeconds');
} else if (diff < 3600) { } else if (diff < 3600) {
const minutes = Math.floor(diff / 60); const minutes = Math.floor(diff / 60);
const seconds = diff % 60; const seconds = diff % 60;
return `${minutes}${seconds}`; return minutes + _t('tasks.durationMinutes') + ' ' + seconds + _t('tasks.durationSeconds');
} else { } else {
const hours = Math.floor(diff / 3600); const hours = Math.floor(diff / 3600);
const minutes = Math.floor((diff % 3600) / 60); const minutes = Math.floor((diff % 3600) / 60);
return `${hours}小时${minutes}`; return hours + _t('tasks.durationHours') + ' ' + minutes + _t('tasks.durationMinutes');
} }
} }
@@ -349,9 +352,9 @@ function renderTasks(tasks) {
if (tasks.length === 0) { if (tasks.length === 0) {
listContainer.innerHTML = ` listContainer.innerHTML = `
<div class="tasks-empty"> <div class="tasks-empty">
<p>当前没有符合条件的任务</p> <p>${_t('tasks.noMatchingTasks')}</p>
${tasksState.allTasks.length === 0 && tasksState.completedTasksHistory.length > 0 ? ${tasksState.allTasks.length === 0 && tasksState.completedTasksHistory.length > 0 ?
'<p style="margin-top: 8px; color: var(--text-muted); font-size: 0.875rem;">提示:有已完成的任务历史,请勾选"显示历史记录"查看</p>' : ''} '<p style="margin-top: 8px; color: var(--text-muted); font-size: 0.875rem;">' + _t('tasks.historyHint') + '</p>' : ''}
</div> </div>
`; `;
return; return;
@@ -359,12 +362,12 @@ function renderTasks(tasks) {
// 状态映射 // 状态映射
const statusMap = { const statusMap = {
'running': { text: '执行中', class: 'task-status-running' }, 'running': { text: _t('tasks.statusRunning'), class: 'task-status-running' },
'cancelling': { text: '取消中', class: 'task-status-cancelling' }, 'cancelling': { text: _t('tasks.statusCancelling'), class: 'task-status-cancelling' },
'failed': { text: '执行失败', class: 'task-status-failed' }, 'failed': { text: _t('tasks.statusFailed'), class: 'task-status-failed' },
'timeout': { text: '执行超时', class: 'task-status-timeout' }, 'timeout': { text: _t('tasks.statusTimeout'), class: 'task-status-timeout' },
'cancelled': { text: '已取消', class: 'task-status-cancelled' }, 'cancelled': { text: _t('tasks.statusCancelled'), class: 'task-status-cancelled' },
'completed': { text: '已完成', class: 'task-status-completed' } 'completed': { text: _t('tasks.statusCompleted'), class: 'task-status-completed' }
}; };
// 分离当前任务和历史任务 // 分离当前任务和历史任务
@@ -382,8 +385,8 @@ function renderTasks(tasks) {
if (historyTasks.length > 0) { if (historyTasks.length > 0) {
html += `<div class="tasks-history-section"> html += `<div class="tasks-history-section">
<div class="tasks-history-header"> <div class="tasks-history-header">
<span class="tasks-history-title">📜 最近完成的任务最近24小时</span> <span class="tasks-history-title">📜 ` + _t('tasks.recentCompletedTasks') + `</span>
<button class="btn-secondary btn-small" onclick="clearTasksHistory()">清空历史</button> <button class="btn-secondary btn-small" onclick="clearTasksHistory()">` + _t('tasks.clearHistory') + `</button>
</div> </div>
${historyTasks.map(task => renderTaskItem(task, statusMap, true)).join('')} ${historyTasks.map(task => renderTaskItem(task, statusMap, true)).join('')}
</div>`; </div>`;
@@ -406,7 +409,7 @@ function renderTaskItem(task, statusMap, isHistory = false) {
minute: '2-digit', minute: '2-digit',
second: '2-digit' second: '2-digit'
}) })
: '未知时间'; : _t('tasks.unknownTime');
const completedText = completedTime && !isNaN(completedTime.getTime()) const completedText = completedTime && !isNaN(completedTime.getTime())
? completedTime.toLocaleString('zh-CN', { ? completedTime.toLocaleString('zh-CN', {
@@ -438,22 +441,22 @@ function renderTaskItem(task, statusMap, isHistory = false) {
</label> </label>
` : '<div class="task-checkbox-placeholder"></div>'} ` : '<div class="task-checkbox-placeholder"></div>'}
<span class="task-status ${status.class}">${status.text}</span> <span class="task-status ${status.class}">${status.text}</span>
${isHistory ? '<span class="task-history-badge" title="历史记录">📜</span>' : ''} ${isHistory ? '<span class="task-history-badge" title="' + _t('tasks.historyBadge') + '">📜</span>' : ''}
<span class="task-message" title="${escapeHtml(task.message || '未命名任务')}">${escapeHtml(task.message || '未命名任务')}</span> <span class="task-message" title="${escapeHtml(task.message || _t('tasks.unnamedTask'))}">${escapeHtml(task.message || _t('tasks.unnamedTask'))}</span>
</div> </div>
<div class="task-actions"> <div class="task-actions">
${duration ? `<span class="task-duration" title="执行时长">⏱ ${duration}</span>` : ''} ${duration ? `<span class="task-duration" title="${_t('tasks.duration')}">⏱ ${duration}</span>` : ''}
<span class="task-time" title="${isHistory && completedText ? '完成时间' : '开始时间'}"> <span class="task-time" title="${isHistory && completedText ? _t('tasks.completedAt') : _t('tasks.startedAt')}">
${isHistory && completedText ? completedText : timeText} ${isHistory && completedText ? completedText : timeText}
</span> </span>
${canCancel ? `<button class="btn-secondary btn-small" onclick="cancelTask('${task.conversationId}', this)">取消任务</button>` : ''} ${canCancel ? `<button class="btn-secondary btn-small" onclick="cancelTask('${task.conversationId}', this)">` + _t('tasks.cancelTask') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewConversation('${task.conversationId}')">查看对话</button>` : ''} ${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewConversation('${task.conversationId}')">` + _t('tasks.viewConversation') + `</button>` : ''}
</div> </div>
</div> </div>
${task.conversationId ? ` ${task.conversationId ? `
<div class="task-details"> <div class="task-details">
<span class="task-id-label">对话ID:</span> <span class="task-id-label">` + _t('tasks.conversationIdLabel') + `:</span>
<span class="task-id-value" title="点击复制" onclick="copyTaskId('${task.conversationId}')">${escapeHtml(task.conversationId)}</span> <span class="task-id-value" title="` + _t('tasks.clickToCopy') + `" onclick="copyTaskId('${task.conversationId}')">${escapeHtml(task.conversationId)}</span>
</div> </div>
` : ''} ` : ''}
</div> </div>
@@ -462,7 +465,7 @@ function renderTaskItem(task, statusMap, isHistory = false) {
// 清空任务历史 // 清空任务历史
function clearTasksHistory() { function clearTasksHistory() {
if (!confirm('确定要清空所有任务历史记录吗?')) { if (!confirm(_t('tasks.clearHistoryConfirm'))) {
return; return;
} }
tasksState.completedTasksHistory = []; tasksState.completedTasksHistory = [];
@@ -490,7 +493,7 @@ function updateBatchActions() {
const count = tasksState.selectedTasks.size; const count = tasksState.selectedTasks.size;
if (count > 0) { if (count > 0) {
batchActions.style.display = 'flex'; batchActions.style.display = 'flex';
selectedCount.textContent = `已选择 ${count}`; selectedCount.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: count }) : `已选择 ${count}`;
} else { } else {
batchActions.style.display = 'none'; batchActions.style.display = 'none';
} }
@@ -509,7 +512,7 @@ async function batchCancelTasks() {
const selected = Array.from(tasksState.selectedTasks); const selected = Array.from(tasksState.selectedTasks);
if (selected.length === 0) return; if (selected.length === 0) return;
if (!confirm(`确定要取消 ${selected.length} 个任务吗?`)) { if (!confirm(_t('tasks.confirmCancelTasks', { n: selected.length }))) {
return; return;
} }
@@ -545,9 +548,9 @@ async function batchCancelTasks() {
// 显示结果 // 显示结果
if (failCount > 0) { if (failCount > 0) {
alert(`批量取消完成:成功 ${successCount} 个,失败 ${failCount}`); alert(_t('tasks.batchCancelResultPartial', { success: successCount, fail: failCount }));
} else { } else {
alert(`成功取消 ${successCount} 个任务`); alert(_t('tasks.batchCancelResultSuccess', { n: successCount }));
} }
} }
@@ -556,7 +559,7 @@ function copyTaskId(conversationId) {
navigator.clipboard.writeText(conversationId).then(() => { navigator.clipboard.writeText(conversationId).then(() => {
// 显示复制成功提示 // 显示复制成功提示
const tooltip = document.createElement('div'); const tooltip = document.createElement('div');
tooltip.textContent = '已复制!'; tooltip.textContent = _t('tasks.copiedToast');
tooltip.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.8); color: white; padding: 8px 16px; border-radius: 4px; z-index: 10000;'; tooltip.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.8); color: white; padding: 8px 16px; border-radius: 4px; z-index: 10000;';
document.body.appendChild(tooltip); document.body.appendChild(tooltip);
setTimeout(() => tooltip.remove(), 1000); setTimeout(() => tooltip.remove(), 1000);
@@ -571,7 +574,7 @@ async function cancelTask(conversationId, button) {
const originalText = button.textContent; const originalText = button.textContent;
button.disabled = true; button.disabled = true;
button.textContent = '取消中...'; button.textContent = _t('tasks.cancelling');
try { try {
const response = await apiFetch('/api/agent-loop/cancel', { const response = await apiFetch('/api/agent-loop/cancel', {
@@ -584,7 +587,7 @@ async function cancelTask(conversationId, button) {
if (!response.ok) { if (!response.ok) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
throw new Error(result.error || '取消任务失败'); throw new Error(result.error || _t('tasks.cancelTaskFailed'));
} }
// 从选择中移除 // 从选择中移除
@@ -595,7 +598,7 @@ async function cancelTask(conversationId, button) {
await loadTasks(); await loadTasks();
} catch (error) { } catch (error) {
console.error('取消任务失败:', error); console.error('取消任务失败:', error);
alert('取消任务失败: ' + error.message); alert(_t('tasks.cancelTaskFailed') + ': ' + error.message);
button.disabled = false; button.disabled = false;
button.textContent = originalText; button.textContent = originalText;
} }
@@ -738,7 +741,7 @@ async function showBatchImportModal() {
try { try {
const loadedRoles = await loadRoles(); const loadedRoles = await loadRoles();
// 清空现有选项(除了默认选项) // 清空现有选项(除了默认选项)
roleSelect.innerHTML = '<option value="">默认</option>'; roleSelect.innerHTML = '<option value="">' + _t('batchImportModal.defaultRole') + '</option>';
// 添加已启用的角色 // 添加已启用的角色
const sortedRoles = loadedRoles.sort((a, b) => { const sortedRoles = loadedRoles.sort((a, b) => {
@@ -782,7 +785,7 @@ function updateBatchImportStats(text) {
const count = lines.length; const count = lines.length;
if (count > 0) { if (count > 0) {
statsEl.innerHTML = `<div class="batch-import-stat">${count} 个任务</div>`; statsEl.innerHTML = '<div class="batch-import-stat">' + _t('tasks.taskCount', { count: count }) + '</div>';
statsEl.style.display = 'block'; statsEl.style.display = 'block';
} else { } else {
statsEl.style.display = 'none'; statsEl.style.display = 'none';
@@ -808,14 +811,14 @@ async function createBatchQueue() {
const text = input.value.trim(); const text = input.value.trim();
if (!text) { if (!text) {
alert('请输入至少一个任务'); alert(_t('tasks.enterTaskPrompt'));
return; return;
} }
// 按行分割任务 // 按行分割任务
const tasks = text.split('\n').map(line => line.trim()).filter(line => line !== ''); const tasks = text.split('\n').map(line => line.trim()).filter(line => line !== '');
if (tasks.length === 0) { if (tasks.length === 0) {
alert('没有有效的任务'); alert(_t('tasks.noValidTask'));
return; return;
} }
@@ -836,7 +839,7 @@ async function createBatchQueue() {
if (!response.ok) { if (!response.ok) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
throw new Error(result.error || '创建批量任务队列失败'); throw new Error(result.error || _t('tasks.createBatchQueueFailed'));
} }
const result = await response.json(); const result = await response.json();
@@ -849,7 +852,7 @@ async function createBatchQueue() {
refreshBatchQueues(); refreshBatchQueues();
} catch (error) { } catch (error) {
console.error('创建批量任务队列失败:', error); console.error('创建批量任务队列失败:', error);
alert('创建批量任务队列失败: ' + error.message); alert(_t('tasks.createBatchQueueFailed') + ': ' + error.message);
} }
} }
@@ -916,7 +919,7 @@ async function loadBatchQueues(page) {
try { try {
const response = await apiFetch(`/api/batch-tasks?${params.toString()}`); const response = await apiFetch(`/api/batch-tasks?${params.toString()}`);
if (!response.ok) { if (!response.ok) {
throw new Error('获取批量任务队列失败'); throw new Error(_t('tasks.loadFailedRetry'));
} }
const result = await response.json(); const result = await response.json();
@@ -929,7 +932,7 @@ async function loadBatchQueues(page) {
section.style.display = 'block'; section.style.display = 'block';
const list = document.getElementById('batch-queues-list'); const list = document.getElementById('batch-queues-list');
if (list) { if (list) {
list.innerHTML = '<div class="tasks-empty"><p>加载失败: ' + escapeHtml(error.message) + '</p><button class="btn-secondary" onclick="refreshBatchQueues()">重试</button></div>'; list.innerHTML = '<div class="tasks-empty"><p>' + _t('tasks.loadFailedRetry') + ': ' + escapeHtml(error.message) + '</p><button class="btn-secondary" onclick="refreshBatchQueues()">' + _t('tasks.retry') + '</button></div>';
} }
} }
} }
@@ -964,7 +967,7 @@ function renderBatchQueues() {
const queues = batchQueuesState.queues; const queues = batchQueuesState.queues;
if (queues.length === 0) { if (queues.length === 0) {
list.innerHTML = '<div class="tasks-empty"><p>当前没有批量任务队列</p></div>'; list.innerHTML = '<div class="tasks-empty"><p>' + _t('tasks.noBatchQueues') + '</p></div>';
if (pagination) pagination.style.display = 'none'; if (pagination) pagination.style.display = 'none';
return; return;
} }
@@ -976,11 +979,11 @@ function renderBatchQueues() {
list.innerHTML = queues.map(queue => { list.innerHTML = queues.map(queue => {
const statusMap = { const statusMap = {
'pending': { text: '待执行', class: 'batch-queue-status-pending' }, 'pending': { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
'running': { text: '执行中', class: 'batch-queue-status-running' }, 'running': { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
'paused': { text: '已暂停', class: 'batch-queue-status-paused' }, 'paused': { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
'completed': { text: '已完成', class: 'batch-queue-status-completed' }, 'completed': { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
'cancelled': { text: '已取消', class: 'batch-queue-status-cancelled' } 'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
}; };
const status = statusMap[queue.status] || { text: queue.status, class: 'batch-queue-status-unknown' }; const status = statusMap[queue.status] || { text: queue.status, class: 'batch-queue-status-unknown' };
@@ -1012,8 +1015,8 @@ function renderBatchQueues() {
// 显示角色信息(使用正确的角色图标) // 显示角色信息(使用正确的角色图标)
const loadedRoles = batchQueuesState.loadedRoles || []; const loadedRoles = batchQueuesState.loadedRoles || [];
const roleIcon = getRoleIconForDisplay(queue.role, loadedRoles); const roleIcon = getRoleIconForDisplay(queue.role, loadedRoles);
const roleName = queue.role && queue.role !== '' ? queue.role : '默认'; const roleName = queue.role && queue.role !== '' ? queue.role : _t('batchQueueDetailModal.defaultRole');
const roleDisplay = `<span class="batch-queue-role" style="margin-right: 8px;" title="角色: ${escapeHtml(roleName)}">${roleIcon} ${escapeHtml(roleName)}</span>`; const roleDisplay = `<span class="batch-queue-role" style="margin-right: 8px;" title="${_t('batchQueueDetailModal.role')}: ${escapeHtml(roleName)}">${roleIcon} ${escapeHtml(roleName)}</span>`;
return ` return `
<div class="batch-queue-item" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')"> <div class="batch-queue-item" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
@@ -1022,8 +1025,8 @@ function renderBatchQueues() {
${titleDisplay} ${titleDisplay}
${roleDisplay} ${roleDisplay}
<span class="batch-queue-status ${status.class}">${status.text}</span> <span class="batch-queue-status ${status.class}">${status.text}</span>
<span class="batch-queue-id">队列ID: ${escapeHtml(queue.id)}</span> <span class="batch-queue-id">${_t('tasks.queueIdLabel')}: ${escapeHtml(queue.id)}</span>
<span class="batch-queue-time">创建时间: ${new Date(queue.createdAt).toLocaleString('zh-CN')}</span> <span class="batch-queue-time">${_t('tasks.createdTimeLabel')}: ${new Date(queue.createdAt).toLocaleString()}</span>
</div> </div>
<div class="batch-queue-progress"> <div class="batch-queue-progress">
<div class="batch-queue-progress-bar"> <div class="batch-queue-progress-bar">
@@ -1032,16 +1035,16 @@ function renderBatchQueues() {
<span class="batch-queue-progress-text">${progress}% (${stats.completed + stats.failed + stats.cancelled}/${stats.total})</span> <span class="batch-queue-progress-text">${progress}% (${stats.completed + stats.failed + stats.cancelled}/${stats.total})</span>
</div> </div>
<div class="batch-queue-actions" style="display: flex; align-items: center; gap: 8px; margin-left: 12px;" onclick="event.stopPropagation();"> <div class="batch-queue-actions" style="display: flex; align-items: center; gap: 8px; margin-left: 12px;" onclick="event.stopPropagation();">
${canDelete ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteBatchQueueFromList('${queue.id}')" title="删除队列">删除</button>` : ''} ${canDelete ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteBatchQueueFromList('${queue.id}')" title="${_t('tasks.deleteQueue')}">${_t('common.delete')}</button>` : ''}
</div> </div>
</div> </div>
<div class="batch-queue-stats"> <div class="batch-queue-stats">
<span>总计: ${stats.total}</span> <span>${_t('tasks.totalLabel')}: ${stats.total}</span>
<span>待执行: ${stats.pending}</span> <span>${_t('tasks.pendingLabel')}: ${stats.pending}</span>
<span>执行中: ${stats.running}</span> <span>${_t('tasks.runningLabel')}: ${stats.running}</span>
<span style="color: var(--success-color);">已完成: ${stats.completed}</span> <span style="color: var(--success-color);">${_t('tasks.completedLabel')}: ${stats.completed}</span>
<span style="color: var(--error-color);">失败: ${stats.failed}</span> <span style="color: var(--error-color);">${_t('tasks.failedLabel')}: ${stats.failed}</span>
${stats.cancelled > 0 ? `<span style="color: var(--text-secondary);">已取消: ${stats.cancelled}</span>` : ''} ${stats.cancelled > 0 ? `<span style="color: var(--text-secondary);">${_t('tasks.cancelledLabel')}: ${stats.cancelled}</span>` : ''}
</div> </div>
</div> </div>
`; `;
@@ -1073,9 +1076,9 @@ function renderBatchQueuesPagination() {
// 左侧:显示范围信息和每页数量选择器(参考Skills样式) // 左侧:显示范围信息和每页数量选择器(参考Skills样式)
paginationHTML += ` paginationHTML += `
<div class="pagination-info"> <div class="pagination-info">
<span>显示 ${start}-${end} / ${total} </span> <span>` + _t('tasks.paginationShow', { start: start, end: end, total: total }) + `</span>
<label class="pagination-page-size"> <label class="pagination-page-size">
每页显示 ` + _t('tasks.paginationPerPage') + `
<select id="batch-queues-page-size-pagination" onchange="changeBatchQueuesPageSize()"> <select id="batch-queues-page-size-pagination" onchange="changeBatchQueuesPageSize()">
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option> <option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option> <option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
@@ -1089,11 +1092,11 @@ function renderBatchQueuesPagination() {
// 右侧:分页按钮(参考Skills样式:首页、上一页、第X/Y页、下一页、末页) // 右侧:分页按钮(参考Skills样式:首页、上一页、第X/Y页、下一页、末页)
paginationHTML += ` paginationHTML += `
<div class="pagination-controls"> <div class="pagination-controls">
<button class="btn-secondary" onclick="goBatchQueuesPage(1)" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>首页</button> <button class="btn-secondary" onclick="goBatchQueuesPage(1)" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationFirst') + `</button>
<button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage - 1})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>上一页</button> <button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage - 1})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationPrev') + `</button>
<span class="pagination-page"> ${currentPage} / ${totalPages || 1} </span> <span class="pagination-page">` + _t('tasks.paginationPage', { current: currentPage, total: totalPages || 1 }) + `</span>
<button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage + 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>下一页</button> <button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage + 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationNext') + `</button>
<button class="btn-secondary" onclick="goBatchQueuesPage(${totalPages || 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>末页</button> <button class="btn-secondary" onclick="goBatchQueuesPage(${totalPages || 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationLast') + `</button>
</div> </div>
`; `;
@@ -1189,7 +1192,7 @@ async function showBatchQueueDetail(queueId) {
const response = await apiFetch(`/api/batch-tasks/${queueId}`); const response = await apiFetch(`/api/batch-tasks/${queueId}`);
if (!response.ok) { if (!response.ok) {
throw new Error('获取队列详情失败'); throw new Error(_t('tasks.getQueueDetailFailed'));
} }
const result = await response.json(); const result = await response.json();
@@ -1197,7 +1200,8 @@ async function showBatchQueueDetail(queueId) {
batchQueuesState.currentQueueId = queueId; batchQueuesState.currentQueueId = queueId;
if (title) { if (title) {
title.textContent = queue.title ? `批量任务队列 - ${escapeHtml(queue.title)}` : '批量任务队列'; // textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &amp;...(看起来像“变形/乱码”)
title.textContent = queue.title ? _t('tasks.batchQueueTitle') + ' - ' + String(queue.title) : _t('tasks.batchQueueTitle');
} }
// 更新按钮显示 // 更新按钮显示
@@ -1209,9 +1213,9 @@ async function showBatchQueueDetail(queueId) {
// pending状态显示"开始执行"paused状态显示"继续执行" // pending状态显示"开始执行"paused状态显示"继续执行"
startBtn.style.display = (queue.status === 'pending' || queue.status === 'paused') ? 'inline-block' : 'none'; startBtn.style.display = (queue.status === 'pending' || queue.status === 'paused') ? 'inline-block' : 'none';
if (startBtn && queue.status === 'paused') { if (startBtn && queue.status === 'paused') {
startBtn.textContent = '继续执行'; startBtn.textContent = _t('tasks.resumeExecute');
} else if (startBtn && queue.status === 'pending') { } else if (startBtn && queue.status === 'pending') {
startBtn.textContent = '开始执行'; startBtn.textContent = _t('batchQueueDetailModal.startExecute');
} }
} }
if (pauseBtn) { if (pauseBtn) {
@@ -1225,20 +1229,20 @@ async function showBatchQueueDetail(queueId) {
// 队列状态映射 // 队列状态映射
const queueStatusMap = { const queueStatusMap = {
'pending': { text: '待执行', class: 'batch-queue-status-pending' }, 'pending': { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
'running': { text: '执行中', class: 'batch-queue-status-running' }, 'running': { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
'paused': { text: '已暂停', class: 'batch-queue-status-paused' }, 'paused': { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
'completed': { text: '已完成', class: 'batch-queue-status-completed' }, 'completed': { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
'cancelled': { text: '已取消', class: 'batch-queue-status-cancelled' } 'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
}; };
// 任务状态映射 // 任务状态映射
const taskStatusMap = { const taskStatusMap = {
'pending': { text: '待执行', class: 'batch-task-status-pending' }, 'pending': { text: _t('tasks.statusPending'), class: 'batch-task-status-pending' },
'running': { text: '执行中', class: 'batch-task-status-running' }, 'running': { text: _t('tasks.statusRunning'), class: 'batch-task-status-running' },
'completed': { text: '已完成', class: 'batch-task-status-completed' }, 'completed': { text: _t('tasks.statusCompleted'), class: 'batch-task-status-completed' },
'failed': { text: '失败', class: 'batch-task-status-failed' }, 'failed': { text: _t('tasks.failedLabel'), class: 'batch-task-status-failed' },
'cancelled': { text: '已取消', class: 'batch-task-status-cancelled' } 'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-task-status-cancelled' }
}; };
// 获取角色信息(如果队列有角色配置) // 获取角色信息(如果队列有角色配置)
@@ -1265,51 +1269,51 @@ async function showBatchQueueDetail(queueId) {
} }
} }
roleDisplay = `<div class="detail-item"> roleDisplay = `<div class="detail-item">
<span class="detail-label">角色</span> <span class="detail-label">` + _t('batchQueueDetailModal.role') + `</span>
<span class="detail-value">${roleIcon} ${escapeHtml(roleName)}</span> <span class="detail-value">${roleIcon} ${escapeHtml(roleName)}</span>
</div>`; </div>`;
} else { } else {
// 默认角色 // 默认角色
roleDisplay = `<div class="detail-item"> roleDisplay = `<div class="detail-item">
<span class="detail-label">角色</span> <span class="detail-label">` + _t('batchQueueDetailModal.role') + `</span>
<span class="detail-value">🔵 默认</span> <span class="detail-value">🔵 ` + _t('batchQueueDetailModal.defaultRole') + `</span>
</div>`; </div>`;
} }
content.innerHTML = ` content.innerHTML = `
<div class="batch-queue-detail-info"> <div class="batch-queue-detail-info">
${queue.title ? `<div class="detail-item"> ${queue.title ? `<div class="detail-item">
<span class="detail-label">任务标题</span> <span class="detail-label">` + _t('batchQueueDetailModal.queueTitle') + `</span>
<span class="detail-value">${escapeHtml(queue.title)}</span> <span class="detail-value">${escapeHtml(queue.title)}</span>
</div>` : ''} </div>` : ''}
${roleDisplay} ${roleDisplay}
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">队列ID</span> <span class="detail-label">` + _t('batchQueueDetailModal.queueId') + `</span>
<span class="detail-value"><code>${escapeHtml(queue.id)}</code></span> <span class="detail-value"><code>${escapeHtml(queue.id)}</code></span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">状态</span> <span class="detail-label">` + _t('batchQueueDetailModal.status') + `</span>
<span class="detail-value"><span class="batch-queue-status ${queueStatusMap[queue.status]?.class || ''}">${queueStatusMap[queue.status]?.text || queue.status}</span></span> <span class="detail-value"><span class="batch-queue-status ${queueStatusMap[queue.status]?.class || ''}">${queueStatusMap[queue.status]?.text || queue.status}</span></span>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">创建时间</span> <span class="detail-label">` + _t('batchQueueDetailModal.createdAt') + `</span>
<span class="detail-value">${new Date(queue.createdAt).toLocaleString('zh-CN')}</span> <span class="detail-value">${new Date(queue.createdAt).toLocaleString()}</span>
</div> </div>
${queue.startedAt ? `<div class="detail-item"> ${queue.startedAt ? `<div class="detail-item">
<span class="detail-label">开始时间</span> <span class="detail-label">` + _t('batchQueueDetailModal.startedAt') + `</span>
<span class="detail-value">${new Date(queue.startedAt).toLocaleString('zh-CN')}</span> <span class="detail-value">${new Date(queue.startedAt).toLocaleString()}</span>
</div>` : ''} </div>` : ''}
${queue.completedAt ? `<div class="detail-item"> ${queue.completedAt ? `<div class="detail-item">
<span class="detail-label">完成时间</span> <span class="detail-label">` + _t('batchQueueDetailModal.completedAt') + `</span>
<span class="detail-value">${new Date(queue.completedAt).toLocaleString('zh-CN')}</span> <span class="detail-value">${new Date(queue.completedAt).toLocaleString()}</span>
</div>` : ''} </div>` : ''}
<div class="detail-item"> <div class="detail-item">
<span class="detail-label">任务总数</span> <span class="detail-label">` + _t('batchQueueDetailModal.taskTotal') + `</span>
<span class="detail-value">${queue.tasks.length}</span> <span class="detail-value">${queue.tasks.length}</span>
</div> </div>
</div> </div>
<div class="batch-queue-tasks-list"> <div class="batch-queue-tasks-list">
<h4>任务列表</h4> <h4>` + _t('batchQueueDetailModal.taskList') + `</h4>
${queue.tasks.map((task, index) => { ${queue.tasks.map((task, index) => {
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' }; const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
const canEdit = queue.status === 'pending' && task.status === 'pending'; const canEdit = queue.status === 'pending' && task.status === 'pending';
@@ -1320,14 +1324,14 @@ async function showBatchQueueDetail(queueId) {
<span class="batch-task-index">#${index + 1}</span> <span class="batch-task-index">#${index + 1}</span>
<span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</span> <span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</span>
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span> <span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">编辑</button>` : ''} ${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">删除</button>` : ''} ${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">查看对话</button>` : ''} ${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
</div> </div>
${task.startedAt ? `<div class="batch-task-time">开始: ${new Date(task.startedAt).toLocaleString('zh-CN')}</div>` : ''} ${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
${task.completedAt ? `<div class="batch-task-time">完成: ${new Date(task.completedAt).toLocaleString('zh-CN')}</div>` : ''} ${task.completedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.completeLabel') + `: ${new Date(task.completedAt).toLocaleString()}</div>` : ''}
${task.error ? `<div class="batch-task-error">错误: ${escapeHtml(task.error)}</div>` : ''} ${task.error ? `<div class="batch-task-error">` + _t('batchQueueDetailModal.errorLabel') + `: ${escapeHtml(task.error)}</div>` : ''}
${task.result ? `<div class="batch-task-result">结果: ${escapeHtml(task.result.substring(0, 200))}${task.result.length > 200 ? '...' : ''}</div>` : ''} ${task.result ? `<div class="batch-task-result">` + _t('batchQueueDetailModal.resultLabel') + `: ${escapeHtml(task.result.substring(0, 200))}${task.result.length > 200 ? '...' : ''}</div>` : ''}
</div> </div>
`; `;
}).join('')} }).join('')}
@@ -1342,7 +1346,7 @@ async function showBatchQueueDetail(queueId) {
} }
} catch (error) { } catch (error) {
console.error('获取队列详情失败:', error); console.error('获取队列详情失败:', error);
alert('获取队列详情失败: ' + error.message); alert(_t('tasks.getQueueDetailFailed') + ': ' + error.message);
} }
} }
@@ -1358,7 +1362,7 @@ async function startBatchQueue() {
if (!response.ok) { if (!response.ok) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
throw new Error(result.error || '启动批量任务失败'); throw new Error(result.error || _t('tasks.startBatchQueueFailed'));
} }
// 刷新详情 // 刷新详情
@@ -1366,7 +1370,7 @@ async function startBatchQueue() {
refreshBatchQueues(); refreshBatchQueues();
} catch (error) { } catch (error) {
console.error('启动批量任务失败:', error); console.error('启动批量任务失败:', error);
alert('启动批量任务失败: ' + error.message); alert(_t('tasks.startBatchQueueFailed') + ': ' + error.message);
} }
} }
@@ -1375,7 +1379,7 @@ async function pauseBatchQueue() {
const queueId = batchQueuesState.currentQueueId; const queueId = batchQueuesState.currentQueueId;
if (!queueId) return; if (!queueId) return;
if (!confirm('确定要暂停这个批量任务队列吗?当前正在执行的任务将被停止,后续任务将保留待执行状态。')) { if (!confirm(_t('tasks.pauseQueueConfirm'))) {
return; return;
} }
@@ -1386,7 +1390,7 @@ async function pauseBatchQueue() {
if (!response.ok) { if (!response.ok) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
throw new Error(result.error || '暂停批量任务失败'); throw new Error(result.error || _t('tasks.pauseQueueFailed'));
} }
// 刷新详情 // 刷新详情
@@ -1394,7 +1398,7 @@ async function pauseBatchQueue() {
refreshBatchQueues(); refreshBatchQueues();
} catch (error) { } catch (error) {
console.error('暂停批量任务失败:', error); console.error('暂停批量任务失败:', error);
alert('暂停批量任务失败: ' + error.message); alert(_t('tasks.pauseQueueFailed') + ': ' + error.message);
} }
} }
@@ -1403,7 +1407,7 @@ async function deleteBatchQueue() {
const queueId = batchQueuesState.currentQueueId; const queueId = batchQueuesState.currentQueueId;
if (!queueId) return; if (!queueId) return;
if (!confirm('确定要删除这个批量任务队列吗?此操作不可恢复。')) { if (!confirm(_t('tasks.deleteQueueConfirm'))) {
return; return;
} }
@@ -1414,14 +1418,14 @@ async function deleteBatchQueue() {
if (!response.ok) { if (!response.ok) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
throw new Error(result.error || '删除批量任务队列失败'); throw new Error(result.error || _t('tasks.deleteQueueFailed'));
} }
closeBatchQueueDetailModal(); closeBatchQueueDetailModal();
refreshBatchQueues(); refreshBatchQueues();
} catch (error) { } catch (error) {
console.error('删除批量任务队列失败:', error); console.error('删除批量任务队列失败:', error);
alert('删除批量任务队列失败: ' + error.message); alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
} }
} }
@@ -1429,7 +1433,7 @@ async function deleteBatchQueue() {
async function deleteBatchQueueFromList(queueId) { async function deleteBatchQueueFromList(queueId) {
if (!queueId) return; if (!queueId) return;
if (!confirm('确定要删除这个批量任务队列吗?此操作不可恢复。')) { if (!confirm(_t('tasks.deleteQueueConfirm'))) {
return; return;
} }
@@ -1440,7 +1444,7 @@ async function deleteBatchQueueFromList(queueId) {
if (!response.ok) { if (!response.ok) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
throw new Error(result.error || '删除批量任务队列失败'); throw new Error(result.error || _t('tasks.deleteQueueFailed'));
} }
// 如果当前正在查看这个队列的详情,关闭详情模态框 // 如果当前正在查看这个队列的详情,关闭详情模态框
@@ -1452,7 +1456,7 @@ async function deleteBatchQueueFromList(queueId) {
refreshBatchQueues(); refreshBatchQueues();
} catch (error) { } catch (error) {
console.error('删除批量任务队列失败:', error); console.error('删除批量任务队列失败:', error);
alert('删除批量任务队列失败: ' + error.message); alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
} }
} }
@@ -1598,18 +1602,18 @@ async function saveBatchTask() {
const messageInput = document.getElementById('edit-task-message'); const messageInput = document.getElementById('edit-task-message');
if (!queueId || !taskId) { if (!queueId || !taskId) {
alert('任务信息不完整'); alert(_t('tasks.taskIncomplete'));
return; return;
} }
if (!messageInput) { if (!messageInput) {
alert('无法获取任务消息输入框'); alert(_t('tasks.cannotGetTaskMessageInput'));
return; return;
} }
const message = messageInput.value.trim(); const message = messageInput.value.trim();
if (!message) { if (!message) {
alert('任务消息不能为空'); alert(_t('tasks.taskMessageRequired'));
return; return;
} }
@@ -1624,7 +1628,7 @@ async function saveBatchTask() {
if (!response.ok) { if (!response.ok) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
throw new Error(result.error || '更新任务失败'); throw new Error(result.error || _t('tasks.updateTaskFailed'));
} }
// 关闭编辑模态框 // 关闭编辑模态框
@@ -1639,7 +1643,7 @@ async function saveBatchTask() {
refreshBatchQueues(); refreshBatchQueues();
} catch (error) { } catch (error) {
console.error('保存任务失败:', error); console.error('保存任务失败:', error);
alert('保存任务失败: ' + error.message); alert(_t('tasks.saveTaskFailed') + ': ' + error.message);
} }
} }
@@ -1647,7 +1651,7 @@ async function saveBatchTask() {
function showAddBatchTaskModal() { function showAddBatchTaskModal() {
const queueId = batchQueuesState.currentQueueId; const queueId = batchQueuesState.currentQueueId;
if (!queueId) { if (!queueId) {
alert('队列信息不存在'); alert(_t('tasks.queueInfoMissing'));
return; return;
} }
@@ -1705,18 +1709,18 @@ async function saveAddBatchTask() {
const messageInput = document.getElementById('add-task-message'); const messageInput = document.getElementById('add-task-message');
if (!queueId) { if (!queueId) {
alert('队列信息不存在'); alert(_t('tasks.queueInfoMissing'));
return; return;
} }
if (!messageInput) { if (!messageInput) {
alert('无法获取任务消息输入框'); alert(_t('tasks.cannotGetTaskMessageInput'));
return; return;
} }
const message = messageInput.value.trim(); const message = messageInput.value.trim();
if (!message) { if (!message) {
alert('任务消息不能为空'); alert(_t('tasks.taskMessageRequired'));
return; return;
} }
@@ -1731,7 +1735,7 @@ async function saveAddBatchTask() {
if (!response.ok) { if (!response.ok) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
throw new Error(result.error || '添加任务失败'); throw new Error(result.error || _t('tasks.addTaskFailed'));
} }
// 关闭添加任务模态框 // 关闭添加任务模态框
@@ -1746,7 +1750,7 @@ async function saveAddBatchTask() {
refreshBatchQueues(); refreshBatchQueues();
} catch (error) { } catch (error) {
console.error('添加任务失败:', error); console.error('添加任务失败:', error);
alert('添加任务失败: ' + error.message); alert(_t('tasks.addTaskFailed') + ': ' + error.message);
} }
} }
@@ -1778,7 +1782,7 @@ function deleteBatchTaskFromElement(button) {
? decodedMessage.substring(0, 50) + '...' ? decodedMessage.substring(0, 50) + '...'
: decodedMessage; : decodedMessage;
if (!confirm(`确定要删除这个任务吗?\n\n任务内容: ${displayMessage}\n\n此操作不可恢复。`)) { if (!confirm(_t('tasks.confirmDeleteTask', { message: displayMessage }))) {
return; return;
} }
@@ -1788,7 +1792,7 @@ function deleteBatchTaskFromElement(button) {
// 删除批量任务 // 删除批量任务
async function deleteBatchTask(queueId, taskId) { async function deleteBatchTask(queueId, taskId) {
if (!queueId || !taskId) { if (!queueId || !taskId) {
alert('任务信息不完整'); alert(_t('tasks.taskIncomplete'));
return; return;
} }
@@ -1799,7 +1803,7 @@ async function deleteBatchTask(queueId, taskId) {
if (!response.ok) { if (!response.ok) {
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
throw new Error(result.error || '删除任务失败'); throw new Error(result.error || _t('tasks.deleteTaskFailed'));
} }
// 刷新队列详情 // 刷新队列详情
@@ -1811,7 +1815,7 @@ async function deleteBatchTask(queueId, taskId) {
refreshBatchQueues(); refreshBatchQueues();
} catch (error) { } catch (error) {
console.error('删除任务失败:', error); console.error('删除任务失败:', error);
alert('删除任务失败: ' + error.message); alert(_t('tasks.deleteTaskFailed') + ': ' + error.message);
} }
} }
+419
View File
@@ -0,0 +1,419 @@
/**
* 系统设置 - 终端多标签流式输出命令历史Ctrl+L 清屏长时间可取消
*/
(function () {
var getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, attrs) {
if (type === '2d') {
attrs = (attrs && typeof attrs === 'object') ? Object.assign({ willReadFrequently: true }, attrs) : { willReadFrequently: true };
return getContext.call(this, type, attrs);
}
return getContext.apply(this, arguments);
};
var terminals = [];
var currentTabId = 1;
var inited = false;
var tabIdCounter = 1;
var PROMPT = ''; // 真实 Shell 自己输出提示符,这里不再自定义
var HISTORY_MAX = 100;
var CANCEL_AFTER_MS = 125000;
function getCurrent() {
for (var i = 0; i < terminals.length; i++) {
if (terminals[i].id === currentTabId) return terminals[i];
}
return terminals[0] || null;
}
var WELCOME_LINE = 'CyberStrikeAI 终端 - 真实 Shell 会话,直接输入命令;Ctrl+L 清屏\r\n';
function writePrompt(tab) {
// 提示符交由后端 Shell 自行输出,这里仅保留占位函数,避免旧代码报错
}
function redrawTabDisplay(t) {
if (!t || !t.term) return;
t.term.clear();
t.term.write(WELCOME_LINE);
}
function writeln(tabOrS, s) {
var t, text;
if (arguments.length === 1) { text = tabOrS; t = getCurrent(); } else { t = tabOrS; text = s; }
if (!t || !t.term) return;
if (text) t.term.writeln(text);
else t.term.writeln('');
}
function writeOutput(tab, text, isError) {
var t = tab || getCurrent();
if (!t || !t.term || !text) return;
var s = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
var lines = s.split('\n');
var prefix = isError ? '\x1b[31m' : '';
var suffix = isError ? '\x1b[0m' : '';
t.term.write(prefix);
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/\r/g, '');
t.term.writeln(line);
}
t.term.write(suffix);
}
// 从本地存储中获取当前登录 token(与 auth.js 使用的结构保持一致)
function getStoredAuthToken() {
try {
var raw = localStorage.getItem('cyberstrike-auth');
if (!raw) return null;
var o = JSON.parse(raw);
if (o && o.token) return o.token;
} catch (e) {}
return null;
}
// WebSocket 地址构造(兼容 http/https,并通过 query 传递 token 以通过后端鉴权)
function buildTerminalWSURL() {
var proto = (window.location.protocol === 'https:') ? 'wss://' : 'ws://';
var url = proto + window.location.host + '/api/terminal/ws';
var token = getStoredAuthToken();
if (token) {
url += '?token=' + encodeURIComponent(token);
}
return url;
}
function ensureTerminalWS(tab) {
if (tab.ws && (tab.ws.readyState === WebSocket.OPEN || tab.ws.readyState === WebSocket.CONNECTING)) {
return;
}
try {
var ws = new WebSocket(buildTerminalWSURL());
tab.ws = ws;
tab.running = true;
ws.onopen = function () {
if (tab.term) {
tab.term.focus();
}
};
ws.onmessage = function (ev) {
if (!tab.term) return;
// 处理二进制消息和文本消息
if (ev.data instanceof ArrayBuffer) {
var decoder = new TextDecoder('utf-8');
tab.term.write(decoder.decode(ev.data));
} else if (ev.data instanceof Blob) {
// Blob 类型,需要异步读取
var reader = new FileReader();
reader.onload = function () {
var decoder = new TextDecoder('utf-8');
tab.term.write(decoder.decode(reader.result));
};
reader.readAsArrayBuffer(ev.data);
} else {
// 字符串类型
tab.term.write(ev.data);
}
};
ws.onclose = function () {
tab.running = false;
if (tab.term) {
tab.term.writeln('\r\n\x1b[2m[会话已关闭]\x1b[0m');
}
};
ws.onerror = function () {
tab.running = false;
if (tab.term) {
tab.term.writeln('\r\n\x1b[31m[终端连接出错]\x1b[0m');
}
};
} catch (e) {
if (tab.term) {
tab.term.writeln('\r\n\x1b[31m[无法连接终端服务: ' + String(e) + ']\x1b[0m');
}
}
}
function createTerminalInContainer(container, tab) {
if (typeof Terminal === 'undefined') return null;
if (!tab.history) tab.history = [];
if (tab.historyIndex === undefined) tab.historyIndex = -1;
if (tab.cursorIndex === undefined) tab.cursorIndex = 0;
var term = new Terminal({
cursorBlink: true,
cursorStyle: 'bar',
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
lineHeight: 1.2,
scrollback: 1000,
theme: {
background: '#0d1117',
foreground: '#e6edf3',
cursor: '#58a6ff',
cursorAccent: '#0d1117',
selection: 'rgba(88, 166, 255, 0.3)',
black: '#484f58',
red: '#ff7b72',
green: '#3fb950',
yellow: '#d29922',
blue: '#58a6ff',
magenta: '#bc8cff',
cyan: '#39c5cf',
white: '#e6edf3',
brightBlack: '#6e7681',
brightRed: '#ffa198',
brightGreen: '#56d364',
brightYellow: '#e3b341',
brightBlue: '#79c0ff',
brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd',
brightWhite: '#f0f6fc'
}
});
var fitAddon = null;
if (typeof FitAddon !== 'undefined') {
var FitCtor = (FitAddon.FitAddon || FitAddon);
fitAddon = new FitCtor();
term.loadAddon(fitAddon);
}
term.open(container);
term.write(WELCOME_LINE);
container.addEventListener('click', function () {
switchTerminalTab(tab.id);
if (term) term.focus();
});
container.setAttribute('tabindex', '0');
container.title = '点击此处后输入命令';
function sendToWS(data) {
ensureTerminalWS(tab);
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
try {
tab.ws.send(data);
} catch (e) {}
}
}
term.onData(function (data) {
// Ctrl+L:本地清屏,同时把 ^L 也发给后端
if (data === '\x0c') {
term.clear();
sendToWS(data);
return;
}
sendToWS(data);
});
tab.term = term;
tab.fitAddon = fitAddon;
return term;
}
function switchTerminalTab(id) {
var prevId = currentTabId;
currentTabId = id;
document.querySelectorAll('.terminal-tab').forEach(function (el) {
el.classList.toggle('active', parseInt(el.getAttribute('data-tab-id'), 10) === id);
});
document.querySelectorAll('.terminal-pane').forEach(function (el) {
var paneId = el.getAttribute('id');
var match = paneId && paneId.match(/terminal-pane-(\d+)/);
var paneTabId = match ? parseInt(match[1], 10) : 0;
el.classList.toggle('active', paneTabId === id);
});
var t = getCurrent();
if (t && t.term) {
if (prevId !== id) {
requestAnimationFrame(function () {
if (currentTabId === id && t.term) t.term.focus();
});
} else {
t.term.focus();
}
}
}
function addTerminalTab() {
if (typeof Terminal === 'undefined') return;
tabIdCounter += 1;
var id = tabIdCounter;
var paneId = 'terminal-pane-' + id;
var containerId = 'terminal-container-' + id;
var tabsEl = document.querySelector('.terminal-tabs');
var panesEl = document.querySelector('.terminal-panes');
if (!tabsEl || !panesEl) return;
var tabDiv = document.createElement('div');
tabDiv.className = 'terminal-tab';
tabDiv.setAttribute('data-tab-id', String(id));
var label = document.createElement('span');
label.className = 'terminal-tab-label';
label.textContent = '终端 ' + id;
label.onclick = function () { switchTerminalTab(id); };
var closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'terminal-tab-close';
closeBtn.title = '关闭';
closeBtn.textContent = '×';
closeBtn.onclick = function (e) { e.stopPropagation(); removeTerminalTab(id); };
tabDiv.appendChild(label);
tabDiv.appendChild(closeBtn);
var plusBtn = tabsEl.querySelector('.terminal-tab-new');
tabsEl.insertBefore(tabDiv, plusBtn);
var paneDiv = document.createElement('div');
paneDiv.id = paneId;
paneDiv.className = 'terminal-pane';
var containerDiv = document.createElement('div');
containerDiv.id = containerId;
containerDiv.className = 'terminal-container';
paneDiv.appendChild(containerDiv);
panesEl.appendChild(paneDiv);
var tab = { id: id, paneId: paneId, containerId: containerId, lineBuffer: '', cursorIndex: 0, running: false, term: null, fitAddon: null, history: [], historyIndex: -1 };
terminals.push(tab);
createTerminalInContainer(containerDiv, tab);
switchTerminalTab(id);
updateTerminalTabCloseVisibility();
setTimeout(function () {
try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {}
}, 50);
}
function updateTerminalTabCloseVisibility() {
var tabsEl = document.querySelector('.terminal-tabs');
if (!tabsEl) return;
var tabDivs = tabsEl.querySelectorAll('.terminal-tab');
var showClose = terminals.length > 1;
for (var i = 0; i < tabDivs.length; i++) {
var btn = tabDivs[i].querySelector('.terminal-tab-close');
if (btn) btn.style.display = showClose ? '' : 'none';
}
}
function removeTerminalTab(id) {
if (terminals.length <= 1) return;
var idx = -1;
for (var i = 0; i < terminals.length; i++) { if (terminals[i].id === id) { idx = i; break; } }
if (idx < 0) return;
var deletingCurrent = (currentTabId === id);
var switchToIndex = deletingCurrent ? (idx > 0 ? idx - 1 : 0) : -1;
var tab = terminals[idx];
if (tab.term && tab.term.dispose) tab.term.dispose();
tab.term = null;
tab.fitAddon = null;
terminals.splice(idx, 1);
var tabDiv = document.querySelector('.terminal-tab[data-tab-id="' + id + '"]');
var paneDiv = document.getElementById('terminal-pane-' + id);
if (tabDiv && tabDiv.parentNode) tabDiv.parentNode.removeChild(tabDiv);
if (paneDiv && paneDiv.parentNode) paneDiv.parentNode.removeChild(paneDiv);
var curIdxBeforeRenumber = -1;
if (!deletingCurrent) {
for (var i = 0; i < terminals.length; i++) {
if (terminals[i].id === currentTabId) { curIdxBeforeRenumber = i; break; }
}
}
for (var i = 0; i < terminals.length; i++) {
var t = terminals[i];
t.id = i + 1;
t.paneId = 'terminal-pane-' + (i + 1);
t.containerId = 'terminal-container-' + (i + 1);
}
tabIdCounter = terminals.length;
if (curIdxBeforeRenumber >= 0) currentTabId = terminals[curIdxBeforeRenumber].id;
var tabsEl = document.querySelector('.terminal-tabs');
var panesEl = document.querySelector('.terminal-panes');
if (tabsEl) {
var tabDivs = tabsEl.querySelectorAll('.terminal-tab');
for (var i = 0; i < tabDivs.length; i++) {
var t = terminals[i];
tabDivs[i].setAttribute('data-tab-id', String(t.id));
var lbl = tabDivs[i].querySelector('.terminal-tab-label');
if (lbl) lbl.textContent = '终端 ' + t.id;
if (lbl) lbl.onclick = (function (tid) { return function () { switchTerminalTab(tid); }; })(t.id);
var cb = tabDivs[i].querySelector('.terminal-tab-close');
if (cb) cb.onclick = (function (tid) { return function (e) { e.stopPropagation(); removeTerminalTab(tid); }; })(t.id);
}
}
if (panesEl) {
var paneDivs = panesEl.querySelectorAll('.terminal-pane');
for (var i = 0; i < paneDivs.length; i++) {
var t = terminals[i];
paneDivs[i].id = t.paneId;
var cont = paneDivs[i].querySelector('.terminal-container');
if (cont) cont.id = t.containerId;
}
}
updateTerminalTabCloseVisibility();
if (deletingCurrent && terminals.length > 0) {
currentTabId = terminals[switchToIndex].id;
switchTerminalTab(currentTabId);
}
}
function initTerminal() {
var pane1 = document.getElementById('terminal-pane-1');
var container1 = document.getElementById('terminal-container-1');
if (!pane1 || !container1) return;
if (inited) {
var t = getCurrent();
if (t && t.term) t.term.focus();
terminals.forEach(function (tab) { try { if (tab.fitAddon) tab.fitAddon.fit(); } catch (e) {} });
return;
}
inited = true;
if (typeof Terminal === 'undefined') {
container1.innerHTML = '<p class="terminal-error">未加载 xterm.js,请刷新页面或检查网络。</p>';
return;
}
currentTabId = 1;
var tab = { id: 1, paneId: 'terminal-pane-1', containerId: 'terminal-container-1', lineBuffer: '', cursorIndex: 0, running: false, term: null, fitAddon: null, history: [], historyIndex: -1 };
terminals.push(tab);
createTerminalInContainer(container1, tab);
updateTerminalTabCloseVisibility();
setTimeout(function () {
try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {}
}, 100);
var resizeTimer;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
terminals.forEach(function (t) { try { if (t.fitAddon) t.fitAddon.fit(); } catch (e) {} });
}, 150);
});
}
function terminalClear() {
var t = getCurrent();
if (!t || !t.term) return;
t.term.clear();
t.lineBuffer = '';
if (t.cursorIndex !== undefined) t.cursorIndex = 0;
writePrompt(t);
t.term.focus();
}
window.initTerminal = initTerminal;
window.terminalClear = terminalClear;
window.switchTerminalTab = switchTerminalTab;
window.addTerminalTab = addTerminalTab;
window.removeTerminalTab = removeTerminalTab;
})();
+11 -5
View File
@@ -156,14 +156,20 @@ async function loadVulnerabilities(page = null) {
function renderVulnerabilities(vulnerabilities) { function renderVulnerabilities(vulnerabilities) {
const listContainer = document.getElementById('vulnerabilities-list'); const listContainer = document.getElementById('vulnerabilities-list');
// 处理空值情况 // 处理空值情况(使用 data-i18n 以便语言切换时自动更新)
if (!vulnerabilities || !Array.isArray(vulnerabilities)) { if (!vulnerabilities || !Array.isArray(vulnerabilities)) {
listContainer.innerHTML = '<div class="empty-state">暂无漏洞记录</div>'; listContainer.innerHTML = '<div class="empty-state" data-i18n="vulnerabilityPage.noRecords">暂无漏洞记录</div>';
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(listContainer);
}
return; return;
} }
if (vulnerabilities.length === 0) { if (vulnerabilities.length === 0) {
listContainer.innerHTML = '<div class="empty-state">暂无漏洞记录</div>'; listContainer.innerHTML = '<div class="empty-state" data-i18n="vulnerabilityPage.noRecords">暂无漏洞记录</div>';
if (typeof window.applyTranslations === 'function') {
window.applyTranslations(listContainer);
}
// 清空分页信息 // 清空分页信息
const paginationContainer = document.getElementById('vulnerability-pagination'); const paginationContainer = document.getElementById('vulnerability-pagination');
if (paginationContainer) { if (paginationContainer) {
@@ -328,7 +334,7 @@ async function changeVulnerabilityPageSize() {
// 显示添加漏洞模态框 // 显示添加漏洞模态框
function showAddVulnerabilityModal() { function showAddVulnerabilityModal() {
currentVulnerabilityId = null; currentVulnerabilityId = null;
document.getElementById('vulnerability-modal-title').textContent = '添加漏洞'; document.getElementById('vulnerability-modal-title').textContent = (typeof window.t === 'function' ? window.t('vulnerability.addVuln') : '添加漏洞');
// 清空表单 // 清空表单
document.getElementById('vulnerability-conversation-id').value = ''; document.getElementById('vulnerability-conversation-id').value = '';
@@ -353,7 +359,7 @@ async function editVulnerability(id) {
const vuln = await response.json(); const vuln = await response.json();
currentVulnerabilityId = id; currentVulnerabilityId = id;
document.getElementById('vulnerability-modal-title').textContent = '编辑漏洞'; document.getElementById('vulnerability-modal-title').textContent = (typeof window.t === 'function' ? window.t('vulnerability.editVuln') : '编辑漏洞');
// 填充表单 // 填充表单
document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || ''; document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || '';
+88 -19
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API 文档 - CyberStrikeAI</title> <title data-i18n="apiDocs.pageTitle">API 文档 - CyberStrikeAI</title>
<link rel="icon" type="image/png" href="/static/logo.png"> <link rel="icon" type="image/png" href="/static/logo.png">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<style> <style>
@@ -22,6 +22,7 @@
} }
.api-docs-header { .api-docs-header {
position: relative;
margin-bottom: 32px; margin-bottom: 32px;
padding-bottom: 24px; padding-bottom: 24px;
border-bottom: 2px solid var(--border-color); border-bottom: 2px solid var(--border-color);
@@ -516,20 +517,36 @@
min-width: 150px; min-width: 150px;
} }
/* 参数名、类型、必需、示例列不换行 */ /* 参数名、类型、必需列不换行 */
.api-params-table th:nth-child(1), .api-params-table th:nth-child(1),
.api-params-table td:nth-child(1), .api-params-table td:nth-child(1),
.api-params-table th:nth-child(2), .api-params-table th:nth-child(2),
.api-params-table td:nth-child(2), .api-params-table td:nth-child(2),
.api-params-table th:nth-child(4), .api-params-table th:nth-child(4),
.api-params-table td:nth-child(4), .api-params-table td:nth-child(4) {
.api-params-table th:nth-child(5),
.api-params-table td:nth-child(5) {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* 示例列允许换行,完整显示 */
.api-params-table th:nth-child(5),
.api-params-table td:nth-child(5) {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
overflow: visible;
}
/* 确保示例列中的code标签也能完整显示 */
.api-params-table td:nth-child(5) code {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
display: inline-block;
max-width: 100%;
}
/* 描述列允许换行,但保持水平方向 */ /* 描述列允许换行,但保持水平方向 */
.api-params-table th:nth-child(3), .api-params-table th:nth-child(3),
.api-params-table td:nth-child(3) { .api-params-table td:nth-child(3) {
@@ -674,6 +691,44 @@
background: var(--bg-secondary); background: var(--bg-secondary);
} }
/* 复制curl按钮 - 黄色主题 */
.api-test-btn.copy-curl {
background: #ffc107;
color: white;
border: 1px solid #ffb300;
}
.api-test-btn.copy-curl:hover {
background: #ffb300;
border-color: #ffa000;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(255, 193, 7, 0.3);
}
.api-test-btn.copy-curl:active {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(255, 193, 7, 0.2);
}
/* 清除结果按钮 - 红色/橙色主题 */
.api-test-btn.clear-result {
background: #ff6b6b;
color: white;
border: 1px solid #ff5252;
}
.api-test-btn.clear-result:hover {
background: #ff5252;
border-color: #ff4444;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
}
.api-test-btn.clear-result:active {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(255, 107, 107, 0.2);
}
.api-test-result { .api-test-result {
margin-top: 20px; margin-top: 20px;
padding: 16px; padding: 16px;
@@ -779,9 +834,21 @@
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
API 文档 <span data-i18n="apiDocs.title">API 文档</span>
</h1> </h1>
<p>CyberStrikeAI 平台 API 接口文档,支持在线测试</p> <p data-i18n="apiDocs.subtitle">CyberStrikeAI 平台 API 接口文档,支持在线测试</p>
<div class="api-docs-lang-switcher" style="position: absolute; top: 24px; right: 24px;">
<div class="lang-switcher">
<button type="button" class="btn-secondary lang-switcher-btn" onclick="typeof toggleLangDropdown === 'function' && toggleLangDropdown()" title="界面语言">
<span class="lang-switcher-icon">🌐</span>
<span id="current-lang-label">中文</span>
</button>
<div id="lang-dropdown" class="lang-dropdown" style="display: none;">
<div class="lang-option" data-lang="zh-CN" onclick="typeof onLanguageSelect === 'function' && onLanguageSelect('zh-CN')">中文</div>
<div class="lang-option" data-lang="en-US" onclick="typeof onLanguageSelect === 'function' && onLanguageSelect('en-US')">English</div>
</div>
</div>
</div>
</div> </div>
<div id="auth-info-section" class="auth-info-section" style="display: none;"> <div id="auth-info-section" class="auth-info-section" style="display: none;">
@@ -792,17 +859,17 @@
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/> <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/> <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg> </svg>
<h3 style="margin: 0; font-size: 1rem; font-weight: 600; color: var(--text-primary);">API 认证说明</h3> <h3 style="margin: 0; font-size: 1rem; font-weight: 600; color: var(--text-primary);" data-i18n="apiDocs.authTitle">API 认证说明</h3>
</div> </div>
<svg id="auth-info-arrow" class="auth-info-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="transition: transform 0.2s ease;"> <svg id="auth-info-arrow" class="auth-info-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="transition: transform 0.2s ease;">
<polyline points="6 9 12 15 18 9"/> <polyline points="6 9 12 15 18 9"/>
</svg> </svg>
</div> </div>
<div id="auth-info-body" class="auth-info-body" style="display: none; color: var(--text-secondary); font-size: 0.875rem; line-height: 1.6; margin-top: 16px;"> <div id="auth-info-body" class="auth-info-body" style="display: none; color: var(--text-secondary); font-size: 0.875rem; line-height: 1.6; margin-top: 16px;">
<p style="margin: 0 0 12px 0;"><strong>所有 API 接口都需要 Token 认证。</strong></p> <p style="margin: 0 0 12px 0;"><strong data-i18n="apiDocs.authAllNeedToken">所有 API 接口都需要 Token 认证。</strong></p>
<div style="background: var(--bg-secondary); padding: 12px; border-radius: 6px; margin-bottom: 12px;"> <div style="background: var(--bg-secondary); padding: 12px; border-radius: 6px; margin-bottom: 12px;">
<p style="margin: 0 0 8px 0; font-weight: 500;">1. 获取 Token</p> <p style="margin: 0 0 8px 0; font-weight: 500;" data-i18n="apiDocs.authGetToken">1. 获取 Token</p>
<p style="margin: 0 0 8px 0;">在前端页面登录后,Token 会自动保存。您也可以通过以下方式获取:</p> <p style="margin: 0 0 8px 0;" data-i18n="apiDocs.authGetTokenDesc">在前端页面登录后,Token 会自动保存。您也可以通过以下方式获取:</p>
<pre style="background: var(--bg-primary); padding: 8px; border-radius: 4px; margin: 8px 0; overflow-x: auto; font-size: 0.8125rem;"><code>POST /api/auth/login <pre style="background: var(--bg-primary); padding: 8px; border-radius: 4px; margin: 8px 0; overflow-x: auto; font-size: 0.8125rem;"><code>POST /api/auth/login
Content-Type: application/json Content-Type: application/json
@@ -817,13 +884,13 @@ Content-Type: application/json
}</code></pre> }</code></pre>
</div> </div>
<div style="background: var(--bg-secondary); padding: 12px; border-radius: 6px; margin-bottom: 12px;"> <div style="background: var(--bg-secondary); padding: 12px; border-radius: 6px; margin-bottom: 12px;">
<p style="margin: 0 0 8px 0; font-weight: 500;">2. 使用 Token</p> <p style="margin: 0 0 8px 0; font-weight: 500;" data-i18n="apiDocs.authUseToken">2. 使用 Token</p>
<p style="margin: 0 0 8px 0;">在请求头中添加 Authorization 字段:</p> <p style="margin: 0 0 8px 0;" data-i18n="apiDocs.authUseTokenDesc">在请求头中添加 Authorization 字段:</p>
<pre style="background: var(--bg-primary); padding: 8px; border-radius: 4px; margin: 8px 0; overflow-x: auto; font-size: 0.8125rem;"><code>Authorization: Bearer your_token_here</code></pre> <pre style="background: var(--bg-primary); padding: 8px; border-radius: 4px; margin: 8px 0; overflow-x: auto; font-size: 0.8125rem;"><code>Authorization: Bearer your_token_here</code></pre>
<p style="margin: 8px 0 0 0; font-size: 0.8125rem; color: var(--text-muted);">💡 提示:本页面会自动使用您已登录的 Token,无需手动填写。</p> <p style="margin: 8px 0 0 0; font-size: 0.8125rem; color: var(--text-muted);" data-i18n="apiDocs.authTip">💡 提示:本页面会自动使用您已登录的 Token,无需手动填写。</p>
</div> </div>
<div id="token-status" style="display: none; background: rgba(0, 102, 255, 0.1); padding: 8px 12px; border-radius: 6px; border-left: 3px solid var(--accent-color);"> <div id="token-status" style="display: none; background: rgba(0, 102, 255, 0.1); padding: 8px 12px; border-radius: 6px; border-left: 3px solid var(--accent-color);">
<p style="margin: 0; font-size: 0.8125rem; color: var(--accent-color);"> <p style="margin: 0; font-size: 0.8125rem; color: var(--accent-color);" data-i18n="apiDocs.tokenDetected">
<strong>✓ 已检测到 Token</strong> - 您可以直接测试 API 接口 <strong>✓ 已检测到 Token</strong> - 您可以直接测试 API 接口
</p> </p>
</div> </div>
@@ -845,10 +912,10 @@ Content-Type: application/json
<div class="api-docs-content"> <div class="api-docs-content">
<div class="api-docs-sidebar"> <div class="api-docs-sidebar">
<h3>API 分组</h3> <h3 data-i18n="apiDocs.sidebarGroupTitle">API 分组</h3>
<ul class="api-group-list" id="api-group-list"> <ul class="api-group-list" id="api-group-list">
<li class="api-group-item"> <li class="api-group-item">
<a href="#" class="api-group-link active" data-group="all">全部接口</a> <a href="#" class="api-group-link active" data-group="all" data-i18n="apiDocs.allApis">全部接口</a>
</li> </li>
</ul> </ul>
</div> </div>
@@ -860,13 +927,15 @@ Content-Type: application/json
<line x1="12" y1="8" x2="12" y2="12"/> <line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/> <line x1="12" y1="16" x2="12.01" y2="16"/>
</svg> </svg>
<h3>加载中...</h3> <h3 data-i18n="apiDocs.loading">加载中...</h3>
<p>正在加载 API 文档</p> <p data-i18n="apiDocs.loadingDesc">正在加载 API 文档</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/i18next@23.11.5/i18next.min.js"></script>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/api-docs.js"></script> <script src="/static/js/api-docs.js"></script>
</body> </body>
</html> </html>
+960 -410
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff