mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 13:43:31 +02:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 797b10b176 | |||
| 0809be60fa | |||
| 62a83f6271 | |||
| b4da3e5d33 | |||
| 4b1023ff6c | |||
| 82ca5225ae | |||
| 5e8fef0ad4 | |||
| 226f9b79e2 | |||
| 7222466cff | |||
| 1630c2b2c4 | |||
| f7ffa1d5d3 | |||
| e4cd68df41 | |||
| d24f797552 | |||
| 0a89ac31c3 | |||
| 379fc8767d | |||
| 8bdab678fa | |||
| cc555af8dd | |||
| 643e0e7adf | |||
| eb27eaff7d | |||
| fc542a48f3 | |||
| dd7d15845c | |||
| ee9559e074 | |||
| 872e570518 | |||
| a5ffafba77 | |||
| 3da7f77e1c | |||
| 26ad9646be | |||
| 959a97870b | |||
| c8bbfcd171 | |||
| 5f2862b629 | |||
| ee6c4b6f19 | |||
| 55b8decbaa | |||
| 1222adc485 | |||
| 38972bf93b | |||
| 127a5dd5c3 | |||
| f5f73d41c0 | |||
| 9811209002 | |||
| f44bb42842 | |||
| d2e751e3d3 | |||
| a5c285c8f3 | |||
| 98938aef00 | |||
| 71f6a97a90 | |||
| 2fce15f82a | |||
| 52b70d8b16 | |||
| 5b3709b9ad | |||
| 639f65602d | |||
| 52b6c3fe1b | |||
| f26ee8e6e7 | |||
| 379486d36c | |||
| 317461e259 | |||
| b7e724407b | |||
| e904dd3481 | |||
| 7b1487383f | |||
| 8a2177ffab | |||
| 3a7bbfbb88 | |||
| 7c01641de9 | |||
| 1c1086eea4 | |||
| 8f4f40f894 | |||
| 7f16ba706a | |||
| 0b950f95db | |||
| d36984a1c1 | |||
| da2109a970 | |||
| 1866aa8089 | |||
| 5af06e539d | |||
| 7493e70686 |
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: 🐛 Bug / 异常问题反馈
|
||||
about: 报告一个 Bug 或异常问题
|
||||
title: '[BUG] '
|
||||
labels: ['bug', '待确认']
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 📋 问题描述
|
||||
<!-- 请清晰、简洁地描述遇到的问题 -->
|
||||
|
||||
|
||||
## 🔄 复现步骤
|
||||
<!-- 请详细描述如何复现这个问题 -->
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
|
||||
## ✅ 期望行为
|
||||
<!-- 描述你期望的正确行为是什么 -->
|
||||
|
||||
|
||||
## ❌ 实际行为
|
||||
<!-- 描述实际发生了什么 -->
|
||||
|
||||
|
||||
## 📸 截图/录屏
|
||||
<!--
|
||||
⚠️ 重要:请提供完整的截图或录屏,确保包含:
|
||||
- 完整的错误信息
|
||||
- 相关的界面元素
|
||||
- 浏览器控制台错误(如有)
|
||||
- 终端输出(如有)
|
||||
|
||||
如果截图不完整,issue 可能会被关闭。
|
||||
-->
|
||||
|
||||
<!-- 请在此处拖拽或粘贴截图 -->
|
||||
|
||||
|
||||
## 📝 报错日志(脱敏后)
|
||||
<!--
|
||||
⚠️ 重要:请提供完整的、脱敏后的报错日志。
|
||||
|
||||
脱敏要求:
|
||||
- 移除所有敏感信息(API Key、密码、Token、真实IP地址、域名等)
|
||||
- 使用占位符替换,如:`sk-xxx`、`password: ***`、`192.168.x.x`、`example.com`
|
||||
- 保留完整的错误堆栈信息
|
||||
- 保留时间戳和日志级别
|
||||
|
||||
请从以下位置收集日志:
|
||||
1. MCP状态监控 页面
|
||||
2. 服务器终端输出
|
||||
3. 日志文件(如果配置了文件输出)
|
||||
4. 浏览器控制台(F12 → Console)
|
||||
-->
|
||||
|
||||
```
|
||||
请在此处粘贴脱敏后的完整报错日志
|
||||
```
|
||||
|
||||
|
||||
## ✅ 检查清单
|
||||
<!-- 提交前请确认以下项目 -->
|
||||
|
||||
- [ ] 我已阅读并理解项目的 Issue 规范
|
||||
- [ ] 我已提供完整的、脱敏后的报错日志
|
||||
- [ ] 我已提供完整的截图(如适用)
|
||||
- [ ] 我已提供详细的复现步骤
|
||||
- [ ] 我已填写所有必要的环境信息
|
||||
- [ ] 我已脱敏所有敏感信息(API Key、密码、IP 等)
|
||||
- [ ] 我已确认这不是重复的 issue
|
||||
|
||||
---
|
||||
|
||||
**注意**:如果缺少必要的日志或截图,此 issue 可能会被标记为 `需要更多信息` 或直接关闭。请确保提供完整的信息以便我们能够快速定位和解决问题。
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
name: ✨ 功能优化建议
|
||||
about: 提出新功能或优化建议
|
||||
title: '[FEATURE] '
|
||||
labels: ['enhancement', '待讨论']
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## 💡 功能描述
|
||||
<!-- 请清晰、简洁地描述你希望添加或优化的功能 -->
|
||||
|
||||
|
||||
## 🎯 使用场景
|
||||
<!-- 描述这个功能的使用场景,解决什么问题 -->
|
||||
<!-- 例如:在什么情况下会用到这个功能?它如何改善用户体验? -->
|
||||
|
||||
|
||||
## 🔄 当前行为
|
||||
<!-- 描述当前系统是如何处理相关需求的,或者为什么需要这个功能 -->
|
||||
|
||||
|
||||
## ✨ 期望行为
|
||||
<!-- 详细描述你期望的新功能或优化后的行为 -->
|
||||
|
||||
|
||||
## 📸 参考示例(如有)
|
||||
<!--
|
||||
如果有其他项目的类似功能实现,可以在此提供截图或链接作为参考
|
||||
⚠️ 请确保截图完整,包含所有相关界面元素
|
||||
-->
|
||||
|
||||
<!-- 请在此处拖拽或粘贴参考截图 -->
|
||||
|
||||
|
||||
## 🛠️ 实现建议(可选)
|
||||
<!-- 如果你有具体的实现思路或技术建议,可以在此描述 -->
|
||||
|
||||
|
||||
## 📊 优先级评估
|
||||
<!-- 请选择你认为的优先级 -->
|
||||
- [ ] 🔴 高优先级(严重影响使用体验或功能缺失)
|
||||
- [ ] 🟡 中优先级(能显著改善体验)
|
||||
- [ ] 🟢 低优先级(锦上添花的功能)
|
||||
|
||||
## 🔍 相关功能
|
||||
<!-- 这个功能是否与现有功能相关? -->
|
||||
<!-- 例如:是否与工具管理、攻击链分析、知识库等功能相关? -->
|
||||
|
||||
|
||||
## 📝 额外信息
|
||||
<!-- 任何其他有助于理解需求的信息 -->
|
||||
- 是否已有替代方案?
|
||||
- 这个功能是否会影响现有功能?
|
||||
- 是否有相关的其他 issue 或讨论?
|
||||
|
||||
## ✅ 检查清单
|
||||
<!-- 提交前请确认以下项目 -->
|
||||
|
||||
- [ ] 我已清晰描述了功能需求和使用场景
|
||||
- [ ] 我已提供完整的参考截图(如有)
|
||||
- [ ] 我已评估了功能的优先级
|
||||
- [ ] 我已确认这不是重复的 issue
|
||||
- [ ] 我已考虑了对现有功能的影响
|
||||
|
||||
---
|
||||
|
||||
**注意**:请提供尽可能详细的信息,包括使用场景、参考示例等,这将有助于我们更好地理解和实现你的需求。
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2025 Ed1s0nZ
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -65,6 +65,14 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
||||
<img src="./images/role-management.png" alt="Role Management" width="100%">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center">
|
||||
<strong>WebShell Management</strong><br/>
|
||||
<img src="./images/webshell-management.png" alt="WebShell Management" width="100%">
|
||||
</td>
|
||||
<td width="33.33%" align="center"></td>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
@@ -84,6 +92,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
||||
- 🎭 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
|
||||
- 📱 **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)
|
||||
- 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter.
|
||||
|
||||
## Tool Overview
|
||||
|
||||
@@ -169,6 +178,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
- **Conversation groups** – Organize conversations into groups, pin important groups, rename or delete groups via context menu.
|
||||
- **Vulnerability management** – Create, update, and track vulnerabilities discovered during testing. Filter by severity (critical/high/medium/low/info), status (open/confirmed/fixed/false_positive), and conversation. View statistics and export findings.
|
||||
- **Batch task management** – Create task queues with multiple tasks, add or edit tasks before execution, and run them sequentially. Each task executes as a separate conversation, with status tracking (pending/running/completed/failed/cancelled) and full execution history.
|
||||
- **WebShell management** – Add and manage WebShell connections (PHP/ASP/ASPX/JSP or custom). Use the virtual terminal to run commands, the file manager to list, read, edit, upload, and delete files, and the AI assistant tab to drive scripted tests with per-connection conversation history. Connections are stored in SQLite; supports GET/POST and configurable command parameter (e.g. IceSword/AntSword style).
|
||||
- **Settings** – Tweak provider keys, MCP enablement, tool toggles, and agent iteration limits.
|
||||
|
||||
### Built-in Safeguards
|
||||
@@ -235,6 +245,14 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
- The web UI renders the chain as an interactive graph with severity scoring and step replay.
|
||||
- Export the chain or raw findings to external reporting pipelines.
|
||||
|
||||
### WebShell Management
|
||||
- **Connections** – From the Web UI, go to **WebShell Management** to add, edit, or delete WebShell connections. Each connection stores: Shell URL, password/key, shell type (PHP, ASP, ASPX, JSP, Custom), request method (GET/POST), command parameter name (default `cmd`), and an optional remark; all records persist in SQLite and are compatible with common clients such as IceSword and AntSword.
|
||||
- **Virtual terminal** – After selecting a connection, use the **Virtual terminal** tab to run arbitrary commands with history and quick commands (whoami/id/ls/pwd etc.). Output is streamed in the browser, and Ctrl+L clears the screen.
|
||||
- **File manager** – Use the **File manager** tab to list directories, read or edit files, delete files, create folders/files, upload files (including chunked uploads for large files), rename paths, and download selected files. Path navigation supports breadcrumbs, parent directory jumps, and name filtering.
|
||||
- **AI assistant** – Use the **AI assistant** tab to chat with an agent that understands the current WebShell connection, automatically runs tools and shell commands, and maintains per-connection conversation history with a sidebar of previous sessions.
|
||||
- **Connectivity test** – Use **Test connectivity** to verify that the shell URL, password, and command parameter are correct before running commands (sends a lightweight `echo 1` check).
|
||||
- **Persistence** – All WebShell connections and AI conversations are stored in SQLite (same database as conversations), so they persist across restarts.
|
||||
|
||||
### MCP Everywhere
|
||||
- **Web mode** – ships with HTTP MCP server automatically consumed by the UI.
|
||||
- **MCP stdio mode** – `go run cmd/mcp-stdio/main.go` exposes the agent to Cursor/CLI.
|
||||
@@ -262,21 +280,33 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
```
|
||||
Replace the paths with your local locations; Cursor will launch the stdio server automatically.
|
||||
|
||||
#### MCP HTTP quick start
|
||||
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).
|
||||
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`.
|
||||
4. Prefer committing the setup via `.cursor/mcp.json` so teammates can reuse it:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cyberstrike-ai-http": {
|
||||
"transport": "http",
|
||||
"url": "http://127.0.0.1:8081/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
#### MCP HTTP quick start (Cursor / Claude Code)
|
||||
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.
|
||||
|
||||
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:
|
||||
- `mcp.auth_header` – header name (e.g. `X-MCP-Token`);
|
||||
- `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`).
|
||||
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.
|
||||
4. **Use in Cursor or Claude Code**:
|
||||
- **Cursor**: Paste the block into `~/.cursor/mcp.json` (or your project’s `.cursor/mcp.json`) under `mcpServers`, or merge it into your existing `mcpServers`.
|
||||
- **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)
|
||||
CyberStrikeAI supports connecting to external MCP servers via three transport modes:
|
||||
@@ -377,6 +407,7 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
|
||||
- **Role APIs** – manage security testing roles via `/api/roles` endpoints: `GET /api/roles` (list all roles), `GET /api/roles/:name` (get role), `POST /api/roles` (create role), `PUT /api/roles/:name` (update role), `DELETE /api/roles/:name` (delete role). Roles are stored as YAML files in the `roles/` directory and support hot-reload.
|
||||
- **Vulnerability APIs** – manage vulnerabilities via `/api/vulnerabilities` endpoints: `GET /api/vulnerabilities` (list with filters), `POST /api/vulnerabilities` (create), `GET /api/vulnerabilities/:id` (get), `PUT /api/vulnerabilities/:id` (update), `DELETE /api/vulnerabilities/:id` (delete), `GET /api/vulnerabilities/stats` (statistics).
|
||||
- **Batch Task APIs** – manage batch task queues via `/api/batch-tasks` endpoints: `POST /api/batch-tasks` (create queue), `GET /api/batch-tasks` (list queues), `GET /api/batch-tasks/:queueId` (get queue), `POST /api/batch-tasks/:queueId/start` (start execution), `POST /api/batch-tasks/:queueId/cancel` (cancel), `DELETE /api/batch-tasks/:queueId` (delete), `POST /api/batch-tasks/:queueId/tasks` (add task), `PUT /api/batch-tasks/:queueId/tasks/:taskId` (update task), `DELETE /api/batch-tasks/:queueId/tasks/:taskId` (delete task). Tasks execute sequentially, each creating a separate conversation with full status tracking.
|
||||
- **WebShell APIs** – manage WebShell connections and execute commands via `/api/webshell/connections` (GET list, POST create, PUT update, DELETE delete) and `/api/webshell/exec` (command execution), `/api/webshell/fileop` (list/read/write/delete files).
|
||||
- **Task control** – pause/resume/stop long scans, re-run steps with new params, or stream transcripts.
|
||||
- **Audit & security** – rotate passwords via `/api/auth/change-password`, enforce short-lived sessions, and restrict MCP ports at the network layer when exposing the service.
|
||||
|
||||
@@ -396,6 +427,8 @@ mcp:
|
||||
enabled: true
|
||||
host: "0.0.0.0"
|
||||
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:
|
||||
api_key: "sk-xxx"
|
||||
base_url: "https://api.deepseek.com/v1"
|
||||
@@ -518,6 +551,13 @@ CyberStrikeAI has joined [404Starlink](https://github.com/knownsec/404StarLink)
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
CyberStrikeAI is licensed under the Apache License 2.0.
|
||||
See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
+55
-15
@@ -64,6 +64,14 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
||||
<img src="./images/role-management.png" alt="角色管理" width="100%">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center">
|
||||
<strong>WebShell 管理</strong><br/>
|
||||
<img src="./images/webshell-management.png" alt="WebShell 管理" width="100%">
|
||||
</td>
|
||||
<td width="33.33%" align="center"></td>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
@@ -83,6 +91,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
||||
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
|
||||
- 🎯 Skills 技能系统:20+ 预设安全测试技能(SQL 注入、XSS、API 安全等),可附加到角色或由 AI 按需调用
|
||||
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md))
|
||||
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
|
||||
|
||||
## 工具概览
|
||||
|
||||
@@ -168,6 +177,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
- **对话分组**:将对话按项目或主题组织到不同分组,支持置顶、重命名、删除等操作,所有数据持久化存储。
|
||||
- **漏洞管理**:在测试过程中创建、更新和跟踪发现的漏洞。支持按严重程度(严重/高/中/低/信息)、状态(待确认/已确认/已修复/误报)和对话进行过滤,查看统计信息并导出发现。
|
||||
- **批量任务管理**:创建任务队列,批量添加多个任务,执行前可编辑或删除任务,然后依次顺序执行。每个任务会作为独立对话执行,支持完整的状态跟踪(待执行/执行中/已完成/失败/已取消)和执行历史。
|
||||
- **WebShell 管理**:添加并管理 WebShell 连接(PHP/ASP/ASPX/JSP 或自定义类型)。使用虚拟终端执行命令(带命令历史与快捷命令),使用文件管理浏览、读取、编辑、上传与删除目标文件,并支持按路径导航和名称过滤。连接信息持久化存储于 SQLite,支持 GET/POST 及可配置命令参数(兼容冰蝎/蚁剑等)。
|
||||
- **可视化配置**:在界面中切换模型、启停工具、设置迭代次数等。
|
||||
|
||||
### 默认安全措施
|
||||
@@ -233,6 +243,14 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
- 智能体解析每次对话,抽取目标、工具、漏洞与因果关系。
|
||||
- Web 端可交互式查看链路节点、风险级别及时间轴,支持导出报告。
|
||||
|
||||
### WebShell 管理
|
||||
- **连接管理**:在 Web 界面进入 **WebShell 管理**,可添加、编辑或删除 WebShell 连接。每条连接包含:Shell 地址、密码/密钥、Shell 类型(PHP/ASP/ASPX/JSP/自定义)、请求方式(GET/POST)、命令参数名(默认 `cmd`)、备注等信息,并持久化存储在 SQLite,兼容冰蝎、蚁剑等常见客户端。
|
||||
- **虚拟终端**:选择连接后,在 **虚拟终端** 标签页中执行任意命令,支持命令历史与常用快捷命令(whoami/id/ls/pwd 等),输出在浏览器中实时显示,支持 Ctrl+L 清屏。
|
||||
- **文件管理**:在 **文件管理** 标签页中可列出目录、读取/编辑文件、删除文件、新建文件/目录、上传文件(大文件分片上传)、重命名路径以及下载勾选文件,并支持面包屑导航与名称过滤。
|
||||
- **AI 助手**:在 **AI 助手** 标签页中与智能体对话,由系统自动结合当前 WebShell 连接执行工具与命令,侧边栏展示该连接下的所有历史会话,支持多轮追踪与查看。
|
||||
- **连通性测试**:使用 **测试连通性** 可在执行命令前通过一次 `echo 1` 调用校验 Shell 地址、密码与命令参数是否正确。
|
||||
- **持久化**:所有 WebShell 连接与相关 AI 会话均保存在 SQLite(与对话共用数据库),服务重启后仍可继续使用。
|
||||
|
||||
### MCP 全场景
|
||||
- **Web 模式**:自带 HTTP MCP 服务供前端调用。
|
||||
- **MCP stdio 模式**:`go run cmd/mcp-stdio/main.go` 可接入 Cursor/命令行。
|
||||
@@ -260,21 +278,33 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
```
|
||||
将路径替换成你本地的实际地址,Cursor 会自动启动 stdio 版本的 MCP。
|
||||
|
||||
#### MCP HTTP 快速集成
|
||||
1. 确认 `config.yaml` 中 `mcp.enabled: true`,按照需要调整 `mcp.host` / `mcp.port`(本地建议 `127.0.0.1:8081`)。
|
||||
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`。
|
||||
4. 也可以在项目根目录创建 `.cursor/mcp.json` 以便团队共享:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cyberstrike-ai-http": {
|
||||
"transport": "http",
|
||||
"url": "http://127.0.0.1:8081/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
#### MCP HTTP 快速集成(Cursor / Claude Code)
|
||||
HTTP MCP 服务在独立端口(默认 `8081`)运行,支持 **Header 鉴权**:仅携带正确 header 的客户端可调用工具。
|
||||
|
||||
1. **在配置中启用 MCP** – 在 `config.yaml` 中设置 `mcp.enabled: true`,并按需设置 `mcp.host` / `mcp.port`。若需鉴权(端口对外暴露时建议开启),可设置:
|
||||
- `mcp.auth_header`:鉴权用的 header 名(如 `X-MCP-Token`);
|
||||
- `mcp.auth_header_value`:鉴权密钥。**留空**时,首次启动会自动生成随机密钥并写回配置文件。
|
||||
2. **启动服务** – 执行 `./run.sh` 或 `go run cmd/server/main.go`。MCP 端点为 `http://<host>:<port>/mcp`(例如 `http://localhost:8081/mcp`)。
|
||||
3. **从终端复制 JSON** – 启用 MCP 后,启动时会在终端打印一段 **可直接复制的 JSON**。若 `auth_header_value` 留空,会自动生成并写入配置,打印内容中会包含 URL 与 headers。
|
||||
4. **在 Cursor 或 Claude Code 中使用**:
|
||||
- **Cursor**:将整段 JSON 粘贴到 `~/.cursor/mcp.json` 或项目下的 `.cursor/mcp.json` 的 `mcpServers` 中(或合并进现有 `mcpServers`)。
|
||||
- **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)
|
||||
CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
|
||||
@@ -376,6 +406,7 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
|
||||
- **角色管理 API**:通过 `/api/roles` 端点管理安全测试角色:`GET /api/roles`(列表)、`GET /api/roles/:name`(获取角色)、`POST /api/roles`(创建角色)、`PUT /api/roles/:name`(更新角色)、`DELETE /api/roles/:name`(删除角色)。角色以 YAML 文件形式存储在 `roles/` 目录,支持热加载。
|
||||
- **漏洞管理 API**:通过 `/api/vulnerabilities` 端点管理漏洞:`GET /api/vulnerabilities`(列表,支持过滤)、`POST /api/vulnerabilities`(创建)、`GET /api/vulnerabilities/:id`(获取)、`PUT /api/vulnerabilities/:id`(更新)、`DELETE /api/vulnerabilities/:id`(删除)、`GET /api/vulnerabilities/stats`(统计)。
|
||||
- **批量任务 API**:通过 `/api/batch-tasks` 端点管理批量任务队列:`POST /api/batch-tasks`(创建队列)、`GET /api/batch-tasks`(列表)、`GET /api/batch-tasks/:queueId`(获取队列)、`POST /api/batch-tasks/:queueId/start`(开始执行)、`POST /api/batch-tasks/:queueId/cancel`(取消)、`DELETE /api/batch-tasks/:queueId`(删除队列)、`POST /api/batch-tasks/:queueId/tasks`(添加任务)、`PUT /api/batch-tasks/:queueId/tasks/:taskId`(更新任务)、`DELETE /api/batch-tasks/:queueId/tasks/:taskId`(删除任务)。任务依次顺序执行,每个任务创建独立对话,支持完整状态跟踪。
|
||||
- **WebShell API**:通过 `/api/webshell/connections`(GET 列表、POST 创建、PUT 更新、DELETE 删除)及 `/api/webshell/exec`(执行命令)、`/api/webshell/fileop`(列出/读取/写入/删除文件)管理 WebShell 连接与执行操作。
|
||||
- **任务控制**:支持暂停/终止长任务、修改参数后重跑、流式获取日志。
|
||||
- **安全管理**:`/api/auth/change-password` 可即时轮换口令;建议在暴露 MCP 端口时配合网络层 ACL。
|
||||
|
||||
@@ -395,6 +426,8 @@ mcp:
|
||||
enabled: true
|
||||
host: "0.0.0.0"
|
||||
port: 8081
|
||||
auth_header: "X-MCP-Token" # 可选;留空则不鉴权
|
||||
auth_header_value: "" # 可选;留空则首次启动自动生成并写回
|
||||
openai:
|
||||
api_key: "sk-xxx"
|
||||
base_url: "https://api.deepseek.com/v1"
|
||||
@@ -517,6 +550,13 @@ CyberStrikeAI 现已加入 [404星链计划](https://github.com/knownsec/404Star
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
CyberStrikeAI 采用 **Apache License 2.0** 开源许可。
|
||||
完整条款见仓库根目录 [LICENSE](LICENSE) 文件。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
**本工具仅供教育和授权测试使用!**
|
||||
|
||||
@@ -19,6 +19,15 @@ func main() {
|
||||
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)
|
||||
|
||||
|
||||
+24
-5
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.3.17"
|
||||
version: "v1.3.25"
|
||||
|
||||
# 服务器配置
|
||||
server:
|
||||
@@ -59,8 +59,9 @@ fofa:
|
||||
agent:
|
||||
max_iterations: 120 # 最大迭代次数,AI 代理最多执行多少轮工具调用
|
||||
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
|
||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
||||
|
||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
||||
tool_timeout_minutes: 30 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||
|
||||
# 数据库配置
|
||||
database:
|
||||
path: data/conversations.db # SQLite 数据库文件路径,用于存储对话历史和消息
|
||||
@@ -93,8 +94,10 @@ security:
|
||||
# MCP (Model Context Protocol) 用于工具注册和调用
|
||||
mcp:
|
||||
enabled: false # 是否启用 MCP 服务器(http模式)
|
||||
host: 0.0.0.0 # MCP 服务器监听地址
|
||||
port: 8081 # MCP 服务器端口
|
||||
host: 0.0.0.0 # MCP 服务器监听地址
|
||||
port: 8081 # MCP 服务器端口
|
||||
auth_header: "X-MCP-Token" # 鉴权:请求需携带该 header 且值与 auth_header_value 一致方可调用。留空表示不鉴权
|
||||
auth_header_value: "" # 鉴权密钥值(与 auth_header 配合使用,建议使用随机字符串)
|
||||
|
||||
# 外部 MCP 配置
|
||||
external_mcp:
|
||||
@@ -116,6 +119,22 @@ knowledge:
|
||||
top_k: 5 # 检索返回的Top-K结果数量
|
||||
similarity_threshold: 0.7 # 相似度阈值(0-1),低于此值的结果将被过滤
|
||||
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),每次重试会递增延迟
|
||||
|
||||
# ============================================
|
||||
# 机器人配置(企业微信、钉钉、飞书)
|
||||
|
||||
@@ -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` 返回对应语言,前端只负责展示。
|
||||
|
||||
+38
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](robot_en.md)
|
||||
|
||||
本文档说明如何通过**钉钉**、**飞书**与 CyberStrikeAI 对话(长连接模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
|
||||
本文档说明如何通过**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
|
||||
|
||||
---
|
||||
|
||||
@@ -19,12 +19,13 @@
|
||||
|
||||
---
|
||||
|
||||
## 二、支持的平台(长连接)
|
||||
## 二、支持的平台(长连接 / 回调)
|
||||
|
||||
| 平台 | 说明 |
|
||||
|------|------|
|
||||
| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 |
|
||||
| 飞书 | 使用长连接,程序主动连接飞书接收消息 |
|
||||
| 平台 | 说明 |
|
||||
|----------|------|
|
||||
| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 |
|
||||
| 飞书 | 使用长连接,程序主动连接飞书接收消息 |
|
||||
| 企业微信 | 使用 HTTP 回调接收消息,被动回包 + 主动调用企业微信发送消息 API |
|
||||
|
||||
下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 CyberStrikeAI 的哪一栏。
|
||||
|
||||
@@ -101,6 +102,37 @@
|
||||
|
||||
---
|
||||
|
||||
### 3.3 企业微信 (WeCom)
|
||||
|
||||
> 企业微信目前采用「HTTP 回调 + 主动发送消息 API」的方式工作:
|
||||
> - 用户发消息 → 企业微信以加密 XML **回调到你的服务器**(本程序的 `/api/robot/wecom`);
|
||||
> - CyberStrikeAI 解密并调用 AI → 使用企业微信的 `message/send` 接口**主动发消息给用户**。
|
||||
|
||||
**配置概览:**
|
||||
|
||||
- 在企业微信管理后台创建或选择一个**自建应用**。
|
||||
- 在该应用的「接收消息」处配置回调 URL、Token、EncodingAESKey。
|
||||
- 在 CyberStrikeAI 的 `config.yaml` 中填入:
|
||||
- `robots.wecom.corp_id`:企业 ID(CorpID)
|
||||
- `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` 提示。
|
||||
|
||||
---
|
||||
|
||||
## 四、机器人命令
|
||||
|
||||
在钉钉/飞书中向机器人发送以下**文本命令**(仅支持文本):
|
||||
|
||||
+36
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
[中文](robot.md)
|
||||
|
||||
This document explains how to chat with CyberStrikeAI from **DingTalk** and **Lark (Feishu)** using long-lived connections—no need to open a browser on the server. Following the steps below helps avoid common mistakes.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,12 +19,13 @@ Settings are written to the `robots` section of `config.yaml`; you can also edit
|
||||
|
||||
---
|
||||
|
||||
## 2. Supported platforms (long-lived connection)
|
||||
## 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 |
|
||||
| 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 WeCom’s message sending API |
|
||||
|
||||
Section 3 below describes, per platform, what to do in the developer console and which fields to copy into CyberStrikeAI.
|
||||
|
||||
@@ -100,6 +101,35 @@ If you only have a **custom bot** Webhook URL (`oapi.dingtalk.com/robot/send?acc
|
||||
|
||||
---
|
||||
|
||||
### 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 (CyberStrikeAI’s `/api/robot/wecom`).
|
||||
> - CyberStrikeAI decrypts it, calls the AI, then uses WeCom’s `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 app’s settings, configure the message **callback URL**, **Token**, and **EncodingAESKey**.
|
||||
- In CyberStrikeAI’s `config.yaml`, fill in:
|
||||
- `robots.wecom.corp_id`: your CorpID (企业 ID)
|
||||
- `robots.wecom.agent_id`: the app’s AgentId
|
||||
- `robots.wecom.token`: the Token used for message callbacks
|
||||
- `robots.wecom.encoding_aes_key`: the EncodingAESKey used for callbacks
|
||||
- `robots.wecom.secret`: the app’s 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 server’s outbound IP is **not in WeCom’s IP allowlist**.
|
||||
> - In the WeCom admin console, open the custom app’s **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):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module cyberstrike-ai
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.4
|
||||
|
||||
@@ -15,6 +15,7 @@ require (
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/pkoukk/tiktoken-go v0.1.8
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
@@ -129,6 +129,8 @@ 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/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
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=
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
+28
-5
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -1234,6 +1235,18 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
||||
var executionID string
|
||||
var err error
|
||||
|
||||
// 单次工具执行超时:防止单个工具长时间挂起(如 30 分钟仍显示执行中)
|
||||
toolCtx := ctx
|
||||
var toolCancel context.CancelFunc
|
||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||
toolCtx, toolCancel = context.WithTimeout(ctx, time.Duration(a.agentConfig.ToolTimeoutMinutes)*time.Minute)
|
||||
defer func() {
|
||||
if toolCancel != nil {
|
||||
toolCancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 检查是否是外部MCP工具(通过工具名称映射)
|
||||
a.mu.RLock()
|
||||
originalToolName, isExternalTool := a.toolNameMapping[toolName]
|
||||
@@ -1245,29 +1258,39 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
||||
zap.String("openAIName", toolName),
|
||||
zap.String("originalName", originalToolName),
|
||||
)
|
||||
result, executionID, err = a.externalMCPMgr.CallTool(ctx, originalToolName, args)
|
||||
result, executionID, err = a.externalMCPMgr.CallTool(toolCtx, originalToolName, args)
|
||||
} else {
|
||||
// 调用内部MCP工具
|
||||
result, executionID, err = a.mcpServer.CallTool(ctx, toolName, args)
|
||||
result, executionID, err = a.mcpServer.CallTool(toolCtx, toolName, args)
|
||||
}
|
||||
|
||||
// 如果调用失败(如工具不存在),返回友好的错误信息而不是抛出异常
|
||||
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
|
||||
if err != nil {
|
||||
detail := err.Error()
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
min := 10
|
||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||
min = a.agentConfig.ToolTimeoutMinutes
|
||||
}
|
||||
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", min)
|
||||
}
|
||||
errorMsg := fmt.Sprintf(`工具调用失败
|
||||
|
||||
工具名称: %s
|
||||
错误类型: 系统错误
|
||||
错误详情: %v
|
||||
错误详情: %s
|
||||
|
||||
可能的原因:
|
||||
- 工具 "%s" 不存在或未启用
|
||||
- 单次执行超时(agent.tool_timeout_minutes)
|
||||
- 系统配置问题
|
||||
- 网络或权限问题
|
||||
|
||||
建议:
|
||||
- 检查工具名称是否正确
|
||||
- 若需更长执行时间,可适当增大 agent.tool_timeout_minutes
|
||||
- 尝试使用其他替代工具
|
||||
- 如果这是必需的工具,请向用户说明情况`, toolName, err, toolName)
|
||||
- 如果这是必需的工具,请向用户说明情况`, toolName, detail, toolName)
|
||||
|
||||
return &ToolExecutionResult{
|
||||
Result: errorMsg,
|
||||
|
||||
+193
-5
@@ -15,11 +15,11 @@ import (
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/handler"
|
||||
"cyberstrike-ai/internal/knowledge"
|
||||
"cyberstrike-ai/internal/robot"
|
||||
"cyberstrike-ai/internal/logger"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
"cyberstrike-ai/internal/robot"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/skills"
|
||||
"cyberstrike-ai/internal/storage"
|
||||
@@ -46,7 +46,7 @@ type App struct {
|
||||
knowledgeHandler *handler.KnowledgeHandler // 知识库处理器(用于动态初始化)
|
||||
agentHandler *handler.AgentHandler // Agent处理器(用于更新知识库管理器)
|
||||
robotHandler *handler.RobotHandler // 机器人处理器(钉钉/飞书/企业微信)
|
||||
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
|
||||
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
|
||||
dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启
|
||||
larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
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服务器
|
||||
knowledge.RegisterKnowledgeTool(mcpServer, knowledgeRetriever, knowledgeManager, log.Logger)
|
||||
@@ -319,6 +319,8 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger)
|
||||
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
|
||||
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
|
||||
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
|
||||
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
||||
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
||||
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
||||
@@ -364,6 +366,13 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
}
|
||||
configHandler.SetVulnerabilityToolRegistrar(vulnerabilityRegistrar)
|
||||
|
||||
// 设置 WebShell 工具注册器(ApplyConfig 时重新注册)
|
||||
webshellRegistrar := func() error {
|
||||
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
||||
return nil
|
||||
}
|
||||
configHandler.SetWebshellToolRegistrar(webshellRegistrar)
|
||||
|
||||
// 设置Skills工具注册器(内置工具,必须设置)
|
||||
skillsRegistrar := func() error {
|
||||
// 创建一个适配器,将database.DB适配为SkillStatsStorage接口
|
||||
@@ -429,6 +438,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
attackChainHandler,
|
||||
app, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||
vulnerabilityHandler,
|
||||
webshellHandler,
|
||||
roleHandler,
|
||||
skillsHandler,
|
||||
fofaHandler,
|
||||
@@ -442,6 +452,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 启动应用
|
||||
func (a *App) Run() error {
|
||||
// 启动MCP服务器(如果启用)
|
||||
@@ -451,7 +476,7 @@ func (a *App) Run() error {
|
||||
a.logger.Info("启动MCP服务器", zap.String("address", mcpAddr))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/mcp", a.mcpServer.HandleHTTP)
|
||||
mux.HandleFunc("/mcp", a.mcpHandlerWithAuth)
|
||||
|
||||
if err := http.ListenAndServe(mcpAddr, mux); err != nil {
|
||||
a.logger.Error("MCP服务器启动失败", zap.Error(err))
|
||||
@@ -541,6 +566,7 @@ func setupRoutes(
|
||||
attackChainHandler *handler.AttackChainHandler,
|
||||
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||
vulnerabilityHandler *handler.VulnerabilityHandler,
|
||||
webshellHandler *handler.WebShellHandler,
|
||||
roleHandler *handler.RoleHandler,
|
||||
skillsHandler *handler.SkillsHandler,
|
||||
fofaHandler *handler.FofaHandler,
|
||||
@@ -802,6 +828,16 @@ func setupRoutes(
|
||||
protected.PUT("/vulnerabilities/:id", vulnerabilityHandler.UpdateVulnerability)
|
||||
protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability)
|
||||
|
||||
// WebShell 管理(代理执行 + 连接配置存 SQLite)
|
||||
protected.GET("/webshell/connections", webshellHandler.ListConnections)
|
||||
protected.POST("/webshell/connections", webshellHandler.CreateConnection)
|
||||
protected.GET("/webshell/connections/:id/ai-history", webshellHandler.GetAIHistory)
|
||||
protected.GET("/webshell/connections/:id/ai-conversations", webshellHandler.ListAIConversations)
|
||||
protected.PUT("/webshell/connections/:id", webshellHandler.UpdateConnection)
|
||||
protected.DELETE("/webshell/connections/:id", webshellHandler.DeleteConnection)
|
||||
protected.POST("/webshell/exec", webshellHandler.Exec)
|
||||
protected.POST("/webshell/file", webshellHandler.FileOp)
|
||||
|
||||
// 角色管理
|
||||
protected.GET("/roles", roleHandler.GetRoles)
|
||||
protected.GET("/roles/:name", roleHandler.GetRole)
|
||||
@@ -1041,6 +1077,158 @@ func registerVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger *z
|
||||
logger.Info("漏洞记录工具注册成功")
|
||||
}
|
||||
|
||||
// registerWebshellTools 注册 WebShell 相关 MCP 工具,供 AI 助手在指定连接上执行命令与文件操作
|
||||
func registerWebshellTools(mcpServer *mcp.Server, db *database.DB, webshellHandler *handler.WebShellHandler, logger *zap.Logger) {
|
||||
if db == nil || webshellHandler == nil {
|
||||
logger.Warn("跳过 WebShell 工具注册:db 或 webshellHandler 为空")
|
||||
return
|
||||
}
|
||||
|
||||
// webshell_exec
|
||||
execTool := mcp.Tool{
|
||||
Name: builtin.ToolWebshellExec,
|
||||
Description: "在指定的 WebShell 连接上执行一条系统命令,返回命令的标准输出。connection_id 由用户在 AI 助手上下文中选定。",
|
||||
ShortDescription: "在 WebShell 连接上执行命令",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"connection_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "WebShell 连接 ID(如 ws_xxx)",
|
||||
},
|
||||
"command": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要执行的系统命令",
|
||||
},
|
||||
},
|
||||
"required": []string{"connection_id", "command"},
|
||||
},
|
||||
}
|
||||
execHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
cid, _ := args["connection_id"].(string)
|
||||
cmd, _ := args["command"].(string)
|
||||
if cid == "" || cmd == "" {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 和 command 均为必填"}}, IsError: true}, nil
|
||||
}
|
||||
conn, err := db.GetWebshellConnection(cid)
|
||||
if err != nil || conn == nil {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接或查询失败"}}, IsError: true}, nil
|
||||
}
|
||||
output, ok, errMsg := webshellHandler.ExecWithConnection(conn, cmd)
|
||||
if errMsg != "" {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
|
||||
}
|
||||
if !ok {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "HTTP 非 200,输出:\n" + output}}, IsError: false}, nil
|
||||
}
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: output}}, IsError: false}, nil
|
||||
}
|
||||
mcpServer.RegisterTool(execTool, execHandler)
|
||||
|
||||
// webshell_file_list
|
||||
listTool := mcp.Tool{
|
||||
Name: builtin.ToolWebshellFileList,
|
||||
Description: "在指定 WebShell 连接上列出目录内容。path 默认为当前目录(.)。",
|
||||
ShortDescription: "在 WebShell 上列出目录",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
|
||||
"path": map[string]interface{}{"type": "string", "description": "目录路径,默认 ."},
|
||||
},
|
||||
"required": []string{"connection_id"},
|
||||
},
|
||||
}
|
||||
listHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
cid, _ := args["connection_id"].(string)
|
||||
path, _ := args["path"].(string)
|
||||
if cid == "" {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 必填"}}, IsError: true}, nil
|
||||
}
|
||||
conn, err := db.GetWebshellConnection(cid)
|
||||
if err != nil || conn == nil {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接"}}, IsError: true}, nil
|
||||
}
|
||||
output, ok, errMsg := webshellHandler.FileOpWithConnection(conn, "list", path, "", "")
|
||||
if errMsg != "" {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
|
||||
}
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: output}}, IsError: !ok}, nil
|
||||
}
|
||||
mcpServer.RegisterTool(listTool, listHandler)
|
||||
|
||||
// webshell_file_read
|
||||
readTool := mcp.Tool{
|
||||
Name: builtin.ToolWebshellFileRead,
|
||||
Description: "在指定 WebShell 连接上读取文件内容。",
|
||||
ShortDescription: "在 WebShell 上读取文件",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
|
||||
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
|
||||
},
|
||||
"required": []string{"connection_id", "path"},
|
||||
},
|
||||
}
|
||||
readHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
cid, _ := args["connection_id"].(string)
|
||||
path, _ := args["path"].(string)
|
||||
if cid == "" || path == "" {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 和 path 必填"}}, IsError: true}, nil
|
||||
}
|
||||
conn, err := db.GetWebshellConnection(cid)
|
||||
if err != nil || conn == nil {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接"}}, IsError: true}, nil
|
||||
}
|
||||
output, ok, errMsg := webshellHandler.FileOpWithConnection(conn, "read", path, "", "")
|
||||
if errMsg != "" {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
|
||||
}
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: output}}, IsError: !ok}, nil
|
||||
}
|
||||
mcpServer.RegisterTool(readTool, readHandler)
|
||||
|
||||
// webshell_file_write
|
||||
writeTool := mcp.Tool{
|
||||
Name: builtin.ToolWebshellFileWrite,
|
||||
Description: "在指定 WebShell 连接上写入文件内容(会覆盖已有文件)。",
|
||||
ShortDescription: "在 WebShell 上写入文件",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
|
||||
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "要写入的内容"},
|
||||
},
|
||||
"required": []string{"connection_id", "path", "content"},
|
||||
},
|
||||
}
|
||||
writeHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
cid, _ := args["connection_id"].(string)
|
||||
path, _ := args["path"].(string)
|
||||
content, _ := args["content"].(string)
|
||||
if cid == "" || path == "" {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 和 path 必填"}}, IsError: true}, nil
|
||||
}
|
||||
conn, err := db.GetWebshellConnection(cid)
|
||||
if err != nil || conn == nil {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接"}}, IsError: true}, nil
|
||||
}
|
||||
output, ok, errMsg := webshellHandler.FileOpWithConnection(conn, "write", path, content, "")
|
||||
if errMsg != "" {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
|
||||
}
|
||||
if !ok {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "写入可能失败,输出:\n" + output}}, IsError: false}, nil
|
||||
}
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "写入成功\n" + output}}, IsError: false}, nil
|
||||
}
|
||||
mcpServer.RegisterTool(writeTool, writeHandler)
|
||||
|
||||
logger.Info("WebShell 工具注册成功")
|
||||
}
|
||||
|
||||
// initializeKnowledge 初始化知识库组件(用于动态初始化)
|
||||
func initializeKnowledge(
|
||||
cfg *config.Config,
|
||||
@@ -1102,7 +1290,7 @@ func initializeKnowledge(
|
||||
knowledgeRetriever := knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, logger)
|
||||
|
||||
// 创建索引器
|
||||
knowledgeIndexer := knowledge.NewIndexer(knowledgeDB, embedder, logger)
|
||||
knowledgeIndexer := knowledge.NewIndexer(knowledgeDB, embedder, logger, &cfg.Knowledge.Indexing)
|
||||
|
||||
// 注册知识检索工具到MCP服务器
|
||||
knowledge.RegisterKnowledgeTool(mcpServer, knowledgeRetriever, knowledgeManager, logger)
|
||||
|
||||
+169
-15
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -74,9 +76,11 @@ type LogConfig struct {
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
AuthHeader string `yaml:"auth_header,omitempty"` // 鉴权 header 名,留空表示不鉴权
|
||||
AuthHeaderValue string `yaml:"auth_header_value,omitempty"` // 鉴权 header 值,需与请求中该 header 一致
|
||||
}
|
||||
|
||||
type OpenAIConfig struct {
|
||||
@@ -108,6 +112,7 @@ type AgentConfig struct {
|
||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||
LargeResultThreshold int `yaml:"large_result_threshold" json:"large_result_threshold"` // 大结果阈值(字节),默认50KB
|
||||
ResultStorageDir string `yaml:"result_storage_dir" json:"result_storage_dir"` // 结果存储目录,默认tmp
|
||||
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
@@ -159,16 +164,17 @@ type ToolConfig struct {
|
||||
|
||||
// ParameterConfig 参数配置
|
||||
type ParameterConfig struct {
|
||||
Name string `yaml:"name"` // 参数名称
|
||||
Type string `yaml:"type"` // 参数类型: string, int, bool, array
|
||||
Description string `yaml:"description"` // 参数描述
|
||||
Required bool `yaml:"required,omitempty"` // 是否必需
|
||||
Default interface{} `yaml:"default,omitempty"` // 默认值
|
||||
Flag string `yaml:"flag,omitempty"` // 命令行标志,如 "-u", "--url", "-p"
|
||||
Position *int `yaml:"position,omitempty"` // 位置参数的位置(从0开始)
|
||||
Format string `yaml:"format,omitempty"` // 参数格式: "flag", "positional", "combined" (flag=value), "template"
|
||||
Template string `yaml:"template,omitempty"` // 模板字符串,如 "{flag} {value}" 或 "{value}"
|
||||
Options []string `yaml:"options,omitempty"` // 可选值列表(用于枚举)
|
||||
Name string `yaml:"name"` // 参数名称
|
||||
Type string `yaml:"type"` // 参数类型: string, int, bool, array
|
||||
Description string `yaml:"description"` // 参数描述
|
||||
Required bool `yaml:"required,omitempty"` // 是否必需
|
||||
Default interface{} `yaml:"default,omitempty"` // 默认值
|
||||
ItemType string `yaml:"item_type,omitempty"` // 当 type 为 array 时,数组元素类型,如 string, number, object
|
||||
Flag string `yaml:"flag,omitempty"` // 命令行标志,如 "-u", "--url", "-p"
|
||||
Position *int `yaml:"position,omitempty"` // 位置参数的位置(从0开始)
|
||||
Format string `yaml:"format,omitempty"` // 参数格式: "flag", "positional", "combined" (flag=value), "template"
|
||||
Template string `yaml:"template,omitempty"` // 模板字符串,如 "{flag} {value}" 或 "{value}"
|
||||
Options []string `yaml:"options,omitempty"` // 可选值列表(用于枚举)
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
@@ -384,6 +390,124 @@ func PrintGeneratedPasswordWarning(password string, persisted bool, persistErr s
|
||||
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 从目录加载所有工具配置文件
|
||||
func LoadToolsFromDir(dir string) ([]ToolConfig, error) {
|
||||
var tools []ToolConfig
|
||||
@@ -559,7 +683,8 @@ func Default() *Config {
|
||||
MaxTotalTokens: 120000,
|
||||
},
|
||||
Agent: AgentConfig{
|
||||
MaxIterations: 30, // 默认最大迭代次数
|
||||
MaxIterations: 30, // 默认最大迭代次数
|
||||
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
Tools: []ToolConfig{}, // 工具配置应该从 config.yaml 或 tools/ 目录加载
|
||||
@@ -582,9 +707,18 @@ func Default() *Config {
|
||||
},
|
||||
Retrieval: RetrievalConfig{
|
||||
TopK: 5,
|
||||
SimilarityThreshold: 0.7,
|
||||
SimilarityThreshold: 0.65, // 降低阈值到 0.65,减少漏检
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -595,6 +729,26 @@ type KnowledgeConfig struct {
|
||||
BasePath string `yaml:"base_path" json:"base_path"` // 知识库路径
|
||||
Embedding EmbeddingConfig `yaml:"embedding" json:"embedding"`
|
||||
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 嵌入配置
|
||||
|
||||
@@ -33,13 +33,26 @@ type Message struct {
|
||||
|
||||
// CreateConversation 创建新对话
|
||||
func (db *DB) CreateConversation(title string) (*Conversation, error) {
|
||||
return db.CreateConversationWithWebshell("", title)
|
||||
}
|
||||
|
||||
// CreateConversationWithWebshell 创建新对话,可选绑定 WebShell 连接 ID(为空则普通对话)
|
||||
func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string) (*Conversation, error) {
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||
id, title, now, now,
|
||||
)
|
||||
var err error
|
||||
if webshellConnectionID != "" {
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id) VALUES (?, ?, ?, ?, ?)",
|
||||
id, title, now, now, webshellConnectionID,
|
||||
)
|
||||
} else {
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||
id, title, now, now,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建对话失败: %w", err)
|
||||
}
|
||||
@@ -52,6 +65,117 @@ func (db *DB) CreateConversation(title string) (*Conversation, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetConversationByWebshellConnectionID 根据 WebShell 连接 ID 获取该连接下最近一条对话(用于 AI 助手持久化)
|
||||
func (db *DB) GetConversationByWebshellConnectionID(connectionID string) (*Conversation, error) {
|
||||
if connectionID == "" {
|
||||
return nil, fmt.Errorf("connectionID is empty")
|
||||
}
|
||||
var conv Conversation
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
err := db.QueryRow(
|
||||
"SELECT id, title, pinned, created_at, updated_at FROM conversations WHERE webshell_connection_id = ? ORDER BY updated_at DESC LIMIT 1",
|
||||
connectionID,
|
||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询对话失败: %w", err)
|
||||
}
|
||||
conv.Pinned = pinned != 0
|
||||
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt); e == nil {
|
||||
conv.CreatedAt = t
|
||||
} else if t, e := time.Parse("2006-01-02 15:04:05", createdAt); e == nil {
|
||||
conv.CreatedAt = t
|
||||
} else {
|
||||
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
}
|
||||
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt); e == nil {
|
||||
conv.UpdatedAt = t
|
||||
} else if t, e := time.Parse("2006-01-02 15:04:05", updatedAt); e == nil {
|
||||
conv.UpdatedAt = t
|
||||
} else {
|
||||
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
}
|
||||
messages, err := db.GetMessages(conv.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载消息失败: %w", err)
|
||||
}
|
||||
conv.Messages = messages
|
||||
|
||||
// 加载过程详情并附加到对应消息(与 GetConversation 一致,便于刷新后仍可查看执行过程)
|
||||
processDetailsMap, err := db.GetProcessDetailsByConversation(conv.ID)
|
||||
if err != nil {
|
||||
db.logger.Warn("加载过程详情失败", zap.Error(err))
|
||||
processDetailsMap = make(map[string][]ProcessDetail)
|
||||
}
|
||||
for i := range conv.Messages {
|
||||
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
|
||||
detailsJSON := make([]map[string]interface{}, len(details))
|
||||
for j, detail := range details {
|
||||
var data interface{}
|
||||
if detail.Data != "" {
|
||||
if err := json.Unmarshal([]byte(detail.Data), &data); err != nil {
|
||||
db.logger.Warn("解析过程详情数据失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
detailsJSON[j] = map[string]interface{}{
|
||||
"id": detail.ID,
|
||||
"messageId": detail.MessageID,
|
||||
"conversationId": detail.ConversationID,
|
||||
"eventType": detail.EventType,
|
||||
"message": detail.Message,
|
||||
"data": data,
|
||||
"createdAt": detail.CreatedAt,
|
||||
}
|
||||
}
|
||||
conv.Messages[i].ProcessDetails = detailsJSON
|
||||
}
|
||||
}
|
||||
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
// WebShellConversationItem 用于侧边栏列表,不含消息
|
||||
type WebShellConversationItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ListConversationsByWebshellConnectionID 列出该 WebShell 连接下的所有对话(按更新时间倒序),供侧边栏展示
|
||||
func (db *DB) ListConversationsByWebshellConnectionID(connectionID string) ([]WebShellConversationItem, error) {
|
||||
if connectionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := db.Query(
|
||||
"SELECT id, title, updated_at FROM conversations WHERE webshell_connection_id = ? ORDER BY updated_at DESC",
|
||||
connectionID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询对话列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []WebShellConversationItem
|
||||
for rows.Next() {
|
||||
var item WebShellConversationItem
|
||||
var updatedAt string
|
||||
if err := rows.Scan(&item.ID, &item.Title, &updatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt); e == nil {
|
||||
item.UpdatedAt = t
|
||||
} else if t, e := time.Parse("2006-01-02 15:04:05", updatedAt); e == nil {
|
||||
item.UpdatedAt = t
|
||||
} else {
|
||||
item.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// GetConversation 获取对话
|
||||
func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||
var conv Conversation
|
||||
|
||||
@@ -227,6 +227,19 @@ func (db *DB) initTables() error {
|
||||
FOREIGN KEY (queue_id) REFERENCES batch_task_queues(id) ON DELETE CASCADE
|
||||
);`
|
||||
|
||||
// 创建 WebShell 连接表
|
||||
createWebshellConnectionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS webshell_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT NOT NULL,
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'php',
|
||||
method TEXT NOT NULL DEFAULT 'post',
|
||||
cmd_param TEXT NOT NULL DEFAULT '',
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
// 创建索引
|
||||
createIndexes := `
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
|
||||
@@ -253,6 +266,7 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_created_at ON batch_task_queues(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_title ON batch_task_queues(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_webshell_connections_created_at ON webshell_connections(created_at);
|
||||
`
|
||||
|
||||
if _, err := db.Exec(createConversationsTable); err != nil {
|
||||
@@ -311,6 +325,10 @@ func (db *DB) initTables() error {
|
||||
return fmt.Errorf("创建batch_tasks表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createWebshellConnectionsTable); err != nil {
|
||||
return fmt.Errorf("创建webshell_connections表失败: %w", err)
|
||||
}
|
||||
|
||||
// 为已有表添加新字段(如果不存在)- 必须在创建索引之前
|
||||
if err := db.migrateConversationsTable(); err != nil {
|
||||
db.logger.Warn("迁移conversations表失败", zap.Error(err))
|
||||
@@ -397,6 +415,21 @@ func (db *DB) migrateConversationsTable() error {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 webshell_connection_id 字段是否存在(WebShell AI 助手对话关联)
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='webshell_connection_id'").Scan(&count)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN webshell_connection_id TEXT"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加webshell_connection_id字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if count == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN webshell_connection_id TEXT"); err != nil {
|
||||
db.logger.Warn("添加webshell_connection_id字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WebShellConnection WebShell 连接配置
|
||||
type WebShellConnection struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmdParam"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
|
||||
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
|
||||
query := `
|
||||
SELECT id, url, password, type, method, cmd_param, remark, created_at
|
||||
FROM webshell_connections
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
db.logger.Error("查询 WebShell 连接列表失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []WebShellConnection
|
||||
for rows.Next() {
|
||||
var c WebShellConnection
|
||||
err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt)
|
||||
if err != nil {
|
||||
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
list = append(list, c)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// GetWebshellConnection 根据 ID 获取一条连接
|
||||
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
||||
query := `
|
||||
SELECT id, url, password, type, method, cmd_param, remark, created_at
|
||||
FROM webshell_connections WHERE id = ?
|
||||
`
|
||||
var c WebShellConnection
|
||||
err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
db.logger.Error("查询 WebShell 连接失败", zap.Error(err), zap.String("id", id))
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// CreateWebshellConnection 创建 WebShell 连接
|
||||
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
||||
query := `
|
||||
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
_, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.CreatedAt)
|
||||
if err != nil {
|
||||
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateWebshellConnection 更新 WebShell 连接
|
||||
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
|
||||
query := `
|
||||
UPDATE webshell_connections
|
||||
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.ID)
|
||||
if err != nil {
|
||||
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWebshellConnection 删除 WebShell 连接
|
||||
func (db *DB) DeleteWebshellConnection(id string) error {
|
||||
result, err := db.Exec(`DELETE FROM webshell_connections WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
db.logger.Error("删除 WebShell 连接失败", zap.Error(err), zap.String("id", id))
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+69
-19
@@ -121,10 +121,11 @@ type ChatAttachment struct {
|
||||
|
||||
// ChatRequest 聊天请求
|
||||
type ChatRequest struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -316,7 +317,34 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
||||
finalMessage := req.Message
|
||||
var roleTools []string // 角色配置的工具列表
|
||||
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
|
||||
if req.Role != "" && req.Role != "默认" {
|
||||
|
||||
// WebShell AI 助手模式:绑定当前连接,仅开放 webshell_* 工具并注入 connection_id
|
||||
if req.WebShellConnectionID != "" {
|
||||
conn, err := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
|
||||
if err != nil || conn == nil {
|
||||
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到该 WebShell 连接"})
|
||||
return
|
||||
}
|
||||
remark := conn.Remark
|
||||
if remark == "" {
|
||||
remark = conn.URL
|
||||
}
|
||||
finalMessage = fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base、list_skills、read_skill。请根据用户输入决定下一步:若仅为问候、闲聊或简单问题,直接简短回复即可,不必调用工具;当用户明确需要执行命令、列目录、读写文件、记录漏洞或检索知识库/查看 Skills 等操作时再调用上述工具。\n\n用户请求:%s",
|
||||
conn.ID, remark, conn.ID, req.Message)
|
||||
roleTools = []string{
|
||||
builtin.ToolWebshellExec,
|
||||
builtin.ToolWebshellFileList,
|
||||
builtin.ToolWebshellFileRead,
|
||||
builtin.ToolWebshellFileWrite,
|
||||
builtin.ToolRecordVulnerability,
|
||||
builtin.ToolListKnowledgeRiskTypes,
|
||||
builtin.ToolSearchKnowledgeBase,
|
||||
builtin.ToolListSkills,
|
||||
builtin.ToolReadSkill,
|
||||
}
|
||||
roleSkills = nil
|
||||
} else if req.Role != "" && req.Role != "默认" {
|
||||
if h.config.Roles != nil {
|
||||
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
|
||||
// 应用用户提示词
|
||||
@@ -712,11 +740,17 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有对话ID,创建新对话
|
||||
// 如果没有对话ID,创建新对话(WebShell 助手模式下关联连接 ID 以便持久化展示)
|
||||
conversationID := req.ConversationID
|
||||
if conversationID == "" {
|
||||
title := safeTruncateString(req.Message, 50)
|
||||
conv, err := h.db.CreateConversation(title)
|
||||
var conv *database.Conversation
|
||||
var err error
|
||||
if req.WebShellConnectionID != "" {
|
||||
conv, err = h.db.CreateConversationWithWebshell(strings.TrimSpace(req.WebShellConnectionID), title)
|
||||
} else {
|
||||
conv, err = h.db.CreateConversation(title)
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Error("创建对话失败", zap.Error(err))
|
||||
sendEvent("error", "创建对话失败: "+err.Error(), nil)
|
||||
@@ -769,7 +803,32 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
// 应用角色用户提示词和工具配置
|
||||
finalMessage := req.Message
|
||||
var roleTools []string // 角色配置的工具列表
|
||||
if req.Role != "" && req.Role != "默认" {
|
||||
var roleSkills []string
|
||||
if req.WebShellConnectionID != "" {
|
||||
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
|
||||
if errConn != nil || conn == nil {
|
||||
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn))
|
||||
sendEvent("error", "未找到该 WebShell 连接", nil)
|
||||
return
|
||||
}
|
||||
remark := conn.Remark
|
||||
if remark == "" {
|
||||
remark = conn.URL
|
||||
}
|
||||
finalMessage = fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base、list_skills、read_skill。请根据用户输入决定下一步:若仅为问候、闲聊或简单问题,直接简短回复即可,不必调用工具;当用户明确需要执行命令、列目录、读写文件、记录漏洞或检索知识库/查看 Skills 等操作时再调用上述工具。\n\n用户请求:%s",
|
||||
conn.ID, remark, conn.ID, req.Message)
|
||||
roleTools = []string{
|
||||
builtin.ToolWebshellExec,
|
||||
builtin.ToolWebshellFileList,
|
||||
builtin.ToolWebshellFileRead,
|
||||
builtin.ToolWebshellFileWrite,
|
||||
builtin.ToolRecordVulnerability,
|
||||
builtin.ToolListKnowledgeRiskTypes,
|
||||
builtin.ToolSearchKnowledgeBase,
|
||||
builtin.ToolListSkills,
|
||||
builtin.ToolReadSkill,
|
||||
}
|
||||
} else if req.Role != "" && req.Role != "默认" {
|
||||
if h.config.Roles != nil {
|
||||
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
|
||||
// 应用用户提示词
|
||||
@@ -788,6 +847,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
// 注意:角色配置的skills不再硬编码注入,AI可以通过list_skills和read_skill工具按需调用
|
||||
if len(role.Skills) > 0 {
|
||||
roleSkills = role.Skills
|
||||
h.logger.Info("角色配置了skills,AI可通过工具按需调用", zap.String("role", req.Role), zap.Int("skillCount", len(role.Skills)), zap.Strings("skills", role.Skills))
|
||||
}
|
||||
}
|
||||
@@ -886,17 +946,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
|
||||
// 执行Agent Loop,传入独立的上下文,确保任务不会因客户端断开而中断(使用包含角色提示词的finalMessage和角色工具列表)
|
||||
sendEvent("progress", "正在分析您的请求...", nil)
|
||||
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
|
||||
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
|
||||
if req.Role != "" && req.Role != "默认" {
|
||||
if h.config.Roles != nil {
|
||||
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
|
||||
if len(role.Skills) > 0 {
|
||||
roleSkills = role.Skills
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 注意:roleSkills 已在上方根据 req.Role 或 WebShell 模式设置
|
||||
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, roleSkills)
|
||||
if err != nil {
|
||||
h.logger.Error("Agent Loop执行失败", zap.Error(err))
|
||||
|
||||
@@ -28,6 +28,9 @@ type KnowledgeToolRegistrar func() error
|
||||
// VulnerabilityToolRegistrar 漏洞工具注册器接口
|
||||
type VulnerabilityToolRegistrar func() error
|
||||
|
||||
// WebshellToolRegistrar WebShell 工具注册器接口(ApplyConfig 时重新注册)
|
||||
type WebshellToolRegistrar func() error
|
||||
|
||||
// SkillsToolRegistrar Skills工具注册器接口
|
||||
type SkillsToolRegistrar func() error
|
||||
|
||||
@@ -60,6 +63,7 @@ type ConfigHandler struct {
|
||||
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
|
||||
knowledgeToolRegistrar KnowledgeToolRegistrar // 知识库工具注册器(可选)
|
||||
vulnerabilityToolRegistrar VulnerabilityToolRegistrar // 漏洞工具注册器(可选)
|
||||
webshellToolRegistrar WebshellToolRegistrar // WebShell 工具注册器(可选)
|
||||
skillsToolRegistrar SkillsToolRegistrar // Skills工具注册器(可选)
|
||||
retrieverUpdater RetrieverUpdater // 检索器更新器(可选)
|
||||
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
|
||||
@@ -120,6 +124,13 @@ func (h *ConfigHandler) SetVulnerabilityToolRegistrar(registrar VulnerabilityToo
|
||||
h.vulnerabilityToolRegistrar = registrar
|
||||
}
|
||||
|
||||
// SetWebshellToolRegistrar 设置 WebShell 工具注册器
|
||||
func (h *ConfigHandler) SetWebshellToolRegistrar(registrar WebshellToolRegistrar) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.webshellToolRegistrar = registrar
|
||||
}
|
||||
|
||||
// SetSkillsToolRegistrar 设置Skills工具注册器
|
||||
func (h *ConfigHandler) SetSkillsToolRegistrar(registrar SkillsToolRegistrar) {
|
||||
h.mu.Lock()
|
||||
@@ -792,6 +803,16 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 重新注册 WebShell 工具(内置工具,必须注册)
|
||||
if h.webshellToolRegistrar != nil {
|
||||
h.logger.Info("重新注册 WebShell 工具")
|
||||
if err := h.webshellToolRegistrar(); err != nil {
|
||||
h.logger.Error("重新注册 WebShell 工具失败", zap.Error(err))
|
||||
} else {
|
||||
h.logger.Info("WebShell 工具已重新注册")
|
||||
}
|
||||
}
|
||||
|
||||
// 重新注册Skills工具(内置工具,必须注册)
|
||||
if h.skillsToolRegistrar != nil {
|
||||
h.logger.Info("重新注册Skills工具")
|
||||
@@ -1062,6 +1083,16 @@ func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
|
||||
setIntInMap(retrievalNode, "top_k", cfg.Retrieval.TopK)
|
||||
setFloatInMap(retrievalNode, "similarity_threshold", cfg.Retrieval.SimilarityThreshold)
|
||||
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) {
|
||||
|
||||
@@ -75,7 +75,7 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
|
||||
groupedByCategory[cat] = append(groupedByCategory[cat], item)
|
||||
}
|
||||
|
||||
// 转换为CategoryWithItems格式
|
||||
// 转换为 CategoryWithItems 格式
|
||||
categoriesWithItems := make([]*knowledge.CategoryWithItems, 0, len(groupedByCategory))
|
||||
for cat, catItems := range groupedByCategory {
|
||||
categoriesWithItems = append(categoriesWithItems, &knowledge.CategoryWithItems{
|
||||
@@ -107,7 +107,7 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
|
||||
categoryPageMode := c.Query("categoryPage") != "false" // 默认使用分类分页
|
||||
|
||||
// 分页参数
|
||||
limit := 50 // 默认每页50条(分类分页时为分类数,项分页时为项数)
|
||||
limit := 50 // 默认每页 50 条(分类分页时为分类数,项分页时为项数)
|
||||
offset := 0
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
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 {
|
||||
// 单分类模式:返回该分类的所有知识项(不分页)
|
||||
items, total, err := h.manager.GetItemsSummary(category, 0, 0)
|
||||
@@ -150,9 +150,9 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
|
||||
|
||||
if categoryPageMode {
|
||||
// 按分类分页模式(默认)
|
||||
// limit表示每页分类数,推荐5-10个分类
|
||||
// limit 表示每页分类数,推荐 5-10 个分类
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 10 // 默认每页10个分类
|
||||
limit = 10 // 默认每页 10 个分类
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
if includeContent {
|
||||
@@ -358,7 +358,7 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// 如果连续失败2次,立即停止增量索引
|
||||
// 如果连续失败 2 次,立即停止增量索引
|
||||
if consecutiveFailures >= 2 {
|
||||
h.logger.Error("连续索引失败次数过多,立即停止增量索引",
|
||||
zap.Int("consecutiveFailures", consecutiveFailures),
|
||||
@@ -397,7 +397,7 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) {
|
||||
func (h *KnowledgeHandler) GetRetrievalLogs(c *gin.Context) {
|
||||
conversationID := c.Query("conversationId")
|
||||
messageID := c.Query("messageId")
|
||||
limit := 50 // 默认50条
|
||||
limit := 50 // 默认 50 条
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if parsed, err := parseInt(limitStr); err == nil && parsed > 0 {
|
||||
@@ -441,18 +441,40 @@ func (h *KnowledgeHandler) GetIndexStatus(c *gin.Context) {
|
||||
if h.indexer != nil {
|
||||
lastError, lastErrorTime := h.indexer.GetLastError()
|
||||
if lastError != "" {
|
||||
// 如果错误是最近发生的(5分钟内),则返回错误信息
|
||||
// 如果错误是最近发生的(5 分钟内),则返回错误信息
|
||||
if time.Since(lastErrorTime) < 5*time.Minute {
|
||||
status["last_error"] = lastError
|
||||
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)
|
||||
}
|
||||
|
||||
// Search 搜索知识库(用于API调用,Agent内部使用Retriever)
|
||||
// Search 搜索知识库(用于 API 调用,Agent 内部使用 Retriever)
|
||||
func (h *KnowledgeHandler) Search(c *gin.Context) {
|
||||
var req knowledge.SearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -4411,6 +4411,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
}
|
||||
|
||||
enrichSpecWithI18nKeys(spec)
|
||||
c.JSON(http.StatusOK, spec)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+391
-77
@@ -1,11 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -141,56 +145,9 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
|
||||
return "请输入内容或发送「帮助」/ help 查看命令。"
|
||||
}
|
||||
|
||||
// 命令分发(支持中英文)
|
||||
switch {
|
||||
case text == robotCmdHelp || text == "help" || text == "?" || text == "?":
|
||||
return h.cmdHelp()
|
||||
case text == robotCmdList || text == robotCmdListAlt || text == "list":
|
||||
return h.cmdList()
|
||||
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)
|
||||
case text == robotCmdNew || text == "new":
|
||||
return h.cmdNew(platform, userID)
|
||||
case text == robotCmdClear || text == "clear":
|
||||
return h.cmdClear(platform, userID)
|
||||
case text == robotCmdCurrent || text == "current":
|
||||
return h.cmdCurrent(platform, userID)
|
||||
case text == robotCmdStop || text == "stop":
|
||||
return h.cmdStop(platform, userID)
|
||||
case text == robotCmdRoles || text == robotCmdRolesList || text == "roles":
|
||||
return h.cmdRoles()
|
||||
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)
|
||||
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)
|
||||
case text == robotCmdVersion || text == "version":
|
||||
return h.cmdVersion()
|
||||
// 先尝试作为命令处理(支持中英文)
|
||||
if cmdReply, ok := h.handleRobotCommand(platform, userID, text); ok {
|
||||
return cmdReply
|
||||
}
|
||||
|
||||
// 普通消息:走 Agent
|
||||
@@ -404,6 +361,62 @@ func (h *RobotHandler) cmdVersion() string {
|
||||
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(明文模式下的简化结构;加密模式需先解密再解析)
|
||||
@@ -418,14 +431,14 @@ type wecomXML struct {
|
||||
Encrypt string `xml:"Encrypt"` // 加密模式下消息在此
|
||||
}
|
||||
|
||||
// wecomReplyXML 被动回复 XML
|
||||
// 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"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime int64 `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Content string `xml:"Content"`
|
||||
}
|
||||
|
||||
// HandleWecomGET 企业微信 URL 校验(GET)
|
||||
@@ -434,15 +447,51 @@ func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
// 明文模式时企业微信可能直接传 echostr,先直接返回以通过校验
|
||||
|
||||
// 如果配置了 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 + "=")
|
||||
@@ -484,54 +533,238 @@ func wecomDecrypt(encodingAESKey, encryptedB64 string) ([]byte, error) {
|
||||
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
|
||||
}
|
||||
bodyRaw, _ := io.ReadAll(c.Request.Body)
|
||||
// 从 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 四个字段
|
||||
// 若配置了 Token 则必须校验签名,避免未授权请求触发 Agent(防止平台被接管)
|
||||
token := h.config.Robots.Wecom.Token
|
||||
if token != "" {
|
||||
if msgSignature == "" {
|
||||
h.logger.Warn("企业微信 POST 缺少签名,已拒绝(需配置 token 并确保回调携带 msg_signature)")
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
var tmp wecomXML
|
||||
if err := xml.Unmarshal(bodyRaw, &tmp); err != nil {
|
||||
h.logger.Warn("企业微信 POST 签名验证前解析 XML 失败", zap.Error(err))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
expected := h.signWecomRequest(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.Debug("企业微信 POST 解析 XML 失败", zap.Error(err))
|
||||
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))
|
||||
}
|
||||
if body.MsgType != "text" {
|
||||
c.XML(http.StatusOK, wecomReplyXML{
|
||||
ToUserName: body.FromUserName,
|
||||
FromUserName: body.ToUserName,
|
||||
CreateTime: time.Now().Unix(),
|
||||
MsgType: "text",
|
||||
Content: "暂仅支持文本消息,请发送文字。",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userID := body.FromUserName
|
||||
text := strings.TrimSpace(body.Content)
|
||||
reply := h.HandleMessage("wecom", userID, text)
|
||||
// 加密模式需加密回复(此处简化为明文回复;若企业要求加密需再实现加密)
|
||||
c.XML(http.StatusOK, wecomReplyXML{
|
||||
ToUserName: body.FromUserName,
|
||||
FromUserName: body.ToUserName,
|
||||
CreateTime: time.Now().Unix(),
|
||||
MsgType: "text",
|
||||
Content: reply,
|
||||
})
|
||||
|
||||
// 限制回复内容长度(企业微信限制 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("企业微信明文回复已发送")
|
||||
}
|
||||
|
||||
// —————— 测试接口(需登录,用于验证机器人逻辑,无需钉钉/飞书客户端) ——————
|
||||
@@ -562,6 +795,87 @@ func (h *RobotHandler) HandleRobotTest(c *gin.Context) {
|
||||
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
|
||||
|
||||
@@ -19,6 +19,7 @@ type AgentTask struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
Status string `json:"status"`
|
||||
CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务
|
||||
|
||||
cancel func(error)
|
||||
}
|
||||
@@ -41,13 +42,61 @@ type AgentTaskManager struct {
|
||||
historyRetention time.Duration // 历史记录保留时间
|
||||
}
|
||||
|
||||
const (
|
||||
// cancellingStuckThreshold 处于「取消中」超过此时长则强制从运行列表移除。正常取消会在当前步骤内返回,
|
||||
// 超过则视为卡住,尽快释放会话。常见做法多为 30–60s 内释放。
|
||||
cancellingStuckThreshold = 45 * time.Second
|
||||
// cancellingStuckThresholdLegacy 未记录 CancellingAt 时用 StartedAt 判断的兜底时长
|
||||
cancellingStuckThresholdLegacy = 2 * time.Minute
|
||||
cleanupInterval = 15 * time.Second // 与上面阈值配合,最长约 60s 内移除
|
||||
)
|
||||
|
||||
// NewAgentTaskManager 创建任务管理器
|
||||
func NewAgentTaskManager() *AgentTaskManager {
|
||||
return &AgentTaskManager{
|
||||
m := &AgentTaskManager{
|
||||
tasks: make(map[string]*AgentTask),
|
||||
completedTasks: make([]*CompletedTask, 0),
|
||||
maxHistorySize: 50, // 最多保留50条历史记录
|
||||
historyRetention: 24 * time.Hour, // 保留24小时
|
||||
maxHistorySize: 50, // 最多保留50条历史记录
|
||||
historyRetention: 24 * time.Hour, // 保留24小时
|
||||
}
|
||||
go m.runStuckCancellingCleanup()
|
||||
return m
|
||||
}
|
||||
|
||||
// runStuckCancellingCleanup 定期将长时间处于「取消中」的任务强制结束,避免卡住无法发新消息
|
||||
func (m *AgentTaskManager) runStuckCancellingCleanup() {
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
m.cleanupStuckCancelling()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AgentTaskManager) cleanupStuckCancelling() {
|
||||
m.mu.Lock()
|
||||
var toFinish []string
|
||||
now := time.Now()
|
||||
for id, task := range m.tasks {
|
||||
if task.Status != "cancelling" {
|
||||
continue
|
||||
}
|
||||
var elapsed time.Duration
|
||||
if !task.CancellingAt.IsZero() {
|
||||
elapsed = now.Sub(task.CancellingAt)
|
||||
if elapsed < cancellingStuckThreshold {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
elapsed = now.Sub(task.StartedAt)
|
||||
if elapsed < cancellingStuckThresholdLegacy {
|
||||
continue
|
||||
}
|
||||
}
|
||||
toFinish = append(toFinish, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, id := range toFinish {
|
||||
m.FinishTask(id, "cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +125,7 @@ func (m *AgentTaskManager) StartTask(conversationID, message string, cancel cont
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// CancelTask 取消指定会话的任务
|
||||
// CancelTask 取消指定会话的任务。若任务已在取消中,仍返回 (true, nil) 以便接口幂等、前端不报错。
|
||||
func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool, error) {
|
||||
m.mu.Lock()
|
||||
task, exists := m.tasks[conversationID]
|
||||
@@ -85,13 +134,14 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 如果已经处于取消流程,直接返回
|
||||
// 如果已经处于取消流程,视为成功(幂等),避免前端重复点击报「未找到任务」
|
||||
if task.Status == "cancelling" {
|
||||
m.mu.Unlock()
|
||||
return false, nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
task.Status = "cancelling"
|
||||
task.CancellingAt = time.Now()
|
||||
cancel := task.cancel
|
||||
m.mu.Unlock()
|
||||
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WebShellHandler 代理执行 WebShell 命令(类似冰蝎/蚁剑),避免前端跨域并统一构建请求
|
||||
type WebShellHandler struct {
|
||||
logger *zap.Logger
|
||||
client *http.Client
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
// NewWebShellHandler 创建 WebShell 处理器,db 可为 nil(连接配置接口将不可用)
|
||||
func NewWebShellHandler(logger *zap.Logger, db *database.DB) *WebShellHandler {
|
||||
return &WebShellHandler{
|
||||
logger: logger,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{DisableKeepAlives: false},
|
||||
},
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateConnectionRequest 创建连接请求
|
||||
type CreateConnectionRequest struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmd_param"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// UpdateConnectionRequest 更新连接请求
|
||||
type UpdateConnectionRequest struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmd_param"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections)
|
||||
func (h *WebShellHandler) ListConnections(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
|
||||
return
|
||||
}
|
||||
list, err := h.db.ListWebshellConnections()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []database.WebShellConnection{}
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// CreateConnection 创建 WebShell 连接(POST /api/webshell/connections)
|
||||
func (h *WebShellHandler) CreateConnection(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
|
||||
return
|
||||
}
|
||||
var req CreateConnectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
if req.URL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"})
|
||||
return
|
||||
}
|
||||
if _, err := url.Parse(req.URL); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
||||
return
|
||||
}
|
||||
method := strings.ToLower(strings.TrimSpace(req.Method))
|
||||
if method != "get" && method != "post" {
|
||||
method = "post"
|
||||
}
|
||||
shellType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if shellType == "" {
|
||||
shellType = "php"
|
||||
}
|
||||
conn := &database.WebShellConnection{
|
||||
ID: "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12],
|
||||
URL: req.URL,
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Type: shellType,
|
||||
Method: method,
|
||||
CmdParam: strings.TrimSpace(req.CmdParam),
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := h.db.CreateWebshellConnection(conn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, conn)
|
||||
}
|
||||
|
||||
// UpdateConnection 更新 WebShell 连接(PUT /api/webshell/connections/:id)
|
||||
func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
var req UpdateConnectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
if req.URL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"})
|
||||
return
|
||||
}
|
||||
if _, err := url.Parse(req.URL); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
||||
return
|
||||
}
|
||||
method := strings.ToLower(strings.TrimSpace(req.Method))
|
||||
if method != "get" && method != "post" {
|
||||
method = "post"
|
||||
}
|
||||
shellType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if shellType == "" {
|
||||
shellType = "php"
|
||||
}
|
||||
conn := &database.WebShellConnection{
|
||||
ID: id,
|
||||
URL: req.URL,
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Type: shellType,
|
||||
Method: method,
|
||||
CmdParam: strings.TrimSpace(req.CmdParam),
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
}
|
||||
if err := h.db.UpdateWebshellConnection(conn); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, _ := h.db.GetWebshellConnection(id)
|
||||
if updated != nil {
|
||||
c.JSON(http.StatusOK, updated)
|
||||
} else {
|
||||
c.JSON(http.StatusOK, conn)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteConnection 删除 WebShell 连接(DELETE /api/webshell/connections/:id)
|
||||
func (h *WebShellHandler) DeleteConnection(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
if err := h.db.DeleteWebshellConnection(id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// GetAIHistory 获取指定 WebShell 连接的 AI 助手对话历史(GET /api/webshell/connections/:id/ai-history)
|
||||
func (h *WebShellHandler) GetAIHistory(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
conv, err := h.db.GetConversationByWebshellConnectionID(id)
|
||||
if err != nil {
|
||||
h.logger.Warn("获取 WebShell AI 对话失败", zap.String("connectionId", id), zap.Error(err))
|
||||
c.JSON(http.StatusOK, gin.H{"conversationId": nil, "messages": []database.Message{}})
|
||||
return
|
||||
}
|
||||
if conv == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"conversationId": nil, "messages": []database.Message{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"conversationId": conv.ID, "messages": conv.Messages})
|
||||
}
|
||||
|
||||
// ListAIConversations 列出该 WebShell 连接下的所有 AI 对话(供侧边栏)
|
||||
func (h *WebShellHandler) ListAIConversations(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
list, err := h.db.ListConversationsByWebshellConnectionID(id)
|
||||
if err != nil {
|
||||
h.logger.Warn("列出 WebShell AI 对话失败", zap.String("connectionId", id), zap.Error(err))
|
||||
c.JSON(http.StatusOK, []database.WebShellConversationItem{})
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []database.WebShellConversationItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// ExecRequest 执行命令请求(前端传入连接信息 + 命令)
|
||||
type ExecRequest struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"` // php, asp, aspx, jsp, custom
|
||||
Method string `json:"method"` // GET 或 POST,空则默认 POST
|
||||
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
|
||||
Command string `json:"command" binding:"required"`
|
||||
}
|
||||
|
||||
// ExecResponse 执行命令响应
|
||||
type ExecResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HTTPCode int `json:"http_code,omitempty"`
|
||||
}
|
||||
|
||||
// FileOpRequest 文件操作请求
|
||||
type FileOpRequest struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"` // GET 或 POST,空则默认 POST
|
||||
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
|
||||
Action string `json:"action" binding:"required"` // list, read, delete, write, mkdir, rename, upload, upload_chunk
|
||||
Path string `json:"path"`
|
||||
TargetPath string `json:"target_path"` // rename 时目标路径
|
||||
Content string `json:"content"` // write/upload 时使用
|
||||
ChunkIndex int `json:"chunk_index"` // upload_chunk 时,0 表示首块
|
||||
}
|
||||
|
||||
// FileOpResponse 文件操作响应
|
||||
type FileOpResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Output string `json:"output"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (h *WebShellHandler) Exec(c *gin.Context) {
|
||||
var req ExecRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
req.Command = strings.TrimSpace(req.Command)
|
||||
if req.URL == "" || req.Command == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "url and command are required"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(req.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url: only http(s) allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
useGET := strings.ToUpper(strings.TrimSpace(req.Method)) == "GET"
|
||||
cmdParam := strings.TrimSpace(req.CmdParam)
|
||||
if cmdParam == "" {
|
||||
cmdParam = "cmd"
|
||||
}
|
||||
var httpReq *http.Request
|
||||
if useGET {
|
||||
targetURL := h.buildExecURL(req.URL, req.Type, req.Password, cmdParam, req.Command)
|
||||
httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil)
|
||||
} else {
|
||||
body := h.buildExecBody(req.Type, req.Password, cmdParam, req.Command)
|
||||
httpReq, err = http.NewRequest(http.MethodPost, req.URL, bytes.NewReader(body))
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Warn("webshell exec NewRequest", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, ExecResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
|
||||
|
||||
resp, err := h.client.Do(httpReq)
|
||||
if err != nil {
|
||||
h.logger.Warn("webshell exec Do", zap.String("url", req.URL), zap.Error(err))
|
||||
c.JSON(http.StatusOK, ExecResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
output := string(out)
|
||||
httpCode := resp.StatusCode
|
||||
|
||||
c.JSON(http.StatusOK, ExecResponse{
|
||||
OK: resp.StatusCode == http.StatusOK,
|
||||
Output: output,
|
||||
HTTPCode: httpCode,
|
||||
})
|
||||
}
|
||||
|
||||
// buildExecBody 按常见 WebShell 约定构建 POST 体(多数使用 pass + cmd,可配置命令参数名)
|
||||
func (h *WebShellHandler) buildExecBody(shellType, password, cmdParam, command string) []byte {
|
||||
form := h.execParams(shellType, password, cmdParam, command)
|
||||
return []byte(form.Encode())
|
||||
}
|
||||
|
||||
// buildExecURL 构建 GET 请求的完整 URL(baseURL + ?pass=xxx&cmd=yyy,cmd 可配置)
|
||||
func (h *WebShellHandler) buildExecURL(baseURL, shellType, password, cmdParam, command string) string {
|
||||
form := h.execParams(shellType, password, cmdParam, command)
|
||||
if parsed, err := url.Parse(baseURL); err == nil {
|
||||
parsed.RawQuery = form.Encode()
|
||||
return parsed.String()
|
||||
}
|
||||
return baseURL + "?" + form.Encode()
|
||||
}
|
||||
|
||||
func (h *WebShellHandler) execParams(shellType, password, cmdParam, command string) url.Values {
|
||||
shellType = strings.ToLower(strings.TrimSpace(shellType))
|
||||
if shellType == "" {
|
||||
shellType = "php"
|
||||
}
|
||||
if strings.TrimSpace(cmdParam) == "" {
|
||||
cmdParam = "cmd"
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("pass", password)
|
||||
form.Set(cmdParam, command)
|
||||
return form
|
||||
}
|
||||
|
||||
func (h *WebShellHandler) FileOp(c *gin.Context) {
|
||||
var req FileOpRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.URL = strings.TrimSpace(req.URL)
|
||||
req.Action = strings.ToLower(strings.TrimSpace(req.Action))
|
||||
if req.URL == "" || req.Action == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "url and action are required"})
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(req.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url: only http(s) allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// 通过执行系统命令实现文件操作(与通用一句话兼容)
|
||||
var command string
|
||||
shellType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
switch req.Action {
|
||||
case "list":
|
||||
path := strings.TrimSpace(req.Path)
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
if shellType == "asp" || shellType == "aspx" {
|
||||
command = "dir " + h.escapePath(path)
|
||||
} else {
|
||||
command = "ls -la " + h.escapePath(path)
|
||||
}
|
||||
case "read":
|
||||
if shellType == "asp" || shellType == "aspx" {
|
||||
command = "type " + h.escapePath(strings.TrimSpace(req.Path))
|
||||
} else {
|
||||
command = "cat " + h.escapePath(strings.TrimSpace(req.Path))
|
||||
}
|
||||
case "delete":
|
||||
if shellType == "asp" || shellType == "aspx" {
|
||||
command = "del " + h.escapePath(strings.TrimSpace(req.Path))
|
||||
} else {
|
||||
command = "rm -f " + h.escapePath(strings.TrimSpace(req.Path))
|
||||
}
|
||||
case "write":
|
||||
path := h.escapePath(strings.TrimSpace(req.Path))
|
||||
command = "echo " + h.escapeForEcho(req.Content) + " > " + path
|
||||
case "mkdir":
|
||||
path := strings.TrimSpace(req.Path)
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for mkdir"})
|
||||
return
|
||||
}
|
||||
if shellType == "asp" || shellType == "aspx" {
|
||||
command = "md " + h.escapePath(path)
|
||||
} else {
|
||||
command = "mkdir -p " + h.escapePath(path)
|
||||
}
|
||||
case "rename":
|
||||
oldPath := strings.TrimSpace(req.Path)
|
||||
newPath := strings.TrimSpace(req.TargetPath)
|
||||
if oldPath == "" || newPath == "" {
|
||||
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path and target_path are required for rename"})
|
||||
return
|
||||
}
|
||||
if shellType == "asp" || shellType == "aspx" {
|
||||
command = "move /y " + h.escapePath(oldPath) + " " + h.escapePath(newPath)
|
||||
} else {
|
||||
command = "mv " + h.escapePath(oldPath) + " " + h.escapePath(newPath)
|
||||
}
|
||||
case "upload":
|
||||
path := strings.TrimSpace(req.Path)
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for upload"})
|
||||
return
|
||||
}
|
||||
if len(req.Content) > 512*1024 {
|
||||
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "upload content too large (max 512KB base64)"})
|
||||
return
|
||||
}
|
||||
// base64 仅含 A-Za-z0-9+/=,用单引号包裹安全
|
||||
command = "echo " + "'" + req.Content + "'" + " | base64 -d > " + h.escapePath(path)
|
||||
case "upload_chunk":
|
||||
path := strings.TrimSpace(req.Path)
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for upload_chunk"})
|
||||
return
|
||||
}
|
||||
redir := ">>"
|
||||
if req.ChunkIndex == 0 {
|
||||
redir = ">"
|
||||
}
|
||||
command = "echo " + "'" + req.Content + "'" + " | base64 -d " + redir + " " + h.escapePath(path)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "unsupported action: " + req.Action})
|
||||
return
|
||||
}
|
||||
|
||||
useGET := strings.ToUpper(strings.TrimSpace(req.Method)) == "GET"
|
||||
cmdParam := strings.TrimSpace(req.CmdParam)
|
||||
if cmdParam == "" {
|
||||
cmdParam = "cmd"
|
||||
}
|
||||
var httpReq *http.Request
|
||||
if useGET {
|
||||
targetURL := h.buildExecURL(req.URL, req.Type, req.Password, cmdParam, command)
|
||||
httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil)
|
||||
} else {
|
||||
body := h.buildExecBody(req.Type, req.Password, cmdParam, command)
|
||||
httpReq, err = http.NewRequest(http.MethodPost, req.URL, bytes.NewReader(body))
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, FileOpResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
|
||||
|
||||
resp, err := h.client.Do(httpReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, FileOpResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
output := string(out)
|
||||
|
||||
c.JSON(http.StatusOK, FileOpResponse{
|
||||
OK: resp.StatusCode == http.StatusOK,
|
||||
Output: output,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebShellHandler) escapePath(p string) string {
|
||||
if p == "" {
|
||||
return "."
|
||||
}
|
||||
// 简单转义空格与敏感字符,避免命令注入
|
||||
return "'" + strings.ReplaceAll(p, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func (h *WebShellHandler) escapeForEcho(s string) string {
|
||||
// 仅用于 write:base64 写入更安全,这里简单用单引号包裹
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
// ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用)
|
||||
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
|
||||
if conn == nil {
|
||||
return "", false, "connection is nil"
|
||||
}
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
return "", false, "command is required"
|
||||
}
|
||||
useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET"
|
||||
cmdParam := strings.TrimSpace(conn.CmdParam)
|
||||
if cmdParam == "" {
|
||||
cmdParam = "cmd"
|
||||
}
|
||||
var httpReq *http.Request
|
||||
var err error
|
||||
if useGET {
|
||||
targetURL := h.buildExecURL(conn.URL, conn.Type, conn.Password, cmdParam, command)
|
||||
httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil)
|
||||
} else {
|
||||
body := h.buildExecBody(conn.Type, conn.Password, cmdParam, command)
|
||||
httpReq, err = http.NewRequest(http.MethodPost, conn.URL, bytes.NewReader(body))
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err.Error()
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
|
||||
resp, err := h.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
return string(out), resp.StatusCode == http.StatusOK, ""
|
||||
}
|
||||
|
||||
// FileOpWithConnection 在指定 WebShell 连接上执行文件操作(供 MCP/Agent 调用),支持 list / read / write
|
||||
func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection, action, path, content, targetPath string) (output string, ok bool, errMsg string) {
|
||||
if conn == nil {
|
||||
return "", false, "connection is nil"
|
||||
}
|
||||
action = strings.ToLower(strings.TrimSpace(action))
|
||||
shellType := strings.ToLower(strings.TrimSpace(conn.Type))
|
||||
if shellType == "" {
|
||||
shellType = "php"
|
||||
}
|
||||
var command string
|
||||
switch action {
|
||||
case "list":
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
if shellType == "asp" || shellType == "aspx" {
|
||||
command = "dir " + h.escapePath(strings.TrimSpace(path))
|
||||
} else {
|
||||
command = "ls -la " + h.escapePath(strings.TrimSpace(path))
|
||||
}
|
||||
case "read":
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", false, "path is required for read"
|
||||
}
|
||||
if shellType == "asp" || shellType == "aspx" {
|
||||
command = "type " + h.escapePath(path)
|
||||
} else {
|
||||
command = "cat " + h.escapePath(path)
|
||||
}
|
||||
case "write":
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", false, "path is required for write"
|
||||
}
|
||||
command = "echo " + h.escapeForEcho(content) + " > " + h.escapePath(path)
|
||||
default:
|
||||
return "", false, "unsupported action: " + action + " (supported: list, read, write)"
|
||||
}
|
||||
useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET"
|
||||
cmdParam := strings.TrimSpace(conn.CmdParam)
|
||||
if cmdParam == "" {
|
||||
cmdParam = "cmd"
|
||||
}
|
||||
var httpReq *http.Request
|
||||
var err error
|
||||
if useGET {
|
||||
targetURL := h.buildExecURL(conn.URL, conn.Type, conn.Password, cmdParam, command)
|
||||
httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil)
|
||||
} else {
|
||||
body := h.buildExecBody(conn.Type, conn.Password, cmdParam, command)
|
||||
httpReq, err = http.NewRequest(http.MethodPost, conn.URL, bytes.NewReader(body))
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err.Error()
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
|
||||
resp, err := h.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", false, err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
return string(out), resp.StatusCode == http.StatusOK, ""
|
||||
}
|
||||
+149
-31
@@ -6,39 +6,75 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Embedder 文本嵌入器
|
||||
type Embedder struct {
|
||||
openAIClient *openai.Client
|
||||
config *config.KnowledgeConfig
|
||||
openAIConfig *config.OpenAIConfig // 用于获取API Key
|
||||
logger *zap.Logger
|
||||
openAIClient *openai.Client
|
||||
config *config.KnowledgeConfig
|
||||
openAIConfig *config.OpenAIConfig // 用于获取 API Key
|
||||
logger *zap.Logger
|
||||
rateLimiter *rate.Limiter // 速率限制器
|
||||
rateLimitDelay time.Duration // 请求间隔时间
|
||||
maxRetries int // 最大重试次数
|
||||
retryDelay time.Duration // 重试间隔
|
||||
mu sync.Mutex // 保护 rateLimiter
|
||||
}
|
||||
|
||||
// NewEmbedder 创建新的嵌入器
|
||||
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{
|
||||
openAIClient: openAIClient,
|
||||
config: cfg,
|
||||
openAIConfig: openAIConfig,
|
||||
logger: logger,
|
||||
openAIClient: openAIClient,
|
||||
config: cfg,
|
||||
openAIConfig: openAIConfig,
|
||||
logger: logger,
|
||||
rateLimiter: rateLimiter,
|
||||
rateLimitDelay: rateLimitDelay,
|
||||
maxRetries: maxRetries,
|
||||
retryDelay: retryDelay,
|
||||
}
|
||||
}
|
||||
|
||||
// EmbeddingRequest OpenAI嵌入请求
|
||||
// EmbeddingRequest OpenAI 嵌入请求
|
||||
type EmbeddingRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
|
||||
// EmbeddingResponse OpenAI嵌入响应
|
||||
// EmbeddingResponse OpenAI 嵌入响应
|
||||
type EmbeddingResponse struct {
|
||||
Data []EmbeddingData `json:"data"`
|
||||
Error *EmbeddingError `json:"error,omitempty"`
|
||||
@@ -56,12 +92,69 @@ type EmbeddingError struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// EmbedText 对文本进行嵌入
|
||||
func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error) {
|
||||
if e.openAIClient == nil {
|
||||
return nil, fmt.Errorf("OpenAI客户端未初始化")
|
||||
// waitRateLimiter 等待速率限制器
|
||||
func (e *Embedder) waitRateLimiter() {
|
||||
e.mu.Lock()
|
||||
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
|
||||
if model == "" {
|
||||
@@ -73,7 +166,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
Input: []string{text},
|
||||
}
|
||||
|
||||
// 清理baseURL:去除前后空格和尾部斜杠
|
||||
// 清理 baseURL:去除前后空格和尾部斜杠
|
||||
baseURL := strings.TrimSpace(e.config.Embedding.BaseURL)
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
if baseURL == "" {
|
||||
@@ -83,24 +176,24 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
// 构建请求
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
return nil, fmt.Errorf("序列化请求失败:%w", err)
|
||||
}
|
||||
|
||||
requestURL := baseURL + "/embeddings"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
return nil, fmt.Errorf("创建请求失败:%w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 使用配置的API Key,如果没有则使用OpenAI配置的
|
||||
|
||||
// 使用配置的 API Key,如果没有则使用 OpenAI 配置的
|
||||
apiKey := strings.TrimSpace(e.config.Embedding.APIKey)
|
||||
if apiKey == "" && e.openAIConfig != nil {
|
||||
apiKey = e.openAIConfig.APIKey
|
||||
}
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("API Key未配置")
|
||||
return nil, fmt.Errorf("API Key 未配置")
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求失败: %w", err)
|
||||
return nil, fmt.Errorf("发送请求失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -132,7 +225,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
if len(requestBodyPreview) > 200 {
|
||||
requestBodyPreview = requestBodyPreview[:200] + "..."
|
||||
}
|
||||
e.logger.Debug("嵌入API请求",
|
||||
e.logger.Debug("嵌入 API 请求",
|
||||
zap.String("url", httpReq.URL.String()),
|
||||
zap.String("model", model),
|
||||
zap.String("requestBody", requestBodyPreview),
|
||||
@@ -148,12 +241,12 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
if len(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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -162,7 +255,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
if len(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 {
|
||||
@@ -170,11 +263,11 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
if len(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)
|
||||
}
|
||||
|
||||
// 转换为float32
|
||||
// 转换为 float32
|
||||
embedding := make([]float32, len(embeddingResp.Data[0].Embedding))
|
||||
for i, v := range embeddingResp.Data[0].Embedding {
|
||||
embedding[i] = float32(v)
|
||||
@@ -183,23 +276,48 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
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 批量嵌入文本
|
||||
func (e *Embedder) EmbedTexts(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
if len(texts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// OpenAI API支持批量,但为了简单起见,我们逐个处理
|
||||
// 实际可以使用批量API以提高效率
|
||||
embeddings := make([][]float32, len(texts))
|
||||
for i, text := range texts {
|
||||
embedding, err := e.EmbedText(ctx, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("嵌入文本[%d]失败: %w", i, err)
|
||||
return nil, fmt.Errorf("嵌入文本 [%d] 失败:%w", i, err)
|
||||
}
|
||||
embeddings[i] = embedding
|
||||
}
|
||||
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
|
||||
+382
-98
@@ -10,56 +10,133 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Indexer 索引器,负责将知识项分块并向量化
|
||||
type Indexer struct {
|
||||
db *sql.DB
|
||||
embedder *Embedder
|
||||
logger *zap.Logger
|
||||
chunkSize int // 每个块的最大token数(估算)
|
||||
overlap int // 块之间的重叠token数
|
||||
|
||||
db *sql.DB
|
||||
embedder *Embedder
|
||||
logger *zap.Logger
|
||||
chunkSize int // 每个块的最大 token 数(估算)
|
||||
overlap int // 块之间的重叠 token 数
|
||||
maxChunks int // 单个知识项的最大块数量(0 表示不限制)
|
||||
|
||||
// 错误跟踪
|
||||
mu sync.RWMutex
|
||||
lastError string // 最近一次错误信息
|
||||
mu sync.RWMutex
|
||||
lastError string // 最近一次错误信息
|
||||
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 创建新的索引器
|
||||
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{
|
||||
db: db,
|
||||
embedder: embedder,
|
||||
logger: logger,
|
||||
chunkSize: 512, // 默认512 tokens
|
||||
overlap: 50, // 默认50 tokens重叠
|
||||
chunkSize: chunkSize,
|
||||
overlap: overlap,
|
||||
maxChunks: maxChunks,
|
||||
}
|
||||
}
|
||||
|
||||
// ChunkText 将文本分块(支持重叠)
|
||||
// ChunkText 将文本分块(支持重叠,保留标题上下文)
|
||||
func (idx *Indexer) ChunkText(text string) []string {
|
||||
// 按Markdown标题分割
|
||||
chunks := idx.splitByMarkdownHeaders(text)
|
||||
// 按 Markdown 标题分割,获取带标题的块
|
||||
sections := idx.splitByMarkdownHeadersWithContent(text)
|
||||
|
||||
// 如果块太大,进一步分割
|
||||
// 处理每个块
|
||||
result := make([]string, 0)
|
||||
for _, chunk := range chunks {
|
||||
if idx.estimateTokens(chunk) <= idx.chunkSize {
|
||||
result = append(result, chunk)
|
||||
for _, section := range sections {
|
||||
// 构建父级标题路径(不包含最后一级标题,因为内容中已经包含)
|
||||
// 例如:["# 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 {
|
||||
// 按段落分割
|
||||
subChunks := idx.splitByParagraphs(chunk)
|
||||
for _, subChunk := range subChunks {
|
||||
if idx.estimateTokens(subChunk) <= idx.chunkSize {
|
||||
result = append(result, subChunk)
|
||||
} else {
|
||||
// 按句子分割(支持重叠)
|
||||
chunksWithOverlap := idx.splitBySentencesWithOverlap(subChunk)
|
||||
result = append(result, chunksWithOverlap...)
|
||||
// 块太大,按子标题或段落分割,保持标题上下文
|
||||
// 首先尝试按子标题分割(保留子标题结构)
|
||||
subSections := idx.splitBySubHeaders(section.Content, firstLine, parentHeaderPath)
|
||||
if len(subSections) > 1 {
|
||||
// 成功按子标题分割,递归处理每个子块
|
||||
for _, sub := range subSections {
|
||||
if idx.estimateTokens(sub) <= idx.chunkSize {
|
||||
result = append(result, sub)
|
||||
} 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
|
||||
}
|
||||
|
||||
// splitByMarkdownHeaders 按Markdown标题分割
|
||||
func (idx *Indexer) splitByMarkdownHeaders(text string) []string {
|
||||
// 匹配Markdown标题 (# ## ### 等)
|
||||
// extractFirstLine 提取第一行内容和剩余内容
|
||||
func extractFirstLine(content string) (firstLine, remaining string) {
|
||||
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+.+$`)
|
||||
|
||||
// 找到所有标题位置
|
||||
matches := headerRegex.FindAllStringIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return []string{text}
|
||||
// 没有标题,返回整个文本
|
||||
return []Section{{HeaderPath: []string{}, Content: text}}
|
||||
}
|
||||
|
||||
chunks := make([]string, 0)
|
||||
lastPos := 0
|
||||
sections := make([]Section, 0, len(matches))
|
||||
currentHeaderPath := []string{}
|
||||
|
||||
for _, match := range matches {
|
||||
for i, match := range matches {
|
||||
start := match[0]
|
||||
if start > lastPos {
|
||||
chunks = append(chunks, strings.TrimSpace(text[lastPos:start]))
|
||||
}
|
||||
lastPos = start
|
||||
}
|
||||
end := match[1]
|
||||
nextStart := len(text)
|
||||
|
||||
// 添加最后一部分
|
||||
if lastPos < len(text) {
|
||||
chunks = append(chunks, strings.TrimSpace(text[lastPos:]))
|
||||
// 找到下一个标题的位置
|
||||
if i+1 < len(matches) {
|
||||
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)
|
||||
for _, chunk := range chunks {
|
||||
if strings.TrimSpace(chunk) != "" {
|
||||
result = append(result, chunk)
|
||||
result := make([]Section, 0, len(sections))
|
||||
for _, section := range sections {
|
||||
if strings.TrimSpace(section.Content) != "" {
|
||||
result = append(result, section)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return []string{text}
|
||||
return []Section{{HeaderPath: []string{}, Content: text}}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -124,8 +341,12 @@ func (idx *Indexer) splitByParagraphs(text string) []string {
|
||||
|
||||
// splitBySentences 按句子分割(用于内部,不包含重叠逻辑)
|
||||
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)
|
||||
result := make([]string, 0)
|
||||
for _, s := range sentences {
|
||||
@@ -221,13 +442,13 @@ func (idx *Indexer) splitBySentencesSimple(text string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// extractLastTokens 从文本末尾提取指定token数量的内容
|
||||
// extractLastTokens 从文本末尾提取指定 token 数量的内容
|
||||
func (idx *Indexer) extractLastTokens(text string, tokenCount int) string {
|
||||
if tokenCount <= 0 || text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 估算字符数(1 token ≈ 4字符)
|
||||
// 估算字符数(1 token ≈ 4 字符)
|
||||
charCount := tokenCount * 4
|
||||
runes := []rune(text)
|
||||
|
||||
@@ -236,12 +457,11 @@ func (idx *Indexer) extractLastTokens(text string, tokenCount int) string {
|
||||
}
|
||||
|
||||
// 从末尾提取指定数量的字符
|
||||
// 尝试在句子边界处截断,避免截断句子中间
|
||||
startPos := len(runes) - charCount
|
||||
extracted := string(runes[startPos:])
|
||||
|
||||
// 尝试找到第一个句子边界(句号、问号、感叹号后的空格)
|
||||
sentenceBoundary := regexp.MustCompile(`[.!?]+\s+`)
|
||||
// 尝试找到第一个句子边界(支持中英文标点)
|
||||
sentenceBoundary := regexp.MustCompile(`[.!?\x{3002}\x{FF01}\x{FF1F}]+`)
|
||||
matches := sentenceBoundary.FindStringIndex(extracted)
|
||||
if len(matches) > 0 && matches[0] > 0 {
|
||||
// 在句子边界处截断,保留完整句子
|
||||
@@ -251,41 +471,51 @@ func (idx *Indexer) extractLastTokens(text string, tokenCount int) string {
|
||||
return strings.TrimSpace(extracted)
|
||||
}
|
||||
|
||||
// estimateTokens 估算token数(简单估算:1 token ≈ 4字符)
|
||||
// estimateTokens 估算 token 数(简单估算:1 token ≈ 4 字符)
|
||||
func (idx *Indexer) estimateTokens(text string) int {
|
||||
return len([]rune(text)) / 4
|
||||
}
|
||||
|
||||
// IndexItem 索引知识项(分块并向量化)
|
||||
func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error {
|
||||
// 获取知识项(包含category和title,用于向量化)
|
||||
// 获取知识项(包含 category 和 title,用于向量化)
|
||||
var content, category, title string
|
||||
err := idx.db.QueryRow("SELECT content, category, title FROM knowledge_base_items WHERE id = ?", itemID).Scan(&content, &category, &title)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取知识项失败: %w", err)
|
||||
return fmt.Errorf("获取知识项失败:%w", err)
|
||||
}
|
||||
|
||||
// 删除旧的向量(在 RebuildIndex 中已经统一清空,这里保留是为了单独调用 IndexItem 时的兼容性)
|
||||
_, err = idx.db.Exec("DELETE FROM knowledge_embeddings WHERE item_id = ?", itemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除旧向量失败: %w", err)
|
||||
return fmt.Errorf("删除旧向量失败:%w", err)
|
||||
}
|
||||
|
||||
// 分块
|
||||
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)))
|
||||
|
||||
// 跟踪该知识项的错误
|
||||
itemErrorCount := 0
|
||||
var firstError error
|
||||
firstErrorChunkIndex := -1
|
||||
|
||||
// 向量化每个块(包含category和title信息,以便向量检索时能匹配到风险类型)
|
||||
|
||||
// 向量化每个块(包含 category 和 title 信息,以便向量检索时能匹配到风险类型)
|
||||
for i, chunk := range chunks {
|
||||
// 将category和title信息包含到向量化的文本中
|
||||
// 格式:"[风险类型: {category}] [标题: {title}]\n{chunk内容}"
|
||||
// 这样向量嵌入就会包含风险类型信息,即使SQL过滤失败,向量相似度也能帮助匹配
|
||||
textForEmbedding := fmt.Sprintf("[风险类型: %s] [标题: %s]\n%s", category, title, chunk)
|
||||
// 将 category 和 title 信息包含到向量化的文本中
|
||||
// 格式:"[风险类型:{category}] [标题:{title}]\n{chunk 内容}"
|
||||
// 这样向量嵌入就会包含风险类型信息,即使 SQL 过滤失败,向量相似度也能帮助匹配
|
||||
textForEmbedding := fmt.Sprintf("[风险类型:%s] [标题:%s]\n%s", category, title, chunk)
|
||||
|
||||
embedding, err := idx.embedder.EmbedText(ctx, textForEmbedding)
|
||||
if err != nil {
|
||||
@@ -305,18 +535,30 @@ func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error {
|
||||
zap.String("chunkPreview", chunkPreview),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
|
||||
// 更新全局错误跟踪
|
||||
errorMsg := fmt.Sprintf("向量化失败 (知识项: %s): %v", itemID, err)
|
||||
errorMsg := fmt.Sprintf("向量化失败 (知识项:%s): %v", itemID, err)
|
||||
idx.mu.Lock()
|
||||
idx.lastError = errorMsg
|
||||
idx.lastErrorTime = time.Now()
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
|
||||
// 如果连续失败2个块,立即停止处理该知识项(降低阈值,更快停止)
|
||||
// 这样可以避免继续浪费API调用,同时也能更快地检测到配置问题
|
||||
if itemErrorCount >= 2 {
|
||||
|
||||
// 如果连续失败 5 个块,立即停止处理该知识项
|
||||
// 这样可以避免继续浪费 API 调用,同时也能更快地检测到配置问题
|
||||
// 对于大文档(超过 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("知识项连续向量化失败,停止处理",
|
||||
zap.String("itemId", itemID),
|
||||
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.rebuildMu.Lock()
|
||||
idx.rebuildLastItemID = itemID
|
||||
idx.rebuildLastChunks = len(chunks)
|
||||
idx.rebuildMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -352,23 +601,38 @@ func (idx *Indexer) HasIndex() (bool, error) {
|
||||
var count int
|
||||
err := idx.db.QueryRow("SELECT COUNT(*) FROM knowledge_embeddings").Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("检查索引失败: %w", err)
|
||||
return false, fmt.Errorf("检查索引失败:%w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// RebuildIndex 重建所有索引
|
||||
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.lastError = ""
|
||||
idx.lastErrorTime = time.Time{}
|
||||
idx.errorCount = 0
|
||||
idx.mu.Unlock()
|
||||
|
||||
|
||||
rows, err := idx.db.Query("SELECT id FROM knowledge_base_items")
|
||||
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()
|
||||
|
||||
@@ -376,34 +640,36 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
|
||||
for rows.Next() {
|
||||
var id string
|
||||
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)
|
||||
}
|
||||
|
||||
idx.rebuildMu.Lock()
|
||||
idx.rebuildTotalItems = len(itemIDs)
|
||||
idx.rebuildMu.Unlock()
|
||||
|
||||
idx.logger.Info("开始重建索引", zap.Int("totalItems", len(itemIDs)))
|
||||
|
||||
// 在开始重建前,先清空所有旧的向量,确保进度从0开始
|
||||
// 这样 GetIndexStatus 可以准确反映重建进度
|
||||
_, err = idx.db.Exec("DELETE FROM knowledge_embeddings")
|
||||
if err != nil {
|
||||
idx.logger.Warn("清空旧索引失败", zap.Error(err))
|
||||
// 继续执行,即使清空失败也尝试重建
|
||||
} else {
|
||||
idx.logger.Info("已清空旧索引,开始重建")
|
||||
}
|
||||
// 注意:不再清空所有旧索引,而是按增量方式更新
|
||||
// 每个知识项在 IndexItem 中会先删除自己的旧向量,然后插入新向量
|
||||
// 这样配置更新后只重新索引变化的知识项,保留其他知识项的索引
|
||||
|
||||
failedCount := 0
|
||||
consecutiveFailures := 0
|
||||
maxConsecutiveFailures := 2 // 连续失败2次后立即停止(降低阈值,更快停止)
|
||||
maxConsecutiveFailures := 5 // 连续失败 5 次后立即停止(允许偶尔的临时错误)
|
||||
firstFailureItemID := ""
|
||||
var firstFailureError error
|
||||
|
||||
|
||||
for i, itemID := range itemIDs {
|
||||
if err := idx.IndexItem(ctx, itemID); err != nil {
|
||||
failedCount++
|
||||
consecutiveFailures++
|
||||
|
||||
|
||||
// 只在第一个失败时记录详细日志
|
||||
if consecutiveFailures == 1 {
|
||||
firstFailureItemID = itemID
|
||||
@@ -414,15 +680,15 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 如果连续失败过多,可能是配置问题,立即停止索引
|
||||
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.lastError = errorMsg
|
||||
idx.lastErrorTime = time.Now()
|
||||
idx.mu.Unlock()
|
||||
|
||||
|
||||
idx.logger.Error("连续索引失败次数过多,立即停止索引",
|
||||
zap.Int("consecutiveFailures", consecutiveFailures),
|
||||
zap.Int("totalItems", len(itemIDs)),
|
||||
@@ -430,17 +696,17 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
|
||||
zap.String("firstFailureItemId", firstFailureItemID),
|
||||
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 {
|
||||
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.lastError = errorMsg
|
||||
idx.lastErrorTime = time.Now()
|
||||
idx.mu.Unlock()
|
||||
|
||||
|
||||
idx.logger.Error("索引失败的知识项过多,可能存在配置问题",
|
||||
zap.Int("failedCount", failedCount),
|
||||
zap.Int("totalItems", len(itemIDs)),
|
||||
@@ -450,20 +716,31 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// 成功时重置连续失败计数和第一个失败信息
|
||||
if consecutiveFailures > 0 {
|
||||
consecutiveFailures = 0
|
||||
firstFailureItemID = ""
|
||||
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) {
|
||||
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))
|
||||
return nil
|
||||
}
|
||||
@@ -474,3 +751,10 @@ func (idx *Indexer) GetLastError() (string, time.Time) {
|
||||
defer idx.mu.RUnlock()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -657,7 +657,7 @@ func (m *Manager) UpdateItem(id, category, title, content string) (*KnowledgeIte
|
||||
|
||||
// 删除旧目录(如果为空)
|
||||
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 err := os.Remove(oldDir); err != nil {
|
||||
@@ -712,7 +712,7 @@ func (m *Manager) DeleteItem(id string) error {
|
||||
|
||||
// 删除空目录(如果为空)
|
||||
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 err := os.Remove(dir); err != nil {
|
||||
@@ -724,6 +724,21 @@ func (m *Manager) DeleteItem(id string) error {
|
||||
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 记录检索日志
|
||||
func (m *Manager) LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error {
|
||||
id := uuid.New().String()
|
||||
|
||||
@@ -69,8 +69,8 @@ func cosineSimilarity(a, b []float32) float64 {
|
||||
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
// bm25Score 计算BM25分数(改进版,更接近标准BM25)
|
||||
// 注意:这是单文档版本的BM25,缺少全局IDF,但比之前的简化版本更准确
|
||||
// bm25Score 计算 BM25 分数(带缓存的改进版本)
|
||||
// 注意:由于缺少全局文档统计,使用简化 IDF 计算
|
||||
func (r *Retriever) bm25Score(query, text string) float64 {
|
||||
queryTerms := strings.Fields(strings.ToLower(query))
|
||||
if len(queryTerms) == 0 {
|
||||
@@ -83,44 +83,56 @@ func (r *Retriever) bm25Score(query, text string) float64 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// BM25参数
|
||||
k1 := 1.5 // 词频饱和度参数
|
||||
b := 0.75 // 长度归一化参数
|
||||
avgDocLength := 100.0 // 估算的平均文档长度(用于归一化)
|
||||
// BM25 参数(标准值)
|
||||
k1 := 1.2 // 词频饱和度参数(标准范围 1.2-2.0)
|
||||
b := 0.75 // 长度归一化参数(标准值)
|
||||
avgDocLength := 150.0 // 估算的平均文档长度(基于典型知识块大小)
|
||||
docLength := float64(len(textTerms))
|
||||
|
||||
score := 0.0
|
||||
for _, term := range queryTerms {
|
||||
// 计算词频(TF)
|
||||
termFreq := 0
|
||||
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
|
||||
}
|
||||
// 计算词频映射
|
||||
textTermFreq := make(map[string]int, len(textTerms))
|
||||
for _, term := range textTerms {
|
||||
textTermFreq[term]++
|
||||
}
|
||||
|
||||
// 归一化到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 {
|
||||
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)
|
||||
@@ -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
|
||||
FROM knowledge_embeddings e
|
||||
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)
|
||||
} else {
|
||||
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("maxSimilarity", maxSimilarity),
|
||||
)
|
||||
} else if len(filteredCandidates) > topK {
|
||||
}
|
||||
|
||||
// 统一在最终返回前严格限制 Top-K 数量
|
||||
if len(filteredCandidates) > topK {
|
||||
// 如果过滤后结果太多,只取Top-K
|
||||
filteredCandidates = filteredCandidates[:topK]
|
||||
}
|
||||
|
||||
+24
-42
@@ -5,6 +5,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// formatTime 格式化时间为 RFC3339 格式,零时间返回空字符串
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// KnowledgeItem 知识库项
|
||||
type KnowledgeItem struct {
|
||||
ID string `json:"id"`
|
||||
@@ -22,12 +30,12 @@ type KnowledgeItemSummary struct {
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
FilePath string `json:"filePath"`
|
||||
Content string `json:"content,omitempty"` // 可选:内容预览(如果提供,通常只包含前150字符)
|
||||
Content string `json:"content,omitempty"` // 可选:内容预览(如果提供,通常只包含前 150 字符)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// MarshalJSON 自定义JSON序列化,确保时间格式正确
|
||||
// MarshalJSON 自定义 JSON 序列化,确保时间格式正确
|
||||
func (k *KnowledgeItemSummary) MarshalJSON() ([]byte, error) {
|
||||
type Alias KnowledgeItemSummary
|
||||
aux := &struct {
|
||||
@@ -37,25 +45,12 @@ func (k *KnowledgeItemSummary) MarshalJSON() ([]byte, error) {
|
||||
}{
|
||||
Alias: (*Alias)(k),
|
||||
}
|
||||
|
||||
// 格式化创建时间
|
||||
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)
|
||||
}
|
||||
|
||||
aux.CreatedAt = formatTime(k.CreatedAt)
|
||||
aux.UpdatedAt = formatTime(k.UpdatedAt)
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
// MarshalJSON 自定义JSON序列化,确保时间格式正确
|
||||
// MarshalJSON 自定义 JSON 序列化,确保时间格式正确
|
||||
func (k *KnowledgeItem) MarshalJSON() ([]byte, error) {
|
||||
type Alias KnowledgeItem
|
||||
aux := &struct {
|
||||
@@ -65,21 +60,8 @@ func (k *KnowledgeItem) MarshalJSON() ([]byte, error) {
|
||||
}{
|
||||
Alias: (*Alias)(k),
|
||||
}
|
||||
|
||||
// 格式化创建时间
|
||||
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)
|
||||
}
|
||||
|
||||
aux.CreatedAt = formatTime(k.CreatedAt)
|
||||
aux.UpdatedAt = formatTime(k.UpdatedAt)
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
@@ -89,7 +71,7 @@ type KnowledgeChunk struct {
|
||||
ItemID string `json:"itemId"`
|
||||
ChunkIndex int `json:"chunkIndex"`
|
||||
ChunkText string `json:"chunkText"`
|
||||
Embedding []float32 `json:"-"` // 向量嵌入,不序列化到JSON
|
||||
Embedding []float32 `json:"-"` // 向量嵌入,不序列化到 JSON
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -108,11 +90,11 @@ type RetrievalLog struct {
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Query string `json:"query"`
|
||||
RiskType string `json:"riskType,omitempty"`
|
||||
RetrievedItems []string `json:"retrievedItems"` // 检索到的知识项ID列表
|
||||
RetrievedItems []string `json:"retrievedItems"` // 检索到的知识项 ID 列表
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// MarshalJSON 自定义JSON序列化,确保时间格式正确
|
||||
// MarshalJSON 自定义 JSON 序列化,确保时间格式正确
|
||||
func (r *RetrievalLog) MarshalJSON() ([]byte, error) {
|
||||
type Alias RetrievalLog
|
||||
return json.Marshal(&struct {
|
||||
@@ -120,21 +102,21 @@ func (r *RetrievalLog) MarshalJSON() ([]byte, error) {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}{
|
||||
Alias: (*Alias)(r),
|
||||
CreatedAt: r.CreatedAt.Format(time.RFC3339),
|
||||
CreatedAt: formatTime(r.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// CategoryWithItems 分类及其下的知识项(用于按分类分页)
|
||||
type CategoryWithItems struct {
|
||||
Category string `json:"category"` // 分类名称
|
||||
ItemCount int `json:"itemCount"` // 该分类下的知识项总数
|
||||
Items []*KnowledgeItemSummary `json:"items"` // 该分类下的知识项列表
|
||||
Category string `json:"category"` // 分类名称
|
||||
ItemCount int `json:"itemCount"` // 该分类下的知识项总数
|
||||
Items []*KnowledgeItemSummary `json:"items"` // 该分类下的知识项列表
|
||||
}
|
||||
|
||||
// SearchRequest 搜索请求
|
||||
type SearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
RiskType string `json:"riskType,omitempty"` // 可选:指定风险类型
|
||||
TopK int `json:"topK,omitempty"` // 返回Top-K结果,默认5
|
||||
Threshold float64 `json:"threshold,omitempty"` // 相似度阈值,默认0.7
|
||||
TopK int `json:"topK,omitempty"` // 返回 Top-K 结果,默认 5
|
||||
Threshold float64 `json:"threshold,omitempty"` // 相似度阈值,默认 0.7
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ const (
|
||||
// Skills工具
|
||||
ToolListSkills = "list_skills"
|
||||
ToolReadSkill = "read_skill"
|
||||
|
||||
// WebShell 助手工具(AI 在 WebShell 管理 - AI 助手 中使用)
|
||||
ToolWebshellExec = "webshell_exec"
|
||||
ToolWebshellFileList = "webshell_file_list"
|
||||
ToolWebshellFileRead = "webshell_file_read"
|
||||
ToolWebshellFileWrite = "webshell_file_write"
|
||||
)
|
||||
|
||||
// IsBuiltinTool 检查工具名称是否是内置工具
|
||||
@@ -22,7 +28,11 @@ func IsBuiltinTool(toolName string) bool {
|
||||
ToolListKnowledgeRiskTypes,
|
||||
ToolSearchKnowledgeBase,
|
||||
ToolListSkills,
|
||||
ToolReadSkill:
|
||||
ToolReadSkill,
|
||||
ToolWebshellExec,
|
||||
ToolWebshellFileList,
|
||||
ToolWebshellFileRead,
|
||||
ToolWebshellFileWrite:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -37,5 +47,9 @@ func GetAllBuiltinTools() []string {
|
||||
ToolSearchKnowledgeBase,
|
||||
ToolListSkills,
|
||||
ToolReadSkill,
|
||||
ToolWebshellExec,
|
||||
ToolWebshellFileList,
|
||||
ToolWebshellFileRead,
|
||||
ToolWebshellFileWrite,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1229,6 +1229,17 @@ func (e *Executor) buildInputSchema(toolConfig *config.ToolConfig) map[string]in
|
||||
"description": param.Description,
|
||||
}
|
||||
|
||||
// JSON Schema/OpenAI 要求 array 类型必须包含 items,否则 API 报 invalid_function_parameters
|
||||
if openAIType == "array" {
|
||||
itemType := strings.TrimSpace(param.ItemType)
|
||||
if itemType == "" {
|
||||
itemType = "string"
|
||||
}
|
||||
prop["items"] = map[string]interface{}{
|
||||
"type": e.convertToOpenAIType(itemType),
|
||||
}
|
||||
}
|
||||
|
||||
// 添加默认值
|
||||
if param.Default != nil {
|
||||
prop["default"] = param.Default
|
||||
|
||||
@@ -202,6 +202,7 @@ description: |
|
||||
```yaml
|
||||
- name: "ports"
|
||||
type: "array"
|
||||
item_type: "number"
|
||||
description: "端口列表"
|
||||
required: false
|
||||
# 输入: [80, 443, 8080]
|
||||
@@ -364,6 +365,13 @@ parameters:
|
||||
- 说明权限要求
|
||||
- 提醒仅在授权环境中使用
|
||||
|
||||
6. **单次执行时长与超时(最佳实践)**
|
||||
- 若某工具经常执行很久(如超过 10~30 分钟仍显示「执行中」),属于异常长时间挂起,建议:
|
||||
- 在 **config.yaml** 的 `agent.tool_timeout_minutes` 中设置单次工具最大执行时长(默认 10 分钟),超时后会自动终止并释放资源;
|
||||
- 需要更长扫描时再适当调大该值(如 20、30),不建议设为 0(不限制);
|
||||
- 在任务监控页可对整条任务使用「停止任务」中断当前对话与后续工具调用;
|
||||
- 工具实现上尽量支持「可中断」或内置超时(如脚本内设 timeout),以便与系统超时协同。
|
||||
|
||||
## 禁用工具
|
||||
|
||||
要禁用某个工具,只需将配置文件中的 `enabled` 字段设置为 `false`,或者直接删除/重命名配置文件。
|
||||
@@ -390,6 +398,13 @@ A: 对于数组类型参数,系统会自动转换为逗号分隔的字符串
|
||||
|
||||
A: 某些工具(如 `nmap`)支持 `scan_type` 参数来覆盖默认的扫描类型。对于其他情况,可以使用 `additional_args` 参数。
|
||||
|
||||
### Q: 工具执行超过 30 分钟一直显示「执行中」怎么办?
|
||||
|
||||
A: 属于异常长时间挂起,建议:
|
||||
1. 在 **config.yaml** 中配置 `agent.tool_timeout_minutes`(默认 10),单次工具超过该分钟数会自动终止;
|
||||
2. 在监控页对该任务使用「停止任务」立即中断;
|
||||
3. 若该工具确实需要更长时间,可适当增大 `tool_timeout_minutes`,但不建议设为 0。
|
||||
|
||||
### Q: 工具执行失败怎么办?
|
||||
|
||||
A: 检查以下几点:
|
||||
|
||||
+330
-151
@@ -2,121 +2,168 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Each tool ships with its own YAML configuration placed in the `tools/` directory. This keeps definitions modular, easier to review, and simple to extend. The runtime automatically loads every `.yaml` / `.yml` file in that directory.
|
||||
Each tool has its own configuration file under the `tools/` directory. This keeps tool definitions clear, easy to maintain, and manageable. The system automatically loads all `.yaml` and `.yml` files in `tools/`.
|
||||
|
||||
## File Structure
|
||||
## Configuration File Format
|
||||
|
||||
The table below enumerates every supported top-level field. Double-check each entry before adding a new tool:
|
||||
Each tool configuration file is a YAML file. The table below lists supported top-level fields and whether they are required. Check each item before submitting:
|
||||
|
||||
| Field | Required | Type | Description |
|
||||
|-------|----------|------|-------------|
|
||||
| `name` | ✅ | string | Unique identifier. Prefer lowercase letters, digits, and hyphens. |
|
||||
| `command` | ✅ | string | Executable or script name. Must exist in `$PATH` or be an absolute path. |
|
||||
| `enabled` | ✅ | bool | Controls MCP registration. Disabled tools are ignored by the loader. |
|
||||
| `description` | ✅ | string | Full Markdown description for MCP `resources/read` and AI comprehension. |
|
||||
| `short_description` | Optional | string | 20–50 character summary shown in tool lists. When omitted, the loader extracts the start of `description`. |
|
||||
| `args` | Optional | string[] | Static arguments prepended to every invocation—useful for default scan profiles. |
|
||||
| `parameters` | Optional | array | Runtime parameter definitions. See **Parameter Definition** for details. |
|
||||
| `arg_mapping` | Optional | string | Mapping strategy (`auto`/`manual`/`template`). Defaults to `auto`; override only for legacy tooling. |
|
||||
| `name` | ✅ | string | Unique tool identifier; use lowercase letters, digits, and hyphens. |
|
||||
| `command` | ✅ | string | Command or script to run; must be on system PATH or an absolute path. |
|
||||
| `enabled` | ✅ | bool | Whether to register with MCP; set to `false` to skip the tool. |
|
||||
| `description` | ✅ | string | Full description, multi-line Markdown, for AI and `resources/read` queries. |
|
||||
| `short_description` | Optional | string | 20–50 character summary for tool lists and lower token usage; defaults to start of `description` if omitted. |
|
||||
| `args` | Optional | string[] | Fixed arguments prepended to the command line; often used for default scan modes. |
|
||||
| `parameters` | Optional | array | Runtime parameter list; see **Parameter Definition** below. |
|
||||
| `arg_mapping` | Optional | string | Parameter mapping mode (`auto`/`manual`/`template`); default `auto`; only set if needed. |
|
||||
|
||||
> If a required field is missing or malformed, the loader skips that tool and logs a warning without blocking the service.
|
||||
> If a field is wrong or a required field is missing, the loader skips that tool and logs a warning; other tools are unaffected.
|
||||
|
||||
## Tool Descriptions
|
||||
|
||||
### Short Description (`short_description`)
|
||||
|
||||
- **Purpose**: compact summary for tool listings and to minimise language model context usage.
|
||||
- **Guideline**: one concise sentence (20–50 Chinese characters or English equivalents).
|
||||
- **Purpose**: Used in tool lists to reduce tokens sent to the model.
|
||||
- **Guideline**: One sentence (20–50 characters) describing the tool’s main use.
|
||||
- **Example**: `"Network scanner for discovering hosts, open ports, and services"`
|
||||
|
||||
### Detailed Description (`description`)
|
||||
|
||||
Supports multi-line Markdown. Recommended contents:
|
||||
Use multi-line text and include:
|
||||
|
||||
1. **Capabilities** – what the tool does.
|
||||
2. **Usage scenarios** – when to prefer this tool.
|
||||
3. **Warnings** – permissions, runtime risks, side-effects.
|
||||
4. **Examples** – optional walkthroughs or sample commands.
|
||||
1. **Capabilities**: What the tool does.
|
||||
2. **Usage scenarios**: When to use it.
|
||||
3. **Warnings**: Caveats and safety notes.
|
||||
4. **Examples**: Optional usage examples.
|
||||
|
||||
**Important**:
|
||||
- Tool menus and MCP summaries use `short_description` when available.
|
||||
- Without `short_description`, the loader trims the first line or first 100 characters of `description`.
|
||||
- Full descriptions are accessible through the MCP `resources/read` endpoint (`tool://<tool_name>`).
|
||||
**Notes**:
|
||||
- Tool lists use `short_description` when present.
|
||||
- If `short_description` is missing, the system uses the first line or first 100 characters of `description`.
|
||||
- Full descriptions are available via MCP `resources/read` (URI: `tool://tool_name`).
|
||||
|
||||
This reduces token usage, especially with many tools (e.g. 100+).
|
||||
|
||||
## Parameter Definition
|
||||
|
||||
Each parameter object accepts the fields below:
|
||||
Each parameter can include:
|
||||
|
||||
- `name` *(required)* – parameter key used in CLI construction and MCP schema.
|
||||
- `type` *(required)* – `string`, `int`/`integer`, `bool`/`boolean`, `array`, etc.
|
||||
- `description` *(required)* – Markdown-friendly explanation including purpose, format rules, example values, and safety notes.
|
||||
- `required` – boolean; when `true`, missing values cause the executor to return an error.
|
||||
- `default` – fallback value applied if the caller omits the argument.
|
||||
- `flag` – CLI switch such as `-u` or `--url`.
|
||||
- `position` – zero-based index for positional arguments.
|
||||
- `format` – rendering strategy:
|
||||
- `flag` *(default)* → `--flag value` / `-f value`
|
||||
- `combined` → `--flag=value`
|
||||
- `positional` → appended according to `position`
|
||||
- `template` → uses the `template` string
|
||||
- `template` – placeholder string (supports `{flag}`, `{value}`, `{name}`) when `format: "template"`.
|
||||
- `options` – array of allowed values; surfaced as `enum` entries in the MCP schema.
|
||||
- `name`: Parameter name.
|
||||
- `type`: One of string, int, bool, array.
|
||||
- `description`: Full description (multi-line supported).
|
||||
- `required`: Whether it is required (true/false).
|
||||
- `default`: Default value.
|
||||
- `flag`: CLI flag (e.g. `-u`, `--url`, `-p`).
|
||||
- `position`: Zero-based index for positional arguments.
|
||||
- `format`: One of `"flag"`, `"positional"`, `"combined"`, `"template"`.
|
||||
- `template`: Template string when `format` is `"template"`.
|
||||
- `options`: Allowed values for enums.
|
||||
|
||||
### Format Reference
|
||||
### Parameter Formats
|
||||
|
||||
- **`flag`**: pass the flag and the value separately.
|
||||
Example: `flag: "-u"` → `-u https://example.com`
|
||||
- **`flag`**: Flag plus value, e.g. `--flag value` or `-f value`
|
||||
- Example: `flag: "-u"` → `-u http://example.com`
|
||||
|
||||
- **`positional`**: insert according to `position`.
|
||||
Example: `position: 0` → becomes the first positional argument.
|
||||
- **`positional`**: Added in order by position.
|
||||
- Example: `position: 0` → first positional argument.
|
||||
|
||||
- **`combined`**: join flag and value in one token.
|
||||
Example: `flag: "--level"`, `format: "combined"` → `--level=3`
|
||||
- **`combined`**: Single token `--flag=value`.
|
||||
- Example: `flag: "--level"`, `format: "combined"` → `--level=3`
|
||||
|
||||
- **`template`**: custom rendering.
|
||||
Example: `template: "{flag} {value}"` → fully manual control.
|
||||
- **`template`**: Custom template.
|
||||
- Example: `template: "{flag} {value}"` → custom format.
|
||||
|
||||
### Reserved Parameters
|
||||
### Special Parameters
|
||||
|
||||
- `additional_args` – allows users to append arbitrary CLI fragments. The executor tokenises the string (preserving quoted groups) and appends the resulting list to the command.
|
||||
- `scan_type` – for scanners like `nmap`, replacing default scan switches (e.g., `-sV -sC`).
|
||||
- `action` – consumed by server-side branching logic and intentionally not forwarded to the command line.
|
||||
#### `additional_args`
|
||||
|
||||
## Parameter Description Checklist
|
||||
Used to pass extra CLI options not defined in the parameter list. The value is split on spaces into multiple arguments.
|
||||
|
||||
When documenting a parameter, include:
|
||||
**Use cases:**
|
||||
- Advanced tool options.
|
||||
- Options not in the schema.
|
||||
- Complex argument combinations.
|
||||
|
||||
1. **Purpose** – what the value controls.
|
||||
2. **Format rules** – accepted patterns (URL, CIDR, path, etc.).
|
||||
3. **Example values** – list several realistic samples.
|
||||
4. **Notes** – permissions, performance impact, or other caveats.
|
||||
**Example:**
|
||||
```yaml
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: "Extra CLI arguments; separate multiple options with spaces"
|
||||
required: false
|
||||
format: "positional"
|
||||
```
|
||||
|
||||
Suggested style: Markdown lists, bold emphasis for key cautions, and code blocks for complex examples.
|
||||
**Usage:**
|
||||
- `additional_args: "--script vuln -O"` → `["--script", "vuln", "-O"]`
|
||||
- `additional_args: "-T4 --max-retries 3"` → `["-T4", "--max-retries", "3"]`
|
||||
|
||||
### Example
|
||||
**Notes:**
|
||||
- Split by spaces; quoted parts are preserved.
|
||||
- Ensure valid syntax to avoid command injection.
|
||||
- Appended at the end of the command.
|
||||
|
||||
#### `scan_type` (tool-specific)
|
||||
|
||||
Some tools (e.g. `nmap`) support `scan_type` to override the default scan arguments.
|
||||
|
||||
**Example (nmap):**
|
||||
```yaml
|
||||
- name: "scan_type"
|
||||
type: "string"
|
||||
description: "Scan type options; overrides default scan arguments"
|
||||
required: false
|
||||
format: "positional"
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
- `scan_type: "-sV -sC"` → version and script scan.
|
||||
- `scan_type: "-A"` → aggressive scan.
|
||||
|
||||
**Notes:**
|
||||
- If set, it replaces the tool’s default scan arguments.
|
||||
- Multiple options separated by spaces.
|
||||
|
||||
### Parameter Description Guidelines
|
||||
|
||||
Parameter descriptions should include:
|
||||
|
||||
1. **Purpose**: What the parameter does.
|
||||
2. **Format**: Expected format (e.g. URL, port range).
|
||||
3. **Example values**: Concrete examples (list if several).
|
||||
4. **Notes**: Permissions, performance, safety, etc.
|
||||
|
||||
**Style:**
|
||||
- Use Markdown for readability.
|
||||
- Use **bold** for important points.
|
||||
- Use lists for multiple examples or options.
|
||||
- Use code blocks for complex formats.
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
description: |
|
||||
Target IP address or domain. Accepts single IPs, ranges, CIDR blocks, or hostnames.
|
||||
Target IP or domain. Can be a single IP, range, CIDR, or hostname.
|
||||
|
||||
**Example values**
|
||||
**Example values:**
|
||||
- Single IP: "192.168.1.1"
|
||||
- Range: "192.168.1.1-100"
|
||||
- CIDR: "192.168.1.0/24"
|
||||
- Domain: "example.com"
|
||||
|
||||
**Notes**
|
||||
**Notes:**
|
||||
- Format must be valid.
|
||||
- Required; cannot be empty.
|
||||
- Validate address format before running to avoid false positives.
|
||||
```
|
||||
|
||||
## Parameter Types
|
||||
|
||||
### Boolean
|
||||
- `true` → adds only the flag (no value).
|
||||
- `false` → suppresses the flag.
|
||||
- Accepts `true`/`false`, `1`/`0`, and `"true"`/`"false"`.
|
||||
### Boolean (`bool`)
|
||||
|
||||
- `true`: Add only the flag (e.g. `--flag`).
|
||||
- `false`: Do not add the argument.
|
||||
- Accepted: `true`/`false`, `1`/`0`, `"true"`/`"false"`.
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
- name: "verbose"
|
||||
type: "bool"
|
||||
@@ -127,91 +174,68 @@ description: |
|
||||
format: "flag"
|
||||
```
|
||||
|
||||
### String
|
||||
Most common parameter type; accepts any string value.
|
||||
### String (`string`)
|
||||
|
||||
### Integer
|
||||
Use for numeric inputs (ports, levels, limits).
|
||||
General-purpose; any string value.
|
||||
|
||||
### Integer (`int` / `integer`)
|
||||
|
||||
For numbers (ports, levels, etc.).
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
- name: "level"
|
||||
type: "int"
|
||||
description: "Level of detail, 1-5"
|
||||
description: "Test level, 1-5"
|
||||
required: false
|
||||
default: 3
|
||||
flag: "--level"
|
||||
format: "combined" # --level=3
|
||||
```
|
||||
|
||||
### Array
|
||||
Automatically converted to a comma-separated string.
|
||||
### Array (`array`)
|
||||
|
||||
Converted to a comma-separated string.
|
||||
|
||||
**Example:**
|
||||
```yaml
|
||||
- name: "ports"
|
||||
type: "array"
|
||||
description: "List of ports to scan"
|
||||
item_type: "number"
|
||||
description: "Port list"
|
||||
required: false
|
||||
# Input: [80, 443, 8080]
|
||||
# Output: "80,443,8080"
|
||||
```
|
||||
|
||||
## Special Parameters
|
||||
## Examples
|
||||
|
||||
### `additional_args`
|
||||
See existing configs under `tools/`:
|
||||
|
||||
```yaml
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: "Extra CLI arguments; separate multiple options with spaces"
|
||||
required: false
|
||||
format: "positional"
|
||||
```
|
||||
- `nmap.yaml`: Network scanner (`scan_type` and `additional_args`).
|
||||
- `sqlmap.yaml`: SQL injection (`additional_args`).
|
||||
- `nikto.yaml`: Web server scanner.
|
||||
- `dirb.yaml`: Directory scanner.
|
||||
- `exec.yaml`: System command execution.
|
||||
|
||||
Examples:
|
||||
- `additional_args: "--script vuln -O"` → `["--script", "vuln", "-O"]`
|
||||
- `additional_args: "-T4 --max-retries 3"` → `["-T4", "--max-retries", "3"]`
|
||||
|
||||
Notes:
|
||||
- Quoted strings are preserved.
|
||||
- Validate user input to avoid command injection.
|
||||
- Appended at the end of the final command.
|
||||
|
||||
### `scan_type`
|
||||
|
||||
```yaml
|
||||
- name: "scan_type"
|
||||
type: "string"
|
||||
description: "Overrides default scan switches"
|
||||
required: false
|
||||
format: "positional"
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `scan_type: "-sV -sC"`
|
||||
- `scan_type: "-A"`
|
||||
|
||||
Notes:
|
||||
- Replaces default entries in the tool’s `args` list.
|
||||
- Separate multiple flags with spaces.
|
||||
|
||||
## Complete Example (`nmap`)
|
||||
### Full Example: nmap
|
||||
|
||||
```yaml
|
||||
name: "nmap"
|
||||
command: "nmap"
|
||||
args: ["-sT", "-sV", "-sC"]
|
||||
args: ["-sT", "-sV", "-sC"] # default scan type
|
||||
enabled: true
|
||||
|
||||
short_description: "Network scanner for discovering hosts, open ports, and services"
|
||||
|
||||
description: |
|
||||
Network mapping and port scanning utility.
|
||||
Network mapping and port scanning for hosts, services, and open ports.
|
||||
|
||||
**Highlights**
|
||||
**Capabilities:**
|
||||
- Host discovery
|
||||
- Port scanning
|
||||
- Service identification
|
||||
- OS fingerprinting
|
||||
- Service/version detection
|
||||
- OS detection
|
||||
- NSE-based vulnerability checks
|
||||
|
||||
parameters:
|
||||
@@ -224,62 +248,80 @@ parameters:
|
||||
|
||||
- name: "ports"
|
||||
type: "string"
|
||||
description: "Port range, e.g., 1-1000"
|
||||
description: "Port range, e.g. 1-1000"
|
||||
required: false
|
||||
flag: "-p"
|
||||
format: "flag"
|
||||
|
||||
- name: "scan_type"
|
||||
type: "string"
|
||||
description: "Override scan switches, e.g., '-sV -sC'"
|
||||
description: "Scan type options, e.g. '-sV -sC'"
|
||||
required: false
|
||||
format: "positional"
|
||||
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: "Extra nmap arguments, e.g., '--script vuln -O'"
|
||||
description: "Extra nmap arguments, e.g. '--script vuln -O'"
|
||||
required: false
|
||||
format: "positional"
|
||||
```
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
1. Create a YAML file in `tools/` (e.g., `tools/mytool.yaml`).
|
||||
2. Fill out the top-level fields and parameter list.
|
||||
3. Provide defaults and rich descriptions wherever possible.
|
||||
4. Run `go run cmd/test-config/main.go` to validate the configuration.
|
||||
5. Restart the service (or trigger a reload) so the UI and MCP registry pick up the change.
|
||||
|
||||
### Template
|
||||
Create a new YAML file under `tools/`, e.g. `my_tool.yaml`:
|
||||
|
||||
```yaml
|
||||
name: "tool_name"
|
||||
command: "command"
|
||||
name: "my_tool"
|
||||
command: "my-command"
|
||||
args: ["--default-arg"] # optional fixed args
|
||||
enabled: true
|
||||
|
||||
short_description: "One-line summary"
|
||||
# Short description (recommended) – for tool list, fewer tokens
|
||||
short_description: "One-line summary of what the tool does"
|
||||
|
||||
# Full description – for docs and AI
|
||||
description: |
|
||||
Detailed description with Markdown formatting.
|
||||
Full description; multi-line and Markdown supported.
|
||||
|
||||
**Capabilities:**
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
|
||||
**Usage:**
|
||||
- Scenario 1
|
||||
- Scenario 2
|
||||
|
||||
**Notes:**
|
||||
- Caveats
|
||||
- Permissions
|
||||
- Performance
|
||||
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "Explain the expected value, format, examples, and caveats"
|
||||
description: |
|
||||
Target parameter description.
|
||||
|
||||
**Example values:**
|
||||
- "value1"
|
||||
- "value2"
|
||||
|
||||
**Notes:**
|
||||
- Format and limits
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
|
||||
- name: "option"
|
||||
type: "string"
|
||||
description: "Optional flag parameter"
|
||||
description: "Option parameter"
|
||||
required: false
|
||||
flag: "--option"
|
||||
format: "flag"
|
||||
|
||||
- name: "verbose"
|
||||
type: "bool"
|
||||
description: "Enable verbose mode"
|
||||
description: "Verbose mode"
|
||||
required: false
|
||||
default: false
|
||||
flag: "-v"
|
||||
@@ -287,33 +329,170 @@ parameters:
|
||||
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: "Extra CLI options separated by spaces"
|
||||
description: "Extra arguments; separate with spaces"
|
||||
required: false
|
||||
format: "positional"
|
||||
```
|
||||
|
||||
## Validation & Troubleshooting
|
||||
Restart the service to load the new tool.
|
||||
|
||||
- ✅ Verify required fields: `name`, `command`, `enabled`, `description`.
|
||||
- ✅ Ensure parameter definitions use supported types and formats.
|
||||
- ✅ Watch server logs for warnings when a tool fails to load.
|
||||
- ✅ Use `go run cmd/test-config/main.go` to inspect parsed tool metadata.
|
||||
### Best Practices
|
||||
|
||||
## Best Practices
|
||||
1. **Parameter design**
|
||||
- Define common parameters explicitly so the AI can use them.
|
||||
- Use `additional_args` for advanced cases.
|
||||
- Provide clear descriptions and examples.
|
||||
|
||||
1. **Parameter design** – expose common flags individually; leverage `additional_args` for advanced scenarios.
|
||||
2. **Documentation** – combine `short_description` with thorough `description` to balance brevity and clarity.
|
||||
3. **Defaults** – provide sensible `default` values, especially for frequently used options.
|
||||
4. **Validation prompts** – describe expected formats and highlight constraints to help the AI and users avoid mistakes.
|
||||
5. **Safety** – warn about privileged commands, destructive actions, or high-impact scans.
|
||||
2. **Descriptions**
|
||||
- Use `short_description` to reduce tokens.
|
||||
- Keep `description` detailed for AI and docs.
|
||||
- Use Markdown for readability.
|
||||
|
||||
3. **Defaults**
|
||||
- Set sensible defaults for common parameters.
|
||||
- Booleans often default to `false`.
|
||||
- Numbers according to tool behavior.
|
||||
|
||||
4. **Validation**
|
||||
- Document format and constraints.
|
||||
- Give several example values.
|
||||
- Mention limits and caveats.
|
||||
|
||||
5. **Safety**
|
||||
- Add warnings for dangerous or privileged actions.
|
||||
- Document permission requirements.
|
||||
- Remind users to use only in authorized environments.
|
||||
|
||||
6. **Execution duration and timeout**
|
||||
- If a tool often runs very long (e.g. still “running” after 10–30 minutes), treat it as abnormal and:
|
||||
- Set **config.yaml** → `agent.tool_timeout_minutes` (default 10) so long runs are stopped and resources freed.
|
||||
- Increase it (e.g. 20, 30) only when longer runs are needed; avoid `0` (no limit).
|
||||
- Use “Stop task” on the task monitor to cancel the whole run.
|
||||
- Prefer tools that support cancellation or an internal timeout so they align with the global timeout.
|
||||
|
||||
## Disabling a Tool
|
||||
|
||||
Set `enabled: false` or remove/rename the YAML file. Disabled tools disappear from the UI and MCP inventory.
|
||||
Set `enabled: false` in the tool’s config, or remove/rename the file. Disabled tools are not listed and cannot be called by the AI.
|
||||
|
||||
## Tool Configuration Validation
|
||||
|
||||
On load, the system checks:
|
||||
|
||||
- ✅ Required fields: `name`, `command`, `enabled`.
|
||||
- ✅ Parameter structure and types.
|
||||
|
||||
Invalid configs produce startup warnings but do not prevent the server from starting. Invalid tools are skipped; others still load.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: How do I pass multiple parameter values?
|
||||
|
||||
A: Array parameters are turned into comma-separated strings. For multiple separate arguments, use `additional_args`.
|
||||
|
||||
### Q: How do I override a tool’s default arguments?
|
||||
|
||||
A: Some tools (e.g. `nmap`) support a `scan_type` parameter. Otherwise use `additional_args`.
|
||||
|
||||
### Q: A tool has been “running” for over 30 minutes. What should I do?
|
||||
|
||||
A: That usually means it’s stuck. You can:
|
||||
1. Set `agent.tool_timeout_minutes` in **config.yaml** (default 10) so single tool runs are stopped after that many minutes.
|
||||
2. Use “Stop task” on the task monitor to stop the run immediately.
|
||||
3. If the tool legitimately needs more time, increase `tool_timeout_minutes` (avoid setting it to 0).
|
||||
|
||||
### Q: What if tool execution fails?
|
||||
|
||||
A: Check:
|
||||
1. The tool is installed and on PATH.
|
||||
2. The tool config is correct.
|
||||
3. Parameter formats match what the tool expects.
|
||||
4. Server logs for the exact error.
|
||||
|
||||
### Q: How can I test a tool configuration?
|
||||
|
||||
A: Use the config test utility:
|
||||
```bash
|
||||
go run cmd/test-config/main.go
|
||||
```
|
||||
|
||||
### Q: How is parameter order controlled?
|
||||
|
||||
A: Use the `position` field for positional arguments. **Position 0** (e.g. gobuster’s `dir` subcommand) is placed right after the command, before any flag arguments, so CLIs that expect “subcommand + options” work. Other flags are added in the order they appear in `parameters`, then position 1, 2, …; `additional_args` is appended last.
|
||||
|
||||
## Tool Configuration Templates
|
||||
|
||||
### Basic template
|
||||
|
||||
```yaml
|
||||
name: "tool_name"
|
||||
command: "command"
|
||||
enabled: true
|
||||
|
||||
short_description: "Short description (20–50 chars)"
|
||||
|
||||
description: |
|
||||
Full description: what it does, when to use it, and caveats.
|
||||
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "Target parameter"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: "Extra CLI arguments"
|
||||
required: false
|
||||
format: "positional"
|
||||
```
|
||||
|
||||
### Template with flag parameters
|
||||
|
||||
```yaml
|
||||
name: "tool_name"
|
||||
command: "command"
|
||||
enabled: true
|
||||
|
||||
short_description: "Short description"
|
||||
|
||||
description: |
|
||||
Full description.
|
||||
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "Target"
|
||||
required: true
|
||||
flag: "-t"
|
||||
format: "flag"
|
||||
|
||||
- name: "option"
|
||||
type: "bool"
|
||||
description: "Option"
|
||||
required: false
|
||||
default: false
|
||||
flag: "--option"
|
||||
format: "flag"
|
||||
|
||||
- name: "level"
|
||||
type: "int"
|
||||
description: "Level"
|
||||
required: false
|
||||
default: 3
|
||||
flag: "--level"
|
||||
format: "combined"
|
||||
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: "Extra arguments"
|
||||
required: false
|
||||
format: "positional"
|
||||
```
|
||||
|
||||
## Related Documents
|
||||
|
||||
- Main project README: `../README.md`
|
||||
- Tool list samples: `tools/*.yaml`
|
||||
- API overview: see the main README
|
||||
|
||||
- Main project README: see `README.md` in the project root.
|
||||
- Tool list: all YAML configs under `tools/`.
|
||||
- API: see the main README for API details.
|
||||
|
||||
+8
-3
@@ -1,5 +1,8 @@
|
||||
name: "dalfox"
|
||||
command: "dalfox"
|
||||
# dalfox v2+ 使用子命令,单目标模式为 `dalfox url <target>`,不再支持根级的 -u
|
||||
args:
|
||||
- "url"
|
||||
enabled: true
|
||||
short_description: "高级XSS漏洞扫描器"
|
||||
description: |
|
||||
@@ -19,10 +22,12 @@ description: |
|
||||
parameters:
|
||||
- name: "url"
|
||||
type: "string"
|
||||
description: "目标URL"
|
||||
description: |
|
||||
目标URL。dalfox 单目标模式为子命令 url,此处作为 url 后的第一个位置参数传入。
|
||||
示例等价 CLI:dalfox url "http://target/page?q=test"
|
||||
required: true
|
||||
flag: "-u"
|
||||
format: "flag"
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "pipe_mode"
|
||||
type: "bool"
|
||||
description: "使用管道模式输入"
|
||||
|
||||
@@ -143,6 +143,7 @@ parameters:
|
||||
format: "positional"
|
||||
- name: "payloads"
|
||||
type: "array"
|
||||
item_type: "string"
|
||||
description: "载荷列表(数组格式),如 [\"test1\", \"test2\", \"test3\"]"
|
||||
required: true
|
||||
position: 4
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
name: "nmap-advanced"
|
||||
command: "nmap"
|
||||
enabled: true
|
||||
short_description: "高级Nmap扫描,支持自定义NSE脚本和优化时序"
|
||||
description: |
|
||||
高级Nmap扫描工具,支持自定义NSE脚本、优化时序和多种扫描技术。
|
||||
|
||||
**主要功能:**
|
||||
- 多种扫描技术(SYN, TCP, UDP等)
|
||||
- 自定义NSE脚本
|
||||
- 时序优化
|
||||
- OS检测和版本检测
|
||||
|
||||
**使用场景:**
|
||||
- 高级网络扫描
|
||||
- 深度安全评估
|
||||
- 渗透测试
|
||||
- 网络侦察
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "目标IP地址或主机名"
|
||||
required: true
|
||||
position: 0
|
||||
format: "positional"
|
||||
- name: "scan_type"
|
||||
type: "string"
|
||||
description: "扫描类型(-sS, -sT, -sU等)"
|
||||
required: false
|
||||
format: "template"
|
||||
template: "{value}"
|
||||
default: "-sS"
|
||||
- name: "ports"
|
||||
type: "string"
|
||||
description: "要扫描的端口"
|
||||
required: false
|
||||
flag: "-p"
|
||||
format: "flag"
|
||||
- name: "timing"
|
||||
type: "string"
|
||||
description: "时序模板(T0-T5)"
|
||||
required: false
|
||||
format: "template"
|
||||
template: "-T{value}"
|
||||
default: "4"
|
||||
- name: "nse_scripts"
|
||||
type: "string"
|
||||
description: "要运行的自定义NSE脚本"
|
||||
required: false
|
||||
flag: "--script"
|
||||
format: "flag"
|
||||
- name: "os_detection"
|
||||
type: "bool"
|
||||
description: "启用OS检测"
|
||||
required: false
|
||||
flag: "-O"
|
||||
format: "flag"
|
||||
default: false
|
||||
- name: "version_detection"
|
||||
type: "bool"
|
||||
description: "启用版本检测"
|
||||
required: false
|
||||
flag: "-sV"
|
||||
format: "flag"
|
||||
default: false
|
||||
- name: "aggressive"
|
||||
type: "bool"
|
||||
description: "启用激进扫描"
|
||||
required: false
|
||||
flag: "-A"
|
||||
format: "flag"
|
||||
default: false
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的nmap-advanced参数。用于传递未在参数列表中定义的nmap-advanced选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
required: false
|
||||
format: "positional"
|
||||
+53
-76
@@ -1,108 +1,85 @@
|
||||
name: "nmap"
|
||||
command: "nmap"
|
||||
args: ["-sT", "-sV", "-sC"] # 固定参数:TCP连接扫描、版本检测、默认脚本
|
||||
# 默认:TCP 连接扫描 + 版本检测 + 默认 NSE 脚本(无 root 也可用)
|
||||
args: ["-sT", "-sV", "-sC"]
|
||||
enabled: true
|
||||
# 简短描述(用于工具列表,减少token消耗)- 一句话说明工具用途
|
||||
short_description: "网络扫描工具,用于发现网络主机、开放端口和服务"
|
||||
# 工具详细描述 - 帮助大模型理解工具用途和使用场景
|
||||
short_description: "网络扫描:端口/服务/脚本;可选时序、自定义 NSE、OS 检测(需 root)"
|
||||
description: |
|
||||
网络映射和端口扫描工具,用于发现网络中的主机、服务和开放端口。
|
||||
网络映射与端口扫描。
|
||||
|
||||
**主要功能:**
|
||||
- 主机发现:检测网络中的活动主机
|
||||
- 端口扫描:识别目标主机上开放的端口
|
||||
- 服务识别:检测运行在端口上的服务类型和版本
|
||||
- 操作系统检测:识别目标主机的操作系统类型
|
||||
- 漏洞检测:使用NSE脚本检测常见漏洞
|
||||
**默认行为(只传 target/ports 即可):**
|
||||
- `-sT` TCP 连接扫描(无需 root)
|
||||
- `-sV` 版本检测
|
||||
- `-sC` 默认 NSE 脚本
|
||||
|
||||
**使用场景:**
|
||||
- 网络资产发现和枚举
|
||||
- 安全评估和渗透测试
|
||||
- 网络故障排查
|
||||
- 端口和服务审计
|
||||
**可选增强:**
|
||||
- `timing`:`-T0`~`-T5` 时序
|
||||
- `nse_scripts`:`--script` 自定义脚本(如 `vuln`、`http-*`)
|
||||
- `os_detection`:`-O` **必须 root**,否则 nmap 会 QUITTING
|
||||
- `aggressive`:`-A` **必须 root**(含 OS 检测)
|
||||
- `scan_type`:若传入则**整段替换**上述默认 `-sT -sV -sC`,需自行写上需要的选项(如 `-sT -sV`)
|
||||
|
||||
**注意事项:**
|
||||
- 使用 -sT (TCP连接扫描) 而不是 -sS (SYN扫描),因为 -sS 需要root权限
|
||||
- 扫描速度取决于网络延迟和目标响应
|
||||
- 某些扫描可能被防火墙或IDS检测到
|
||||
- 请确保有权限扫描目标网络
|
||||
# 参数定义
|
||||
- `-sS` SYN 扫描需要 root;无 root 请用默认或 `-sT`
|
||||
- 扫描全端口 `1-65535` 非常慢,建议先常用端口
|
||||
- 请确保有权限扫描目标
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: |
|
||||
目标IP地址或域名。可以是单个IP、IP范围、CIDR格式或域名。
|
||||
目标 IP、主机名、CIDR 或域名;URL 会自动提取主机部分。
|
||||
|
||||
**示例值:**
|
||||
- 单个IP: "192.168.1.1"
|
||||
- IP范围: "192.168.1.1-100"
|
||||
- CIDR: "192.168.1.0/24"
|
||||
- 域名: "example.com"
|
||||
- URL: "https://example.com" (会自动提取域名部分)
|
||||
|
||||
**注意事项:**
|
||||
- 如果提供URL,会自动提取域名部分
|
||||
- 确保目标地址格式正确
|
||||
- 必需参数,不能为空
|
||||
**示例:** `192.168.1.1`、`10.0.0.0/24`、`example.com`
|
||||
required: true
|
||||
position: 0 # 位置参数,放在命令最后
|
||||
position: 1
|
||||
format: "positional"
|
||||
- name: "ports"
|
||||
type: "string"
|
||||
description: |
|
||||
要扫描的端口范围。可以是单个端口、端口范围、逗号分隔的端口列表,或特殊值。
|
||||
|
||||
**示例值:**
|
||||
- 单个端口: "80"
|
||||
- 端口范围: "1-1000"
|
||||
- 多个端口: "80,443,8080,8443"
|
||||
- 组合: "80,443,8000-9000"
|
||||
- 常用端口: "1-1024"
|
||||
- 所有端口: "1-65535"
|
||||
- 快速扫描: "80,443,22,21,25,53,110,143,993,995"
|
||||
|
||||
**注意事项:**
|
||||
- 如果不指定,将扫描默认的1000个常用端口
|
||||
- 扫描所有端口(1-65535)会非常耗时
|
||||
- 建议先扫描常用端口,再根据结果决定是否扫描全部端口
|
||||
端口范围。示例:`80`、`1-1000`、`80,443,8080`、`1-65535`(全端口很慢)
|
||||
required: false
|
||||
flag: "-p"
|
||||
format: "flag"
|
||||
- name: "timing"
|
||||
type: "string"
|
||||
description: "时序模板 T0–T5,数字越大越快。示例:`4` 生成 `-T4`"
|
||||
required: false
|
||||
format: "template"
|
||||
template: "-T{value}"
|
||||
- name: "nse_scripts"
|
||||
type: "string"
|
||||
description: "NSE 脚本,传给 `--script`。示例:`vuln`、`http-title,http-headers`"
|
||||
required: false
|
||||
flag: "--script"
|
||||
format: "flag"
|
||||
- name: "os_detection"
|
||||
type: "bool"
|
||||
description: |
|
||||
启用 `-O` OS 检测。**必须 root**;无 root 请保持 false。
|
||||
required: false
|
||||
flag: "-O"
|
||||
format: "flag"
|
||||
default: false
|
||||
- name: "aggressive"
|
||||
type: "bool"
|
||||
description: |
|
||||
启用 `-A` 激进扫描(含 OS 检测)。**必须 root**;无 root 请保持 false。
|
||||
required: false
|
||||
flag: "-A"
|
||||
format: "flag"
|
||||
default: false
|
||||
- name: "scan_type"
|
||||
type: "string"
|
||||
description: |
|
||||
扫描类型选项。可以覆盖默认的扫描类型。
|
||||
|
||||
**常用选项:**
|
||||
- "-sV": 版本检测
|
||||
- "-sC": 默认脚本扫描
|
||||
- "-sS": SYN扫描(需要root权限)
|
||||
- "-sT": TCP连接扫描(默认)
|
||||
- "-sU": UDP扫描
|
||||
- "-A": 全面扫描(OS检测、版本检测、脚本扫描、路由追踪)
|
||||
|
||||
**注意事项:**
|
||||
- 多个选项可以组合,用空格分隔,例如: "-sV -sC"
|
||||
- 默认已包含 "-sT -sV -sC"
|
||||
- 如果指定此参数,将替换默认的扫描类型
|
||||
扫描类型选项;**若填写则替换默认的 `-sT -sV -sC`**,只保留你写的选项。
|
||||
多选项用空格分隔,例如:`-sT -sV`、`-sU`(UDP)。
|
||||
required: false
|
||||
format: "template"
|
||||
template: "{value}"
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的Nmap参数。用于传递未在参数列表中定义的Nmap选项。
|
||||
|
||||
**示例值:**
|
||||
- "--script vuln": 运行漏洞检测脚本
|
||||
- "-O": 操作系统检测
|
||||
- "-T4": 时间模板(0-5,数字越大越快)
|
||||
- "--max-retries 3": 最大重试次数
|
||||
- "-v": 详细输出
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
额外参数,按空格追加到命令末尾。
|
||||
示例:`--max-retries 3`、`-v`、`-Pn`
|
||||
required: false
|
||||
format: "positional"
|
||||
|
||||
+119
-25
@@ -1,58 +1,152 @@
|
||||
name: "rustscan"
|
||||
command: "rustscan"
|
||||
enabled: true
|
||||
short_description: "超快速端口扫描工具,使用Rust编写"
|
||||
short_description: "超快速端口扫描(Rust);可选 greppable、批量与脚本级别"
|
||||
description: |
|
||||
Rustscan是一个用Rust编写的超快速端口扫描工具,可以快速扫描大量端口。
|
||||
RustScan 2.x:快速端口发现,可选再调 Nmap 脚本。
|
||||
|
||||
**主要功能:**
|
||||
- 超快速端口扫描
|
||||
- 可配置的扫描速度
|
||||
- 支持Nmap脚本集成
|
||||
- 批量扫描支持
|
||||
**与 `rustscan -h` 对应关系:**
|
||||
- `-a` / `--addresses`:扫描目标列表(逗号分隔或文件)
|
||||
- `-p`:逗号分隔端口;`-r`:端口范围 `start-end`(二选一或与 `-p` 配合以 CLI 为准)
|
||||
- `-g` / `--greppable`:只输出端口,便于 grep/管道
|
||||
- `--scripts`:**官方默认是 default**(会跑 Nmap);设为 **none** 可只做端口发现、更快
|
||||
- `-b` batch-size、`-t` timeout、`--scan-order` 等可微调速度与顺序
|
||||
|
||||
**使用场景:**
|
||||
- 快速端口扫描
|
||||
- 大规模网络扫描
|
||||
- 渗透测试信息收集
|
||||
**使用建议:**
|
||||
- 快速扫端口、不要 Nmap:`scripts` 用 `none`,必要时加 `-g`
|
||||
- 需要服务识别/脚本:用 `default` 或 `custom`,并确保本机有 nmap
|
||||
parameters:
|
||||
# -a, --addresses
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "目标IP地址或主机名"
|
||||
description: |
|
||||
对应 `-a`:逗号分隔的 CIDR、IP 或主机名;也可为含换行/列表的文件路径。
|
||||
示例:`192.168.1.1`、`10.0.0.1,10.0.0.2`、`192.168.0.0/24`
|
||||
required: true
|
||||
flag: "-a"
|
||||
format: "flag"
|
||||
|
||||
# -p, --ports;范围请用 range (-r),勿把 1-1000 填进 ports
|
||||
- name: "ports"
|
||||
type: "string"
|
||||
description: "要扫描的端口(如:22,80,443或1-1000)"
|
||||
description: |
|
||||
要扫描的端口,**仅**逗号分隔列表,对应 `-p`。
|
||||
示例:`22,80,443`、`80,443,8080`。
|
||||
若要范围如 `1-1000`,请用参数 **range**(`-r`),不要写在本参数。
|
||||
required: false
|
||||
flag: "-p"
|
||||
format: "flag"
|
||||
|
||||
# -r, --range(与 ports 列表二选一或按官方说明组合)
|
||||
- name: "range"
|
||||
type: "string"
|
||||
description: |
|
||||
端口范围,格式 start-end,对应 `-r`。
|
||||
示例:`1-1000`、`1-65535`(全端口很慢)。
|
||||
离散端口如 `22,80,443` 请用 **ports**(`-p`),不要写在本参数。
|
||||
required: false
|
||||
flag: "-r"
|
||||
format: "flag"
|
||||
|
||||
# -u, --ulimit
|
||||
- name: "ulimit"
|
||||
type: "int"
|
||||
description: "文件描述符限制"
|
||||
description: "提升扫描用的 ulimit;依系统 fd 限制调整"
|
||||
required: false
|
||||
flag: "-u"
|
||||
format: "flag"
|
||||
default: 5000
|
||||
|
||||
# --scripts:none | default | custom(CLI 默认 default)
|
||||
- name: "scripts"
|
||||
type: "bool"
|
||||
description: "在发现的端口上运行Nmap脚本"
|
||||
type: "string"
|
||||
description: |
|
||||
脚本级别;**必须**传字符串,勿传 true/false。
|
||||
- **none**:不跑 Nmap,仅端口发现(最快)
|
||||
- **default**:与 rustscan 官方默认一致,会调 Nmap
|
||||
- **custom**:自定义脚本,常需配合 additional_args
|
||||
required: false
|
||||
flag: "--scripts"
|
||||
format: "flag"
|
||||
default: "none"
|
||||
options:
|
||||
- "none"
|
||||
- "default"
|
||||
- "custom"
|
||||
|
||||
# -g, --greppable:仅端口列表,无 Nmap 输出
|
||||
- name: "greppable"
|
||||
type: "bool"
|
||||
description: "Greppable 模式:只输出端口,适合脚本解析或写入文件"
|
||||
required: false
|
||||
flag: "-g"
|
||||
format: "flag"
|
||||
default: false
|
||||
|
||||
# -b, --batch-size [default: 4500]
|
||||
- name: "batch_size"
|
||||
type: "int"
|
||||
description: "每批并发端口数;越大越快,受 OS 打开文件数限制。官方默认 4500"
|
||||
required: false
|
||||
flag: "-b"
|
||||
format: "flag"
|
||||
|
||||
# -t, --timeout ms [default: 1500]
|
||||
- name: "timeout_ms"
|
||||
type: "int"
|
||||
description: "单端口超时(毫秒)。官方默认 1500"
|
||||
required: false
|
||||
flag: "-t"
|
||||
format: "flag"
|
||||
|
||||
# --scan-order serial | random [default: serial]
|
||||
- name: "scan_order"
|
||||
type: "string"
|
||||
description: "扫描顺序:serial 升序;random 随机"
|
||||
required: false
|
||||
flag: "--scan-order"
|
||||
format: "flag"
|
||||
options:
|
||||
- "serial"
|
||||
- "random"
|
||||
|
||||
# --top:top 1000 端口
|
||||
- name: "top_ports"
|
||||
type: "bool"
|
||||
description: "使用内置 top 1000 端口(等价于传 `--top`)"
|
||||
required: false
|
||||
flag: "--top"
|
||||
format: "flag"
|
||||
default: false
|
||||
|
||||
# -e, --exclude-ports
|
||||
- name: "exclude_ports"
|
||||
type: "string"
|
||||
description: "排除端口,逗号分隔。示例:`80,443`"
|
||||
required: false
|
||||
flag: "-e"
|
||||
format: "flag"
|
||||
|
||||
# -x, --exclude-addresses
|
||||
- name: "exclude_addresses"
|
||||
type: "string"
|
||||
description: "排除地址,逗号分隔 CIDR/IP/主机"
|
||||
required: false
|
||||
flag: "-x"
|
||||
format: "flag"
|
||||
|
||||
# --tries [default: 1]
|
||||
- name: "tries"
|
||||
type: "int"
|
||||
description: "判定关闭前的重试次数;0 会被纠正为 1"
|
||||
required: false
|
||||
flag: "--tries"
|
||||
format: "flag"
|
||||
|
||||
- name: "additional_args"
|
||||
type: "string"
|
||||
description: |
|
||||
额外的rustscan参数。用于传递未在参数列表中定义的rustscan选项。
|
||||
|
||||
**示例值:**
|
||||
- 根据工具特性添加常用参数示例
|
||||
|
||||
**注意事项:**
|
||||
- 多个参数用空格分隔
|
||||
- 确保参数格式正确,避免命令注入
|
||||
- 此参数会直接追加到命令末尾
|
||||
未列在上面的选项可写在这里,空格分隔。
|
||||
示例:`--no-banner`、`--udp`、`-n`(忽略配置文件)、`-c /path/config.toml`、`--resolver 8.8.8.8`
|
||||
required: false
|
||||
format: "positional"
|
||||
|
||||
+1097
-2
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
+146
-61
@@ -3,13 +3,74 @@
|
||||
let apiSpec = 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 () => {
|
||||
await waitForI18n();
|
||||
await loadToken();
|
||||
await loadAPISpec();
|
||||
if (apiSpec) {
|
||||
renderAPIDocs();
|
||||
}
|
||||
document.addEventListener('languagechange', function () {
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(document);
|
||||
}
|
||||
if (apiSpec) {
|
||||
renderAPIDocs();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 加载token
|
||||
@@ -43,22 +104,25 @@ async function loadAPISpec() {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
showError('需要登录才能查看API文档。请先在前端页面登录,然后刷新此页面。');
|
||||
showError(_t('apiDocs.errorLoginRequired'));
|
||||
return;
|
||||
}
|
||||
throw new Error('加载API规范失败: ' + response.status);
|
||||
throw new Error(_t('apiDocs.errorLoadSpec') + response.status);
|
||||
}
|
||||
|
||||
apiSpec = await response.json();
|
||||
buildApiSpecTagToKey();
|
||||
} catch (error) {
|
||||
console.error('加载API规范失败:', error);
|
||||
showError('加载API文档失败: ' + error.message);
|
||||
showError(_t('apiDocs.errorLoadFailed') + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示错误
|
||||
function showError(message) {
|
||||
const main = document.getElementById('api-docs-main');
|
||||
const loadFailed = _t('apiDocs.loadFailed');
|
||||
const backToLogin = _t('apiDocs.backToLogin');
|
||||
main.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<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="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
<h3>加载失败</h3>
|
||||
<p>${message}</p>
|
||||
<h3>${escapeHtml(loadFailed)}</h3>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
<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>
|
||||
`;
|
||||
@@ -78,7 +142,7 @@ function showError(message) {
|
||||
// 渲染API文档
|
||||
function renderAPIDocs() {
|
||||
if (!apiSpec || !apiSpec.paths) {
|
||||
showError('API规范格式错误');
|
||||
showError(_t('apiDocs.errorSpecInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,7 +173,7 @@ function renderAuthInfo() {
|
||||
tokenStatus.style.display = 'block';
|
||||
tokenStatus.style.background = 'rgba(255, 152, 0, 0.1)';
|
||||
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 allGroups = Array.from(groups).sort();
|
||||
|
||||
while (groupList.children.length > 1) {
|
||||
groupList.removeChild(groupList.lastChild);
|
||||
}
|
||||
allGroups.forEach(group => {
|
||||
const li = document.createElement('li');
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -176,7 +243,7 @@ function renderEndpoints(filterGroup = null) {
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -192,8 +259,8 @@ function createEndpointCard(endpoint) {
|
||||
|
||||
const methodClass = endpoint.method.toLowerCase();
|
||||
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 = `
|
||||
<div class="api-endpoint-header">
|
||||
<div class="api-endpoint-title">
|
||||
@@ -204,21 +271,21 @@ function createEndpointCard(endpoint) {
|
||||
</div>
|
||||
<div class="api-endpoint-body">
|
||||
<div class="api-section">
|
||||
<div class="api-section-title">描述</div>
|
||||
${endpoint.summary ? `<div class="api-description" style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(endpoint.summary)}</div>` : ''}
|
||||
<div class="api-section-title">${escapeHtml(_t('apiDocs.sectionDescription'))}</div>
|
||||
${summaryText ? `<div class="api-description" style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(summaryText)}</div>` : ''}
|
||||
${endpoint.description ? `
|
||||
<div class="api-description-toggle">
|
||||
<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">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<span>查看详细说明</span>
|
||||
<span>${escapeHtml(_t('apiDocs.viewDetailDesc'))}</span>
|
||||
</button>
|
||||
<div class="api-description-detail" style="display: none;">
|
||||
${formatDescription(endpoint.description)}
|
||||
</div>
|
||||
</div>
|
||||
` : endpoint.summary ? '' : '<div class="api-description">无描述</div>'}
|
||||
` : endpoint.summary ? '' : '<div class="api-description">' + escapeHtml(_t('apiDocs.noDescription')) + '</div>'}
|
||||
</div>
|
||||
|
||||
${renderParameters(endpoint)}
|
||||
@@ -236,8 +303,10 @@ function renderParameters(endpoint) {
|
||||
const params = endpoint.parameters || [];
|
||||
if (params.length === 0) return '';
|
||||
|
||||
const requiredLabel = escapeHtml(_t('apiDocs.required'));
|
||||
const optionalLabel = escapeHtml(_t('apiDocs.optional'));
|
||||
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>
|
||||
let descriptionHtml = '-';
|
||||
if (param.description) {
|
||||
@@ -255,17 +324,20 @@ function renderParameters(endpoint) {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const paramName = escapeHtml(_t('apiDocs.paramName'));
|
||||
const typeLabel = escapeHtml(_t('apiDocs.type'));
|
||||
const descLabel = escapeHtml(_t('apiDocs.description'));
|
||||
return `
|
||||
<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">
|
||||
<table class="api-params-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数名</th>
|
||||
<th>类型</th>
|
||||
<th>描述</th>
|
||||
<th>必需</th>
|
||||
<th>${paramName}</th>
|
||||
<th>${typeLabel}</th>
|
||||
<th>${descLabel}</th>
|
||||
<th>${requiredLabel}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -297,11 +369,13 @@ function renderRequestBody(endpoint) {
|
||||
let paramsTable = '';
|
||||
if (schema.properties) {
|
||||
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 prop = schema.properties[key];
|
||||
const required = requiredFields.includes(key)
|
||||
? '<span class="api-param-required">必需</span>'
|
||||
: '<span class="api-param-optional">可选</span>';
|
||||
? '<span class="api-param-required">' + reqLabel + '</span>'
|
||||
: '<span class="api-param-optional">' + optLabel + '</span>';
|
||||
|
||||
// 处理嵌套类型
|
||||
let typeDisplay = prop.type || 'object';
|
||||
@@ -338,16 +412,20 @@ function renderRequestBody(endpoint) {
|
||||
}).join('');
|
||||
|
||||
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 = `
|
||||
<div class="api-table-wrapper" style="margin-top: 12px;">
|
||||
<table class="api-params-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数名</th>
|
||||
<th>类型</th>
|
||||
<th>描述</th>
|
||||
<th>必需</th>
|
||||
<th>示例</th>
|
||||
<th>${pName}</th>
|
||||
<th>${tLabel}</th>
|
||||
<th>${dLabel}</th>
|
||||
<th>${reqLabel}</th>
|
||||
<th>${exLabel}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -389,12 +467,12 @@ function renderRequestBody(endpoint) {
|
||||
|
||||
return `
|
||||
<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>` : ''}
|
||||
${paramsTable}
|
||||
${example ? `
|
||||
<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">
|
||||
<pre>${escapeHtml(example)}</pre>
|
||||
</div>
|
||||
@@ -414,11 +492,11 @@ function renderResponses(endpoint) {
|
||||
if (schema.example) {
|
||||
example = JSON.stringify(schema.example, null, 2);
|
||||
}
|
||||
|
||||
const descText = translateApiDocResponseDescFromResp(response);
|
||||
return `
|
||||
<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>
|
||||
${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 ? `
|
||||
<div class="api-response-example" style="margin-top: 8px;">
|
||||
<pre>${escapeHtml(example)}</pre>
|
||||
@@ -432,7 +510,7 @@ function renderResponses(endpoint) {
|
||||
|
||||
return `
|
||||
<div class="api-section">
|
||||
<div class="api-section-title">响应</div>
|
||||
<div class="api-section-title">${escapeHtml(_t('apiDocs.sectionResponse'))}</div>
|
||||
${responseItems}
|
||||
</div>
|
||||
`;
|
||||
@@ -462,8 +540,8 @@ function renderTestSection(endpoint) {
|
||||
const bodyInputId = `test-body-${escapeId(path)}-${method}`;
|
||||
bodyInput = `
|
||||
<div class="api-test-input-group">
|
||||
<label>请求体 (JSON)</label>
|
||||
<textarea id="${bodyInputId}" class="test-body-input" placeholder='请输入JSON格式的请求体'>${defaultBody}</textarea>
|
||||
<label>${escapeHtml(_t('apiDocs.requestBodyJson'))}</label>
|
||||
<textarea id="${bodyInputId}" class="test-body-input" placeholder='${escapeHtml(_t('apiDocs.requestBodyPlaceholder'))}'>${defaultBody}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -491,7 +569,7 @@ function renderTestSection(endpoint) {
|
||||
const inputId = `test-query-${param.name}-${escapeId(path)}-${method}`;
|
||||
const defaultValue = param.schema?.default !== undefined ? param.schema.default : '';
|
||||
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 `
|
||||
<div class="api-test-input-group">
|
||||
<label>${param.name} ${required}</label>
|
||||
@@ -505,33 +583,40 @@ function renderTestSection(endpoint) {
|
||||
}).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 `
|
||||
<div class="api-test-section">
|
||||
<div class="api-section-title">测试接口</div>
|
||||
<div class="api-section-title">${testSectionTitle}</div>
|
||||
<div class="api-test-form">
|
||||
${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}
|
||||
<div class="api-test-buttons">
|
||||
<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">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
发送请求
|
||||
${sendRequestLabel}
|
||||
</button>
|
||||
<button class="api-test-btn copy-curl" 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">
|
||||
<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"/>
|
||||
</svg>
|
||||
复制curl
|
||||
${copyCurlLabel}
|
||||
</button>
|
||||
<button class="api-test-btn clear-result" onclick="clearTestResult('${escapeId(path)}-${method}')" title="清除测试结果">
|
||||
<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>
|
||||
</div>
|
||||
<div id="test-result-${escapeId(path)}-${method}" class="api-test-result" style="display: none;"></div>
|
||||
@@ -548,7 +633,7 @@ async function testAPI(method, path, operationId) {
|
||||
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.className = 'api-test-result loading';
|
||||
resultDiv.textContent = '发送请求中...';
|
||||
resultDiv.textContent = _t('apiDocs.sendingRequest');
|
||||
|
||||
try {
|
||||
// 替换路径参数
|
||||
@@ -561,7 +646,7 @@ async function testAPI(method, path, operationId) {
|
||||
if (input && input.value) {
|
||||
actualPath = actualPath.replace(param, encodeURIComponent(input.value));
|
||||
} else {
|
||||
throw new Error(`路径参数 ${paramName} 不能为空`);
|
||||
throw new Error(_t('apiDocs.errorPathParamRequired', { name: paramName }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -580,7 +665,7 @@ async function testAPI(method, path, operationId) {
|
||||
if (input && input.value !== '' && input.value !== null && input.value !== undefined) {
|
||||
queryParams.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(input.value)}`);
|
||||
} else if (param.required) {
|
||||
throw new Error(`查询参数 ${param.name} 不能为空`);
|
||||
throw new Error(_t('apiDocs.errorQueryParamRequired', { name: param.name }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -602,8 +687,7 @@ async function testAPI(method, path, operationId) {
|
||||
if (currentToken) {
|
||||
options.headers['Authorization'] = 'Bearer ' + currentToken;
|
||||
} else {
|
||||
// 如果没有token,提示用户
|
||||
throw new Error('未检测到 Token。请先在前端页面登录,然后刷新此页面。或者手动在请求头中添加 Authorization: Bearer your_token');
|
||||
throw new Error(_t('apiDocs.errorTokenRequired'));
|
||||
}
|
||||
|
||||
// 添加请求体
|
||||
@@ -614,7 +698,7 @@ async function testAPI(method, path, operationId) {
|
||||
try {
|
||||
options.body = JSON.stringify(JSON.parse(bodyInput.value.trim()));
|
||||
} catch (e) {
|
||||
throw new Error('请求体JSON格式错误: ' + e.message);
|
||||
throw new Error(_t('apiDocs.errorJsonInvalid') + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,7 +720,7 @@ async function testAPI(method, path, operationId) {
|
||||
|
||||
} catch (error) {
|
||||
resultDiv.className = 'api-test-result error';
|
||||
resultDiv.textContent = '请求失败: ' + error.message;
|
||||
resultDiv.textContent = _t('apiDocs.requestFailed') + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,17 +811,17 @@ function copyCurlCommand(event, method, path) {
|
||||
// 复制到剪贴板
|
||||
const button = event ? event.target.closest('button') : null;
|
||||
navigator.clipboard.writeText(curlCommand).then(() => {
|
||||
// 显示成功提示
|
||||
if (button) {
|
||||
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)';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.style.color = '';
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('curl命令已复制到剪贴板!');
|
||||
alert(_t('apiDocs.curlCopied'));
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('复制失败:', err);
|
||||
@@ -752,24 +836,25 @@ function copyCurlCommand(event, method, path) {
|
||||
document.execCommand('copy');
|
||||
if (button) {
|
||||
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)';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.style.color = '';
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('curl命令已复制到剪贴板!');
|
||||
alert(_t('apiDocs.curlCopied'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('复制失败,请手动复制:\n\n' + curlCommand);
|
||||
alert(_t('apiDocs.copyFailedManual') + curlCommand);
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('生成curl命令失败:', error);
|
||||
alert('生成curl命令失败: ' + error.message);
|
||||
alert(_t('apiDocs.curlGenFailed') + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,10 +1020,10 @@ function toggleDescription(button) {
|
||||
if (detail.style.display === 'none') {
|
||||
detail.style.display = 'block';
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
span.textContent = '隐藏详细说明';
|
||||
span.textContent = typeof window.t === 'function' ? window.t('apiDocs.hideDetailDesc') : '隐藏详细说明';
|
||||
} else {
|
||||
detail.style.display = 'none';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
span.textContent = '查看详细说明';
|
||||
span.textContent = typeof window.t === 'function' ? window.t('apiDocs.viewDetailDesc') : '查看详细说明';
|
||||
}
|
||||
}
|
||||
|
||||
+42
-14
@@ -123,12 +123,20 @@ async function ensureAuthenticated() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleUnauthorized({ message = '认证已过期,请重新登录', silent = false } = {}) {
|
||||
function handleUnauthorized({ message = null, silent = false } = {}) {
|
||||
clearAuthStorage();
|
||||
authPromise = null;
|
||||
authPromiseResolvers = [];
|
||||
let finalMessage = message;
|
||||
if (!finalMessage) {
|
||||
if (typeof window !== 'undefined' && typeof window.t === 'function') {
|
||||
finalMessage = window.t('auth.sessionExpired');
|
||||
} else {
|
||||
finalMessage = '认证已过期,请重新登录';
|
||||
}
|
||||
}
|
||||
if (!silent) {
|
||||
showLoginOverlay(message);
|
||||
showLoginOverlay(finalMessage);
|
||||
} else {
|
||||
showLoginOverlay();
|
||||
}
|
||||
@@ -147,7 +155,10 @@ async function apiFetch(url, options = {}) {
|
||||
const response = await fetch(url, opts);
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized();
|
||||
throw new Error('未授权访问');
|
||||
const msg = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('auth.unauthorized')
|
||||
: '未授权访问';
|
||||
throw new Error(msg);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -165,7 +176,10 @@ async function submitLogin(event) {
|
||||
const password = passwordInput.value.trim();
|
||||
if (!password) {
|
||||
if (errorBox) {
|
||||
errorBox.textContent = '请输入密码';
|
||||
const msgEmpty = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('auth.enterPassword')
|
||||
: '请输入密码';
|
||||
errorBox.textContent = msgEmpty;
|
||||
errorBox.style.display = 'block';
|
||||
}
|
||||
return;
|
||||
@@ -186,7 +200,10 @@ async function submitLogin(event) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !result.token) {
|
||||
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';
|
||||
}
|
||||
return;
|
||||
@@ -203,7 +220,10 @@ async function submitLogin(event) {
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
if (errorBox) {
|
||||
errorBox.textContent = '登录失败,请稍后重试';
|
||||
const fallback = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('auth.loginFailedRetry')
|
||||
: '登录失败,请稍后重试';
|
||||
errorBox.textContent = fallback;
|
||||
errorBox.style.display = 'block';
|
||||
}
|
||||
} finally {
|
||||
@@ -222,6 +242,14 @@ async function refreshAppData(showTaskErrors = false) {
|
||||
|
||||
async function bootstrapApp() {
|
||||
if (!isAppInitialized) {
|
||||
// 等待 i18n 首包加载完成后再插系统就绪消息,避免清除缓存后语言显示 English 气泡仍是中文
|
||||
try {
|
||||
if (window.i18nReady && typeof window.i18nReady.then === 'function') {
|
||||
await window.i18nReady;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('等待 i18n 就绪失败,继续初始化聊天', e);
|
||||
}
|
||||
initializeChatUI();
|
||||
isAppInitialized = true;
|
||||
}
|
||||
@@ -230,13 +258,13 @@ async function bootstrapApp() {
|
||||
|
||||
// 通用工具函数
|
||||
function getStatusText(status) {
|
||||
const statusMap = {
|
||||
'pending': '等待中',
|
||||
'running': '执行中',
|
||||
'completed': '已完成',
|
||||
'failed': '失败'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
if (typeof window.t !== 'function') {
|
||||
const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败' };
|
||||
return fallback[status] || status;
|
||||
}
|
||||
const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed' };
|
||||
const key = keyMap[status];
|
||||
return key ? window.t(key) : status;
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
@@ -375,7 +403,7 @@ async function logout() {
|
||||
// 无论如何都清除本地认证信息
|
||||
clearAuthStorage();
|
||||
hideLoginOverlay();
|
||||
showLoginOverlay('已退出登录');
|
||||
showLoginOverlay(typeof window.t === 'function' ? window.t('auth.loggedOut') : '已退出登录');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+394
-134
File diff suppressed because it is too large
Load Diff
+32
-17
@@ -17,7 +17,7 @@ async function refreshDashboard() {
|
||||
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 = '加载中…'; }
|
||||
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 = ''; }
|
||||
|
||||
@@ -38,10 +38,25 @@ async function refreshDashboard() {
|
||||
apiFetch('/api/skills/stats').then(r => r.ok ? r.json() : null).catch(() => null)
|
||||
]);
|
||||
|
||||
// 运行中任务:Agent 循环任务 + 批量队列「执行中」数量统一统计,避免顶部 KPI 与运行概览不一致
|
||||
let agentRunningCount = null;
|
||||
if (tasksRes && Array.isArray(tasksRes.tasks)) {
|
||||
if (runningEl) runningEl.textContent = String(tasksRes.tasks.length);
|
||||
} else {
|
||||
if (runningEl) runningEl.textContent = '-';
|
||||
agentRunningCount = tasksRes.tasks.length;
|
||||
}
|
||||
let batchRunningCount = 0;
|
||||
if (batchRes && Array.isArray(batchRes.queues)) {
|
||||
batchRes.queues.forEach(q => {
|
||||
if ((q.status || '').toLowerCase() === 'running') batchRunningCount++;
|
||||
});
|
||||
}
|
||||
if (runningEl) {
|
||||
if (agentRunningCount !== null) {
|
||||
runningEl.textContent = String(agentRunningCount + batchRunningCount);
|
||||
} else if (batchRes && Array.isArray(batchRes.queues)) {
|
||||
runningEl.textContent = String(batchRunningCount);
|
||||
} else {
|
||||
runningEl.textContent = '-';
|
||||
}
|
||||
}
|
||||
|
||||
if (vulnRes && typeof vulnRes.total === 'number') {
|
||||
@@ -63,21 +78,21 @@ async function refreshDashboard() {
|
||||
});
|
||||
}
|
||||
|
||||
// 批量任务队列:按状态统计(优化版)
|
||||
// 批量任务队列:按状态统计(优化版;running 与上方 batchRunningCount 一致)
|
||||
if (batchRes && Array.isArray(batchRes.queues)) {
|
||||
const queues = batchRes.queues;
|
||||
let pending = 0, running = 0, done = 0;
|
||||
let pending = 0, running = batchRunningCount, done = 0;
|
||||
queues.forEach(q => {
|
||||
const s = (q.status || '').toLowerCase();
|
||||
if (s === 'pending' || s === 'paused') pending++;
|
||||
else if (s === 'running') running++;
|
||||
else if (s === 'running') { /* already counted into batchRunningCount */ }
|
||||
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 ? `共 ${total} 个` : '暂无任务');
|
||||
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) {
|
||||
@@ -138,7 +153,7 @@ async function refreshDashboard() {
|
||||
if (knowledgeRes && typeof knowledgeRes === 'object') {
|
||||
if (knowledgeRes.enabled === false) {
|
||||
// 功能未启用:用状态标签展示,数值保持为 "-"
|
||||
if (knowledgeStatusEl) knowledgeStatusEl.textContent = '未启用';
|
||||
if (knowledgeStatusEl) knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.notEnabled') : '未启用');
|
||||
if (knowledgeItemsEl) knowledgeItemsEl.textContent = '-';
|
||||
if (knowledgeCategoriesEl) knowledgeCategoriesEl.textContent = '-';
|
||||
} else {
|
||||
@@ -149,9 +164,9 @@ async function refreshDashboard() {
|
||||
// 根据数据量给个轻量状态文案
|
||||
if (knowledgeStatusEl) {
|
||||
if (items > 0 || categories > 0) {
|
||||
knowledgeStatusEl.textContent = '已启用';
|
||||
knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.enabled') : '已启用');
|
||||
} else {
|
||||
knowledgeStatusEl.textContent = '待配置';
|
||||
knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.toConfigure') : '待配置');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,15 +187,15 @@ async function refreshDashboard() {
|
||||
const statusEl = document.getElementById('dashboard-skills-status');
|
||||
if (statusEl) {
|
||||
if (totalCalls === 0) {
|
||||
statusEl.textContent = '待使用';
|
||||
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 = '活跃';
|
||||
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 = '高频';
|
||||
statusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.highFreq') : '高频');
|
||||
statusEl.style.background = 'rgba(59, 130, 246, 0.1)';
|
||||
statusEl.style.color = '#3b82f6';
|
||||
}
|
||||
@@ -200,7 +215,7 @@ async function refreshDashboard() {
|
||||
setEl('dashboard-kpi-tools-calls', '-');
|
||||
renderDashboardToolsBar(null);
|
||||
var ph = document.getElementById('dashboard-tools-pie-placeholder');
|
||||
if (ph) { ph.style.removeProperty('display'); ph.textContent = '暂无调用数据'; }
|
||||
if (ph) { ph.style.removeProperty('display'); ph.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +272,7 @@ function renderDashboardToolsBar(monitorRes) {
|
||||
|
||||
if (!monitorRes || typeof monitorRes !== 'object') {
|
||||
placeholder.style.removeProperty('display');
|
||||
placeholder.textContent = '暂无调用数据';
|
||||
placeholder.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据');
|
||||
barChartEl.style.display = 'none';
|
||||
barChartEl.innerHTML = '';
|
||||
return;
|
||||
@@ -273,7 +288,7 @@ function renderDashboardToolsBar(monitorRes) {
|
||||
|
||||
if (entries.length === 0) {
|
||||
placeholder.style.removeProperty('display');
|
||||
placeholder.textContent = '暂无调用数据';
|
||||
placeholder.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据');
|
||||
barChartEl.style.display = 'none';
|
||||
barChartEl.innerHTML = '';
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// 前端国际化初始化(基于 i18next 浏览器版本)
|
||||
(function () {
|
||||
const DEFAULT_LANG = 'zh-CN';
|
||||
const STORAGE_KEY = 'csai_lang';
|
||||
const RESOURCES_PREFIX = '/static/i18n';
|
||||
|
||||
const loadedLangs = {};
|
||||
|
||||
// 供 bootstrap 等逻辑等待:避免 chat 在 t() 未就绪时用中文硬编码渲染,导致与语言标签不一致
|
||||
let i18nReadyResolve;
|
||||
window.i18nReady = new Promise(function (resolve) {
|
||||
i18nReadyResolve = resolve;
|
||||
});
|
||||
|
||||
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 未加载,跳过前端国际化初始化');
|
||||
if (typeof i18nReadyResolve === 'function') i18nReadyResolve();
|
||||
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);
|
||||
|
||||
// 若 chat 已在 i18n 完成前用后备中文渲染了系统就绪消息,这里按当前语言纠正一次
|
||||
try {
|
||||
if (typeof refreshSystemReadyMessageBubbles === 'function') {
|
||||
refreshSystemReadyMessageBubbles();
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
if (typeof i18nReadyResolve === 'function') i18nReadyResolve();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// i18n 初始化在 DOM Ready 后执行
|
||||
initI18n().catch(function (e) {
|
||||
console.error('初始化国际化失败:', e);
|
||||
if (typeof i18nReadyResolve === 'function') i18nReadyResolve();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// 信息收集页面(FOFA)
|
||||
function _t(key, opts) {
|
||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||
}
|
||||
|
||||
const FOFA_FORM_STORAGE_KEY = 'info-collect-fofa-form';
|
||||
const FOFA_HIDDEN_FIELDS_STORAGE_KEY = 'info-collect-fofa-hidden-fields';
|
||||
@@ -197,12 +200,12 @@ async function submitFofaSearch() {
|
||||
const full = !!els.full?.checked;
|
||||
|
||||
if (!query) {
|
||||
alert('请输入 FOFA 查询语法');
|
||||
alert(_t('infoCollect.enterFofaQuery'));
|
||||
return;
|
||||
}
|
||||
|
||||
saveFofaFormToStorage({ query, size, page, fields, full });
|
||||
setFofaMeta('查询中...');
|
||||
setFofaMeta(_t('infoCollect.querying'));
|
||||
setFofaLoading(true);
|
||||
|
||||
try {
|
||||
@@ -219,9 +222,9 @@ async function submitFofaSearch() {
|
||||
renderFofaResults(result);
|
||||
} catch (e) {
|
||||
console.error('FOFA 查询失败:', e);
|
||||
setFofaMeta('查询失败');
|
||||
setFofaMeta(_t('infoCollect.queryFailed'));
|
||||
renderFofaResults({ query, fields: [], results: [], total: 0, page: 1, size: 0 });
|
||||
alert('FOFA 查询失败: ' + (e && e.message ? e.message : String(e)));
|
||||
alert(_t('infoCollect.queryFailed') + ': ' + (e && e.message ? e.message : String(e)));
|
||||
} finally {
|
||||
setFofaLoading(false);
|
||||
}
|
||||
@@ -231,7 +234,7 @@ async function parseFofaNaturalLanguage() {
|
||||
const els = getFofaFormElements();
|
||||
const text = (els.nl?.value || '').trim();
|
||||
if (!text) {
|
||||
alert('请输入自然语言描述');
|
||||
alert(_t('infoCollect.enterNaturalLanguage'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -243,16 +246,16 @@ async function parseFofaNaturalLanguage() {
|
||||
|
||||
// 先创建 controller,避免极快的重复点击触发并发请求
|
||||
fofaParseAbortController = new AbortController();
|
||||
setFofaParseLoading(true, 'AI 解析中...');
|
||||
setFofaParseLoading(true, _t('infoCollect.parsePending'));
|
||||
|
||||
// 持续提示:直到请求完成/取消/失败才消失
|
||||
fofaParseToastHandle = showInlineToast('AI 解析中...(点击按钮可取消)', { duration: 0, id: 'fofa-parse-pending' });
|
||||
fofaParseToastHandle = showInlineToast(_t('infoCollect.parsePendingClickCancel'), { duration: 0, id: 'fofa-parse-pending' });
|
||||
|
||||
// 如果超过一小段时间还没返回,再强调“仍在进行中”,降低误判为失败的概率
|
||||
fofaParseSlowTimer = setTimeout(() => {
|
||||
const status = document.getElementById('fofa-nl-status');
|
||||
if (status) {
|
||||
status.textContent = 'AI 解析耗时较长,仍在处理中…';
|
||||
status.textContent = _t('infoCollect.parseSlow');
|
||||
status.style.display = 'block';
|
||||
}
|
||||
}, 1800);
|
||||
@@ -269,15 +272,15 @@ async function parseFofaNaturalLanguage() {
|
||||
throw new Error(result.error || `请求失败: ${resp.status}`);
|
||||
}
|
||||
showFofaParseModal(text, result);
|
||||
showInlineToast('AI 解析完成');
|
||||
showInlineToast(_t('infoCollect.parseDone'));
|
||||
} catch (e) {
|
||||
// AbortController 取消:不视为失败
|
||||
if (e && (e.name === 'AbortError' || String(e).includes('AbortError'))) {
|
||||
showInlineToast('已取消 AI 解析');
|
||||
showInlineToast(_t('infoCollect.parseCancelled'));
|
||||
return;
|
||||
}
|
||||
console.error('FOFA 自然语言解析失败:', e);
|
||||
showInlineToast('AI 解析失败:' + (e && e.message ? e.message : String(e)), { duration: 2800 });
|
||||
showInlineToast(_t('infoCollect.parseFailed') + (e && e.message ? e.message : String(e)), { duration: 2800 });
|
||||
}
|
||||
finally {
|
||||
fofaParseAbortController = null;
|
||||
@@ -298,17 +301,17 @@ function setFofaParseLoading(loading, statusText) {
|
||||
const status = document.getElementById('fofa-nl-status');
|
||||
if (btn) {
|
||||
if (loading) {
|
||||
if (!btn.dataset.originalText) btn.dataset.originalText = btn.textContent || 'AI 解析';
|
||||
if (!btn.dataset.originalText) btn.dataset.originalText = btn.textContent || _t('infoCollectPage.parseBtn');
|
||||
btn.classList.add('btn-loading');
|
||||
btn.textContent = '取消解析';
|
||||
btn.title = '点击取消 AI 解析';
|
||||
btn.textContent = _t('infoCollect.cancelParse');
|
||||
btn.title = _t('infoCollect.clickToCancelParse');
|
||||
btn.dataset.loading = '1';
|
||||
btn.setAttribute('aria-busy', 'true');
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
btn.classList.remove('btn-loading');
|
||||
btn.textContent = btn.dataset.originalText || 'AI 解析';
|
||||
btn.title = '将自然语言解析为 FOFA 查询语法';
|
||||
btn.textContent = btn.dataset.originalText || _t('infoCollectPage.parseBtn');
|
||||
btn.title = _t('infoCollect.parseToFofa');
|
||||
btn.disabled = false;
|
||||
delete btn.dataset.loading;
|
||||
btn.removeAttribute('aria-busy');
|
||||
@@ -336,7 +339,7 @@ function showFofaParseModal(nlText, parsed) {
|
||||
|
||||
const warningsHtml = warnings.length
|
||||
? `<ul style="margin: 8px 0 0 18px;">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}</ul>`
|
||||
: `<div class="muted" style="margin-top: 8px;">无</div>`;
|
||||
: '<div class="muted" style="margin-top: 8px;">' + _t('infoCollect.none') + '</div>';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'fofa-parse-modal';
|
||||
@@ -345,23 +348,23 @@ function showFofaParseModal(nlText, parsed) {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>AI 解析结果</h2>
|
||||
<span class="modal-close" id="fofa-parse-modal-close" title="关闭">×</span>
|
||||
<h2>${_t('infoCollect.parseResultTitle')}</h2>
|
||||
<span class="modal-close" id="fofa-parse-modal-close" title="${_t('common.close')}">×</span>
|
||||
</div>
|
||||
<div style="padding: 18px 28px; overflow: auto;">
|
||||
<div class="form-group">
|
||||
<label>自然语言</label>
|
||||
<label>${_t('infoCollect.naturalLanguageLabel')}</label>
|
||||
<div class="muted" style="margin-top: 6px; white-space: pre-wrap;">${safeNL || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<label for="fofa-parse-query">FOFA 查询语法(可编辑)</label>
|
||||
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder='例如:app="Apache" && country="CN"'></textarea>
|
||||
<small class="form-hint">请人工确认语法与范围无误后再执行查询。</small>
|
||||
<label for="fofa-parse-query">${_t('infoCollect.fofaQueryEditable')}</label>
|
||||
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder="${_t('infoCollect.queryPlaceholder')}"></textarea>
|
||||
<small class="form-hint">${_t('infoCollect.confirmBeforeQuery')}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<label>提醒</label>
|
||||
<label>${_t('infoCollect.reminder')}</label>
|
||||
<div style="background: #fff8e1; border: 1px solid #ffe8a3; border-radius: 10px; padding: 10px 12px;">
|
||||
${warningsHtml}
|
||||
</div>
|
||||
@@ -369,14 +372,14 @@ function showFofaParseModal(nlText, parsed) {
|
||||
|
||||
${explanation ? `
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<label>解析说明</label>
|
||||
<label>${_t('infoCollect.explanation')}</label>
|
||||
<pre style="margin-top: 8px; white-space: pre-wrap; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 10px; padding: 10px 12px; font-size: 13px;">${escapeHtml(explanation)}</pre>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="modal-footer" style="padding: 18px 28px;">
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-cancel">取消</button>
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-apply">填入查询框</button>
|
||||
<button class="btn-primary" type="button" id="fofa-parse-apply-run">填入并查询</button>
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-cancel">${_t('infoCollect.parseModalCancel')}</button>
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-apply">${_t('infoCollect.parseModalApply')}</button>
|
||||
<button class="btn-primary" type="button" id="fofa-parse-apply-run">${_t('infoCollect.parseModalApplyRun')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -402,7 +405,7 @@ function showFofaParseModal(nlText, parsed) {
|
||||
const els = getFofaFormElements();
|
||||
const q = (queryTextarea?.value || '').trim();
|
||||
if (!q) {
|
||||
showInlineToast('解析结果为空:请在弹窗中补充/修改 FOFA 查询语法', { duration: 2600 });
|
||||
showInlineToast(_t('infoCollect.parseResultEmpty'), { duration: 2600 });
|
||||
return;
|
||||
}
|
||||
if (els.query) {
|
||||
@@ -444,7 +447,7 @@ function setFofaMeta(text) {
|
||||
function updateSelectedMeta() {
|
||||
const els = getFofaFormElements();
|
||||
if (els.selectedMeta) {
|
||||
els.selectedMeta.textContent = `已选择 ${infoCollectState.selectedRowIndexes.size} 条`;
|
||||
els.selectedMeta.textContent = _t('infoCollectPage.selectedRows', { count: infoCollectState.selectedRowIndexes.size });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +457,7 @@ function setFofaLoading(loading) {
|
||||
if (loading) {
|
||||
const fieldsCount = (document.getElementById('fofa-fields')?.value || '').split(',').filter(Boolean).length;
|
||||
const colspan = Math.max(1, fieldsCount + 1);
|
||||
els.tbody.innerHTML = `<tr><td class="muted" style="padding: 16px;" colspan="${colspan}">加载中...</td></tr>`;
|
||||
els.tbody.innerHTML = '<tr><td class="muted" style="padding: 16px;" colspan="' + colspan + '">' + escapeHtml(_t('infoCollect.loading')) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,7 +493,7 @@ function renderFofaResults(payload) {
|
||||
const size = typeof payload.size === 'number' ? payload.size : 0;
|
||||
const page = typeof payload.page === 'number' ? payload.page : 1;
|
||||
|
||||
setFofaMeta(`共 ${total} 条 · 本页 ${results.length} 条 · page=${page} · size=${size}`);
|
||||
setFofaMeta(_t('infoCollect.resultsMeta', { total, count: results.length, page, size }));
|
||||
|
||||
// 可见字段
|
||||
const visibleFields = fields.filter(f => !infoCollectState.hiddenFields.has(f));
|
||||
@@ -500,16 +503,16 @@ function renderFofaResults(payload) {
|
||||
|
||||
// 表头(左:勾选列;右:操作列固定)
|
||||
const headerCells = [
|
||||
'<th class="info-collect-col-select"><input type="checkbox" id="fofa-select-all" title="全选/全不选"/></th>',
|
||||
'<th class="info-collect-col-select"><input type="checkbox" id="fofa-select-all" title="' + escapeHtml(_t('infoCollect.selectAll')) + '"/></th>',
|
||||
...visibleFields.map(f => `<th>${escapeHtml(String(f))}</th>`),
|
||||
'<th class="info-collect-col-actions">操作</th>'
|
||||
'<th class="info-collect-col-actions">' + escapeHtml(_t('infoCollect.actions')) + '</th>'
|
||||
].join('');
|
||||
els.thead.innerHTML = `<tr>${headerCells}</tr>`;
|
||||
|
||||
// 表体
|
||||
if (results.length === 0) {
|
||||
const colspan = Math.max(1, visibleFields.length + 2);
|
||||
els.tbody.innerHTML = `<tr><td class="muted" style="padding: 16px;" colspan="${colspan}">暂无数据</td></tr>`;
|
||||
els.tbody.innerHTML = '<tr><td class="muted" style="padding: 16px;" colspan="' + colspan + '">' + escapeHtml(_t('common.noData')) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -519,7 +522,7 @@ function renderFofaResults(payload) {
|
||||
const encoded = encodeURIComponent(JSON.stringify(safeRow));
|
||||
const encodedTarget = encodeURIComponent(target || '');
|
||||
|
||||
const selectHtml = `<td class="info-collect-col-select"><input class="fofa-row-select" type="checkbox" data-index="${idx}" title="选择该行"/></td>`;
|
||||
const selectHtml = '<td class="info-collect-col-select"><input class="fofa-row-select" type="checkbox" data-index="' + idx + '" title="' + escapeHtml(_t('infoCollect.selectRow')) + '"/></td>';
|
||||
|
||||
const cellsHtml = visibleFields.map(f => {
|
||||
const val = safeRow[f];
|
||||
@@ -537,13 +540,13 @@ function renderFofaResults(payload) {
|
||||
|
||||
const actionHtml = `
|
||||
<div class="info-collect-actions">
|
||||
<button class="btn-icon" onclick="copyFofaTargetEncoded('${encodedTarget}'); event.stopPropagation();" title="复制目标">
|
||||
<button class="btn-icon" onclick="copyFofaTargetEncoded('${encodedTarget}'); event.stopPropagation();" title="${escapeHtml(_t('infoCollect.copyTarget'))}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="9" y="9" width="13" height="13" rx="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" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" onclick="scanFofaRow('${encoded}', event); event.stopPropagation();" title="发送到对话(可编辑;Ctrl/⌘+点击可直接发送)">
|
||||
<button class="btn-icon" onclick="scanFofaRow('${encoded}', event); event.stopPropagation();" title="${escapeHtml(_t('infoCollect.sendToChat'))}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.5 13.5l3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M8 8H5a4 4 0 1 0 0 8h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
@@ -602,14 +605,14 @@ function normalizeHttpLink(raw) {
|
||||
function copyFofaTarget(target) {
|
||||
const text = (target || '').trim();
|
||||
if (!text) {
|
||||
alert('没有可复制的目标');
|
||||
alert(_t('infoCollect.noTargetToCopy'));
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// 简单提示
|
||||
showInlineToast('已复制目标');
|
||||
showInlineToast(_t('infoCollect.targetCopied'));
|
||||
}).catch(() => {
|
||||
alert('复制失败,请手动复制:' + text);
|
||||
alert(_t('infoCollect.manualCopyHint') + text);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -655,7 +658,7 @@ function showInlineToast(text, options) {
|
||||
function truncateForPreview(value, maxLen) {
|
||||
const s = value == null ? '' : String(value);
|
||||
if (maxLen <= 0 || s.length <= maxLen) return s;
|
||||
return s.slice(0, maxLen) + '...(已截断)';
|
||||
return s.slice(0, maxLen) + '...(' + _t('infoCollect.truncated') + ')';
|
||||
}
|
||||
|
||||
function formatFofaRowSummary(row, fields) {
|
||||
@@ -707,7 +710,7 @@ function scanFofaRow(encodedRowJson, clickEvent) {
|
||||
const fields = (document.getElementById('fofa-fields')?.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const target = inferTargetFromRow(row, fields);
|
||||
if (!target) {
|
||||
alert('无法从该行推断扫描目标(建议在 fields 中包含 host/ip/port/domain)');
|
||||
alert(_t('infoCollect.cannotInferTarget'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -745,10 +748,10 @@ function scanFofaRow(encodedRowJson, clickEvent) {
|
||||
if (typeof sendMessage === 'function') {
|
||||
sendMessage();
|
||||
} else {
|
||||
alert('未找到 sendMessage(),请刷新页面后重试');
|
||||
alert(_t('infoCollect.noSendMessage'));
|
||||
}
|
||||
} else {
|
||||
showInlineToast('已填入对话输入框,可编辑后发送');
|
||||
showInlineToast(_t('infoCollect.filledToInput'));
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
@@ -910,7 +913,7 @@ function hideAllFofaColumns() {
|
||||
function exportFofaResults(format) {
|
||||
const p = infoCollectState.currentPayload;
|
||||
if (!p || !Array.isArray(p.results) || p.results.length === 0) {
|
||||
alert('暂无可导出的结果');
|
||||
alert(_t('infoCollect.noExportResult'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -936,7 +939,7 @@ function exportFofaResults(format) {
|
||||
if (format === 'xlsx') {
|
||||
// 使用 SheetJS 生成 XLSX(需在页面中引入 xlsx 库)
|
||||
if (typeof XLSX === 'undefined') {
|
||||
alert('未加载 XLSX 库,请刷新页面后重试');
|
||||
alert(_t('infoCollect.xlsxNotLoaded'));
|
||||
return;
|
||||
}
|
||||
const aoa = [visibleFields].concat(p.results.map(row => {
|
||||
@@ -945,7 +948,7 @@ function exportFofaResults(format) {
|
||||
}));
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'FOFA结果');
|
||||
XLSX.utils.book_append_sheet(wb, ws, _t('infoCollect.batchScanTitle'));
|
||||
XLSX.writeFile(wb, `fofa_results_${ts}.xlsx`);
|
||||
return;
|
||||
}
|
||||
@@ -982,12 +985,12 @@ function downloadBlob(content, filename, mime) {
|
||||
async function batchScanSelectedFofaRows() {
|
||||
const p = infoCollectState.currentPayload;
|
||||
if (!p || !Array.isArray(p.results) || p.results.length === 0) {
|
||||
alert('暂无结果');
|
||||
alert(_t('infoCollect.noResults'));
|
||||
return;
|
||||
}
|
||||
const selected = Array.from(infoCollectState.selectedRowIndexes).sort((a, b) => a - b);
|
||||
if (selected.length === 0) {
|
||||
alert('请先勾选需要扫描的行');
|
||||
alert(_t('infoCollect.selectRowsFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1009,11 +1012,11 @@ async function batchScanSelectedFofaRows() {
|
||||
});
|
||||
|
||||
if (tasks.length === 0) {
|
||||
alert('未能从所选行推断任何可扫描目标(建议 fields 中包含 host/ip/port/domain)');
|
||||
alert(_t('infoCollect.noScanTarget'));
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (p.query ? `FOFA 批量扫描:${p.query}` : 'FOFA 批量扫描').slice(0, 80);
|
||||
const title = (p.query ? _t('infoCollect.batchScanTitle') + ':' + p.query : _t('infoCollect.batchScanTitle')).slice(0, 80);
|
||||
try {
|
||||
// 不强制切换到“信息收集”角色:沿用当前已选角色;若为默认则传空字符串交给后端走默认逻辑
|
||||
let role = '';
|
||||
@@ -1029,7 +1032,7 @@ async function batchScanSelectedFofaRows() {
|
||||
});
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
throw new Error(result.error || `创建批量队列失败: ${resp.status}`);
|
||||
throw new Error(result.error || _t('infoCollect.createQueueFailed') + ': ' + resp.status);
|
||||
}
|
||||
const queueId = result.queueId;
|
||||
if (!queueId) {
|
||||
@@ -1045,13 +1048,13 @@ async function batchScanSelectedFofaRows() {
|
||||
}, 250);
|
||||
|
||||
if (skipped.length > 0) {
|
||||
showInlineToast(`已创建队列(跳过 ${skipped.length} 条无目标行)`);
|
||||
showInlineToast(_t('infoCollect.queueCreatedSkipped', { n: skipped.length }));
|
||||
} else {
|
||||
showInlineToast('已创建批量扫描队列');
|
||||
showInlineToast(_t('infoCollect.batchQueueCreated'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('批量扫描失败:', e);
|
||||
alert('批量扫描失败: ' + (e && e.message ? e.message : String(e)));
|
||||
alert(_t('infoCollect.batchScanFailed') + ': ' + (e && e.message ? e.message : String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1065,8 +1068,8 @@ function showCellDetailModal(field, fullText) {
|
||||
modal.innerHTML = `
|
||||
<div class="info-collect-cell-modal-content" role="dialog" aria-modal="true">
|
||||
<div class="info-collect-cell-modal-header">
|
||||
<div class="info-collect-cell-modal-title">${escapeHtml(field || '字段')}</div>
|
||||
<button class="btn-icon" type="button" id="info-collect-cell-modal-close" title="关闭">
|
||||
<div class="info-collect-cell-modal-title">${escapeHtml(field || _t('infoCollect.field'))}</div>
|
||||
<button class="btn-icon" type="button" id="info-collect-cell-modal-close" title="${_t('common.close')}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
@@ -1076,8 +1079,8 @@ function showCellDetailModal(field, fullText) {
|
||||
<pre class="info-collect-cell-modal-pre">${escapeHtml(fullText || '')}</pre>
|
||||
</div>
|
||||
<div class="info-collect-cell-modal-footer">
|
||||
<button class="btn-secondary" type="button" id="info-collect-cell-modal-copy">复制</button>
|
||||
<button class="btn-primary" type="button" id="info-collect-cell-modal-ok">关闭</button>
|
||||
<button class="btn-secondary" type="button" id="info-collect-cell-modal-copy">${_t('common.copy')}</button>
|
||||
<button class="btn-primary" type="button" id="info-collect-cell-modal-ok">${_t('common.close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1091,7 +1094,7 @@ function showCellDetailModal(field, fullText) {
|
||||
document.getElementById('info-collect-cell-modal-close')?.addEventListener('click', close);
|
||||
document.getElementById('info-collect-cell-modal-ok')?.addEventListener('click', close);
|
||||
document.getElementById('info-collect-cell-modal-copy')?.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(fullText || '').then(() => showInlineToast('已复制')).catch(() => alert('复制失败'));
|
||||
navigator.clipboard.writeText(fullText || '').then(() => showInlineToast(_t('common.copied'))).catch(() => alert(_t('common.copyFailed')));
|
||||
});
|
||||
|
||||
// Esc 关闭
|
||||
@@ -1122,3 +1125,13 @@ window.toggleFofaColumn = toggleFofaColumn;
|
||||
window.exportFofaResults = exportFofaResults;
|
||||
window.batchScanSelectedFofaRows = batchScanSelectedFofaRows;
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
updateSelectedMeta();
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { updateSelectedMeta(); });
|
||||
} else {
|
||||
updateSelectedMeta();
|
||||
}
|
||||
|
||||
|
||||
+150
-102
@@ -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 knowledgeItems = [];
|
||||
let currentEditingItemId = null;
|
||||
@@ -32,26 +65,8 @@ async function loadKnowledgeCategories() {
|
||||
|
||||
// 检查知识库功能是否启用
|
||||
if (data.enabled === false) {
|
||||
// 功能未启用,显示友好提示
|
||||
const container = 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>
|
||||
`;
|
||||
}
|
||||
// 功能未启用,显示友好提示(使用 data-i18n,切换语言时会自动更新)
|
||||
renderKnowledgeNotEnabledState(document.getElementById('knowledge-items-list'));
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -116,25 +131,10 @@ async function loadKnowledgeItems(category = '', page = 1, pageSize = 10) {
|
||||
|
||||
// 检查知识库功能是否启用
|
||||
if (data.enabled === false) {
|
||||
// 功能未启用,显示友好提示(如果还没有显示的话)
|
||||
// 功能未启用,显示友好提示(如果还没有显示的话;使用 data-i18n,切换语言时会自动更新)
|
||||
const container = document.getElementById('knowledge-items-list');
|
||||
if (container && !container.querySelector('.empty-state')) {
|
||||
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>
|
||||
`;
|
||||
renderKnowledgeNotEnabledState(container);
|
||||
}
|
||||
knowledgeItems = [];
|
||||
knowledgePagination.total = 0;
|
||||
@@ -459,6 +459,9 @@ async function updateIndexProgress() {
|
||||
const isComplete = status.is_complete || false;
|
||||
const lastError = status.last_error || '';
|
||||
|
||||
// 检查是否正在重建索引(优先使用重建状态)
|
||||
const isRebuilding = status.is_rebuilding || false;
|
||||
|
||||
if (totalItems === 0) {
|
||||
// 没有知识项,隐藏进度条
|
||||
progressContainer.style.display = 'none';
|
||||
@@ -524,6 +527,45 @@ async function updateIndexProgress() {
|
||||
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) {
|
||||
progressContainer.innerHTML = `
|
||||
<div class="knowledge-index-progress-complete">
|
||||
@@ -711,25 +753,7 @@ async function searchKnowledgeItems() {
|
||||
|
||||
// 检查知识库功能是否启用
|
||||
if (data.enabled === false) {
|
||||
const container = 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>
|
||||
`;
|
||||
}
|
||||
renderKnowledgeNotEnabledState(document.getElementById('knowledge-items-list'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1270,7 +1294,7 @@ async function loadRetrievalLogs(conversationId = '', messageId = '') {
|
||||
renderRetrievalLogs([]);
|
||||
// 只在非空筛选条件下才显示错误通知(避免在没有数据时显示错误)
|
||||
if (conversationId || messageId) {
|
||||
showNotification('加载检索日志失败: ' + error.message, 'error');
|
||||
showNotification(_t('retrievalLogs.loadError') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1284,7 +1308,7 @@ function renderRetrievalLogs(logs) {
|
||||
updateRetrievalStats(logs);
|
||||
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">暂无检索记录</div>';
|
||||
container.innerHTML = '<div class="empty-state">' + _t('retrievalLogs.noRecords') + '</div>';
|
||||
retrievalLogsData = [];
|
||||
return;
|
||||
}
|
||||
@@ -1344,7 +1368,7 @@ function renderRetrievalLogs(logs) {
|
||||
</div>
|
||||
<div class="retrieval-log-main-info">
|
||||
<div class="retrieval-log-query">
|
||||
${escapeHtml(log.query || '无查询内容')}
|
||||
${escapeHtml(log.query || _t('retrievalLogs.noQuery'))}
|
||||
</div>
|
||||
<div class="retrieval-log-meta">
|
||||
<span class="retrieval-log-time" title="${formatTime(log.createdAt)}">
|
||||
@@ -1354,33 +1378,33 @@ function renderRetrievalLogs(logs) {
|
||||
</div>
|
||||
</div>
|
||||
<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 class="retrieval-log-card-body">
|
||||
<div class="retrieval-log-details-grid">
|
||||
${log.conversationId ? `
|
||||
<div class="retrieval-log-detail-item">
|
||||
<span class="detail-label">对话ID</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>
|
||||
<span class="detail-label">${_t('retrievalLogs.conversationId')}</span>
|
||||
<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>
|
||||
` : ''}
|
||||
${log.messageId ? `
|
||||
<div class="retrieval-log-detail-item">
|
||||
<span class="detail-label">消息ID</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>
|
||||
<span class="detail-label">${_t('retrievalLogs.messageId')}</span>
|
||||
<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 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'}">
|
||||
${hasResults ? (itemCount > 0 ? `找到 ${itemCount} 个相关知识项` : '找到相关知识项(数量未知)') : '未找到匹配的知识项'}
|
||||
${hasResults ? (itemCount > 0 ? _t('retrievalLogs.foundCount', { count: itemCount }) : _t('retrievalLogs.foundUnknown')) : _t('retrievalLogs.noMatch')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
${hasResults && log.retrievedItems && log.retrievedItems.length > 0 ? `
|
||||
<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">
|
||||
${log.retrievedItems.slice(0, 3).map((itemId, idx) => `
|
||||
<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"/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
查看详情
|
||||
${_t('retrievalLogs.viewDetails')}
|
||||
</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">
|
||||
<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>
|
||||
删除
|
||||
${_t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1438,22 +1462,25 @@ function updateRetrievalStats(logs) {
|
||||
|
||||
statsContainer.innerHTML = `
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(statsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取相对时间
|
||||
@@ -1549,7 +1576,7 @@ function refreshRetrievalLogs() {
|
||||
|
||||
// 删除检索日志
|
||||
async function deleteRetrievalLog(id, index) {
|
||||
if (!confirm('确定要删除这条检索记录吗?')) {
|
||||
if (!confirm(_t('retrievalLogs.deleteConfirm'))) {
|
||||
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');
|
||||
if (badge && badge.classList.contains('success')) {
|
||||
const text = badge.textContent.trim();
|
||||
const match = text.match(/(\d+)\s*项/);
|
||||
const match = text.match(/(\d+)/);
|
||||
if (match) {
|
||||
return sum + parseInt(match[1]);
|
||||
} else if (text === '有结果') {
|
||||
return sum + 1; // 简化处理,假设为1
|
||||
return sum + parseInt(match[1], 10);
|
||||
}
|
||||
return sum + 1; // 有结果但数量未知(如 "Has results" / "有结果")
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
@@ -1671,28 +1697,31 @@ function updateRetrievalStatsAfterDelete() {
|
||||
|
||||
statsContainer.innerHTML = `
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(statsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示检索日志详情
|
||||
async function showRetrievalLogDetails(index) {
|
||||
if (!retrievalLogsData || index < 0 || index >= retrievalLogsData.length) {
|
||||
showNotification('无法获取检索详情', 'error');
|
||||
showNotification(_t('retrievalLogs.detailError'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1741,16 +1770,19 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 900px; max-height: 90vh; overflow-y: auto;">
|
||||
<div class="modal-header">
|
||||
<h2>检索详情</h2>
|
||||
<h2 data-i18n="retrievalLogs.detailsTitle">检索详情</h2>
|
||||
<span class="modal-close" onclick="closeRetrievalLogDetailsModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body" id="retrieval-log-details-content">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeRetrievalLogDetailsModal()">关闭</button>
|
||||
<button class="btn-secondary" onclick="closeRetrievalLogDetailsModal()" data-i18n="common.close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(modal);
|
||||
}
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
@@ -1774,57 +1806,57 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
|
||||
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 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>
|
||||
<span style="font-size: 0.875rem; color: var(--text-secondary);">${escapeHtml(item.category || '未分类')}</span>
|
||||
<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 || _t('retrievalLogs.uncategorized'))}</span>
|
||||
</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;">
|
||||
${escapeHtml(previewText || '无内容预览')}
|
||||
${escapeHtml(previewText || _t('retrievalLogs.noContentPreview'))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} 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 = `
|
||||
<div style="display: flex; flex-direction: column; gap: 20px;">
|
||||
<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="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">查询内容:</div>
|
||||
<div style="color: var(--text-primary); line-height: 1.6; word-break: break-word;">${escapeHtml(log.query || '无查询内容')}</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 || _t('retrievalLogs.noQuery'))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
${log.riskType ? `
|
||||
<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>
|
||||
` : ''}
|
||||
<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>
|
||||
<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-weight: 500; color: var(--text-primary);">${retrievedItems.length} 个知识项</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);">${_t('retrievalLogs.itemsCount', { count: retrievedItems.length })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${log.conversationId || log.messageId ? `
|
||||
<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;">
|
||||
${log.conversationId ? `
|
||||
<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;"
|
||||
onclick="navigator.clipboard.writeText('${escapeHtml(log.conversationId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
|
||||
title="点击复制">${escapeHtml(log.conversationId)}</code>
|
||||
@@ -1832,7 +1864,7 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
|
||||
` : ''}
|
||||
${log.messageId ? `
|
||||
<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;"
|
||||
onclick="navigator.clipboard.writeText('${escapeHtml(log.messageId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
|
||||
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-i18n,applyTranslations 已处理;此处可选地重新应用一次以兼容旧 DOM
|
||||
var listEl = document.getElementById('knowledge-items-list');
|
||||
if (listEl && typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listEl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 页面切换时加载数据
|
||||
if (typeof switchPage === 'function') {
|
||||
const originalSwitchPage = switchPage;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+392
-152
File diff suppressed because it is too large
Load Diff
+52
-38
@@ -1,4 +1,7 @@
|
||||
// 角色管理相关功能
|
||||
function _t(key, opts) {
|
||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||
}
|
||||
let currentRole = localStorage.getItem('currentRole') || '';
|
||||
let roles = [];
|
||||
let rolesSearchKeyword = ''; // 角色搜索关键词
|
||||
@@ -54,7 +57,11 @@ async function loadRoles() {
|
||||
return roles;
|
||||
} catch (error) {
|
||||
console.error('加载角色失败:', error);
|
||||
showNotification('加载角色失败: ' + error.message, 'error');
|
||||
// 提示文案使用 i18n;若此时 i18n 尚未初始化,则回退为可读中文,而不是暴露 key(roles.loadFailed)
|
||||
var loadFailedLabel = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('roles.loadFailed')
|
||||
: '加载角色失败';
|
||||
showNotification(loadFailedLabel + ': ' + error.message, 'error');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -108,11 +115,13 @@ function updateRoleSelectorDisplay() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
// 默认角色
|
||||
roleSelectorIcon.textContent = '🔵';
|
||||
roleSelectorText.textContent = '默认';
|
||||
roleSelectorText.textContent = typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,9 +174,9 @@ function renderRoleSelectionSidebar() {
|
||||
const icon = getRoleIcon(role);
|
||||
|
||||
// 处理默认角色的描述
|
||||
let description = role.description || '暂无描述';
|
||||
let description = role.description || _t('roles.noDescription');
|
||||
if (isDefaultRole && !role.description) {
|
||||
description = '默认角色,不额外携带用户提示词,使用默认MCP';
|
||||
description = _t('roles.defaultRoleDescription');
|
||||
}
|
||||
|
||||
roleItem.innerHTML = `
|
||||
@@ -280,7 +289,7 @@ function renderRolesList() {
|
||||
|
||||
if (filteredRoles.length === 0) {
|
||||
rolesList.innerHTML = '<div class="empty-state">' +
|
||||
(rolesSearchKeyword ? '没有找到匹配的角色' : '暂无角色') +
|
||||
(rolesSearchKeyword ? _t('roles.noMatchingRoles') : _t('roles.noRoles')) +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
@@ -310,7 +319,7 @@ function renderRolesList() {
|
||||
let toolsDisplay = '';
|
||||
let toolsCount = 0;
|
||||
if (role.name === '默认') {
|
||||
toolsDisplay = '使用所有工具';
|
||||
toolsDisplay = _t('roleModal.usingAllTools');
|
||||
} else if (role.tools && role.tools.length > 0) {
|
||||
toolsCount = role.tools.length;
|
||||
// 显示前5个工具名称
|
||||
@@ -322,13 +331,13 @@ function renderRolesList() {
|
||||
if (toolsCount <= 5) {
|
||||
toolsDisplay = toolNames.join(', ');
|
||||
} else {
|
||||
toolsDisplay = toolNames.join(', ') + ` 等 ${toolsCount} 个`;
|
||||
toolsDisplay = toolNames.join(', ') + _t('roleModal.andNMore', { count: toolsCount });
|
||||
}
|
||||
} else if (role.mcps && role.mcps.length > 0) {
|
||||
toolsCount = role.mcps.length;
|
||||
toolsDisplay = `等 ${toolsCount} 个`;
|
||||
toolsDisplay = _t('roleModal.andNMore', { count: toolsCount });
|
||||
} else {
|
||||
toolsDisplay = '使用所有工具';
|
||||
toolsDisplay = _t('roleModal.usingAllTools');
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -339,17 +348,17 @@ function renderRolesList() {
|
||||
${escapeHtml(role.name)}
|
||||
</h3>
|
||||
<span class="role-card-badge ${role.enabled !== false ? 'enabled' : 'disabled'}">
|
||||
${role.enabled !== false ? '已启用' : '已禁用'}
|
||||
${role.enabled !== false ? _t('roles.enabled') : _t('roles.disabled')}
|
||||
</span>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="role-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="editRole('${escapeHtml(role.name)}')">编辑</button>
|
||||
${role.name !== '默认' ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteRole('${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)}')">${_t('common.delete')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -501,7 +510,7 @@ async function loadRoleTools(page = 1, searchKeyword = '') {
|
||||
console.error('加载工具列表失败:', error);
|
||||
const toolsList = document.getElementById('role-tools-list');
|
||||
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 +528,7 @@ function renderRoleToolsList() {
|
||||
listContainer.innerHTML = '';
|
||||
|
||||
if (allRoleTools.length === 0) {
|
||||
listContainer.innerHTML = '<div class="tools-empty">暂无工具</div>';
|
||||
listContainer.innerHTML = '<div class="tools-empty">' + _t('roleModal.noTools') + '</div>';
|
||||
toolsList.appendChild(listContainer);
|
||||
return;
|
||||
}
|
||||
@@ -592,16 +601,16 @@ function renderRoleToolsPagination() {
|
||||
const startItem = (page - 1) * roleToolsPagination.pageSize + 1;
|
||||
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 = `
|
||||
<div class="pagination-info">
|
||||
显示 ${startItem}-${endItem} / 共 ${total} 个工具${roleToolsSearchKeyword ? ` (搜索: "${escapeHtml(roleToolsSearchKeyword)}")` : ''}
|
||||
</div>
|
||||
<div class="pagination-info">${paginationShowText}</div>
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-secondary" onclick="loadRoleTools(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>首页</button>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(${page - 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>上一页</button>
|
||||
<span class="pagination-page">第 ${page} / ${totalPages} 页</span>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>下一页</button>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? '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' : ''}>${_t('roleModal.prevPage')}</button>
|
||||
<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' : ''}>${_t('roleModal.nextPage')}</button>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${_t('roleModal.lastPage')}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -725,8 +734,8 @@ function updateRoleToolsStats() {
|
||||
// 总工具数(所有工具,包括已启用和未启用的)
|
||||
const totalTools = roleToolsPagination.total || 0;
|
||||
statsEl.innerHTML = `
|
||||
<span title="当前页选中的工具数">✅ 当前页已选中: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
|
||||
<span title="所有已启用工具中选中的工具总数(基于MCP管理)">📊 总计已选中: <strong>${totalEnabled}</strong> / ${totalTools} <em>(使用所有已启用工具)</em></span>
|
||||
<span title="${_t('roleModal.currentPageSelectedTitle')}">✅ ${_t('roleModal.currentPageSelected', { current: currentPageEnabled, total: currentPageTotal })}</span>
|
||||
<span title="${_t('roleModal.totalSelectedTitle')}">📊 ${_t('roleModal.totalSelected', { current: totalEnabled, total: totalTools })} <em>${_t('roleModal.usingAllEnabledTools')}</em></span>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
@@ -777,8 +786,8 @@ function updateRoleToolsStats() {
|
||||
const totalTools = roleToolsPagination.total || 0;
|
||||
|
||||
statsEl.innerHTML = `
|
||||
<span title="当前页选中的工具数(只统计已启用的工具)">✅ 当前页已选中: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
|
||||
<span title="角色已关联的工具总数(基于角色实际配置)">📊 总计已选中: <strong>${totalSelected}</strong> / ${totalTools}</span>
|
||||
<span title="${_t('roleModal.currentPageSelectedTitle')}">✅ ${_t('roleModal.currentPageSelected', { current: currentPageEnabled, total: currentPageTotal })}</span>
|
||||
<span title="${_t('roleModal.totalSelectedTitle')}">📊 ${_t('roleModal.totalSelected', { current: totalSelected, total: totalTools })}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -836,7 +845,7 @@ async function showAddRoleModal() {
|
||||
const modal = document.getElementById('role-modal');
|
||||
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').disabled = false;
|
||||
document.getElementById('role-description').value = '';
|
||||
@@ -916,14 +925,14 @@ async function showAddRoleModal() {
|
||||
async function editRole(roleName) {
|
||||
const role = roles.find(r => r.name === roleName);
|
||||
if (!role) {
|
||||
showNotification('角色不存在', 'error');
|
||||
showNotification(_t('roleModal.roleNotFound'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('role-modal');
|
||||
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').disabled = true; // 编辑时不允许修改名称
|
||||
document.getElementById('role-description').value = role.description || '';
|
||||
@@ -1184,7 +1193,7 @@ async function loadAllToolsToStateMap() {
|
||||
async function saveRole() {
|
||||
const name = document.getElementById('role-name').value.trim();
|
||||
if (!name) {
|
||||
showNotification('角色名称不能为空', 'error');
|
||||
showNotification(_t('roleModal.roleNameRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1225,7 +1234,7 @@ async function saveRole() {
|
||||
// 如果是首次添加角色且没有选择工具,默认使用全部工具
|
||||
if (isFirstUserRole && allSelectedTools.length === 0) {
|
||||
roleUsesAllTools = true;
|
||||
showNotification('检测到这是首次添加角色且未选择工具,将默认使用全部工具', 'info');
|
||||
showNotification(_t('roleModal.firstRoleNoToolsHint'), 'info');
|
||||
} else if (roleUsesAllTools) {
|
||||
// 如果当前使用所有工具,需要检查用户是否取消了一些工具
|
||||
// 检查状态映射中是否有未选中的已启用工具
|
||||
@@ -1356,7 +1365,7 @@ async function saveRole() {
|
||||
// 删除角色
|
||||
async function deleteRole(roleName) {
|
||||
if (roleName === '默认') {
|
||||
showNotification('不能删除默认角色', 'error');
|
||||
showNotification(_t('roleModal.cannotDeleteDefaultRole'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1428,6 +1437,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
updateRoleSelectorDisplay();
|
||||
});
|
||||
|
||||
// 语言切换后刷新角色选择器显示(默认/自定义角色名)
|
||||
document.addEventListener('languagechange', () => {
|
||||
updateRoleSelectorDisplay();
|
||||
});
|
||||
|
||||
// 获取当前选中的角色(供chat.js使用)
|
||||
function getCurrentRole() {
|
||||
return currentRole || '';
|
||||
@@ -1467,7 +1481,7 @@ async function loadRoleSkills() {
|
||||
allRoleSkills = [];
|
||||
const skillsList = document.getElementById('role-skills-list');
|
||||
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 +1502,7 @@ function renderRoleSkills() {
|
||||
|
||||
if (filteredSkills.length === 0) {
|
||||
skillsList.innerHTML = '<div class="skills-empty">' +
|
||||
(roleSkillsSearchKeyword ? '没有找到匹配的skills' : '暂无可用skills') +
|
||||
(roleSkillsSearchKeyword ? _t('roleModal.noMatchingSkills') : _t('roleModal.noSkillsAvailable')) +
|
||||
'</div>';
|
||||
updateRoleSkillsStats();
|
||||
return;
|
||||
@@ -1594,7 +1608,7 @@ function updateRoleSkillsStats() {
|
||||
filteredSkills.includes(skill)
|
||||
).length;
|
||||
|
||||
statsEl.textContent = `已选择 ${selectedCount} / ${filteredSkills.length}`;
|
||||
statsEl.textContent = _t('roleModal.skillsSelectedCount', { count: selectedCount, total: filteredSkills.length });
|
||||
}
|
||||
|
||||
// HTML转义函数
|
||||
|
||||
+35
-3
@@ -8,7 +8,7 @@ function initRouter() {
|
||||
if (hash) {
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
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)) {
|
||||
if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'webshell', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
|
||||
// 如果是chat页面且带有conversation参数,加载对应对话
|
||||
@@ -243,7 +243,8 @@ function initPage(pageId) {
|
||||
}
|
||||
break;
|
||||
case 'chat':
|
||||
// 对话页面已由chat.js初始化
|
||||
// 恢复对话列表折叠状态(从其他页返回时保持用户选择)
|
||||
initConversationSidebarState();
|
||||
break;
|
||||
case 'info-collect':
|
||||
// 信息收集页面
|
||||
@@ -292,6 +293,12 @@ function initPage(pageId) {
|
||||
initVulnerabilityPage();
|
||||
}
|
||||
break;
|
||||
case 'webshell':
|
||||
// 初始化 WebShell 管理页面
|
||||
if (typeof initWebshellPage === 'function') {
|
||||
initWebshellPage();
|
||||
}
|
||||
break;
|
||||
case 'settings':
|
||||
// 初始化设置页面(不需要加载工具列表)
|
||||
if (typeof loadConfig === 'function') {
|
||||
@@ -361,7 +368,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
|
||||
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)) {
|
||||
if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
|
||||
// 如果是chat页面且带有conversation参数,加载对应对话
|
||||
@@ -421,11 +428,36 @@ function initSidebarState() {
|
||||
sidebar.classList.add('collapsed');
|
||||
}
|
||||
}
|
||||
initConversationSidebarState();
|
||||
}
|
||||
|
||||
// 切换对话页左侧列表折叠/展开
|
||||
function toggleConversationSidebar() {
|
||||
const sidebar = document.getElementById('conversation-sidebar');
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||
localStorage.setItem('conversationSidebarCollapsed', isCollapsed ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复对话列表折叠状态(进入对话页时生效)
|
||||
function initConversationSidebarState() {
|
||||
const sidebar = document.getElementById('conversation-sidebar');
|
||||
if (sidebar) {
|
||||
const savedState = localStorage.getItem('conversationSidebarCollapsed');
|
||||
if (savedState === 'true') {
|
||||
sidebar.classList.add('collapsed');
|
||||
} else {
|
||||
sidebar.classList.remove('collapsed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出函数供其他脚本使用
|
||||
window.switchPage = switchPage;
|
||||
window.toggleSubmenu = toggleSubmenu;
|
||||
window.toggleSidebar = toggleSidebar;
|
||||
window.toggleConversationSidebar = toggleConversationSidebar;
|
||||
window.currentPage = function() { return currentPage; };
|
||||
|
||||
|
||||
+177
-81
@@ -172,6 +172,43 @@ async function loadConfig(loadTools = true) {
|
||||
// 允许0.0值,只有undefined/null时才使用默认值
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 填充机器人配置
|
||||
@@ -218,7 +255,10 @@ async function loadConfig(loadTools = true) {
|
||||
}
|
||||
} catch (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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +272,7 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
// 显示加载状态
|
||||
if (toolsList) {
|
||||
// 清空整个容器,包括可能存在的分页控件
|
||||
toolsList.innerHTML = '<div class="tools-list-items"><div class="loading" style="padding: 20px; text-align: center; color: var(--text-muted);">⏳ 正在加载工具列表...</div></div>';
|
||||
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 {
|
||||
@@ -287,8 +327,8 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
if (toolsList) {
|
||||
const isTimeout = error.name === 'AbortError' || error.message.includes('timeout');
|
||||
const errorMsg = isTimeout
|
||||
? '加载工具列表超时,可能是外部MCP连接较慢。请点击"刷新"按钮重试,或检查外部MCP连接状态。'
|
||||
: `加载工具列表失败: ${escapeHtml(error.message)}`;
|
||||
? (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>`;
|
||||
}
|
||||
}
|
||||
@@ -362,7 +402,7 @@ function renderToolsList() {
|
||||
listContainer.innerHTML = '';
|
||||
|
||||
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)) {
|
||||
toolsList.appendChild(listContainer);
|
||||
}
|
||||
@@ -391,8 +431,8 @@ function renderToolsList() {
|
||||
let externalBadge = '';
|
||||
if (toolState.is_external || tool.is_external) {
|
||||
const externalMcpName = toolState.external_mcp || tool.external_mcp || '';
|
||||
const badgeText = externalMcpName ? `外部 (${escapeHtml(externalMcpName)})` : '外部';
|
||||
const badgeTitle = externalMcpName ? `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}` : '外部MCP工具';
|
||||
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 ? (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>`;
|
||||
}
|
||||
|
||||
@@ -406,7 +446,7 @@ function renderToolsList() {
|
||||
${escapeHtml(tool.name)}
|
||||
${externalBadge}
|
||||
</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>
|
||||
`;
|
||||
listContainer.appendChild(toolItem);
|
||||
@@ -444,12 +484,19 @@ function renderToolsPagination() {
|
||||
const endItem = Math.min(page * toolsPagination.pageSize, total);
|
||||
|
||||
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 = `
|
||||
<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 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()">
|
||||
<option value="10" ${savedPageSize === 10 ? 'selected' : ''}>10</option>
|
||||
<option value="20" ${savedPageSize === 20 ? 'selected' : ''}>20</option>
|
||||
@@ -458,11 +505,11 @@ function renderToolsPagination() {
|
||||
</select>
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-secondary" onclick="loadToolsList(1, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>首页</button>
|
||||
<button class="btn-secondary" onclick="loadToolsList(${page - 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>上一页</button>
|
||||
<span class="pagination-page">第 ${page} / ${totalPages} 页</span>
|
||||
<button class="btn-secondary" onclick="loadToolsList(${page + 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>下一页</button>
|
||||
<button class="btn-secondary" onclick="loadToolsList(${totalPages}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? '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' : ''}>${t('mcp.prevPage')}</button>
|
||||
<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' : ''}>${t('mcp.nextPage')}</button>
|
||||
<button class="btn-secondary" onclick="loadToolsList(${totalPages}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${t('mcp.lastPage')}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -656,9 +703,10 @@ async function updateToolsStats() {
|
||||
totalEnabled = currentPageEnabled;
|
||||
}
|
||||
|
||||
const tStats = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
statsEl.innerHTML = `
|
||||
<span title="当前页启用的工具数">✅ 当前页已启用: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
|
||||
<span title="所有工具中启用的工具总数">📊 总计已启用: <strong>${totalEnabled}</strong> / ${totalTools}</span>
|
||||
<span title="${tStats('mcp.currentPageEnabled')}">✅ ${tStats('mcp.currentPageEnabled')}: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
|
||||
<span title="${tStats('mcp.totalEnabled')}">📊 ${tStats('mcp.totalEnabled')}: <strong>${totalEnabled}</strong> / ${totalTools}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -700,7 +748,10 @@ async function applySettings() {
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
alert('请填写所有必填字段(标记为 * 的字段)');
|
||||
const msg = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('settings.apply.fillRequired')
|
||||
: '请填写所有必填字段(标记为 * 的字段)';
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -728,6 +779,15 @@ async function applySettings() {
|
||||
const val = parseFloat(document.getElementById('knowledge-retrieval-hybrid-weight')?.value);
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -850,7 +910,10 @@ async function applySettings() {
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 应用配置
|
||||
@@ -860,14 +923,23 @@ async function applySettings() {
|
||||
|
||||
if (!applyResponse.ok) {
|
||||
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();
|
||||
} catch (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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,7 +1050,7 @@ async function saveToolsConfig() {
|
||||
throw new Error(error.error || '应用配置失败');
|
||||
}
|
||||
|
||||
alert('工具配置已成功保存!');
|
||||
alert(typeof window.t === 'function' ? window.t('mcp.toolsConfigSaved') : '工具配置已成功保存!');
|
||||
|
||||
// 重新加载工具列表以反映最新状态
|
||||
if (typeof loadToolsList === 'function') {
|
||||
@@ -986,7 +1058,7 @@ async function saveToolsConfig() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存工具配置失败:', error);
|
||||
alert('保存工具配置失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('mcp.saveToolsConfigFailed') : '保存工具配置失败') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,7 +1105,7 @@ async function changePassword() {
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
alert('请正确填写当前密码和新密码,新密码至少 8 位且需要两次输入一致。');
|
||||
alert(typeof window.t === 'function' ? window.t('settings.security.fillPasswordHint') : '请正确填写当前密码和新密码,新密码至少 8 位且需要两次输入一致。');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1058,13 +1130,14 @@ async function changePassword() {
|
||||
throw new Error(result.error || '修改密码失败');
|
||||
}
|
||||
|
||||
alert('密码已更新,请使用新密码重新登录。');
|
||||
const pwdMsg = typeof window.t === 'function' ? window.t('settings.security.passwordUpdated') : '密码已更新,请使用新密码重新登录。';
|
||||
alert(pwdMsg);
|
||||
resetPasswordForm();
|
||||
handleUnauthorized({ message: '密码已更新,请使用新密码重新登录。', silent: false });
|
||||
handleUnauthorized({ message: pwdMsg, silent: false });
|
||||
closeSettings();
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error);
|
||||
alert('修改密码失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('settings.security.changePasswordFailed') : '修改密码失败') + ': ' + error.message);
|
||||
} finally {
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
@@ -1093,7 +1166,8 @@ async function loadExternalMCPs() {
|
||||
console.error('加载外部MCP列表失败:', error);
|
||||
const list = document.getElementById('external-mcp-list');
|
||||
if (list) {
|
||||
list.innerHTML = `<div class="error">加载失败: ${escapeHtml(error.message)}</div>`;
|
||||
const errT = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
list.innerHTML = `<div class="error">${escapeHtml(errT('mcp.loadExternalMCPFailed'))}: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1127,7 +1201,8 @@ function renderExternalMCPList(servers) {
|
||||
if (!list) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1138,10 +1213,11 @@ function renderExternalMCPList(servers) {
|
||||
status === 'connecting' ? 'status-connecting' :
|
||||
status === 'error' ? 'status-error' :
|
||||
status === 'disabled' ? 'status-disabled' : 'status-disconnected';
|
||||
const statusText = status === 'connected' ? '已连接' :
|
||||
status === 'connecting' ? '连接中...' :
|
||||
status === 'error' ? '连接失败' :
|
||||
status === 'disabled' ? '已禁用' : '未连接';
|
||||
const statusT = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
const statusText = status === 'connected' ? statusT('mcp.connected') :
|
||||
status === 'connecting' ? statusT('mcp.connecting') :
|
||||
status === 'error' ? statusT('mcp.connectionFailed') :
|
||||
status === 'disabled' ? statusT('mcp.disabled') : statusT('mcp.disconnected');
|
||||
const transport = server.config.transport || (server.config.command ? 'stdio' : 'http');
|
||||
const transportIcon = transport === 'stdio' ? '⚙️' : '🌐';
|
||||
|
||||
@@ -1149,58 +1225,58 @@ function renderExternalMCPList(servers) {
|
||||
<div class="external-mcp-item">
|
||||
<div class="external-mcp-item-header">
|
||||
<div class="external-mcp-item-info">
|
||||
<h4>${transportIcon} ${escapeHtml(name)}${server.tool_count !== undefined && server.tool_count > 0 ? `<span class="tool-count-badge" title="工具数量">🔧 ${server.tool_count}</span>` : ''}</h4>
|
||||
<h4>${transportIcon} ${escapeHtml(name)}${server.tool_count !== undefined && server.tool_count > 0 ? `<span class="tool-count-badge" title="${escapeHtml(statusT('mcp.toolCount'))}">🔧 ${server.tool_count}</span>` : ''}</h4>
|
||||
<span class="external-mcp-status ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="external-mcp-item-actions">
|
||||
${status === 'connected' || status === 'disconnected' || status === 'error' ?
|
||||
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" onclick="toggleExternalMCP('${escapeHtml(name)}', '${status}')" title="${status === 'connected' ? '停止连接' : '启动连接'}">
|
||||
${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' ? '⏸ ' + statusT('mcp.stop') : '▶ ' + statusT('mcp.start')}
|
||||
</button>` :
|
||||
status === 'connecting' ?
|
||||
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" disabled style="opacity: 0.6; cursor: not-allowed;">
|
||||
⏳ 连接中...
|
||||
⏳ ${statusT('mcp.connecting')}
|
||||
</button>` : ''}
|
||||
<button class="btn-small" onclick="editExternalMCP('${escapeHtml(name)}')" title="编辑配置" ${status === 'connecting' ? 'disabled' : ''}>✏️ 编辑</button>
|
||||
<button class="btn-small btn-danger" onclick="deleteExternalMCP('${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="${statusT('mcp.deleteConfig')}" ${status === 'connecting' ? 'disabled' : ''}>🗑 ${statusT('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
${status === 'error' && server.error ? `
|
||||
<div class="external-mcp-error" style="margin: 12px 0; padding: 12px; background: #fee; border-left: 3px solid #f44; border-radius: 4px; color: #c33; font-size: 0.875rem;">
|
||||
<strong>❌ 连接错误:</strong>${escapeHtml(server.error)}
|
||||
<strong>❌ ${statusT('mcp.connectionErrorLabel')}</strong>${escapeHtml(server.error)}
|
||||
</div>` : ''}
|
||||
<div class="external-mcp-item-details">
|
||||
<div>
|
||||
<strong>传输模式</strong>
|
||||
<strong>${statusT('mcp.transportMode')}</strong>
|
||||
<span>${transportIcon} ${escapeHtml(transport.toUpperCase())}</span>
|
||||
</div>
|
||||
${server.tool_count !== undefined && server.tool_count > 0 ? `
|
||||
<div>
|
||||
<strong>工具数量</strong>
|
||||
<span style="font-weight: 600; color: var(--accent-color);">🔧 ${server.tool_count} 个工具</span>
|
||||
<strong>${statusT('mcp.toolCount')}</strong>
|
||||
<span style="font-weight: 600; color: var(--accent-color);">${statusT('mcp.toolsCountValue', { count: server.tool_count })}</span>
|
||||
</div>` : server.tool_count === 0 && status === 'connected' ? `
|
||||
<div>
|
||||
<strong>工具数量</strong>
|
||||
<span style="color: var(--text-muted);">暂无工具</span>
|
||||
<strong>${statusT('mcp.toolCount')}</strong>
|
||||
<span style="color: var(--text-muted);">${statusT('mcp.noTools')}</span>
|
||||
</div>` : ''}
|
||||
${server.config.description ? `
|
||||
<div>
|
||||
<strong>描述</strong>
|
||||
<strong>${statusT('mcp.description')}</strong>
|
||||
<span>${escapeHtml(server.config.description)}</span>
|
||||
</div>` : ''}
|
||||
${server.config.timeout ? `
|
||||
<div>
|
||||
<strong>超时时间</strong>
|
||||
<span>${server.config.timeout} 秒</span>
|
||||
<strong>${statusT('mcp.timeout')}</strong>
|
||||
<span>${server.config.timeout} ${statusT('mcp.secondsUnit')}</span>
|
||||
</div>` : ''}
|
||||
${transport === 'stdio' && server.config.command ? `
|
||||
<div>
|
||||
<strong>命令</strong>
|
||||
<strong>${statusT('mcp.command')}</strong>
|
||||
<span style="font-family: monospace; font-size: 0.8125rem;">${escapeHtml(server.config.command)}</span>
|
||||
</div>` : ''}
|
||||
${transport === 'http' && server.config.url ? `
|
||||
<div>
|
||||
<strong>URL</strong>
|
||||
<strong>${statusT('mcp.urlLabel')}</strong>
|
||||
<span style="font-family: monospace; font-size: 0.8125rem; word-break: break-all;">${escapeHtml(server.config.url)}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
@@ -1221,18 +1297,19 @@ function renderExternalMCPStats(stats) {
|
||||
const disabled = stats.disabled || 0;
|
||||
const connected = stats.connected || 0;
|
||||
|
||||
const statsT = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
statsEl.innerHTML = `
|
||||
<span title="总配置数">📊 总数: <strong>${total}</strong></span>
|
||||
<span title="已启用的配置数">✅ 已启用: <strong>${enabled}</strong></span>
|
||||
<span title="已停用的配置数">⏸ 已停用: <strong>${disabled}</strong></span>
|
||||
<span title="当前已连接的配置数">🔗 已连接: <strong>${connected}</strong></span>
|
||||
<span title="${statsT('mcp.totalCount')}">📊 ${statsT('mcp.totalCount')}: <strong>${total}</strong></span>
|
||||
<span title="${statsT('mcp.enabledCount')}">✅ ${statsT('mcp.enabledCount')}: <strong>${enabled}</strong></span>
|
||||
<span title="${statsT('mcp.disabledCount')}">⏸ ${statsT('mcp.disabledCount')}: <strong>${disabled}</strong></span>
|
||||
<span title="${statsT('mcp.connectedCount')}">🔗 ${statsT('mcp.connectedCount')}: <strong>${connected}</strong></span>
|
||||
`;
|
||||
}
|
||||
|
||||
// 显示添加外部MCP模态框
|
||||
function showAddExternalMCPModal() {
|
||||
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-error').style.display = 'none';
|
||||
document.getElementById('external-mcp-json-error').textContent = '';
|
||||
@@ -1251,13 +1328,13 @@ async function editExternalMCP(name) {
|
||||
try {
|
||||
const response = await apiFetch(`/api/external-mcp/${encodeURIComponent(name)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取外部MCP配置失败');
|
||||
throw new Error(typeof window.t === 'function' ? window.t('mcp.getConfigFailed') : '获取外部MCP配置失败');
|
||||
}
|
||||
|
||||
const server = await response.json();
|
||||
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为名称)
|
||||
const config = { ...server.config };
|
||||
@@ -1279,7 +1356,7 @@ async function editExternalMCP(name) {
|
||||
document.getElementById('external-mcp-modal').style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('编辑外部MCP失败:', error);
|
||||
alert('编辑失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '编辑失败') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1291,7 +1368,7 @@ function formatExternalMCPJSON() {
|
||||
try {
|
||||
const jsonStr = jsonTextarea.value.trim();
|
||||
if (!jsonStr) {
|
||||
errorDiv.textContent = 'JSON不能为空';
|
||||
errorDiv.textContent = (typeof window.t === 'function' ? window.t('mcp.jsonEmpty') : 'JSON不能为空');
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
@@ -1303,7 +1380,7 @@ function formatExternalMCPJSON() {
|
||||
errorDiv.style.display = 'none';
|
||||
jsonTextarea.classList.remove('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';
|
||||
jsonTextarea.classList.add('error');
|
||||
}
|
||||
@@ -1311,6 +1388,7 @@ function formatExternalMCPJSON() {
|
||||
|
||||
// 加载示例
|
||||
function loadExternalMCPExample() {
|
||||
const desc = (typeof window.t === 'function' ? window.t('externalMcpModal.exampleDescription') : '示例描述');
|
||||
const example = {
|
||||
"hexstrike-ai": {
|
||||
command: "python3",
|
||||
@@ -1319,7 +1397,7 @@ function loadExternalMCPExample() {
|
||||
"--server",
|
||||
"http://example.com"
|
||||
],
|
||||
description: "示例描述",
|
||||
description: desc,
|
||||
timeout: 300
|
||||
},
|
||||
"cyberstrike-ai-http": {
|
||||
@@ -1344,7 +1422,7 @@ async function saveExternalMCP() {
|
||||
const errorDiv = document.getElementById('external-mcp-json-error');
|
||||
|
||||
if (!jsonStr) {
|
||||
errorDiv.textContent = 'JSON配置不能为空';
|
||||
errorDiv.textContent = (typeof window.t === 'function' ? window.t('mcp.jsonEmpty') : 'JSON不能为空');
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
jsonTextarea.focus();
|
||||
@@ -1355,16 +1433,17 @@ async function saveExternalMCP() {
|
||||
try {
|
||||
configObj = JSON.parse(jsonStr);
|
||||
} catch (error) {
|
||||
errorDiv.textContent = 'JSON格式错误: ' + error.message;
|
||||
errorDiv.textContent = (typeof window.t === 'function' ? window.t('mcp.jsonError') : 'JSON格式错误') + ': ' + error.message;
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
jsonTextarea.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const t = (typeof window.t === 'function' ? window.t : function (k, opts) { return k; });
|
||||
// 验证必须是对象格式
|
||||
if (typeof configObj !== 'object' || Array.isArray(configObj) || configObj === null) {
|
||||
errorDiv.textContent = '配置错误: 必须是JSON对象格式,key为配置名称,value为配置内容';
|
||||
errorDiv.textContent = t('mcp.configMustBeObject');
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
@@ -1373,7 +1452,7 @@ async function saveExternalMCP() {
|
||||
// 获取所有配置名称
|
||||
const names = Object.keys(configObj);
|
||||
if (names.length === 0) {
|
||||
errorDiv.textContent = '配置错误: 至少需要一个配置项';
|
||||
errorDiv.textContent = t('mcp.configNeedOne');
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
@@ -1382,7 +1461,7 @@ async function saveExternalMCP() {
|
||||
// 验证每个配置
|
||||
for (const name of names) {
|
||||
if (!name || name.trim() === '') {
|
||||
errorDiv.textContent = '配置错误: 配置名称不能为空';
|
||||
errorDiv.textContent = t('mcp.configNameEmpty');
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
@@ -1390,7 +1469,7 @@ async function saveExternalMCP() {
|
||||
|
||||
const config = configObj[name];
|
||||
if (typeof config !== 'object' || Array.isArray(config) || config === null) {
|
||||
errorDiv.textContent = `配置错误: "${name}" 的配置必须是对象`;
|
||||
errorDiv.textContent = t('mcp.configMustBeObj', { name: name });
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
@@ -1402,28 +1481,28 @@ async function saveExternalMCP() {
|
||||
// 验证配置内容
|
||||
const transport = config.transport || (config.command ? 'stdio' : config.url ? 'http' : '');
|
||||
if (!transport) {
|
||||
errorDiv.textContent = `配置错误: "${name}" 需要指定command(stdio模式)或url(http/sse模式)`;
|
||||
errorDiv.textContent = t('mcp.configNeedCommand', { name: name });
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (transport === 'stdio' && !config.command) {
|
||||
errorDiv.textContent = `配置错误: "${name}" stdio模式需要command字段`;
|
||||
errorDiv.textContent = t('mcp.configStdioNeedCommand', { name: name });
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (transport === 'http' && !config.url) {
|
||||
errorDiv.textContent = `配置错误: "${name}" http模式需要url字段`;
|
||||
errorDiv.textContent = t('mcp.configHttpNeedUrl', { name: name });
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (transport === 'sse' && !config.url) {
|
||||
errorDiv.textContent = `配置错误: "${name}" sse模式需要url字段`;
|
||||
errorDiv.textContent = t('mcp.configSseNeedUrl', { name: name });
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
@@ -1438,7 +1517,7 @@ async function saveExternalMCP() {
|
||||
// 如果是编辑模式,只更新当前编辑的配置
|
||||
if (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';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
@@ -1482,10 +1561,10 @@ async function saveExternalMCP() {
|
||||
}
|
||||
// 轮询几次以拉取后端异步更新的工具数量(无固定延迟,拿到即停)
|
||||
pollExternalMCPToolCount(null, 5);
|
||||
alert('保存成功');
|
||||
alert(typeof window.t === 'function' ? window.t('mcp.saveSuccess') : '保存成功');
|
||||
} catch (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';
|
||||
jsonTextarea.classList.add('error');
|
||||
}
|
||||
@@ -1493,7 +1572,7 @@ async function saveExternalMCP() {
|
||||
|
||||
// 删除外部MCP
|
||||
async function deleteExternalMCP(name) {
|
||||
if (!confirm(`确定要删除外部MCP "${name}" 吗?`)) {
|
||||
if (!confirm((typeof window.t === 'function' ? window.t('mcp.deleteExternalConfirm', { name: name }) : `确定要删除外部MCP "${name}" 吗?`))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1512,10 +1591,10 @@ async function deleteExternalMCP(name) {
|
||||
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
||||
window.refreshMentionTools();
|
||||
}
|
||||
alert('删除成功');
|
||||
alert(typeof window.t === 'function' ? window.t('mcp.deleteSuccess') : '删除成功');
|
||||
} catch (error) {
|
||||
console.error('删除外部MCP失败:', error);
|
||||
alert('删除失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '删除失败') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1580,7 +1659,7 @@ async function toggleExternalMCP(name, currentStatus) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换外部MCP状态失败:', error);
|
||||
alert('操作失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '操作失败') + ': ' + error.message);
|
||||
|
||||
// 恢复按钮状态
|
||||
if (button) {
|
||||
@@ -1633,7 +1712,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
|
||||
window.refreshMentionTools();
|
||||
}
|
||||
if (status === 'error') {
|
||||
alert('连接失败,请检查配置和网络连接');
|
||||
alert(typeof window.t === 'function' ? window.t('mcp.connectionFailedCheck') : '连接失败,请检查配置和网络连接');
|
||||
}
|
||||
return;
|
||||
} else if (status === 'connecting') {
|
||||
@@ -1655,7 +1734,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
|
||||
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
||||
window.refreshMentionTools();
|
||||
}
|
||||
alert('连接超时,请检查配置和网络连接');
|
||||
alert(typeof window.t === 'function' ? window.t('mcp.connectionTimeout') : '连接超时,请检查配置和网络连接');
|
||||
}
|
||||
|
||||
// 在打开设置时加载外部MCP列表
|
||||
@@ -1664,3 +1743,20 @@ openSettings = async function() {
|
||||
await originalOpenSettings();
|
||||
await loadExternalMCPs();
|
||||
};
|
||||
|
||||
// 语言切换后重新渲染 MCP 管理页中由 JS 写入的区块(innerHTML 不会随 data-i18n 自动更新)
|
||||
document.addEventListener('languagechange', function () {
|
||||
try {
|
||||
const mcpPage = document.getElementById('page-mcp-management');
|
||||
if (mcpPage && mcpPage.classList.contains('active')) {
|
||||
if (typeof loadExternalMCPs === 'function') {
|
||||
loadExternalMCPs().catch(function () { /* ignore */ });
|
||||
}
|
||||
if (typeof updateToolsStats === 'function') {
|
||||
updateToolsStats().catch(function () { /* ignore */ });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('languagechange MCP refresh failed', e);
|
||||
}
|
||||
});
|
||||
|
||||
+94
-66
@@ -1,4 +1,7 @@
|
||||
// Skills管理相关功能
|
||||
function _t(key, opts) {
|
||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||
}
|
||||
let skillsList = [];
|
||||
let currentEditingSkillName = null;
|
||||
let isSavingSkill = false; // 防止重复提交
|
||||
@@ -65,7 +68,7 @@ async function loadSkills(page = 1, pageSize = null) {
|
||||
|
||||
const response = await apiFetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取skills列表失败');
|
||||
throw new Error(_t('skills.loadListFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
skillsList = data.skills || [];
|
||||
@@ -76,10 +79,10 @@ async function loadSkills(page = 1, pageSize = null) {
|
||||
updateSkillsManagementStats();
|
||||
} catch (error) {
|
||||
console.error('加载skills列表失败:', error);
|
||||
showNotification('加载skills列表失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.loadListFailed') + ': ' + error.message, 'error');
|
||||
const skillsListEl = document.getElementById('skills-list');
|
||||
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) {
|
||||
skillsListEl.innerHTML = '<div class="empty-state">' +
|
||||
(skillsSearchKeyword ? '没有找到匹配的skills' : '暂无skills,点击"创建Skill"创建第一个skill') +
|
||||
(skillsSearchKeyword ? _t('skills.noMatch') : _t('skills.noSkills')) +
|
||||
'</div>';
|
||||
// 搜索时隐藏分页
|
||||
const paginationContainer = document.getElementById('skills-pagination');
|
||||
@@ -109,12 +112,12 @@ function renderSkillsList() {
|
||||
<div class="skill-card">
|
||||
<div class="skill-card-header">
|
||||
<h3 class="skill-card-title">${escapeHtml(skill.name || '')}</h3>
|
||||
<div class="skill-card-description">${escapeHtml(skill.description || '无描述')}</div>
|
||||
<div class="skill-card-description">${escapeHtml(skill.description || _t('skills.noDescription'))}</div>
|
||||
</div>
|
||||
<div class="skill-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="viewSkill('${escapeHtml(skill.name)}')">查看</button>
|
||||
<button class="btn-secondary btn-small" onclick="editSkill('${escapeHtml(skill.name)}')">编辑</button>
|
||||
<button class="btn-secondary btn-small btn-danger" onclick="deleteSkill('${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)}')">${_t('common.edit')}</button>
|
||||
<button class="btn-secondary btn-small btn-danger" onclick="deleteSkill('${escapeHtml(skill.name)}')">${_t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -154,12 +157,19 @@ function renderSkillsPagination() {
|
||||
|
||||
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样式)
|
||||
paginationHTML += `
|
||||
<div class="pagination-info">
|
||||
<span>显示 ${start}-${end} / 共 ${total} 条</span>
|
||||
<span>${escapeHtml(paginationShowText)}</span>
|
||||
<label class="pagination-page-size">
|
||||
每页显示
|
||||
${escapeHtml(perPageLabelText)}
|
||||
<select id="skills-page-size-pagination" onchange="changeSkillsPageSize()">
|
||||
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
|
||||
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||||
@@ -173,11 +183,11 @@ function renderSkillsPagination() {
|
||||
// 右侧:分页按钮(参考MCP样式:首页、上一页、第X/Y页、下一页、末页)
|
||||
paginationHTML += `
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-secondary" onclick="loadSkills(1, ${pageSize})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>首页</button>
|
||||
<button class="btn-secondary" onclick="loadSkills(${currentPage - 1}, ${pageSize})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>上一页</button>
|
||||
<span class="pagination-page">第 ${currentPage} / ${totalPages || 1} 页</span>
|
||||
<button class="btn-secondary" onclick="loadSkills(${currentPage + 1}, ${pageSize})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>下一页</button>
|
||||
<button class="btn-secondary" onclick="loadSkills(${totalPages || 1}, ${pageSize})" ${currentPage >= totalPages || 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' : ''}>${escapeHtml(prevPageText)}</button>
|
||||
<span class="pagination-page">${escapeHtml(pageOfText)}</span>
|
||||
<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' : ''}>${escapeHtml(lastPageText)}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -291,7 +301,7 @@ async function searchSkills() {
|
||||
try {
|
||||
const response = await apiFetch(`/api/skills?search=${encodeURIComponent(skillsSearchKeyword)}&limit=10000&offset=0`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取skills列表失败');
|
||||
throw new Error(_t('skills.loadListFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
skillsList = data.skills || [];
|
||||
@@ -306,7 +316,7 @@ async function searchSkills() {
|
||||
updateSkillsManagementStats();
|
||||
} catch (error) {
|
||||
console.error('搜索skills失败:', error);
|
||||
showNotification('搜索失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.searchFailed') + ': ' + error.message, 'error');
|
||||
}
|
||||
} else {
|
||||
// 没有搜索关键词时,恢复分页加载
|
||||
@@ -332,7 +342,7 @@ function clearSkillsSearch() {
|
||||
// 刷新skills
|
||||
async function refreshSkills() {
|
||||
await loadSkills(skillsPagination.currentPage, skillsPagination.pageSize);
|
||||
showNotification('已刷新', 'success');
|
||||
showNotification(_t('skills.refreshed'), 'success');
|
||||
}
|
||||
|
||||
// 显示添加skill模态框
|
||||
@@ -340,7 +350,7 @@ function showAddSkillModal() {
|
||||
const modal = document.getElementById('skill-modal');
|
||||
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').disabled = false;
|
||||
document.getElementById('skill-description').value = '';
|
||||
@@ -354,7 +364,7 @@ async function editSkill(skillName) {
|
||||
try {
|
||||
const response = await apiFetch(`/api/skills/${encodeURIComponent(skillName)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取skill详情失败');
|
||||
throw new Error(_t('skills.loadDetailFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
const skill = data.skill;
|
||||
@@ -362,7 +372,7 @@ async function editSkill(skillName) {
|
||||
const modal = document.getElementById('skill-modal');
|
||||
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').disabled = true; // 编辑时不允许修改名称
|
||||
document.getElementById('skill-description').value = skill.description || '';
|
||||
@@ -372,7 +382,7 @@ async function editSkill(skillName) {
|
||||
modal.style.display = 'flex';
|
||||
} catch (error) {
|
||||
console.error('加载skill详情失败:', error);
|
||||
showNotification('加载skill详情失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.loadDetailFailed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +391,7 @@ async function viewSkill(skillName) {
|
||||
try {
|
||||
const response = await apiFetch(`/api/skills/${encodeURIComponent(skillName)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取skill详情失败');
|
||||
throw new Error(_t('skills.loadDetailFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
const skill = data.skill;
|
||||
@@ -390,22 +400,29 @@ async function viewSkill(skillName) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = '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 = `
|
||||
<div class="modal-content" style="max-width: 900px; max-height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h2>查看Skill: ${escapeHtml(skill.name)}</h2>
|
||||
<h2>${escapeHtml(viewTitle)}</h2>
|
||||
<span class="modal-close" onclick="closeSkillViewModal()">×</span>
|
||||
</div>
|
||||
<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>` : ''}
|
||||
<div style="margin-bottom: 8px;"><strong>路径:</strong> ${escapeHtml(skill.path || '')}</div>
|
||||
<div style="margin-bottom: 16px;"><strong>修改时间:</strong> ${escapeHtml(skill.mod_time || '')}</div>
|
||||
<div style="margin-bottom: 8px;"><strong>内容:</strong></div>
|
||||
${skill.description ? `<div style="margin-bottom: 16px;"><strong>${escapeHtml(descLabel)}</strong> ${escapeHtml(skill.description)}</div>` : ''}
|
||||
<div style="margin-bottom: 8px;"><strong>${escapeHtml(pathLabel)}</strong> ${escapeHtml(skill.path || '')}</div>
|
||||
<div style="margin-bottom: 16px;"><strong>${escapeHtml(modTimeLabel)}</strong> ${escapeHtml(skill.mod_time || '')}</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>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeSkillViewModal()">关闭</button>
|
||||
<button class="btn-primary" onclick="editSkill('${escapeHtml(skill.name)}'); closeSkillViewModal();">编辑</button>
|
||||
<button class="btn-secondary" onclick="closeSkillViewModal()">${escapeHtml(closeBtn)}</button>
|
||||
<button class="btn-primary" onclick="editSkill('${escapeHtml(skill.name)}'); closeSkillViewModal();">${escapeHtml(editBtn)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -413,7 +430,7 @@ async function viewSkill(skillName) {
|
||||
modal.style.display = 'flex';
|
||||
} catch (error) {
|
||||
console.error('查看skill失败:', error);
|
||||
showNotification('查看skill失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.viewFailed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,18 +460,18 @@ async function saveSkill() {
|
||||
const content = document.getElementById('skill-content').value.trim();
|
||||
|
||||
if (!name) {
|
||||
showNotification('skill名称不能为空', 'error');
|
||||
showNotification(_t('skills.nameRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
showNotification('skill内容不能为空', 'error');
|
||||
showNotification(_t('skills.contentRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证skill名称
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
showNotification('skill名称只能包含字母、数字、连字符和下划线', 'error');
|
||||
showNotification(_t('skills.nameInvalid'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -462,7 +479,7 @@ async function saveSkill() {
|
||||
const saveBtn = document.querySelector('#skill-modal .btn-primary');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '保存中...';
|
||||
saveBtn.textContent = _t('skills.saving');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -484,20 +501,20 @@ async function saveSkill() {
|
||||
|
||||
if (!response.ok) {
|
||||
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();
|
||||
await loadSkills(skillsPagination.currentPage, skillsPagination.pageSize);
|
||||
} catch (error) {
|
||||
console.error('保存skill失败:', error);
|
||||
showNotification('保存skill失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.saveFailed') + ': ' + error.message, 'error');
|
||||
} finally {
|
||||
isSavingSkill = false;
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = '保存';
|
||||
saveBtn.textContent = _t('common.save');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,10 +535,10 @@ async function deleteSkill(skillName) {
|
||||
}
|
||||
|
||||
// 构建确认消息
|
||||
let confirmMessage = `确定要删除skill "${skillName}" 吗?此操作不可恢复。`;
|
||||
let confirmMessage = _t('skills.deleteConfirm', { name: skillName });
|
||||
if (boundRoles.length > 0) {
|
||||
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)) {
|
||||
@@ -535,14 +552,14 @@ async function deleteSkill(skillName) {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '删除skill失败');
|
||||
throw new Error(error.error || _t('skills.deleteFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
let successMessage = 'skill已删除';
|
||||
let successMessage = _t('skills.deleteSuccess');
|
||||
if (data.affected_roles && data.affected_roles.length > 0) {
|
||||
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');
|
||||
|
||||
@@ -554,7 +571,7 @@ async function deleteSkill(skillName) {
|
||||
await loadSkills(pageToLoad, skillsPagination.pageSize);
|
||||
} catch (error) {
|
||||
console.error('删除skill失败:', error);
|
||||
showNotification('删除skill失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.deleteFailed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,7 +582,7 @@ async function loadSkillsMonitor() {
|
||||
try {
|
||||
const response = await apiFetch('/api/skills/stats');
|
||||
if (!response.ok) {
|
||||
throw new Error('获取skills统计信息失败');
|
||||
throw new Error(_t('skills.loadStatsFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
@@ -581,14 +598,14 @@ async function loadSkillsMonitor() {
|
||||
renderSkillsMonitor();
|
||||
} catch (error) {
|
||||
console.error('加载skills监控数据失败:', error);
|
||||
showNotification('加载skills监控数据失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.loadStatsFailed') + ': ' + error.message, 'error');
|
||||
const statsEl = document.getElementById('skills-stats');
|
||||
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');
|
||||
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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -604,23 +621,23 @@ function renderSkillsMonitor() {
|
||||
|
||||
statsEl.innerHTML = `
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
`;
|
||||
@@ -634,7 +651,7 @@ function renderSkillsMonitor() {
|
||||
|
||||
// 如果没有统计数据,显示空状态
|
||||
if (stats.length === 0) {
|
||||
monitorListEl.innerHTML = '<div class="monitor-empty">暂无Skills调用记录</div>';
|
||||
monitorListEl.innerHTML = '<div class="monitor-empty">' + _t('skills.noCallRecords') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -652,12 +669,12 @@ function renderSkillsMonitor() {
|
||||
<table class="monitor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left !important;">Skill名称</th>
|
||||
<th style="text-align: center;">总调用</th>
|
||||
<th style="text-align: center;">成功</th>
|
||||
<th style="text-align: center;">失败</th>
|
||||
<th style="text-align: center;">成功率</th>
|
||||
<th style="text-align: left;">最后调用时间</th>
|
||||
<th style="text-align: left !important;">${_t('skills.skillName')}</th>
|
||||
<th style="text-align: center;">${_t('skills.totalCalls')}</th>
|
||||
<th style="text-align: center;">${_t('skills.success')}</th>
|
||||
<th style="text-align: center;">${_t('skills.failure')}</th>
|
||||
<th style="text-align: center;">${_t('skills.successRate')}</th>
|
||||
<th style="text-align: left;">${_t('skills.lastCallTime')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -687,12 +704,12 @@ function renderSkillsMonitor() {
|
||||
// 刷新skills监控
|
||||
async function refreshSkillsMonitor() {
|
||||
await loadSkillsMonitor();
|
||||
showNotification('已刷新', 'success');
|
||||
showNotification(_t('skills.refreshed'), 'success');
|
||||
}
|
||||
|
||||
// 清空skills统计数据
|
||||
async function clearSkillsStats() {
|
||||
if (!confirm('确定要清空所有Skills统计数据吗?此操作不可恢复。')) {
|
||||
if (!confirm(_t('skills.clearStatsConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -703,15 +720,15 @@ async function clearSkillsStats() {
|
||||
|
||||
if (!response.ok) {
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('清空统计数据失败:', error);
|
||||
showNotification('清空统计数据失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.clearStatsFailed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,3 +739,14 @@ function escapeHtml(text) {
|
||||
div.textContent = text;
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+134
-131
@@ -1,4 +1,7 @@
|
||||
// 任务管理页面功能
|
||||
function _t(key, opts) {
|
||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||
}
|
||||
|
||||
// HTML转义函数(如果未定义)
|
||||
if (typeof escapeHtml === 'undefined') {
|
||||
@@ -106,7 +109,7 @@ async function loadTasks() {
|
||||
const listContainer = document.getElementById('tasks-list');
|
||||
if (!listContainer) return;
|
||||
|
||||
listContainer.innerHTML = '<div class="loading-spinner">加载中...</div>';
|
||||
listContainer.innerHTML = '<div class="loading-spinner">' + _t('tasks.loadingTasks') + '</div>';
|
||||
|
||||
try {
|
||||
// 并行加载运行中的任务和已完成的任务历史
|
||||
@@ -117,7 +120,7 @@ async function loadTasks() {
|
||||
|
||||
// 处理运行中的任务
|
||||
if (activeResponse.status === 'rejected' || !activeResponse.value || !activeResponse.value.ok) {
|
||||
throw new Error('获取任务列表失败');
|
||||
throw new Error(_t('tasks.loadTaskListFailed'));
|
||||
}
|
||||
|
||||
const activeResult = await activeResponse.value.json();
|
||||
@@ -177,8 +180,8 @@ async function loadTasks() {
|
||||
console.error('加载任务失败:', error);
|
||||
listContainer.innerHTML = `
|
||||
<div class="tasks-empty">
|
||||
<p>加载失败: ${escapeHtml(error.message)}</p>
|
||||
<button class="btn-secondary" onclick="loadTasks()">重试</button>
|
||||
<p>${_t('tasks.loadFailedRetry')}: ${escapeHtml(error.message)}</p>
|
||||
<button class="btn-secondary" onclick="loadTasks()">${_t('tasks.retry')}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -296,21 +299,21 @@ function toggleShowHistory(show) {
|
||||
|
||||
// 计算执行时长
|
||||
function calculateDuration(startedAt) {
|
||||
if (!startedAt) return '未知';
|
||||
if (!startedAt) return _t('tasks.unknown');
|
||||
const start = new Date(startedAt);
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - start) / 1000); // 秒
|
||||
const diff = Math.floor((now - start) / 1000);
|
||||
|
||||
if (diff < 60) {
|
||||
return `${diff}秒`;
|
||||
return diff + _t('tasks.durationSeconds');
|
||||
} else if (diff < 3600) {
|
||||
const minutes = Math.floor(diff / 60);
|
||||
const seconds = diff % 60;
|
||||
return `${minutes}分${seconds}秒`;
|
||||
return minutes + _t('tasks.durationMinutes') + ' ' + seconds + _t('tasks.durationSeconds');
|
||||
} else {
|
||||
const hours = Math.floor(diff / 3600);
|
||||
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) {
|
||||
listContainer.innerHTML = `
|
||||
<div class="tasks-empty">
|
||||
<p>当前没有符合条件的任务</p>
|
||||
<p>${_t('tasks.noMatchingTasks')}</p>
|
||||
${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>
|
||||
`;
|
||||
return;
|
||||
@@ -359,12 +362,12 @@ function renderTasks(tasks) {
|
||||
|
||||
// 状态映射
|
||||
const statusMap = {
|
||||
'running': { text: '执行中', class: 'task-status-running' },
|
||||
'cancelling': { text: '取消中', class: 'task-status-cancelling' },
|
||||
'failed': { text: '执行失败', class: 'task-status-failed' },
|
||||
'timeout': { text: '执行超时', class: 'task-status-timeout' },
|
||||
'cancelled': { text: '已取消', class: 'task-status-cancelled' },
|
||||
'completed': { text: '已完成', class: 'task-status-completed' }
|
||||
'running': { text: _t('tasks.statusRunning'), class: 'task-status-running' },
|
||||
'cancelling': { text: _t('tasks.statusCancelling'), class: 'task-status-cancelling' },
|
||||
'failed': { text: _t('tasks.statusFailed'), class: 'task-status-failed' },
|
||||
'timeout': { text: _t('tasks.statusTimeout'), class: 'task-status-timeout' },
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'task-status-cancelled' },
|
||||
'completed': { text: _t('tasks.statusCompleted'), class: 'task-status-completed' }
|
||||
};
|
||||
|
||||
// 分离当前任务和历史任务
|
||||
@@ -382,8 +385,8 @@ function renderTasks(tasks) {
|
||||
if (historyTasks.length > 0) {
|
||||
html += `<div class="tasks-history-section">
|
||||
<div class="tasks-history-header">
|
||||
<span class="tasks-history-title">📜 最近完成的任务(最近24小时)</span>
|
||||
<button class="btn-secondary btn-small" onclick="clearTasksHistory()">清空历史</button>
|
||||
<span class="tasks-history-title">📜 ` + _t('tasks.recentCompletedTasks') + `</span>
|
||||
<button class="btn-secondary btn-small" onclick="clearTasksHistory()">` + _t('tasks.clearHistory') + `</button>
|
||||
</div>
|
||||
${historyTasks.map(task => renderTaskItem(task, statusMap, true)).join('')}
|
||||
</div>`;
|
||||
@@ -406,7 +409,7 @@ function renderTaskItem(task, statusMap, isHistory = false) {
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
: '未知时间';
|
||||
: _t('tasks.unknownTime');
|
||||
|
||||
const completedText = completedTime && !isNaN(completedTime.getTime())
|
||||
? completedTime.toLocaleString('zh-CN', {
|
||||
@@ -438,22 +441,22 @@ function renderTaskItem(task, statusMap, isHistory = false) {
|
||||
</label>
|
||||
` : '<div class="task-checkbox-placeholder"></div>'}
|
||||
<span class="task-status ${status.class}">${status.text}</span>
|
||||
${isHistory ? '<span class="task-history-badge" title="历史记录">📜</span>' : ''}
|
||||
<span class="task-message" title="${escapeHtml(task.message || '未命名任务')}">${escapeHtml(task.message || '未命名任务')}</span>
|
||||
${isHistory ? '<span class="task-history-badge" title="' + _t('tasks.historyBadge') + '">📜</span>' : ''}
|
||||
<span class="task-message" title="${escapeHtml(task.message || _t('tasks.unnamedTask'))}">${escapeHtml(task.message || _t('tasks.unnamedTask'))}</span>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
${duration ? `<span class="task-duration" title="执行时长">⏱ ${duration}</span>` : ''}
|
||||
<span class="task-time" title="${isHistory && completedText ? '完成时间' : '开始时间'}">
|
||||
${duration ? `<span class="task-duration" title="${_t('tasks.duration')}">⏱ ${duration}</span>` : ''}
|
||||
<span class="task-time" title="${isHistory && completedText ? _t('tasks.completedAt') : _t('tasks.startedAt')}">
|
||||
${isHistory && completedText ? completedText : timeText}
|
||||
</span>
|
||||
${canCancel ? `<button class="btn-secondary btn-small" onclick="cancelTask('${task.conversationId}', this)">取消任务</button>` : ''}
|
||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewConversation('${task.conversationId}')">查看对话</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}')">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${task.conversationId ? `
|
||||
<div class="task-details">
|
||||
<span class="task-id-label">对话ID:</span>
|
||||
<span class="task-id-value" title="点击复制" onclick="copyTaskId('${task.conversationId}')">${escapeHtml(task.conversationId)}</span>
|
||||
<span class="task-id-label">` + _t('tasks.conversationIdLabel') + `:</span>
|
||||
<span class="task-id-value" title="` + _t('tasks.clickToCopy') + `" onclick="copyTaskId('${task.conversationId}')">${escapeHtml(task.conversationId)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -462,7 +465,7 @@ function renderTaskItem(task, statusMap, isHistory = false) {
|
||||
|
||||
// 清空任务历史
|
||||
function clearTasksHistory() {
|
||||
if (!confirm('确定要清空所有任务历史记录吗?')) {
|
||||
if (!confirm(_t('tasks.clearHistoryConfirm'))) {
|
||||
return;
|
||||
}
|
||||
tasksState.completedTasksHistory = [];
|
||||
@@ -490,7 +493,7 @@ function updateBatchActions() {
|
||||
const count = tasksState.selectedTasks.size;
|
||||
if (count > 0) {
|
||||
batchActions.style.display = 'flex';
|
||||
selectedCount.textContent = `已选择 ${count} 项`;
|
||||
selectedCount.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: count }) : `已选择 ${count} 项`;
|
||||
} else {
|
||||
batchActions.style.display = 'none';
|
||||
}
|
||||
@@ -509,7 +512,7 @@ async function batchCancelTasks() {
|
||||
const selected = Array.from(tasksState.selectedTasks);
|
||||
if (selected.length === 0) return;
|
||||
|
||||
if (!confirm(`确定要取消 ${selected.length} 个任务吗?`)) {
|
||||
if (!confirm(_t('tasks.confirmCancelTasks', { n: selected.length }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -545,9 +548,9 @@ async function batchCancelTasks() {
|
||||
|
||||
// 显示结果
|
||||
if (failCount > 0) {
|
||||
alert(`批量取消完成:成功 ${successCount} 个,失败 ${failCount} 个`);
|
||||
alert(_t('tasks.batchCancelResultPartial', { success: successCount, fail: failCount }));
|
||||
} else {
|
||||
alert(`成功取消 ${successCount} 个任务`);
|
||||
alert(_t('tasks.batchCancelResultSuccess', { n: successCount }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,7 +559,7 @@ function copyTaskId(conversationId) {
|
||||
navigator.clipboard.writeText(conversationId).then(() => {
|
||||
// 显示复制成功提示
|
||||
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;';
|
||||
document.body.appendChild(tooltip);
|
||||
setTimeout(() => tooltip.remove(), 1000);
|
||||
@@ -571,7 +574,7 @@ async function cancelTask(conversationId, button) {
|
||||
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = '取消中...';
|
||||
button.textContent = _t('tasks.cancelling');
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/agent-loop/cancel', {
|
||||
@@ -584,7 +587,7 @@ async function cancelTask(conversationId, button) {
|
||||
|
||||
if (!response.ok) {
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('取消任务失败:', error);
|
||||
alert('取消任务失败: ' + error.message);
|
||||
alert(_t('tasks.cancelTaskFailed') + ': ' + error.message);
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
@@ -738,7 +741,7 @@ async function showBatchImportModal() {
|
||||
try {
|
||||
const loadedRoles = await loadRoles();
|
||||
// 清空现有选项(除了默认选项)
|
||||
roleSelect.innerHTML = '<option value="">默认</option>';
|
||||
roleSelect.innerHTML = '<option value="">' + _t('batchImportModal.defaultRole') + '</option>';
|
||||
|
||||
// 添加已启用的角色
|
||||
const sortedRoles = loadedRoles.sort((a, b) => {
|
||||
@@ -782,7 +785,7 @@ function updateBatchImportStats(text) {
|
||||
const count = lines.length;
|
||||
|
||||
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';
|
||||
} else {
|
||||
statsEl.style.display = 'none';
|
||||
@@ -808,14 +811,14 @@ async function createBatchQueue() {
|
||||
|
||||
const text = input.value.trim();
|
||||
if (!text) {
|
||||
alert('请输入至少一个任务');
|
||||
alert(_t('tasks.enterTaskPrompt'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 按行分割任务
|
||||
const tasks = text.split('\n').map(line => line.trim()).filter(line => line !== '');
|
||||
if (tasks.length === 0) {
|
||||
alert('没有有效的任务');
|
||||
alert(_t('tasks.noValidTask'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -836,7 +839,7 @@ async function createBatchQueue() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '创建批量任务队列失败');
|
||||
throw new Error(result.error || _t('tasks.createBatchQueueFailed'));
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -849,7 +852,7 @@ async function createBatchQueue() {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('创建批量任务队列失败:', error);
|
||||
alert('创建批量任务队列失败: ' + error.message);
|
||||
alert(_t('tasks.createBatchQueueFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,7 +919,7 @@ async function loadBatchQueues(page) {
|
||||
try {
|
||||
const response = await apiFetch(`/api/batch-tasks?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取批量任务队列失败');
|
||||
throw new Error(_t('tasks.loadFailedRetry'));
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -929,7 +932,7 @@ async function loadBatchQueues(page) {
|
||||
section.style.display = 'block';
|
||||
const list = document.getElementById('batch-queues-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;
|
||||
|
||||
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';
|
||||
return;
|
||||
}
|
||||
@@ -976,11 +979,11 @@ function renderBatchQueues() {
|
||||
|
||||
list.innerHTML = queues.map(queue => {
|
||||
const statusMap = {
|
||||
'pending': { text: '待执行', class: 'batch-queue-status-pending' },
|
||||
'running': { text: '执行中', class: 'batch-queue-status-running' },
|
||||
'paused': { text: '已暂停', class: 'batch-queue-status-paused' },
|
||||
'completed': { text: '已完成', class: 'batch-queue-status-completed' },
|
||||
'cancelled': { text: '已取消', class: 'batch-queue-status-cancelled' }
|
||||
'pending': { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
|
||||
'running': { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
|
||||
'paused': { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
|
||||
'completed': { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
|
||||
};
|
||||
|
||||
const status = statusMap[queue.status] || { text: queue.status, class: 'batch-queue-status-unknown' };
|
||||
@@ -1012,8 +1015,8 @@ function renderBatchQueues() {
|
||||
// 显示角色信息(使用正确的角色图标)
|
||||
const loadedRoles = batchQueuesState.loadedRoles || [];
|
||||
const roleIcon = getRoleIconForDisplay(queue.role, loadedRoles);
|
||||
const roleName = queue.role && queue.role !== '' ? queue.role : '默认';
|
||||
const roleDisplay = `<span class="batch-queue-role" style="margin-right: 8px;" title="角色: ${escapeHtml(roleName)}">${roleIcon} ${escapeHtml(roleName)}</span>`;
|
||||
const roleName = queue.role && queue.role !== '' ? queue.role : _t('batchQueueDetailModal.defaultRole');
|
||||
const roleDisplay = `<span class="batch-queue-role" style="margin-right: 8px;" title="${_t('batchQueueDetailModal.role')}: ${escapeHtml(roleName)}">${roleIcon} ${escapeHtml(roleName)}</span>`;
|
||||
|
||||
return `
|
||||
<div class="batch-queue-item" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
|
||||
@@ -1022,8 +1025,8 @@ function renderBatchQueues() {
|
||||
${titleDisplay}
|
||||
${roleDisplay}
|
||||
<span class="batch-queue-status ${status.class}">${status.text}</span>
|
||||
<span class="batch-queue-id">队列ID: ${escapeHtml(queue.id)}</span>
|
||||
<span class="batch-queue-time">创建时间: ${new Date(queue.createdAt).toLocaleString('zh-CN')}</span>
|
||||
<span class="batch-queue-id">${_t('tasks.queueIdLabel')}: ${escapeHtml(queue.id)}</span>
|
||||
<span class="batch-queue-time">${_t('tasks.createdTimeLabel')}: ${new Date(queue.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="batch-queue-progress">
|
||||
<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>
|
||||
</div>
|
||||
<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 class="batch-queue-stats">
|
||||
<span>总计: ${stats.total}</span>
|
||||
<span>待执行: ${stats.pending}</span>
|
||||
<span>执行中: ${stats.running}</span>
|
||||
<span style="color: var(--success-color);">已完成: ${stats.completed}</span>
|
||||
<span style="color: var(--error-color);">失败: ${stats.failed}</span>
|
||||
${stats.cancelled > 0 ? `<span style="color: var(--text-secondary);">已取消: ${stats.cancelled}</span>` : ''}
|
||||
<span>${_t('tasks.totalLabel')}: ${stats.total}</span>
|
||||
<span>${_t('tasks.pendingLabel')}: ${stats.pending}</span>
|
||||
<span>${_t('tasks.runningLabel')}: ${stats.running}</span>
|
||||
<span style="color: var(--success-color);">${_t('tasks.completedLabel')}: ${stats.completed}</span>
|
||||
<span style="color: var(--error-color);">${_t('tasks.failedLabel')}: ${stats.failed}</span>
|
||||
${stats.cancelled > 0 ? `<span style="color: var(--text-secondary);">${_t('tasks.cancelledLabel')}: ${stats.cancelled}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1073,9 +1076,9 @@ function renderBatchQueuesPagination() {
|
||||
// 左侧:显示范围信息和每页数量选择器(参考Skills样式)
|
||||
paginationHTML += `
|
||||
<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">
|
||||
每页显示
|
||||
` + _t('tasks.paginationPerPage') + `
|
||||
<select id="batch-queues-page-size-pagination" onchange="changeBatchQueuesPageSize()">
|
||||
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
|
||||
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||||
@@ -1089,11 +1092,11 @@ function renderBatchQueuesPagination() {
|
||||
// 右侧:分页按钮(参考Skills样式:首页、上一页、第X/Y页、下一页、末页)
|
||||
paginationHTML += `
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(1)" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>首页</button>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage - 1})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>上一页</button>
|
||||
<span class="pagination-page">第 ${currentPage} / ${totalPages || 1} 页</span>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage + 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>下一页</button>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(${totalPages || 1})" ${currentPage >= totalPages || 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' : ''}>` + _t('tasks.paginationPrev') + `</button>
|
||||
<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' : ''}>` + _t('tasks.paginationNext') + `</button>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(${totalPages || 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationLast') + `</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1189,7 +1192,7 @@ async function showBatchQueueDetail(queueId) {
|
||||
|
||||
const response = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取队列详情失败');
|
||||
throw new Error(_t('tasks.getQueueDetailFailed'));
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -1198,7 +1201,7 @@ async function showBatchQueueDetail(queueId) {
|
||||
|
||||
if (title) {
|
||||
// textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &...(看起来像“变形/乱码”)
|
||||
title.textContent = queue.title ? `批量任务队列 - ${String(queue.title)}` : '批量任务队列';
|
||||
title.textContent = queue.title ? _t('tasks.batchQueueTitle') + ' - ' + String(queue.title) : _t('tasks.batchQueueTitle');
|
||||
}
|
||||
|
||||
// 更新按钮显示
|
||||
@@ -1210,9 +1213,9 @@ async function showBatchQueueDetail(queueId) {
|
||||
// pending状态显示"开始执行",paused状态显示"继续执行"
|
||||
startBtn.style.display = (queue.status === 'pending' || queue.status === 'paused') ? 'inline-block' : 'none';
|
||||
if (startBtn && queue.status === 'paused') {
|
||||
startBtn.textContent = '继续执行';
|
||||
startBtn.textContent = _t('tasks.resumeExecute');
|
||||
} else if (startBtn && queue.status === 'pending') {
|
||||
startBtn.textContent = '开始执行';
|
||||
startBtn.textContent = _t('batchQueueDetailModal.startExecute');
|
||||
}
|
||||
}
|
||||
if (pauseBtn) {
|
||||
@@ -1226,20 +1229,20 @@ async function showBatchQueueDetail(queueId) {
|
||||
|
||||
// 队列状态映射
|
||||
const queueStatusMap = {
|
||||
'pending': { text: '待执行', class: 'batch-queue-status-pending' },
|
||||
'running': { text: '执行中', class: 'batch-queue-status-running' },
|
||||
'paused': { text: '已暂停', class: 'batch-queue-status-paused' },
|
||||
'completed': { text: '已完成', class: 'batch-queue-status-completed' },
|
||||
'cancelled': { text: '已取消', class: 'batch-queue-status-cancelled' }
|
||||
'pending': { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
|
||||
'running': { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
|
||||
'paused': { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
|
||||
'completed': { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
|
||||
};
|
||||
|
||||
// 任务状态映射
|
||||
const taskStatusMap = {
|
||||
'pending': { text: '待执行', class: 'batch-task-status-pending' },
|
||||
'running': { text: '执行中', class: 'batch-task-status-running' },
|
||||
'completed': { text: '已完成', class: 'batch-task-status-completed' },
|
||||
'failed': { text: '失败', class: 'batch-task-status-failed' },
|
||||
'cancelled': { text: '已取消', class: 'batch-task-status-cancelled' }
|
||||
'pending': { text: _t('tasks.statusPending'), class: 'batch-task-status-pending' },
|
||||
'running': { text: _t('tasks.statusRunning'), class: 'batch-task-status-running' },
|
||||
'completed': { text: _t('tasks.statusCompleted'), class: 'batch-task-status-completed' },
|
||||
'failed': { text: _t('tasks.failedLabel'), class: 'batch-task-status-failed' },
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-task-status-cancelled' }
|
||||
};
|
||||
|
||||
// 获取角色信息(如果队列有角色配置)
|
||||
@@ -1266,51 +1269,51 @@ async function showBatchQueueDetail(queueId) {
|
||||
}
|
||||
}
|
||||
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>
|
||||
</div>`;
|
||||
} else {
|
||||
// 默认角色
|
||||
roleDisplay = `<div class="detail-item">
|
||||
<span class="detail-label">角色</span>
|
||||
<span class="detail-value">🔵 默认</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.role') + `</span>
|
||||
<span class="detail-value">🔵 ` + _t('batchQueueDetailModal.defaultRole') + `</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="batch-queue-detail-info">
|
||||
${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>
|
||||
</div>` : ''}
|
||||
${roleDisplay}
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">创建时间</span>
|
||||
<span class="detail-value">${new Date(queue.createdAt).toLocaleString('zh-CN')}</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.createdAt') + `</span>
|
||||
<span class="detail-value">${new Date(queue.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
${queue.startedAt ? `<div class="detail-item">
|
||||
<span class="detail-label">开始时间</span>
|
||||
<span class="detail-value">${new Date(queue.startedAt).toLocaleString('zh-CN')}</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.startedAt') + `</span>
|
||||
<span class="detail-value">${new Date(queue.startedAt).toLocaleString()}</span>
|
||||
</div>` : ''}
|
||||
${queue.completedAt ? `<div class="detail-item">
|
||||
<span class="detail-label">完成时间</span>
|
||||
<span class="detail-value">${new Date(queue.completedAt).toLocaleString('zh-CN')}</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.completedAt') + `</span>
|
||||
<span class="detail-value">${new Date(queue.completedAt).toLocaleString()}</span>
|
||||
</div>` : ''}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-queue-tasks-list">
|
||||
<h4>任务列表</h4>
|
||||
<h4>` + _t('batchQueueDetailModal.taskList') + `</h4>
|
||||
${queue.tasks.map((task, index) => {
|
||||
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
|
||||
const canEdit = queue.status === 'pending' && task.status === 'pending';
|
||||
@@ -1321,14 +1324,14 @@ async function showBatchQueueDetail(queueId) {
|
||||
<span class="batch-task-index">#${index + 1}</span>
|
||||
<span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</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 btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">删除</button>` : ''}
|
||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); 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();">` + _t('common.delete') + `</button>` : ''}
|
||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||||
</div>
|
||||
${task.startedAt ? `<div class="batch-task-time">开始: ${new Date(task.startedAt).toLocaleString('zh-CN')}</div>` : ''}
|
||||
${task.completedAt ? `<div class="batch-task-time">完成: ${new Date(task.completedAt).toLocaleString('zh-CN')}</div>` : ''}
|
||||
${task.error ? `<div class="batch-task-error">错误: ${escapeHtml(task.error)}</div>` : ''}
|
||||
${task.result ? `<div class="batch-task-result">结果: ${escapeHtml(task.result.substring(0, 200))}${task.result.length > 200 ? '...' : ''}</div>` : ''}
|
||||
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
|
||||
${task.completedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.completeLabel') + `: ${new Date(task.completedAt).toLocaleString()}</div>` : ''}
|
||||
${task.error ? `<div class="batch-task-error">` + _t('batchQueueDetailModal.errorLabel') + `: ${escapeHtml(task.error)}</div>` : ''}
|
||||
${task.result ? `<div class="batch-task-result">` + _t('batchQueueDetailModal.resultLabel') + `: ${escapeHtml(task.result.substring(0, 200))}${task.result.length > 200 ? '...' : ''}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
@@ -1343,7 +1346,7 @@ async function showBatchQueueDetail(queueId) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取队列详情失败:', error);
|
||||
alert('获取队列详情失败: ' + error.message);
|
||||
alert(_t('tasks.getQueueDetailFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1359,7 +1362,7 @@ async function startBatchQueue() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '启动批量任务失败');
|
||||
throw new Error(result.error || _t('tasks.startBatchQueueFailed'));
|
||||
}
|
||||
|
||||
// 刷新详情
|
||||
@@ -1367,7 +1370,7 @@ async function startBatchQueue() {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('启动批量任务失败:', error);
|
||||
alert('启动批量任务失败: ' + error.message);
|
||||
alert(_t('tasks.startBatchQueueFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1376,7 +1379,7 @@ async function pauseBatchQueue() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) return;
|
||||
|
||||
if (!confirm('确定要暂停这个批量任务队列吗?当前正在执行的任务将被停止,后续任务将保留待执行状态。')) {
|
||||
if (!confirm(_t('tasks.pauseQueueConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1387,7 +1390,7 @@ async function pauseBatchQueue() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '暂停批量任务失败');
|
||||
throw new Error(result.error || _t('tasks.pauseQueueFailed'));
|
||||
}
|
||||
|
||||
// 刷新详情
|
||||
@@ -1395,7 +1398,7 @@ async function pauseBatchQueue() {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('暂停批量任务失败:', error);
|
||||
alert('暂停批量任务失败: ' + error.message);
|
||||
alert(_t('tasks.pauseQueueFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1404,7 +1407,7 @@ async function deleteBatchQueue() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) return;
|
||||
|
||||
if (!confirm('确定要删除这个批量任务队列吗?此操作不可恢复。')) {
|
||||
if (!confirm(_t('tasks.deleteQueueConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1415,14 +1418,14 @@ async function deleteBatchQueue() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '删除批量任务队列失败');
|
||||
throw new Error(result.error || _t('tasks.deleteQueueFailed'));
|
||||
}
|
||||
|
||||
closeBatchQueueDetailModal();
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('删除批量任务队列失败:', error);
|
||||
alert('删除批量任务队列失败: ' + error.message);
|
||||
alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1430,7 +1433,7 @@ async function deleteBatchQueue() {
|
||||
async function deleteBatchQueueFromList(queueId) {
|
||||
if (!queueId) return;
|
||||
|
||||
if (!confirm('确定要删除这个批量任务队列吗?此操作不可恢复。')) {
|
||||
if (!confirm(_t('tasks.deleteQueueConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1441,7 +1444,7 @@ async function deleteBatchQueueFromList(queueId) {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '删除批量任务队列失败');
|
||||
throw new Error(result.error || _t('tasks.deleteQueueFailed'));
|
||||
}
|
||||
|
||||
// 如果当前正在查看这个队列的详情,关闭详情模态框
|
||||
@@ -1453,7 +1456,7 @@ async function deleteBatchQueueFromList(queueId) {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('删除批量任务队列失败:', error);
|
||||
alert('删除批量任务队列失败: ' + error.message);
|
||||
alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1599,18 +1602,18 @@ async function saveBatchTask() {
|
||||
const messageInput = document.getElementById('edit-task-message');
|
||||
|
||||
if (!queueId || !taskId) {
|
||||
alert('任务信息不完整');
|
||||
alert(_t('tasks.taskIncomplete'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!messageInput) {
|
||||
alert('无法获取任务消息输入框');
|
||||
alert(_t('tasks.cannotGetTaskMessageInput'));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = messageInput.value.trim();
|
||||
if (!message) {
|
||||
alert('任务消息不能为空');
|
||||
alert(_t('tasks.taskMessageRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1625,7 +1628,7 @@ async function saveBatchTask() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '更新任务失败');
|
||||
throw new Error(result.error || _t('tasks.updateTaskFailed'));
|
||||
}
|
||||
|
||||
// 关闭编辑模态框
|
||||
@@ -1640,7 +1643,7 @@ async function saveBatchTask() {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('保存任务失败:', error);
|
||||
alert('保存任务失败: ' + error.message);
|
||||
alert(_t('tasks.saveTaskFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1648,7 +1651,7 @@ async function saveBatchTask() {
|
||||
function showAddBatchTaskModal() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) {
|
||||
alert('队列信息不存在');
|
||||
alert(_t('tasks.queueInfoMissing'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1706,18 +1709,18 @@ async function saveAddBatchTask() {
|
||||
const messageInput = document.getElementById('add-task-message');
|
||||
|
||||
if (!queueId) {
|
||||
alert('队列信息不存在');
|
||||
alert(_t('tasks.queueInfoMissing'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!messageInput) {
|
||||
alert('无法获取任务消息输入框');
|
||||
alert(_t('tasks.cannotGetTaskMessageInput'));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = messageInput.value.trim();
|
||||
if (!message) {
|
||||
alert('任务消息不能为空');
|
||||
alert(_t('tasks.taskMessageRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1732,7 +1735,7 @@ async function saveAddBatchTask() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '添加任务失败');
|
||||
throw new Error(result.error || _t('tasks.addTaskFailed'));
|
||||
}
|
||||
|
||||
// 关闭添加任务模态框
|
||||
@@ -1747,7 +1750,7 @@ async function saveAddBatchTask() {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('添加任务失败:', error);
|
||||
alert('添加任务失败: ' + error.message);
|
||||
alert(_t('tasks.addTaskFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1779,7 +1782,7 @@ function deleteBatchTaskFromElement(button) {
|
||||
? decodedMessage.substring(0, 50) + '...'
|
||||
: decodedMessage;
|
||||
|
||||
if (!confirm(`确定要删除这个任务吗?\n\n任务内容: ${displayMessage}\n\n此操作不可恢复。`)) {
|
||||
if (!confirm(_t('tasks.confirmDeleteTask', { message: displayMessage }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1789,7 +1792,7 @@ function deleteBatchTaskFromElement(button) {
|
||||
// 删除批量任务
|
||||
async function deleteBatchTask(queueId, taskId) {
|
||||
if (!queueId || !taskId) {
|
||||
alert('任务信息不完整');
|
||||
alert(_t('tasks.taskIncomplete'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1800,7 +1803,7 @@ async function deleteBatchTask(queueId, taskId) {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '删除任务失败');
|
||||
throw new Error(result.error || _t('tasks.deleteTaskFailed'));
|
||||
}
|
||||
|
||||
// 刷新队列详情
|
||||
@@ -1812,7 +1815,7 @@ async function deleteBatchTask(queueId, taskId) {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('删除任务失败:', error);
|
||||
alert('删除任务失败: ' + error.message);
|
||||
alert(_t('tasks.deleteTaskFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+76
-11
@@ -26,7 +26,33 @@
|
||||
return terminals[0] || null;
|
||||
}
|
||||
|
||||
var WELCOME_LINE = 'CyberStrikeAI 终端 - 真实 Shell 会话,直接输入命令;Ctrl+L 清屏\r\n';
|
||||
function tr(key, opts) {
|
||||
if (typeof window !== 'undefined' && typeof window.t === 'function') {
|
||||
return window.t(key, opts);
|
||||
}
|
||||
// i18n 未就绪时的后备(与 zh-CN 一致)
|
||||
var fallbacks = {
|
||||
'settingsTerminal.welcomeLine': 'CyberStrikeAI 终端 - 真实 Shell 会话,直接输入命令;Ctrl+L 清屏',
|
||||
'settingsTerminal.sessionClosed': '[会话已关闭]',
|
||||
'settingsTerminal.connectionError': '[终端连接出错]',
|
||||
'settingsTerminal.connectFailed': '[无法连接终端服务: {{msg}}]',
|
||||
'settingsTerminal.closeTabTitle': '关闭',
|
||||
'settingsTerminal.containerClickTitle': '点击此处后输入命令',
|
||||
'settingsTerminal.xtermNotLoaded': '未加载 xterm.js,请刷新页面或检查网络。',
|
||||
'settingsTerminal.terminalTab': '终端 {{n}}'
|
||||
};
|
||||
var s = fallbacks[key] || key;
|
||||
if (opts && typeof opts === 'object') {
|
||||
Object.keys(opts).forEach(function (k) {
|
||||
s = s.split('{{' + k + '}}').join(String(opts[k]));
|
||||
});
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function getWelcomeLine() {
|
||||
return tr('settingsTerminal.welcomeLine') + '\r\n';
|
||||
}
|
||||
|
||||
function writePrompt(tab) {
|
||||
// 提示符交由后端 Shell 自行输出,这里仅保留占位函数,避免旧代码报错
|
||||
@@ -35,7 +61,7 @@
|
||||
function redrawTabDisplay(t) {
|
||||
if (!t || !t.term) return;
|
||||
t.term.clear();
|
||||
t.term.write(WELCOME_LINE);
|
||||
t.term.write(getWelcomeLine());
|
||||
}
|
||||
|
||||
function writeln(tabOrS, s) {
|
||||
@@ -121,19 +147,19 @@
|
||||
ws.onclose = function () {
|
||||
tab.running = false;
|
||||
if (tab.term) {
|
||||
tab.term.writeln('\r\n\x1b[2m[会话已关闭]\x1b[0m');
|
||||
tab.term.writeln('\r\n\x1b[2m' + tr('settingsTerminal.sessionClosed') + '\x1b[0m');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
tab.running = false;
|
||||
if (tab.term) {
|
||||
tab.term.writeln('\r\n\x1b[31m[终端连接出错]\x1b[0m');
|
||||
tab.term.writeln('\r\n\x1b[31m' + tr('settingsTerminal.connectionError') + '\x1b[0m');
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
if (tab.term) {
|
||||
tab.term.writeln('\r\n\x1b[31m[无法连接终端服务: ' + String(e) + ']\x1b[0m');
|
||||
tab.term.writeln('\r\n\x1b[31m' + tr('settingsTerminal.connectFailed', { msg: String(e) }) + '\x1b[0m');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,13 +208,13 @@
|
||||
term.loadAddon(fitAddon);
|
||||
}
|
||||
term.open(container);
|
||||
term.write(WELCOME_LINE);
|
||||
term.write(getWelcomeLine());
|
||||
container.addEventListener('click', function () {
|
||||
switchTerminalTab(tab.id);
|
||||
if (term) term.focus();
|
||||
});
|
||||
container.setAttribute('tabindex', '0');
|
||||
container.title = '点击此处后输入命令';
|
||||
container.title = tr('settingsTerminal.containerClickTitle');
|
||||
|
||||
function sendToWS(data) {
|
||||
ensureTerminalWS(tab);
|
||||
@@ -211,6 +237,9 @@
|
||||
|
||||
tab.term = term;
|
||||
tab.fitAddon = fitAddon;
|
||||
// 立即建立 WebSocket,让后端 PTY/Shell 马上启动并输出提示符;
|
||||
// 若等到首次按键才 connect,用户会感觉必须先按回车才能输入(实为连接尚未建立)。
|
||||
ensureTerminalWS(tab);
|
||||
return term;
|
||||
}
|
||||
|
||||
@@ -253,12 +282,12 @@
|
||||
tabDiv.setAttribute('data-tab-id', String(id));
|
||||
var label = document.createElement('span');
|
||||
label.className = 'terminal-tab-label';
|
||||
label.textContent = '终端 ' + id;
|
||||
label.textContent = tr('settingsTerminal.terminalTab', { n: id });
|
||||
label.onclick = function () { switchTerminalTab(id); };
|
||||
var closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'terminal-tab-close';
|
||||
closeBtn.title = '关闭';
|
||||
closeBtn.title = tr('settingsTerminal.closeTabTitle');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.onclick = function (e) { e.stopPropagation(); removeTerminalTab(id); };
|
||||
tabDiv.appendChild(label);
|
||||
@@ -340,7 +369,7 @@
|
||||
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.textContent = tr('settingsTerminal.terminalTab', { n: 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);
|
||||
@@ -364,6 +393,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function refreshTerminalI18n() {
|
||||
// 语言切换后更新标签与容器 title;已打开的终端内容不强制清屏,以免丢失会话输出
|
||||
try {
|
||||
var tabsEl = document.querySelector('.terminal-tabs');
|
||||
if (tabsEl) {
|
||||
var tabDivs = tabsEl.querySelectorAll('.terminal-tab');
|
||||
for (var i = 0; i < tabDivs.length && i < terminals.length; i++) {
|
||||
var tid = terminals[i].id;
|
||||
var lbl = tabDivs[i].querySelector('.terminal-tab-label');
|
||||
if (lbl) lbl.textContent = tr('settingsTerminal.terminalTab', { n: tid });
|
||||
var cb = tabDivs[i].querySelector('.terminal-tab-close');
|
||||
if (cb) cb.title = tr('settingsTerminal.closeTabTitle');
|
||||
}
|
||||
}
|
||||
terminals.forEach(function (tab) {
|
||||
if (!tab || !tab.term) return;
|
||||
var cont = document.getElementById(tab.containerId);
|
||||
if (cont) cont.title = tr('settingsTerminal.containerClickTitle');
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
refreshTerminalI18n();
|
||||
});
|
||||
|
||||
function initTerminal() {
|
||||
var pane1 = document.getElementById('terminal-pane-1');
|
||||
var container1 = document.getElementById('terminal-container-1');
|
||||
@@ -377,7 +440,7 @@
|
||||
inited = true;
|
||||
|
||||
if (typeof Terminal === 'undefined') {
|
||||
container1.innerHTML = '<p class="terminal-error">未加载 xterm.js,请刷新页面或检查网络。</p>';
|
||||
container1.innerHTML = '<p class="terminal-error">' + escapeHtml(tr('settingsTerminal.xtermNotLoaded')) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -388,6 +451,8 @@
|
||||
|
||||
updateTerminalTabCloseVisibility();
|
||||
|
||||
refreshTerminalI18n();
|
||||
|
||||
setTimeout(function () {
|
||||
try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {}
|
||||
}, 100);
|
||||
|
||||
@@ -156,14 +156,20 @@ async function loadVulnerabilities(page = null) {
|
||||
function renderVulnerabilities(vulnerabilities) {
|
||||
const listContainer = document.getElementById('vulnerabilities-list');
|
||||
|
||||
// 处理空值情况
|
||||
// 处理空值情况(使用 data-i18n 以便语言切换时自动更新)
|
||||
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;
|
||||
}
|
||||
|
||||
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');
|
||||
if (paginationContainer) {
|
||||
@@ -328,7 +334,7 @@ async function changeVulnerabilityPageSize() {
|
||||
// 显示添加漏洞模态框
|
||||
function showAddVulnerabilityModal() {
|
||||
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 = '';
|
||||
@@ -353,7 +359,7 @@ async function editVulnerability(id) {
|
||||
|
||||
const vuln = await response.json();
|
||||
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 || '';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+30
-15
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
@@ -22,6 +22,7 @@
|
||||
}
|
||||
|
||||
.api-docs-header {
|
||||
position: relative;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
@@ -833,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="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
API 文档
|
||||
<span data-i18n="apiDocs.title">API 文档</span>
|
||||
</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 id="auth-info-section" class="auth-info-section" style="display: none;">
|
||||
@@ -846,17 +859,17 @@
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</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>
|
||||
<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"/>
|
||||
</svg>
|
||||
</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;">
|
||||
<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;">
|
||||
<p style="margin: 0 0 8px 0; font-weight: 500;">1. 获取 Token:</p>
|
||||
<p style="margin: 0 0 8px 0;">在前端页面登录后,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;" 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
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -871,13 +884,13 @@ Content-Type: application/json
|
||||
}</code></pre>
|
||||
</div>
|
||||
<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;">在请求头中添加 Authorization 字段:</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;" 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>
|
||||
<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 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 接口
|
||||
</p>
|
||||
</div>
|
||||
@@ -899,10 +912,10 @@ Content-Type: application/json
|
||||
|
||||
<div class="api-docs-content">
|
||||
<div class="api-docs-sidebar">
|
||||
<h3>API 分组</h3>
|
||||
<h3 data-i18n="apiDocs.sidebarGroupTitle">API 分组</h3>
|
||||
<ul class="api-group-list" id="api-group-list">
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -914,13 +927,15 @@ Content-Type: application/json
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<h3>加载中...</h3>
|
||||
<p>正在加载 API 文档</p>
|
||||
<h3 data-i18n="apiDocs.loading">加载中...</h3>
|
||||
<p data-i18n="apiDocs.loadingDesc">正在加载 API 文档</p>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+693
-552
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user