mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-16 13:19:17 +02:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f44bb42842 | |||
| d2e751e3d3 | |||
| a5c285c8f3 | |||
| 98938aef00 | |||
| 71f6a97a90 | |||
| 2fce15f82a | |||
| 52b70d8b16 | |||
| 5b3709b9ad | |||
| 639f65602d | |||
| 52b6c3fe1b | |||
| f26ee8e6e7 | |||
| 379486d36c | |||
| 317461e259 | |||
| b7e724407b | |||
| e904dd3481 | |||
| 7b1487383f | |||
| 8a2177ffab | |||
| 3a7bbfbb88 | |||
| 7c01641de9 | |||
| 1c1086eea4 | |||
| 8f4f40f894 | |||
| 7f16ba706a | |||
| 0b950f95db | |||
| d36984a1c1 | |||
| da2109a970 | |||
| 1866aa8089 | |||
| 5af06e539d | |||
| 7493e70686 | |||
| 81f7a601b7 | |||
| 27830d1399 | |||
| d9a0178f80 | |||
| 1dd8cc7f50 | |||
| 55045dd4e0 | |||
| 90508c9084 | |||
| 361480f2d1 | |||
| 538565117b | |||
| 1c8742b7b6 | |||
| 2fb6a1d1ef | |||
| 6e390acb3d | |||
| d6236e285d | |||
| ad8efffbb4 | |||
| 352d9b712c | |||
| acadbe19c6 | |||
| c265e66afb | |||
| 647bb4b5e4 | |||
| dd311f7a3b |
@@ -262,21 +262,33 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
```
|
||||
Replace the paths with your local locations; Cursor will launch the stdio server automatically.
|
||||
|
||||
#### MCP HTTP quick start
|
||||
1. Ensure `config.yaml` has `mcp.enabled: true` and adjust `mcp.host` / `mcp.port` if you need a non-default binding (localhost:8081 works well for local Cursor usage).
|
||||
2. Start the main service (`./run.sh` or `go run cmd/server/main.go`); the MCP endpoint lives at `http://<host>:<port>/mcp`.
|
||||
3. In Cursor, choose **Add Custom MCP → HTTP** and set `Base URL` to `http://127.0.0.1:8081/mcp`.
|
||||
4. Prefer committing the setup via `.cursor/mcp.json` so teammates can reuse it:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cyberstrike-ai-http": {
|
||||
"transport": "http",
|
||||
"url": "http://127.0.0.1:8081/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
#### MCP HTTP quick start (Cursor / Claude Code)
|
||||
The HTTP MCP server runs on a separate port (default `8081`) and supports **header-based authentication** so only clients that send the correct header can call tools.
|
||||
|
||||
1. **Enable MCP in config** – In `config.yaml` set `mcp.enabled: true` and optionally `mcp.host` / `mcp.port`. For auth (recommended if the port is reachable from the network), set:
|
||||
- `mcp.auth_header` – header name (e.g. `X-MCP-Token`);
|
||||
- `mcp.auth_header_value` – secret value. **Leave it empty** if you want the server to **auto-generate** a random token on first start and write it back to the config.
|
||||
2. **Start the service** – Run `./run.sh` or `go run cmd/server/main.go`. The MCP endpoint is `http://<host>:<port>/mcp` (e.g. `http://localhost:8081/mcp`).
|
||||
3. **Copy the JSON from the terminal** – When MCP is enabled, the server prints a **ready-to-paste** JSON block. If `auth_header_value` was empty, it will have been generated and saved; the printed JSON includes the URL and headers.
|
||||
4. **Use in Cursor or Claude Code**:
|
||||
- **Cursor**: Paste the block into `~/.cursor/mcp.json` (or your project’s `.cursor/mcp.json`) under `mcpServers`, or merge it into your existing `mcpServers`.
|
||||
- **Claude Code**: Paste into `.mcp.json` or `~/.claude.json` under `mcpServers`.
|
||||
|
||||
Example of what the terminal prints (with auth enabled):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cyberstrike-ai": {
|
||||
"url": "http://localhost:8081/mcp",
|
||||
"headers": {
|
||||
"X-MCP-Token": "<auto-generated-or-your-value>"
|
||||
},
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
If you do not set `auth_header` / `auth_header_value`, the endpoint accepts requests without authentication (suitable only for localhost or trusted networks).
|
||||
|
||||
#### External MCP federation (HTTP/stdio/SSE)
|
||||
CyberStrikeAI supports connecting to external MCP servers via three transport modes:
|
||||
@@ -396,6 +408,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"
|
||||
@@ -501,20 +515,6 @@ Compress the 5 MB nuclei report, summarize critical CVEs, and attach the artifac
|
||||
Build an attack chain for the latest engagement and export the node list with severity >= high.
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
### Recent Highlights
|
||||
|
||||
- **2026-01-27** – OpenAPI documentation with interactive testing interface, supporting conversation management, message interaction, and result querying
|
||||
- **2026-01-15** – Skills system with 20+ predefined security testing skills
|
||||
- **2026-01-11** – Role-based testing with predefined security testing roles
|
||||
- **2026-01-08** – SSE transport mode support for external MCP servers
|
||||
- **2026-01-01** – Batch task management with queue-based execution
|
||||
- **2025-12-25** – Vulnerability management and conversation grouping features
|
||||
- **2025-12-20** – Knowledge base with vector search and hybrid retrieval
|
||||
|
||||
|
||||
|
||||
## 404Starlink
|
||||
|
||||
<img src="./images/404StarLinkLogo.png" width="30%">
|
||||
@@ -532,6 +532,22 @@ CyberStrikeAI has joined [404Starlink](https://github.com/knownsec/404StarLink)
|
||||

|
||||
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
**This tool is for educational and authorized testing purposes only!**
|
||||
|
||||
CyberStrikeAI is a professional security testing platform designed to assist security researchers, penetration testers, and IT professionals in conducting security assessments and vulnerability research **with explicit authorization**.
|
||||
|
||||
**By using this tool, you agree to:**
|
||||
- Use this tool only on systems where you have clear written authorization
|
||||
- Comply with all applicable laws, regulations, and ethical standards
|
||||
- Take full responsibility for any unauthorized use or misuse
|
||||
- Not use this tool for any illegal or malicious purposes
|
||||
|
||||
**The developers are not responsible for any misuse!** Please ensure your usage complies with local laws and regulations, and that you have obtained explicit authorization from the target system owner.
|
||||
|
||||
---
|
||||
|
||||
Need help or want to contribute? Open an issue or PR—community tooling additions are welcome!
|
||||
|
||||
+45
-28
@@ -260,21 +260,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 服务器:
|
||||
@@ -395,6 +407,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"
|
||||
@@ -500,19 +514,6 @@ CyberStrikeAI/
|
||||
构建最新一次测试的攻击链,只导出风险 >= 高的节点列表。
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
### 近期亮点
|
||||
|
||||
- **2026-01-27** – 新增 OpenAPI 文档,提供交互式测试界面,支持对话管理、消息交互和结果查询
|
||||
- **2026-01-15** – 新增 Skills 技能系统,内置 20+ 预设安全测试技能
|
||||
- **2026-01-11** – 新增角色化测试功能,支持预设安全测试角色
|
||||
- **2026-01-08** – 新增 SSE 传输模式支持,外部 MCP 联邦支持三种模式
|
||||
- **2026-01-01** – 新增批量任务管理功能,支持队列式任务执行
|
||||
- **2025-12-25** – 新增漏洞管理和对话分组功能
|
||||
- **2025-12-20** – 新增知识库功能,支持向量检索和混合搜索
|
||||
|
||||
|
||||
## 404星链计划
|
||||
<img src="./images/404StarLinkLogo.png" width="30%">
|
||||
|
||||
@@ -530,4 +531,20 @@ CyberStrikeAI 现已加入 [404星链计划](https://github.com/knownsec/404Star
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
**本工具仅供教育和授权测试使用!**
|
||||
|
||||
CyberStrikeAI 是一个专业的安全测试平台,旨在帮助安全研究人员、渗透测试人员和IT专业人员在**获得明确授权**的情况下进行安全评估和漏洞研究。
|
||||
|
||||
**使用本工具即表示您同意:**
|
||||
- 仅在您拥有明确书面授权的系统上使用此工具
|
||||
- 遵守所有适用的法律法规和道德准则
|
||||
- 对任何未经授权的使用或滥用行为承担全部责任
|
||||
- 不会将本工具用于任何非法或恶意目的
|
||||
|
||||
**开发者不对任何滥用行为负责!** 请确保您的使用符合当地法律法规,并获得目标系统所有者的明确授权。
|
||||
|
||||
---
|
||||
|
||||
欢迎提交 Issue/PR 贡献新的工具模版或优化建议!
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+21
-3
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.3.13"
|
||||
version: "v1.3.22"
|
||||
|
||||
# 服务器配置
|
||||
server:
|
||||
@@ -93,8 +93,10 @@ security:
|
||||
# MCP (Model Context Protocol) 用于工具注册和调用
|
||||
mcp:
|
||||
enabled: false # 是否启用 MCP 服务器(http模式)
|
||||
host: 0.0.0.0 # MCP 服务器监听地址
|
||||
port: 8081 # MCP 服务器端口
|
||||
host: 0.0.0.0 # MCP 服务器监听地址
|
||||
port: 8081 # MCP 服务器端口
|
||||
auth_header: "X-MCP-Token" # 鉴权:请求需携带该 header 且值与 auth_header_value 一致方可调用。留空表示不鉴权
|
||||
auth_header_value: "" # 鉴权密钥值(与 auth_header 配合使用,建议使用随机字符串)
|
||||
|
||||
# 外部 MCP 配置
|
||||
external_mcp:
|
||||
@@ -116,6 +118,22 @@ knowledge:
|
||||
top_k: 5 # 检索返回的Top-K结果数量
|
||||
similarity_threshold: 0.7 # 相似度阈值(0-1),低于此值的结果将被过滤
|
||||
hybrid_weight: 0.7 # 混合检索权重(0-1),向量检索的权重,1.0表示纯向量检索,0.0表示纯关键词检索
|
||||
# ============================================
|
||||
# 索引配置(用于解决 API 限制问题)
|
||||
# ============================================
|
||||
indexing:
|
||||
# 分块配置
|
||||
chunk_size: 512 # 每个块的最大 token 数(默认 512),长文本会被分割成多个块
|
||||
chunk_overlap: 50 # 块之间的重叠 token 数(默认 50),保持上下文连贯性
|
||||
max_chunks_per_item: 0 # 单个知识项的最大块数量(0 表示不限制),防止单个文件消耗过多 API 配额
|
||||
# 速率限制配置(解决 429 错误)
|
||||
max_rpm: 0 # 每分钟最大请求数(默认 0 表示不限制),如 OpenAI 默认 200 RPM
|
||||
rate_limit_delay_ms: 300 # 请求间隔毫秒数(默认 300),用于避免 API 速率限制,设为 0 不限制
|
||||
# 建议值:200 次/分钟≈300ms, 100 次/分钟≈600ms
|
||||
|
||||
# 重试配置
|
||||
max_retries: 3 # 最大重试次数(默认 3),遇到速率限制或服务器错误时自动重试
|
||||
retry_delay_ms: 1000 # 重试间隔毫秒数(默认 1000),每次重试会递增延迟
|
||||
|
||||
# ============================================
|
||||
# 机器人配置(企业微信、钉钉、飞书)
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
## CyberStrikeAI 前端国际化方案
|
||||
|
||||
本文档说明 CyberStrikeAI Web 前端(`web/templates/index.html` + `web/static/js/*.js`)的国际化设计与开发规范,确保在不引入打包工具和不改动后端路由的前提下,实现可扩展、低返工的多语言支持。
|
||||
|
||||
当前目标:
|
||||
|
||||
- **支持中英文切换(zh-CN / en-US)**
|
||||
- 后续可方便扩展更多语言(如 ja-JP、ko-KR 等)
|
||||
|
||||
---
|
||||
|
||||
## 一、总体设计原则
|
||||
|
||||
- **前端主导的客户端国际化**:所有 UI 文案在浏览器端根据当前语言动态渲染,后端 Go 仅负责结构和数据,不参与语言分发。
|
||||
- **单一 HTML 模板**:继续使用一份 `index.html` 模板,不为不同语言复制模板文件。
|
||||
- **文案与逻辑分离**:所有可见文本通过「键值表」管理(多语言 JSON),HTML / JS 只写 key,不直接写中文/英文常量。
|
||||
- **渐进式改造**:先覆盖 header / 登录 / 侧边栏 / 系统设置等关键区域,其他页面按模块逐步迁移,避免一次性大改动。
|
||||
- **可回退默认语言**:即使目标语言未完全翻译,也能回退到默认中文,不出现原始 key。
|
||||
|
||||
---
|
||||
|
||||
## 二、技术选型与目录结构
|
||||
|
||||
### 2.1 技术选型
|
||||
|
||||
- **i18n 引擎**:使用 [i18next](https://www.i18next.com/) 的浏览器 UMD 版本(通过 CDN 引入),无需打包器。
|
||||
- **资源格式**:每种语言一份 JSON 文件,采用「域 + 语义」的层级 key 方案,例如:
|
||||
- `common.ok`
|
||||
- `nav.dashboard`
|
||||
- `header.apiDocs`
|
||||
- `settings.robot.wecom.token`
|
||||
|
||||
### 2.2 目录结构
|
||||
|
||||
- `web/templates/index.html`
|
||||
- 页面骨架 + 所有静态文案位置,将逐步改为 `data-i18n` 标记。
|
||||
- `web/static/js/i18n.js`
|
||||
- 前端 i18n 初始化与 DOM 应用逻辑(本方案新增)。
|
||||
- `web/static/i18n/`(新增目录)
|
||||
- `zh-CN.json`:中文文案(默认语言)
|
||||
- `en-US.json`:英文文案
|
||||
- 未来可新增:`ja-JP.json`、`ko-KR.json` 等。
|
||||
|
||||
---
|
||||
|
||||
## 三、文案组织规范
|
||||
|
||||
### 3.1 Key 命名约定
|
||||
|
||||
- 采用「**模块.语义**」形式,最多 2–3 级,确保可读性:
|
||||
- 导航:`nav.dashboard`、`nav.chat`、`nav.settings`
|
||||
- 头部:`header.title`、`header.apiDocs`、`header.logout`
|
||||
- 登录:`login.title`、`login.subtitle`、`login.passwordLabel`、`login.submit`
|
||||
- 仪表盘:`dashboard.title`、`dashboard.refresh`、`dashboard.runningTasks`
|
||||
- 系统设置:`settings.title`、`settings.nav.basic`、`settings.nav.robot`、`settings.apply`
|
||||
- 机器人配置:`settings.robot.wecom.enabled`、`settings.robot.wecom.token` 等。
|
||||
- 尽量按「界面区域」而不是「文件名」划分域,便于非开发人员理解。
|
||||
|
||||
### 3.2 JSON 示例
|
||||
|
||||
`web/static/i18n/zh-CN.json` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"ok": "确定",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
"chat": "对话",
|
||||
"infoCollect": "信息收集",
|
||||
"tasks": "任务管理",
|
||||
"vulnerabilities": "漏洞管理",
|
||||
"settings": "系统设置"
|
||||
},
|
||||
"header": {
|
||||
"title": "CyberStrikeAI",
|
||||
"apiDocs": "API 文档",
|
||||
"logout": "退出登录",
|
||||
"language": "界面语言"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录 CyberStrikeAI",
|
||||
"subtitle": "请输入配置中的访问密码",
|
||||
"passwordLabel": "密码",
|
||||
"passwordPlaceholder": "输入登录密码",
|
||||
"submit": "登录"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
英文文件 `en-US.json` 保持相同 key,不同 value:
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"chat": "Chat",
|
||||
"infoCollect": "Recon",
|
||||
"tasks": "Tasks",
|
||||
"vulnerabilities": "Vulnerabilities",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"header": {
|
||||
"title": "CyberStrikeAI",
|
||||
"apiDocs": "API Docs",
|
||||
"logout": "Sign out",
|
||||
"language": "Interface language"
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in to CyberStrikeAI",
|
||||
"subtitle": "Enter the access password from config",
|
||||
"passwordLabel": "Password",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
"submit": "Sign in"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 约定:**新增界面时,必须先定义 i18n key,再在 HTML/JS 中使用 key**,禁止直接写死中文/英文。
|
||||
|
||||
---
|
||||
|
||||
## 四、HTML 标记规范(data-i18n)
|
||||
|
||||
### 4.1 基本规则
|
||||
|
||||
- 使用 `data-i18n` 将元素文本与某个 key 绑定:
|
||||
|
||||
```html
|
||||
<span data-i18n="nav.dashboard">仪表盘</span>
|
||||
```
|
||||
|
||||
- 默认行为:脚本会替换元素的 `textContent`。
|
||||
- 同时翻译属性时,额外使用 `data-i18n-attr`,逗号分隔多个属性名:
|
||||
|
||||
```html
|
||||
<button
|
||||
class="openapi-doc-btn"
|
||||
onclick="window.open('/api-docs', '_blank')"
|
||||
data-i18n="header.apiDocs"
|
||||
data-i18n-attr="title"
|
||||
title="API 文档">
|
||||
<span data-i18n="header.apiDocs">API 文档</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### 4.2 默认文本的作用
|
||||
|
||||
- HTML 内的中文默认值作为「**无 JS / 初始化前**」的占位内容:
|
||||
- 页面在 JS 尚未加载完成时不会出现空白或 key。
|
||||
- JS 初始化后会用当前语言覆盖这些文本。
|
||||
|
||||
---
|
||||
|
||||
## 五、JavaScript 中的文案规范
|
||||
|
||||
### 5.1 全局翻译函数 `t()`
|
||||
|
||||
由 `i18n.js` 暴露以下全局函数:
|
||||
|
||||
- `window.t(key: string): string`
|
||||
- 返回当前语言下的翻译文本,若缺失则回退到默认语言,再不行则返回 key 本身。
|
||||
- `window.changeLanguage(lang: string): Promise<void>`
|
||||
- 切换语言并刷新页面文案(不会刷新整页)。
|
||||
|
||||
示例(以 `web/static/js/settings.js` 为例):
|
||||
|
||||
```js
|
||||
// 之前
|
||||
alert('加载配置失败: ' + error.message);
|
||||
|
||||
// 之后
|
||||
alert(t('settings.loadConfigFailed') + ': ' + error.message);
|
||||
```
|
||||
|
||||
> 规范:**JS 内所有面向用户的提示、按钮文字、对话框标题都应通过 `t()` 获取**,不直接写死中文/英文。
|
||||
|
||||
### 5.2 渐进迁移建议
|
||||
|
||||
- 优先改造:
|
||||
- 频繁弹出的错误提示 / 成功提示;
|
||||
- 登录相关、系统设置相关文案。
|
||||
- 低优先级:
|
||||
- 仅面向运维人员的调试提示,可以暂时保留英文/中文常量。
|
||||
|
||||
---
|
||||
|
||||
## 六、i18n 初始化与语言切换实现
|
||||
|
||||
### 6.1 语言选择策略
|
||||
|
||||
- 默认语言:`zh-CN`。
|
||||
- 优先级(从高到低):
|
||||
1. `localStorage` 中的用户选择(key:`csai_lang`)。
|
||||
2. 浏览器 `navigator.language`(`zh` 开头 → `zh-CN`,否则 `en-US`)。
|
||||
3. 默认 `zh-CN`。
|
||||
|
||||
### 6.2 初始化流程(`i18n.js`)
|
||||
|
||||
1. 读取初始语言。
|
||||
2. 初始化 i18next:
|
||||
- `lng` 为当前语言;
|
||||
- `fallbackLng` 为 `zh-CN`;
|
||||
- 资源先留空,采用按需加载。
|
||||
3. 通过 `fetch` 拉取 `/static/i18n/{lng}.json` 并 `i18next.addResources`。
|
||||
4. 更新:
|
||||
- `<html lang="...">` 属性;
|
||||
- 所有带 `data-i18n` / `data-i18n-attr` 的元素。
|
||||
5. 暴露 `window.t` 与 `window.changeLanguage`。
|
||||
|
||||
### 6.3 DOM 应用逻辑
|
||||
|
||||
伪代码:
|
||||
|
||||
```js
|
||||
function applyTranslations(root = document) {
|
||||
const elements = root.querySelectorAll('[data-i18n]');
|
||||
elements.forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
if (!key) return;
|
||||
const text = i18next.t(key);
|
||||
if (text) {
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
const attrList = el.getAttribute('data-i18n-attr');
|
||||
if (attrList) {
|
||||
attrList.split(',').map(s => s.trim()).forEach(attr => {
|
||||
if (!attr) return;
|
||||
const val = i18next.t(key);
|
||||
if (val) el.setAttribute(attr, val);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
> 对于由 JS 动态插入的元素,需要在插入后再次调用 `applyTranslations(新容器)`。
|
||||
|
||||
---
|
||||
|
||||
## 七、语言切换 UI 规范
|
||||
|
||||
### 7.1 位置与形态
|
||||
|
||||
- 位置:`index.html` header 右侧 `API 文档` 按钮附近(靠近用户头像)。
|
||||
- 交互形式:
|
||||
- 一个紧凑的语言切换组件,例如:
|
||||
- `🌐` 图标 + 当前语言文本(`中文` / `English`)的下拉按钮;
|
||||
- 下拉内容列出所有可用语言。
|
||||
|
||||
### 7.2 示例结构
|
||||
|
||||
```html
|
||||
<div class="lang-switcher">
|
||||
<button class="btn-secondary lang-switcher-btn" onclick="toggleLangDropdown()" data-i18n="header.language">
|
||||
<span class="lang-switcher-icon">🌐</span>
|
||||
<span id="current-lang-label">中文</span>
|
||||
</button>
|
||||
<div id="lang-dropdown" class="lang-dropdown" style="display: none;">
|
||||
<div class="lang-option" data-lang="zh-CN" onclick="onLanguageSelect('zh-CN')">中文</div>
|
||||
<div class="lang-option" data-lang="en-US" onclick="onLanguageSelect('en-US')">English</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
对应 JS(在 `i18n.js` 中):
|
||||
|
||||
```js
|
||||
function onLanguageSelect(lang) {
|
||||
changeLanguage(lang).then(updateLangLabel).catch(console.error);
|
||||
closeLangDropdown();
|
||||
}
|
||||
|
||||
function updateLangLabel() {
|
||||
const labelEl = document.getElementById('current-lang-label');
|
||||
if (!labelEl) return;
|
||||
const lang = i18next.language || 'zh-CN';
|
||||
labelEl.textContent = lang.startsWith('zh') ? '中文' : 'English';
|
||||
}
|
||||
```
|
||||
|
||||
> 规范:**语言切换只更新文案,不刷新整页,也不修改 URL hash**。
|
||||
|
||||
---
|
||||
|
||||
## 八、开发流程建议
|
||||
|
||||
### 8.1 新增 / 修改界面的流程
|
||||
|
||||
1. 设计界面时,先列出所有文案。
|
||||
2. 在对应语言 JSON 中补充/修改 key 与翻译。
|
||||
3. 在 HTML 中使用 `data-i18n`,在 JS 中使用 `t('...')`。
|
||||
4. 在浏览器中切换中英文,确认两种语言显示都正确。
|
||||
|
||||
### 8.2 渐进式改造顺序(推荐)
|
||||
|
||||
1. **阶段 1(已规划)**
|
||||
- 引入 i18next 与 `i18n.js`。
|
||||
- 新建 `zh-CN.json` / `en-US.json`(先覆盖 header / 登录 / 左侧导航)。
|
||||
- 实现 header 区域语言切换组件。
|
||||
2. **阶段 2**(已完成)
|
||||
- 系统设置页面(包括机器人配置页面)全部文案 i18n 化。
|
||||
- `settings.js` 中的提示与错误信息改用 `t()`。
|
||||
3. **阶段 3**(进行中)
|
||||
- 仪表盘、任务管理、漏洞管理、MCP、Skills、Roles 等页面按模块逐步迁移。
|
||||
4. **阶段 4**
|
||||
- 清理 JS / HTML 中残留的硬编码中文,统一通过 i18n。
|
||||
|
||||
---
|
||||
|
||||
## 九、后续扩展新语言
|
||||
|
||||
当需要新增语言时:
|
||||
|
||||
1. 在 `web/static/i18n/` 中新增 `{lang}.json`,复制现有英文/中文文件结构,补充对应翻译。
|
||||
2. 在语言切换下拉中添加对应选项,例如:
|
||||
- `data-lang="ja-JP"` / 文本 `日本語`
|
||||
3. 无需修改 `i18n.js` 或现有 HTML/JS 逻辑,即可支持新语言。
|
||||
|
||||
---
|
||||
|
||||
## 十、注意事项与坑点
|
||||
|
||||
- **不要复制多份 HTML 模板** 来做多语言,那样维护成本极高,本方案统一由前端 i18n 控制。
|
||||
- **避免 key 直接用中文/英文句子**,统一采用「模块.语义」短 key,便于 diff 与搜索。
|
||||
- 避免在 CSS 中写死文本(如 `content: "xxx"`),如确有需要,应通过 JS 设置并走 i18n。
|
||||
- 对于后端返回的可本地化错误文本(未来可能支持),优先由后端根据 `Accept-Language` 返回对应语言,前端只负责展示。
|
||||
|
||||
+38
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](robot_en.md)
|
||||
|
||||
本文档说明如何通过**钉钉**、**飞书**与 CyberStrikeAI 对话(长连接模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
|
||||
本文档说明如何通过**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
|
||||
|
||||
---
|
||||
|
||||
@@ -19,12 +19,13 @@
|
||||
|
||||
---
|
||||
|
||||
## 二、支持的平台(长连接)
|
||||
## 二、支持的平台(长连接 / 回调)
|
||||
|
||||
| 平台 | 说明 |
|
||||
|------|------|
|
||||
| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 |
|
||||
| 飞书 | 使用长连接,程序主动连接飞书接收消息 |
|
||||
| 平台 | 说明 |
|
||||
|----------|------|
|
||||
| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 |
|
||||
| 飞书 | 使用长连接,程序主动连接飞书接收消息 |
|
||||
| 企业微信 | 使用 HTTP 回调接收消息,被动回包 + 主动调用企业微信发送消息 API |
|
||||
|
||||
下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 CyberStrikeAI 的哪一栏。
|
||||
|
||||
@@ -101,6 +102,37 @@
|
||||
|
||||
---
|
||||
|
||||
### 3.3 企业微信 (WeCom)
|
||||
|
||||
> 企业微信目前采用「HTTP 回调 + 主动发送消息 API」的方式工作:
|
||||
> - 用户发消息 → 企业微信以加密 XML **回调到你的服务器**(本程序的 `/api/robot/wecom`);
|
||||
> - CyberStrikeAI 解密并调用 AI → 使用企业微信的 `message/send` 接口**主动发消息给用户**。
|
||||
|
||||
**配置概览:**
|
||||
|
||||
- 在企业微信管理后台创建或选择一个**自建应用**。
|
||||
- 在该应用的「接收消息」处配置回调 URL、Token、EncodingAESKey。
|
||||
- 在 CyberStrikeAI 的 `config.yaml` 中填入:
|
||||
- `robots.wecom.corp_id`:企业 ID(CorpID)
|
||||
- `robots.wecom.agent_id`:应用的 AgentId
|
||||
- `robots.wecom.token`:消息回调使用的 Token
|
||||
- `robots.wecom.encoding_aes_key`:消息回调使用的 EncodingAESKey
|
||||
- `robots.wecom.secret`:该应用的 Secret(用于调用企业微信主动发送消息接口)
|
||||
|
||||
> **重要:IP 白名单(errcode 60020)**
|
||||
> CyberStrikeAI 使用 `https://qyapi.weixin.qq.com/cgi-bin/message/send` 主动发送 AI 回复。
|
||||
> 若企业微信日志或本程序日志中出现 `errcode 60020 not allow to access from your ip`:
|
||||
>
|
||||
> - 说明你的服务器出口 IP **没有加入企业微信的 IP 白名单**;
|
||||
> - 请在企业微信管理后台中找到该自建应用的**「安全设置 / IP 白名单」**(具体入口可能因版本略有不同),将运行 CyberStrikeAI 的服务器公网 IP(如 `110.xxx.xxx.xxx`)加入白名单;
|
||||
> - 保存后等待生效,再次发送消息测试。
|
||||
>
|
||||
> 如果 IP 未加入白名单,企业微信会拒绝主动发送消息,表现为:
|
||||
> - 回调接口 `/api/robot/wecom` 能正常收到并处理消息;
|
||||
> - 但手机端**始终收不到 AI 回复**,日志中有 `not allow to access from your ip` 提示。
|
||||
|
||||
---
|
||||
|
||||
## 四、机器人命令
|
||||
|
||||
在钉钉/飞书中向机器人发送以下**文本命令**(仅支持文本):
|
||||
|
||||
+36
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
[中文](robot.md)
|
||||
|
||||
This document explains how to chat with CyberStrikeAI from **DingTalk** and **Lark (Feishu)** using long-lived connections—no need to open a browser on the server. Following the steps below helps avoid common mistakes.
|
||||
This document explains how to chat with CyberStrikeAI from **DingTalk**, **Lark (Feishu)**, and **WeCom (Enterprise WeChat)** using long-lived connections or HTTP callbacks—no need to open a browser on the server. Following the steps below helps avoid common mistakes.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,12 +19,13 @@ Settings are written to the `robots` section of `config.yaml`; you can also edit
|
||||
|
||||
---
|
||||
|
||||
## 2. Supported platforms (long-lived connection)
|
||||
## 2. Supported platforms (long-lived / callback)
|
||||
|
||||
| Platform | Description |
|
||||
|----------|-------------|
|
||||
| DingTalk | Stream long-lived connection; the app connects to DingTalk to receive messages |
|
||||
| Lark (Feishu) | Long-lived connection; the app connects to Lark to receive messages |
|
||||
| Platform | Description |
|
||||
|----------------|-------------|
|
||||
| DingTalk | Stream long-lived connection; the app connects to DingTalk to receive messages |
|
||||
| Lark (Feishu) | Long-lived connection; the app connects to Lark to receive messages |
|
||||
| WeCom (Qiye WX)| HTTP callback to receive messages; CyberStrikeAI replies via WeCom’s message sending API |
|
||||
|
||||
Section 3 below describes, per platform, what to do in the developer console and which fields to copy into CyberStrikeAI.
|
||||
|
||||
@@ -100,6 +101,35 @@ If you only have a **custom bot** Webhook URL (`oapi.dingtalk.com/robot/send?acc
|
||||
|
||||
---
|
||||
|
||||
### 3.3 WeCom (Enterprise WeChat)
|
||||
|
||||
> WeCom uses a **“HTTP callback + active message send API”** model:
|
||||
> - User sends a message → WeCom sends an **encrypted XML callback** to your server (CyberStrikeAI’s `/api/robot/wecom`).
|
||||
> - CyberStrikeAI decrypts it, calls the AI, then uses WeCom’s `message/send` API to **actively push the reply** to the user.
|
||||
|
||||
**Configuration overview:**
|
||||
|
||||
- In the WeCom admin console, create or select a **custom app** (自建应用).
|
||||
- In that app’s settings, configure the message **callback URL**, **Token**, and **EncodingAESKey**.
|
||||
- In CyberStrikeAI’s `config.yaml`, fill in:
|
||||
- `robots.wecom.corp_id`: your CorpID (企业 ID)
|
||||
- `robots.wecom.agent_id`: the app’s AgentId
|
||||
- `robots.wecom.token`: the Token used for message callbacks
|
||||
- `robots.wecom.encoding_aes_key`: the EncodingAESKey used for callbacks
|
||||
- `robots.wecom.secret`: the app’s Secret (used when calling WeCom APIs to send messages)
|
||||
|
||||
> **Important: IP allowlist (errcode 60020)**
|
||||
> CyberStrikeAI calls `https://qyapi.weixin.qq.com/cgi-bin/message/send` to actively send AI replies.
|
||||
> If logs show `errcode 60020 not allow to access from your ip`:
|
||||
>
|
||||
> - Your server’s outbound IP is **not in WeCom’s IP allowlist**.
|
||||
> - In the WeCom admin console, open the custom app’s **Security / IP allowlist** settings (name may vary slightly), and add the public IP of the machine running CyberStrikeAI (e.g. `110.xxx.xxx.xxx`).
|
||||
> - Save and wait for it to take effect, then test again.
|
||||
>
|
||||
> If the IP is not whitelisted, WeCom will reject active message sending. You will see that `/api/robot/wecom` receives and processes callbacks, but users **never see AI replies**, and logs contain `not allow to access from your ip`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bot commands
|
||||
|
||||
Send these **text commands** to the bot in DingTalk or Lark (text only):
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
module cyberstrike-ai
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/google/uuid v1.5.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.4.22
|
||||
github.com/mattn/go-sqlite3 v1.14.18
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/pkoukk/tiktoken-go v0.1.8
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -28,7 +31,6 @@ 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/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
|
||||
@@ -4,6 +4,8 @@ github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZX
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -127,6 +129,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
|
||||
+26
-3
@@ -198,7 +198,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
knowledgeRetriever = knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, log.Logger)
|
||||
|
||||
// 创建索引器
|
||||
knowledgeIndexer = knowledge.NewIndexer(knowledgeDB, embedder, log.Logger)
|
||||
knowledgeIndexer = knowledge.NewIndexer(knowledgeDB, embedder, log.Logger, &cfg.Knowledge.Indexing)
|
||||
|
||||
// 注册知识检索工具到MCP服务器
|
||||
knowledge.RegisterKnowledgeTool(mcpServer, knowledgeRetriever, knowledgeManager, log.Logger)
|
||||
@@ -325,6 +325,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
roleHandler.SetSkillsManager(skillsManager) // 设置Skills管理器到RoleHandler
|
||||
skillsHandler := handler.NewSkillsHandler(skillsManager, cfg, configPath, log.Logger)
|
||||
fofaHandler := handler.NewFofaHandler(cfg, log.Logger)
|
||||
terminalHandler := handler.NewTerminalHandler(log.Logger)
|
||||
if db != nil {
|
||||
skillsHandler.SetDB(db) // 设置数据库连接以便获取调用统计
|
||||
}
|
||||
@@ -431,6 +432,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
roleHandler,
|
||||
skillsHandler,
|
||||
fofaHandler,
|
||||
terminalHandler,
|
||||
mcpServer,
|
||||
authManager,
|
||||
openAPIHandler,
|
||||
@@ -440,6 +442,21 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
|
||||
}
|
||||
|
||||
// mcpHandlerWithAuth 在鉴权通过后转发到 MCP 处理;若配置了 auth_header 则校验请求头,否则直接放行
|
||||
func (a *App) mcpHandlerWithAuth(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := a.config.MCP
|
||||
if cfg.AuthHeader != "" {
|
||||
if r.Header.Get(cfg.AuthHeader) != cfg.AuthHeaderValue {
|
||||
a.logger.Logger.Debug("MCP 鉴权失败:header 缺失或值不匹配", zap.String("header", cfg.AuthHeader))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
a.mcpServer.HandleHTTP(w, r)
|
||||
}
|
||||
|
||||
// Run 启动应用
|
||||
func (a *App) Run() error {
|
||||
// 启动MCP服务器(如果启用)
|
||||
@@ -449,7 +466,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))
|
||||
@@ -542,6 +559,7 @@ func setupRoutes(
|
||||
roleHandler *handler.RoleHandler,
|
||||
skillsHandler *handler.SkillsHandler,
|
||||
fofaHandler *handler.FofaHandler,
|
||||
terminalHandler *handler.TerminalHandler,
|
||||
mcpServer *mcp.Server,
|
||||
authManager *security.AuthManager,
|
||||
openAPIHandler *handler.OpenAPIHandler,
|
||||
@@ -628,6 +646,11 @@ func setupRoutes(
|
||||
protected.PUT("/config", configHandler.UpdateConfig)
|
||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||
|
||||
// 系统设置 - 终端(执行命令,提高运维效率)
|
||||
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
||||
protected.POST("/terminal/run/stream", terminalHandler.RunCommandStream)
|
||||
protected.GET("/terminal/ws", terminalHandler.RunCommandWS)
|
||||
|
||||
// 外部MCP管理
|
||||
protected.GET("/external-mcp", externalMCPHandler.GetExternalMCPs)
|
||||
protected.GET("/external-mcp/stats", externalMCPHandler.GetExternalMCPStats)
|
||||
@@ -1094,7 +1117,7 @@ func initializeKnowledge(
|
||||
knowledgeRetriever := knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, logger)
|
||||
|
||||
// 创建索引器
|
||||
knowledgeIndexer := knowledge.NewIndexer(knowledgeDB, embedder, logger)
|
||||
knowledgeIndexer := knowledge.NewIndexer(knowledgeDB, embedder, logger, &cfg.Knowledge.Indexing)
|
||||
|
||||
// 注册知识检索工具到MCP服务器
|
||||
knowledge.RegisterKnowledgeTool(mcpServer, knowledgeRetriever, knowledgeManager, logger)
|
||||
|
||||
+155
-4
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -74,9 +76,11 @@ type LogConfig struct {
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
AuthHeader string `yaml:"auth_header,omitempty"` // 鉴权 header 名,留空表示不鉴权
|
||||
AuthHeaderValue string `yaml:"auth_header_value,omitempty"` // 鉴权 header 值,需与请求中该 header 一致
|
||||
}
|
||||
|
||||
type OpenAIConfig struct {
|
||||
@@ -384,6 +388,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
|
||||
@@ -582,9 +704,18 @@ func Default() *Config {
|
||||
},
|
||||
Retrieval: RetrievalConfig{
|
||||
TopK: 5,
|
||||
SimilarityThreshold: 0.7,
|
||||
SimilarityThreshold: 0.65, // 降低阈值到 0.65,减少漏检
|
||||
HybridWeight: 0.7,
|
||||
},
|
||||
Indexing: IndexingConfig{
|
||||
ChunkSize: 768, // 增加到 768,更好的上下文保持
|
||||
ChunkOverlap: 50,
|
||||
MaxChunksPerItem: 20, // 限制单个知识项最多 20 个块,避免消耗过多配额
|
||||
MaxRPM: 100, // 默认 100 RPM,避免 429 错误
|
||||
RateLimitDelayMs: 600, // 600ms 间隔,对应 100 RPM
|
||||
MaxRetries: 3,
|
||||
RetryDelayMs: 1000,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -595,6 +726,26 @@ type KnowledgeConfig struct {
|
||||
BasePath string `yaml:"base_path" json:"base_path"` // 知识库路径
|
||||
Embedding EmbeddingConfig `yaml:"embedding" json:"embedding"`
|
||||
Retrieval RetrievalConfig `yaml:"retrieval" json:"retrieval"`
|
||||
Indexing IndexingConfig `yaml:"indexing,omitempty" json:"indexing,omitempty"` // 索引构建配置
|
||||
}
|
||||
|
||||
// IndexingConfig 索引构建配置(用于控制知识库索引构建时的行为)
|
||||
type IndexingConfig struct {
|
||||
// 分块配置
|
||||
ChunkSize int `yaml:"chunk_size,omitempty" json:"chunk_size,omitempty"` // 每个块的最大 token 数(估算),默认 512
|
||||
ChunkOverlap int `yaml:"chunk_overlap,omitempty" json:"chunk_overlap,omitempty"` // 块之间的重叠 token 数,默认 50
|
||||
MaxChunksPerItem int `yaml:"max_chunks_per_item,omitempty" json:"max_chunks_per_item,omitempty"` // 单个知识项的最大块数量,0 表示不限制
|
||||
|
||||
// 速率限制配置(用于避免 API 速率限制)
|
||||
RateLimitDelayMs int `yaml:"rate_limit_delay_ms,omitempty" json:"rate_limit_delay_ms,omitempty"` // 请求间隔时间(毫秒),0 表示不使用固定延迟
|
||||
MaxRPM int `yaml:"max_rpm,omitempty" json:"max_rpm,omitempty"` // 每分钟最大请求数,0 表示不限制
|
||||
|
||||
// 重试配置(用于处理临时错误)
|
||||
MaxRetries int `yaml:"max_retries,omitempty" json:"max_retries,omitempty"` // 最大重试次数,默认 3
|
||||
RetryDelayMs int `yaml:"retry_delay_ms,omitempty" json:"retry_delay_ms,omitempty"` // 重试间隔(毫秒),默认 1000
|
||||
|
||||
// 批处理配置(用于批量嵌入,当前未使用,保留扩展)
|
||||
BatchSize int `yaml:"batch_size,omitempty" json:"batch_size,omitempty"` // 批量处理大小,0 表示逐个处理
|
||||
}
|
||||
|
||||
// EmbeddingConfig 嵌入配置
|
||||
|
||||
+169
-8
@@ -2,10 +2,14 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -108,11 +112,132 @@ func (h *AgentHandler) SetSkillsManager(manager *skills.Manager) {
|
||||
h.skillsManager = manager
|
||||
}
|
||||
|
||||
// ChatAttachment 聊天附件(用户上传的文件)
|
||||
type ChatAttachment struct {
|
||||
FileName string `json:"fileName"` // 文件名
|
||||
Content string `json:"content"` // 文本内容或 base64(由 MimeType 决定是否解码)
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
}
|
||||
|
||||
// ChatRequest 聊天请求
|
||||
type ChatRequest struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Message string `json:"message" binding:"required"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxAttachments = 10
|
||||
chatUploadsDirName = "chat_uploads" // 对话附件保存的根目录(相对当前工作目录)
|
||||
)
|
||||
|
||||
// saveAttachmentsToDateAndConversationDir 将附件保存到 chat_uploads/YYYY-MM-DD/{conversationID}/,返回每个文件的保存路径(与 attachments 顺序一致)
|
||||
// conversationID 为空时使用 "_new" 作为目录名(新对话尚未有 ID)
|
||||
func saveAttachmentsToDateAndConversationDir(attachments []ChatAttachment, conversationID string, logger *zap.Logger) (savedPaths []string, err error) {
|
||||
if len(attachments) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取当前工作目录失败: %w", err)
|
||||
}
|
||||
dateDir := filepath.Join(cwd, chatUploadsDirName, time.Now().Format("2006-01-02"))
|
||||
convDirName := strings.TrimSpace(conversationID)
|
||||
if convDirName == "" {
|
||||
convDirName = "_new"
|
||||
} else {
|
||||
convDirName = strings.ReplaceAll(convDirName, string(filepath.Separator), "_")
|
||||
}
|
||||
targetDir := filepath.Join(dateDir, convDirName)
|
||||
if err = os.MkdirAll(targetDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建上传目录失败: %w", err)
|
||||
}
|
||||
savedPaths = make([]string, 0, len(attachments))
|
||||
for i, a := range attachments {
|
||||
raw, decErr := attachmentContentToBytes(a)
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("附件 %s 解码失败: %w", a.FileName, decErr)
|
||||
}
|
||||
baseName := filepath.Base(a.FileName)
|
||||
if baseName == "" || baseName == "." {
|
||||
baseName = "file"
|
||||
}
|
||||
baseName = strings.ReplaceAll(baseName, string(filepath.Separator), "_")
|
||||
ext := filepath.Ext(baseName)
|
||||
nameNoExt := strings.TrimSuffix(baseName, ext)
|
||||
suffix := fmt.Sprintf("_%s_%s", time.Now().Format("150405"), shortRand(6))
|
||||
var unique string
|
||||
if ext != "" {
|
||||
unique = nameNoExt + suffix + ext
|
||||
} else {
|
||||
unique = baseName + suffix
|
||||
}
|
||||
fullPath := filepath.Join(targetDir, unique)
|
||||
if err = os.WriteFile(fullPath, raw, 0644); err != nil {
|
||||
return nil, fmt.Errorf("写入文件 %s 失败: %w", a.FileName, err)
|
||||
}
|
||||
absPath, _ := filepath.Abs(fullPath)
|
||||
savedPaths = append(savedPaths, absPath)
|
||||
if logger != nil {
|
||||
logger.Debug("对话附件已保存", zap.Int("index", i+1), zap.String("fileName", a.FileName), zap.String("path", absPath))
|
||||
}
|
||||
}
|
||||
return savedPaths, nil
|
||||
}
|
||||
|
||||
func shortRand(n int) string {
|
||||
const letters = "0123456789abcdef"
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
for i := range b {
|
||||
b[i] = letters[int(b[i])%len(letters)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func attachmentContentToBytes(a ChatAttachment) ([]byte, error) {
|
||||
content := a.Content
|
||||
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil && len(decoded) > 0 {
|
||||
return decoded, nil
|
||||
}
|
||||
return []byte(content), nil
|
||||
}
|
||||
|
||||
// userMessageContentForStorage 返回要存入数据库的用户消息内容:有附件时在正文后追加附件名(及路径),刷新后仍能显示,继续对话时大模型也能从历史中拿到路径
|
||||
func userMessageContentForStorage(message string, attachments []ChatAttachment, savedPaths []string) string {
|
||||
if len(attachments) == 0 {
|
||||
return message
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(message)
|
||||
for i, a := range attachments {
|
||||
b.WriteString("\n📎 ")
|
||||
b.WriteString(a.FileName)
|
||||
if i < len(savedPaths) && savedPaths[i] != "" {
|
||||
b.WriteString(": ")
|
||||
b.WriteString(savedPaths[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// appendAttachmentsToMessage 仅将附件的保存路径追加到用户消息末尾,不再内联附件内容,避免上下文过长
|
||||
func appendAttachmentsToMessage(msg string, attachments []ChatAttachment, savedPaths []string) string {
|
||||
if len(attachments) == 0 {
|
||||
return msg
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(msg)
|
||||
b.WriteString("\n\n[用户上传的文件已保存到以下路径(请按需读取文件内容,而不是依赖内联内容)]\n")
|
||||
for i, a := range attachments {
|
||||
if i < len(savedPaths) && savedPaths[i] != "" {
|
||||
b.WriteString(fmt.Sprintf("- %s: %s\n", a.FileName, savedPaths[i]))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("- %s: (路径未知,可能保存失败)\n", a.FileName))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ChatResponse 聊天响应
|
||||
@@ -181,6 +306,12 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
||||
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
|
||||
}
|
||||
|
||||
// 校验附件数量(非流式)
|
||||
if len(req.Attachments) > maxAttachments {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("附件最多 %d 个", maxAttachments)})
|
||||
return
|
||||
}
|
||||
|
||||
// 应用角色用户提示词和工具配置
|
||||
finalMessage := req.Message
|
||||
var roleTools []string // 角色配置的工具列表
|
||||
@@ -206,9 +337,20 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
var savedPaths []string
|
||||
if len(req.Attachments) > 0 {
|
||||
savedPaths, err = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
|
||||
if err != nil {
|
||||
h.logger.Error("保存对话附件失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存上传文件失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths)
|
||||
|
||||
// 保存用户消息(保存原始消息,不包含角色提示词)
|
||||
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil)
|
||||
// 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
|
||||
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
||||
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
|
||||
if err != nil {
|
||||
h.logger.Error("保存用户消息失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存用户消息失败: " + err.Error()})
|
||||
@@ -618,6 +760,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
|
||||
}
|
||||
|
||||
// 校验附件数量
|
||||
if len(req.Attachments) > maxAttachments {
|
||||
sendEvent("error", fmt.Sprintf("附件最多 %d 个", maxAttachments), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 应用角色用户提示词和工具配置
|
||||
finalMessage := req.Message
|
||||
var roleTools []string // 角色配置的工具列表
|
||||
@@ -645,10 +793,22 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
var savedPaths []string
|
||||
if len(req.Attachments) > 0 {
|
||||
savedPaths, err = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
|
||||
if err != nil {
|
||||
h.logger.Error("保存对话附件失败", zap.Error(err))
|
||||
sendEvent("error", "保存上传文件失败: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 仅将附件保存路径追加到 finalMessage,避免将文件内容内联到大模型上下文中
|
||||
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths)
|
||||
// 如果roleTools为空,表示使用所有工具(默认角色或未配置工具的角色)
|
||||
|
||||
// 保存用户消息(保存原始消息,不包含角色提示词)
|
||||
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil)
|
||||
// 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
|
||||
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
||||
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
|
||||
if err != nil {
|
||||
h.logger.Error("保存用户消息失败", zap.Error(err))
|
||||
}
|
||||
@@ -1284,7 +1444,8 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
// 执行任务(使用包含角色提示词的finalMessage和角色工具列表)
|
||||
h.logger.Info("执行批量任务", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("message", task.Message), zap.String("role", queue.Role), zap.String("conversationId", conversationID))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
// 单个子任务超时时间:从30分钟调整为6小时,适配长时间渗透/扫描任务
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour)
|
||||
// 存储取消函数,以便在取消队列时能够取消当前任务
|
||||
h.batchTaskManager.SetTaskCancel(queueID, cancel)
|
||||
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
||||
|
||||
@@ -1062,6 +1062,16 @@ func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
|
||||
setIntInMap(retrievalNode, "top_k", cfg.Retrieval.TopK)
|
||||
setFloatInMap(retrievalNode, "similarity_threshold", cfg.Retrieval.SimilarityThreshold)
|
||||
setFloatInMap(retrievalNode, "hybrid_weight", cfg.Retrieval.HybridWeight)
|
||||
|
||||
// 更新索引配置
|
||||
indexingNode := ensureMap(knowledgeNode, "indexing")
|
||||
setIntInMap(indexingNode, "chunk_size", cfg.Indexing.ChunkSize)
|
||||
setIntInMap(indexingNode, "chunk_overlap", cfg.Indexing.ChunkOverlap)
|
||||
setIntInMap(indexingNode, "max_chunks_per_item", cfg.Indexing.MaxChunksPerItem)
|
||||
setIntInMap(indexingNode, "max_rpm", cfg.Indexing.MaxRPM)
|
||||
setIntInMap(indexingNode, "rate_limit_delay_ms", cfg.Indexing.RateLimitDelayMs)
|
||||
setIntInMap(indexingNode, "max_retries", cfg.Indexing.MaxRetries)
|
||||
setIntInMap(indexingNode, "retry_delay_ms", cfg.Indexing.RetryDelayMs)
|
||||
}
|
||||
|
||||
func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
||||
|
||||
@@ -75,7 +75,7 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
|
||||
groupedByCategory[cat] = append(groupedByCategory[cat], item)
|
||||
}
|
||||
|
||||
// 转换为CategoryWithItems格式
|
||||
// 转换为 CategoryWithItems 格式
|
||||
categoriesWithItems := make([]*knowledge.CategoryWithItems, 0, len(groupedByCategory))
|
||||
for cat, catItems := range groupedByCategory {
|
||||
categoriesWithItems = append(categoriesWithItems, &knowledge.CategoryWithItems{
|
||||
@@ -107,7 +107,7 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
|
||||
categoryPageMode := c.Query("categoryPage") != "false" // 默认使用分类分页
|
||||
|
||||
// 分页参数
|
||||
limit := 50 // 默认每页50条(分类分页时为分类数,项分页时为项数)
|
||||
limit := 50 // 默认每页 50 条(分类分页时为分类数,项分页时为项数)
|
||||
offset := 0
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if parsed, err := parseInt(limitStr); err == nil && parsed > 0 && parsed <= 500 {
|
||||
@@ -120,7 +120,7 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果指定了category参数,且使用分类分页模式,则只返回该分类
|
||||
// 如果指定了 category 参数,且使用分类分页模式,则只返回该分类
|
||||
if category != "" && categoryPageMode {
|
||||
// 单分类模式:返回该分类的所有知识项(不分页)
|
||||
items, total, err := h.manager.GetItemsSummary(category, 0, 0)
|
||||
@@ -150,9 +150,9 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
|
||||
|
||||
if categoryPageMode {
|
||||
// 按分类分页模式(默认)
|
||||
// limit表示每页分类数,推荐5-10个分类
|
||||
// limit 表示每页分类数,推荐 5-10 个分类
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 10 // 默认每页10个分类
|
||||
limit = 10 // 默认每页 10 个分类
|
||||
}
|
||||
|
||||
categoriesWithItems, totalCategories, err := h.manager.GetCategoriesWithItems(limit, offset)
|
||||
@@ -172,7 +172,7 @@ func (h *KnowledgeHandler) GetItems(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 按项分页模式(向后兼容)
|
||||
// 是否包含完整内容(默认false,只返回摘要)
|
||||
// 是否包含完整内容(默认 false,只返回摘要)
|
||||
includeContent := c.Query("includeContent") == "true"
|
||||
|
||||
if includeContent {
|
||||
@@ -358,7 +358,7 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// 如果连续失败2次,立即停止增量索引
|
||||
// 如果连续失败 2 次,立即停止增量索引
|
||||
if consecutiveFailures >= 2 {
|
||||
h.logger.Error("连续索引失败次数过多,立即停止增量索引",
|
||||
zap.Int("consecutiveFailures", consecutiveFailures),
|
||||
@@ -397,7 +397,7 @@ func (h *KnowledgeHandler) ScanKnowledgeBase(c *gin.Context) {
|
||||
func (h *KnowledgeHandler) GetRetrievalLogs(c *gin.Context) {
|
||||
conversationID := c.Query("conversationId")
|
||||
messageID := c.Query("messageId")
|
||||
limit := 50 // 默认50条
|
||||
limit := 50 // 默认 50 条
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if parsed, err := parseInt(limitStr); err == nil && parsed > 0 {
|
||||
@@ -441,18 +441,40 @@ func (h *KnowledgeHandler) GetIndexStatus(c *gin.Context) {
|
||||
if h.indexer != nil {
|
||||
lastError, lastErrorTime := h.indexer.GetLastError()
|
||||
if lastError != "" {
|
||||
// 如果错误是最近发生的(5分钟内),则返回错误信息
|
||||
// 如果错误是最近发生的(5 分钟内),则返回错误信息
|
||||
if time.Since(lastErrorTime) < 5*time.Minute {
|
||||
status["last_error"] = lastError
|
||||
status["last_error_time"] = lastErrorTime.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取重建索引状态
|
||||
isRebuilding, totalItems, current, failed, lastItemID, lastChunks, startTime := h.indexer.GetRebuildStatus()
|
||||
if isRebuilding {
|
||||
status["is_rebuilding"] = true
|
||||
status["rebuild_total"] = totalItems
|
||||
status["rebuild_current"] = current
|
||||
status["rebuild_failed"] = failed
|
||||
status["rebuild_start_time"] = startTime.Format(time.RFC3339)
|
||||
if lastItemID != "" {
|
||||
status["rebuild_last_item_id"] = lastItemID
|
||||
}
|
||||
if lastChunks > 0 {
|
||||
status["rebuild_last_chunks"] = lastChunks
|
||||
}
|
||||
// 重建中时,is_complete 为 false
|
||||
status["is_complete"] = false
|
||||
// 计算重建进度百分比
|
||||
if totalItems > 0 {
|
||||
status["progress_percent"] = float64(current) / float64(totalItems) * 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// Search 搜索知识库(用于API调用,Agent内部使用Retriever)
|
||||
// Search 搜索知识库(用于 API 调用,Agent 内部使用 Retriever)
|
||||
func (h *KnowledgeHandler) Search(c *gin.Context) {
|
||||
var req knowledge.SearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -4411,6 +4411,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
}
|
||||
|
||||
enrichSpecWithI18nKeys(spec)
|
||||
c.JSON(http.StatusOK, spec)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
// apiDocI18n 为 OpenAPI 文档提供 x-i18n-* 扩展键,供前端 apiDocs 国际化使用。
|
||||
// 前端通过 apiDocs.tags.* / apiDocs.summary.* / apiDocs.response.* 翻译。
|
||||
|
||||
var apiDocI18nTagToKey = map[string]string{
|
||||
"认证": "auth", "对话管理": "conversationManagement", "对话交互": "conversationInteraction",
|
||||
"批量任务": "batchTasks", "对话分组": "conversationGroups", "漏洞管理": "vulnerabilityManagement",
|
||||
"角色管理": "roleManagement", "Skills管理": "skillsManagement", "监控": "monitoring",
|
||||
"配置管理": "configManagement", "外部MCP管理": "externalMCPManagement", "攻击链": "attackChain",
|
||||
"知识库": "knowledgeBase", "MCP": "mcp",
|
||||
}
|
||||
|
||||
var apiDocI18nSummaryToKey = map[string]string{
|
||||
"用户登录": "login", "用户登出": "logout", "修改密码": "changePassword", "验证Token": "validateToken",
|
||||
"创建对话": "createConversation", "列出对话": "listConversations", "查看对话详情": "getConversationDetail",
|
||||
"更新对话": "updateConversation", "删除对话": "deleteConversation", "获取对话结果": "getConversationResult",
|
||||
"发送消息并获取AI回复(非流式)": "sendMessageNonStream", "发送消息并获取AI回复(流式)": "sendMessageStream",
|
||||
"取消任务": "cancelTask", "列出运行中的任务": "listRunningTasks", "列出已完成的任务": "listCompletedTasks",
|
||||
"创建批量任务队列": "createBatchQueue", "列出批量任务队列": "listBatchQueues", "获取批量任务队列": "getBatchQueue",
|
||||
"删除批量任务队列": "deleteBatchQueue", "启动批量任务队列": "startBatchQueue", "暂停批量任务队列": "pauseBatchQueue",
|
||||
"添加任务到队列": "addTaskToQueue", "SQL注入扫描": "sqlInjectionScan", "端口扫描": "portScan",
|
||||
"更新批量任务": "updateBatchTask", "删除批量任务": "deleteBatchTask",
|
||||
"创建分组": "createGroup", "列出分组": "listGroups", "获取分组": "getGroup", "更新分组": "updateGroup",
|
||||
"删除分组": "deleteGroup", "获取分组中的对话": "getGroupConversations", "添加对话到分组": "addConversationToGroup",
|
||||
"从分组移除对话": "removeConversationFromGroup",
|
||||
"列出漏洞": "listVulnerabilities", "创建漏洞": "createVulnerability", "获取漏洞统计": "getVulnerabilityStats",
|
||||
"获取漏洞": "getVulnerability", "更新漏洞": "updateVulnerability", "删除漏洞": "deleteVulnerability",
|
||||
"列出角色": "listRoles", "创建角色": "createRole", "获取角色": "getRole", "更新角色": "updateRole", "删除角色": "deleteRole",
|
||||
"获取可用Skills列表": "getAvailableSkills", "列出Skills": "listSkills", "创建Skill": "createSkill",
|
||||
"获取Skill统计": "getSkillStats", "清空Skill统计": "clearSkillStats", "获取Skill": "getSkill",
|
||||
"更新Skill": "updateSkill", "删除Skill": "deleteSkill", "获取绑定角色": "getBoundRoles",
|
||||
"获取监控信息": "getMonitorInfo", "获取执行记录": "getExecutionRecords", "删除执行记录": "deleteExecutionRecord",
|
||||
"批量删除执行记录": "batchDeleteExecutionRecords", "获取统计信息": "getStats",
|
||||
"获取配置": "getConfig", "更新配置": "updateConfig", "获取工具配置": "getToolConfig", "应用配置": "applyConfig",
|
||||
"列出外部MCP": "listExternalMCP", "获取外部MCP统计": "getExternalMCPStats", "获取外部MCP": "getExternalMCP",
|
||||
"添加或更新外部MCP": "addOrUpdateExternalMCP", "stdio模式配置": "stdioModeConfig", "SSE模式配置": "sseModeConfig",
|
||||
"删除外部MCP": "deleteExternalMCP", "启动外部MCP": "startExternalMCP", "停止外部MCP": "stopExternalMCP",
|
||||
"获取攻击链": "getAttackChain", "重新生成攻击链": "regenerateAttackChain",
|
||||
"设置对话置顶": "pinConversation", "设置分组置顶": "pinGroup", "设置分组中对话的置顶": "pinGroupConversation",
|
||||
"获取分类": "getCategories", "列出知识项": "listKnowledgeItems", "创建知识项": "createKnowledgeItem",
|
||||
"获取知识项": "getKnowledgeItem", "更新知识项": "updateKnowledgeItem", "删除知识项": "deleteKnowledgeItem",
|
||||
"获取索引状态": "getIndexStatus", "重建索引": "rebuildIndex", "扫描知识库": "scanKnowledgeBase",
|
||||
"搜索知识库": "searchKnowledgeBase", "基础搜索": "basicSearch", "按风险类型搜索": "searchByRiskType",
|
||||
"获取检索日志": "getRetrievalLogs", "删除检索日志": "deleteRetrievalLog",
|
||||
"MCP端点": "mcpEndpoint", "列出所有工具": "listAllTools", "调用工具": "invokeTool", "初始化连接": "initConnection",
|
||||
"成功响应": "successResponse", "错误响应": "errorResponse",
|
||||
}
|
||||
|
||||
var apiDocI18nResponseDescToKey = map[string]string{
|
||||
"获取成功": "getSuccess", "未授权": "unauthorized", "未授权,需要有效的Token": "unauthorizedToken",
|
||||
"创建成功": "createSuccess", "请求参数错误": "badRequest", "对话不存在": "conversationNotFound",
|
||||
"对话不存在或结果不存在": "conversationOrResultNotFound", "请求参数错误(如task为空)": "badRequestTaskEmpty",
|
||||
"请求参数错误或分组名称已存在": "badRequestGroupNameExists", "分组不存在": "groupNotFound",
|
||||
"请求参数错误(如配置格式不正确、缺少必需字段等)": "badRequestConfig",
|
||||
"请求参数错误(如query为空)": "badRequestQueryEmpty", "方法不允许(仅支持POST请求)": "methodNotAllowed",
|
||||
"登录成功": "loginSuccess", "密码错误": "invalidPassword", "登出成功": "logoutSuccess",
|
||||
"密码修改成功": "passwordChanged", "Token有效": "tokenValid", "Token无效或已过期": "tokenInvalid",
|
||||
"对话创建成功": "conversationCreated", "服务器内部错误": "internalError", "更新成功": "updateSuccess",
|
||||
"删除成功": "deleteSuccess", "队列不存在": "queueNotFound", "启动成功": "startSuccess",
|
||||
"暂停成功": "pauseSuccess", "添加成功": "addSuccess",
|
||||
"任务不存在": "taskNotFound", "对话或分组不存在": "conversationOrGroupNotFound",
|
||||
"取消请求已提交": "cancelSubmitted", "未找到正在执行的任务": "noRunningTask",
|
||||
"消息发送成功,返回AI回复": "messageSent", "流式响应(Server-Sent Events)": "streamResponse",
|
||||
}
|
||||
|
||||
// enrichSpecWithI18nKeys 在 spec 的每个 operation 上写入 x-i18n-tags、x-i18n-summary,
|
||||
// 在每个 response 上写入 x-i18n-description,供前端按 key 做国际化。
|
||||
func enrichSpecWithI18nKeys(spec map[string]interface{}) {
|
||||
paths, _ := spec["paths"].(map[string]interface{})
|
||||
if paths == nil {
|
||||
return
|
||||
}
|
||||
for _, pathItem := range paths {
|
||||
pm, _ := pathItem.(map[string]interface{})
|
||||
if pm == nil {
|
||||
continue
|
||||
}
|
||||
for _, method := range []string{"get", "post", "put", "delete", "patch"} {
|
||||
opVal, ok := pm[method]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
op, _ := opVal.(map[string]interface{})
|
||||
if op == nil {
|
||||
continue
|
||||
}
|
||||
// x-i18n-tags: 与 tags 一一对应的 i18n 键数组(spec 中 tags 为 []string)
|
||||
switch tags := op["tags"].(type) {
|
||||
case []string:
|
||||
if len(tags) > 0 {
|
||||
keys := make([]string, 0, len(tags))
|
||||
for _, s := range tags {
|
||||
if k := apiDocI18nTagToKey[s]; k != "" {
|
||||
keys = append(keys, k)
|
||||
} else {
|
||||
keys = append(keys, s)
|
||||
}
|
||||
}
|
||||
op["x-i18n-tags"] = keys
|
||||
}
|
||||
case []interface{}:
|
||||
if len(tags) > 0 {
|
||||
keys := make([]interface{}, 0, len(tags))
|
||||
for _, t := range tags {
|
||||
if s, ok := t.(string); ok {
|
||||
if k := apiDocI18nTagToKey[s]; k != "" {
|
||||
keys = append(keys, k)
|
||||
} else {
|
||||
keys = append(keys, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
op["x-i18n-tags"] = keys
|
||||
}
|
||||
}
|
||||
}
|
||||
// x-i18n-summary
|
||||
if summary, _ := op["summary"].(string); summary != "" {
|
||||
if k := apiDocI18nSummaryToKey[summary]; k != "" {
|
||||
op["x-i18n-summary"] = k
|
||||
}
|
||||
}
|
||||
// responses -> 每个 status -> x-i18n-description
|
||||
if respMap, _ := op["responses"].(map[string]interface{}); respMap != nil {
|
||||
for _, rv := range respMap {
|
||||
if r, _ := rv.(map[string]interface{}); r != nil {
|
||||
if desc, _ := r["description"].(string); desc != "" {
|
||||
if k := apiDocI18nResponseDescToKey[desc]; k != "" {
|
||||
r["x-i18n-description"] = k
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+381
-77
@@ -1,11 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -141,56 +145,9 @@ func (h *RobotHandler) HandleMessage(platform, userID, text string) (reply strin
|
||||
return "请输入内容或发送「帮助」/ help 查看命令。"
|
||||
}
|
||||
|
||||
// 命令分发(支持中英文)
|
||||
switch {
|
||||
case text == robotCmdHelp || text == "help" || text == "?" || text == "?":
|
||||
return h.cmdHelp()
|
||||
case text == robotCmdList || text == robotCmdListAlt || text == "list":
|
||||
return h.cmdList()
|
||||
case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "):
|
||||
var id string
|
||||
switch {
|
||||
case strings.HasPrefix(text, robotCmdSwitch+" "):
|
||||
id = strings.TrimSpace(text[len(robotCmdSwitch)+1:])
|
||||
case strings.HasPrefix(text, robotCmdContinue+" "):
|
||||
id = strings.TrimSpace(text[len(robotCmdContinue)+1:])
|
||||
case strings.HasPrefix(text, "switch "):
|
||||
id = strings.TrimSpace(text[7:])
|
||||
default:
|
||||
id = strings.TrimSpace(text[9:])
|
||||
}
|
||||
return h.cmdSwitch(platform, userID, id)
|
||||
case text == robotCmdNew || text == "new":
|
||||
return h.cmdNew(platform, userID)
|
||||
case text == robotCmdClear || text == "clear":
|
||||
return h.cmdClear(platform, userID)
|
||||
case text == robotCmdCurrent || text == "current":
|
||||
return h.cmdCurrent(platform, userID)
|
||||
case text == robotCmdStop || text == "stop":
|
||||
return h.cmdStop(platform, userID)
|
||||
case text == robotCmdRoles || text == robotCmdRolesList || text == "roles":
|
||||
return h.cmdRoles()
|
||||
case strings.HasPrefix(text, robotCmdRoles+" ") || strings.HasPrefix(text, robotCmdSwitchRole+" ") || strings.HasPrefix(text, "role "):
|
||||
var roleName string
|
||||
switch {
|
||||
case strings.HasPrefix(text, robotCmdRoles+" "):
|
||||
roleName = strings.TrimSpace(text[len(robotCmdRoles)+1:])
|
||||
case strings.HasPrefix(text, robotCmdSwitchRole+" "):
|
||||
roleName = strings.TrimSpace(text[len(robotCmdSwitchRole)+1:])
|
||||
default:
|
||||
roleName = strings.TrimSpace(text[5:])
|
||||
}
|
||||
return h.cmdSwitchRole(platform, userID, roleName)
|
||||
case strings.HasPrefix(text, robotCmdDelete+" ") || strings.HasPrefix(text, "delete "):
|
||||
var convID string
|
||||
if strings.HasPrefix(text, robotCmdDelete+" ") {
|
||||
convID = strings.TrimSpace(text[len(robotCmdDelete)+1:])
|
||||
} else {
|
||||
convID = strings.TrimSpace(text[7:])
|
||||
}
|
||||
return h.cmdDelete(platform, userID, convID)
|
||||
case text == robotCmdVersion || text == "version":
|
||||
return h.cmdVersion()
|
||||
// 先尝试作为命令处理(支持中英文)
|
||||
if cmdReply, ok := h.handleRobotCommand(platform, userID, text); ok {
|
||||
return cmdReply
|
||||
}
|
||||
|
||||
// 普通消息:走 Agent
|
||||
@@ -404,6 +361,62 @@ func (h *RobotHandler) cmdVersion() string {
|
||||
return "CyberStrikeAI " + v
|
||||
}
|
||||
|
||||
// handleRobotCommand 处理机器人内置命令;若匹配到命令返回 (回复内容, true),否则返回 ("", false)
|
||||
func (h *RobotHandler) handleRobotCommand(platform, userID, text string) (string, bool) {
|
||||
switch {
|
||||
case text == robotCmdHelp || text == "help" || text == "?" || text == "?":
|
||||
return h.cmdHelp(), true
|
||||
case text == robotCmdList || text == robotCmdListAlt || text == "list":
|
||||
return h.cmdList(), true
|
||||
case strings.HasPrefix(text, robotCmdSwitch+" ") || strings.HasPrefix(text, robotCmdContinue+" ") || strings.HasPrefix(text, "switch ") || strings.HasPrefix(text, "continue "):
|
||||
var id string
|
||||
switch {
|
||||
case strings.HasPrefix(text, robotCmdSwitch+" "):
|
||||
id = strings.TrimSpace(text[len(robotCmdSwitch)+1:])
|
||||
case strings.HasPrefix(text, robotCmdContinue+" "):
|
||||
id = strings.TrimSpace(text[len(robotCmdContinue)+1:])
|
||||
case strings.HasPrefix(text, "switch "):
|
||||
id = strings.TrimSpace(text[7:])
|
||||
default:
|
||||
id = strings.TrimSpace(text[9:])
|
||||
}
|
||||
return h.cmdSwitch(platform, userID, id), true
|
||||
case text == robotCmdNew || text == "new":
|
||||
return h.cmdNew(platform, userID), true
|
||||
case text == robotCmdClear || text == "clear":
|
||||
return h.cmdClear(platform, userID), true
|
||||
case text == robotCmdCurrent || text == "current":
|
||||
return h.cmdCurrent(platform, userID), true
|
||||
case text == robotCmdStop || text == "stop":
|
||||
return h.cmdStop(platform, userID), true
|
||||
case text == robotCmdRoles || text == robotCmdRolesList || text == "roles":
|
||||
return h.cmdRoles(), true
|
||||
case strings.HasPrefix(text, robotCmdRoles+" ") || strings.HasPrefix(text, robotCmdSwitchRole+" ") || strings.HasPrefix(text, "role "):
|
||||
var roleName string
|
||||
switch {
|
||||
case strings.HasPrefix(text, robotCmdRoles+" "):
|
||||
roleName = strings.TrimSpace(text[len(robotCmdRoles)+1:])
|
||||
case strings.HasPrefix(text, robotCmdSwitchRole+" "):
|
||||
roleName = strings.TrimSpace(text[len(robotCmdSwitchRole)+1:])
|
||||
default:
|
||||
roleName = strings.TrimSpace(text[5:])
|
||||
}
|
||||
return h.cmdSwitchRole(platform, userID, roleName), true
|
||||
case strings.HasPrefix(text, robotCmdDelete+" ") || strings.HasPrefix(text, "delete "):
|
||||
var convID string
|
||||
if strings.HasPrefix(text, robotCmdDelete+" ") {
|
||||
convID = strings.TrimSpace(text[len(robotCmdDelete)+1:])
|
||||
} else {
|
||||
convID = strings.TrimSpace(text[7:])
|
||||
}
|
||||
return h.cmdDelete(platform, userID, convID), true
|
||||
case text == robotCmdVersion || text == "version":
|
||||
return h.cmdVersion(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// —————— 企业微信 ——————
|
||||
|
||||
// wecomXML 企业微信回调 XML(明文模式下的简化结构;加密模式需先解密再解析)
|
||||
@@ -418,14 +431,14 @@ type wecomXML struct {
|
||||
Encrypt string `xml:"Encrypt"` // 加密模式下消息在此
|
||||
}
|
||||
|
||||
// wecomReplyXML 被动回复 XML
|
||||
// wecomReplyXML 被动回复 XML(仅用于兼容,当前使用手动构造 XML)
|
||||
type wecomReplyXML struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime int64 `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Content string `xml:"Content"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime int64 `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"`
|
||||
Content string `xml:"Content"`
|
||||
}
|
||||
|
||||
// HandleWecomGET 企业微信 URL 校验(GET)
|
||||
@@ -434,15 +447,51 @@ func (h *RobotHandler) HandleWecomGET(c *gin.Context) {
|
||||
c.String(http.StatusNotFound, "")
|
||||
return
|
||||
}
|
||||
// Gin 的 Query() 会自动 URL 解码,拿到的就是正确的 base64 字符串
|
||||
echostr := c.Query("echostr")
|
||||
msgSignature := c.Query("msg_signature")
|
||||
timestamp := c.Query("timestamp")
|
||||
nonce := c.Query("nonce")
|
||||
|
||||
// 验证签名:将 token、timestamp、nonce、echostr 四个参数排序后拼接计算 SHA1
|
||||
signature := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, echostr)
|
||||
if signature != msgSignature {
|
||||
h.logger.Warn("企业微信 URL 验证签名失败", zap.String("expected", msgSignature), zap.String("got", signature))
|
||||
c.String(http.StatusBadRequest, "invalid signature")
|
||||
return
|
||||
}
|
||||
|
||||
if echostr == "" {
|
||||
c.String(http.StatusBadRequest, "missing echostr")
|
||||
return
|
||||
}
|
||||
// 明文模式时企业微信可能直接传 echostr,先直接返回以通过校验
|
||||
|
||||
// 如果配置了 EncodingAESKey,说明是加密模式,需要解密 echostr
|
||||
if h.config.Robots.Wecom.EncodingAESKey != "" {
|
||||
decrypted, err := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, echostr)
|
||||
if err != nil {
|
||||
h.logger.Warn("企业微信 echostr 解密失败", zap.Error(err))
|
||||
c.String(http.StatusBadRequest, "decrypt failed")
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, string(decrypted))
|
||||
return
|
||||
}
|
||||
|
||||
// 明文模式直接返回 echostr
|
||||
c.String(http.StatusOK, echostr)
|
||||
}
|
||||
|
||||
// signWecomRequest 生成企业微信请求签名
|
||||
// 企业微信签名算法:将 token、timestamp、nonce、echostr 四个值排序后拼接成字符串,再计算 SHA1
|
||||
func (h *RobotHandler) signWecomRequest(token, timestamp, nonce, echostr string) string {
|
||||
strs := []string{token, timestamp, nonce, echostr}
|
||||
sort.Strings(strs)
|
||||
s := strings.Join(strs, "")
|
||||
hash := sha1.Sum([]byte(s))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// wecomDecrypt 企业微信消息解密(AES-256-CBC,PKCS7,明文格式:16字节随机+4字节长度+消息+corpID)
|
||||
func wecomDecrypt(encodingAESKey, encryptedB64 string) ([]byte, error) {
|
||||
key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
||||
@@ -484,54 +533,228 @@ func wecomDecrypt(encodingAESKey, encryptedB64 string) ([]byte, error) {
|
||||
return plain[20 : 20+msgLen], nil
|
||||
}
|
||||
|
||||
// wecomEncrypt 企业微信消息加密(AES-256-CBC,PKCS7,明文格式:16字节随机+4字节长度+消息+corpID)
|
||||
func wecomEncrypt(encodingAESKey, message, corpID string) (string, error) {
|
||||
key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return "", fmt.Errorf("encoding_aes_key 解码后应为 32 字节")
|
||||
}
|
||||
// 构造明文:16 字节随机 + 4 字节长度 (大端) + 消息 + corpID
|
||||
random := make([]byte, 16)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
// 降级方案:使用时间戳生成随机数
|
||||
for i := range random {
|
||||
random[i] = byte(time.Now().UnixNano() % 256)
|
||||
}
|
||||
}
|
||||
msgLen := len(message)
|
||||
msgBytes := []byte(message)
|
||||
corpBytes := []byte(corpID)
|
||||
plain := make([]byte, 16+4+msgLen+len(corpBytes))
|
||||
copy(plain[:16], random)
|
||||
binary.BigEndian.PutUint32(plain[16:20], uint32(msgLen))
|
||||
copy(plain[20:20+msgLen], msgBytes)
|
||||
copy(plain[20+msgLen:], corpBytes)
|
||||
// PKCS7 填充
|
||||
padding := aes.BlockSize - len(plain)%aes.BlockSize
|
||||
pad := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
plain = append(plain, pad...)
|
||||
// AES-256-CBC 加密
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iv := key[:16]
|
||||
ciphertext := make([]byte, len(plain))
|
||||
mode := cipher.NewCBCEncrypter(block, iv)
|
||||
mode.CryptBlocks(ciphertext, plain)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// HandleWecomPOST 企业微信消息回调(POST),支持明文与加密模式
|
||||
func (h *RobotHandler) HandleWecomPOST(c *gin.Context) {
|
||||
if !h.config.Robots.Wecom.Enabled {
|
||||
h.logger.Debug("企业微信机器人未启用,跳过请求")
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
bodyRaw, _ := io.ReadAll(c.Request.Body)
|
||||
// 从 URL 获取签名参数(加密模式回复时需要用到)
|
||||
timestamp := c.Query("timestamp")
|
||||
nonce := c.Query("nonce")
|
||||
msgSignature := c.Query("msg_signature")
|
||||
|
||||
// 先读取请求体,后续解析/签名验证都会用到
|
||||
bodyRaw, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
h.logger.Warn("企业微信 POST 读取请求体失败", zap.Error(err))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
h.logger.Debug("企业微信 POST 收到请求", zap.String("body", string(bodyRaw)))
|
||||
|
||||
// 验证请求签名防止伪造。企业微信签名算法同 URL 验证,使用 token、timestamp、nonce、 Encrypt 四个字段
|
||||
if msgSignature != "" {
|
||||
var tmp wecomXML
|
||||
if err := xml.Unmarshal(bodyRaw, &tmp); err == nil {
|
||||
expected := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, tmp.Encrypt)
|
||||
if expected != msgSignature {
|
||||
h.logger.Warn("企业微信 POST 签名验证失败", zap.String("expected", expected), zap.String("got", msgSignature))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body wecomXML
|
||||
if err := xml.Unmarshal(bodyRaw, &body); err != nil {
|
||||
h.logger.Debug("企业微信 POST 解析 XML 失败", zap.Error(err))
|
||||
h.logger.Warn("企业微信 POST 解析 XML 失败", zap.Error(err))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
h.logger.Debug("企业微信 XML 解析成功", zap.String("ToUserName", body.ToUserName), zap.String("FromUserName", body.FromUserName), zap.String("MsgType", body.MsgType), zap.String("Content", body.Content), zap.String("Encrypt", body.Encrypt))
|
||||
|
||||
// 保存企业 ID(用于明文模式回复)
|
||||
enterpriseID := body.ToUserName
|
||||
|
||||
// 加密模式:先解密再解析内层 XML
|
||||
if body.Encrypt != "" && h.config.Robots.Wecom.EncodingAESKey != "" {
|
||||
h.logger.Debug("企业微信进入加密模式解密流程")
|
||||
decrypted, err := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, body.Encrypt)
|
||||
if err != nil {
|
||||
h.logger.Warn("企业微信消息解密失败", zap.Error(err))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
h.logger.Debug("企业微信解密成功", zap.String("decrypted", string(decrypted)))
|
||||
if err := xml.Unmarshal(decrypted, &body); err != nil {
|
||||
h.logger.Warn("企业微信解密后 XML 解析失败", zap.Error(err))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
h.logger.Debug("企业微信内层 XML 解析成功", zap.String("FromUserName", body.FromUserName), zap.String("Content", body.Content))
|
||||
}
|
||||
if body.MsgType != "text" {
|
||||
c.XML(http.StatusOK, wecomReplyXML{
|
||||
ToUserName: body.FromUserName,
|
||||
FromUserName: body.ToUserName,
|
||||
CreateTime: time.Now().Unix(),
|
||||
MsgType: "text",
|
||||
Content: "暂仅支持文本消息,请发送文字。",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userID := body.FromUserName
|
||||
text := strings.TrimSpace(body.Content)
|
||||
reply := h.HandleMessage("wecom", userID, text)
|
||||
// 加密模式需加密回复(此处简化为明文回复;若企业要求加密需再实现加密)
|
||||
c.XML(http.StatusOK, wecomReplyXML{
|
||||
ToUserName: body.FromUserName,
|
||||
FromUserName: body.ToUserName,
|
||||
CreateTime: time.Now().Unix(),
|
||||
MsgType: "text",
|
||||
Content: reply,
|
||||
})
|
||||
|
||||
// 限制回复内容长度(企业微信限制 2048 字节)
|
||||
maxReplyLen := 2000
|
||||
limitReply := func(s string) string {
|
||||
if len(s) > maxReplyLen {
|
||||
return s[:maxReplyLen] + "\n\n(内容过长,已截断)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
if body.MsgType != "text" {
|
||||
h.logger.Debug("企业微信收到非文本消息", zap.String("MsgType", body.MsgType))
|
||||
h.sendWecomReply(c, userID, enterpriseID, limitReply("暂仅支持文本消息,请发送文字。"), timestamp, nonce)
|
||||
return
|
||||
}
|
||||
|
||||
// 文本消息:先判断是否为内置命令(如 帮助/列表/新对话 等),这类命令处理很快,可以直接走被动回复,避免依赖主动发送 API。
|
||||
if cmdReply, ok := h.handleRobotCommand("wecom", userID, text); ok {
|
||||
h.logger.Debug("企业微信收到命令消息,走被动回复", zap.String("userID", userID), zap.String("text", text))
|
||||
h.sendWecomReply(c, userID, enterpriseID, limitReply(cmdReply), timestamp, nonce)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("企业微信开始处理消息(异步 AI)", zap.String("userID", userID), zap.String("text", text))
|
||||
|
||||
// 企业微信被动回复有 5 秒超时限制,而 AI 调用通常超过该时长。
|
||||
// 这里采用推荐做法:立即返回 success(或空串),然后通过主动发送接口推送完整回复。
|
||||
c.String(http.StatusOK, "success")
|
||||
|
||||
// 异步处理消息并通过企业微信主动消息接口发送结果
|
||||
go func() {
|
||||
reply := h.HandleMessage("wecom", userID, text)
|
||||
reply = limitReply(reply)
|
||||
h.logger.Debug("企业微信消息处理完成", zap.String("userID", userID), zap.String("reply", reply))
|
||||
// 调用企业微信 API 主动发送消息
|
||||
h.sendWecomMessageViaAPI(userID, enterpriseID, reply)
|
||||
}()
|
||||
}
|
||||
|
||||
// sendWecomReply 发送企业微信回复(加密模式自动加密)
|
||||
// 参数:toUser=用户 ID, fromUser=企业 ID(明文模式)/CorpID(加密模式), content=回复内容,timestamp/nonce=请求参数
|
||||
func (h *RobotHandler) sendWecomReply(c *gin.Context, toUser, fromUser, content, timestamp, nonce string) {
|
||||
// 加密模式:判断 EncodingAESKey 是否配置
|
||||
if h.config.Robots.Wecom.EncodingAESKey != "" {
|
||||
// 加密模式使用 CorpID 进行加密
|
||||
corpID := h.config.Robots.Wecom.CorpID
|
||||
if corpID == "" {
|
||||
h.logger.Warn("企业微信加密模式缺少 CorpID 配置")
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
|
||||
// 构造完整的明文 XML 回复(格式严格按企业微信文档要求)
|
||||
plainResp := fmt.Sprintf(`<xml>
|
||||
<ToUserName><![CDATA[%s]]></ToUserName>
|
||||
<FromUserName><![CDATA[%s]]></FromUserName>
|
||||
<CreateTime>%d</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[%s]]></Content>
|
||||
</xml>`, toUser, fromUser, time.Now().Unix(), content)
|
||||
|
||||
encrypted, err := wecomEncrypt(h.config.Robots.Wecom.EncodingAESKey, plainResp, corpID)
|
||||
if err != nil {
|
||||
h.logger.Warn("企业微信回复加密失败", zap.Error(err))
|
||||
c.String(http.StatusOK, "")
|
||||
return
|
||||
}
|
||||
// 使用请求中的 timestamp/nonce 生成签名(企业微信要求回复时使用与请求相同的 timestamp 和 nonce)
|
||||
msgSignature := h.signWecomRequest(h.config.Robots.Wecom.Token, timestamp, nonce, encrypted)
|
||||
|
||||
h.logger.Debug("企业微信发送加密回复",
|
||||
zap.String("Encrypt", encrypted[:50]+"..."),
|
||||
zap.String("MsgSignature", msgSignature),
|
||||
zap.String("TimeStamp", timestamp),
|
||||
zap.String("Nonce", nonce))
|
||||
|
||||
// 加密模式仅返回 4 个核心字段(企业微信官方要求)
|
||||
xmlResp := fmt.Sprintf(`<xml><Encrypt><![CDATA[%s]]></Encrypt><MsgSignature><![CDATA[%s]]></MsgSignature><TimeStamp><![CDATA[%s]]></TimeStamp><Nonce><![CDATA[%s]]></Nonce></xml>`, encrypted, msgSignature, timestamp, nonce)
|
||||
// also log the final response body so we can cross-check with the
|
||||
// network traffic or developer console
|
||||
h.logger.Debug("企业微信加密回复包", zap.String("xml", xmlResp))
|
||||
// for additional confidence, decrypt the payload ourselves and log it
|
||||
if dec, err2 := wecomDecrypt(h.config.Robots.Wecom.EncodingAESKey, encrypted); err2 == nil {
|
||||
h.logger.Debug("企业微信加密回复解密检查", zap.String("plain", string(dec)))
|
||||
} else {
|
||||
h.logger.Warn("企业微信加密回复解密检查失败", zap.Error(err2))
|
||||
}
|
||||
|
||||
// 使用 c.Writer.Write 直接写入响应,避免 c.String 的转义问题
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
// use text/xml as that's what WeCom examples show
|
||||
c.Writer.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
_, _ = c.Writer.Write([]byte(xmlResp))
|
||||
h.logger.Debug("企业微信加密回复已发送")
|
||||
return
|
||||
}
|
||||
|
||||
// 明文模式
|
||||
h.logger.Debug("企业微信发送明文回复", zap.String("ToUserName", toUser), zap.String("FromUserName", fromUser), zap.String("Content", content[:50]+"..."))
|
||||
|
||||
// 手动构造 XML 响应(使用 CDATA 包裹所有字段,并包含 AgentID)
|
||||
xmlResp := fmt.Sprintf(`<xml>
|
||||
<ToUserName><![CDATA[%s]]></ToUserName>
|
||||
<FromUserName><![CDATA[%s]]></FromUserName>
|
||||
<CreateTime>%d</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[%s]]></Content>
|
||||
</xml>`, toUser, fromUser, time.Now().Unix(), content)
|
||||
|
||||
// log the exact plaintext response for debugging
|
||||
h.logger.Debug("企业微信明文回复包", zap.String("xml", xmlResp))
|
||||
|
||||
// use text/xml as recommended by WeCom docs
|
||||
c.Header("Content-Type", "text/xml; charset=utf-8")
|
||||
c.String(http.StatusOK, xmlResp)
|
||||
h.logger.Debug("企业微信明文回复已发送")
|
||||
}
|
||||
|
||||
// —————— 测试接口(需登录,用于验证机器人逻辑,无需钉钉/飞书客户端) ——————
|
||||
@@ -562,6 +785,87 @@ func (h *RobotHandler) HandleRobotTest(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"reply": reply})
|
||||
}
|
||||
|
||||
// sendWecomMessageViaAPI 通过企业微信 API 主动发送消息(用于异步处理后的结果发送)
|
||||
func (h *RobotHandler) sendWecomMessageViaAPI(toUser, toParty, content string) {
|
||||
if !h.config.Robots.Wecom.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
secret := h.config.Robots.Wecom.Secret
|
||||
corpID := h.config.Robots.Wecom.CorpID
|
||||
agentID := h.config.Robots.Wecom.AgentID
|
||||
|
||||
if secret == "" || corpID == "" {
|
||||
h.logger.Warn("企业微信主动 API 缺少 secret 或 corpID 配置")
|
||||
return
|
||||
}
|
||||
|
||||
// 第 1 步:获取 access_token
|
||||
tokenURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s", corpID, secret)
|
||||
resp, err := http.Get(tokenURL)
|
||||
if err != nil {
|
||||
h.logger.Warn("企业微信获取 token 失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
h.logger.Warn("企业微信 token 响应解析失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if tokenResp.ErrCode != 0 {
|
||||
h.logger.Warn("企业微信 token 获取错误", zap.String("errmsg", tokenResp.ErrMsg), zap.Int("errcode", tokenResp.ErrCode))
|
||||
return
|
||||
}
|
||||
|
||||
// 第 2 步:构造发送消息请求
|
||||
msgReq := map[string]interface{}{
|
||||
"touser": toUser,
|
||||
"msgtype": "text",
|
||||
"agentid": agentID,
|
||||
"text": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
|
||||
msgBody, err := json.Marshal(msgReq)
|
||||
if err != nil {
|
||||
h.logger.Warn("企业微信消息序列化失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 第 3 步:发送消息
|
||||
sendURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=%s", tokenResp.AccessToken)
|
||||
msgResp, err := http.Post(sendURL, "application/json", bytes.NewReader(msgBody))
|
||||
if err != nil {
|
||||
h.logger.Warn("企业微信主动发送消息失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer msgResp.Body.Close()
|
||||
|
||||
var sendResp struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
InvalidUser string `json:"invaliduser"`
|
||||
MsgID string `json:"msgid"`
|
||||
}
|
||||
if err := json.NewDecoder(msgResp.Body).Decode(&sendResp); err != nil {
|
||||
h.logger.Warn("企业微信发送响应解析失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if sendResp.ErrCode == 0 {
|
||||
h.logger.Debug("企业微信主动发送消息成功", zap.String("msgid", sendResp.MsgID))
|
||||
} else {
|
||||
h.logger.Warn("企业微信主动发送消息失败", zap.String("errmsg", sendResp.ErrMsg), zap.Int("errcode", sendResp.ErrCode), zap.String("invaliduser", sendResp.InvalidUser))
|
||||
}
|
||||
}
|
||||
|
||||
// —————— 钉钉 ——————
|
||||
|
||||
// HandleDingtalkPOST 钉钉事件回调(流式接入等);当前为占位,返回 200
|
||||
|
||||
@@ -19,6 +19,7 @@ type AgentTask struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
Status string `json:"status"`
|
||||
CancellingAt time.Time `json:"-"` // 进入 cancelling 状态的时间,用于清理长时间卡住的任务
|
||||
|
||||
cancel func(error)
|
||||
}
|
||||
@@ -41,13 +42,61 @@ type AgentTaskManager struct {
|
||||
historyRetention time.Duration // 历史记录保留时间
|
||||
}
|
||||
|
||||
const (
|
||||
// cancellingStuckThreshold 处于「取消中」超过此时长则强制从运行列表移除。正常取消会在当前步骤内返回,
|
||||
// 超过则视为卡住,尽快释放会话。常见做法多为 30–60s 内释放。
|
||||
cancellingStuckThreshold = 45 * time.Second
|
||||
// cancellingStuckThresholdLegacy 未记录 CancellingAt 时用 StartedAt 判断的兜底时长
|
||||
cancellingStuckThresholdLegacy = 2 * time.Minute
|
||||
cleanupInterval = 15 * time.Second // 与上面阈值配合,最长约 60s 内移除
|
||||
)
|
||||
|
||||
// NewAgentTaskManager 创建任务管理器
|
||||
func NewAgentTaskManager() *AgentTaskManager {
|
||||
return &AgentTaskManager{
|
||||
m := &AgentTaskManager{
|
||||
tasks: make(map[string]*AgentTask),
|
||||
completedTasks: make([]*CompletedTask, 0),
|
||||
maxHistorySize: 50, // 最多保留50条历史记录
|
||||
historyRetention: 24 * time.Hour, // 保留24小时
|
||||
maxHistorySize: 50, // 最多保留50条历史记录
|
||||
historyRetention: 24 * time.Hour, // 保留24小时
|
||||
}
|
||||
go m.runStuckCancellingCleanup()
|
||||
return m
|
||||
}
|
||||
|
||||
// runStuckCancellingCleanup 定期将长时间处于「取消中」的任务强制结束,避免卡住无法发新消息
|
||||
func (m *AgentTaskManager) runStuckCancellingCleanup() {
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
m.cleanupStuckCancelling()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AgentTaskManager) cleanupStuckCancelling() {
|
||||
m.mu.Lock()
|
||||
var toFinish []string
|
||||
now := time.Now()
|
||||
for id, task := range m.tasks {
|
||||
if task.Status != "cancelling" {
|
||||
continue
|
||||
}
|
||||
var elapsed time.Duration
|
||||
if !task.CancellingAt.IsZero() {
|
||||
elapsed = now.Sub(task.CancellingAt)
|
||||
if elapsed < cancellingStuckThreshold {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
elapsed = now.Sub(task.StartedAt)
|
||||
if elapsed < cancellingStuckThresholdLegacy {
|
||||
continue
|
||||
}
|
||||
}
|
||||
toFinish = append(toFinish, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, id := range toFinish {
|
||||
m.FinishTask(id, "cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +125,7 @@ func (m *AgentTaskManager) StartTask(conversationID, message string, cancel cont
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// CancelTask 取消指定会话的任务
|
||||
// CancelTask 取消指定会话的任务。若任务已在取消中,仍返回 (true, nil) 以便接口幂等、前端不报错。
|
||||
func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool, error) {
|
||||
m.mu.Lock()
|
||||
task, exists := m.tasks[conversationID]
|
||||
@@ -85,13 +134,14 @@ func (m *AgentTaskManager) CancelTask(conversationID string, cause error) (bool,
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 如果已经处于取消流程,直接返回
|
||||
// 如果已经处于取消流程,视为成功(幂等),避免前端重复点击报「未找到任务」
|
||||
if task.Status == "cancelling" {
|
||||
m.mu.Unlock()
|
||||
return false, nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
task.Status = "cancelling"
|
||||
task.CancellingAt = time.Now()
|
||||
cancel := task.cancel
|
||||
m.mu.Unlock()
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
terminalMaxCommandLen = 4096
|
||||
terminalMaxOutputLen = 256 * 1024 // 256KB
|
||||
terminalTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// TerminalHandler 处理系统设置中的终端命令执行
|
||||
type TerminalHandler struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// maskTerminalCommand 对可能包含敏感信息的终端命令做脱敏,避免在日志中直接记录密码等内容
|
||||
func maskTerminalCommand(cmd string) string {
|
||||
trimmed := strings.TrimSpace(cmd)
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.Contains(lower, "sudo") || strings.Contains(lower, "password") {
|
||||
return "[masked sensitive terminal command]"
|
||||
}
|
||||
if len(trimmed) > 256 {
|
||||
return trimmed[:256] + "..."
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// NewTerminalHandler 创建终端处理器
|
||||
func NewTerminalHandler(logger *zap.Logger) *TerminalHandler {
|
||||
return &TerminalHandler{logger: logger}
|
||||
}
|
||||
|
||||
// RunCommandRequest 执行命令请求
|
||||
type RunCommandRequest struct {
|
||||
Command string `json:"command"`
|
||||
Shell string `json:"shell,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
}
|
||||
|
||||
// RunCommandResponse 执行命令响应
|
||||
type RunCommandResponse struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RunCommand 执行终端命令(需登录)
|
||||
func (h *TerminalHandler) RunCommand(c *gin.Context) {
|
||||
var req RunCommandRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效,需要 command 字段"})
|
||||
return
|
||||
}
|
||||
|
||||
cmdStr := strings.TrimSpace(req.Command)
|
||||
if cmdStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "command 不能为空"})
|
||||
return
|
||||
}
|
||||
if len(cmdStr) > terminalMaxCommandLen {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "命令过长"})
|
||||
return
|
||||
}
|
||||
|
||||
shell := req.Shell
|
||||
if shell == "" {
|
||||
if runtime.GOOS == "windows" {
|
||||
shell = "cmd"
|
||||
} else {
|
||||
shell = "sh"
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), terminalTimeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.CommandContext(ctx, "cmd", "/c", cmdStr)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, shell, "-c", cmdStr)
|
||||
// 无 TTY 时设置 COLUMNS/TERM,使 ping 等工具的 usage 排版与真实终端一致
|
||||
cmd.Env = append(os.Environ(), "COLUMNS=256", "LINES=40", "TERM=xterm-256color")
|
||||
}
|
||||
|
||||
if req.Cwd != "" {
|
||||
absCwd, err := filepath.Abs(req.Cwd)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录无效"})
|
||||
return
|
||||
}
|
||||
cur, _ := os.Getwd()
|
||||
curAbs, _ := filepath.Abs(cur)
|
||||
rel, err := filepath.Rel(curAbs, absCwd)
|
||||
if err != nil || strings.HasPrefix(rel, "..") || rel == ".." {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录必须在当前进程目录下"})
|
||||
return
|
||||
}
|
||||
cmd.Dir = absCwd
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
stdoutBytes := stdout.Bytes()
|
||||
stderrBytes := stderr.Bytes()
|
||||
|
||||
// 限制输出长度,防止内存占用过大(复制后截断,避免修改原 buffer)
|
||||
truncSuffix := []byte("\n...(输出已截断)\n")
|
||||
if len(stdoutBytes) > terminalMaxOutputLen {
|
||||
tmp := make([]byte, terminalMaxOutputLen+len(truncSuffix))
|
||||
n := copy(tmp, stdoutBytes[:terminalMaxOutputLen])
|
||||
copy(tmp[n:], truncSuffix)
|
||||
stdoutBytes = tmp
|
||||
}
|
||||
if len(stderrBytes) > terminalMaxOutputLen {
|
||||
tmp := make([]byte, terminalMaxOutputLen+len(truncSuffix))
|
||||
n := copy(tmp, stderrBytes[:terminalMaxOutputLen])
|
||||
copy(tmp[n:], truncSuffix)
|
||||
stderrBytes = tmp
|
||||
}
|
||||
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
exitCode = -1
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
so := strings.ReplaceAll(string(stdoutBytes), "\r\n", "\n")
|
||||
so = strings.ReplaceAll(so, "\r", "\n")
|
||||
se := strings.ReplaceAll(string(stderrBytes), "\r\n", "\n")
|
||||
se = strings.ReplaceAll(se, "\r", "\n")
|
||||
resp := RunCommandResponse{
|
||||
Stdout: so,
|
||||
Stderr: se,
|
||||
ExitCode: -1,
|
||||
Error: "命令执行超时(" + terminalTimeout.String() + ")",
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
h.logger.Debug("终端命令执行异常", zap.String("command", maskTerminalCommand(cmdStr)), zap.Error(err))
|
||||
}
|
||||
|
||||
// 统一为 \n,避免前端因 \r 出现错位/对角线排版
|
||||
stdoutStr := strings.ReplaceAll(string(stdoutBytes), "\r\n", "\n")
|
||||
stdoutStr = strings.ReplaceAll(stdoutStr, "\r", "\n")
|
||||
stderrStr := strings.ReplaceAll(string(stderrBytes), "\r\n", "\n")
|
||||
stderrStr = strings.ReplaceAll(stderrStr, "\r", "\n")
|
||||
|
||||
resp := RunCommandResponse{
|
||||
Stdout: stdoutStr,
|
||||
Stderr: stderrStr,
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
if err != nil && exitCode != 0 {
|
||||
resp.Error = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// streamEvent SSE 事件
|
||||
type streamEvent struct {
|
||||
T string `json:"t"` // "out" | "err" | "exit"
|
||||
D string `json:"d,omitempty"`
|
||||
C int `json:"c"` // exit code(不用 omitempty,否则 0 不序列化导致前端显示 [exit undefined])
|
||||
}
|
||||
|
||||
// RunCommandStream 流式执行命令,输出实时推送到前端(SSE)
|
||||
func (h *TerminalHandler) RunCommandStream(c *gin.Context) {
|
||||
var req RunCommandRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效,需要 command 字段"})
|
||||
return
|
||||
}
|
||||
cmdStr := strings.TrimSpace(req.Command)
|
||||
if cmdStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "command 不能为空"})
|
||||
return
|
||||
}
|
||||
if len(cmdStr) > terminalMaxCommandLen {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "命令过长"})
|
||||
return
|
||||
}
|
||||
shell := req.Shell
|
||||
if shell == "" {
|
||||
if runtime.GOOS == "windows" {
|
||||
shell = "cmd"
|
||||
} else {
|
||||
shell = "sh"
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), terminalTimeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.CommandContext(ctx, "cmd", "/c", cmdStr)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, shell, "-c", cmdStr)
|
||||
cmd.Env = append(os.Environ(), "COLUMNS=256", "LINES=40", "TERM=xterm-256color")
|
||||
}
|
||||
if req.Cwd != "" {
|
||||
absCwd, err := filepath.Abs(req.Cwd)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录无效"})
|
||||
return
|
||||
}
|
||||
cur, _ := os.Getwd()
|
||||
curAbs, _ := filepath.Abs(cur)
|
||||
rel, err := filepath.Rel(curAbs, absCwd)
|
||||
if err != nil || strings.HasPrefix(rel, "..") || rel == ".." {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录必须在当前进程目录下"})
|
||||
return
|
||||
}
|
||||
cmd.Dir = absCwd
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent := func(ev streamEvent) {
|
||||
body, _ := json.Marshal(ev)
|
||||
c.SSEvent("", string(body))
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
runCommandStreamImpl(cmd, sendEvent, ctx)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build !windows
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
const ptyCols = 256
|
||||
const ptyRows = 40
|
||||
|
||||
// runCommandStreamImpl 在 Unix 下用 PTY 执行,使 ping 等命令按终端宽度排版(isatty 为真)
|
||||
func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) {
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows})
|
||||
if err != nil {
|
||||
sendEvent(streamEvent{T: "exit", C: -1})
|
||||
return
|
||||
}
|
||||
defer ptmx.Close()
|
||||
|
||||
normalize := func(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
return strings.ReplaceAll(s, "\r", "\n")
|
||||
}
|
||||
sc := bufio.NewScanner(ptmx)
|
||||
for sc.Scan() {
|
||||
sendEvent(streamEvent{T: "out", D: normalize(sc.Text())})
|
||||
}
|
||||
exitCode := 0
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
exitCode = -1
|
||||
}
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
exitCode = -1
|
||||
}
|
||||
sendEvent(streamEvent{T: "exit", C: exitCode})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//go:build windows
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// runCommandStreamImpl 在 Windows 下用 stdout/stderr 管道执行
|
||||
func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
sendEvent(streamEvent{T: "exit", C: -1})
|
||||
return
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
sendEvent(streamEvent{T: "exit", C: -1})
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
sendEvent(streamEvent{T: "exit", C: -1})
|
||||
return
|
||||
}
|
||||
|
||||
normalize := func(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
return strings.ReplaceAll(s, "\r", "\n")
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sc := bufio.NewScanner(stdoutPipe)
|
||||
for sc.Scan() {
|
||||
sendEvent(streamEvent{T: "out", D: normalize(sc.Text())})
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sc := bufio.NewScanner(stderrPipe)
|
||||
for sc.Scan() {
|
||||
sendEvent(streamEvent{T: "err", D: normalize(sc.Text())})
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
exitCode := 0
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
exitCode = -1
|
||||
}
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
exitCode = -1
|
||||
}
|
||||
sendEvent(streamEvent{T: "exit", C: exitCode})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//go:build !windows
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// wsUpgrader 仅用于系统设置中的终端 WebSocket,会复用已有的登录保护(JWT 中间件在上层路由组)
|
||||
var wsUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
// 由于已在 Gin 路由层做了认证,这里放宽 Origin,方便在同一域名下通过 HTTPS/WSS 访问
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// RunCommandWS 提供真正交互式 Shell:基于 WebSocket + PTY 的长会话
|
||||
// 前端建立 WebSocket 连接后,所有键盘输入都会透传到 Shell,Shell 的输出也会实时写回前端。
|
||||
func (h *TerminalHandler) RunCommandWS(c *gin.Context) {
|
||||
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 启动交互式 Shell,这里优先使用 bash,找不到则退回 sh
|
||||
shell := "bash"
|
||||
if _, err := exec.LookPath(shell); err != nil {
|
||||
shell = "sh"
|
||||
}
|
||||
cmd := exec.Command(shell)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"COLUMNS=256",
|
||||
"LINES=40",
|
||||
"TERM=xterm-256color",
|
||||
)
|
||||
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer ptmx.Close()
|
||||
|
||||
// Shell -> WebSocket:将 PTY 输出实时发给前端
|
||||
doneChan := make(chan struct{})
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := ptmx.Read(buf)
|
||||
if n > 0 {
|
||||
_ = conn.WriteMessage(websocket.BinaryMessage, buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
close(doneChan)
|
||||
}()
|
||||
|
||||
// WebSocket -> Shell:将前端输入写入 PTY(包括 sudo 密码、Ctrl+C 等)
|
||||
conn.SetReadLimit(64 * 1024)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(terminalTimeout))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(terminalTimeout))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
msgType, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
break
|
||||
}
|
||||
if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := ptmx.Write(data); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
<-doneChan
|
||||
}
|
||||
|
||||
+149
-31
@@ -6,39 +6,75 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Embedder 文本嵌入器
|
||||
type Embedder struct {
|
||||
openAIClient *openai.Client
|
||||
config *config.KnowledgeConfig
|
||||
openAIConfig *config.OpenAIConfig // 用于获取API Key
|
||||
logger *zap.Logger
|
||||
openAIClient *openai.Client
|
||||
config *config.KnowledgeConfig
|
||||
openAIConfig *config.OpenAIConfig // 用于获取 API Key
|
||||
logger *zap.Logger
|
||||
rateLimiter *rate.Limiter // 速率限制器
|
||||
rateLimitDelay time.Duration // 请求间隔时间
|
||||
maxRetries int // 最大重试次数
|
||||
retryDelay time.Duration // 重试间隔
|
||||
mu sync.Mutex // 保护 rateLimiter
|
||||
}
|
||||
|
||||
// NewEmbedder 创建新的嵌入器
|
||||
func NewEmbedder(cfg *config.KnowledgeConfig, openAIConfig *config.OpenAIConfig, openAIClient *openai.Client, logger *zap.Logger) *Embedder {
|
||||
// 初始化速率限制器
|
||||
var rateLimiter *rate.Limiter
|
||||
var rateLimitDelay time.Duration
|
||||
|
||||
// 如果配置了 MaxRPM,根据 RPM 计算速率限制
|
||||
if cfg.Indexing.MaxRPM > 0 {
|
||||
rpm := cfg.Indexing.MaxRPM
|
||||
rateLimiter = rate.NewLimiter(rate.Every(time.Minute/time.Duration(rpm)), rpm)
|
||||
logger.Info("知识库索引速率限制已启用", zap.Int("maxRPM", rpm))
|
||||
} else if cfg.Indexing.RateLimitDelayMs > 0 {
|
||||
// 如果没有配置 MaxRPM 但配置了固定延迟,使用固定延迟模式
|
||||
rateLimitDelay = time.Duration(cfg.Indexing.RateLimitDelayMs) * time.Millisecond
|
||||
logger.Info("知识库索引固定延迟已启用", zap.Duration("delay", rateLimitDelay))
|
||||
}
|
||||
|
||||
// 重试配置
|
||||
maxRetries := 3
|
||||
retryDelay := 1000 * time.Millisecond
|
||||
if cfg.Indexing.MaxRetries > 0 {
|
||||
maxRetries = cfg.Indexing.MaxRetries
|
||||
}
|
||||
if cfg.Indexing.RetryDelayMs > 0 {
|
||||
retryDelay = time.Duration(cfg.Indexing.RetryDelayMs) * time.Millisecond
|
||||
}
|
||||
|
||||
return &Embedder{
|
||||
openAIClient: openAIClient,
|
||||
config: cfg,
|
||||
openAIConfig: openAIConfig,
|
||||
logger: logger,
|
||||
openAIClient: openAIClient,
|
||||
config: cfg,
|
||||
openAIConfig: openAIConfig,
|
||||
logger: logger,
|
||||
rateLimiter: rateLimiter,
|
||||
rateLimitDelay: rateLimitDelay,
|
||||
maxRetries: maxRetries,
|
||||
retryDelay: retryDelay,
|
||||
}
|
||||
}
|
||||
|
||||
// EmbeddingRequest OpenAI嵌入请求
|
||||
// EmbeddingRequest OpenAI 嵌入请求
|
||||
type EmbeddingRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
|
||||
// EmbeddingResponse OpenAI嵌入响应
|
||||
// EmbeddingResponse OpenAI 嵌入响应
|
||||
type EmbeddingResponse struct {
|
||||
Data []EmbeddingData `json:"data"`
|
||||
Error *EmbeddingError `json:"error,omitempty"`
|
||||
@@ -56,12 +92,69 @@ type EmbeddingError struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// EmbedText 对文本进行嵌入
|
||||
func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error) {
|
||||
if e.openAIClient == nil {
|
||||
return nil, fmt.Errorf("OpenAI客户端未初始化")
|
||||
// waitRateLimiter 等待速率限制器
|
||||
func (e *Embedder) waitRateLimiter() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if e.rateLimiter != nil {
|
||||
// 等待令牌
|
||||
ctx := context.Background()
|
||||
if err := e.rateLimiter.Wait(ctx); err != nil {
|
||||
e.logger.Warn("速率限制器等待失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if e.rateLimitDelay > 0 {
|
||||
time.Sleep(e.rateLimitDelay)
|
||||
}
|
||||
}
|
||||
|
||||
// EmbedText 对文本进行嵌入(带重试和速率限制)
|
||||
func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error) {
|
||||
if e.openAIClient == nil {
|
||||
return nil, fmt.Errorf("OpenAI 客户端未初始化")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < e.maxRetries; attempt++ {
|
||||
// 速率限制
|
||||
if attempt > 0 {
|
||||
// 重试时等待更长时间
|
||||
waitTime := e.retryDelay * time.Duration(attempt)
|
||||
e.logger.Debug("重试前等待", zap.Int("attempt", attempt+1), zap.Duration("waitTime", waitTime))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(waitTime):
|
||||
}
|
||||
} else {
|
||||
e.waitRateLimiter()
|
||||
}
|
||||
|
||||
result, err := e.doEmbedText(ctx, text)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// 检查是否是可重试的错误(429 速率限制、5xx 服务器错误、网络错误)
|
||||
if !e.isRetryableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.logger.Debug("嵌入请求失败,准备重试",
|
||||
zap.Int("attempt", attempt+1),
|
||||
zap.Int("maxRetries", e.maxRetries),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("达到最大重试次数 (%d): %v", e.maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// doEmbedText 执行实际的嵌入请求(内部方法)
|
||||
func (e *Embedder) doEmbedText(ctx context.Context, text string) ([]float32, error) {
|
||||
// 使用配置的嵌入模型
|
||||
model := e.config.Embedding.Model
|
||||
if model == "" {
|
||||
@@ -73,7 +166,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
Input: []string{text},
|
||||
}
|
||||
|
||||
// 清理baseURL:去除前后空格和尾部斜杠
|
||||
// 清理 baseURL:去除前后空格和尾部斜杠
|
||||
baseURL := strings.TrimSpace(e.config.Embedding.BaseURL)
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
if baseURL == "" {
|
||||
@@ -83,24 +176,24 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
// 构建请求
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
return nil, fmt.Errorf("序列化请求失败:%w", err)
|
||||
}
|
||||
|
||||
requestURL := baseURL + "/embeddings"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
return nil, fmt.Errorf("创建请求失败:%w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 使用配置的API Key,如果没有则使用OpenAI配置的
|
||||
|
||||
// 使用配置的 API Key,如果没有则使用 OpenAI 配置的
|
||||
apiKey := strings.TrimSpace(e.config.Embedding.APIKey)
|
||||
if apiKey == "" && e.openAIConfig != nil {
|
||||
apiKey = e.openAIConfig.APIKey
|
||||
}
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("API Key未配置")
|
||||
return nil, fmt.Errorf("API Key 未配置")
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
@@ -110,7 +203,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
}
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求失败: %w", err)
|
||||
return nil, fmt.Errorf("发送请求失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -132,7 +225,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
if len(requestBodyPreview) > 200 {
|
||||
requestBodyPreview = requestBodyPreview[:200] + "..."
|
||||
}
|
||||
e.logger.Debug("嵌入API请求",
|
||||
e.logger.Debug("嵌入 API 请求",
|
||||
zap.String("url", httpReq.URL.String()),
|
||||
zap.String("model", model),
|
||||
zap.String("requestBody", requestBodyPreview),
|
||||
@@ -148,12 +241,12 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
if len(bodyPreview) > 500 {
|
||||
bodyPreview = bodyPreview[:500] + "..."
|
||||
}
|
||||
return nil, fmt.Errorf("解析响应失败 (URL: %s, 状态码: %d, 响应长度: %d字节): %w\n请求体: %s\n响应内容预览: %s",
|
||||
return nil, fmt.Errorf("解析响应失败 (URL: %s, 状态码:%d, 响应长度:%d字节): %w\n请求体:%s\n响应内容预览:%s",
|
||||
requestURL, resp.StatusCode, len(bodyBytes), err, requestBodyPreview, bodyPreview)
|
||||
}
|
||||
|
||||
if embeddingResp.Error != nil {
|
||||
return nil, fmt.Errorf("OpenAI API错误 (状态码: %d): 类型=%s, 消息=%s",
|
||||
return nil, fmt.Errorf("OpenAI API 错误 (状态码:%d): 类型=%s, 消息=%s",
|
||||
resp.StatusCode, embeddingResp.Error.Type, embeddingResp.Error.Message)
|
||||
}
|
||||
|
||||
@@ -162,7 +255,7 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
if len(bodyPreview) > 500 {
|
||||
bodyPreview = bodyPreview[:500] + "..."
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP请求失败 (URL: %s, 状态码: %d): 响应内容=%s", requestURL, resp.StatusCode, bodyPreview)
|
||||
return nil, fmt.Errorf("HTTP 请求失败 (URL: %s, 状态码:%d): 响应内容=%s", requestURL, resp.StatusCode, bodyPreview)
|
||||
}
|
||||
|
||||
if len(embeddingResp.Data) == 0 {
|
||||
@@ -170,11 +263,11 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
if len(bodyPreview) > 500 {
|
||||
bodyPreview = bodyPreview[:500] + "..."
|
||||
}
|
||||
return nil, fmt.Errorf("未收到嵌入数据 (状态码: %d, 响应长度: %d字节)\n响应内容: %s",
|
||||
return nil, fmt.Errorf("未收到嵌入数据 (状态码:%d, 响应长度:%d字节)\n响应内容:%s",
|
||||
resp.StatusCode, len(bodyBytes), bodyPreview)
|
||||
}
|
||||
|
||||
// 转换为float32
|
||||
// 转换为 float32
|
||||
embedding := make([]float32, len(embeddingResp.Data[0].Embedding))
|
||||
for i, v := range embeddingResp.Data[0].Embedding {
|
||||
embedding[i] = float32(v)
|
||||
@@ -183,23 +276,48 @@ func (e *Embedder) EmbedText(ctx context.Context, text string) ([]float32, error
|
||||
return embedding, nil
|
||||
}
|
||||
|
||||
// isRetryableError 判断是否是可重试的错误
|
||||
func (e *Embedder) isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// 429 速率限制错误
|
||||
if strings.Contains(errStr, "429") || strings.Contains(errStr, "rate limit") {
|
||||
return true
|
||||
}
|
||||
|
||||
// 5xx 服务器错误
|
||||
if strings.Contains(errStr, "500") || strings.Contains(errStr, "502") ||
|
||||
strings.Contains(errStr, "503") || strings.Contains(errStr, "504") {
|
||||
return true
|
||||
}
|
||||
|
||||
// 网络错误
|
||||
if strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection") ||
|
||||
strings.Contains(errStr, "network") || strings.Contains(errStr, "EOF") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// EmbedTexts 批量嵌入文本
|
||||
func (e *Embedder) EmbedTexts(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
if len(texts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// OpenAI API支持批量,但为了简单起见,我们逐个处理
|
||||
// 实际可以使用批量API以提高效率
|
||||
embeddings := make([][]float32, len(texts))
|
||||
for i, text := range texts {
|
||||
embedding, err := e.EmbedText(ctx, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("嵌入文本[%d]失败: %w", i, err)
|
||||
return nil, fmt.Errorf("嵌入文本 [%d] 失败:%w", i, err)
|
||||
}
|
||||
embeddings[i] = embedding
|
||||
}
|
||||
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
|
||||
+382
-98
@@ -10,56 +10,133 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Indexer 索引器,负责将知识项分块并向量化
|
||||
type Indexer struct {
|
||||
db *sql.DB
|
||||
embedder *Embedder
|
||||
logger *zap.Logger
|
||||
chunkSize int // 每个块的最大token数(估算)
|
||||
overlap int // 块之间的重叠token数
|
||||
|
||||
db *sql.DB
|
||||
embedder *Embedder
|
||||
logger *zap.Logger
|
||||
chunkSize int // 每个块的最大 token 数(估算)
|
||||
overlap int // 块之间的重叠 token 数
|
||||
maxChunks int // 单个知识项的最大块数量(0 表示不限制)
|
||||
|
||||
// 错误跟踪
|
||||
mu sync.RWMutex
|
||||
lastError string // 最近一次错误信息
|
||||
mu sync.RWMutex
|
||||
lastError string // 最近一次错误信息
|
||||
lastErrorTime time.Time // 最近一次错误时间
|
||||
errorCount int // 连续错误计数
|
||||
errorCount int // 连续错误计数
|
||||
|
||||
// 重建索引状态跟踪
|
||||
rebuildMu sync.RWMutex
|
||||
isRebuilding bool // 是否正在重建索引
|
||||
rebuildTotalItems int // 重建总项数
|
||||
rebuildCurrent int // 当前已处理项数
|
||||
rebuildFailed int // 重建失败项数
|
||||
rebuildStartTime time.Time // 重建开始时间
|
||||
rebuildLastItemID string // 最近处理的项 ID
|
||||
rebuildLastChunks int // 最近处理的项的分块数
|
||||
}
|
||||
|
||||
// NewIndexer 创建新的索引器
|
||||
func NewIndexer(db *sql.DB, embedder *Embedder, logger *zap.Logger) *Indexer {
|
||||
func NewIndexer(db *sql.DB, embedder *Embedder, logger *zap.Logger, indexingCfg *config.IndexingConfig) *Indexer {
|
||||
chunkSize := 512
|
||||
overlap := 50
|
||||
maxChunks := 0
|
||||
if indexingCfg != nil {
|
||||
if indexingCfg.ChunkSize > 0 {
|
||||
chunkSize = indexingCfg.ChunkSize
|
||||
}
|
||||
if indexingCfg.ChunkOverlap >= 0 {
|
||||
overlap = indexingCfg.ChunkOverlap
|
||||
}
|
||||
if indexingCfg.MaxChunksPerItem > 0 {
|
||||
maxChunks = indexingCfg.MaxChunksPerItem
|
||||
}
|
||||
}
|
||||
return &Indexer{
|
||||
db: db,
|
||||
embedder: embedder,
|
||||
logger: logger,
|
||||
chunkSize: 512, // 默认512 tokens
|
||||
overlap: 50, // 默认50 tokens重叠
|
||||
chunkSize: chunkSize,
|
||||
overlap: overlap,
|
||||
maxChunks: maxChunks,
|
||||
}
|
||||
}
|
||||
|
||||
// ChunkText 将文本分块(支持重叠)
|
||||
// ChunkText 将文本分块(支持重叠,保留标题上下文)
|
||||
func (idx *Indexer) ChunkText(text string) []string {
|
||||
// 按Markdown标题分割
|
||||
chunks := idx.splitByMarkdownHeaders(text)
|
||||
// 按 Markdown 标题分割,获取带标题的块
|
||||
sections := idx.splitByMarkdownHeadersWithContent(text)
|
||||
|
||||
// 如果块太大,进一步分割
|
||||
// 处理每个块
|
||||
result := make([]string, 0)
|
||||
for _, chunk := range chunks {
|
||||
if idx.estimateTokens(chunk) <= idx.chunkSize {
|
||||
result = append(result, chunk)
|
||||
for _, section := range sections {
|
||||
// 构建父级标题路径(不包含最后一级标题,因为内容中已经包含)
|
||||
// 例如:["# A", "## B", "### C"] -> "[# A > ## B]"
|
||||
var parentHeaderPath string
|
||||
if len(section.HeaderPath) > 1 {
|
||||
parentHeaderPath = strings.Join(section.HeaderPath[:len(section.HeaderPath)-1], " > ")
|
||||
}
|
||||
|
||||
// 提取内容的第一行作为标题(如 "# Prompt Injection")
|
||||
firstLine, remainingContent := extractFirstLine(section.Content)
|
||||
|
||||
// 如果剩余内容为空或只有空白,说明这个块只有标题没有正文,跳过
|
||||
if strings.TrimSpace(remainingContent) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果块太大,进一步分割
|
||||
if idx.estimateTokens(section.Content) <= idx.chunkSize {
|
||||
// 块大小合适,添加父级标题前缀
|
||||
if parentHeaderPath != "" {
|
||||
result = append(result, fmt.Sprintf("[%s] %s", parentHeaderPath, section.Content))
|
||||
} else {
|
||||
result = append(result, section.Content)
|
||||
}
|
||||
} else {
|
||||
// 按段落分割
|
||||
subChunks := idx.splitByParagraphs(chunk)
|
||||
for _, subChunk := range subChunks {
|
||||
if idx.estimateTokens(subChunk) <= idx.chunkSize {
|
||||
result = append(result, subChunk)
|
||||
} else {
|
||||
// 按句子分割(支持重叠)
|
||||
chunksWithOverlap := idx.splitBySentencesWithOverlap(subChunk)
|
||||
result = append(result, chunksWithOverlap...)
|
||||
// 块太大,按子标题或段落分割,保持标题上下文
|
||||
// 首先尝试按子标题分割(保留子标题结构)
|
||||
subSections := idx.splitBySubHeaders(section.Content, firstLine, parentHeaderPath)
|
||||
if len(subSections) > 1 {
|
||||
// 成功按子标题分割,递归处理每个子块
|
||||
for _, sub := range subSections {
|
||||
if idx.estimateTokens(sub) <= idx.chunkSize {
|
||||
result = append(result, sub)
|
||||
} else {
|
||||
// 子块仍然太大,按段落分割(保留标题前缀)
|
||||
paragraphs := idx.splitByParagraphsWithHeader(sub, parentHeaderPath)
|
||||
for _, para := range paragraphs {
|
||||
if idx.estimateTokens(para) <= idx.chunkSize {
|
||||
result = append(result, para)
|
||||
} else {
|
||||
// 段落仍太大,按句子分割
|
||||
sentenceChunks := idx.splitBySentencesWithOverlap(para)
|
||||
for _, chunk := range sentenceChunks {
|
||||
result = append(result, chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有子标题,按段落分割(保留标题前缀)
|
||||
paragraphs := idx.splitByParagraphsWithHeader(section.Content, parentHeaderPath)
|
||||
for _, para := range paragraphs {
|
||||
if idx.estimateTokens(para) <= idx.chunkSize {
|
||||
result = append(result, para)
|
||||
} else {
|
||||
// 段落仍太大,按句子分割
|
||||
sentenceChunks := idx.splitBySentencesWithOverlap(para)
|
||||
for _, chunk := range sentenceChunks {
|
||||
result = append(result, chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,43 +145,183 @@ func (idx *Indexer) ChunkText(text string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// splitByMarkdownHeaders 按Markdown标题分割
|
||||
func (idx *Indexer) splitByMarkdownHeaders(text string) []string {
|
||||
// 匹配Markdown标题 (# ## ### 等)
|
||||
// extractFirstLine 提取第一行内容和剩余内容
|
||||
func extractFirstLine(content string) (firstLine, remaining string) {
|
||||
lines := strings.SplitN(content, "\n", 2)
|
||||
if len(lines) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
if len(lines) == 1 {
|
||||
return lines[0], ""
|
||||
}
|
||||
return lines[0], lines[1]
|
||||
}
|
||||
|
||||
// splitBySubHeaders 尝试按子标题分割内容(用于处理大块内容)
|
||||
// headerPrefix 是父级标题路径,用于添加到每个子块
|
||||
func (idx *Indexer) splitBySubHeaders(content, headerPrefix, parentPath string) []string {
|
||||
// 匹配 Markdown 子标题(## 及以上)
|
||||
subHeaderRegex := regexp.MustCompile(`(?m)^#{2,6}\s+.+$`)
|
||||
matches := subHeaderRegex.FindAllStringIndex(content, -1)
|
||||
|
||||
if len(matches) == 0 {
|
||||
// 没有子标题,返回原始内容
|
||||
return []string{content}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(matches))
|
||||
for i, match := range matches {
|
||||
start := match[0]
|
||||
nextStart := len(content)
|
||||
if i+1 < len(matches) {
|
||||
nextStart = matches[i+1][0]
|
||||
}
|
||||
|
||||
subContent := strings.TrimSpace(content[start:nextStart])
|
||||
|
||||
// 添加父级路径前缀
|
||||
if parentPath != "" {
|
||||
result = append(result, fmt.Sprintf("[%s] %s", parentPath, subContent))
|
||||
} else {
|
||||
result = append(result, subContent)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// splitByParagraphsWithHeader 按段落分割,每个段落添加标题前缀(用于保持上下文)
|
||||
func (idx *Indexer) splitByParagraphsWithHeader(content, parentPath string) []string {
|
||||
// 提取第一行作为标题
|
||||
firstLine, _ := extractFirstLine(content)
|
||||
|
||||
paragraphs := strings.Split(content, "\n\n")
|
||||
result := make([]string, 0)
|
||||
|
||||
for i, p := range paragraphs {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 过滤掉只有标题的段落(没有实际内容)
|
||||
if strings.TrimSpace(trimmed) == strings.TrimSpace(firstLine) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 第一个段落已经包含标题,不需要重复添加
|
||||
if i == 0 && strings.Contains(trimmed, firstLine) {
|
||||
if parentPath != "" {
|
||||
result = append(result, fmt.Sprintf("[%s] %s", parentPath, trimmed))
|
||||
} else {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
} else {
|
||||
// 其他段落添加标题前缀以保持上下文
|
||||
if parentPath != "" {
|
||||
result = append(result, fmt.Sprintf("[%s] %s\n%s", parentPath, firstLine, trimmed))
|
||||
} else {
|
||||
result = append(result, fmt.Sprintf("%s\n%s", firstLine, trimmed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Section 表示一个带标题路径的文本块
|
||||
type Section struct {
|
||||
HeaderPath []string // 标题路径(如 ["# SQL 注入", "## 检测方法"])
|
||||
Content string // 块内容
|
||||
}
|
||||
|
||||
// splitByMarkdownHeadersWithContent 按 Markdown 标题分割,返回带标题路径的块
|
||||
// 每个块的内容包含自己的标题,用于向量化检索
|
||||
//
|
||||
// 例如,对于以下 Markdown:
|
||||
// # Prompt Injection
|
||||
// 引言内容
|
||||
// ## Summary
|
||||
// 目录内容
|
||||
//
|
||||
// 返回:
|
||||
// [{HeaderPath: ["# Prompt Injection"], Content: "# Prompt Injection\n引言内容"},
|
||||
// {HeaderPath: ["# Prompt Injection", "## Summary"], Content: "## Summary\n目录内容"}]
|
||||
func (idx *Indexer) splitByMarkdownHeadersWithContent(text string) []Section {
|
||||
// 匹配 Markdown 标题 (# ## ### 等)
|
||||
headerRegex := regexp.MustCompile(`(?m)^#{1,6}\s+.+$`)
|
||||
|
||||
// 找到所有标题位置
|
||||
matches := headerRegex.FindAllStringIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return []string{text}
|
||||
// 没有标题,返回整个文本
|
||||
return []Section{{HeaderPath: []string{}, Content: text}}
|
||||
}
|
||||
|
||||
chunks := make([]string, 0)
|
||||
lastPos := 0
|
||||
sections := make([]Section, 0, len(matches))
|
||||
currentHeaderPath := []string{}
|
||||
|
||||
for _, match := range matches {
|
||||
for i, match := range matches {
|
||||
start := match[0]
|
||||
if start > lastPos {
|
||||
chunks = append(chunks, strings.TrimSpace(text[lastPos:start]))
|
||||
}
|
||||
lastPos = start
|
||||
}
|
||||
end := match[1]
|
||||
nextStart := len(text)
|
||||
|
||||
// 添加最后一部分
|
||||
if lastPos < len(text) {
|
||||
chunks = append(chunks, strings.TrimSpace(text[lastPos:]))
|
||||
// 找到下一个标题的位置
|
||||
if i+1 < len(matches) {
|
||||
nextStart = matches[i+1][0]
|
||||
}
|
||||
|
||||
// 提取当前标题
|
||||
headerLine := strings.TrimSpace(text[start:end])
|
||||
|
||||
// 计算标题层级(# 的数量)
|
||||
level := 0
|
||||
for _, ch := range headerLine {
|
||||
if ch == '#' {
|
||||
level++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 更新标题路径:移除比当前层级深或等于的子标题,然后添加当前标题
|
||||
newPath := make([]string, 0, len(currentHeaderPath)+1)
|
||||
for _, h := range currentHeaderPath {
|
||||
hLevel := 0
|
||||
for _, ch := range h {
|
||||
if ch == '#' {
|
||||
hLevel++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if hLevel < level {
|
||||
newPath = append(newPath, h)
|
||||
}
|
||||
}
|
||||
newPath = append(newPath, headerLine)
|
||||
currentHeaderPath = newPath
|
||||
|
||||
// 提取当前标题到下一个标题之间的内容(包含当前标题)
|
||||
content := strings.TrimSpace(text[start:nextStart])
|
||||
|
||||
// 创建块,使用当前标题路径(包含当前标题)
|
||||
sections = append(sections, Section{
|
||||
HeaderPath: append([]string(nil), currentHeaderPath...),
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
// 过滤空块
|
||||
result := make([]string, 0)
|
||||
for _, chunk := range chunks {
|
||||
if strings.TrimSpace(chunk) != "" {
|
||||
result = append(result, chunk)
|
||||
result := make([]Section, 0, len(sections))
|
||||
for _, section := range sections {
|
||||
if strings.TrimSpace(section.Content) != "" {
|
||||
result = append(result, section)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return []string{text}
|
||||
return []Section{{HeaderPath: []string{}, Content: text}}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -124,8 +341,12 @@ func (idx *Indexer) splitByParagraphs(text string) []string {
|
||||
|
||||
// splitBySentences 按句子分割(用于内部,不包含重叠逻辑)
|
||||
func (idx *Indexer) splitBySentences(text string) []string {
|
||||
// 简单的句子分割(按句号、问号、感叹号)
|
||||
sentenceRegex := regexp.MustCompile(`[.!?]+\s+`)
|
||||
// 简单的句子分割(按句号、问号、感叹号,支持中英文)
|
||||
// . ! ? = 英文标点
|
||||
// \u3002 = 。(中文句号)
|
||||
// \uFF01 = !(中文叹号)
|
||||
// \uFF1F = ?(中文问号)
|
||||
sentenceRegex := regexp.MustCompile(`[.!?\x{3002}\x{FF01}\x{FF1F}]+`)
|
||||
sentences := sentenceRegex.Split(text, -1)
|
||||
result := make([]string, 0)
|
||||
for _, s := range sentences {
|
||||
@@ -221,13 +442,13 @@ func (idx *Indexer) splitBySentencesSimple(text string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// extractLastTokens 从文本末尾提取指定token数量的内容
|
||||
// extractLastTokens 从文本末尾提取指定 token 数量的内容
|
||||
func (idx *Indexer) extractLastTokens(text string, tokenCount int) string {
|
||||
if tokenCount <= 0 || text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 估算字符数(1 token ≈ 4字符)
|
||||
// 估算字符数(1 token ≈ 4 字符)
|
||||
charCount := tokenCount * 4
|
||||
runes := []rune(text)
|
||||
|
||||
@@ -236,12 +457,11 @@ func (idx *Indexer) extractLastTokens(text string, tokenCount int) string {
|
||||
}
|
||||
|
||||
// 从末尾提取指定数量的字符
|
||||
// 尝试在句子边界处截断,避免截断句子中间
|
||||
startPos := len(runes) - charCount
|
||||
extracted := string(runes[startPos:])
|
||||
|
||||
// 尝试找到第一个句子边界(句号、问号、感叹号后的空格)
|
||||
sentenceBoundary := regexp.MustCompile(`[.!?]+\s+`)
|
||||
// 尝试找到第一个句子边界(支持中英文标点)
|
||||
sentenceBoundary := regexp.MustCompile(`[.!?\x{3002}\x{FF01}\x{FF1F}]+`)
|
||||
matches := sentenceBoundary.FindStringIndex(extracted)
|
||||
if len(matches) > 0 && matches[0] > 0 {
|
||||
// 在句子边界处截断,保留完整句子
|
||||
@@ -251,41 +471,51 @@ func (idx *Indexer) extractLastTokens(text string, tokenCount int) string {
|
||||
return strings.TrimSpace(extracted)
|
||||
}
|
||||
|
||||
// estimateTokens 估算token数(简单估算:1 token ≈ 4字符)
|
||||
// estimateTokens 估算 token 数(简单估算:1 token ≈ 4 字符)
|
||||
func (idx *Indexer) estimateTokens(text string) int {
|
||||
return len([]rune(text)) / 4
|
||||
}
|
||||
|
||||
// IndexItem 索引知识项(分块并向量化)
|
||||
func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error {
|
||||
// 获取知识项(包含category和title,用于向量化)
|
||||
// 获取知识项(包含 category 和 title,用于向量化)
|
||||
var content, category, title string
|
||||
err := idx.db.QueryRow("SELECT content, category, title FROM knowledge_base_items WHERE id = ?", itemID).Scan(&content, &category, &title)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取知识项失败: %w", err)
|
||||
return fmt.Errorf("获取知识项失败:%w", err)
|
||||
}
|
||||
|
||||
// 删除旧的向量(在 RebuildIndex 中已经统一清空,这里保留是为了单独调用 IndexItem 时的兼容性)
|
||||
_, err = idx.db.Exec("DELETE FROM knowledge_embeddings WHERE item_id = ?", itemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除旧向量失败: %w", err)
|
||||
return fmt.Errorf("删除旧向量失败:%w", err)
|
||||
}
|
||||
|
||||
// 分块
|
||||
chunks := idx.ChunkText(content)
|
||||
|
||||
// 应用最大块数限制
|
||||
if idx.maxChunks > 0 && len(chunks) > idx.maxChunks {
|
||||
idx.logger.Info("知识项块数量超过限制,已截断",
|
||||
zap.String("itemId", itemID),
|
||||
zap.Int("originalChunks", len(chunks)),
|
||||
zap.Int("maxChunks", idx.maxChunks))
|
||||
chunks = chunks[:idx.maxChunks]
|
||||
}
|
||||
|
||||
idx.logger.Info("知识项分块完成", zap.String("itemId", itemID), zap.Int("chunks", len(chunks)))
|
||||
|
||||
// 跟踪该知识项的错误
|
||||
itemErrorCount := 0
|
||||
var firstError error
|
||||
firstErrorChunkIndex := -1
|
||||
|
||||
// 向量化每个块(包含category和title信息,以便向量检索时能匹配到风险类型)
|
||||
|
||||
// 向量化每个块(包含 category 和 title 信息,以便向量检索时能匹配到风险类型)
|
||||
for i, chunk := range chunks {
|
||||
// 将category和title信息包含到向量化的文本中
|
||||
// 格式:"[风险类型: {category}] [标题: {title}]\n{chunk内容}"
|
||||
// 这样向量嵌入就会包含风险类型信息,即使SQL过滤失败,向量相似度也能帮助匹配
|
||||
textForEmbedding := fmt.Sprintf("[风险类型: %s] [标题: %s]\n%s", category, title, chunk)
|
||||
// 将 category 和 title 信息包含到向量化的文本中
|
||||
// 格式:"[风险类型:{category}] [标题:{title}]\n{chunk 内容}"
|
||||
// 这样向量嵌入就会包含风险类型信息,即使 SQL 过滤失败,向量相似度也能帮助匹配
|
||||
textForEmbedding := fmt.Sprintf("[风险类型:%s] [标题:%s]\n%s", category, title, chunk)
|
||||
|
||||
embedding, err := idx.embedder.EmbedText(ctx, textForEmbedding)
|
||||
if err != nil {
|
||||
@@ -305,18 +535,30 @@ func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error {
|
||||
zap.String("chunkPreview", chunkPreview),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
|
||||
// 更新全局错误跟踪
|
||||
errorMsg := fmt.Sprintf("向量化失败 (知识项: %s): %v", itemID, err)
|
||||
errorMsg := fmt.Sprintf("向量化失败 (知识项:%s): %v", itemID, err)
|
||||
idx.mu.Lock()
|
||||
idx.lastError = errorMsg
|
||||
idx.lastErrorTime = time.Now()
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
|
||||
// 如果连续失败2个块,立即停止处理该知识项(降低阈值,更快停止)
|
||||
// 这样可以避免继续浪费API调用,同时也能更快地检测到配置问题
|
||||
if itemErrorCount >= 2 {
|
||||
|
||||
// 如果连续失败 5 个块,立即停止处理该知识项
|
||||
// 这样可以避免继续浪费 API 调用,同时也能更快地检测到配置问题
|
||||
// 对于大文档(超过 10 个块),允许失败比例不超过 50%
|
||||
maxConsecutiveFailures := 5
|
||||
if len(chunks) > 10 && itemErrorCount > len(chunks)/2 {
|
||||
idx.logger.Error("知识项向量化失败比例过高,停止处理",
|
||||
zap.String("itemId", itemID),
|
||||
zap.Int("totalChunks", len(chunks)),
|
||||
zap.Int("failedChunks", itemErrorCount),
|
||||
zap.Int("firstErrorChunkIndex", firstErrorChunkIndex),
|
||||
zap.Error(firstError),
|
||||
)
|
||||
return fmt.Errorf("知识项向量化失败比例过高 (%d/%d个块失败): %v", itemErrorCount, len(chunks), firstError)
|
||||
}
|
||||
if itemErrorCount >= maxConsecutiveFailures {
|
||||
idx.logger.Error("知识项连续向量化失败,停止处理",
|
||||
zap.String("itemId", itemID),
|
||||
zap.Int("totalChunks", len(chunks)),
|
||||
@@ -344,6 +586,13 @@ func (idx *Indexer) IndexItem(ctx context.Context, itemID string) error {
|
||||
}
|
||||
|
||||
idx.logger.Info("知识项索引完成", zap.String("itemId", itemID), zap.Int("chunks", len(chunks)))
|
||||
|
||||
// 更新重建状态中的最近处理信息
|
||||
idx.rebuildMu.Lock()
|
||||
idx.rebuildLastItemID = itemID
|
||||
idx.rebuildLastChunks = len(chunks)
|
||||
idx.rebuildMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -352,23 +601,38 @@ func (idx *Indexer) HasIndex() (bool, error) {
|
||||
var count int
|
||||
err := idx.db.QueryRow("SELECT COUNT(*) FROM knowledge_embeddings").Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("检查索引失败: %w", err)
|
||||
return false, fmt.Errorf("检查索引失败:%w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// RebuildIndex 重建所有索引
|
||||
func (idx *Indexer) RebuildIndex(ctx context.Context) error {
|
||||
// 设置重建状态
|
||||
idx.rebuildMu.Lock()
|
||||
idx.isRebuilding = true
|
||||
idx.rebuildTotalItems = 0
|
||||
idx.rebuildCurrent = 0
|
||||
idx.rebuildFailed = 0
|
||||
idx.rebuildStartTime = time.Now()
|
||||
idx.rebuildLastItemID = ""
|
||||
idx.rebuildLastChunks = 0
|
||||
idx.rebuildMu.Unlock()
|
||||
|
||||
// 重置错误跟踪
|
||||
idx.mu.Lock()
|
||||
idx.lastError = ""
|
||||
idx.lastErrorTime = time.Time{}
|
||||
idx.errorCount = 0
|
||||
idx.mu.Unlock()
|
||||
|
||||
|
||||
rows, err := idx.db.Query("SELECT id FROM knowledge_base_items")
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询知识项失败: %w", err)
|
||||
// 重置重建状态
|
||||
idx.rebuildMu.Lock()
|
||||
idx.isRebuilding = false
|
||||
idx.rebuildMu.Unlock()
|
||||
return fmt.Errorf("查询知识项失败:%w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
@@ -376,34 +640,36 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return fmt.Errorf("扫描知识项ID失败: %w", err)
|
||||
// 重置重建状态
|
||||
idx.rebuildMu.Lock()
|
||||
idx.isRebuilding = false
|
||||
idx.rebuildMu.Unlock()
|
||||
return fmt.Errorf("扫描知识项 ID 失败:%w", err)
|
||||
}
|
||||
itemIDs = append(itemIDs, id)
|
||||
}
|
||||
|
||||
idx.rebuildMu.Lock()
|
||||
idx.rebuildTotalItems = len(itemIDs)
|
||||
idx.rebuildMu.Unlock()
|
||||
|
||||
idx.logger.Info("开始重建索引", zap.Int("totalItems", len(itemIDs)))
|
||||
|
||||
// 在开始重建前,先清空所有旧的向量,确保进度从0开始
|
||||
// 这样 GetIndexStatus 可以准确反映重建进度
|
||||
_, err = idx.db.Exec("DELETE FROM knowledge_embeddings")
|
||||
if err != nil {
|
||||
idx.logger.Warn("清空旧索引失败", zap.Error(err))
|
||||
// 继续执行,即使清空失败也尝试重建
|
||||
} else {
|
||||
idx.logger.Info("已清空旧索引,开始重建")
|
||||
}
|
||||
// 注意:不再清空所有旧索引,而是按增量方式更新
|
||||
// 每个知识项在 IndexItem 中会先删除自己的旧向量,然后插入新向量
|
||||
// 这样配置更新后只重新索引变化的知识项,保留其他知识项的索引
|
||||
|
||||
failedCount := 0
|
||||
consecutiveFailures := 0
|
||||
maxConsecutiveFailures := 2 // 连续失败2次后立即停止(降低阈值,更快停止)
|
||||
maxConsecutiveFailures := 5 // 连续失败 5 次后立即停止(允许偶尔的临时错误)
|
||||
firstFailureItemID := ""
|
||||
var firstFailureError error
|
||||
|
||||
|
||||
for i, itemID := range itemIDs {
|
||||
if err := idx.IndexItem(ctx, itemID); err != nil {
|
||||
failedCount++
|
||||
consecutiveFailures++
|
||||
|
||||
|
||||
// 只在第一个失败时记录详细日志
|
||||
if consecutiveFailures == 1 {
|
||||
firstFailureItemID = itemID
|
||||
@@ -414,15 +680,15 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 如果连续失败过多,可能是配置问题,立即停止索引
|
||||
if consecutiveFailures >= maxConsecutiveFailures {
|
||||
errorMsg := fmt.Sprintf("连续 %d 个知识项索引失败,可能存在配置问题(如嵌入模型配置错误、API密钥无效、余额不足等)。第一个失败项: %s, 错误: %v", consecutiveFailures, firstFailureItemID, firstFailureError)
|
||||
errorMsg := fmt.Sprintf("连续 %d 个知识项索引失败,可能存在配置问题(如嵌入模型配置错误、API 密钥无效、余额不足等)。第一个失败项:%s, 错误:%v", consecutiveFailures, firstFailureItemID, firstFailureError)
|
||||
idx.mu.Lock()
|
||||
idx.lastError = errorMsg
|
||||
idx.lastErrorTime = time.Now()
|
||||
idx.mu.Unlock()
|
||||
|
||||
|
||||
idx.logger.Error("连续索引失败次数过多,立即停止索引",
|
||||
zap.Int("consecutiveFailures", consecutiveFailures),
|
||||
zap.Int("totalItems", len(itemIDs)),
|
||||
@@ -430,17 +696,17 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
|
||||
zap.String("firstFailureItemId", firstFailureItemID),
|
||||
zap.Error(firstFailureError),
|
||||
)
|
||||
return fmt.Errorf("连续索引失败次数过多: %v", firstFailureError)
|
||||
return fmt.Errorf("连续索引失败次数过多:%v", firstFailureError)
|
||||
}
|
||||
|
||||
// 如果失败的知识项过多,记录警告但继续处理(降低阈值到30%)
|
||||
|
||||
// 如果失败的知识项过多,记录警告但继续处理(降低阈值到 30%)
|
||||
if failedCount > len(itemIDs)*3/10 && failedCount == len(itemIDs)*3/10+1 {
|
||||
errorMsg := fmt.Sprintf("索引失败的知识项过多 (%d/%d),可能存在配置问题。第一个失败项: %s, 错误: %v", failedCount, len(itemIDs), firstFailureItemID, firstFailureError)
|
||||
errorMsg := fmt.Sprintf("索引失败的知识项过多 (%d/%d),可能存在配置问题。第一个失败项:%s, 错误:%v", failedCount, len(itemIDs), firstFailureItemID, firstFailureError)
|
||||
idx.mu.Lock()
|
||||
idx.lastError = errorMsg
|
||||
idx.lastErrorTime = time.Now()
|
||||
idx.mu.Unlock()
|
||||
|
||||
|
||||
idx.logger.Error("索引失败的知识项过多,可能存在配置问题",
|
||||
zap.Int("failedCount", failedCount),
|
||||
zap.Int("totalItems", len(itemIDs)),
|
||||
@@ -450,20 +716,31 @@ func (idx *Indexer) RebuildIndex(ctx context.Context) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// 成功时重置连续失败计数和第一个失败信息
|
||||
if consecutiveFailures > 0 {
|
||||
consecutiveFailures = 0
|
||||
firstFailureItemID = ""
|
||||
firstFailureError = nil
|
||||
}
|
||||
|
||||
// 减少进度日志频率(每10个或每10%记录一次)
|
||||
|
||||
// 更新重建进度
|
||||
idx.rebuildMu.Lock()
|
||||
idx.rebuildCurrent = i + 1
|
||||
idx.rebuildFailed = failedCount
|
||||
idx.rebuildMu.Unlock()
|
||||
|
||||
// 减少进度日志频率(每 10 个或每 10% 记录一次)
|
||||
if (i+1)%10 == 0 || (len(itemIDs) > 0 && (i+1)*100/len(itemIDs)%10 == 0 && (i+1)*100/len(itemIDs) > 0) {
|
||||
idx.logger.Info("索引进度", zap.Int("current", i+1), zap.Int("total", len(itemIDs)), zap.Int("failed", failedCount))
|
||||
}
|
||||
}
|
||||
|
||||
// 重置重建状态
|
||||
idx.rebuildMu.Lock()
|
||||
idx.isRebuilding = false
|
||||
idx.rebuildMu.Unlock()
|
||||
|
||||
idx.logger.Info("索引重建完成", zap.Int("totalItems", len(itemIDs)), zap.Int("failedCount", failedCount))
|
||||
return nil
|
||||
}
|
||||
@@ -474,3 +751,10 @@ func (idx *Indexer) GetLastError() (string, time.Time) {
|
||||
defer idx.mu.RUnlock()
|
||||
return idx.lastError, idx.lastErrorTime
|
||||
}
|
||||
|
||||
// GetRebuildStatus 获取重建索引状态
|
||||
func (idx *Indexer) GetRebuildStatus() (isRebuilding bool, totalItems int, current int, failed int, lastItemID string, lastChunks int, startTime time.Time) {
|
||||
idx.rebuildMu.RLock()
|
||||
defer idx.rebuildMu.RUnlock()
|
||||
return idx.isRebuilding, idx.rebuildTotalItems, idx.rebuildCurrent, idx.rebuildFailed, idx.rebuildLastItemID, idx.rebuildLastChunks, idx.rebuildStartTime
|
||||
}
|
||||
|
||||
@@ -657,7 +657,7 @@ func (m *Manager) UpdateItem(id, category, title, content string) (*KnowledgeIte
|
||||
|
||||
// 删除旧目录(如果为空)
|
||||
oldDir := filepath.Dir(item.FilePath)
|
||||
if entries, err := os.ReadDir(oldDir); err == nil && len(entries) == 0 {
|
||||
if isEmpty, _ := isEmptyDir(oldDir); isEmpty {
|
||||
// 只有当目录不是知识库根目录时才删除(避免删除根目录)
|
||||
if oldDir != m.basePath {
|
||||
if err := os.Remove(oldDir); err != nil {
|
||||
@@ -712,7 +712,7 @@ func (m *Manager) DeleteItem(id string) error {
|
||||
|
||||
// 删除空目录(如果为空)
|
||||
dir := filepath.Dir(filePath)
|
||||
if entries, err := os.ReadDir(dir); err == nil && len(entries) == 0 {
|
||||
if isEmpty, _ := isEmptyDir(dir); isEmpty {
|
||||
// 只有当目录不是知识库根目录时才删除(避免删除根目录)
|
||||
if dir != m.basePath {
|
||||
if err := os.Remove(dir); err != nil {
|
||||
@@ -724,6 +724,21 @@ func (m *Manager) DeleteItem(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isEmptyDir 检查目录是否为空(忽略隐藏文件和 . 开头的文件)
|
||||
func isEmptyDir(dir string) (bool, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
// 忽略隐藏文件(以 . 开头)
|
||||
if !strings.HasPrefix(entry.Name(), ".") {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// LogRetrieval 记录检索日志
|
||||
func (m *Manager) LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error {
|
||||
id := uuid.New().String()
|
||||
|
||||
@@ -69,8 +69,8 @@ func cosineSimilarity(a, b []float32) float64 {
|
||||
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
// bm25Score 计算BM25分数(改进版,更接近标准BM25)
|
||||
// 注意:这是单文档版本的BM25,缺少全局IDF,但比之前的简化版本更准确
|
||||
// bm25Score 计算 BM25 分数(带缓存的改进版本)
|
||||
// 注意:由于缺少全局文档统计,使用简化 IDF 计算
|
||||
func (r *Retriever) bm25Score(query, text string) float64 {
|
||||
queryTerms := strings.Fields(strings.ToLower(query))
|
||||
if len(queryTerms) == 0 {
|
||||
@@ -83,44 +83,56 @@ func (r *Retriever) bm25Score(query, text string) float64 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// BM25参数
|
||||
k1 := 1.5 // 词频饱和度参数
|
||||
b := 0.75 // 长度归一化参数
|
||||
avgDocLength := 100.0 // 估算的平均文档长度(用于归一化)
|
||||
// BM25 参数(标准值)
|
||||
k1 := 1.2 // 词频饱和度参数(标准范围 1.2-2.0)
|
||||
b := 0.75 // 长度归一化参数(标准值)
|
||||
avgDocLength := 150.0 // 估算的平均文档长度(基于典型知识块大小)
|
||||
docLength := float64(len(textTerms))
|
||||
|
||||
score := 0.0
|
||||
for _, term := range queryTerms {
|
||||
// 计算词频(TF)
|
||||
termFreq := 0
|
||||
for _, textTerm := range textTerms {
|
||||
if textTerm == term {
|
||||
termFreq++
|
||||
}
|
||||
}
|
||||
|
||||
if termFreq > 0 {
|
||||
// BM25公式的核心部分
|
||||
// TF部分:termFreq / (termFreq + k1 * (1 - b + b * (docLength / avgDocLength)))
|
||||
tf := float64(termFreq)
|
||||
lengthNorm := 1 - b + b*(docLength/avgDocLength)
|
||||
tfScore := tf / (tf + k1*lengthNorm)
|
||||
|
||||
// 简化IDF:使用词长度作为权重(短词通常更重要)
|
||||
// 实际BM25需要全局文档统计,这里用简化版本
|
||||
idfWeight := 1.0
|
||||
if len(term) > 2 {
|
||||
// 长词稍微降低权重(但实际BM25中,罕见词IDF更高)
|
||||
idfWeight = 1.0 + math.Log(1.0+float64(len(term))/10.0)
|
||||
}
|
||||
|
||||
score += tfScore * idfWeight
|
||||
}
|
||||
// 计算词频映射
|
||||
textTermFreq := make(map[string]int, len(textTerms))
|
||||
for _, term := range textTerms {
|
||||
textTermFreq[term]++
|
||||
}
|
||||
|
||||
// 归一化到0-1范围
|
||||
score := 0.0
|
||||
matchedQueryTerms := 0
|
||||
|
||||
for _, term := range queryTerms {
|
||||
termFreq, exists := textTermFreq[term]
|
||||
if !exists || termFreq == 0 {
|
||||
continue
|
||||
}
|
||||
matchedQueryTerms++
|
||||
|
||||
// BM25 TF 计算公式
|
||||
tf := float64(termFreq)
|
||||
lengthNorm := 1 - b + b*(docLength/avgDocLength)
|
||||
tfScore := tf / (tf + k1*lengthNorm)
|
||||
|
||||
// 改进的 IDF 计算:使用词长度和出现频率估算
|
||||
// 短词(2-3 字符)通常更重要,长词 IDF 略低
|
||||
idfWeight := 1.0
|
||||
termLen := len(term)
|
||||
if termLen <= 2 {
|
||||
// 极短词(如 go, js)给予更高权重
|
||||
idfWeight = 1.2 + math.Log(1.0+float64(termFreq)/20.0)
|
||||
} else if termLen <= 4 {
|
||||
// 短词(4 字符)标准权重
|
||||
idfWeight = 1.0 + math.Log(1.0+float64(termFreq)/15.0)
|
||||
} else {
|
||||
// 长词稍微降低权重
|
||||
idfWeight = 0.9 + math.Log(1.0+float64(termFreq)/10.0)
|
||||
}
|
||||
|
||||
score += tfScore * idfWeight
|
||||
}
|
||||
|
||||
// 归一化:考虑匹配的查询词比例
|
||||
if len(queryTerms) > 0 {
|
||||
score = score / float64(len(queryTerms))
|
||||
// 使用匹配比例作为额外因子
|
||||
matchRatio := float64(matchedQueryTerms) / float64(len(queryTerms))
|
||||
score = (score / float64(len(queryTerms))) * (1 + matchRatio) / 2
|
||||
}
|
||||
|
||||
return math.Min(score, 1.0)
|
||||
@@ -173,7 +185,7 @@ func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*Retrieva
|
||||
SELECT e.id, e.item_id, e.chunk_index, e.chunk_text, e.embedding, i.category, i.title
|
||||
FROM knowledge_embeddings e
|
||||
JOIN knowledge_base_items i ON e.item_id = i.id
|
||||
WHERE i.category = ? COLLATE NOCASE
|
||||
WHERE TRIM(i.category) = TRIM(?) COLLATE NOCASE
|
||||
`, req.RiskType)
|
||||
} else {
|
||||
rows, err = r.db.Query(`
|
||||
@@ -357,7 +369,10 @@ func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*Retrieva
|
||||
zap.Float64("threshold", threshold),
|
||||
zap.Float64("maxSimilarity", maxSimilarity),
|
||||
)
|
||||
} else if len(filteredCandidates) > topK {
|
||||
}
|
||||
|
||||
// 统一在最终返回前严格限制 Top-K 数量
|
||||
if len(filteredCandidates) > topK {
|
||||
// 如果过滤后结果太多,只取Top-K
|
||||
filteredCandidates = filteredCandidates[:topK]
|
||||
}
|
||||
|
||||
+24
-42
@@ -5,6 +5,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// formatTime 格式化时间为 RFC3339 格式,零时间返回空字符串
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// KnowledgeItem 知识库项
|
||||
type KnowledgeItem struct {
|
||||
ID string `json:"id"`
|
||||
@@ -22,12 +30,12 @@ type KnowledgeItemSummary struct {
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
FilePath string `json:"filePath"`
|
||||
Content string `json:"content,omitempty"` // 可选:内容预览(如果提供,通常只包含前150字符)
|
||||
Content string `json:"content,omitempty"` // 可选:内容预览(如果提供,通常只包含前 150 字符)
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// MarshalJSON 自定义JSON序列化,确保时间格式正确
|
||||
// MarshalJSON 自定义 JSON 序列化,确保时间格式正确
|
||||
func (k *KnowledgeItemSummary) MarshalJSON() ([]byte, error) {
|
||||
type Alias KnowledgeItemSummary
|
||||
aux := &struct {
|
||||
@@ -37,25 +45,12 @@ func (k *KnowledgeItemSummary) MarshalJSON() ([]byte, error) {
|
||||
}{
|
||||
Alias: (*Alias)(k),
|
||||
}
|
||||
|
||||
// 格式化创建时间
|
||||
if k.CreatedAt.IsZero() {
|
||||
aux.CreatedAt = ""
|
||||
} else {
|
||||
aux.CreatedAt = k.CreatedAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// 格式化更新时间
|
||||
if k.UpdatedAt.IsZero() {
|
||||
aux.UpdatedAt = ""
|
||||
} else {
|
||||
aux.UpdatedAt = k.UpdatedAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
aux.CreatedAt = formatTime(k.CreatedAt)
|
||||
aux.UpdatedAt = formatTime(k.UpdatedAt)
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
// MarshalJSON 自定义JSON序列化,确保时间格式正确
|
||||
// MarshalJSON 自定义 JSON 序列化,确保时间格式正确
|
||||
func (k *KnowledgeItem) MarshalJSON() ([]byte, error) {
|
||||
type Alias KnowledgeItem
|
||||
aux := &struct {
|
||||
@@ -65,21 +60,8 @@ func (k *KnowledgeItem) MarshalJSON() ([]byte, error) {
|
||||
}{
|
||||
Alias: (*Alias)(k),
|
||||
}
|
||||
|
||||
// 格式化创建时间
|
||||
if k.CreatedAt.IsZero() {
|
||||
aux.CreatedAt = ""
|
||||
} else {
|
||||
aux.CreatedAt = k.CreatedAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// 格式化更新时间
|
||||
if k.UpdatedAt.IsZero() {
|
||||
aux.UpdatedAt = ""
|
||||
} else {
|
||||
aux.UpdatedAt = k.UpdatedAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
aux.CreatedAt = formatTime(k.CreatedAt)
|
||||
aux.UpdatedAt = formatTime(k.UpdatedAt)
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
@@ -89,7 +71,7 @@ type KnowledgeChunk struct {
|
||||
ItemID string `json:"itemId"`
|
||||
ChunkIndex int `json:"chunkIndex"`
|
||||
ChunkText string `json:"chunkText"`
|
||||
Embedding []float32 `json:"-"` // 向量嵌入,不序列化到JSON
|
||||
Embedding []float32 `json:"-"` // 向量嵌入,不序列化到 JSON
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -108,11 +90,11 @@ type RetrievalLog struct {
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Query string `json:"query"`
|
||||
RiskType string `json:"riskType,omitempty"`
|
||||
RetrievedItems []string `json:"retrievedItems"` // 检索到的知识项ID列表
|
||||
RetrievedItems []string `json:"retrievedItems"` // 检索到的知识项 ID 列表
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// MarshalJSON 自定义JSON序列化,确保时间格式正确
|
||||
// MarshalJSON 自定义 JSON 序列化,确保时间格式正确
|
||||
func (r *RetrievalLog) MarshalJSON() ([]byte, error) {
|
||||
type Alias RetrievalLog
|
||||
return json.Marshal(&struct {
|
||||
@@ -120,21 +102,21 @@ func (r *RetrievalLog) MarshalJSON() ([]byte, error) {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}{
|
||||
Alias: (*Alias)(r),
|
||||
CreatedAt: r.CreatedAt.Format(time.RFC3339),
|
||||
CreatedAt: formatTime(r.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// CategoryWithItems 分类及其下的知识项(用于按分类分页)
|
||||
type CategoryWithItems struct {
|
||||
Category string `json:"category"` // 分类名称
|
||||
ItemCount int `json:"itemCount"` // 该分类下的知识项总数
|
||||
Items []*KnowledgeItemSummary `json:"items"` // 该分类下的知识项列表
|
||||
Category string `json:"category"` // 分类名称
|
||||
ItemCount int `json:"itemCount"` // 该分类下的知识项总数
|
||||
Items []*KnowledgeItemSummary `json:"items"` // 该分类下的知识项列表
|
||||
}
|
||||
|
||||
// SearchRequest 搜索请求
|
||||
type SearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
RiskType string `json:"riskType,omitempty"` // 可选:指定风险类型
|
||||
TopK int `json:"topK,omitempty"` // 返回Top-K结果,默认5
|
||||
Threshold float64 `json:"threshold,omitempty"` // 相似度阈值,默认0.7
|
||||
TopK int `json:"topK,omitempty"` // 返回 Top-K 结果,默认 5
|
||||
Threshold float64 `json:"threshold,omitempty"` // 相似度阈值,默认 0.7
|
||||
}
|
||||
|
||||
@@ -55,6 +55,14 @@ func New(level, output string) *Logger {
|
||||
}
|
||||
|
||||
func (l *Logger) Fatal(msg string, fields ...interface{}) {
|
||||
l.Logger.Fatal(msg, zap.Any("fields", fields))
|
||||
zapFields := make([]zap.Field, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
switch v := f.(type) {
|
||||
case error:
|
||||
zapFields = append(zapFields, zap.Error(v))
|
||||
default:
|
||||
zapFields = append(zapFields, zap.Any("field", v))
|
||||
}
|
||||
}
|
||||
l.Logger.Fatal(msg, zapFields...)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ parameters:
|
||||
- 确保目标地址格式正确
|
||||
- 必需参数,不能为空
|
||||
required: true
|
||||
position: 0 # 位置参数,放在命令最后
|
||||
position: 1 # 位置参数,必须放在命令最后(nmap [options] target),用 1 确保在 flag 之后、最后添加
|
||||
format: "positional"
|
||||
- name: "ports"
|
||||
type: "string"
|
||||
|
||||
@@ -529,6 +529,60 @@ header {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lang-switcher {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lang-switcher-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.lang-switcher-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.lang-switcher-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.lang-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
min-width: 120px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 4px 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.lang-option {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lang-option:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1582,12 +1636,106 @@ header {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-input-container > .chat-input-with-files {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-input-container > .chat-input-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-file-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-file-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 102, 255, 0.08);
|
||||
border: 1px solid rgba(0, 102, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.chat-file-chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-file-chip-remove {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.chat-file-chip-remove:hover {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chat-file-input-hidden {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chat-upload-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, color 0.2s, background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-upload-btn:hover {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
background: rgba(0, 102, 255, 0.04);
|
||||
}
|
||||
|
||||
.chat-input-container.drag-over {
|
||||
background: rgba(0, 102, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
outline: 2px dashed rgba(0, 102, 255, 0.35);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.chat-input-field {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@@ -1654,6 +1802,7 @@ header {
|
||||
|
||||
.chat-input-container textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chat-input-container .send-btn {
|
||||
@@ -3038,6 +3187,164 @@ header {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 系统设置 - 终端 */
|
||||
.terminal-wrapper {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
.terminal-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
padding: 6px 10px 0;
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.terminal-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px 5px 12px;
|
||||
font-size: 0.8125rem;
|
||||
color: #8b949e;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px 4px 0 0;
|
||||
cursor: pointer;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.terminal-tab-label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terminal-tab-close {
|
||||
padding: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
color: #8b949e;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.terminal-tab-close:hover {
|
||||
color: #ff7b72;
|
||||
background: rgba(255, 123, 114, 0.15);
|
||||
}
|
||||
|
||||
.terminal-tab:hover {
|
||||
color: #e6edf3;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.terminal-tab.active {
|
||||
color: #e6edf3;
|
||||
background: #0d1117;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.terminal-tab-new {
|
||||
margin-left: 4px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
color: #8b949e;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.terminal-tab-new:hover {
|
||||
color: #58a6ff;
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
border-color: rgba(88, 166, 255, 0.3);
|
||||
}
|
||||
|
||||
.terminal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.terminal-toolbar-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #8b949e;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.terminal-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.terminal-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: #8b949e;
|
||||
}
|
||||
|
||||
.terminal-panes {
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.terminal-pane {
|
||||
display: none;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.terminal-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
min-height: 400px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.terminal-container .xterm {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.terminal-error {
|
||||
color: #ff7b72;
|
||||
padding: 16px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+146
-61
@@ -3,13 +3,74 @@
|
||||
let apiSpec = null;
|
||||
let currentToken = null;
|
||||
|
||||
function _t(key, opts) {
|
||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||
}
|
||||
|
||||
function waitForI18n() {
|
||||
return new Promise(function (resolve) {
|
||||
if (window.t) return resolve();
|
||||
var n = 0;
|
||||
var iv = setInterval(function () {
|
||||
if (window.t) { clearInterval(iv); resolve(); return; }
|
||||
n++;
|
||||
if (n >= 100) { clearInterval(iv); resolve(); }
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// 从 OpenAPI spec 的 x-i18n-tags 构建 tag -> i18n key 映射(方案 A:后端提供键)
|
||||
var apiSpecTagToKey = {};
|
||||
function buildApiSpecTagToKey() {
|
||||
apiSpecTagToKey = {};
|
||||
if (!apiSpec || !apiSpec.paths) return;
|
||||
Object.keys(apiSpec.paths).forEach(function (path) {
|
||||
var pathItem = apiSpec.paths[path];
|
||||
if (!pathItem || typeof pathItem !== 'object') return;
|
||||
['get', 'post', 'put', 'delete', 'patch'].forEach(function (method) {
|
||||
var op = pathItem[method];
|
||||
if (!op || !op.tags || !op['x-i18n-tags']) return;
|
||||
var tags = op.tags;
|
||||
var keys = op['x-i18n-tags'];
|
||||
for (var i = 0; i < tags.length && i < keys.length; i++) {
|
||||
apiSpecTagToKey[tags[i]] = typeof keys[i] === 'string' ? keys[i] : keys[i];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function translateApiDocTag(tag) {
|
||||
if (!tag) return tag;
|
||||
var key = apiSpecTagToKey[tag];
|
||||
return key ? _t('apiDocs.tags.' + key) : tag;
|
||||
}
|
||||
function translateApiDocSummaryFromOp(op) {
|
||||
var key = op && op['x-i18n-summary'];
|
||||
if (key) return _t('apiDocs.summary.' + key);
|
||||
return op && op.summary ? op.summary : '';
|
||||
}
|
||||
function translateApiDocResponseDescFromResp(resp) {
|
||||
if (!resp) return '';
|
||||
var key = resp['x-i18n-description'];
|
||||
if (key) return _t('apiDocs.response.' + key);
|
||||
return resp.description || '';
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await waitForI18n();
|
||||
await loadToken();
|
||||
await loadAPISpec();
|
||||
if (apiSpec) {
|
||||
renderAPIDocs();
|
||||
}
|
||||
document.addEventListener('languagechange', function () {
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(document);
|
||||
}
|
||||
if (apiSpec) {
|
||||
renderAPIDocs();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 加载token
|
||||
@@ -43,22 +104,25 @@ async function loadAPISpec() {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
showError('需要登录才能查看API文档。请先在前端页面登录,然后刷新此页面。');
|
||||
showError(_t('apiDocs.errorLoginRequired'));
|
||||
return;
|
||||
}
|
||||
throw new Error('加载API规范失败: ' + response.status);
|
||||
throw new Error(_t('apiDocs.errorLoadSpec') + response.status);
|
||||
}
|
||||
|
||||
apiSpec = await response.json();
|
||||
buildApiSpecTagToKey();
|
||||
} catch (error) {
|
||||
console.error('加载API规范失败:', error);
|
||||
showError('加载API文档失败: ' + error.message);
|
||||
showError(_t('apiDocs.errorLoadFailed') + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示错误
|
||||
function showError(message) {
|
||||
const main = document.getElementById('api-docs-main');
|
||||
const loadFailed = _t('apiDocs.loadFailed');
|
||||
const backToLogin = _t('apiDocs.backToLogin');
|
||||
main.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -66,10 +130,10 @@ function showError(message) {
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
<h3>加载失败</h3>
|
||||
<p>${message}</p>
|
||||
<h3>${escapeHtml(loadFailed)}</h3>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
<div style="margin-top: 16px;">
|
||||
<a href="/" style="color: var(--accent-color); text-decoration: none;">返回首页登录</a>
|
||||
<a href="/" style="color: var(--accent-color); text-decoration: none;">${escapeHtml(backToLogin)}</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -78,7 +142,7 @@ function showError(message) {
|
||||
// 渲染API文档
|
||||
function renderAPIDocs() {
|
||||
if (!apiSpec || !apiSpec.paths) {
|
||||
showError('API规范格式错误');
|
||||
showError(_t('apiDocs.errorSpecInvalid'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,7 +173,7 @@ function renderAuthInfo() {
|
||||
tokenStatus.style.display = 'block';
|
||||
tokenStatus.style.background = 'rgba(255, 152, 0, 0.1)';
|
||||
tokenStatus.style.borderLeftColor = '#ff9800';
|
||||
tokenStatus.innerHTML = '<p style="margin: 0; font-size: 0.8125rem; color: #ff9800;"><strong>⚠ 未检测到 Token</strong> - 请先在前端页面登录,然后刷新此页面。测试接口时需要在请求头中添加 Authorization: Bearer token</p>';
|
||||
tokenStatus.innerHTML = '<p style="margin: 0; font-size: 0.8125rem; color: #ff9800;">' + escapeHtml(_t('apiDocs.tokenNotDetected')) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +191,14 @@ function renderSidebar() {
|
||||
|
||||
const groupList = document.getElementById('api-group-list');
|
||||
const allGroups = Array.from(groups).sort();
|
||||
|
||||
while (groupList.children.length > 1) {
|
||||
groupList.removeChild(groupList.lastChild);
|
||||
}
|
||||
allGroups.forEach(group => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'api-group-item';
|
||||
li.innerHTML = `<a href="#" class="api-group-link" data-group="${group}">${group}</a>`;
|
||||
const groupLabel = translateApiDocTag(group);
|
||||
li.innerHTML = `<a href="#" class="api-group-link" data-group="${escapeHtml(group)}">${escapeHtml(groupLabel)}</a>`;
|
||||
groupList.appendChild(li);
|
||||
});
|
||||
|
||||
@@ -176,7 +243,7 @@ function renderEndpoints(filterGroup = null) {
|
||||
});
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
main.innerHTML = '<div class="empty-state"><h3>暂无API</h3><p>该分组下没有API端点</p></div>';
|
||||
main.innerHTML = '<div class="empty-state"><h3>' + escapeHtml(_t('apiDocs.noApis')) + '</h3><p>' + escapeHtml(_t('apiDocs.noEndpointsInGroup')) + '</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,8 +259,8 @@ function createEndpointCard(endpoint) {
|
||||
|
||||
const methodClass = endpoint.method.toLowerCase();
|
||||
const tags = endpoint.tags || [];
|
||||
const tagHtml = tags.map(tag => `<span class="api-tag">${tag}</span>`).join('');
|
||||
|
||||
const tagHtml = tags.map(tag => `<span class="api-tag">${escapeHtml(translateApiDocTag(tag))}</span>`).join('');
|
||||
const summaryText = translateApiDocSummaryFromOp(endpoint);
|
||||
card.innerHTML = `
|
||||
<div class="api-endpoint-header">
|
||||
<div class="api-endpoint-title">
|
||||
@@ -204,21 +271,21 @@ function createEndpointCard(endpoint) {
|
||||
</div>
|
||||
<div class="api-endpoint-body">
|
||||
<div class="api-section">
|
||||
<div class="api-section-title">描述</div>
|
||||
${endpoint.summary ? `<div class="api-description" style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(endpoint.summary)}</div>` : ''}
|
||||
<div class="api-section-title">${escapeHtml(_t('apiDocs.sectionDescription'))}</div>
|
||||
${summaryText ? `<div class="api-description" style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(summaryText)}</div>` : ''}
|
||||
${endpoint.description ? `
|
||||
<div class="api-description-toggle">
|
||||
<button class="description-toggle-btn" onclick="toggleDescription(this)">
|
||||
<svg class="description-toggle-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
<span>查看详细说明</span>
|
||||
<span>${escapeHtml(_t('apiDocs.viewDetailDesc'))}</span>
|
||||
</button>
|
||||
<div class="api-description-detail" style="display: none;">
|
||||
${formatDescription(endpoint.description)}
|
||||
</div>
|
||||
</div>
|
||||
` : endpoint.summary ? '' : '<div class="api-description">无描述</div>'}
|
||||
` : endpoint.summary ? '' : '<div class="api-description">' + escapeHtml(_t('apiDocs.noDescription')) + '</div>'}
|
||||
</div>
|
||||
|
||||
${renderParameters(endpoint)}
|
||||
@@ -236,8 +303,10 @@ function renderParameters(endpoint) {
|
||||
const params = endpoint.parameters || [];
|
||||
if (params.length === 0) return '';
|
||||
|
||||
const requiredLabel = escapeHtml(_t('apiDocs.required'));
|
||||
const optionalLabel = escapeHtml(_t('apiDocs.optional'));
|
||||
const rows = params.map(param => {
|
||||
const required = param.required ? '<span class="api-param-required">必需</span>' : '<span class="api-param-optional">可选</span>';
|
||||
const required = param.required ? '<span class="api-param-required">' + requiredLabel + '</span>' : '<span class="api-param-optional">' + optionalLabel + '</span>';
|
||||
// 处理描述文本,将换行符转换为<br>
|
||||
let descriptionHtml = '-';
|
||||
if (param.description) {
|
||||
@@ -255,17 +324,20 @@ function renderParameters(endpoint) {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const paramName = escapeHtml(_t('apiDocs.paramName'));
|
||||
const typeLabel = escapeHtml(_t('apiDocs.type'));
|
||||
const descLabel = escapeHtml(_t('apiDocs.description'));
|
||||
return `
|
||||
<div class="api-section">
|
||||
<div class="api-section-title">参数</div>
|
||||
<div class="api-section-title">${escapeHtml(_t('apiDocs.sectionParams'))}</div>
|
||||
<div class="api-table-wrapper">
|
||||
<table class="api-params-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数名</th>
|
||||
<th>类型</th>
|
||||
<th>描述</th>
|
||||
<th>必需</th>
|
||||
<th>${paramName}</th>
|
||||
<th>${typeLabel}</th>
|
||||
<th>${descLabel}</th>
|
||||
<th>${requiredLabel}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -297,11 +369,13 @@ function renderRequestBody(endpoint) {
|
||||
let paramsTable = '';
|
||||
if (schema.properties) {
|
||||
const requiredFields = schema.required || [];
|
||||
const reqLabel = escapeHtml(_t('apiDocs.required'));
|
||||
const optLabel = escapeHtml(_t('apiDocs.optional'));
|
||||
const rows = Object.keys(schema.properties).map(key => {
|
||||
const prop = schema.properties[key];
|
||||
const required = requiredFields.includes(key)
|
||||
? '<span class="api-param-required">必需</span>'
|
||||
: '<span class="api-param-optional">可选</span>';
|
||||
? '<span class="api-param-required">' + reqLabel + '</span>'
|
||||
: '<span class="api-param-optional">' + optLabel + '</span>';
|
||||
|
||||
// 处理嵌套类型
|
||||
let typeDisplay = prop.type || 'object';
|
||||
@@ -338,16 +412,20 @@ function renderRequestBody(endpoint) {
|
||||
}).join('');
|
||||
|
||||
if (rows) {
|
||||
const pName = escapeHtml(_t('apiDocs.paramName'));
|
||||
const tLabel = escapeHtml(_t('apiDocs.type'));
|
||||
const dLabel = escapeHtml(_t('apiDocs.description'));
|
||||
const exLabel = escapeHtml(_t('apiDocs.example'));
|
||||
paramsTable = `
|
||||
<div class="api-table-wrapper" style="margin-top: 12px;">
|
||||
<table class="api-params-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数名</th>
|
||||
<th>类型</th>
|
||||
<th>描述</th>
|
||||
<th>必需</th>
|
||||
<th>示例</th>
|
||||
<th>${pName}</th>
|
||||
<th>${tLabel}</th>
|
||||
<th>${dLabel}</th>
|
||||
<th>${reqLabel}</th>
|
||||
<th>${exLabel}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -389,12 +467,12 @@ function renderRequestBody(endpoint) {
|
||||
|
||||
return `
|
||||
<div class="api-section">
|
||||
<div class="api-section-title">请求体</div>
|
||||
<div class="api-section-title">${escapeHtml(_t('apiDocs.sectionRequestBody'))}</div>
|
||||
${endpoint.requestBody.description ? `<div class="api-description">${endpoint.requestBody.description}</div>` : ''}
|
||||
${paramsTable}
|
||||
${example ? `
|
||||
<div style="margin-top: 16px;">
|
||||
<div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">示例JSON:</div>
|
||||
<div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${escapeHtml(_t('apiDocs.exampleJson'))}</div>
|
||||
<div class="api-response-example">
|
||||
<pre>${escapeHtml(example)}</pre>
|
||||
</div>
|
||||
@@ -414,11 +492,11 @@ function renderResponses(endpoint) {
|
||||
if (schema.example) {
|
||||
example = JSON.stringify(schema.example, null, 2);
|
||||
}
|
||||
|
||||
const descText = translateApiDocResponseDescFromResp(response);
|
||||
return `
|
||||
<div style="margin-bottom: 16px;">
|
||||
<strong style="color: ${status.startsWith('2') ? 'var(--success-color)' : status.startsWith('4') ? 'var(--error-color)' : 'var(--warning-color)'}">${status}</strong>
|
||||
${response.description ? `<span style="color: var(--text-secondary); margin-left: 8px;">${response.description}</span>` : ''}
|
||||
${descText ? `<span style="color: var(--text-secondary); margin-left: 8px;">${escapeHtml(descText)}</span>` : ''}
|
||||
${example ? `
|
||||
<div class="api-response-example" style="margin-top: 8px;">
|
||||
<pre>${escapeHtml(example)}</pre>
|
||||
@@ -432,7 +510,7 @@ function renderResponses(endpoint) {
|
||||
|
||||
return `
|
||||
<div class="api-section">
|
||||
<div class="api-section-title">响应</div>
|
||||
<div class="api-section-title">${escapeHtml(_t('apiDocs.sectionResponse'))}</div>
|
||||
${responseItems}
|
||||
</div>
|
||||
`;
|
||||
@@ -462,8 +540,8 @@ function renderTestSection(endpoint) {
|
||||
const bodyInputId = `test-body-${escapeId(path)}-${method}`;
|
||||
bodyInput = `
|
||||
<div class="api-test-input-group">
|
||||
<label>请求体 (JSON)</label>
|
||||
<textarea id="${bodyInputId}" class="test-body-input" placeholder='请输入JSON格式的请求体'>${defaultBody}</textarea>
|
||||
<label>${escapeHtml(_t('apiDocs.requestBodyJson'))}</label>
|
||||
<textarea id="${bodyInputId}" class="test-body-input" placeholder='${escapeHtml(_t('apiDocs.requestBodyPlaceholder'))}'>${defaultBody}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -491,7 +569,7 @@ function renderTestSection(endpoint) {
|
||||
const inputId = `test-query-${param.name}-${escapeId(path)}-${method}`;
|
||||
const defaultValue = param.schema?.default !== undefined ? param.schema.default : '';
|
||||
const placeholder = param.description || param.name;
|
||||
const required = param.required ? '<span style="color: var(--error-color);">*</span>' : '<span style="color: var(--text-muted);">可选</span>';
|
||||
const required = param.required ? '<span style="color: var(--error-color);">*</span>' : '<span style="color: var(--text-muted);">' + escapeHtml(_t('apiDocs.optional')) + '</span>';
|
||||
return `
|
||||
<div class="api-test-input-group">
|
||||
<label>${param.name} ${required}</label>
|
||||
@@ -505,33 +583,40 @@ function renderTestSection(endpoint) {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const testSectionTitle = escapeHtml(_t('apiDocs.testSection'));
|
||||
const queryParamsTitle = escapeHtml(_t('apiDocs.queryParams'));
|
||||
const sendRequestLabel = escapeHtml(_t('apiDocs.sendRequest'));
|
||||
const copyCurlLabel = escapeHtml(_t('apiDocs.copyCurl'));
|
||||
const clearResultLabel = escapeHtml(_t('apiDocs.clearResult'));
|
||||
const copyCurlTitle = escapeHtml(_t('apiDocs.copyCurlTitle'));
|
||||
const clearResultTitle = escapeHtml(_t('apiDocs.clearResultTitle'));
|
||||
return `
|
||||
<div class="api-test-section">
|
||||
<div class="api-section-title">测试接口</div>
|
||||
<div class="api-section-title">${testSectionTitle}</div>
|
||||
<div class="api-test-form">
|
||||
${pathParamsInput}
|
||||
${queryParamsInput ? `<div style="margin-top: 16px;"><div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">查询参数:</div>${queryParamsInput}</div>` : ''}
|
||||
${queryParamsInput ? `<div style="margin-top: 16px;"><div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${queryParamsTitle}</div>${queryParamsInput}</div>` : ''}
|
||||
${bodyInput}
|
||||
<div class="api-test-buttons">
|
||||
<button class="api-test-btn primary" onclick="testAPI('${method}', '${escapeHtml(path)}', '${endpoint.operationId || ''}')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
发送请求
|
||||
${sendRequestLabel}
|
||||
</button>
|
||||
<button class="api-test-btn copy-curl" onclick="copyCurlCommand(event, '${method}', '${escapeHtml(path)}')" title="复制curl命令">
|
||||
<button class="api-test-btn copy-curl" onclick="copyCurlCommand(event, '${method}', '${escapeHtml(path)}')" title="${copyCurlTitle}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
复制curl
|
||||
${copyCurlLabel}
|
||||
</button>
|
||||
<button class="api-test-btn clear-result" onclick="clearTestResult('${escapeId(path)}-${method}')" title="清除测试结果">
|
||||
<button class="api-test-btn clear-result" onclick="clearTestResult('${escapeId(path)}-${method}')" title="${clearResultTitle}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
清除结果
|
||||
${clearResultLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div id="test-result-${escapeId(path)}-${method}" class="api-test-result" style="display: none;"></div>
|
||||
@@ -548,7 +633,7 @@ async function testAPI(method, path, operationId) {
|
||||
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.className = 'api-test-result loading';
|
||||
resultDiv.textContent = '发送请求中...';
|
||||
resultDiv.textContent = _t('apiDocs.sendingRequest');
|
||||
|
||||
try {
|
||||
// 替换路径参数
|
||||
@@ -561,7 +646,7 @@ async function testAPI(method, path, operationId) {
|
||||
if (input && input.value) {
|
||||
actualPath = actualPath.replace(param, encodeURIComponent(input.value));
|
||||
} else {
|
||||
throw new Error(`路径参数 ${paramName} 不能为空`);
|
||||
throw new Error(_t('apiDocs.errorPathParamRequired', { name: paramName }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -580,7 +665,7 @@ async function testAPI(method, path, operationId) {
|
||||
if (input && input.value !== '' && input.value !== null && input.value !== undefined) {
|
||||
queryParams.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(input.value)}`);
|
||||
} else if (param.required) {
|
||||
throw new Error(`查询参数 ${param.name} 不能为空`);
|
||||
throw new Error(_t('apiDocs.errorQueryParamRequired', { name: param.name }));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -602,8 +687,7 @@ async function testAPI(method, path, operationId) {
|
||||
if (currentToken) {
|
||||
options.headers['Authorization'] = 'Bearer ' + currentToken;
|
||||
} else {
|
||||
// 如果没有token,提示用户
|
||||
throw new Error('未检测到 Token。请先在前端页面登录,然后刷新此页面。或者手动在请求头中添加 Authorization: Bearer your_token');
|
||||
throw new Error(_t('apiDocs.errorTokenRequired'));
|
||||
}
|
||||
|
||||
// 添加请求体
|
||||
@@ -614,7 +698,7 @@ async function testAPI(method, path, operationId) {
|
||||
try {
|
||||
options.body = JSON.stringify(JSON.parse(bodyInput.value.trim()));
|
||||
} catch (e) {
|
||||
throw new Error('请求体JSON格式错误: ' + e.message);
|
||||
throw new Error(_t('apiDocs.errorJsonInvalid') + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -636,7 +720,7 @@ async function testAPI(method, path, operationId) {
|
||||
|
||||
} catch (error) {
|
||||
resultDiv.className = 'api-test-result error';
|
||||
resultDiv.textContent = '请求失败: ' + error.message;
|
||||
resultDiv.textContent = _t('apiDocs.requestFailed') + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,17 +811,17 @@ function copyCurlCommand(event, method, path) {
|
||||
// 复制到剪贴板
|
||||
const button = event ? event.target.closest('button') : null;
|
||||
navigator.clipboard.writeText(curlCommand).then(() => {
|
||||
// 显示成功提示
|
||||
if (button) {
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>已复制';
|
||||
const copiedLabel = escapeHtml(_t('apiDocs.copied'));
|
||||
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>' + copiedLabel;
|
||||
button.style.color = 'var(--success-color)';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.style.color = '';
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('curl命令已复制到剪贴板!');
|
||||
alert(_t('apiDocs.curlCopied'));
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('复制失败:', err);
|
||||
@@ -752,24 +836,25 @@ function copyCurlCommand(event, method, path) {
|
||||
document.execCommand('copy');
|
||||
if (button) {
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>已复制';
|
||||
const copiedLabel = escapeHtml(_t('apiDocs.copied'));
|
||||
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>' + copiedLabel;
|
||||
button.style.color = 'var(--success-color)';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.style.color = '';
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('curl命令已复制到剪贴板!');
|
||||
alert(_t('apiDocs.curlCopied'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('复制失败,请手动复制:\n\n' + curlCommand);
|
||||
alert(_t('apiDocs.copyFailedManual') + curlCommand);
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('生成curl命令失败:', error);
|
||||
alert('生成curl命令失败: ' + error.message);
|
||||
alert(_t('apiDocs.curlGenFailed') + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,10 +1020,10 @@ function toggleDescription(button) {
|
||||
if (detail.style.display === 'none') {
|
||||
detail.style.display = 'block';
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
span.textContent = '隐藏详细说明';
|
||||
span.textContent = typeof window.t === 'function' ? window.t('apiDocs.hideDetailDesc') : '隐藏详细说明';
|
||||
} else {
|
||||
detail.style.display = 'none';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
span.textContent = '查看详细说明';
|
||||
span.textContent = typeof window.t === 'function' ? window.t('apiDocs.viewDetailDesc') : '查看详细说明';
|
||||
}
|
||||
}
|
||||
|
||||
+34
-14
@@ -123,12 +123,20 @@ async function ensureAuthenticated() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleUnauthorized({ message = '认证已过期,请重新登录', silent = false } = {}) {
|
||||
function handleUnauthorized({ message = null, silent = false } = {}) {
|
||||
clearAuthStorage();
|
||||
authPromise = null;
|
||||
authPromiseResolvers = [];
|
||||
let finalMessage = message;
|
||||
if (!finalMessage) {
|
||||
if (typeof window !== 'undefined' && typeof window.t === 'function') {
|
||||
finalMessage = window.t('auth.sessionExpired');
|
||||
} else {
|
||||
finalMessage = '认证已过期,请重新登录';
|
||||
}
|
||||
}
|
||||
if (!silent) {
|
||||
showLoginOverlay(message);
|
||||
showLoginOverlay(finalMessage);
|
||||
} else {
|
||||
showLoginOverlay();
|
||||
}
|
||||
@@ -147,7 +155,10 @@ async function apiFetch(url, options = {}) {
|
||||
const response = await fetch(url, opts);
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized();
|
||||
throw new Error('未授权访问');
|
||||
const msg = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('auth.unauthorized')
|
||||
: '未授权访问';
|
||||
throw new Error(msg);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -165,7 +176,10 @@ async function submitLogin(event) {
|
||||
const password = passwordInput.value.trim();
|
||||
if (!password) {
|
||||
if (errorBox) {
|
||||
errorBox.textContent = '请输入密码';
|
||||
const msgEmpty = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('auth.enterPassword')
|
||||
: '请输入密码';
|
||||
errorBox.textContent = msgEmpty;
|
||||
errorBox.style.display = 'block';
|
||||
}
|
||||
return;
|
||||
@@ -186,7 +200,10 @@ async function submitLogin(event) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !result.token) {
|
||||
if (errorBox) {
|
||||
errorBox.textContent = result.error || '登录失败,请检查密码';
|
||||
const fallback = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('auth.loginFailedCheck')
|
||||
: '登录失败,请检查密码';
|
||||
errorBox.textContent = result.error || fallback;
|
||||
errorBox.style.display = 'block';
|
||||
}
|
||||
return;
|
||||
@@ -203,7 +220,10 @@ async function submitLogin(event) {
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
if (errorBox) {
|
||||
errorBox.textContent = '登录失败,请稍后重试';
|
||||
const fallback = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('auth.loginFailedRetry')
|
||||
: '登录失败,请稍后重试';
|
||||
errorBox.textContent = fallback;
|
||||
errorBox.style.display = 'block';
|
||||
}
|
||||
} finally {
|
||||
@@ -230,13 +250,13 @@ async function bootstrapApp() {
|
||||
|
||||
// 通用工具函数
|
||||
function getStatusText(status) {
|
||||
const statusMap = {
|
||||
'pending': '等待中',
|
||||
'running': '执行中',
|
||||
'completed': '已完成',
|
||||
'failed': '失败'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
if (typeof window.t !== 'function') {
|
||||
const fallback = { pending: '等待中', running: '执行中', completed: '已完成', failed: '失败' };
|
||||
return fallback[status] || status;
|
||||
}
|
||||
const keyMap = { pending: 'mcpDetailModal.statusPending', running: 'mcpDetailModal.statusRunning', completed: 'mcpDetailModal.statusCompleted', failed: 'mcpDetailModal.statusFailed' };
|
||||
const key = keyMap[status];
|
||||
return key ? window.t(key) : status;
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
@@ -375,7 +395,7 @@ async function logout() {
|
||||
// 无论如何都清除本地认证信息
|
||||
clearAuthStorage();
|
||||
hideLoginOverlay();
|
||||
showLoginOverlay('已退出登录');
|
||||
showLoginOverlay(typeof window.t === 'function' ? window.t('auth.loggedOut') : '已退出登录');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+365
-122
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -17,7 +17,7 @@ async function refreshDashboard() {
|
||||
setEl('dashboard-kpi-tools-calls', '…');
|
||||
setEl('dashboard-kpi-success-rate', '…');
|
||||
var chartPlaceholder = document.getElementById('dashboard-tools-pie-placeholder');
|
||||
if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = '加载中…'; }
|
||||
if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); }
|
||||
var barChartEl = document.getElementById('dashboard-tools-bar-chart');
|
||||
if (barChartEl) { barChartEl.style.display = 'none'; barChartEl.innerHTML = ''; }
|
||||
|
||||
@@ -77,7 +77,7 @@ async function refreshDashboard() {
|
||||
setEl('dashboard-batch-pending', String(pending));
|
||||
setEl('dashboard-batch-running', String(running));
|
||||
setEl('dashboard-batch-done', String(done));
|
||||
setEl('dashboard-batch-total', total > 0 ? `共 ${total} 个` : '暂无任务');
|
||||
setEl('dashboard-batch-total', total > 0 ? (typeof window.t === 'function' ? window.t('dashboard.totalCount', { count: total }) : `共 ${total} 个`) : (typeof window.t === 'function' ? window.t('dashboard.noTasks') : '暂无任务'));
|
||||
|
||||
// 更新进度条
|
||||
if (total > 0) {
|
||||
@@ -138,7 +138,7 @@ async function refreshDashboard() {
|
||||
if (knowledgeRes && typeof knowledgeRes === 'object') {
|
||||
if (knowledgeRes.enabled === false) {
|
||||
// 功能未启用:用状态标签展示,数值保持为 "-"
|
||||
if (knowledgeStatusEl) knowledgeStatusEl.textContent = '未启用';
|
||||
if (knowledgeStatusEl) knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.notEnabled') : '未启用');
|
||||
if (knowledgeItemsEl) knowledgeItemsEl.textContent = '-';
|
||||
if (knowledgeCategoriesEl) knowledgeCategoriesEl.textContent = '-';
|
||||
} else {
|
||||
@@ -149,9 +149,9 @@ async function refreshDashboard() {
|
||||
// 根据数据量给个轻量状态文案
|
||||
if (knowledgeStatusEl) {
|
||||
if (items > 0 || categories > 0) {
|
||||
knowledgeStatusEl.textContent = '已启用';
|
||||
knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.enabled') : '已启用');
|
||||
} else {
|
||||
knowledgeStatusEl.textContent = '待配置';
|
||||
knowledgeStatusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.toConfigure') : '待配置');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,15 +172,15 @@ async function refreshDashboard() {
|
||||
const statusEl = document.getElementById('dashboard-skills-status');
|
||||
if (statusEl) {
|
||||
if (totalCalls === 0) {
|
||||
statusEl.textContent = '待使用';
|
||||
statusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.toUse') : '待使用');
|
||||
statusEl.style.background = 'rgba(0, 0, 0, 0.05)';
|
||||
statusEl.style.color = 'var(--text-secondary)';
|
||||
} else if (totalCalls < 10) {
|
||||
statusEl.textContent = '活跃';
|
||||
statusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.active') : '活跃');
|
||||
statusEl.style.background = 'rgba(16, 185, 129, 0.1)';
|
||||
statusEl.style.color = '#10b981';
|
||||
} else {
|
||||
statusEl.textContent = '高频';
|
||||
statusEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.highFreq') : '高频');
|
||||
statusEl.style.background = 'rgba(59, 130, 246, 0.1)';
|
||||
statusEl.style.color = '#3b82f6';
|
||||
}
|
||||
@@ -200,7 +200,7 @@ async function refreshDashboard() {
|
||||
setEl('dashboard-kpi-tools-calls', '-');
|
||||
renderDashboardToolsBar(null);
|
||||
var ph = document.getElementById('dashboard-tools-pie-placeholder');
|
||||
if (ph) { ph.style.removeProperty('display'); ph.textContent = '暂无调用数据'; }
|
||||
if (ph) { ph.style.removeProperty('display'); ph.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ function renderDashboardToolsBar(monitorRes) {
|
||||
|
||||
if (!monitorRes || typeof monitorRes !== 'object') {
|
||||
placeholder.style.removeProperty('display');
|
||||
placeholder.textContent = '暂无调用数据';
|
||||
placeholder.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据');
|
||||
barChartEl.style.display = 'none';
|
||||
barChartEl.innerHTML = '';
|
||||
return;
|
||||
@@ -273,7 +273,7 @@ function renderDashboardToolsBar(monitorRes) {
|
||||
|
||||
if (entries.length === 0) {
|
||||
placeholder.style.removeProperty('display');
|
||||
placeholder.textContent = '暂无调用数据';
|
||||
placeholder.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据');
|
||||
barChartEl.style.display = 'none';
|
||||
barChartEl.innerHTML = '';
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// 前端国际化初始化(基于 i18next 浏览器版本)
|
||||
(function () {
|
||||
const DEFAULT_LANG = 'zh-CN';
|
||||
const STORAGE_KEY = 'csai_lang';
|
||||
const RESOURCES_PREFIX = '/static/i18n';
|
||||
|
||||
const loadedLangs = {};
|
||||
|
||||
function detectInitialLang() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return stored;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('无法读取语言设置:', e);
|
||||
}
|
||||
|
||||
const navLang = (navigator.language || navigator.userLanguage || '').toLowerCase();
|
||||
if (navLang.startsWith('zh')) {
|
||||
return 'zh-CN';
|
||||
}
|
||||
if (navLang.startsWith('en')) {
|
||||
return 'en-US';
|
||||
}
|
||||
return DEFAULT_LANG;
|
||||
}
|
||||
|
||||
async function loadLanguageResources(lang) {
|
||||
if (loadedLangs[lang]) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(RESOURCES_PREFIX + '/' + lang + '.json', {
|
||||
cache: 'no-cache'
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('加载语言包失败:', lang, resp.status);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (typeof i18next !== 'undefined') {
|
||||
i18next.addResourceBundle(lang, 'translation', data, true, true);
|
||||
}
|
||||
loadedLangs[lang] = true;
|
||||
} catch (e) {
|
||||
console.error('加载语言包异常:', lang, e);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTranslations(root) {
|
||||
if (typeof i18next === 'undefined') return;
|
||||
const container = root || document;
|
||||
if (!container) return;
|
||||
|
||||
const elements = container.querySelectorAll('[data-i18n]');
|
||||
elements.forEach(function (el) {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
if (!key) return;
|
||||
const skipText = el.getAttribute('data-i18n-skip-text') === 'true';
|
||||
const isFormControl = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA');
|
||||
const attrList = el.getAttribute('data-i18n-attr');
|
||||
const text = i18next.t(key);
|
||||
// 仅当元素无子元素(仅文本或空)时才替换文本,避免覆盖卡片内的数字、子节点等;input/textarea 永不设置 textContent
|
||||
const hasNoElementChildren = !el.querySelector('*');
|
||||
if (!skipText && !isFormControl && hasNoElementChildren && text && typeof text === 'string') {
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
if (attrList) {
|
||||
const titleKey = el.getAttribute('data-i18n-title');
|
||||
attrList.split(',').map(function (s) { return s.trim(); }).forEach(function (attr) {
|
||||
if (!attr) return;
|
||||
var val = text;
|
||||
if (attr === 'title' && titleKey) {
|
||||
var titleText = i18next.t(titleKey);
|
||||
if (titleText && typeof titleText === 'string') val = titleText;
|
||||
}
|
||||
if (val && typeof val === 'string') {
|
||||
el.setAttribute(attr, val);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 对话输入框:若 value 与 placeholder 相同,清空 value 以便正确显示占位提示
|
||||
try {
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
if (chatInput && chatInput.tagName === 'TEXTAREA') {
|
||||
const ph = (chatInput.getAttribute('placeholder') || '').trim();
|
||||
if (ph && chatInput.value.trim() === ph) {
|
||||
chatInput.value = '';
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// 更新 html lang 属性
|
||||
try {
|
||||
if (document && document.documentElement) {
|
||||
document.documentElement.lang = i18next.language || DEFAULT_LANG;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function updateLangLabel() {
|
||||
const label = document.getElementById('current-lang-label');
|
||||
if (!label || typeof i18next === 'undefined') return;
|
||||
const lang = (i18next.language || DEFAULT_LANG).toLowerCase();
|
||||
if (lang.indexOf('zh') === 0) {
|
||||
label.textContent = i18next.t('lang.zhCN');
|
||||
} else {
|
||||
label.textContent = i18next.t('lang.enUS');
|
||||
}
|
||||
}
|
||||
|
||||
function closeLangDropdown() {
|
||||
const dropdown = document.getElementById('lang-dropdown');
|
||||
if (dropdown) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalClickForLangDropdown(ev) {
|
||||
const dropdown = document.getElementById('lang-dropdown');
|
||||
const btn = document.querySelector('.lang-switcher-btn');
|
||||
if (!dropdown || dropdown.style.display !== 'block') return;
|
||||
const target = ev.target;
|
||||
if (btn && btn.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (!dropdown.contains(target)) {
|
||||
closeLangDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
async function changeLanguage(lang) {
|
||||
if (typeof i18next === 'undefined') return;
|
||||
const current = i18next.language || DEFAULT_LANG;
|
||||
if (lang === current) return;
|
||||
await loadLanguageResources(lang);
|
||||
await i18next.changeLanguage(lang);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
} catch (e) {
|
||||
console.warn('无法保存语言设置:', e);
|
||||
}
|
||||
applyTranslations(document);
|
||||
updateLangLabel();
|
||||
try {
|
||||
window.__locale = lang;
|
||||
} catch (e) { /* ignore */ }
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('languagechange', { detail: { lang: lang } }));
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function initI18n() {
|
||||
if (typeof i18next === 'undefined') {
|
||||
console.warn('i18next 未加载,跳过前端国际化初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const initialLang = detectInitialLang();
|
||||
await i18next.init({
|
||||
lng: initialLang,
|
||||
fallbackLng: DEFAULT_LANG,
|
||||
debug: false,
|
||||
resources: {}
|
||||
});
|
||||
|
||||
await loadLanguageResources(initialLang);
|
||||
applyTranslations(document);
|
||||
updateLangLabel();
|
||||
try {
|
||||
window.__locale = i18next.language || initialLang;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// 导出全局函数供其他脚本调用(支持插值参数,如 _t('key', { count: 2 }))
|
||||
window.t = function (key, opts) {
|
||||
if (typeof i18next === 'undefined') return key;
|
||||
return i18next.t(key, opts);
|
||||
};
|
||||
window.changeLanguage = changeLanguage;
|
||||
window.applyTranslations = applyTranslations;
|
||||
|
||||
// 语言切换下拉支持
|
||||
window.toggleLangDropdown = function () {
|
||||
const dropdown = document.getElementById('lang-dropdown');
|
||||
if (!dropdown) return;
|
||||
if (dropdown.style.display === 'block') {
|
||||
dropdown.style.display = 'none';
|
||||
} else {
|
||||
dropdown.style.display = 'block';
|
||||
}
|
||||
};
|
||||
window.onLanguageSelect = function (lang) {
|
||||
changeLanguage(lang);
|
||||
closeLangDropdown();
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleGlobalClickForLangDropdown);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// i18n 初始化在 DOM Ready 后执行
|
||||
initI18n().catch(function (e) {
|
||||
console.error('初始化国际化失败:', e);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// 信息收集页面(FOFA)
|
||||
function _t(key, opts) {
|
||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||
}
|
||||
|
||||
const FOFA_FORM_STORAGE_KEY = 'info-collect-fofa-form';
|
||||
const FOFA_HIDDEN_FIELDS_STORAGE_KEY = 'info-collect-fofa-hidden-fields';
|
||||
@@ -197,12 +200,12 @@ async function submitFofaSearch() {
|
||||
const full = !!els.full?.checked;
|
||||
|
||||
if (!query) {
|
||||
alert('请输入 FOFA 查询语法');
|
||||
alert(_t('infoCollect.enterFofaQuery'));
|
||||
return;
|
||||
}
|
||||
|
||||
saveFofaFormToStorage({ query, size, page, fields, full });
|
||||
setFofaMeta('查询中...');
|
||||
setFofaMeta(_t('infoCollect.querying'));
|
||||
setFofaLoading(true);
|
||||
|
||||
try {
|
||||
@@ -219,9 +222,9 @@ async function submitFofaSearch() {
|
||||
renderFofaResults(result);
|
||||
} catch (e) {
|
||||
console.error('FOFA 查询失败:', e);
|
||||
setFofaMeta('查询失败');
|
||||
setFofaMeta(_t('infoCollect.queryFailed'));
|
||||
renderFofaResults({ query, fields: [], results: [], total: 0, page: 1, size: 0 });
|
||||
alert('FOFA 查询失败: ' + (e && e.message ? e.message : String(e)));
|
||||
alert(_t('infoCollect.queryFailed') + ': ' + (e && e.message ? e.message : String(e)));
|
||||
} finally {
|
||||
setFofaLoading(false);
|
||||
}
|
||||
@@ -231,7 +234,7 @@ async function parseFofaNaturalLanguage() {
|
||||
const els = getFofaFormElements();
|
||||
const text = (els.nl?.value || '').trim();
|
||||
if (!text) {
|
||||
alert('请输入自然语言描述');
|
||||
alert(_t('infoCollect.enterNaturalLanguage'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -243,16 +246,16 @@ async function parseFofaNaturalLanguage() {
|
||||
|
||||
// 先创建 controller,避免极快的重复点击触发并发请求
|
||||
fofaParseAbortController = new AbortController();
|
||||
setFofaParseLoading(true, 'AI 解析中...');
|
||||
setFofaParseLoading(true, _t('infoCollect.parsePending'));
|
||||
|
||||
// 持续提示:直到请求完成/取消/失败才消失
|
||||
fofaParseToastHandle = showInlineToast('AI 解析中...(点击按钮可取消)', { duration: 0, id: 'fofa-parse-pending' });
|
||||
fofaParseToastHandle = showInlineToast(_t('infoCollect.parsePendingClickCancel'), { duration: 0, id: 'fofa-parse-pending' });
|
||||
|
||||
// 如果超过一小段时间还没返回,再强调“仍在进行中”,降低误判为失败的概率
|
||||
fofaParseSlowTimer = setTimeout(() => {
|
||||
const status = document.getElementById('fofa-nl-status');
|
||||
if (status) {
|
||||
status.textContent = 'AI 解析耗时较长,仍在处理中…';
|
||||
status.textContent = _t('infoCollect.parseSlow');
|
||||
status.style.display = 'block';
|
||||
}
|
||||
}, 1800);
|
||||
@@ -269,15 +272,15 @@ async function parseFofaNaturalLanguage() {
|
||||
throw new Error(result.error || `请求失败: ${resp.status}`);
|
||||
}
|
||||
showFofaParseModal(text, result);
|
||||
showInlineToast('AI 解析完成');
|
||||
showInlineToast(_t('infoCollect.parseDone'));
|
||||
} catch (e) {
|
||||
// AbortController 取消:不视为失败
|
||||
if (e && (e.name === 'AbortError' || String(e).includes('AbortError'))) {
|
||||
showInlineToast('已取消 AI 解析');
|
||||
showInlineToast(_t('infoCollect.parseCancelled'));
|
||||
return;
|
||||
}
|
||||
console.error('FOFA 自然语言解析失败:', e);
|
||||
showInlineToast('AI 解析失败:' + (e && e.message ? e.message : String(e)), { duration: 2800 });
|
||||
showInlineToast(_t('infoCollect.parseFailed') + (e && e.message ? e.message : String(e)), { duration: 2800 });
|
||||
}
|
||||
finally {
|
||||
fofaParseAbortController = null;
|
||||
@@ -298,17 +301,17 @@ function setFofaParseLoading(loading, statusText) {
|
||||
const status = document.getElementById('fofa-nl-status');
|
||||
if (btn) {
|
||||
if (loading) {
|
||||
if (!btn.dataset.originalText) btn.dataset.originalText = btn.textContent || 'AI 解析';
|
||||
if (!btn.dataset.originalText) btn.dataset.originalText = btn.textContent || _t('infoCollectPage.parseBtn');
|
||||
btn.classList.add('btn-loading');
|
||||
btn.textContent = '取消解析';
|
||||
btn.title = '点击取消 AI 解析';
|
||||
btn.textContent = _t('infoCollect.cancelParse');
|
||||
btn.title = _t('infoCollect.clickToCancelParse');
|
||||
btn.dataset.loading = '1';
|
||||
btn.setAttribute('aria-busy', 'true');
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
btn.classList.remove('btn-loading');
|
||||
btn.textContent = btn.dataset.originalText || 'AI 解析';
|
||||
btn.title = '将自然语言解析为 FOFA 查询语法';
|
||||
btn.textContent = btn.dataset.originalText || _t('infoCollectPage.parseBtn');
|
||||
btn.title = _t('infoCollect.parseToFofa');
|
||||
btn.disabled = false;
|
||||
delete btn.dataset.loading;
|
||||
btn.removeAttribute('aria-busy');
|
||||
@@ -336,7 +339,7 @@ function showFofaParseModal(nlText, parsed) {
|
||||
|
||||
const warningsHtml = warnings.length
|
||||
? `<ul style="margin: 8px 0 0 18px;">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}</ul>`
|
||||
: `<div class="muted" style="margin-top: 8px;">无</div>`;
|
||||
: '<div class="muted" style="margin-top: 8px;">' + _t('infoCollect.none') + '</div>';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'fofa-parse-modal';
|
||||
@@ -345,23 +348,23 @@ function showFofaParseModal(nlText, parsed) {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>AI 解析结果</h2>
|
||||
<span class="modal-close" id="fofa-parse-modal-close" title="关闭">×</span>
|
||||
<h2>${_t('infoCollect.parseResultTitle')}</h2>
|
||||
<span class="modal-close" id="fofa-parse-modal-close" title="${_t('common.close')}">×</span>
|
||||
</div>
|
||||
<div style="padding: 18px 28px; overflow: auto;">
|
||||
<div class="form-group">
|
||||
<label>自然语言</label>
|
||||
<label>${_t('infoCollect.naturalLanguageLabel')}</label>
|
||||
<div class="muted" style="margin-top: 6px; white-space: pre-wrap;">${safeNL || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<label for="fofa-parse-query">FOFA 查询语法(可编辑)</label>
|
||||
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder='例如:app="Apache" && country="CN"'></textarea>
|
||||
<small class="form-hint">请人工确认语法与范围无误后再执行查询。</small>
|
||||
<label for="fofa-parse-query">${_t('infoCollect.fofaQueryEditable')}</label>
|
||||
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder="${_t('infoCollect.queryPlaceholder')}"></textarea>
|
||||
<small class="form-hint">${_t('infoCollect.confirmBeforeQuery')}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<label>提醒</label>
|
||||
<label>${_t('infoCollect.reminder')}</label>
|
||||
<div style="background: #fff8e1; border: 1px solid #ffe8a3; border-radius: 10px; padding: 10px 12px;">
|
||||
${warningsHtml}
|
||||
</div>
|
||||
@@ -369,14 +372,14 @@ function showFofaParseModal(nlText, parsed) {
|
||||
|
||||
${explanation ? `
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<label>解析说明</label>
|
||||
<label>${_t('infoCollect.explanation')}</label>
|
||||
<pre style="margin-top: 8px; white-space: pre-wrap; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 10px; padding: 10px 12px; font-size: 13px;">${escapeHtml(explanation)}</pre>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="modal-footer" style="padding: 18px 28px;">
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-cancel">取消</button>
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-apply">填入查询框</button>
|
||||
<button class="btn-primary" type="button" id="fofa-parse-apply-run">填入并查询</button>
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-cancel">${_t('infoCollect.parseModalCancel')}</button>
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-apply">${_t('infoCollect.parseModalApply')}</button>
|
||||
<button class="btn-primary" type="button" id="fofa-parse-apply-run">${_t('infoCollect.parseModalApplyRun')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -402,7 +405,7 @@ function showFofaParseModal(nlText, parsed) {
|
||||
const els = getFofaFormElements();
|
||||
const q = (queryTextarea?.value || '').trim();
|
||||
if (!q) {
|
||||
showInlineToast('解析结果为空:请在弹窗中补充/修改 FOFA 查询语法', { duration: 2600 });
|
||||
showInlineToast(_t('infoCollect.parseResultEmpty'), { duration: 2600 });
|
||||
return;
|
||||
}
|
||||
if (els.query) {
|
||||
@@ -444,7 +447,7 @@ function setFofaMeta(text) {
|
||||
function updateSelectedMeta() {
|
||||
const els = getFofaFormElements();
|
||||
if (els.selectedMeta) {
|
||||
els.selectedMeta.textContent = `已选择 ${infoCollectState.selectedRowIndexes.size} 条`;
|
||||
els.selectedMeta.textContent = _t('infoCollectPage.selectedRows', { count: infoCollectState.selectedRowIndexes.size });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +457,7 @@ function setFofaLoading(loading) {
|
||||
if (loading) {
|
||||
const fieldsCount = (document.getElementById('fofa-fields')?.value || '').split(',').filter(Boolean).length;
|
||||
const colspan = Math.max(1, fieldsCount + 1);
|
||||
els.tbody.innerHTML = `<tr><td class="muted" style="padding: 16px;" colspan="${colspan}">加载中...</td></tr>`;
|
||||
els.tbody.innerHTML = '<tr><td class="muted" style="padding: 16px;" colspan="' + colspan + '">' + escapeHtml(_t('infoCollect.loading')) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,7 +493,7 @@ function renderFofaResults(payload) {
|
||||
const size = typeof payload.size === 'number' ? payload.size : 0;
|
||||
const page = typeof payload.page === 'number' ? payload.page : 1;
|
||||
|
||||
setFofaMeta(`共 ${total} 条 · 本页 ${results.length} 条 · page=${page} · size=${size}`);
|
||||
setFofaMeta(_t('infoCollect.resultsMeta', { total, count: results.length, page, size }));
|
||||
|
||||
// 可见字段
|
||||
const visibleFields = fields.filter(f => !infoCollectState.hiddenFields.has(f));
|
||||
@@ -500,16 +503,16 @@ function renderFofaResults(payload) {
|
||||
|
||||
// 表头(左:勾选列;右:操作列固定)
|
||||
const headerCells = [
|
||||
'<th class="info-collect-col-select"><input type="checkbox" id="fofa-select-all" title="全选/全不选"/></th>',
|
||||
'<th class="info-collect-col-select"><input type="checkbox" id="fofa-select-all" title="' + escapeHtml(_t('infoCollect.selectAll')) + '"/></th>',
|
||||
...visibleFields.map(f => `<th>${escapeHtml(String(f))}</th>`),
|
||||
'<th class="info-collect-col-actions">操作</th>'
|
||||
'<th class="info-collect-col-actions">' + escapeHtml(_t('infoCollect.actions')) + '</th>'
|
||||
].join('');
|
||||
els.thead.innerHTML = `<tr>${headerCells}</tr>`;
|
||||
|
||||
// 表体
|
||||
if (results.length === 0) {
|
||||
const colspan = Math.max(1, visibleFields.length + 2);
|
||||
els.tbody.innerHTML = `<tr><td class="muted" style="padding: 16px;" colspan="${colspan}">暂无数据</td></tr>`;
|
||||
els.tbody.innerHTML = '<tr><td class="muted" style="padding: 16px;" colspan="' + colspan + '">' + escapeHtml(_t('common.noData')) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -519,7 +522,7 @@ function renderFofaResults(payload) {
|
||||
const encoded = encodeURIComponent(JSON.stringify(safeRow));
|
||||
const encodedTarget = encodeURIComponent(target || '');
|
||||
|
||||
const selectHtml = `<td class="info-collect-col-select"><input class="fofa-row-select" type="checkbox" data-index="${idx}" title="选择该行"/></td>`;
|
||||
const selectHtml = '<td class="info-collect-col-select"><input class="fofa-row-select" type="checkbox" data-index="' + idx + '" title="' + escapeHtml(_t('infoCollect.selectRow')) + '"/></td>';
|
||||
|
||||
const cellsHtml = visibleFields.map(f => {
|
||||
const val = safeRow[f];
|
||||
@@ -537,13 +540,13 @@ function renderFofaResults(payload) {
|
||||
|
||||
const actionHtml = `
|
||||
<div class="info-collect-actions">
|
||||
<button class="btn-icon" onclick="copyFofaTargetEncoded('${encodedTarget}'); event.stopPropagation();" title="复制目标">
|
||||
<button class="btn-icon" onclick="copyFofaTargetEncoded('${encodedTarget}'); event.stopPropagation();" title="${escapeHtml(_t('infoCollect.copyTarget'))}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" onclick="scanFofaRow('${encoded}', event); event.stopPropagation();" title="发送到对话(可编辑;Ctrl/⌘+点击可直接发送)">
|
||||
<button class="btn-icon" onclick="scanFofaRow('${encoded}', event); event.stopPropagation();" title="${escapeHtml(_t('infoCollect.sendToChat'))}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.5 13.5l3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M8 8H5a4 4 0 1 0 0 8h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
@@ -602,14 +605,14 @@ function normalizeHttpLink(raw) {
|
||||
function copyFofaTarget(target) {
|
||||
const text = (target || '').trim();
|
||||
if (!text) {
|
||||
alert('没有可复制的目标');
|
||||
alert(_t('infoCollect.noTargetToCopy'));
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// 简单提示
|
||||
showInlineToast('已复制目标');
|
||||
showInlineToast(_t('infoCollect.targetCopied'));
|
||||
}).catch(() => {
|
||||
alert('复制失败,请手动复制:' + text);
|
||||
alert(_t('infoCollect.manualCopyHint') + text);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -655,7 +658,7 @@ function showInlineToast(text, options) {
|
||||
function truncateForPreview(value, maxLen) {
|
||||
const s = value == null ? '' : String(value);
|
||||
if (maxLen <= 0 || s.length <= maxLen) return s;
|
||||
return s.slice(0, maxLen) + '...(已截断)';
|
||||
return s.slice(0, maxLen) + '...(' + _t('infoCollect.truncated') + ')';
|
||||
}
|
||||
|
||||
function formatFofaRowSummary(row, fields) {
|
||||
@@ -707,7 +710,7 @@ function scanFofaRow(encodedRowJson, clickEvent) {
|
||||
const fields = (document.getElementById('fofa-fields')?.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const target = inferTargetFromRow(row, fields);
|
||||
if (!target) {
|
||||
alert('无法从该行推断扫描目标(建议在 fields 中包含 host/ip/port/domain)');
|
||||
alert(_t('infoCollect.cannotInferTarget'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -745,10 +748,10 @@ function scanFofaRow(encodedRowJson, clickEvent) {
|
||||
if (typeof sendMessage === 'function') {
|
||||
sendMessage();
|
||||
} else {
|
||||
alert('未找到 sendMessage(),请刷新页面后重试');
|
||||
alert(_t('infoCollect.noSendMessage'));
|
||||
}
|
||||
} else {
|
||||
showInlineToast('已填入对话输入框,可编辑后发送');
|
||||
showInlineToast(_t('infoCollect.filledToInput'));
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
@@ -910,7 +913,7 @@ function hideAllFofaColumns() {
|
||||
function exportFofaResults(format) {
|
||||
const p = infoCollectState.currentPayload;
|
||||
if (!p || !Array.isArray(p.results) || p.results.length === 0) {
|
||||
alert('暂无可导出的结果');
|
||||
alert(_t('infoCollect.noExportResult'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -936,7 +939,7 @@ function exportFofaResults(format) {
|
||||
if (format === 'xlsx') {
|
||||
// 使用 SheetJS 生成 XLSX(需在页面中引入 xlsx 库)
|
||||
if (typeof XLSX === 'undefined') {
|
||||
alert('未加载 XLSX 库,请刷新页面后重试');
|
||||
alert(_t('infoCollect.xlsxNotLoaded'));
|
||||
return;
|
||||
}
|
||||
const aoa = [visibleFields].concat(p.results.map(row => {
|
||||
@@ -945,7 +948,7 @@ function exportFofaResults(format) {
|
||||
}));
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'FOFA结果');
|
||||
XLSX.utils.book_append_sheet(wb, ws, _t('infoCollect.batchScanTitle'));
|
||||
XLSX.writeFile(wb, `fofa_results_${ts}.xlsx`);
|
||||
return;
|
||||
}
|
||||
@@ -982,12 +985,12 @@ function downloadBlob(content, filename, mime) {
|
||||
async function batchScanSelectedFofaRows() {
|
||||
const p = infoCollectState.currentPayload;
|
||||
if (!p || !Array.isArray(p.results) || p.results.length === 0) {
|
||||
alert('暂无结果');
|
||||
alert(_t('infoCollect.noResults'));
|
||||
return;
|
||||
}
|
||||
const selected = Array.from(infoCollectState.selectedRowIndexes).sort((a, b) => a - b);
|
||||
if (selected.length === 0) {
|
||||
alert('请先勾选需要扫描的行');
|
||||
alert(_t('infoCollect.selectRowsFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1009,11 +1012,11 @@ async function batchScanSelectedFofaRows() {
|
||||
});
|
||||
|
||||
if (tasks.length === 0) {
|
||||
alert('未能从所选行推断任何可扫描目标(建议 fields 中包含 host/ip/port/domain)');
|
||||
alert(_t('infoCollect.noScanTarget'));
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (p.query ? `FOFA 批量扫描:${p.query}` : 'FOFA 批量扫描').slice(0, 80);
|
||||
const title = (p.query ? _t('infoCollect.batchScanTitle') + ':' + p.query : _t('infoCollect.batchScanTitle')).slice(0, 80);
|
||||
try {
|
||||
// 不强制切换到“信息收集”角色:沿用当前已选角色;若为默认则传空字符串交给后端走默认逻辑
|
||||
let role = '';
|
||||
@@ -1029,7 +1032,7 @@ async function batchScanSelectedFofaRows() {
|
||||
});
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
throw new Error(result.error || `创建批量队列失败: ${resp.status}`);
|
||||
throw new Error(result.error || _t('infoCollect.createQueueFailed') + ': ' + resp.status);
|
||||
}
|
||||
const queueId = result.queueId;
|
||||
if (!queueId) {
|
||||
@@ -1045,13 +1048,13 @@ async function batchScanSelectedFofaRows() {
|
||||
}, 250);
|
||||
|
||||
if (skipped.length > 0) {
|
||||
showInlineToast(`已创建队列(跳过 ${skipped.length} 条无目标行)`);
|
||||
showInlineToast(_t('infoCollect.queueCreatedSkipped', { n: skipped.length }));
|
||||
} else {
|
||||
showInlineToast('已创建批量扫描队列');
|
||||
showInlineToast(_t('infoCollect.batchQueueCreated'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('批量扫描失败:', e);
|
||||
alert('批量扫描失败: ' + (e && e.message ? e.message : String(e)));
|
||||
alert(_t('infoCollect.batchScanFailed') + ': ' + (e && e.message ? e.message : String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1065,8 +1068,8 @@ function showCellDetailModal(field, fullText) {
|
||||
modal.innerHTML = `
|
||||
<div class="info-collect-cell-modal-content" role="dialog" aria-modal="true">
|
||||
<div class="info-collect-cell-modal-header">
|
||||
<div class="info-collect-cell-modal-title">${escapeHtml(field || '字段')}</div>
|
||||
<button class="btn-icon" type="button" id="info-collect-cell-modal-close" title="关闭">
|
||||
<div class="info-collect-cell-modal-title">${escapeHtml(field || _t('infoCollect.field'))}</div>
|
||||
<button class="btn-icon" type="button" id="info-collect-cell-modal-close" title="${_t('common.close')}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
@@ -1076,8 +1079,8 @@ function showCellDetailModal(field, fullText) {
|
||||
<pre class="info-collect-cell-modal-pre">${escapeHtml(fullText || '')}</pre>
|
||||
</div>
|
||||
<div class="info-collect-cell-modal-footer">
|
||||
<button class="btn-secondary" type="button" id="info-collect-cell-modal-copy">复制</button>
|
||||
<button class="btn-primary" type="button" id="info-collect-cell-modal-ok">关闭</button>
|
||||
<button class="btn-secondary" type="button" id="info-collect-cell-modal-copy">${_t('common.copy')}</button>
|
||||
<button class="btn-primary" type="button" id="info-collect-cell-modal-ok">${_t('common.close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1091,7 +1094,7 @@ function showCellDetailModal(field, fullText) {
|
||||
document.getElementById('info-collect-cell-modal-close')?.addEventListener('click', close);
|
||||
document.getElementById('info-collect-cell-modal-ok')?.addEventListener('click', close);
|
||||
document.getElementById('info-collect-cell-modal-copy')?.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(fullText || '').then(() => showInlineToast('已复制')).catch(() => alert('复制失败'));
|
||||
navigator.clipboard.writeText(fullText || '').then(() => showInlineToast(_t('common.copied'))).catch(() => alert(_t('common.copyFailed')));
|
||||
});
|
||||
|
||||
// Esc 关闭
|
||||
@@ -1122,3 +1125,13 @@ window.toggleFofaColumn = toggleFofaColumn;
|
||||
window.exportFofaResults = exportFofaResults;
|
||||
window.batchScanSelectedFofaRows = batchScanSelectedFofaRows;
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
updateSelectedMeta();
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { updateSelectedMeta(); });
|
||||
} else {
|
||||
updateSelectedMeta();
|
||||
}
|
||||
|
||||
|
||||
+150
-102
@@ -1,4 +1,37 @@
|
||||
// 知识库管理相关功能
|
||||
function _t(key, opts) {
|
||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||
}
|
||||
|
||||
// 返回「知识库未启用」提示区块的 HTML(使用 data-i18n 以便语言切换时自动更新)
|
||||
function getKnowledgeNotEnabledHTML() {
|
||||
return `
|
||||
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
|
||||
<div style="font-size: 48px; margin-bottom: 20px;">📚</div>
|
||||
<h3 data-i18n="knowledge.notEnabledTitle" style="margin-bottom: 10px; color: #666;"></h3>
|
||||
<p data-i18n="knowledge.notEnabledHint" style="color: #999; margin-bottom: 20px;"></p>
|
||||
<button data-i18n="knowledge.goToSettings" onclick="switchToSettings()" style="
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
"></button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 渲染「知识库未启用」状态到容器,并应用当前语言
|
||||
function renderKnowledgeNotEnabledState(container) {
|
||||
if (!container) return;
|
||||
container.innerHTML = getKnowledgeNotEnabledHTML();
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(container);
|
||||
}
|
||||
}
|
||||
|
||||
let knowledgeCategories = [];
|
||||
let knowledgeItems = [];
|
||||
let currentEditingItemId = null;
|
||||
@@ -32,26 +65,8 @@ async function loadKnowledgeCategories() {
|
||||
|
||||
// 检查知识库功能是否启用
|
||||
if (data.enabled === false) {
|
||||
// 功能未启用,显示友好提示
|
||||
const container = document.getElementById('knowledge-items-list');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
|
||||
<div style="font-size: 48px; margin-bottom: 20px;">📚</div>
|
||||
<h3 style="margin-bottom: 10px; color: #666;">知识库功能未启用</h3>
|
||||
<p style="color: #999; margin-bottom: 20px;">${data.message || '请前往系统设置启用知识检索功能'}</p>
|
||||
<button onclick="switchToSettings()" style="
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
">前往设置</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// 功能未启用,显示友好提示(使用 data-i18n,切换语言时会自动更新)
|
||||
renderKnowledgeNotEnabledState(document.getElementById('knowledge-items-list'));
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -116,25 +131,10 @@ async function loadKnowledgeItems(category = '', page = 1, pageSize = 10) {
|
||||
|
||||
// 检查知识库功能是否启用
|
||||
if (data.enabled === false) {
|
||||
// 功能未启用,显示友好提示(如果还没有显示的话)
|
||||
// 功能未启用,显示友好提示(如果还没有显示的话;使用 data-i18n,切换语言时会自动更新)
|
||||
const container = document.getElementById('knowledge-items-list');
|
||||
if (container && !container.querySelector('.empty-state')) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
|
||||
<div style="font-size: 48px; margin-bottom: 20px;">📚</div>
|
||||
<h3 style="margin-bottom: 10px; color: #666;">知识库功能未启用</h3>
|
||||
<p style="color: #999; margin-bottom: 20px;">${data.message || '请前往系统设置启用知识检索功能'}</p>
|
||||
<button onclick="switchToSettings()" style="
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
">前往设置</button>
|
||||
</div>
|
||||
`;
|
||||
renderKnowledgeNotEnabledState(container);
|
||||
}
|
||||
knowledgeItems = [];
|
||||
knowledgePagination.total = 0;
|
||||
@@ -459,6 +459,9 @@ async function updateIndexProgress() {
|
||||
const isComplete = status.is_complete || false;
|
||||
const lastError = status.last_error || '';
|
||||
|
||||
// 检查是否正在重建索引(优先使用重建状态)
|
||||
const isRebuilding = status.is_rebuilding || false;
|
||||
|
||||
if (totalItems === 0) {
|
||||
// 没有知识项,隐藏进度条
|
||||
progressContainer.style.display = 'none';
|
||||
@@ -524,6 +527,45 @@ async function updateIndexProgress() {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 优先处理重建状态
|
||||
if (isRebuilding) {
|
||||
const rebuildTotal = status.rebuild_total || totalItems;
|
||||
const rebuildCurrent = status.rebuild_current || 0;
|
||||
const rebuildFailed = status.rebuild_failed || 0;
|
||||
const rebuildLastItemID = status.rebuild_last_item_id || '';
|
||||
const rebuildLastChunks = status.rebuild_last_chunks || 0;
|
||||
const rebuildStartTime = status.rebuild_start_time || '';
|
||||
|
||||
// 计算进度百分比(使用重建进度)
|
||||
let rebuildProgress = progressPercent;
|
||||
if (rebuildTotal > 0) {
|
||||
rebuildProgress = (rebuildCurrent / rebuildTotal) * 100;
|
||||
}
|
||||
|
||||
progressContainer.innerHTML = `
|
||||
<div class="knowledge-index-progress">
|
||||
<div class="progress-header">
|
||||
<span class="progress-icon">🔨</span>
|
||||
<span class="progress-text">正在重建索引:${rebuildCurrent}/${rebuildTotal} (${rebuildProgress.toFixed(1)}%) - 失败:${rebuildFailed}</span>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" style="width: ${rebuildProgress}%"></div>
|
||||
</div>
|
||||
<div class="progress-hint">
|
||||
${rebuildLastItemID ? `正在处理:${escapeHtml(rebuildLastItemID.substring(0, 36))}... (${rebuildLastChunks} chunks)` : '正在处理...'}
|
||||
${rebuildStartTime ? `<br>开始时间:${new Date(rebuildStartTime).toLocaleString()}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 重建中时继续轮询
|
||||
if (!indexProgressInterval) {
|
||||
indexProgressInterval = setInterval(updateIndexProgress, 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
progressContainer.innerHTML = `
|
||||
<div class="knowledge-index-progress-complete">
|
||||
@@ -711,25 +753,7 @@ async function searchKnowledgeItems() {
|
||||
|
||||
// 检查知识库功能是否启用
|
||||
if (data.enabled === false) {
|
||||
const container = document.getElementById('knowledge-items-list');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
|
||||
<div style="font-size: 48px; margin-bottom: 20px;">📚</div>
|
||||
<h3 style="margin-bottom: 10px; color: #666;">知识库功能未启用</h3>
|
||||
<p style="color: #999; margin-bottom: 20px;">${data.message || '请前往系统设置启用知识检索功能'}</p>
|
||||
<button onclick="switchToSettings()" style="
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
">前往设置</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
renderKnowledgeNotEnabledState(document.getElementById('knowledge-items-list'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1270,7 +1294,7 @@ async function loadRetrievalLogs(conversationId = '', messageId = '') {
|
||||
renderRetrievalLogs([]);
|
||||
// 只在非空筛选条件下才显示错误通知(避免在没有数据时显示错误)
|
||||
if (conversationId || messageId) {
|
||||
showNotification('加载检索日志失败: ' + error.message, 'error');
|
||||
showNotification(_t('retrievalLogs.loadError') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1284,7 +1308,7 @@ function renderRetrievalLogs(logs) {
|
||||
updateRetrievalStats(logs);
|
||||
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">暂无检索记录</div>';
|
||||
container.innerHTML = '<div class="empty-state">' + _t('retrievalLogs.noRecords') + '</div>';
|
||||
retrievalLogsData = [];
|
||||
return;
|
||||
}
|
||||
@@ -1344,7 +1368,7 @@ function renderRetrievalLogs(logs) {
|
||||
</div>
|
||||
<div class="retrieval-log-main-info">
|
||||
<div class="retrieval-log-query">
|
||||
${escapeHtml(log.query || '无查询内容')}
|
||||
${escapeHtml(log.query || _t('retrievalLogs.noQuery'))}
|
||||
</div>
|
||||
<div class="retrieval-log-meta">
|
||||
<span class="retrieval-log-time" title="${formatTime(log.createdAt)}">
|
||||
@@ -1354,33 +1378,33 @@ function renderRetrievalLogs(logs) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="retrieval-log-result-badge ${hasResults ? 'success' : 'empty'}">
|
||||
${hasResults ? (itemCount > 0 ? `${itemCount} 项` : '有结果') : '无结果'}
|
||||
${hasResults ? (itemCount > 0 ? itemCount + ' ' + _t('retrievalLogs.itemsUnit') : _t('retrievalLogs.hasResults')) : _t('retrievalLogs.noResults')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="retrieval-log-card-body">
|
||||
<div class="retrieval-log-details-grid">
|
||||
${log.conversationId ? `
|
||||
<div class="retrieval-log-detail-item">
|
||||
<span class="detail-label">对话ID</span>
|
||||
<code class="detail-value" title="点击复制" onclick="navigator.clipboard.writeText('${escapeHtml(log.conversationId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);" style="cursor: pointer;">${escapeHtml(log.conversationId)}</code>
|
||||
<span class="detail-label">${_t('retrievalLogs.conversationId')}</span>
|
||||
<code class="detail-value" title="${_t('retrievalLogs.clickToCopy')}" data-copy-title-copied="${_t('common.copied')}" data-copy-title-click="${_t('retrievalLogs.clickToCopy')}" onclick="var t=this; navigator.clipboard.writeText('${escapeHtml(log.conversationId)}').then(function(){ t.title=t.getAttribute('data-copy-title-copied')||'Copied!'; setTimeout(function(){ t.title=t.getAttribute('data-copy-title-click')||'Click to copy'; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.conversationId)}</code>
|
||||
</div>
|
||||
` : ''}
|
||||
${log.messageId ? `
|
||||
<div class="retrieval-log-detail-item">
|
||||
<span class="detail-label">消息ID</span>
|
||||
<code class="detail-value" title="点击复制" onclick="navigator.clipboard.writeText('${escapeHtml(log.messageId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);" style="cursor: pointer;">${escapeHtml(log.messageId)}</code>
|
||||
<span class="detail-label">${_t('retrievalLogs.messageId')}</span>
|
||||
<code class="detail-value" title="${_t('retrievalLogs.clickToCopy')}" data-copy-title-copied="${_t('common.copied')}" data-copy-title-click="${_t('retrievalLogs.clickToCopy')}" onclick="var el=this; navigator.clipboard.writeText('${escapeHtml(log.messageId)}').then(function(){ el.title=el.getAttribute('data-copy-title-copied')||el.title; setTimeout(function(){ el.title=el.getAttribute('data-copy-title-click')||el.title; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.messageId)}</code>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="retrieval-log-detail-item">
|
||||
<span class="detail-label">检索结果</span>
|
||||
<span class="detail-label">${_t('retrievalLogs.retrievalResult')}</span>
|
||||
<span class="detail-value ${hasResults ? 'text-success' : 'text-muted'}">
|
||||
${hasResults ? (itemCount > 0 ? `找到 ${itemCount} 个相关知识项` : '找到相关知识项(数量未知)') : '未找到匹配的知识项'}
|
||||
${hasResults ? (itemCount > 0 ? _t('retrievalLogs.foundCount', { count: itemCount }) : _t('retrievalLogs.foundUnknown')) : _t('retrievalLogs.noMatch')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
${hasResults && log.retrievedItems && log.retrievedItems.length > 0 ? `
|
||||
<div class="retrieval-log-items-preview">
|
||||
<div class="retrieval-log-items-label">检索到的知识项:</div>
|
||||
<div class="retrieval-log-items-label">${_t('retrievalLogs.retrievedItemsLabel')}</div>
|
||||
<div class="retrieval-log-items-list">
|
||||
${log.retrievedItems.slice(0, 3).map((itemId, idx) => `
|
||||
<span class="retrieval-log-item-tag">${idx + 1}</span>
|
||||
@@ -1395,13 +1419,13 @@ function renderRetrievalLogs(logs) {
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
查看详情
|
||||
${_t('retrievalLogs.viewDetails')}
|
||||
</button>
|
||||
<button class="btn-secondary btn-sm retrieval-log-delete-btn" onclick="deleteRetrievalLog('${escapeHtml(log.id)}', ${index})" style="margin-top: 12px; margin-left: 8px; display: inline-flex; align-items: center; gap: 4px; color: var(--error-color, #dc3545); border-color: var(--error-color, #dc3545);" onmouseover="this.style.backgroundColor='rgba(220, 53, 69, 0.1)'; this.style.color='#dc3545';" onmouseout="this.style.backgroundColor=''; this.style.color='var(--error-color, #dc3545)';" title="删除">
|
||||
<button class="btn-secondary btn-sm retrieval-log-delete-btn" onclick="deleteRetrievalLog('${escapeHtml(log.id)}', ${index})" style="margin-top: 12px; margin-left: 8px; display: inline-flex; align-items: center; gap: 4px; color: var(--error-color, #dc3545); border-color: var(--error-color, #dc3545);" onmouseover="this.style.backgroundColor='rgba(220, 53, 69, 0.1)'; this.style.color='#dc3545';" onmouseout="this.style.backgroundColor=''; this.style.color='var(--error-color, #dc3545)';" title="${_t('common.delete')}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
删除
|
||||
${_t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1438,22 +1462,25 @@ function updateRetrievalStats(logs) {
|
||||
|
||||
statsContainer.innerHTML = `
|
||||
<div class="retrieval-stat-item">
|
||||
<span class="retrieval-stat-label">总检索次数</span>
|
||||
<span class="retrieval-stat-label" data-i18n="retrievalLogs.totalRetrievals">总检索次数</span>
|
||||
<span class="retrieval-stat-value">${totalLogs}</span>
|
||||
</div>
|
||||
<div class="retrieval-stat-item">
|
||||
<span class="retrieval-stat-label">成功检索</span>
|
||||
<span class="retrieval-stat-label" data-i18n="retrievalLogs.successRetrievals">成功检索</span>
|
||||
<span class="retrieval-stat-value text-success">${successfulLogs}</span>
|
||||
</div>
|
||||
<div class="retrieval-stat-item">
|
||||
<span class="retrieval-stat-label">成功率</span>
|
||||
<span class="retrieval-stat-label" data-i18n="retrievalLogs.successRate">成功率</span>
|
||||
<span class="retrieval-stat-value">${successRate}%</span>
|
||||
</div>
|
||||
<div class="retrieval-stat-item">
|
||||
<span class="retrieval-stat-label">检索到知识项</span>
|
||||
<span class="retrieval-stat-label" data-i18n="retrievalLogs.retrievedItems">检索到知识项</span>
|
||||
<span class="retrieval-stat-value">${totalItems}</span>
|
||||
</div>
|
||||
`;
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(statsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取相对时间
|
||||
@@ -1549,7 +1576,7 @@ function refreshRetrievalLogs() {
|
||||
|
||||
// 删除检索日志
|
||||
async function deleteRetrievalLog(id, index) {
|
||||
if (!confirm('确定要删除这条检索记录吗?')) {
|
||||
if (!confirm(_t('retrievalLogs.deleteConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1635,7 +1662,7 @@ async function deleteRetrievalLog(id, index) {
|
||||
}
|
||||
}
|
||||
|
||||
showNotification('❌ 删除检索日志失败: ' + error.message, 'error');
|
||||
showNotification(_t('retrievalLogs.deleteError') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1657,12 +1684,11 @@ function updateRetrievalStatsAfterDelete() {
|
||||
const badge = card.querySelector('.retrieval-log-result-badge');
|
||||
if (badge && badge.classList.contains('success')) {
|
||||
const text = badge.textContent.trim();
|
||||
const match = text.match(/(\d+)\s*项/);
|
||||
const match = text.match(/(\d+)/);
|
||||
if (match) {
|
||||
return sum + parseInt(match[1]);
|
||||
} else if (text === '有结果') {
|
||||
return sum + 1; // 简化处理,假设为1
|
||||
return sum + parseInt(match[1], 10);
|
||||
}
|
||||
return sum + 1; // 有结果但数量未知(如 "Has results" / "有结果")
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
@@ -1671,28 +1697,31 @@ function updateRetrievalStatsAfterDelete() {
|
||||
|
||||
statsContainer.innerHTML = `
|
||||
<div class="retrieval-stat-item">
|
||||
<span class="retrieval-stat-label">总检索次数</span>
|
||||
<span class="retrieval-stat-label" data-i18n="retrievalLogs.totalRetrievals">总检索次数</span>
|
||||
<span class="retrieval-stat-value">${totalLogs}</span>
|
||||
</div>
|
||||
<div class="retrieval-stat-item">
|
||||
<span class="retrieval-stat-label">成功检索</span>
|
||||
<span class="retrieval-stat-label" data-i18n="retrievalLogs.successRetrievals">成功检索</span>
|
||||
<span class="retrieval-stat-value text-success">${successfulLogs}</span>
|
||||
</div>
|
||||
<div class="retrieval-stat-item">
|
||||
<span class="retrieval-stat-label">成功率</span>
|
||||
<span class="retrieval-stat-label" data-i18n="retrievalLogs.successRate">成功率</span>
|
||||
<span class="retrieval-stat-value">${successRate}%</span>
|
||||
</div>
|
||||
<div class="retrieval-stat-item">
|
||||
<span class="retrieval-stat-label">检索到知识项</span>
|
||||
<span class="retrieval-stat-label" data-i18n="retrievalLogs.retrievedItems">检索到知识项</span>
|
||||
<span class="retrieval-stat-value">${totalItems}</span>
|
||||
</div>
|
||||
`;
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(statsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示检索日志详情
|
||||
async function showRetrievalLogDetails(index) {
|
||||
if (!retrievalLogsData || index < 0 || index >= retrievalLogsData.length) {
|
||||
showNotification('无法获取检索详情', 'error');
|
||||
showNotification(_t('retrievalLogs.detailError'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1741,16 +1770,19 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 900px; max-height: 90vh; overflow-y: auto;">
|
||||
<div class="modal-header">
|
||||
<h2>检索详情</h2>
|
||||
<h2 data-i18n="retrievalLogs.detailsTitle">检索详情</h2>
|
||||
<span class="modal-close" onclick="closeRetrievalLogDetailsModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body" id="retrieval-log-details-content">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeRetrievalLogDetailsModal()">关闭</button>
|
||||
<button class="btn-secondary" onclick="closeRetrievalLogDetailsModal()" data-i18n="common.close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(modal);
|
||||
}
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
@@ -1774,57 +1806,57 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
|
||||
return `
|
||||
<div class="retrieval-detail-item-card" style="margin-bottom: 16px; padding: 16px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-secondary);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
|
||||
<h4 style="margin: 0; color: var(--text-primary);">${idx + 1}. ${escapeHtml(item.title || '未命名')}</h4>
|
||||
<span style="font-size: 0.875rem; color: var(--text-secondary);">${escapeHtml(item.category || '未分类')}</span>
|
||||
<h4 style="margin: 0; color: var(--text-primary);">${idx + 1}. ${escapeHtml(item.title || _t('retrievalLogs.untitled'))}</h4>
|
||||
<span style="font-size: 0.875rem; color: var(--text-secondary);">${escapeHtml(item.category || _t('retrievalLogs.uncategorized'))}</span>
|
||||
</div>
|
||||
${item.filePath ? `<div style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 8px;">📁 ${escapeHtml(item.filePath)}</div>` : ''}
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); line-height: 1.6;">
|
||||
${escapeHtml(previewText || '无内容预览')}
|
||||
${escapeHtml(previewText || _t('retrievalLogs.noContentPreview'))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
itemsHtml = '<div style="padding: 16px; text-align: center; color: var(--text-muted);">未找到知识项详情</div>';
|
||||
itemsHtml = '<div style="padding: 16px; text-align: center; color: var(--text-muted);">' + _t('retrievalLogs.noItemDetails') + '</div>';
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; gap: 20px;">
|
||||
<div class="retrieval-detail-section">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">查询信息</h3>
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">${_t('retrievalLogs.queryInfo')}</h3>
|
||||
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px; border-left: 3px solid var(--accent-color);">
|
||||
<div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">查询内容:</div>
|
||||
<div style="color: var(--text-primary); line-height: 1.6; word-break: break-word;">${escapeHtml(log.query || '无查询内容')}</div>
|
||||
<div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${_t('retrievalLogs.queryContent')}</div>
|
||||
<div style="color: var(--text-primary); line-height: 1.6; word-break: break-word;">${escapeHtml(log.query || _t('retrievalLogs.noQuery'))}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="retrieval-detail-section">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">检索信息</h3>
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">${_t('retrievalLogs.retrievalInfo')}</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
|
||||
${log.riskType ? `
|
||||
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">风险类型</div>
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.riskType')}</div>
|
||||
<div style="font-weight: 500; color: var(--text-primary);">${escapeHtml(log.riskType)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">检索时间</div>
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.retrievalTime')}</div>
|
||||
<div style="font-weight: 500; color: var(--text-primary);" title="${fullTime}">${timeAgo}</div>
|
||||
</div>
|
||||
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">检索结果</div>
|
||||
<div style="font-weight: 500; color: var(--text-primary);">${retrievedItems.length} 个知识项</div>
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.retrievalResult')}</div>
|
||||
<div style="font-weight: 500; color: var(--text-primary);">${_t('retrievalLogs.itemsCount', { count: retrievedItems.length })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${log.conversationId || log.messageId ? `
|
||||
<div class="retrieval-detail-section">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">关联信息</h3>
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 1.125rem; color: var(--text-primary);">${_t('retrievalLogs.relatedInfo')}</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;">
|
||||
${log.conversationId ? `
|
||||
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">对话ID</div>
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.conversationId')}</div>
|
||||
<code style="font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; cursor: pointer;"
|
||||
onclick="navigator.clipboard.writeText('${escapeHtml(log.conversationId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
|
||||
title="点击复制">${escapeHtml(log.conversationId)}</code>
|
||||
@@ -1832,7 +1864,7 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
|
||||
` : ''}
|
||||
${log.messageId ? `
|
||||
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">消息ID</div>
|
||||
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.messageId')}</div>
|
||||
<code style="font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; cursor: pointer;"
|
||||
onclick="navigator.clipboard.writeText('${escapeHtml(log.messageId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
|
||||
title="点击复制">${escapeHtml(log.messageId)}</code>
|
||||
@@ -1868,6 +1900,22 @@ window.addEventListener('click', function(event) {
|
||||
}
|
||||
});
|
||||
|
||||
// 语言切换时重新渲染检索历史列表与统计,使动态内容随语言更新;知识管理页的「未启用」区块已使用 data-i18n,会由 applyTranslations(document) 自动更新
|
||||
document.addEventListener('languagechange', function () {
|
||||
var cur = typeof window.currentPage === 'function' ? window.currentPage() : (window.currentPage || '');
|
||||
if (cur === 'knowledge-retrieval-logs') {
|
||||
if (retrievalLogsData && retrievalLogsData.length >= 0) {
|
||||
renderRetrievalLogs(retrievalLogsData);
|
||||
}
|
||||
} else if (cur === 'knowledge-management') {
|
||||
// 仅对「知识库未启用」状态:已有 data-i18n,applyTranslations 已处理;此处可选地重新应用一次以兼容旧 DOM
|
||||
var listEl = document.getElementById('knowledge-items-list');
|
||||
if (listEl && typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listEl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 页面切换时加载数据
|
||||
if (typeof switchPage === 'function') {
|
||||
const originalSwitchPage = switchPage;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+227
-148
@@ -3,6 +3,30 @@ let activeTaskInterval = null;
|
||||
const ACTIVE_TASK_REFRESH_INTERVAL = 10000; // 10秒检查一次
|
||||
const TASK_FINAL_STATUSES = new Set(['failed', 'timeout', 'cancelled', 'completed']);
|
||||
|
||||
// 将后端下发的进度文案转为当前语言的翻译(已知中文 key 映射)
|
||||
function translateProgressMessage(message) {
|
||||
if (!message || typeof message !== 'string') return message;
|
||||
if (typeof window.t !== 'function') return message;
|
||||
const trim = message.trim();
|
||||
const map = {
|
||||
'正在调用AI模型...': 'progress.callingAI',
|
||||
'最后一次迭代:正在生成总结和下一步计划...': 'progress.lastIterSummary',
|
||||
'总结生成完成': 'progress.summaryDone',
|
||||
'正在生成最终回复...': 'progress.generatingFinalReply',
|
||||
'达到最大迭代次数,正在生成总结...': 'progress.maxIterSummary'
|
||||
};
|
||||
if (map[trim]) return window.t(map[trim]);
|
||||
const callingToolPrefix = '正在调用工具: ';
|
||||
if (trim.indexOf(callingToolPrefix) === 0) {
|
||||
const name = trim.slice(callingToolPrefix.length);
|
||||
return window.t('progress.callingTool', { name: name });
|
||||
}
|
||||
return message;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.translateProgressMessage = translateProgressMessage;
|
||||
}
|
||||
|
||||
// 存储工具调用ID到DOM元素的映射,用于更新执行状态
|
||||
const toolCallStatusMap = new Map();
|
||||
|
||||
@@ -57,11 +81,15 @@ function markProgressCancelling(progressId) {
|
||||
}
|
||||
}
|
||||
|
||||
function finalizeProgressTask(progressId, finalLabel = '已完成') {
|
||||
function finalizeProgressTask(progressId, finalLabel) {
|
||||
const stopBtn = document.getElementById(`${progressId}-stop-btn`);
|
||||
if (stopBtn) {
|
||||
stopBtn.disabled = true;
|
||||
stopBtn.textContent = finalLabel;
|
||||
if (finalLabel !== undefined && finalLabel !== '') {
|
||||
stopBtn.textContent = finalLabel;
|
||||
} else {
|
||||
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成';
|
||||
}
|
||||
}
|
||||
progressTaskState.delete(progressId);
|
||||
}
|
||||
@@ -76,7 +104,7 @@ async function requestCancel(conversationId) {
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '取消失败');
|
||||
throw new Error(result.error || (typeof window.t === 'function' ? window.t('tasks.cancelFailed') : '取消失败'));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -94,12 +122,15 @@ function addProgressMessage() {
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'message-bubble progress-container';
|
||||
const progressTitleText = typeof window.t === 'function' ? window.t('chat.progressInProgress') : '渗透测试进行中...';
|
||||
const stopTaskText = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
|
||||
const collapseDetailText = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
|
||||
bubble.innerHTML = `
|
||||
<div class="progress-header">
|
||||
<span class="progress-title">🔍 渗透测试进行中...</span>
|
||||
<span class="progress-title">🔍 ${progressTitleText}</span>
|
||||
<div class="progress-actions">
|
||||
<button class="progress-stop" id="${id}-stop-btn" onclick="cancelProgressTask('${id}')">停止任务</button>
|
||||
<button class="progress-toggle" onclick="toggleProgressDetails('${id}')">收起详情</button>
|
||||
<button class="progress-stop" id="${id}-stop-btn" onclick="cancelProgressTask('${id}')">${stopTaskText}</button>
|
||||
<button class="progress-toggle" onclick="toggleProgressDetails('${id}')">${collapseDetailText}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-timeline expanded" id="${id}-timeline"></div>
|
||||
@@ -123,10 +154,10 @@ function toggleProgressDetails(progressId) {
|
||||
|
||||
if (timeline.classList.contains('expanded')) {
|
||||
timeline.classList.remove('expanded');
|
||||
toggleBtn.textContent = '展开详情';
|
||||
toggleBtn.textContent = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
|
||||
} else {
|
||||
timeline.classList.add('expanded');
|
||||
toggleBtn.textContent = '收起详情';
|
||||
toggleBtn.textContent = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +174,7 @@ function collapseAllProgressDetails(assistantMessageId, progressId) {
|
||||
timeline.classList.remove('expanded');
|
||||
const btn = document.querySelector(`#${assistantMessageId} .process-detail-btn`);
|
||||
if (btn) {
|
||||
btn.innerHTML = '<span>展开详情</span>';
|
||||
btn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + '</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,7 +189,7 @@ function collapseAllProgressDetails(assistantMessageId, progressId) {
|
||||
if (timeline) {
|
||||
timeline.classList.remove('expanded');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.textContent = '展开详情';
|
||||
toggleBtn.textContent = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -170,7 +201,7 @@ function collapseAllProgressDetails(assistantMessageId, progressId) {
|
||||
if (progressTimeline) {
|
||||
progressTimeline.classList.remove('expanded');
|
||||
if (progressToggleBtn) {
|
||||
progressToggleBtn.textContent = '展开详情';
|
||||
progressToggleBtn.textContent = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,7 +277,7 @@ function integrateProgressToMCPSection(progressId, assistantMessageId) {
|
||||
// 设置详情内容(如果有错误,默认折叠;否则默认折叠)
|
||||
detailsContainer.innerHTML = `
|
||||
<div class="process-details-content">
|
||||
${hasContent ? `<div class="progress-timeline" id="${detailsId}-timeline">${timelineHTML}</div>` : '<div class="progress-timeline-empty">暂无过程详情</div>'}
|
||||
${hasContent ? `<div class="progress-timeline" id="${detailsId}-timeline">${timelineHTML}</div>` : '<div class="progress-timeline-empty">' + (typeof window.t === 'function' ? window.t('chat.noProcessDetail') : '暂无过程详情(可能执行过快或未触发详细事件)') + '</div>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -258,10 +289,9 @@ function integrateProgressToMCPSection(progressId, assistantMessageId) {
|
||||
timeline.classList.remove('expanded');
|
||||
}
|
||||
|
||||
// 更新按钮文本为"展开详情"(因为默认折叠)
|
||||
const processDetailBtn = buttonsContainer.querySelector('.process-detail-btn');
|
||||
if (processDetailBtn) {
|
||||
processDetailBtn.innerHTML = '<span>展开详情</span>';
|
||||
processDetailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,22 +309,23 @@ function toggleProcessDetails(progressId, assistantMessageId) {
|
||||
const timeline = detailsContainer.querySelector('.progress-timeline');
|
||||
const btn = document.querySelector(`#${assistantMessageId} .process-detail-btn`);
|
||||
|
||||
const expandT = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
|
||||
const collapseT = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
|
||||
if (content && timeline) {
|
||||
if (timeline.classList.contains('expanded')) {
|
||||
timeline.classList.remove('expanded');
|
||||
if (btn) btn.innerHTML = '<span>展开详情</span>';
|
||||
if (btn) btn.innerHTML = '<span>' + expandT + '</span>';
|
||||
} else {
|
||||
timeline.classList.add('expanded');
|
||||
if (btn) btn.innerHTML = '<span>收起详情</span>';
|
||||
if (btn) btn.innerHTML = '<span>' + collapseT + '</span>';
|
||||
}
|
||||
} else if (timeline) {
|
||||
// 如果只有timeline,直接切换
|
||||
if (timeline.classList.contains('expanded')) {
|
||||
timeline.classList.remove('expanded');
|
||||
if (btn) btn.innerHTML = '<span>展开详情</span>';
|
||||
if (btn) btn.innerHTML = '<span>' + expandT + '</span>';
|
||||
} else {
|
||||
timeline.classList.add('expanded');
|
||||
if (btn) btn.innerHTML = '<span>收起详情</span>';
|
||||
if (btn) btn.innerHTML = '<span>' + collapseT + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +350,7 @@ async function cancelProgressTask(progressId) {
|
||||
stopBtn.disabled = false;
|
||||
}, 1500);
|
||||
}
|
||||
alert('任务信息尚未同步,请稍后再试。');
|
||||
alert(typeof window.t === 'function' ? window.t('tasks.taskInfoNotSynced') : '任务信息尚未同步,请稍后再试。');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -330,7 +361,7 @@ async function cancelProgressTask(progressId) {
|
||||
markProgressCancelling(progressId);
|
||||
if (stopBtn) {
|
||||
stopBtn.disabled = true;
|
||||
stopBtn.textContent = '取消中...';
|
||||
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -338,10 +369,10 @@ async function cancelProgressTask(progressId) {
|
||||
loadActiveTasks();
|
||||
} catch (error) {
|
||||
console.error('取消任务失败:', error);
|
||||
alert('取消任务失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
|
||||
if (stopBtn) {
|
||||
stopBtn.disabled = false;
|
||||
stopBtn.textContent = '停止任务';
|
||||
stopBtn.textContent = typeof window.t === 'function' ? window.t('tasks.stopTask') : '停止任务';
|
||||
}
|
||||
const currentState = progressTaskState.get(progressId);
|
||||
if (currentState) {
|
||||
@@ -391,15 +422,17 @@ function convertProgressToDetails(progressId, assistantMessageId) {
|
||||
// 如果有错误,默认折叠;否则默认展开
|
||||
const shouldExpand = !hasError;
|
||||
const expandedClass = shouldExpand ? 'expanded' : '';
|
||||
const toggleText = shouldExpand ? '收起详情' : '展开详情';
|
||||
|
||||
// 总是显示详情组件,即使没有内容也显示
|
||||
const collapseDetailText = typeof window.t === 'function' ? window.t('tasks.collapseDetail') : '收起详情';
|
||||
const expandDetailText = typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情';
|
||||
const toggleText = shouldExpand ? collapseDetailText : expandDetailText;
|
||||
const penetrationDetailText = typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '渗透测试详情';
|
||||
const noProcessDetailText = typeof window.t === 'function' ? window.t('chat.noProcessDetail') : '暂无过程详情(可能执行过快或未触发详细事件)';
|
||||
bubble.innerHTML = `
|
||||
<div class="progress-header">
|
||||
<span class="progress-title">📋 渗透测试详情</span>
|
||||
<span class="progress-title">📋 ${penetrationDetailText}</span>
|
||||
${hasContent ? `<button class="progress-toggle" onclick="toggleProgressDetails('${detailsId}')">${toggleText}</button>` : ''}
|
||||
</div>
|
||||
${hasContent ? `<div class="progress-timeline ${expandedClass}" id="${detailsId}-timeline">${timelineHTML}</div>` : '<div class="progress-timeline-empty">暂无过程详情(可能执行过快或未触发详细事件)</div>'}
|
||||
${hasContent ? `<div class="progress-timeline ${expandedClass}" id="${detailsId}-timeline">${timelineHTML}</div>` : '<div class="progress-timeline-empty">' + noProcessDetailText + '</div>'}
|
||||
`;
|
||||
|
||||
contentWrapper.appendChild(bubble);
|
||||
@@ -466,41 +499,37 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
case 'iteration':
|
||||
// 添加迭代标记
|
||||
addTimelineItem(timeline, 'iteration', {
|
||||
title: `第 ${event.data?.iteration || 1} 轮迭代`,
|
||||
title: typeof window.t === 'function' ? window.t('chat.iterationRound', { n: event.data?.iteration || 1 }) : '第 ' + (event.data?.iteration || 1) + ' 轮迭代',
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
break;
|
||||
|
||||
case 'thinking':
|
||||
// 显示AI思考内容
|
||||
addTimelineItem(timeline, 'thinking', {
|
||||
title: '🤔 AI思考',
|
||||
title: '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'),
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool_calls_detected':
|
||||
// 工具调用检测
|
||||
addTimelineItem(timeline, 'tool_calls_detected', {
|
||||
title: `🔧 检测到 ${event.data?.count || 0} 个工具调用`,
|
||||
title: '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: event.data?.count || 0 }) : '检测到 ' + (event.data?.count || 0) + ' 个工具调用'),
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
break;
|
||||
|
||||
case 'tool_call':
|
||||
// 显示工具调用信息
|
||||
const toolInfo = event.data || {};
|
||||
const toolName = toolInfo.toolName || '未知工具';
|
||||
const toolName = toolInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
||||
const index = toolInfo.index || 0;
|
||||
const total = toolInfo.total || 0;
|
||||
const toolCallId = toolInfo.toolCallId || null;
|
||||
|
||||
// 添加工具调用项,并标记为执行中
|
||||
const toolCallTitle = typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')';
|
||||
const toolCallItemId = addTimelineItem(timeline, 'tool_call', {
|
||||
title: `🔧 调用工具: ${escapeHtml(toolName)} (${index}/${total})`,
|
||||
title: '🔧 ' + toolCallTitle,
|
||||
message: event.message,
|
||||
data: toolInfo,
|
||||
expanded: false
|
||||
@@ -519,22 +548,18 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
// 显示工具执行结果
|
||||
const resultInfo = event.data || {};
|
||||
const resultToolName = resultInfo.toolName || '未知工具';
|
||||
const resultToolName = resultInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
||||
const success = resultInfo.success !== false;
|
||||
const statusIcon = success ? '✅' : '❌';
|
||||
const resultToolCallId = resultInfo.toolCallId || null;
|
||||
|
||||
// 如果有关联的toolCallId,更新工具调用项的状态
|
||||
const resultExecText = success ? (typeof window.t === 'function' ? window.t('chat.toolExecComplete', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行完成') : (typeof window.t === 'function' ? window.t('chat.toolExecFailed', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行失败');
|
||||
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
|
||||
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
|
||||
// 从映射中移除(已完成)
|
||||
toolCallStatusMap.delete(resultToolCallId);
|
||||
}
|
||||
|
||||
addTimelineItem(timeline, 'tool_result', {
|
||||
title: `${statusIcon} 工具 ${escapeHtml(resultToolName)} 执行${success ? '完成' : '失败'}`,
|
||||
title: statusIcon + ' ' + resultExecText,
|
||||
message: event.message,
|
||||
data: resultInfo,
|
||||
expanded: false
|
||||
@@ -542,36 +567,30 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
break;
|
||||
|
||||
case 'progress':
|
||||
// 更新进度状态
|
||||
const progressTitle = document.querySelector(`#${progressId} .progress-title`);
|
||||
if (progressTitle) {
|
||||
progressTitle.textContent = '🔍 ' + event.message;
|
||||
const progressMsg = translateProgressMessage(event.message);
|
||||
progressTitle.textContent = '🔍 ' + progressMsg;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cancelled':
|
||||
// 显示错误
|
||||
const taskCancelledText = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
|
||||
addTimelineItem(timeline, 'cancelled', {
|
||||
title: '⛔ 任务已取消',
|
||||
title: '⛔ ' + taskCancelledText,
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
|
||||
// 更新进度标题为取消状态
|
||||
const cancelTitle = document.querySelector(`#${progressId} .progress-title`);
|
||||
if (cancelTitle) {
|
||||
cancelTitle.textContent = '⛔ 任务已取消';
|
||||
cancelTitle.textContent = '⛔ ' + taskCancelledText;
|
||||
}
|
||||
|
||||
// 更新进度容器为已完成状态(添加completed类)
|
||||
const cancelProgressContainer = document.querySelector(`#${progressId} .progress-container`);
|
||||
if (cancelProgressContainer) {
|
||||
cancelProgressContainer.classList.add('completed');
|
||||
}
|
||||
|
||||
// 完成进度任务(标记为已取消)
|
||||
if (progressTaskState.has(progressId)) {
|
||||
finalizeProgressTask(progressId, '已取消');
|
||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCancelled') : '已取消');
|
||||
}
|
||||
|
||||
// 如果取消事件包含messageId,说明有助手消息,需要显示取消内容
|
||||
@@ -670,7 +689,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
case 'error':
|
||||
// 显示错误
|
||||
addTimelineItem(timeline, 'error', {
|
||||
title: '❌ 错误',
|
||||
title: '❌ ' + (typeof window.t === 'function' ? window.t('chat.error') : '错误'),
|
||||
message: event.message,
|
||||
data: event.data
|
||||
});
|
||||
@@ -678,7 +697,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
// 更新进度标题为错误状态
|
||||
const errorTitle = document.querySelector(`#${progressId} .progress-title`);
|
||||
if (errorTitle) {
|
||||
errorTitle.textContent = '❌ 执行失败';
|
||||
errorTitle.textContent = '❌ ' + (typeof window.t === 'function' ? window.t('chat.executionFailed') : '执行失败');
|
||||
}
|
||||
|
||||
// 更新进度容器为已完成状态(添加completed类)
|
||||
@@ -689,7 +708,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
|
||||
// 完成进度任务(标记为失败)
|
||||
if (progressTaskState.has(progressId)) {
|
||||
finalizeProgressTask(progressId, '已失败');
|
||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusFailed') : '执行失败');
|
||||
}
|
||||
|
||||
// 如果错误事件包含messageId,说明有助手消息,需要显示错误内容
|
||||
@@ -743,7 +762,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
// 完成,更新进度标题(如果进度消息还存在)
|
||||
const doneTitle = document.querySelector(`#${progressId} .progress-title`);
|
||||
if (doneTitle) {
|
||||
doneTitle.textContent = '✅ 渗透测试完成';
|
||||
doneTitle.textContent = '✅ ' + (typeof window.t === 'function' ? window.t('chat.penetrationTestComplete') : '渗透测试完成');
|
||||
}
|
||||
// 更新对话ID
|
||||
if (event.data && event.data.conversationId) {
|
||||
@@ -753,7 +772,7 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
updateProgressConversation(progressId, event.data.conversationId);
|
||||
}
|
||||
if (progressTaskState.has(progressId)) {
|
||||
finalizeProgressTask(progressId, '已完成');
|
||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
|
||||
}
|
||||
|
||||
// 检查时间线中是否有错误项
|
||||
@@ -807,17 +826,19 @@ function updateToolCallStatus(toolCallId, status) {
|
||||
// 移除之前的状态类
|
||||
item.classList.remove('tool-call-running', 'tool-call-completed', 'tool-call-failed');
|
||||
|
||||
// 根据状态更新样式和文本
|
||||
const runningLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...';
|
||||
const completedLabel = typeof window.t === 'function' ? window.t('timeline.completed') : '已完成';
|
||||
const failedLabel = typeof window.t === 'function' ? window.t('timeline.execFailed') : '执行失败';
|
||||
let statusText = '';
|
||||
if (status === 'running') {
|
||||
item.classList.add('tool-call-running');
|
||||
statusText = ' <span class="tool-status-badge tool-status-running">执行中...</span>';
|
||||
statusText = ' <span class="tool-status-badge tool-status-running">' + escapeHtml(runningLabel) + '</span>';
|
||||
} else if (status === 'completed') {
|
||||
item.classList.add('tool-call-completed');
|
||||
statusText = ' <span class="tool-status-badge tool-status-completed">✅ 已完成</span>';
|
||||
statusText = ' <span class="tool-status-badge tool-status-completed">✅ ' + escapeHtml(completedLabel) + '</span>';
|
||||
} else if (status === 'failed') {
|
||||
item.classList.add('tool-call-failed');
|
||||
statusText = ' <span class="tool-status-badge tool-status-failed">❌ 执行失败</span>';
|
||||
statusText = ' <span class="tool-status-badge tool-status-failed">❌ ' + escapeHtml(failedLabel) + '</span>';
|
||||
}
|
||||
|
||||
// 更新标题(保留原有文本,追加状态)
|
||||
@@ -854,7 +875,8 @@ function addTimelineItem(timeline, type, options) {
|
||||
eventTime = new Date();
|
||||
}
|
||||
|
||||
const time = eventTime.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const timeLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||
const time = eventTime.toLocaleTimeString(timeLocale, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
let content = `
|
||||
<div class="timeline-item-header">
|
||||
@@ -869,11 +891,12 @@ function addTimelineItem(timeline, type, options) {
|
||||
} else if (type === 'tool_call' && options.data) {
|
||||
const data = options.data;
|
||||
const args = data.argumentsObj || (data.arguments ? JSON.parse(data.arguments) : {});
|
||||
const paramsLabel = typeof window.t === 'function' ? window.t('timeline.params') : '参数:';
|
||||
content += `
|
||||
<div class="timeline-item-content">
|
||||
<div class="tool-details">
|
||||
<div class="tool-arg-section">
|
||||
<strong>参数:</strong>
|
||||
<strong>${escapeHtml(paramsLabel)}</strong>
|
||||
<pre class="tool-args">${escapeHtml(JSON.stringify(args, null, 2))}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -882,22 +905,25 @@ function addTimelineItem(timeline, type, options) {
|
||||
} else if (type === 'tool_result' && options.data) {
|
||||
const data = options.data;
|
||||
const isError = data.isError || !data.success;
|
||||
const result = data.result || data.error || '无结果';
|
||||
// 确保 result 是字符串
|
||||
const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果';
|
||||
const result = data.result || data.error || noResultText;
|
||||
const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
const execResultLabel = typeof window.t === 'function' ? window.t('timeline.executionResult') : '执行结果:';
|
||||
const execIdLabel = typeof window.t === 'function' ? window.t('timeline.executionId') : '执行ID:';
|
||||
content += `
|
||||
<div class="timeline-item-content">
|
||||
<div class="tool-result-section ${isError ? 'error' : 'success'}">
|
||||
<strong>执行结果:</strong>
|
||||
<strong>${escapeHtml(execResultLabel)}</strong>
|
||||
<pre class="tool-result">${escapeHtml(resultStr)}</pre>
|
||||
${data.executionId ? `<div class="tool-execution-id">执行ID: <code>${escapeHtml(data.executionId)}</code></div>` : ''}
|
||||
${data.executionId ? `<div class="tool-execution-id">${escapeHtml(execIdLabel)} <code>${escapeHtml(data.executionId)}</code></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'cancelled') {
|
||||
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
|
||||
content += `
|
||||
<div class="timeline-item-content">
|
||||
${escapeHtml(options.message || '任务已取消')}
|
||||
${escapeHtml(options.message || taskCancelledLabel)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -923,7 +949,7 @@ async function loadActiveTasks(showErrors = false) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '获取活跃任务失败');
|
||||
throw new Error(result.error || (typeof window.t === 'function' ? window.t('tasks.loadActiveTasksFailed') : '获取活跃任务失败'));
|
||||
}
|
||||
|
||||
renderActiveTasks(result.tasks || []);
|
||||
@@ -931,7 +957,8 @@ async function loadActiveTasks(showErrors = false) {
|
||||
console.error('获取活跃任务失败:', error);
|
||||
if (showErrors && bar) {
|
||||
bar.style.display = 'block';
|
||||
bar.innerHTML = `<div class="active-task-error">无法获取任务状态:${escapeHtml(error.message)}</div>`;
|
||||
const cannotGetStatus = typeof window.t === 'function' ? window.t('tasks.cannotGetTaskStatus') : '无法获取任务状态:';
|
||||
bar.innerHTML = `<div class="active-task-error">${escapeHtml(cannotGetStatus)}${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -960,30 +987,33 @@ function renderActiveTasks(tasks) {
|
||||
item.className = 'active-task-item';
|
||||
|
||||
const startedTime = task.startedAt ? new Date(task.startedAt) : null;
|
||||
const taskTimeLocale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||
const timeText = startedTime && !isNaN(startedTime.getTime())
|
||||
? startedTime.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
? startedTime.toLocaleTimeString(taskTimeLocale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
: '';
|
||||
|
||||
// 根据任务状态显示不同的文本
|
||||
const _t = function (k) { return typeof window.t === 'function' ? window.t(k) : k; };
|
||||
const statusMap = {
|
||||
'running': '执行中',
|
||||
'cancelling': '取消中',
|
||||
'failed': '执行失败',
|
||||
'timeout': '执行超时',
|
||||
'cancelled': '已取消',
|
||||
'completed': '已完成'
|
||||
'running': _t('tasks.statusRunning'),
|
||||
'cancelling': _t('tasks.statusCancelling'),
|
||||
'failed': _t('tasks.statusFailed'),
|
||||
'timeout': _t('tasks.statusTimeout'),
|
||||
'cancelled': _t('tasks.statusCancelled'),
|
||||
'completed': _t('tasks.statusCompleted')
|
||||
};
|
||||
const statusText = statusMap[task.status] || '执行中';
|
||||
const statusText = statusMap[task.status] || _t('tasks.statusRunning');
|
||||
const isFinalStatus = ['failed', 'timeout', 'cancelled', 'completed'].includes(task.status);
|
||||
const unnamedTaskText = _t('tasks.unnamedTask');
|
||||
const stopTaskBtnText = _t('tasks.stopTask');
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="active-task-info">
|
||||
<span class="active-task-status">${statusText}</span>
|
||||
<span class="active-task-message">${escapeHtml(task.message || '未命名任务')}</span>
|
||||
<span class="active-task-message">${escapeHtml(task.message || unnamedTaskText)}</span>
|
||||
</div>
|
||||
<div class="active-task-actions">
|
||||
${timeText ? `<span class="active-task-time">${timeText}</span>` : ''}
|
||||
${!isFinalStatus ? '<button class="active-task-cancel">停止任务</button>' : ''}
|
||||
${!isFinalStatus ? '<button class="active-task-cancel">' + stopTaskBtnText + '</button>' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -994,7 +1024,7 @@ function renderActiveTasks(tasks) {
|
||||
cancelBtn.onclick = () => cancelActiveTask(task.conversationId, cancelBtn);
|
||||
if (task.status === 'cancelling') {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = '取消中...';
|
||||
cancelBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1007,14 +1037,14 @@ async function cancelActiveTask(conversationId, button) {
|
||||
if (!conversationId) return;
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = '取消中...';
|
||||
button.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
|
||||
|
||||
try {
|
||||
await requestCancel(conversationId);
|
||||
loadActiveTasks();
|
||||
} catch (error) {
|
||||
console.error('取消任务失败:', error);
|
||||
alert('取消任务失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('tasks.cancelTaskFailed') : '取消任务失败') + ': ' + error.message);
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
@@ -1138,10 +1168,10 @@ async function refreshMonitorPanel(page = null) {
|
||||
} catch (error) {
|
||||
console.error('刷新监控面板失败:', error);
|
||||
if (statsContainer) {
|
||||
statsContainer.innerHTML = `<div class="monitor-error">无法加载统计信息:${escapeHtml(error.message)}</div>`;
|
||||
statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}:${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
if (execContainer) {
|
||||
execContainer.innerHTML = `<div class="monitor-error">无法加载执行记录:${escapeHtml(error.message)}</div>`;
|
||||
execContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadExecutionsError') : '无法加载执行记录')}:${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1215,10 +1245,10 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
|
||||
} catch (error) {
|
||||
console.error('刷新监控面板失败:', error);
|
||||
if (statsContainer) {
|
||||
statsContainer.innerHTML = `<div class="monitor-error">无法加载统计信息:${escapeHtml(error.message)}</div>`;
|
||||
statsContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadStatsError') : '无法加载统计信息')}:${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
if (execContainer) {
|
||||
execContainer.innerHTML = `<div class="monitor-error">无法加载执行记录:${escapeHtml(error.message)}</div>`;
|
||||
execContainer.innerHTML = `<div class="monitor-error">${escapeHtml(typeof window.t === 'function' ? window.t('mcpMonitor.loadExecutionsError') : '无法加载执行记录')}:${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1232,7 +1262,8 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
|
||||
const entries = Object.values(statsMap);
|
||||
if (entries.length === 0) {
|
||||
container.innerHTML = '<div class="monitor-empty">暂无统计数据</div>';
|
||||
const noStats = typeof window.t === 'function' ? window.t('mcpMonitor.noStatsData') : '暂无统计数据';
|
||||
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1252,24 +1283,32 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
);
|
||||
|
||||
const successRate = totals.total > 0 ? ((totals.success / totals.total) * 100).toFixed(1) : '0.0';
|
||||
const lastUpdatedText = lastFetchedAt ? lastFetchedAt.toLocaleString('zh-CN') : 'N/A';
|
||||
const lastCallText = totals.lastCallTime ? totals.lastCallTime.toLocaleString('zh-CN') : '暂无调用';
|
||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
|
||||
const lastUpdatedText = lastFetchedAt ? (lastFetchedAt.toLocaleString ? lastFetchedAt.toLocaleString(locale || 'en-US') : String(lastFetchedAt)) : 'N/A';
|
||||
const noCallsYet = typeof window.t === 'function' ? window.t('mcpMonitor.noCallsYet') : '暂无调用';
|
||||
const lastCallText = totals.lastCallTime ? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale || 'en-US') : String(totals.lastCallTime)) : noCallsYet;
|
||||
const totalCallsLabel = typeof window.t === 'function' ? window.t('mcpMonitor.totalCalls') : '总调用次数';
|
||||
const successFailedLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successFailed', { success: totals.success, failed: totals.failed }) : `成功 ${totals.success} / 失败 ${totals.failed}`;
|
||||
const successRateLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successRate') : '成功率';
|
||||
const statsFromAll = typeof window.t === 'function' ? window.t('mcpMonitor.statsFromAllTools') : '统计自全部工具调用';
|
||||
const lastCallLabel = typeof window.t === 'function' ? window.t('mcpMonitor.lastCall') : '最近一次调用';
|
||||
const lastRefreshLabel = typeof window.t === 'function' ? window.t('mcpMonitor.lastRefreshTime') : '最后刷新时间';
|
||||
|
||||
let html = `
|
||||
<div class="monitor-stat-card">
|
||||
<h4>总调用次数</h4>
|
||||
<h4>${escapeHtml(totalCallsLabel)}</h4>
|
||||
<div class="monitor-stat-value">${totals.total}</div>
|
||||
<div class="monitor-stat-meta">成功 ${totals.success} / 失败 ${totals.failed}</div>
|
||||
<div class="monitor-stat-meta">${escapeHtml(successFailedLabel)}</div>
|
||||
</div>
|
||||
<div class="monitor-stat-card">
|
||||
<h4>成功率</h4>
|
||||
<h4>${escapeHtml(successRateLabel)}</h4>
|
||||
<div class="monitor-stat-value">${successRate}%</div>
|
||||
<div class="monitor-stat-meta">统计自全部工具调用</div>
|
||||
<div class="monitor-stat-meta">${escapeHtml(statsFromAll)}</div>
|
||||
</div>
|
||||
<div class="monitor-stat-card">
|
||||
<h4>最近一次调用</h4>
|
||||
<div class="monitor-stat-value" style="font-size:1rem;">${lastCallText}</div>
|
||||
<div class="monitor-stat-meta">最后刷新时间:${lastUpdatedText}</div>
|
||||
<h4>${escapeHtml(lastCallLabel)}</h4>
|
||||
<div class="monitor-stat-value" style="font-size:1rem;">${escapeHtml(lastCallText)}</div>
|
||||
<div class="monitor-stat-meta">${escapeHtml(lastRefreshLabel)}:${escapeHtml(lastUpdatedText)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1280,14 +1319,16 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
|
||||
.slice(0, 4);
|
||||
|
||||
const unknownToolLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknownTool') : '未知工具';
|
||||
topTools.forEach(tool => {
|
||||
const toolSuccessRate = tool.totalCalls > 0 ? ((tool.successCalls || 0) / tool.totalCalls * 100).toFixed(1) : '0.0';
|
||||
const toolMeta = typeof window.t === 'function' ? window.t('mcpMonitor.successFailedRate', { success: tool.successCalls || 0, failed: tool.failedCalls || 0, rate: toolSuccessRate }) : `成功 ${tool.successCalls || 0} / 失败 ${tool.failedCalls || 0} · 成功率 ${toolSuccessRate}%`;
|
||||
html += `
|
||||
<div class="monitor-stat-card">
|
||||
<h4>${escapeHtml(tool.toolName || '未知工具')}</h4>
|
||||
<h4>${escapeHtml(tool.toolName || unknownToolLabel)}</h4>
|
||||
<div class="monitor-stat-value">${tool.totalCalls || 0}</div>
|
||||
<div class="monitor-stat-meta">
|
||||
成功 ${tool.successCalls || 0} / 失败 ${tool.failedCalls || 0} · 成功率 ${toolSuccessRate}%
|
||||
${escapeHtml(toolMeta)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1307,10 +1348,12 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||
const currentToolFilter = toolFilter ? toolFilter.value : 'all';
|
||||
const hasFilter = (statusFilter && statusFilter !== 'all') || (currentToolFilter && currentToolFilter !== 'all');
|
||||
const noRecordsFilter = typeof window.t === 'function' ? window.t('mcpMonitor.noRecordsWithFilter') : '当前筛选条件下暂无记录';
|
||||
const noExecutions = typeof window.t === 'function' ? window.t('mcpMonitor.noExecutions') : '暂无执行记录';
|
||||
if (hasFilter) {
|
||||
container.innerHTML = '<div class="monitor-empty">当前筛选条件下暂无记录</div>';
|
||||
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noRecordsFilter) + '</div>';
|
||||
} else {
|
||||
container.innerHTML = '<div class="monitor-empty">暂无执行记录</div>';
|
||||
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noExecutions) + '</div>';
|
||||
}
|
||||
// 隐藏批量操作栏
|
||||
const batchActions = document.getElementById('monitor-batch-actions');
|
||||
@@ -1322,14 +1365,22 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
|
||||
// 由于筛选已经在后端完成,这里直接使用所有传入的执行记录
|
||||
// 不再需要前端再次筛选,因为后端已经返回了筛选后的数据
|
||||
const unknownLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknown') : '未知';
|
||||
const unknownToolLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknownTool') : '未知工具';
|
||||
const viewDetailLabel = typeof window.t === 'function' ? window.t('mcpMonitor.viewDetail') : '查看详情';
|
||||
const deleteLabel = typeof window.t === 'function' ? window.t('mcpMonitor.delete') : '删除';
|
||||
const deleteExecTitle = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecTitle') : '删除此执行记录';
|
||||
const statusKeyMap = { pending: 'statusPending', running: 'statusRunning', completed: 'statusCompleted', failed: 'statusFailed' };
|
||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
|
||||
const rows = executions
|
||||
.map(exec => {
|
||||
const status = (exec.status || 'unknown').toLowerCase();
|
||||
const statusClass = `monitor-status-chip ${status}`;
|
||||
const statusLabel = getStatusText(status);
|
||||
const startTime = exec.startTime ? new Date(exec.startTime).toLocaleString('zh-CN') : '未知';
|
||||
const statusKey = statusKeyMap[status];
|
||||
const statusLabel = (typeof window.t === 'function' && statusKey) ? window.t('mcpMonitor.' + statusKey) : getStatusText(status);
|
||||
const startTime = exec.startTime ? (new Date(exec.startTime).toLocaleString ? new Date(exec.startTime).toLocaleString(locale || 'en-US') : String(exec.startTime)) : unknownLabel;
|
||||
const duration = formatExecutionDuration(exec.startTime, exec.endTime);
|
||||
const toolName = escapeHtml(exec.toolName || '未知工具');
|
||||
const toolName = escapeHtml(exec.toolName || unknownToolLabel);
|
||||
const executionId = escapeHtml(exec.id || '');
|
||||
return `
|
||||
<tr>
|
||||
@@ -1337,13 +1388,13 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
<input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" onchange="updateBatchActionsState()" />
|
||||
</td>
|
||||
<td>${toolName}</td>
|
||||
<td><span class="${statusClass}">${statusLabel}</span></td>
|
||||
<td>${startTime}</td>
|
||||
<td>${duration}</td>
|
||||
<td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td>
|
||||
<td>${escapeHtml(startTime)}</td>
|
||||
<td>${escapeHtml(duration)}</td>
|
||||
<td>
|
||||
<div class="monitor-execution-actions">
|
||||
<button class="btn-secondary" onclick="showMCPDetail('${executionId}')">查看详情</button>
|
||||
<button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="删除此执行记录">删除</button>
|
||||
<button class="btn-secondary" onclick="showMCPDetail('${executionId}')">${escapeHtml(viewDetailLabel)}</button>
|
||||
<button class="btn-secondary btn-delete" onclick="deleteExecution('${executionId}')" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1365,6 +1416,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
// 创建表格容器
|
||||
const tableContainer = document.createElement('div');
|
||||
tableContainer.className = 'monitor-table-container';
|
||||
const colTool = typeof window.t === 'function' ? window.t('mcpMonitor.columnTool') : '工具';
|
||||
const colStatus = typeof window.t === 'function' ? window.t('mcpMonitor.columnStatus') : '状态';
|
||||
const colStartTime = typeof window.t === 'function' ? window.t('mcpMonitor.columnStartTime') : '开始时间';
|
||||
const colDuration = typeof window.t === 'function' ? window.t('mcpMonitor.columnDuration') : '耗时';
|
||||
const colActions = typeof window.t === 'function' ? window.t('mcpMonitor.columnActions') : '操作';
|
||||
tableContainer.innerHTML = `
|
||||
<table class="monitor-table">
|
||||
<thead>
|
||||
@@ -1372,11 +1428,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
<th style="width: 40px;">
|
||||
<input type="checkbox" id="monitor-select-all" onchange="toggleSelectAll(this)" />
|
||||
</th>
|
||||
<th>工具</th>
|
||||
<th>状态</th>
|
||||
<th>开始时间</th>
|
||||
<th>耗时</th>
|
||||
<th>操作</th>
|
||||
<th>${escapeHtml(colTool)}</th>
|
||||
<th>${escapeHtml(colStatus)}</th>
|
||||
<th>${escapeHtml(colStartTime)}</th>
|
||||
<th>${escapeHtml(colDuration)}</th>
|
||||
<th>${escapeHtml(colActions)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
@@ -1415,12 +1471,18 @@ function renderMonitorPagination() {
|
||||
// 处理没有数据的情况
|
||||
const startItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const endItem = total === 0 ? 0 : Math.min(page * pageSize, total);
|
||||
|
||||
const paginationInfoText = typeof window.t === 'function' ? window.t('mcpMonitor.paginationInfo', { start: startItem, end: endItem, total: total }) : `显示 ${startItem}-${endItem} / 共 ${total} 条记录`;
|
||||
const perPageLabel = typeof window.t === 'function' ? window.t('mcpMonitor.perPageLabel') : '每页显示';
|
||||
const firstPageLabel = typeof window.t === 'function' ? window.t('mcp.firstPage') : '首页';
|
||||
const prevPageLabel = typeof window.t === 'function' ? window.t('mcp.prevPage') : '上一页';
|
||||
const pageInfoText = typeof window.t === 'function' ? window.t('mcp.pageInfo', { page: page, total: totalPages || 1 }) : `第 ${page} / ${totalPages || 1} 页`;
|
||||
const nextPageLabel = typeof window.t === 'function' ? window.t('mcp.nextPage') : '下一页';
|
||||
const lastPageLabel = typeof window.t === 'function' ? window.t('mcp.lastPage') : '末页';
|
||||
pagination.innerHTML = `
|
||||
<div class="pagination-info">
|
||||
<span>显示 ${startItem}-${endItem} / 共 ${total} 条记录</span>
|
||||
<span>${escapeHtml(paginationInfoText)}</span>
|
||||
<label class="pagination-page-size">
|
||||
每页显示
|
||||
${escapeHtml(perPageLabel)}
|
||||
<select id="monitor-page-size" onchange="changeMonitorPageSize()">
|
||||
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
|
||||
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||||
@@ -1430,11 +1492,11 @@ function renderMonitorPagination() {
|
||||
</label>
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-secondary" onclick="refreshMonitorPanel(1)" ${page === 1 || total === 0 ? 'disabled' : ''}>首页</button>
|
||||
<button class="btn-secondary" onclick="refreshMonitorPanel(${page - 1})" ${page === 1 || total === 0 ? 'disabled' : ''}>上一页</button>
|
||||
<span class="pagination-page">第 ${page} / ${totalPages || 1} 页</span>
|
||||
<button class="btn-secondary" onclick="refreshMonitorPanel(${page + 1})" ${page >= totalPages || total === 0 ? 'disabled' : ''}>下一页</button>
|
||||
<button class="btn-secondary" onclick="refreshMonitorPanel(${totalPages || 1})" ${page >= totalPages || total === 0 ? 'disabled' : ''}>末页</button>
|
||||
<button class="btn-secondary" onclick="refreshMonitorPanel(1)" ${page === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(firstPageLabel)}</button>
|
||||
<button class="btn-secondary" onclick="refreshMonitorPanel(${page - 1})" ${page === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(prevPageLabel)}</button>
|
||||
<span class="pagination-page">${escapeHtml(pageInfoText)}</span>
|
||||
<button class="btn-secondary" onclick="refreshMonitorPanel(${page + 1})" ${page >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(nextPageLabel)}</button>
|
||||
<button class="btn-secondary" onclick="refreshMonitorPanel(${totalPages || 1})" ${page >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(lastPageLabel)}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1450,8 +1512,8 @@ async function deleteExecution(executionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
if (!confirm('确定要删除此执行记录吗?此操作不可恢复。')) {
|
||||
const deleteConfirmMsg = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecConfirmSingle') : '确定要删除此执行记录吗?此操作不可恢复。';
|
||||
if (!confirm(deleteConfirmMsg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1462,17 +1524,20 @@ async function deleteExecution(executionId) {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || '删除执行记录失败');
|
||||
const deleteFailedMsg = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecFailed') : '删除执行记录失败';
|
||||
throw new Error(error.error || deleteFailedMsg);
|
||||
}
|
||||
|
||||
// 删除成功后刷新当前页面
|
||||
const currentPage = monitorState.pagination.page;
|
||||
await refreshMonitorPanel(currentPage);
|
||||
|
||||
alert('执行记录已删除');
|
||||
const execDeletedMsg = typeof window.t === 'function' ? window.t('mcpMonitor.execDeleted') : '执行记录已删除';
|
||||
alert(execDeletedMsg);
|
||||
} catch (error) {
|
||||
console.error('删除执行记录失败:', error);
|
||||
alert('删除执行记录失败: ' + error.message);
|
||||
const deleteFailedMsg = typeof window.t === 'function' ? window.t('mcpMonitor.deleteExecFailed') : '删除执行记录失败';
|
||||
alert(deleteFailedMsg + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1487,14 +1552,14 @@ function updateBatchActionsState() {
|
||||
if (batchActions) {
|
||||
batchActions.style.display = 'flex';
|
||||
}
|
||||
if (selectedCountSpan) {
|
||||
selectedCountSpan.textContent = `已选择 ${selectedCount} 项`;
|
||||
}
|
||||
} else {
|
||||
if (batchActions) {
|
||||
batchActions.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (selectedCountSpan) {
|
||||
selectedCountSpan.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: selectedCount }) : '已选择 ' + selectedCount + ' 项';
|
||||
}
|
||||
|
||||
// 更新全选复选框状态
|
||||
const selectAllCheckbox = document.getElementById('monitor-select-all');
|
||||
@@ -1547,15 +1612,15 @@ function deselectAllExecutions() {
|
||||
async function batchDeleteExecutions() {
|
||||
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('请先选择要删除的执行记录');
|
||||
const selectFirstMsg = typeof window.t === 'function' ? window.t('mcpMonitor.selectExecFirst') : '请先选择要删除的执行记录';
|
||||
alert(selectFirstMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = Array.from(checkboxes).map(cb => cb.value);
|
||||
const count = ids.length;
|
||||
|
||||
// 确认删除
|
||||
if (!confirm(`确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`)) {
|
||||
const batchConfirmMsg = typeof window.t === 'function' ? window.t('mcpMonitor.batchDeleteConfirm', { count: count }) : `确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`;
|
||||
if (!confirm(batchConfirmMsg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1570,7 +1635,8 @@ async function batchDeleteExecutions() {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || '批量删除执行记录失败');
|
||||
const batchFailedMsg = typeof window.t === 'function' ? window.t('mcp.batchDeleteFailed') : '批量删除执行记录失败';
|
||||
throw new Error(error.error || batchFailedMsg);
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
@@ -1580,33 +1646,46 @@ async function batchDeleteExecutions() {
|
||||
const currentPage = monitorState.pagination.page;
|
||||
await refreshMonitorPanel(currentPage);
|
||||
|
||||
alert(`成功删除 ${deletedCount} 条执行记录`);
|
||||
const batchSuccessMsg = typeof window.t === 'function' ? window.t('mcpMonitor.batchDeleteSuccess', { count: deletedCount }) : `成功删除 ${deletedCount} 条执行记录`;
|
||||
alert(batchSuccessMsg);
|
||||
} catch (error) {
|
||||
console.error('批量删除执行记录失败:', error);
|
||||
alert('批量删除执行记录失败: ' + error.message);
|
||||
const batchFailedMsg = typeof window.t === 'function' ? window.t('mcp.batchDeleteFailed') : '批量删除执行记录失败';
|
||||
alert(batchFailedMsg + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function formatExecutionDuration(start, end) {
|
||||
const unknownLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknown') : '未知';
|
||||
if (!start) {
|
||||
return '未知';
|
||||
return unknownLabel;
|
||||
}
|
||||
const startTime = new Date(start);
|
||||
const endTime = end ? new Date(end) : new Date();
|
||||
if (Number.isNaN(startTime.getTime()) || Number.isNaN(endTime.getTime())) {
|
||||
return '未知';
|
||||
return unknownLabel;
|
||||
}
|
||||
const diffMs = Math.max(0, endTime - startTime);
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
if (seconds < 60) {
|
||||
return `${seconds} 秒`;
|
||||
return typeof window.t === 'function' ? window.t('mcpMonitor.durationSeconds', { n: seconds }) : seconds + ' 秒';
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
const remain = seconds % 60;
|
||||
return remain > 0 ? `${minutes} 分 ${remain} 秒` : `${minutes} 分`;
|
||||
if (remain > 0) {
|
||||
return typeof window.t === 'function' ? window.t('mcpMonitor.durationMinutes', { minutes: minutes, seconds: remain }) : minutes + ' 分 ' + remain + ' 秒';
|
||||
}
|
||||
return typeof window.t === 'function' ? window.t('mcpMonitor.durationMinutesOnly', { minutes: minutes }) : minutes + ' 分';
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainMinutes = minutes % 60;
|
||||
return remainMinutes > 0 ? `${hours} 小时 ${remainMinutes} 分` : `${hours} 小时`;
|
||||
if (remainMinutes > 0) {
|
||||
return typeof window.t === 'function' ? window.t('mcpMonitor.durationHours', { hours: hours, minutes: remainMinutes }) : hours + ' 小时 ' + remainMinutes + ' 分';
|
||||
}
|
||||
return typeof window.t === 'function' ? window.t('mcpMonitor.durationHoursOnly', { hours: hours }) : hours + ' 小时';
|
||||
}
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
updateBatchActionsState();
|
||||
});
|
||||
|
||||
+48
-38
@@ -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,7 @@ async function loadRoles() {
|
||||
return roles;
|
||||
} catch (error) {
|
||||
console.error('加载角色失败:', error);
|
||||
showNotification('加载角色失败: ' + error.message, 'error');
|
||||
showNotification(_t('roles.loadFailed') + ': ' + error.message, 'error');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -108,11 +111,13 @@ function updateRoleSelectorDisplay() {
|
||||
}
|
||||
}
|
||||
roleSelectorIcon.textContent = icon;
|
||||
roleSelectorText.textContent = selectedRole.name || '默认';
|
||||
const displayName = (selectedRole.name === '默认' || !selectedRole.name) && typeof window.t === 'function'
|
||||
? window.t('chat.defaultRole') : (selectedRole.name || (typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认'));
|
||||
roleSelectorText.textContent = displayName;
|
||||
} else {
|
||||
// 默认角色
|
||||
roleSelectorIcon.textContent = '🔵';
|
||||
roleSelectorText.textContent = '默认';
|
||||
roleSelectorText.textContent = typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,9 +170,9 @@ function renderRoleSelectionSidebar() {
|
||||
const icon = getRoleIcon(role);
|
||||
|
||||
// 处理默认角色的描述
|
||||
let description = role.description || '暂无描述';
|
||||
let description = role.description || _t('roles.noDescription');
|
||||
if (isDefaultRole && !role.description) {
|
||||
description = '默认角色,不额外携带用户提示词,使用默认MCP';
|
||||
description = _t('roles.defaultRoleDescription');
|
||||
}
|
||||
|
||||
roleItem.innerHTML = `
|
||||
@@ -280,7 +285,7 @@ function renderRolesList() {
|
||||
|
||||
if (filteredRoles.length === 0) {
|
||||
rolesList.innerHTML = '<div class="empty-state">' +
|
||||
(rolesSearchKeyword ? '没有找到匹配的角色' : '暂无角色') +
|
||||
(rolesSearchKeyword ? _t('roles.noMatchingRoles') : _t('roles.noRoles')) +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
@@ -310,7 +315,7 @@ function renderRolesList() {
|
||||
let toolsDisplay = '';
|
||||
let toolsCount = 0;
|
||||
if (role.name === '默认') {
|
||||
toolsDisplay = '使用所有工具';
|
||||
toolsDisplay = _t('roleModal.usingAllTools');
|
||||
} else if (role.tools && role.tools.length > 0) {
|
||||
toolsCount = role.tools.length;
|
||||
// 显示前5个工具名称
|
||||
@@ -322,13 +327,13 @@ function renderRolesList() {
|
||||
if (toolsCount <= 5) {
|
||||
toolsDisplay = toolNames.join(', ');
|
||||
} else {
|
||||
toolsDisplay = toolNames.join(', ') + ` 等 ${toolsCount} 个`;
|
||||
toolsDisplay = toolNames.join(', ') + _t('roleModal.andNMore', { count: toolsCount });
|
||||
}
|
||||
} else if (role.mcps && role.mcps.length > 0) {
|
||||
toolsCount = role.mcps.length;
|
||||
toolsDisplay = `等 ${toolsCount} 个`;
|
||||
toolsDisplay = _t('roleModal.andNMore', { count: toolsCount });
|
||||
} else {
|
||||
toolsDisplay = '使用所有工具';
|
||||
toolsDisplay = _t('roleModal.usingAllTools');
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -339,17 +344,17 @@ function renderRolesList() {
|
||||
${escapeHtml(role.name)}
|
||||
</h3>
|
||||
<span class="role-card-badge ${role.enabled !== false ? 'enabled' : 'disabled'}">
|
||||
${role.enabled !== false ? '已启用' : '已禁用'}
|
||||
${role.enabled !== false ? _t('roles.enabled') : _t('roles.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="role-card-description">${escapeHtml(role.description || '无描述')}</div>
|
||||
<div class="role-card-description">${escapeHtml(role.description || _t('roles.noDescriptionShort'))}</div>
|
||||
<div class="role-card-tools">
|
||||
<span class="role-card-tools-label">工具:</span>
|
||||
<span class="role-card-tools-label">${_t('roleModal.toolsLabel')}</span>
|
||||
<span class="role-card-tools-value">${toolsDisplay}</span>
|
||||
</div>
|
||||
<div class="role-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="editRole('${escapeHtml(role.name)}')">编辑</button>
|
||||
${role.name !== '默认' ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteRole('${escapeHtml(role.name)}')">删除</button>` : ''}
|
||||
<button class="btn-secondary btn-small" onclick="editRole('${escapeHtml(role.name)}')">${_t('common.edit')}</button>
|
||||
${role.name !== '默认' ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteRole('${escapeHtml(role.name)}')">${_t('common.delete')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -501,7 +506,7 @@ async function loadRoleTools(page = 1, searchKeyword = '') {
|
||||
console.error('加载工具列表失败:', error);
|
||||
const toolsList = document.getElementById('role-tools-list');
|
||||
if (toolsList) {
|
||||
toolsList.innerHTML = `<div class="tools-error">加载工具列表失败: ${escapeHtml(error.message)}</div>`;
|
||||
toolsList.innerHTML = `<div class="tools-error">${_t('roleModal.loadToolsFailed')}: ${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,7 +524,7 @@ function renderRoleToolsList() {
|
||||
listContainer.innerHTML = '';
|
||||
|
||||
if (allRoleTools.length === 0) {
|
||||
listContainer.innerHTML = '<div class="tools-empty">暂无工具</div>';
|
||||
listContainer.innerHTML = '<div class="tools-empty">' + _t('roleModal.noTools') + '</div>';
|
||||
toolsList.appendChild(listContainer);
|
||||
return;
|
||||
}
|
||||
@@ -592,16 +597,16 @@ function renderRoleToolsPagination() {
|
||||
const startItem = (page - 1) * roleToolsPagination.pageSize + 1;
|
||||
const endItem = Math.min(page * roleToolsPagination.pageSize, total);
|
||||
|
||||
const paginationShowText = _t('roleModal.paginationShow', { start: startItem, end: endItem, total: total }) +
|
||||
(roleToolsSearchKeyword ? _t('roleModal.paginationSearch', { keyword: roleToolsSearchKeyword }) : '');
|
||||
pagination.innerHTML = `
|
||||
<div class="pagination-info">
|
||||
显示 ${startItem}-${endItem} / 共 ${total} 个工具${roleToolsSearchKeyword ? ` (搜索: "${escapeHtml(roleToolsSearchKeyword)}")` : ''}
|
||||
</div>
|
||||
<div class="pagination-info">${paginationShowText}</div>
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-secondary" onclick="loadRoleTools(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>首页</button>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(${page - 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>上一页</button>
|
||||
<span class="pagination-page">第 ${page} / ${totalPages} 页</span>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>下一页</button>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>末页</button>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${_t('roleModal.firstPage')}</button>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(${page - 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${_t('roleModal.prevPage')}</button>
|
||||
<span class="pagination-page">${_t('roleModal.pageOf', { page: page, total: totalPages })}</span>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${_t('roleModal.nextPage')}</button>
|
||||
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${_t('roleModal.lastPage')}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -725,8 +730,8 @@ function updateRoleToolsStats() {
|
||||
// 总工具数(所有工具,包括已启用和未启用的)
|
||||
const totalTools = roleToolsPagination.total || 0;
|
||||
statsEl.innerHTML = `
|
||||
<span title="当前页选中的工具数">✅ 当前页已选中: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
|
||||
<span title="所有已启用工具中选中的工具总数(基于MCP管理)">📊 总计已选中: <strong>${totalEnabled}</strong> / ${totalTools} <em>(使用所有已启用工具)</em></span>
|
||||
<span title="${_t('roleModal.currentPageSelectedTitle')}">✅ ${_t('roleModal.currentPageSelected', { current: currentPageEnabled, total: currentPageTotal })}</span>
|
||||
<span title="${_t('roleModal.totalSelectedTitle')}">📊 ${_t('roleModal.totalSelected', { current: totalEnabled, total: totalTools })} <em>${_t('roleModal.usingAllEnabledTools')}</em></span>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
@@ -777,8 +782,8 @@ function updateRoleToolsStats() {
|
||||
const totalTools = roleToolsPagination.total || 0;
|
||||
|
||||
statsEl.innerHTML = `
|
||||
<span title="当前页选中的工具数(只统计已启用的工具)">✅ 当前页已选中: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
|
||||
<span title="角色已关联的工具总数(基于角色实际配置)">📊 总计已选中: <strong>${totalSelected}</strong> / ${totalTools}</span>
|
||||
<span title="${_t('roleModal.currentPageSelectedTitle')}">✅ ${_t('roleModal.currentPageSelected', { current: currentPageEnabled, total: currentPageTotal })}</span>
|
||||
<span title="${_t('roleModal.totalSelectedTitle')}">📊 ${_t('roleModal.totalSelected', { current: totalSelected, total: totalTools })}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -836,7 +841,7 @@ async function showAddRoleModal() {
|
||||
const modal = document.getElementById('role-modal');
|
||||
if (!modal) return;
|
||||
|
||||
document.getElementById('role-modal-title').textContent = '添加角色';
|
||||
document.getElementById('role-modal-title').textContent = _t('roleModal.addRole');
|
||||
document.getElementById('role-name').value = '';
|
||||
document.getElementById('role-name').disabled = false;
|
||||
document.getElementById('role-description').value = '';
|
||||
@@ -916,14 +921,14 @@ async function showAddRoleModal() {
|
||||
async function editRole(roleName) {
|
||||
const role = roles.find(r => r.name === roleName);
|
||||
if (!role) {
|
||||
showNotification('角色不存在', 'error');
|
||||
showNotification(_t('roleModal.roleNotFound'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('role-modal');
|
||||
if (!modal) return;
|
||||
|
||||
document.getElementById('role-modal-title').textContent = '编辑角色';
|
||||
document.getElementById('role-modal-title').textContent = _t('roleModal.editRole');
|
||||
document.getElementById('role-name').value = role.name;
|
||||
document.getElementById('role-name').disabled = true; // 编辑时不允许修改名称
|
||||
document.getElementById('role-description').value = role.description || '';
|
||||
@@ -1184,7 +1189,7 @@ async function loadAllToolsToStateMap() {
|
||||
async function saveRole() {
|
||||
const name = document.getElementById('role-name').value.trim();
|
||||
if (!name) {
|
||||
showNotification('角色名称不能为空', 'error');
|
||||
showNotification(_t('roleModal.roleNameRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1225,7 +1230,7 @@ async function saveRole() {
|
||||
// 如果是首次添加角色且没有选择工具,默认使用全部工具
|
||||
if (isFirstUserRole && allSelectedTools.length === 0) {
|
||||
roleUsesAllTools = true;
|
||||
showNotification('检测到这是首次添加角色且未选择工具,将默认使用全部工具', 'info');
|
||||
showNotification(_t('roleModal.firstRoleNoToolsHint'), 'info');
|
||||
} else if (roleUsesAllTools) {
|
||||
// 如果当前使用所有工具,需要检查用户是否取消了一些工具
|
||||
// 检查状态映射中是否有未选中的已启用工具
|
||||
@@ -1356,7 +1361,7 @@ async function saveRole() {
|
||||
// 删除角色
|
||||
async function deleteRole(roleName) {
|
||||
if (roleName === '默认') {
|
||||
showNotification('不能删除默认角色', 'error');
|
||||
showNotification(_t('roleModal.cannotDeleteDefaultRole'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1428,6 +1433,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
updateRoleSelectorDisplay();
|
||||
});
|
||||
|
||||
// 语言切换后刷新角色选择器显示(默认/自定义角色名)
|
||||
document.addEventListener('languagechange', () => {
|
||||
updateRoleSelectorDisplay();
|
||||
});
|
||||
|
||||
// 获取当前选中的角色(供chat.js使用)
|
||||
function getCurrentRole() {
|
||||
return currentRole || '';
|
||||
@@ -1467,7 +1477,7 @@ async function loadRoleSkills() {
|
||||
allRoleSkills = [];
|
||||
const skillsList = document.getElementById('role-skills-list');
|
||||
if (skillsList) {
|
||||
skillsList.innerHTML = '<div class="skills-error">加载skills列表失败: ' + error.message + '</div>';
|
||||
skillsList.innerHTML = '<div class="skills-error">' + _t('roleModal.loadSkillsFailed') + ': ' + error.message + '</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1488,7 +1498,7 @@ function renderRoleSkills() {
|
||||
|
||||
if (filteredSkills.length === 0) {
|
||||
skillsList.innerHTML = '<div class="skills-empty">' +
|
||||
(roleSkillsSearchKeyword ? '没有找到匹配的skills' : '暂无可用skills') +
|
||||
(roleSkillsSearchKeyword ? _t('roleModal.noMatchingSkills') : _t('roleModal.noSkillsAvailable')) +
|
||||
'</div>';
|
||||
updateRoleSkillsStats();
|
||||
return;
|
||||
@@ -1594,7 +1604,7 @@ function updateRoleSkillsStats() {
|
||||
filteredSkills.includes(skill)
|
||||
).length;
|
||||
|
||||
statsEl.textContent = `已选择 ${selectedCount} / ${filteredSkills.length}`;
|
||||
statsEl.textContent = _t('roleModal.skillsSelectedCount', { count: selectedCount, total: filteredSkills.length });
|
||||
}
|
||||
|
||||
// HTML转义函数
|
||||
|
||||
+154
-73
@@ -46,6 +46,9 @@ function switchSettingsSection(section) {
|
||||
if (activeContent) {
|
||||
activeContent.classList.add('active');
|
||||
}
|
||||
if (section === 'terminal' && typeof initTerminal === 'function') {
|
||||
setTimeout(initTerminal, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 打开设置
|
||||
@@ -169,6 +172,43 @@ async function loadConfig(loadTools = true) {
|
||||
// 允许0.0值,只有undefined/null时才使用默认值
|
||||
retrievalWeightInput.value = (hybridWeight !== undefined && hybridWeight !== null) ? hybridWeight : 0.7;
|
||||
}
|
||||
|
||||
// 索引配置
|
||||
const indexing = knowledge.indexing || {};
|
||||
const chunkSizeInput = document.getElementById('knowledge-indexing-chunk-size');
|
||||
if (chunkSizeInput) {
|
||||
chunkSizeInput.value = indexing.chunk_size || 512;
|
||||
}
|
||||
|
||||
const chunkOverlapInput = document.getElementById('knowledge-indexing-chunk-overlap');
|
||||
if (chunkOverlapInput) {
|
||||
chunkOverlapInput.value = indexing.chunk_overlap ?? 50;
|
||||
}
|
||||
|
||||
const maxChunksPerItemInput = document.getElementById('knowledge-indexing-max-chunks-per-item');
|
||||
if (maxChunksPerItemInput) {
|
||||
maxChunksPerItemInput.value = indexing.max_chunks_per_item ?? 0;
|
||||
}
|
||||
|
||||
const maxRpmInput = document.getElementById('knowledge-indexing-max-rpm');
|
||||
if (maxRpmInput) {
|
||||
maxRpmInput.value = indexing.max_rpm ?? 0;
|
||||
}
|
||||
|
||||
const rateLimitDelayInput = document.getElementById('knowledge-indexing-rate-limit-delay-ms');
|
||||
if (rateLimitDelayInput) {
|
||||
rateLimitDelayInput.value = indexing.rate_limit_delay_ms ?? 300;
|
||||
}
|
||||
|
||||
const maxRetriesInput = document.getElementById('knowledge-indexing-max-retries');
|
||||
if (maxRetriesInput) {
|
||||
maxRetriesInput.value = indexing.max_retries ?? 3;
|
||||
}
|
||||
|
||||
const retryDelayInput = document.getElementById('knowledge-indexing-retry-delay-ms');
|
||||
if (retryDelayInput) {
|
||||
retryDelayInput.value = indexing.retry_delay_ms ?? 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// 填充机器人配置
|
||||
@@ -215,7 +255,10 @@ async function loadConfig(loadTools = true) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
alert('加载配置失败: ' + error.message);
|
||||
const baseMsg = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('settings.apply.loadFailed')
|
||||
: '加载配置失败';
|
||||
alert(baseMsg + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +272,7 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
// 显示加载状态
|
||||
if (toolsList) {
|
||||
// 清空整个容器,包括可能存在的分页控件
|
||||
toolsList.innerHTML = '<div class="tools-list-items"><div class="loading" style="padding: 20px; text-align: center; color: var(--text-muted);">⏳ 正在加载工具列表...</div></div>';
|
||||
toolsList.innerHTML = '<div class="tools-list-items"><div class="loading" style="padding: 20px; text-align: center; color: var(--text-muted);">⏳ ' + (typeof window.t === 'function' ? window.t('mcp.loadingTools') : '正在加载工具列表...') + '</div></div>';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -284,8 +327,8 @@ async function loadToolsList(page = 1, searchKeyword = '') {
|
||||
if (toolsList) {
|
||||
const isTimeout = error.name === 'AbortError' || error.message.includes('timeout');
|
||||
const errorMsg = isTimeout
|
||||
? '加载工具列表超时,可能是外部MCP连接较慢。请点击"刷新"按钮重试,或检查外部MCP连接状态。'
|
||||
: `加载工具列表失败: ${escapeHtml(error.message)}`;
|
||||
? (typeof window.t === 'function' ? window.t('mcp.loadToolsTimeout') : '加载工具列表超时,可能是外部MCP连接较慢。请点击"刷新"按钮重试,或检查外部MCP连接状态。')
|
||||
: (typeof window.t === 'function' ? window.t('mcp.loadToolsFailed') : '加载工具列表失败') + ': ' + escapeHtml(error.message);
|
||||
toolsList.innerHTML = `<div class="error" style="padding: 20px; text-align: center;">${errorMsg}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -359,7 +402,7 @@ function renderToolsList() {
|
||||
listContainer.innerHTML = '';
|
||||
|
||||
if (allTools.length === 0) {
|
||||
listContainer.innerHTML = '<div class="empty">暂无工具</div>';
|
||||
listContainer.innerHTML = '<div class="empty">' + (typeof window.t === 'function' ? window.t('mcp.noTools') : '暂无工具') + '</div>';
|
||||
if (!toolsList.contains(listContainer)) {
|
||||
toolsList.appendChild(listContainer);
|
||||
}
|
||||
@@ -388,8 +431,8 @@ function renderToolsList() {
|
||||
let externalBadge = '';
|
||||
if (toolState.is_external || tool.is_external) {
|
||||
const externalMcpName = toolState.external_mcp || tool.external_mcp || '';
|
||||
const badgeText = externalMcpName ? `外部 (${escapeHtml(externalMcpName)})` : '外部';
|
||||
const badgeTitle = externalMcpName ? `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}` : '外部MCP工具';
|
||||
const badgeText = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalFrom', { name: escapeHtml(externalMcpName) }) : `外部 (${escapeHtml(externalMcpName)})`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部');
|
||||
const badgeTitle = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalToolFrom', { name: escapeHtml(externalMcpName) }) : `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部MCP工具');
|
||||
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
|
||||
}
|
||||
|
||||
@@ -403,7 +446,7 @@ function renderToolsList() {
|
||||
${escapeHtml(tool.name)}
|
||||
${externalBadge}
|
||||
</div>
|
||||
<div class="tool-item-desc">${escapeHtml(tool.description || '无描述')}</div>
|
||||
<div class="tool-item-desc">${escapeHtml(tool.description || (typeof window.t === 'function' ? window.t('mcp.noDescription') : '无描述'))}</div>
|
||||
</div>
|
||||
`;
|
||||
listContainer.appendChild(toolItem);
|
||||
@@ -441,12 +484,19 @@ function renderToolsPagination() {
|
||||
const endItem = Math.min(page * toolsPagination.pageSize, total);
|
||||
|
||||
const savedPageSize = getToolsPageSize();
|
||||
const t = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
const paginationT = (key, opts) => {
|
||||
if (typeof window.t === 'function') return window.t(key, opts);
|
||||
if (key === 'mcp.paginationInfo' && opts) return `显示 ${opts.start}-${opts.end} / 共 ${opts.total} 个工具`;
|
||||
if (key === 'mcp.pageInfo' && opts) return `第 ${opts.page} / ${opts.total} 页`;
|
||||
return key;
|
||||
};
|
||||
pagination.innerHTML = `
|
||||
<div class="pagination-info">
|
||||
显示 ${startItem}-${endItem} / 共 ${total} 个工具${toolsSearchKeyword ? ` (搜索: "${escapeHtml(toolsSearchKeyword)}")` : ''}
|
||||
${paginationT('mcp.paginationInfo', { start: startItem, end: endItem, total: total })}${toolsSearchKeyword ? ` (${t('common.search')}: "${escapeHtml(toolsSearchKeyword)}")` : ''}
|
||||
</div>
|
||||
<div class="pagination-page-size">
|
||||
<label for="tools-page-size-pagination">每页:</label>
|
||||
<label for="tools-page-size-pagination">${t('mcp.perPage')}</label>
|
||||
<select id="tools-page-size-pagination" onchange="changeToolsPageSize()">
|
||||
<option value="10" ${savedPageSize === 10 ? 'selected' : ''}>10</option>
|
||||
<option value="20" ${savedPageSize === 20 ? 'selected' : ''}>20</option>
|
||||
@@ -455,11 +505,11 @@ function renderToolsPagination() {
|
||||
</select>
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-secondary" onclick="loadToolsList(1, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>首页</button>
|
||||
<button class="btn-secondary" onclick="loadToolsList(${page - 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>上一页</button>
|
||||
<span class="pagination-page">第 ${page} / ${totalPages} 页</span>
|
||||
<button class="btn-secondary" onclick="loadToolsList(${page + 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>下一页</button>
|
||||
<button class="btn-secondary" onclick="loadToolsList(${totalPages}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>末页</button>
|
||||
<button class="btn-secondary" onclick="loadToolsList(1, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${t('mcp.firstPage')}</button>
|
||||
<button class="btn-secondary" onclick="loadToolsList(${page - 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${t('mcp.prevPage')}</button>
|
||||
<span class="pagination-page">${paginationT('mcp.pageInfo', { page: page, total: totalPages })}</span>
|
||||
<button class="btn-secondary" onclick="loadToolsList(${page + 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${t('mcp.nextPage')}</button>
|
||||
<button class="btn-secondary" onclick="loadToolsList(${totalPages}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${t('mcp.lastPage')}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -653,9 +703,10 @@ async function updateToolsStats() {
|
||||
totalEnabled = currentPageEnabled;
|
||||
}
|
||||
|
||||
const tStats = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
statsEl.innerHTML = `
|
||||
<span title="当前页启用的工具数">✅ 当前页已启用: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
|
||||
<span title="所有工具中启用的工具总数">📊 总计已启用: <strong>${totalEnabled}</strong> / ${totalTools}</span>
|
||||
<span title="${tStats('mcp.currentPageEnabled')}">✅ ${tStats('mcp.currentPageEnabled')}: <strong>${currentPageEnabled}</strong> / ${currentPageTotal}</span>
|
||||
<span title="${tStats('mcp.totalEnabled')}">📊 ${tStats('mcp.totalEnabled')}: <strong>${totalEnabled}</strong> / ${totalTools}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -697,7 +748,10 @@ async function applySettings() {
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
alert('请填写所有必填字段(标记为 * 的字段)');
|
||||
const msg = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('settings.apply.fillRequired')
|
||||
: '请填写所有必填字段(标记为 * 的字段)';
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -725,6 +779,15 @@ async function applySettings() {
|
||||
const val = parseFloat(document.getElementById('knowledge-retrieval-hybrid-weight')?.value);
|
||||
return isNaN(val) ? 0.7 : val; // 允许0.0值,只有NaN时才使用默认值
|
||||
})()
|
||||
},
|
||||
indexing: {
|
||||
chunk_size: parseInt(document.getElementById("knowledge-indexing-chunk-size")?.value) || 512,
|
||||
chunk_overlap: parseInt(document.getElementById("knowledge-indexing-chunk-overlap")?.value) ?? 50,
|
||||
max_chunks_per_item: parseInt(document.getElementById("knowledge-indexing-max-chunks-per-item")?.value) ?? 0,
|
||||
max_rpm: parseInt(document.getElementById("knowledge-indexing-max-rpm")?.value) ?? 0,
|
||||
rate_limit_delay_ms: parseInt(document.getElementById("knowledge-indexing-rate-limit-delay-ms")?.value) ?? 300,
|
||||
max_retries: parseInt(document.getElementById("knowledge-indexing-max-retries")?.value) ?? 3,
|
||||
retry_delay_ms: parseInt(document.getElementById("knowledge-indexing-retry-delay-ms")?.value) ?? 1000
|
||||
}
|
||||
};
|
||||
|
||||
@@ -847,7 +910,10 @@ async function applySettings() {
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
const error = await updateResponse.json();
|
||||
throw new Error(error.error || '更新配置失败');
|
||||
const fallback = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('settings.apply.applyFailed')
|
||||
: '应用配置失败';
|
||||
throw new Error(error.error || fallback);
|
||||
}
|
||||
|
||||
// 应用配置
|
||||
@@ -857,14 +923,23 @@ async function applySettings() {
|
||||
|
||||
if (!applyResponse.ok) {
|
||||
const error = await applyResponse.json();
|
||||
throw new Error(error.error || '应用配置失败');
|
||||
const fallback = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('settings.apply.applyFailed')
|
||||
: '应用配置失败';
|
||||
throw new Error(error.error || fallback);
|
||||
}
|
||||
|
||||
alert('配置已成功应用!');
|
||||
const successMsg = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('settings.apply.applySuccess')
|
||||
: '配置已成功应用!';
|
||||
alert(successMsg);
|
||||
closeSettings();
|
||||
} catch (error) {
|
||||
console.error('应用配置失败:', error);
|
||||
alert('应用配置失败: ' + error.message);
|
||||
const baseMsg = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('settings.apply.applyFailed')
|
||||
: '应用配置失败';
|
||||
alert(baseMsg + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -975,7 +1050,7 @@ async function saveToolsConfig() {
|
||||
throw new Error(error.error || '应用配置失败');
|
||||
}
|
||||
|
||||
alert('工具配置已成功保存!');
|
||||
alert(typeof window.t === 'function' ? window.t('mcp.toolsConfigSaved') : '工具配置已成功保存!');
|
||||
|
||||
// 重新加载工具列表以反映最新状态
|
||||
if (typeof loadToolsList === 'function') {
|
||||
@@ -983,7 +1058,7 @@ async function saveToolsConfig() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存工具配置失败:', error);
|
||||
alert('保存工具配置失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('mcp.saveToolsConfigFailed') : '保存工具配置失败') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,7 +1105,7 @@ async function changePassword() {
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
alert('请正确填写当前密码和新密码,新密码至少 8 位且需要两次输入一致。');
|
||||
alert(typeof window.t === 'function' ? window.t('settings.security.fillPasswordHint') : '请正确填写当前密码和新密码,新密码至少 8 位且需要两次输入一致。');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1055,13 +1130,14 @@ async function changePassword() {
|
||||
throw new Error(result.error || '修改密码失败');
|
||||
}
|
||||
|
||||
alert('密码已更新,请使用新密码重新登录。');
|
||||
const pwdMsg = typeof window.t === 'function' ? window.t('settings.security.passwordUpdated') : '密码已更新,请使用新密码重新登录。';
|
||||
alert(pwdMsg);
|
||||
resetPasswordForm();
|
||||
handleUnauthorized({ message: '密码已更新,请使用新密码重新登录。', silent: false });
|
||||
handleUnauthorized({ message: pwdMsg, silent: false });
|
||||
closeSettings();
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error);
|
||||
alert('修改密码失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('settings.security.changePasswordFailed') : '修改密码失败') + ': ' + error.message);
|
||||
} finally {
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
@@ -1124,7 +1200,8 @@ function renderExternalMCPList(servers) {
|
||||
if (!list) return;
|
||||
|
||||
if (Object.keys(servers).length === 0) {
|
||||
list.innerHTML = '<div class="empty">📋 暂无外部MCP配置<br><span style="font-size: 0.875rem; margin-top: 8px; display: block;">点击"添加外部MCP"按钮开始配置</span></div>';
|
||||
const emptyT = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
list.innerHTML = '<div class="empty">📋 ' + emptyT('mcp.noExternalMCP') + '<br><span style="font-size: 0.875rem; margin-top: 8px; display: block;">' + emptyT('mcp.clickToAddExternal') + '</span></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1135,10 +1212,11 @@ function renderExternalMCPList(servers) {
|
||||
status === 'connecting' ? 'status-connecting' :
|
||||
status === 'error' ? 'status-error' :
|
||||
status === 'disabled' ? 'status-disabled' : 'status-disconnected';
|
||||
const statusText = status === 'connected' ? '已连接' :
|
||||
status === 'connecting' ? '连接中...' :
|
||||
status === 'error' ? '连接失败' :
|
||||
status === 'disabled' ? '已禁用' : '未连接';
|
||||
const statusT = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
const statusText = status === 'connected' ? statusT('mcp.connected') :
|
||||
status === 'connecting' ? statusT('mcp.connecting') :
|
||||
status === 'error' ? statusT('mcp.connectionFailed') :
|
||||
status === 'disabled' ? statusT('mcp.disabled') : statusT('mcp.disconnected');
|
||||
const transport = server.config.transport || (server.config.command ? 'stdio' : 'http');
|
||||
const transportIcon = transport === 'stdio' ? '⚙️' : '🌐';
|
||||
|
||||
@@ -1151,15 +1229,15 @@ function renderExternalMCPList(servers) {
|
||||
</div>
|
||||
<div class="external-mcp-item-actions">
|
||||
${status === 'connected' || status === 'disconnected' || status === 'error' ?
|
||||
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" onclick="toggleExternalMCP('${escapeHtml(name)}', '${status}')" title="${status === 'connected' ? '停止连接' : '启动连接'}">
|
||||
${status === 'connected' ? '⏸ 停止' : '▶ 启动'}
|
||||
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" onclick="toggleExternalMCP('${escapeHtml(name)}', '${status}')" title="${status === 'connected' ? statusT('mcp.stopConnection') : statusT('mcp.startConnection')}">
|
||||
${status === 'connected' ? '⏸ ' + statusT('mcp.stop') : '▶ ' + statusT('mcp.start')}
|
||||
</button>` :
|
||||
status === 'connecting' ?
|
||||
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" disabled style="opacity: 0.6; cursor: not-allowed;">
|
||||
⏳ 连接中...
|
||||
</button>` : ''}
|
||||
<button class="btn-small" onclick="editExternalMCP('${escapeHtml(name)}')" title="编辑配置" ${status === 'connecting' ? 'disabled' : ''}>✏️ 编辑</button>
|
||||
<button class="btn-small btn-danger" onclick="deleteExternalMCP('${escapeHtml(name)}')" title="删除配置" ${status === 'connecting' ? 'disabled' : ''}>🗑 删除</button>
|
||||
<button class="btn-small" onclick="editExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.editConfig')}" ${status === 'connecting' ? 'disabled' : ''}>✏️ ${statusT('common.edit')}</button>
|
||||
<button class="btn-small btn-danger" onclick="deleteExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.deleteConfig')}" ${status === 'connecting' ? 'disabled' : ''}>🗑 ${statusT('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
${status === 'error' && server.error ? `
|
||||
@@ -1168,31 +1246,31 @@ function renderExternalMCPList(servers) {
|
||||
</div>` : ''}
|
||||
<div class="external-mcp-item-details">
|
||||
<div>
|
||||
<strong>传输模式</strong>
|
||||
<strong>${statusT('mcp.transportMode')}</strong>
|
||||
<span>${transportIcon} ${escapeHtml(transport.toUpperCase())}</span>
|
||||
</div>
|
||||
${server.tool_count !== undefined && server.tool_count > 0 ? `
|
||||
<div>
|
||||
<strong>工具数量</strong>
|
||||
<strong>${statusT('mcp.toolCount')}</strong>
|
||||
<span style="font-weight: 600; color: var(--accent-color);">🔧 ${server.tool_count} 个工具</span>
|
||||
</div>` : server.tool_count === 0 && status === 'connected' ? `
|
||||
<div>
|
||||
<strong>工具数量</strong>
|
||||
<span style="color: var(--text-muted);">暂无工具</span>
|
||||
<strong>${statusT('mcp.toolCount')}</strong>
|
||||
<span style="color: var(--text-muted);">${statusT('mcp.noTools')}</span>
|
||||
</div>` : ''}
|
||||
${server.config.description ? `
|
||||
<div>
|
||||
<strong>描述</strong>
|
||||
<strong>${statusT('mcp.description')}</strong>
|
||||
<span>${escapeHtml(server.config.description)}</span>
|
||||
</div>` : ''}
|
||||
${server.config.timeout ? `
|
||||
<div>
|
||||
<strong>超时时间</strong>
|
||||
<strong>${statusT('mcp.timeout')}</strong>
|
||||
<span>${server.config.timeout} 秒</span>
|
||||
</div>` : ''}
|
||||
${transport === 'stdio' && server.config.command ? `
|
||||
<div>
|
||||
<strong>命令</strong>
|
||||
<strong>${statusT('mcp.command')}</strong>
|
||||
<span style="font-family: monospace; font-size: 0.8125rem;">${escapeHtml(server.config.command)}</span>
|
||||
</div>` : ''}
|
||||
${transport === 'http' && server.config.url ? `
|
||||
@@ -1218,18 +1296,19 @@ function renderExternalMCPStats(stats) {
|
||||
const disabled = stats.disabled || 0;
|
||||
const connected = stats.connected || 0;
|
||||
|
||||
const statsT = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
statsEl.innerHTML = `
|
||||
<span title="总配置数">📊 总数: <strong>${total}</strong></span>
|
||||
<span title="已启用的配置数">✅ 已启用: <strong>${enabled}</strong></span>
|
||||
<span title="已停用的配置数">⏸ 已停用: <strong>${disabled}</strong></span>
|
||||
<span title="当前已连接的配置数">🔗 已连接: <strong>${connected}</strong></span>
|
||||
<span title="${statsT('mcp.totalCount')}">📊 ${statsT('mcp.totalCount')}: <strong>${total}</strong></span>
|
||||
<span title="${statsT('mcp.enabledCount')}">✅ ${statsT('mcp.enabledCount')}: <strong>${enabled}</strong></span>
|
||||
<span title="${statsT('mcp.disabledCount')}">⏸ ${statsT('mcp.disabledCount')}: <strong>${disabled}</strong></span>
|
||||
<span title="${statsT('mcp.connectedCount')}">🔗 ${statsT('mcp.connectedCount')}: <strong>${connected}</strong></span>
|
||||
`;
|
||||
}
|
||||
|
||||
// 显示添加外部MCP模态框
|
||||
function showAddExternalMCPModal() {
|
||||
currentEditingMCPName = null;
|
||||
document.getElementById('external-mcp-modal-title').textContent = '添加外部MCP';
|
||||
document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.addExternalMCP') : '添加外部MCP');
|
||||
document.getElementById('external-mcp-json').value = '';
|
||||
document.getElementById('external-mcp-json-error').style.display = 'none';
|
||||
document.getElementById('external-mcp-json-error').textContent = '';
|
||||
@@ -1254,7 +1333,7 @@ async function editExternalMCP(name) {
|
||||
const server = await response.json();
|
||||
currentEditingMCPName = name;
|
||||
|
||||
document.getElementById('external-mcp-modal-title').textContent = '编辑外部MCP';
|
||||
document.getElementById('external-mcp-modal-title').textContent = (typeof window.t === 'function' ? window.t('mcp.editExternalMCP') : '编辑外部MCP');
|
||||
|
||||
// 将配置转换为对象格式(key为名称)
|
||||
const config = { ...server.config };
|
||||
@@ -1276,7 +1355,7 @@ async function editExternalMCP(name) {
|
||||
document.getElementById('external-mcp-modal').style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('编辑外部MCP失败:', error);
|
||||
alert('编辑失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '编辑失败') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1288,7 +1367,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;
|
||||
@@ -1300,7 +1379,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');
|
||||
}
|
||||
@@ -1308,6 +1387,7 @@ function formatExternalMCPJSON() {
|
||||
|
||||
// 加载示例
|
||||
function loadExternalMCPExample() {
|
||||
const desc = (typeof window.t === 'function' ? window.t('externalMcpModal.exampleDescription') : '示例描述');
|
||||
const example = {
|
||||
"hexstrike-ai": {
|
||||
command: "python3",
|
||||
@@ -1316,7 +1396,7 @@ function loadExternalMCPExample() {
|
||||
"--server",
|
||||
"http://example.com"
|
||||
],
|
||||
description: "示例描述",
|
||||
description: desc,
|
||||
timeout: 300
|
||||
},
|
||||
"cyberstrike-ai-http": {
|
||||
@@ -1341,7 +1421,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();
|
||||
@@ -1352,16 +1432,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;
|
||||
@@ -1370,7 +1451,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;
|
||||
@@ -1379,7 +1460,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;
|
||||
@@ -1387,7 +1468,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;
|
||||
@@ -1399,28 +1480,28 @@ async function saveExternalMCP() {
|
||||
// 验证配置内容
|
||||
const transport = config.transport || (config.command ? 'stdio' : config.url ? 'http' : '');
|
||||
if (!transport) {
|
||||
errorDiv.textContent = `配置错误: "${name}" 需要指定command(stdio模式)或url(http/sse模式)`;
|
||||
errorDiv.textContent = t('mcp.configNeedCommand', { name: name });
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (transport === 'stdio' && !config.command) {
|
||||
errorDiv.textContent = `配置错误: "${name}" stdio模式需要command字段`;
|
||||
errorDiv.textContent = t('mcp.configStdioNeedCommand', { name: name });
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (transport === 'http' && !config.url) {
|
||||
errorDiv.textContent = `配置错误: "${name}" http模式需要url字段`;
|
||||
errorDiv.textContent = t('mcp.configHttpNeedUrl', { name: name });
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (transport === 'sse' && !config.url) {
|
||||
errorDiv.textContent = `配置错误: "${name}" sse模式需要url字段`;
|
||||
errorDiv.textContent = t('mcp.configSseNeedUrl', { name: name });
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
return;
|
||||
@@ -1435,7 +1516,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;
|
||||
@@ -1479,10 +1560,10 @@ async function saveExternalMCP() {
|
||||
}
|
||||
// 轮询几次以拉取后端异步更新的工具数量(无固定延迟,拿到即停)
|
||||
pollExternalMCPToolCount(null, 5);
|
||||
alert('保存成功');
|
||||
alert(typeof window.t === 'function' ? window.t('mcp.saveSuccess') : '保存成功');
|
||||
} catch (error) {
|
||||
console.error('保存外部MCP失败:', error);
|
||||
errorDiv.textContent = '保存失败: ' + error.message;
|
||||
errorDiv.textContent = (typeof window.t === 'function' ? window.t('mcp.operationFailed') : '保存失败') + ': ' + error.message;
|
||||
errorDiv.style.display = 'block';
|
||||
jsonTextarea.classList.add('error');
|
||||
}
|
||||
@@ -1490,7 +1571,7 @@ async function saveExternalMCP() {
|
||||
|
||||
// 删除外部MCP
|
||||
async function deleteExternalMCP(name) {
|
||||
if (!confirm(`确定要删除外部MCP "${name}" 吗?`)) {
|
||||
if (!confirm((typeof window.t === 'function' ? window.t('mcp.deleteExternalConfirm', { name: name }) : `确定要删除外部MCP "${name}" 吗?`))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1509,10 +1590,10 @@ async function deleteExternalMCP(name) {
|
||||
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
||||
window.refreshMentionTools();
|
||||
}
|
||||
alert('删除成功');
|
||||
alert(typeof window.t === 'function' ? window.t('mcp.deleteSuccess') : '删除成功');
|
||||
} catch (error) {
|
||||
console.error('删除外部MCP失败:', error);
|
||||
alert('删除失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '删除失败') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1577,7 +1658,7 @@ async function toggleExternalMCP(name, currentStatus) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换外部MCP状态失败:', error);
|
||||
alert('操作失败: ' + error.message);
|
||||
alert((typeof window.t === 'function' ? window.t('mcp.operationFailed') : '操作失败') + ': ' + error.message);
|
||||
|
||||
// 恢复按钮状态
|
||||
if (button) {
|
||||
@@ -1630,7 +1711,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
|
||||
window.refreshMentionTools();
|
||||
}
|
||||
if (status === 'error') {
|
||||
alert('连接失败,请检查配置和网络连接');
|
||||
alert(typeof window.t === 'function' ? window.t('mcp.connectionFailedCheck') : '连接失败,请检查配置和网络连接');
|
||||
}
|
||||
return;
|
||||
} else if (status === 'connecting') {
|
||||
@@ -1652,7 +1733,7 @@ async function pollExternalMCPStatus(name, maxAttempts = 30) {
|
||||
if (typeof window !== 'undefined' && typeof window.refreshMentionTools === 'function') {
|
||||
window.refreshMentionTools();
|
||||
}
|
||||
alert('连接超时,请检查配置和网络连接');
|
||||
alert(typeof window.t === 'function' ? window.t('mcp.connectionTimeout') : '连接超时,请检查配置和网络连接');
|
||||
}
|
||||
|
||||
// 在打开设置时加载外部MCP列表
|
||||
|
||||
+94
-66
@@ -1,4 +1,7 @@
|
||||
// Skills管理相关功能
|
||||
function _t(key, opts) {
|
||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||
}
|
||||
let skillsList = [];
|
||||
let currentEditingSkillName = null;
|
||||
let isSavingSkill = false; // 防止重复提交
|
||||
@@ -65,7 +68,7 @@ async function loadSkills(page = 1, pageSize = null) {
|
||||
|
||||
const response = await apiFetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取skills列表失败');
|
||||
throw new Error(_t('skills.loadListFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
skillsList = data.skills || [];
|
||||
@@ -76,10 +79,10 @@ async function loadSkills(page = 1, pageSize = null) {
|
||||
updateSkillsManagementStats();
|
||||
} catch (error) {
|
||||
console.error('加载skills列表失败:', error);
|
||||
showNotification('加载skills列表失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.loadListFailed') + ': ' + error.message, 'error');
|
||||
const skillsListEl = document.getElementById('skills-list');
|
||||
if (skillsListEl) {
|
||||
skillsListEl.innerHTML = '<div class="empty-state">加载失败: ' + error.message + '</div>';
|
||||
skillsListEl.innerHTML = '<div class="empty-state">' + _t('skills.loadFailedShort') + ': ' + escapeHtml(error.message) + '</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +97,7 @@ function renderSkillsList() {
|
||||
|
||||
if (filteredSkills.length === 0) {
|
||||
skillsListEl.innerHTML = '<div class="empty-state">' +
|
||||
(skillsSearchKeyword ? '没有找到匹配的skills' : '暂无skills,点击"创建Skill"创建第一个skill') +
|
||||
(skillsSearchKeyword ? _t('skills.noMatch') : _t('skills.noSkills')) +
|
||||
'</div>';
|
||||
// 搜索时隐藏分页
|
||||
const paginationContainer = document.getElementById('skills-pagination');
|
||||
@@ -109,12 +112,12 @@ function renderSkillsList() {
|
||||
<div class="skill-card">
|
||||
<div class="skill-card-header">
|
||||
<h3 class="skill-card-title">${escapeHtml(skill.name || '')}</h3>
|
||||
<div class="skill-card-description">${escapeHtml(skill.description || '无描述')}</div>
|
||||
<div class="skill-card-description">${escapeHtml(skill.description || _t('skills.noDescription'))}</div>
|
||||
</div>
|
||||
<div class="skill-card-actions">
|
||||
<button class="btn-secondary btn-small" onclick="viewSkill('${escapeHtml(skill.name)}')">查看</button>
|
||||
<button class="btn-secondary btn-small" onclick="editSkill('${escapeHtml(skill.name)}')">编辑</button>
|
||||
<button class="btn-secondary btn-small btn-danger" onclick="deleteSkill('${escapeHtml(skill.name)}')">删除</button>
|
||||
<button class="btn-secondary btn-small" onclick="viewSkill('${escapeHtml(skill.name)}')">${_t('common.view')}</button>
|
||||
<button class="btn-secondary btn-small" onclick="editSkill('${escapeHtml(skill.name)}')">${_t('common.edit')}</button>
|
||||
<button class="btn-secondary btn-small btn-danger" onclick="deleteSkill('${escapeHtml(skill.name)}')">${_t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -154,12 +157,19 @@ function renderSkillsPagination() {
|
||||
|
||||
let paginationHTML = '<div class="pagination">';
|
||||
|
||||
const paginationShowText = _t('skillsPage.paginationShow', { start, end, total });
|
||||
const perPageLabelText = _t('skillsPage.perPageLabel');
|
||||
const firstPageText = _t('skillsPage.firstPage');
|
||||
const prevPageText = _t('skillsPage.prevPage');
|
||||
const pageOfText = _t('skillsPage.pageOf', { current: currentPage, total: totalPages || 1 });
|
||||
const nextPageText = _t('skillsPage.nextPage');
|
||||
const lastPageText = _t('skillsPage.lastPage');
|
||||
// 左侧:显示范围信息和每页数量选择器(参考MCP样式)
|
||||
paginationHTML += `
|
||||
<div class="pagination-info">
|
||||
<span>显示 ${start}-${end} / 共 ${total} 条</span>
|
||||
<span>${escapeHtml(paginationShowText)}</span>
|
||||
<label class="pagination-page-size">
|
||||
每页显示
|
||||
${escapeHtml(perPageLabelText)}
|
||||
<select id="skills-page-size-pagination" onchange="changeSkillsPageSize()">
|
||||
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
|
||||
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||||
@@ -173,11 +183,11 @@ function renderSkillsPagination() {
|
||||
// 右侧:分页按钮(参考MCP样式:首页、上一页、第X/Y页、下一页、末页)
|
||||
paginationHTML += `
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-secondary" onclick="loadSkills(1, ${pageSize})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>首页</button>
|
||||
<button class="btn-secondary" onclick="loadSkills(${currentPage - 1}, ${pageSize})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>上一页</button>
|
||||
<span class="pagination-page">第 ${currentPage} / ${totalPages || 1} 页</span>
|
||||
<button class="btn-secondary" onclick="loadSkills(${currentPage + 1}, ${pageSize})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>下一页</button>
|
||||
<button class="btn-secondary" onclick="loadSkills(${totalPages || 1}, ${pageSize})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>末页</button>
|
||||
<button class="btn-secondary" onclick="loadSkills(1, ${pageSize})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(firstPageText)}</button>
|
||||
<button class="btn-secondary" onclick="loadSkills(${currentPage - 1}, ${pageSize})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>${escapeHtml(prevPageText)}</button>
|
||||
<span class="pagination-page">${escapeHtml(pageOfText)}</span>
|
||||
<button class="btn-secondary" onclick="loadSkills(${currentPage + 1}, ${pageSize})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(nextPageText)}</button>
|
||||
<button class="btn-secondary" onclick="loadSkills(${totalPages || 1}, ${pageSize})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>${escapeHtml(lastPageText)}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -291,7 +301,7 @@ async function searchSkills() {
|
||||
try {
|
||||
const response = await apiFetch(`/api/skills?search=${encodeURIComponent(skillsSearchKeyword)}&limit=10000&offset=0`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取skills列表失败');
|
||||
throw new Error(_t('skills.loadListFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
skillsList = data.skills || [];
|
||||
@@ -306,7 +316,7 @@ async function searchSkills() {
|
||||
updateSkillsManagementStats();
|
||||
} catch (error) {
|
||||
console.error('搜索skills失败:', error);
|
||||
showNotification('搜索失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.searchFailed') + ': ' + error.message, 'error');
|
||||
}
|
||||
} else {
|
||||
// 没有搜索关键词时,恢复分页加载
|
||||
@@ -332,7 +342,7 @@ function clearSkillsSearch() {
|
||||
// 刷新skills
|
||||
async function refreshSkills() {
|
||||
await loadSkills(skillsPagination.currentPage, skillsPagination.pageSize);
|
||||
showNotification('已刷新', 'success');
|
||||
showNotification(_t('skills.refreshed'), 'success');
|
||||
}
|
||||
|
||||
// 显示添加skill模态框
|
||||
@@ -340,7 +350,7 @@ function showAddSkillModal() {
|
||||
const modal = document.getElementById('skill-modal');
|
||||
if (!modal) return;
|
||||
|
||||
document.getElementById('skill-modal-title').textContent = '添加Skill';
|
||||
document.getElementById('skill-modal-title').textContent = _t('skills.addSkill');
|
||||
document.getElementById('skill-name').value = '';
|
||||
document.getElementById('skill-name').disabled = false;
|
||||
document.getElementById('skill-description').value = '';
|
||||
@@ -354,7 +364,7 @@ async function editSkill(skillName) {
|
||||
try {
|
||||
const response = await apiFetch(`/api/skills/${encodeURIComponent(skillName)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取skill详情失败');
|
||||
throw new Error(_t('skills.loadDetailFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
const skill = data.skill;
|
||||
@@ -362,7 +372,7 @@ async function editSkill(skillName) {
|
||||
const modal = document.getElementById('skill-modal');
|
||||
if (!modal) return;
|
||||
|
||||
document.getElementById('skill-modal-title').textContent = '编辑Skill';
|
||||
document.getElementById('skill-modal-title').textContent = _t('skills.editSkill');
|
||||
document.getElementById('skill-name').value = skill.name;
|
||||
document.getElementById('skill-name').disabled = true; // 编辑时不允许修改名称
|
||||
document.getElementById('skill-description').value = skill.description || '';
|
||||
@@ -372,7 +382,7 @@ async function editSkill(skillName) {
|
||||
modal.style.display = 'flex';
|
||||
} catch (error) {
|
||||
console.error('加载skill详情失败:', error);
|
||||
showNotification('加载skill详情失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.loadDetailFailed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +391,7 @@ async function viewSkill(skillName) {
|
||||
try {
|
||||
const response = await apiFetch(`/api/skills/${encodeURIComponent(skillName)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取skill详情失败');
|
||||
throw new Error(_t('skills.loadDetailFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
const skill = data.skill;
|
||||
@@ -390,22 +400,29 @@ async function viewSkill(skillName) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal';
|
||||
modal.id = 'skill-view-modal';
|
||||
const viewTitle = _t('skills.viewSkillTitle', { name: skill.name });
|
||||
const descLabel = _t('skills.descriptionLabel');
|
||||
const pathLabel = _t('skills.pathLabel');
|
||||
const modTimeLabel = _t('skills.modTimeLabel');
|
||||
const contentLabel = _t('skills.contentLabel');
|
||||
const closeBtn = _t('common.close');
|
||||
const editBtn = _t('common.edit');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 900px; max-height: 90vh;">
|
||||
<div class="modal-header">
|
||||
<h2>查看Skill: ${escapeHtml(skill.name)}</h2>
|
||||
<h2>${escapeHtml(viewTitle)}</h2>
|
||||
<span class="modal-close" onclick="closeSkillViewModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body" style="overflow-y: auto; max-height: calc(90vh - 120px);">
|
||||
${skill.description ? `<div style="margin-bottom: 16px;"><strong>描述:</strong> ${escapeHtml(skill.description)}</div>` : ''}
|
||||
<div style="margin-bottom: 8px;"><strong>路径:</strong> ${escapeHtml(skill.path || '')}</div>
|
||||
<div style="margin-bottom: 16px;"><strong>修改时间:</strong> ${escapeHtml(skill.mod_time || '')}</div>
|
||||
<div style="margin-bottom: 8px;"><strong>内容:</strong></div>
|
||||
${skill.description ? `<div style="margin-bottom: 16px;"><strong>${escapeHtml(descLabel)}</strong> ${escapeHtml(skill.description)}</div>` : ''}
|
||||
<div style="margin-bottom: 8px;"><strong>${escapeHtml(pathLabel)}</strong> ${escapeHtml(skill.path || '')}</div>
|
||||
<div style="margin-bottom: 16px;"><strong>${escapeHtml(modTimeLabel)}</strong> ${escapeHtml(skill.mod_time || '')}</div>
|
||||
<div style="margin-bottom: 8px;"><strong>${escapeHtml(contentLabel)}</strong></div>
|
||||
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;">${escapeHtml(skill.content || '')}</pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeSkillViewModal()">关闭</button>
|
||||
<button class="btn-primary" onclick="editSkill('${escapeHtml(skill.name)}'); closeSkillViewModal();">编辑</button>
|
||||
<button class="btn-secondary" onclick="closeSkillViewModal()">${escapeHtml(closeBtn)}</button>
|
||||
<button class="btn-primary" onclick="editSkill('${escapeHtml(skill.name)}'); closeSkillViewModal();">${escapeHtml(editBtn)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -413,7 +430,7 @@ async function viewSkill(skillName) {
|
||||
modal.style.display = 'flex';
|
||||
} catch (error) {
|
||||
console.error('查看skill失败:', error);
|
||||
showNotification('查看skill失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.viewFailed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,18 +460,18 @@ async function saveSkill() {
|
||||
const content = document.getElementById('skill-content').value.trim();
|
||||
|
||||
if (!name) {
|
||||
showNotification('skill名称不能为空', 'error');
|
||||
showNotification(_t('skills.nameRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
showNotification('skill内容不能为空', 'error');
|
||||
showNotification(_t('skills.contentRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证skill名称
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
showNotification('skill名称只能包含字母、数字、连字符和下划线', 'error');
|
||||
showNotification(_t('skills.nameInvalid'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -462,7 +479,7 @@ async function saveSkill() {
|
||||
const saveBtn = document.querySelector('#skill-modal .btn-primary');
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '保存中...';
|
||||
saveBtn.textContent = _t('skills.saving');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -484,20 +501,20 @@ async function saveSkill() {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '保存skill失败');
|
||||
throw new Error(error.error || _t('skills.saveFailed'));
|
||||
}
|
||||
|
||||
showNotification(isEdit ? 'skill已更新' : 'skill已创建', 'success');
|
||||
showNotification(isEdit ? _t('skills.saveSuccess') : _t('skills.createdSuccess'), 'success');
|
||||
closeSkillModal();
|
||||
await loadSkills(skillsPagination.currentPage, skillsPagination.pageSize);
|
||||
} catch (error) {
|
||||
console.error('保存skill失败:', error);
|
||||
showNotification('保存skill失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.saveFailed') + ': ' + error.message, 'error');
|
||||
} finally {
|
||||
isSavingSkill = false;
|
||||
if (saveBtn) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = '保存';
|
||||
saveBtn.textContent = _t('common.save');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,10 +535,10 @@ async function deleteSkill(skillName) {
|
||||
}
|
||||
|
||||
// 构建确认消息
|
||||
let confirmMessage = `确定要删除skill "${skillName}" 吗?此操作不可恢复。`;
|
||||
let confirmMessage = _t('skills.deleteConfirm', { name: skillName });
|
||||
if (boundRoles.length > 0) {
|
||||
const rolesList = boundRoles.join('、');
|
||||
confirmMessage = `确定要删除skill "${skillName}" 吗?\n\n⚠️ 该skill当前已被以下 ${boundRoles.length} 个角色绑定:\n${rolesList}\n\n删除后,系统将自动从这些角色中移除该skill的绑定。\n\n此操作不可恢复,是否继续?`;
|
||||
confirmMessage = _t('skills.deleteConfirmWithRoles', { name: skillName, count: boundRoles.length, roles: rolesList });
|
||||
}
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
@@ -535,14 +552,14 @@ async function deleteSkill(skillName) {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '删除skill失败');
|
||||
throw new Error(error.error || _t('skills.deleteFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
let successMessage = 'skill已删除';
|
||||
let successMessage = _t('skills.deleteSuccess');
|
||||
if (data.affected_roles && data.affected_roles.length > 0) {
|
||||
const rolesList = data.affected_roles.join('、');
|
||||
successMessage = `skill已删除,已自动从 ${data.affected_roles.length} 个角色中移除绑定:${rolesList}`;
|
||||
successMessage = _t('skills.deleteSuccessWithRoles', { count: data.affected_roles.length, roles: rolesList });
|
||||
}
|
||||
showNotification(successMessage, 'success');
|
||||
|
||||
@@ -554,7 +571,7 @@ async function deleteSkill(skillName) {
|
||||
await loadSkills(pageToLoad, skillsPagination.pageSize);
|
||||
} catch (error) {
|
||||
console.error('删除skill失败:', error);
|
||||
showNotification('删除skill失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.deleteFailed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,7 +582,7 @@ async function loadSkillsMonitor() {
|
||||
try {
|
||||
const response = await apiFetch('/api/skills/stats');
|
||||
if (!response.ok) {
|
||||
throw new Error('获取skills统计信息失败');
|
||||
throw new Error(_t('skills.loadStatsFailed'));
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
@@ -581,14 +598,14 @@ async function loadSkillsMonitor() {
|
||||
renderSkillsMonitor();
|
||||
} catch (error) {
|
||||
console.error('加载skills监控数据失败:', error);
|
||||
showNotification('加载skills监控数据失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.loadStatsFailed') + ': ' + error.message, 'error');
|
||||
const statsEl = document.getElementById('skills-stats');
|
||||
if (statsEl) {
|
||||
statsEl.innerHTML = '<div class="monitor-error">无法加载统计信息:' + escapeHtml(error.message) + '</div>';
|
||||
statsEl.innerHTML = '<div class="monitor-error">' + _t('skills.loadStatsErrorShort') + ': ' + escapeHtml(error.message) + '</div>';
|
||||
}
|
||||
const monitorListEl = document.getElementById('skills-monitor-list');
|
||||
if (monitorListEl) {
|
||||
monitorListEl.innerHTML = '<div class="monitor-error">无法加载调用统计:' + escapeHtml(error.message) + '</div>';
|
||||
monitorListEl.innerHTML = '<div class="monitor-error">' + _t('skills.loadCallStatsError') + ': ' + escapeHtml(error.message) + '</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -604,23 +621,23 @@ function renderSkillsMonitor() {
|
||||
|
||||
statsEl.innerHTML = `
|
||||
<div class="monitor-stat-card">
|
||||
<div class="monitor-stat-label">总Skills数</div>
|
||||
<div class="monitor-stat-label">${_t('skills.totalSkillsCount')}</div>
|
||||
<div class="monitor-stat-value">${skillsStats.total}</div>
|
||||
</div>
|
||||
<div class="monitor-stat-card">
|
||||
<div class="monitor-stat-label">总调用次数</div>
|
||||
<div class="monitor-stat-label">${_t('skills.totalCallsCount')}</div>
|
||||
<div class="monitor-stat-value">${skillsStats.totalCalls}</div>
|
||||
</div>
|
||||
<div class="monitor-stat-card">
|
||||
<div class="monitor-stat-label">成功调用</div>
|
||||
<div class="monitor-stat-label">${_t('skills.successfulCalls')}</div>
|
||||
<div class="monitor-stat-value" style="color: #28a745;">${skillsStats.totalSuccess}</div>
|
||||
</div>
|
||||
<div class="monitor-stat-card">
|
||||
<div class="monitor-stat-label">失败调用</div>
|
||||
<div class="monitor-stat-label">${_t('skills.failedCalls')}</div>
|
||||
<div class="monitor-stat-value" style="color: #dc3545;">${skillsStats.totalFailed}</div>
|
||||
</div>
|
||||
<div class="monitor-stat-card">
|
||||
<div class="monitor-stat-label">成功率</div>
|
||||
<div class="monitor-stat-label">${_t('skills.successRate')}</div>
|
||||
<div class="monitor-stat-value">${successRate}%</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -634,7 +651,7 @@ function renderSkillsMonitor() {
|
||||
|
||||
// 如果没有统计数据,显示空状态
|
||||
if (stats.length === 0) {
|
||||
monitorListEl.innerHTML = '<div class="monitor-empty">暂无Skills调用记录</div>';
|
||||
monitorListEl.innerHTML = '<div class="monitor-empty">' + _t('skills.noCallRecords') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -652,12 +669,12 @@ function renderSkillsMonitor() {
|
||||
<table class="monitor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left !important;">Skill名称</th>
|
||||
<th style="text-align: center;">总调用</th>
|
||||
<th style="text-align: center;">成功</th>
|
||||
<th style="text-align: center;">失败</th>
|
||||
<th style="text-align: center;">成功率</th>
|
||||
<th style="text-align: left;">最后调用时间</th>
|
||||
<th style="text-align: left !important;">${_t('skills.skillName')}</th>
|
||||
<th style="text-align: center;">${_t('skills.totalCalls')}</th>
|
||||
<th style="text-align: center;">${_t('skills.success')}</th>
|
||||
<th style="text-align: center;">${_t('skills.failure')}</th>
|
||||
<th style="text-align: center;">${_t('skills.successRate')}</th>
|
||||
<th style="text-align: left;">${_t('skills.lastCallTime')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -687,12 +704,12 @@ function renderSkillsMonitor() {
|
||||
// 刷新skills监控
|
||||
async function refreshSkillsMonitor() {
|
||||
await loadSkillsMonitor();
|
||||
showNotification('已刷新', 'success');
|
||||
showNotification(_t('skills.refreshed'), 'success');
|
||||
}
|
||||
|
||||
// 清空skills统计数据
|
||||
async function clearSkillsStats() {
|
||||
if (!confirm('确定要清空所有Skills统计数据吗?此操作不可恢复。')) {
|
||||
if (!confirm(_t('skills.clearStatsConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -703,15 +720,15 @@ async function clearSkillsStats() {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '清空统计数据失败');
|
||||
throw new Error(error.error || _t('skills.clearStatsFailed'));
|
||||
}
|
||||
|
||||
showNotification('已清空所有Skills统计数据', 'success');
|
||||
showNotification(_t('skills.statsCleared'), 'success');
|
||||
// 重新加载统计数据
|
||||
await loadSkillsMonitor();
|
||||
} catch (error) {
|
||||
console.error('清空统计数据失败:', error);
|
||||
showNotification('清空统计数据失败: ' + error.message, 'error');
|
||||
showNotification(_t('skills.clearStatsFailed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,3 +739,14 @@ function escapeHtml(text) {
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 语言切换时重新渲染当前页(技能列表与分页使用 _t,需随语言更新)
|
||||
document.addEventListener('languagechange', function () {
|
||||
const page = document.getElementById('page-skills-management');
|
||||
if (page && page.classList.contains('active')) {
|
||||
renderSkillsList();
|
||||
if (!skillsSearchKeyword) {
|
||||
renderSkillsPagination();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+134
-131
@@ -1,4 +1,7 @@
|
||||
// 任务管理页面功能
|
||||
function _t(key, opts) {
|
||||
return typeof window.t === 'function' ? window.t(key, opts) : key;
|
||||
}
|
||||
|
||||
// HTML转义函数(如果未定义)
|
||||
if (typeof escapeHtml === 'undefined') {
|
||||
@@ -106,7 +109,7 @@ async function loadTasks() {
|
||||
const listContainer = document.getElementById('tasks-list');
|
||||
if (!listContainer) return;
|
||||
|
||||
listContainer.innerHTML = '<div class="loading-spinner">加载中...</div>';
|
||||
listContainer.innerHTML = '<div class="loading-spinner">' + _t('tasks.loadingTasks') + '</div>';
|
||||
|
||||
try {
|
||||
// 并行加载运行中的任务和已完成的任务历史
|
||||
@@ -117,7 +120,7 @@ async function loadTasks() {
|
||||
|
||||
// 处理运行中的任务
|
||||
if (activeResponse.status === 'rejected' || !activeResponse.value || !activeResponse.value.ok) {
|
||||
throw new Error('获取任务列表失败');
|
||||
throw new Error(_t('tasks.loadTaskListFailed'));
|
||||
}
|
||||
|
||||
const activeResult = await activeResponse.value.json();
|
||||
@@ -177,8 +180,8 @@ async function loadTasks() {
|
||||
console.error('加载任务失败:', error);
|
||||
listContainer.innerHTML = `
|
||||
<div class="tasks-empty">
|
||||
<p>加载失败: ${escapeHtml(error.message)}</p>
|
||||
<button class="btn-secondary" onclick="loadTasks()">重试</button>
|
||||
<p>${_t('tasks.loadFailedRetry')}: ${escapeHtml(error.message)}</p>
|
||||
<button class="btn-secondary" onclick="loadTasks()">${_t('tasks.retry')}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -296,21 +299,21 @@ function toggleShowHistory(show) {
|
||||
|
||||
// 计算执行时长
|
||||
function calculateDuration(startedAt) {
|
||||
if (!startedAt) return '未知';
|
||||
if (!startedAt) return _t('tasks.unknown');
|
||||
const start = new Date(startedAt);
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - start) / 1000); // 秒
|
||||
const diff = Math.floor((now - start) / 1000);
|
||||
|
||||
if (diff < 60) {
|
||||
return `${diff}秒`;
|
||||
return diff + _t('tasks.durationSeconds');
|
||||
} else if (diff < 3600) {
|
||||
const minutes = Math.floor(diff / 60);
|
||||
const seconds = diff % 60;
|
||||
return `${minutes}分${seconds}秒`;
|
||||
return minutes + _t('tasks.durationMinutes') + ' ' + seconds + _t('tasks.durationSeconds');
|
||||
} else {
|
||||
const hours = Math.floor(diff / 3600);
|
||||
const minutes = Math.floor((diff % 3600) / 60);
|
||||
return `${hours}小时${minutes}分`;
|
||||
return hours + _t('tasks.durationHours') + ' ' + minutes + _t('tasks.durationMinutes');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,9 +352,9 @@ function renderTasks(tasks) {
|
||||
if (tasks.length === 0) {
|
||||
listContainer.innerHTML = `
|
||||
<div class="tasks-empty">
|
||||
<p>当前没有符合条件的任务</p>
|
||||
<p>${_t('tasks.noMatchingTasks')}</p>
|
||||
${tasksState.allTasks.length === 0 && tasksState.completedTasksHistory.length > 0 ?
|
||||
'<p style="margin-top: 8px; color: var(--text-muted); font-size: 0.875rem;">提示:有已完成的任务历史,请勾选"显示历史记录"查看</p>' : ''}
|
||||
'<p style="margin-top: 8px; color: var(--text-muted); font-size: 0.875rem;">' + _t('tasks.historyHint') + '</p>' : ''}
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@@ -359,12 +362,12 @@ function renderTasks(tasks) {
|
||||
|
||||
// 状态映射
|
||||
const statusMap = {
|
||||
'running': { text: '执行中', class: 'task-status-running' },
|
||||
'cancelling': { text: '取消中', class: 'task-status-cancelling' },
|
||||
'failed': { text: '执行失败', class: 'task-status-failed' },
|
||||
'timeout': { text: '执行超时', class: 'task-status-timeout' },
|
||||
'cancelled': { text: '已取消', class: 'task-status-cancelled' },
|
||||
'completed': { text: '已完成', class: 'task-status-completed' }
|
||||
'running': { text: _t('tasks.statusRunning'), class: 'task-status-running' },
|
||||
'cancelling': { text: _t('tasks.statusCancelling'), class: 'task-status-cancelling' },
|
||||
'failed': { text: _t('tasks.statusFailed'), class: 'task-status-failed' },
|
||||
'timeout': { text: _t('tasks.statusTimeout'), class: 'task-status-timeout' },
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'task-status-cancelled' },
|
||||
'completed': { text: _t('tasks.statusCompleted'), class: 'task-status-completed' }
|
||||
};
|
||||
|
||||
// 分离当前任务和历史任务
|
||||
@@ -382,8 +385,8 @@ function renderTasks(tasks) {
|
||||
if (historyTasks.length > 0) {
|
||||
html += `<div class="tasks-history-section">
|
||||
<div class="tasks-history-header">
|
||||
<span class="tasks-history-title">📜 最近完成的任务(最近24小时)</span>
|
||||
<button class="btn-secondary btn-small" onclick="clearTasksHistory()">清空历史</button>
|
||||
<span class="tasks-history-title">📜 ` + _t('tasks.recentCompletedTasks') + `</span>
|
||||
<button class="btn-secondary btn-small" onclick="clearTasksHistory()">` + _t('tasks.clearHistory') + `</button>
|
||||
</div>
|
||||
${historyTasks.map(task => renderTaskItem(task, statusMap, true)).join('')}
|
||||
</div>`;
|
||||
@@ -406,7 +409,7 @@ function renderTaskItem(task, statusMap, isHistory = false) {
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
: '未知时间';
|
||||
: _t('tasks.unknownTime');
|
||||
|
||||
const completedText = completedTime && !isNaN(completedTime.getTime())
|
||||
? completedTime.toLocaleString('zh-CN', {
|
||||
@@ -438,22 +441,22 @@ function renderTaskItem(task, statusMap, isHistory = false) {
|
||||
</label>
|
||||
` : '<div class="task-checkbox-placeholder"></div>'}
|
||||
<span class="task-status ${status.class}">${status.text}</span>
|
||||
${isHistory ? '<span class="task-history-badge" title="历史记录">📜</span>' : ''}
|
||||
<span class="task-message" title="${escapeHtml(task.message || '未命名任务')}">${escapeHtml(task.message || '未命名任务')}</span>
|
||||
${isHistory ? '<span class="task-history-badge" title="' + _t('tasks.historyBadge') + '">📜</span>' : ''}
|
||||
<span class="task-message" title="${escapeHtml(task.message || _t('tasks.unnamedTask'))}">${escapeHtml(task.message || _t('tasks.unnamedTask'))}</span>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
${duration ? `<span class="task-duration" title="执行时长">⏱ ${duration}</span>` : ''}
|
||||
<span class="task-time" title="${isHistory && completedText ? '完成时间' : '开始时间'}">
|
||||
${duration ? `<span class="task-duration" title="${_t('tasks.duration')}">⏱ ${duration}</span>` : ''}
|
||||
<span class="task-time" title="${isHistory && completedText ? _t('tasks.completedAt') : _t('tasks.startedAt')}">
|
||||
${isHistory && completedText ? completedText : timeText}
|
||||
</span>
|
||||
${canCancel ? `<button class="btn-secondary btn-small" onclick="cancelTask('${task.conversationId}', this)">取消任务</button>` : ''}
|
||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewConversation('${task.conversationId}')">查看对话</button>` : ''}
|
||||
${canCancel ? `<button class="btn-secondary btn-small" onclick="cancelTask('${task.conversationId}', this)">` + _t('tasks.cancelTask') + `</button>` : ''}
|
||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewConversation('${task.conversationId}')">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${task.conversationId ? `
|
||||
<div class="task-details">
|
||||
<span class="task-id-label">对话ID:</span>
|
||||
<span class="task-id-value" title="点击复制" onclick="copyTaskId('${task.conversationId}')">${escapeHtml(task.conversationId)}</span>
|
||||
<span class="task-id-label">` + _t('tasks.conversationIdLabel') + `:</span>
|
||||
<span class="task-id-value" title="` + _t('tasks.clickToCopy') + `" onclick="copyTaskId('${task.conversationId}')">${escapeHtml(task.conversationId)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -462,7 +465,7 @@ function renderTaskItem(task, statusMap, isHistory = false) {
|
||||
|
||||
// 清空任务历史
|
||||
function clearTasksHistory() {
|
||||
if (!confirm('确定要清空所有任务历史记录吗?')) {
|
||||
if (!confirm(_t('tasks.clearHistoryConfirm'))) {
|
||||
return;
|
||||
}
|
||||
tasksState.completedTasksHistory = [];
|
||||
@@ -490,7 +493,7 @@ function updateBatchActions() {
|
||||
const count = tasksState.selectedTasks.size;
|
||||
if (count > 0) {
|
||||
batchActions.style.display = 'flex';
|
||||
selectedCount.textContent = `已选择 ${count} 项`;
|
||||
selectedCount.textContent = typeof window.t === 'function' ? window.t('mcp.selectedCount', { count: count }) : `已选择 ${count} 项`;
|
||||
} else {
|
||||
batchActions.style.display = 'none';
|
||||
}
|
||||
@@ -509,7 +512,7 @@ async function batchCancelTasks() {
|
||||
const selected = Array.from(tasksState.selectedTasks);
|
||||
if (selected.length === 0) return;
|
||||
|
||||
if (!confirm(`确定要取消 ${selected.length} 个任务吗?`)) {
|
||||
if (!confirm(_t('tasks.confirmCancelTasks', { n: selected.length }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -545,9 +548,9 @@ async function batchCancelTasks() {
|
||||
|
||||
// 显示结果
|
||||
if (failCount > 0) {
|
||||
alert(`批量取消完成:成功 ${successCount} 个,失败 ${failCount} 个`);
|
||||
alert(_t('tasks.batchCancelResultPartial', { success: successCount, fail: failCount }));
|
||||
} else {
|
||||
alert(`成功取消 ${successCount} 个任务`);
|
||||
alert(_t('tasks.batchCancelResultSuccess', { n: successCount }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,7 +559,7 @@ function copyTaskId(conversationId) {
|
||||
navigator.clipboard.writeText(conversationId).then(() => {
|
||||
// 显示复制成功提示
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.textContent = '已复制!';
|
||||
tooltip.textContent = _t('tasks.copiedToast');
|
||||
tooltip.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.8); color: white; padding: 8px 16px; border-radius: 4px; z-index: 10000;';
|
||||
document.body.appendChild(tooltip);
|
||||
setTimeout(() => tooltip.remove(), 1000);
|
||||
@@ -571,7 +574,7 @@ async function cancelTask(conversationId, button) {
|
||||
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = '取消中...';
|
||||
button.textContent = _t('tasks.cancelling');
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/agent-loop/cancel', {
|
||||
@@ -584,7 +587,7 @@ async function cancelTask(conversationId, button) {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '取消任务失败');
|
||||
throw new Error(result.error || _t('tasks.cancelTaskFailed'));
|
||||
}
|
||||
|
||||
// 从选择中移除
|
||||
@@ -595,7 +598,7 @@ async function cancelTask(conversationId, button) {
|
||||
await loadTasks();
|
||||
} catch (error) {
|
||||
console.error('取消任务失败:', error);
|
||||
alert('取消任务失败: ' + error.message);
|
||||
alert(_t('tasks.cancelTaskFailed') + ': ' + error.message);
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
@@ -738,7 +741,7 @@ async function showBatchImportModal() {
|
||||
try {
|
||||
const loadedRoles = await loadRoles();
|
||||
// 清空现有选项(除了默认选项)
|
||||
roleSelect.innerHTML = '<option value="">默认</option>';
|
||||
roleSelect.innerHTML = '<option value="">' + _t('batchImportModal.defaultRole') + '</option>';
|
||||
|
||||
// 添加已启用的角色
|
||||
const sortedRoles = loadedRoles.sort((a, b) => {
|
||||
@@ -782,7 +785,7 @@ function updateBatchImportStats(text) {
|
||||
const count = lines.length;
|
||||
|
||||
if (count > 0) {
|
||||
statsEl.innerHTML = `<div class="batch-import-stat">共 ${count} 个任务</div>`;
|
||||
statsEl.innerHTML = '<div class="batch-import-stat">' + _t('tasks.taskCount', { count: count }) + '</div>';
|
||||
statsEl.style.display = 'block';
|
||||
} else {
|
||||
statsEl.style.display = 'none';
|
||||
@@ -808,14 +811,14 @@ async function createBatchQueue() {
|
||||
|
||||
const text = input.value.trim();
|
||||
if (!text) {
|
||||
alert('请输入至少一个任务');
|
||||
alert(_t('tasks.enterTaskPrompt'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 按行分割任务
|
||||
const tasks = text.split('\n').map(line => line.trim()).filter(line => line !== '');
|
||||
if (tasks.length === 0) {
|
||||
alert('没有有效的任务');
|
||||
alert(_t('tasks.noValidTask'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -836,7 +839,7 @@ async function createBatchQueue() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '创建批量任务队列失败');
|
||||
throw new Error(result.error || _t('tasks.createBatchQueueFailed'));
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -849,7 +852,7 @@ async function createBatchQueue() {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('创建批量任务队列失败:', error);
|
||||
alert('创建批量任务队列失败: ' + error.message);
|
||||
alert(_t('tasks.createBatchQueueFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,7 +919,7 @@ async function loadBatchQueues(page) {
|
||||
try {
|
||||
const response = await apiFetch(`/api/batch-tasks?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取批量任务队列失败');
|
||||
throw new Error(_t('tasks.loadFailedRetry'));
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -929,7 +932,7 @@ async function loadBatchQueues(page) {
|
||||
section.style.display = 'block';
|
||||
const list = document.getElementById('batch-queues-list');
|
||||
if (list) {
|
||||
list.innerHTML = '<div class="tasks-empty"><p>加载失败: ' + escapeHtml(error.message) + '</p><button class="btn-secondary" onclick="refreshBatchQueues()">重试</button></div>';
|
||||
list.innerHTML = '<div class="tasks-empty"><p>' + _t('tasks.loadFailedRetry') + ': ' + escapeHtml(error.message) + '</p><button class="btn-secondary" onclick="refreshBatchQueues()">' + _t('tasks.retry') + '</button></div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -964,7 +967,7 @@ function renderBatchQueues() {
|
||||
const queues = batchQueuesState.queues;
|
||||
|
||||
if (queues.length === 0) {
|
||||
list.innerHTML = '<div class="tasks-empty"><p>当前没有批量任务队列</p></div>';
|
||||
list.innerHTML = '<div class="tasks-empty"><p>' + _t('tasks.noBatchQueues') + '</p></div>';
|
||||
if (pagination) pagination.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -976,11 +979,11 @@ function renderBatchQueues() {
|
||||
|
||||
list.innerHTML = queues.map(queue => {
|
||||
const statusMap = {
|
||||
'pending': { text: '待执行', class: 'batch-queue-status-pending' },
|
||||
'running': { text: '执行中', class: 'batch-queue-status-running' },
|
||||
'paused': { text: '已暂停', class: 'batch-queue-status-paused' },
|
||||
'completed': { text: '已完成', class: 'batch-queue-status-completed' },
|
||||
'cancelled': { text: '已取消', class: 'batch-queue-status-cancelled' }
|
||||
'pending': { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
|
||||
'running': { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
|
||||
'paused': { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
|
||||
'completed': { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
|
||||
};
|
||||
|
||||
const status = statusMap[queue.status] || { text: queue.status, class: 'batch-queue-status-unknown' };
|
||||
@@ -1012,8 +1015,8 @@ function renderBatchQueues() {
|
||||
// 显示角色信息(使用正确的角色图标)
|
||||
const loadedRoles = batchQueuesState.loadedRoles || [];
|
||||
const roleIcon = getRoleIconForDisplay(queue.role, loadedRoles);
|
||||
const roleName = queue.role && queue.role !== '' ? queue.role : '默认';
|
||||
const roleDisplay = `<span class="batch-queue-role" style="margin-right: 8px;" title="角色: ${escapeHtml(roleName)}">${roleIcon} ${escapeHtml(roleName)}</span>`;
|
||||
const roleName = queue.role && queue.role !== '' ? queue.role : _t('batchQueueDetailModal.defaultRole');
|
||||
const roleDisplay = `<span class="batch-queue-role" style="margin-right: 8px;" title="${_t('batchQueueDetailModal.role')}: ${escapeHtml(roleName)}">${roleIcon} ${escapeHtml(roleName)}</span>`;
|
||||
|
||||
return `
|
||||
<div class="batch-queue-item" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
|
||||
@@ -1022,8 +1025,8 @@ function renderBatchQueues() {
|
||||
${titleDisplay}
|
||||
${roleDisplay}
|
||||
<span class="batch-queue-status ${status.class}">${status.text}</span>
|
||||
<span class="batch-queue-id">队列ID: ${escapeHtml(queue.id)}</span>
|
||||
<span class="batch-queue-time">创建时间: ${new Date(queue.createdAt).toLocaleString('zh-CN')}</span>
|
||||
<span class="batch-queue-id">${_t('tasks.queueIdLabel')}: ${escapeHtml(queue.id)}</span>
|
||||
<span class="batch-queue-time">${_t('tasks.createdTimeLabel')}: ${new Date(queue.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="batch-queue-progress">
|
||||
<div class="batch-queue-progress-bar">
|
||||
@@ -1032,16 +1035,16 @@ function renderBatchQueues() {
|
||||
<span class="batch-queue-progress-text">${progress}% (${stats.completed + stats.failed + stats.cancelled}/${stats.total})</span>
|
||||
</div>
|
||||
<div class="batch-queue-actions" style="display: flex; align-items: center; gap: 8px; margin-left: 12px;" onclick="event.stopPropagation();">
|
||||
${canDelete ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteBatchQueueFromList('${queue.id}')" title="删除队列">删除</button>` : ''}
|
||||
${canDelete ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteBatchQueueFromList('${queue.id}')" title="${_t('tasks.deleteQueue')}">${_t('common.delete')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-queue-stats">
|
||||
<span>总计: ${stats.total}</span>
|
||||
<span>待执行: ${stats.pending}</span>
|
||||
<span>执行中: ${stats.running}</span>
|
||||
<span style="color: var(--success-color);">已完成: ${stats.completed}</span>
|
||||
<span style="color: var(--error-color);">失败: ${stats.failed}</span>
|
||||
${stats.cancelled > 0 ? `<span style="color: var(--text-secondary);">已取消: ${stats.cancelled}</span>` : ''}
|
||||
<span>${_t('tasks.totalLabel')}: ${stats.total}</span>
|
||||
<span>${_t('tasks.pendingLabel')}: ${stats.pending}</span>
|
||||
<span>${_t('tasks.runningLabel')}: ${stats.running}</span>
|
||||
<span style="color: var(--success-color);">${_t('tasks.completedLabel')}: ${stats.completed}</span>
|
||||
<span style="color: var(--error-color);">${_t('tasks.failedLabel')}: ${stats.failed}</span>
|
||||
${stats.cancelled > 0 ? `<span style="color: var(--text-secondary);">${_t('tasks.cancelledLabel')}: ${stats.cancelled}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1073,9 +1076,9 @@ function renderBatchQueuesPagination() {
|
||||
// 左侧:显示范围信息和每页数量选择器(参考Skills样式)
|
||||
paginationHTML += `
|
||||
<div class="pagination-info">
|
||||
<span>显示 ${start}-${end} / 共 ${total} 条</span>
|
||||
<span>` + _t('tasks.paginationShow', { start: start, end: end, total: total }) + `</span>
|
||||
<label class="pagination-page-size">
|
||||
每页显示
|
||||
` + _t('tasks.paginationPerPage') + `
|
||||
<select id="batch-queues-page-size-pagination" onchange="changeBatchQueuesPageSize()">
|
||||
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
|
||||
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||||
@@ -1089,11 +1092,11 @@ function renderBatchQueuesPagination() {
|
||||
// 右侧:分页按钮(参考Skills样式:首页、上一页、第X/Y页、下一页、末页)
|
||||
paginationHTML += `
|
||||
<div class="pagination-controls">
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(1)" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>首页</button>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage - 1})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>上一页</button>
|
||||
<span class="pagination-page">第 ${currentPage} / ${totalPages || 1} 页</span>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage + 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>下一页</button>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(${totalPages || 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>末页</button>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(1)" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationFirst') + `</button>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage - 1})" ${currentPage === 1 || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationPrev') + `</button>
|
||||
<span class="pagination-page">` + _t('tasks.paginationPage', { current: currentPage, total: totalPages || 1 }) + `</span>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(${currentPage + 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationNext') + `</button>
|
||||
<button class="btn-secondary" onclick="goBatchQueuesPage(${totalPages || 1})" ${currentPage >= totalPages || total === 0 ? 'disabled' : ''}>` + _t('tasks.paginationLast') + `</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1189,7 +1192,7 @@ async function showBatchQueueDetail(queueId) {
|
||||
|
||||
const response = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取队列详情失败');
|
||||
throw new Error(_t('tasks.getQueueDetailFailed'));
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -1198,7 +1201,7 @@ async function showBatchQueueDetail(queueId) {
|
||||
|
||||
if (title) {
|
||||
// textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &...(看起来像“变形/乱码”)
|
||||
title.textContent = queue.title ? `批量任务队列 - ${String(queue.title)}` : '批量任务队列';
|
||||
title.textContent = queue.title ? _t('tasks.batchQueueTitle') + ' - ' + String(queue.title) : _t('tasks.batchQueueTitle');
|
||||
}
|
||||
|
||||
// 更新按钮显示
|
||||
@@ -1210,9 +1213,9 @@ async function showBatchQueueDetail(queueId) {
|
||||
// pending状态显示"开始执行",paused状态显示"继续执行"
|
||||
startBtn.style.display = (queue.status === 'pending' || queue.status === 'paused') ? 'inline-block' : 'none';
|
||||
if (startBtn && queue.status === 'paused') {
|
||||
startBtn.textContent = '继续执行';
|
||||
startBtn.textContent = _t('tasks.resumeExecute');
|
||||
} else if (startBtn && queue.status === 'pending') {
|
||||
startBtn.textContent = '开始执行';
|
||||
startBtn.textContent = _t('batchQueueDetailModal.startExecute');
|
||||
}
|
||||
}
|
||||
if (pauseBtn) {
|
||||
@@ -1226,20 +1229,20 @@ async function showBatchQueueDetail(queueId) {
|
||||
|
||||
// 队列状态映射
|
||||
const queueStatusMap = {
|
||||
'pending': { text: '待执行', class: 'batch-queue-status-pending' },
|
||||
'running': { text: '执行中', class: 'batch-queue-status-running' },
|
||||
'paused': { text: '已暂停', class: 'batch-queue-status-paused' },
|
||||
'completed': { text: '已完成', class: 'batch-queue-status-completed' },
|
||||
'cancelled': { text: '已取消', class: 'batch-queue-status-cancelled' }
|
||||
'pending': { text: _t('tasks.statusPending'), class: 'batch-queue-status-pending' },
|
||||
'running': { text: _t('tasks.statusRunning'), class: 'batch-queue-status-running' },
|
||||
'paused': { text: _t('tasks.statusPaused'), class: 'batch-queue-status-paused' },
|
||||
'completed': { text: _t('tasks.statusCompleted'), class: 'batch-queue-status-completed' },
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-queue-status-cancelled' }
|
||||
};
|
||||
|
||||
// 任务状态映射
|
||||
const taskStatusMap = {
|
||||
'pending': { text: '待执行', class: 'batch-task-status-pending' },
|
||||
'running': { text: '执行中', class: 'batch-task-status-running' },
|
||||
'completed': { text: '已完成', class: 'batch-task-status-completed' },
|
||||
'failed': { text: '失败', class: 'batch-task-status-failed' },
|
||||
'cancelled': { text: '已取消', class: 'batch-task-status-cancelled' }
|
||||
'pending': { text: _t('tasks.statusPending'), class: 'batch-task-status-pending' },
|
||||
'running': { text: _t('tasks.statusRunning'), class: 'batch-task-status-running' },
|
||||
'completed': { text: _t('tasks.statusCompleted'), class: 'batch-task-status-completed' },
|
||||
'failed': { text: _t('tasks.failedLabel'), class: 'batch-task-status-failed' },
|
||||
'cancelled': { text: _t('tasks.statusCancelled'), class: 'batch-task-status-cancelled' }
|
||||
};
|
||||
|
||||
// 获取角色信息(如果队列有角色配置)
|
||||
@@ -1266,51 +1269,51 @@ async function showBatchQueueDetail(queueId) {
|
||||
}
|
||||
}
|
||||
roleDisplay = `<div class="detail-item">
|
||||
<span class="detail-label">角色</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.role') + `</span>
|
||||
<span class="detail-value">${roleIcon} ${escapeHtml(roleName)}</span>
|
||||
</div>`;
|
||||
} else {
|
||||
// 默认角色
|
||||
roleDisplay = `<div class="detail-item">
|
||||
<span class="detail-label">角色</span>
|
||||
<span class="detail-value">🔵 默认</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.role') + `</span>
|
||||
<span class="detail-value">🔵 ` + _t('batchQueueDetailModal.defaultRole') + `</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="batch-queue-detail-info">
|
||||
${queue.title ? `<div class="detail-item">
|
||||
<span class="detail-label">任务标题</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.queueTitle') + `</span>
|
||||
<span class="detail-value">${escapeHtml(queue.title)}</span>
|
||||
</div>` : ''}
|
||||
${roleDisplay}
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">队列ID</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.queueId') + `</span>
|
||||
<span class="detail-value"><code>${escapeHtml(queue.id)}</code></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">状态</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.status') + `</span>
|
||||
<span class="detail-value"><span class="batch-queue-status ${queueStatusMap[queue.status]?.class || ''}">${queueStatusMap[queue.status]?.text || queue.status}</span></span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">创建时间</span>
|
||||
<span class="detail-value">${new Date(queue.createdAt).toLocaleString('zh-CN')}</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.createdAt') + `</span>
|
||||
<span class="detail-value">${new Date(queue.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
${queue.startedAt ? `<div class="detail-item">
|
||||
<span class="detail-label">开始时间</span>
|
||||
<span class="detail-value">${new Date(queue.startedAt).toLocaleString('zh-CN')}</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.startedAt') + `</span>
|
||||
<span class="detail-value">${new Date(queue.startedAt).toLocaleString()}</span>
|
||||
</div>` : ''}
|
||||
${queue.completedAt ? `<div class="detail-item">
|
||||
<span class="detail-label">完成时间</span>
|
||||
<span class="detail-value">${new Date(queue.completedAt).toLocaleString('zh-CN')}</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.completedAt') + `</span>
|
||||
<span class="detail-value">${new Date(queue.completedAt).toLocaleString()}</span>
|
||||
</div>` : ''}
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">任务总数</span>
|
||||
<span class="detail-label">` + _t('batchQueueDetailModal.taskTotal') + `</span>
|
||||
<span class="detail-value">${queue.tasks.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-queue-tasks-list">
|
||||
<h4>任务列表</h4>
|
||||
<h4>` + _t('batchQueueDetailModal.taskList') + `</h4>
|
||||
${queue.tasks.map((task, index) => {
|
||||
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
|
||||
const canEdit = queue.status === 'pending' && task.status === 'pending';
|
||||
@@ -1321,14 +1324,14 @@ async function showBatchQueueDetail(queueId) {
|
||||
<span class="batch-task-index">#${index + 1}</span>
|
||||
<span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</span>
|
||||
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
|
||||
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">编辑</button>` : ''}
|
||||
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">删除</button>` : ''}
|
||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">查看对话</button>` : ''}
|
||||
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
|
||||
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
|
||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||||
</div>
|
||||
${task.startedAt ? `<div class="batch-task-time">开始: ${new Date(task.startedAt).toLocaleString('zh-CN')}</div>` : ''}
|
||||
${task.completedAt ? `<div class="batch-task-time">完成: ${new Date(task.completedAt).toLocaleString('zh-CN')}</div>` : ''}
|
||||
${task.error ? `<div class="batch-task-error">错误: ${escapeHtml(task.error)}</div>` : ''}
|
||||
${task.result ? `<div class="batch-task-result">结果: ${escapeHtml(task.result.substring(0, 200))}${task.result.length > 200 ? '...' : ''}</div>` : ''}
|
||||
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
|
||||
${task.completedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.completeLabel') + `: ${new Date(task.completedAt).toLocaleString()}</div>` : ''}
|
||||
${task.error ? `<div class="batch-task-error">` + _t('batchQueueDetailModal.errorLabel') + `: ${escapeHtml(task.error)}</div>` : ''}
|
||||
${task.result ? `<div class="batch-task-result">` + _t('batchQueueDetailModal.resultLabel') + `: ${escapeHtml(task.result.substring(0, 200))}${task.result.length > 200 ? '...' : ''}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
@@ -1343,7 +1346,7 @@ async function showBatchQueueDetail(queueId) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取队列详情失败:', error);
|
||||
alert('获取队列详情失败: ' + error.message);
|
||||
alert(_t('tasks.getQueueDetailFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1359,7 +1362,7 @@ async function startBatchQueue() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '启动批量任务失败');
|
||||
throw new Error(result.error || _t('tasks.startBatchQueueFailed'));
|
||||
}
|
||||
|
||||
// 刷新详情
|
||||
@@ -1367,7 +1370,7 @@ async function startBatchQueue() {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('启动批量任务失败:', error);
|
||||
alert('启动批量任务失败: ' + error.message);
|
||||
alert(_t('tasks.startBatchQueueFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1376,7 +1379,7 @@ async function pauseBatchQueue() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) return;
|
||||
|
||||
if (!confirm('确定要暂停这个批量任务队列吗?当前正在执行的任务将被停止,后续任务将保留待执行状态。')) {
|
||||
if (!confirm(_t('tasks.pauseQueueConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1387,7 +1390,7 @@ async function pauseBatchQueue() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '暂停批量任务失败');
|
||||
throw new Error(result.error || _t('tasks.pauseQueueFailed'));
|
||||
}
|
||||
|
||||
// 刷新详情
|
||||
@@ -1395,7 +1398,7 @@ async function pauseBatchQueue() {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('暂停批量任务失败:', error);
|
||||
alert('暂停批量任务失败: ' + error.message);
|
||||
alert(_t('tasks.pauseQueueFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1404,7 +1407,7 @@ async function deleteBatchQueue() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) return;
|
||||
|
||||
if (!confirm('确定要删除这个批量任务队列吗?此操作不可恢复。')) {
|
||||
if (!confirm(_t('tasks.deleteQueueConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1415,14 +1418,14 @@ async function deleteBatchQueue() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '删除批量任务队列失败');
|
||||
throw new Error(result.error || _t('tasks.deleteQueueFailed'));
|
||||
}
|
||||
|
||||
closeBatchQueueDetailModal();
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('删除批量任务队列失败:', error);
|
||||
alert('删除批量任务队列失败: ' + error.message);
|
||||
alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1430,7 +1433,7 @@ async function deleteBatchQueue() {
|
||||
async function deleteBatchQueueFromList(queueId) {
|
||||
if (!queueId) return;
|
||||
|
||||
if (!confirm('确定要删除这个批量任务队列吗?此操作不可恢复。')) {
|
||||
if (!confirm(_t('tasks.deleteQueueConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1441,7 +1444,7 @@ async function deleteBatchQueueFromList(queueId) {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '删除批量任务队列失败');
|
||||
throw new Error(result.error || _t('tasks.deleteQueueFailed'));
|
||||
}
|
||||
|
||||
// 如果当前正在查看这个队列的详情,关闭详情模态框
|
||||
@@ -1453,7 +1456,7 @@ async function deleteBatchQueueFromList(queueId) {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('删除批量任务队列失败:', error);
|
||||
alert('删除批量任务队列失败: ' + error.message);
|
||||
alert(_t('tasks.deleteQueueFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1599,18 +1602,18 @@ async function saveBatchTask() {
|
||||
const messageInput = document.getElementById('edit-task-message');
|
||||
|
||||
if (!queueId || !taskId) {
|
||||
alert('任务信息不完整');
|
||||
alert(_t('tasks.taskIncomplete'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!messageInput) {
|
||||
alert('无法获取任务消息输入框');
|
||||
alert(_t('tasks.cannotGetTaskMessageInput'));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = messageInput.value.trim();
|
||||
if (!message) {
|
||||
alert('任务消息不能为空');
|
||||
alert(_t('tasks.taskMessageRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1625,7 +1628,7 @@ async function saveBatchTask() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '更新任务失败');
|
||||
throw new Error(result.error || _t('tasks.updateTaskFailed'));
|
||||
}
|
||||
|
||||
// 关闭编辑模态框
|
||||
@@ -1640,7 +1643,7 @@ async function saveBatchTask() {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('保存任务失败:', error);
|
||||
alert('保存任务失败: ' + error.message);
|
||||
alert(_t('tasks.saveTaskFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1648,7 +1651,7 @@ async function saveBatchTask() {
|
||||
function showAddBatchTaskModal() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) {
|
||||
alert('队列信息不存在');
|
||||
alert(_t('tasks.queueInfoMissing'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1706,18 +1709,18 @@ async function saveAddBatchTask() {
|
||||
const messageInput = document.getElementById('add-task-message');
|
||||
|
||||
if (!queueId) {
|
||||
alert('队列信息不存在');
|
||||
alert(_t('tasks.queueInfoMissing'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!messageInput) {
|
||||
alert('无法获取任务消息输入框');
|
||||
alert(_t('tasks.cannotGetTaskMessageInput'));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = messageInput.value.trim();
|
||||
if (!message) {
|
||||
alert('任务消息不能为空');
|
||||
alert(_t('tasks.taskMessageRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1732,7 +1735,7 @@ async function saveAddBatchTask() {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '添加任务失败');
|
||||
throw new Error(result.error || _t('tasks.addTaskFailed'));
|
||||
}
|
||||
|
||||
// 关闭添加任务模态框
|
||||
@@ -1747,7 +1750,7 @@ async function saveAddBatchTask() {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('添加任务失败:', error);
|
||||
alert('添加任务失败: ' + error.message);
|
||||
alert(_t('tasks.addTaskFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1779,7 +1782,7 @@ function deleteBatchTaskFromElement(button) {
|
||||
? decodedMessage.substring(0, 50) + '...'
|
||||
: decodedMessage;
|
||||
|
||||
if (!confirm(`确定要删除这个任务吗?\n\n任务内容: ${displayMessage}\n\n此操作不可恢复。`)) {
|
||||
if (!confirm(_t('tasks.confirmDeleteTask', { message: displayMessage }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1789,7 +1792,7 @@ function deleteBatchTaskFromElement(button) {
|
||||
// 删除批量任务
|
||||
async function deleteBatchTask(queueId, taskId) {
|
||||
if (!queueId || !taskId) {
|
||||
alert('任务信息不完整');
|
||||
alert(_t('tasks.taskIncomplete'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1800,7 +1803,7 @@ async function deleteBatchTask(queueId, taskId) {
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error || '删除任务失败');
|
||||
throw new Error(result.error || _t('tasks.deleteTaskFailed'));
|
||||
}
|
||||
|
||||
// 刷新队列详情
|
||||
@@ -1812,7 +1815,7 @@ async function deleteBatchTask(queueId, taskId) {
|
||||
refreshBatchQueues();
|
||||
} catch (error) {
|
||||
console.error('删除任务失败:', error);
|
||||
alert('删除任务失败: ' + error.message);
|
||||
alert(_t('tasks.deleteTaskFailed') + ': ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* 系统设置 - 终端:多标签、流式输出、命令历史、Ctrl+L 清屏、长时间可取消
|
||||
*/
|
||||
(function () {
|
||||
var getContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, attrs) {
|
||||
if (type === '2d') {
|
||||
attrs = (attrs && typeof attrs === 'object') ? Object.assign({ willReadFrequently: true }, attrs) : { willReadFrequently: true };
|
||||
return getContext.call(this, type, attrs);
|
||||
}
|
||||
return getContext.apply(this, arguments);
|
||||
};
|
||||
|
||||
var terminals = [];
|
||||
var currentTabId = 1;
|
||||
var inited = false;
|
||||
var tabIdCounter = 1;
|
||||
var PROMPT = ''; // 真实 Shell 自己输出提示符,这里不再自定义
|
||||
var HISTORY_MAX = 100;
|
||||
var CANCEL_AFTER_MS = 125000;
|
||||
|
||||
function getCurrent() {
|
||||
for (var i = 0; i < terminals.length; i++) {
|
||||
if (terminals[i].id === currentTabId) return terminals[i];
|
||||
}
|
||||
return terminals[0] || null;
|
||||
}
|
||||
|
||||
var WELCOME_LINE = 'CyberStrikeAI 终端 - 真实 Shell 会话,直接输入命令;Ctrl+L 清屏\r\n';
|
||||
|
||||
function writePrompt(tab) {
|
||||
// 提示符交由后端 Shell 自行输出,这里仅保留占位函数,避免旧代码报错
|
||||
}
|
||||
|
||||
function redrawTabDisplay(t) {
|
||||
if (!t || !t.term) return;
|
||||
t.term.clear();
|
||||
t.term.write(WELCOME_LINE);
|
||||
}
|
||||
|
||||
function writeln(tabOrS, s) {
|
||||
var t, text;
|
||||
if (arguments.length === 1) { text = tabOrS; t = getCurrent(); } else { t = tabOrS; text = s; }
|
||||
if (!t || !t.term) return;
|
||||
if (text) t.term.writeln(text);
|
||||
else t.term.writeln('');
|
||||
}
|
||||
|
||||
function writeOutput(tab, text, isError) {
|
||||
var t = tab || getCurrent();
|
||||
if (!t || !t.term || !text) return;
|
||||
var s = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
var lines = s.split('\n');
|
||||
var prefix = isError ? '\x1b[31m' : '';
|
||||
var suffix = isError ? '\x1b[0m' : '';
|
||||
t.term.write(prefix);
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].replace(/\r/g, '');
|
||||
t.term.writeln(line);
|
||||
}
|
||||
t.term.write(suffix);
|
||||
}
|
||||
|
||||
// 从本地存储中获取当前登录 token(与 auth.js 使用的结构保持一致)
|
||||
function getStoredAuthToken() {
|
||||
try {
|
||||
var raw = localStorage.getItem('cyberstrike-auth');
|
||||
if (!raw) return null;
|
||||
var o = JSON.parse(raw);
|
||||
if (o && o.token) return o.token;
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// WebSocket 地址构造(兼容 http/https,并通过 query 传递 token 以通过后端鉴权)
|
||||
function buildTerminalWSURL() {
|
||||
var proto = (window.location.protocol === 'https:') ? 'wss://' : 'ws://';
|
||||
var url = proto + window.location.host + '/api/terminal/ws';
|
||||
var token = getStoredAuthToken();
|
||||
if (token) {
|
||||
url += '?token=' + encodeURIComponent(token);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function ensureTerminalWS(tab) {
|
||||
if (tab.ws && (tab.ws.readyState === WebSocket.OPEN || tab.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var ws = new WebSocket(buildTerminalWSURL());
|
||||
tab.ws = ws;
|
||||
tab.running = true;
|
||||
|
||||
ws.onopen = function () {
|
||||
if (tab.term) {
|
||||
tab.term.focus();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = function (ev) {
|
||||
if (!tab.term) return;
|
||||
// 处理二进制消息和文本消息
|
||||
if (ev.data instanceof ArrayBuffer) {
|
||||
var decoder = new TextDecoder('utf-8');
|
||||
tab.term.write(decoder.decode(ev.data));
|
||||
} else if (ev.data instanceof Blob) {
|
||||
// Blob 类型,需要异步读取
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
var decoder = new TextDecoder('utf-8');
|
||||
tab.term.write(decoder.decode(reader.result));
|
||||
};
|
||||
reader.readAsArrayBuffer(ev.data);
|
||||
} else {
|
||||
// 字符串类型
|
||||
tab.term.write(ev.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function () {
|
||||
tab.running = false;
|
||||
if (tab.term) {
|
||||
tab.term.writeln('\r\n\x1b[2m[会话已关闭]\x1b[0m');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
tab.running = false;
|
||||
if (tab.term) {
|
||||
tab.term.writeln('\r\n\x1b[31m[终端连接出错]\x1b[0m');
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
if (tab.term) {
|
||||
tab.term.writeln('\r\n\x1b[31m[无法连接终端服务: ' + String(e) + ']\x1b[0m');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTerminalInContainer(container, tab) {
|
||||
if (typeof Terminal === 'undefined') return null;
|
||||
if (!tab.history) tab.history = [];
|
||||
if (tab.historyIndex === undefined) tab.historyIndex = -1;
|
||||
if (tab.cursorIndex === undefined) tab.cursorIndex = 0;
|
||||
|
||||
var term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
fontSize: 13,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
lineHeight: 1.2,
|
||||
scrollback: 1000,
|
||||
theme: {
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#58a6ff',
|
||||
cursorAccent: '#0d1117',
|
||||
selection: 'rgba(88, 166, 255, 0.3)',
|
||||
black: '#484f58',
|
||||
red: '#ff7b72',
|
||||
green: '#3fb950',
|
||||
yellow: '#d29922',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#39c5cf',
|
||||
white: '#e6edf3',
|
||||
brightBlack: '#6e7681',
|
||||
brightRed: '#ffa198',
|
||||
brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#79c0ff',
|
||||
brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd',
|
||||
brightWhite: '#f0f6fc'
|
||||
}
|
||||
});
|
||||
var fitAddon = null;
|
||||
if (typeof FitAddon !== 'undefined') {
|
||||
var FitCtor = (FitAddon.FitAddon || FitAddon);
|
||||
fitAddon = new FitCtor();
|
||||
term.loadAddon(fitAddon);
|
||||
}
|
||||
term.open(container);
|
||||
term.write(WELCOME_LINE);
|
||||
container.addEventListener('click', function () {
|
||||
switchTerminalTab(tab.id);
|
||||
if (term) term.focus();
|
||||
});
|
||||
container.setAttribute('tabindex', '0');
|
||||
container.title = '点击此处后输入命令';
|
||||
|
||||
function sendToWS(data) {
|
||||
ensureTerminalWS(tab);
|
||||
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
tab.ws.send(data);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
// Ctrl+L:本地清屏,同时把 ^L 也发给后端
|
||||
if (data === '\x0c') {
|
||||
term.clear();
|
||||
sendToWS(data);
|
||||
return;
|
||||
}
|
||||
sendToWS(data);
|
||||
});
|
||||
|
||||
tab.term = term;
|
||||
tab.fitAddon = fitAddon;
|
||||
return term;
|
||||
}
|
||||
|
||||
function switchTerminalTab(id) {
|
||||
var prevId = currentTabId;
|
||||
currentTabId = id;
|
||||
document.querySelectorAll('.terminal-tab').forEach(function (el) {
|
||||
el.classList.toggle('active', parseInt(el.getAttribute('data-tab-id'), 10) === id);
|
||||
});
|
||||
document.querySelectorAll('.terminal-pane').forEach(function (el) {
|
||||
var paneId = el.getAttribute('id');
|
||||
var match = paneId && paneId.match(/terminal-pane-(\d+)/);
|
||||
var paneTabId = match ? parseInt(match[1], 10) : 0;
|
||||
el.classList.toggle('active', paneTabId === id);
|
||||
});
|
||||
var t = getCurrent();
|
||||
if (t && t.term) {
|
||||
if (prevId !== id) {
|
||||
requestAnimationFrame(function () {
|
||||
if (currentTabId === id && t.term) t.term.focus();
|
||||
});
|
||||
} else {
|
||||
t.term.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addTerminalTab() {
|
||||
if (typeof Terminal === 'undefined') return;
|
||||
tabIdCounter += 1;
|
||||
var id = tabIdCounter;
|
||||
var paneId = 'terminal-pane-' + id;
|
||||
var containerId = 'terminal-container-' + id;
|
||||
var tabsEl = document.querySelector('.terminal-tabs');
|
||||
var panesEl = document.querySelector('.terminal-panes');
|
||||
if (!tabsEl || !panesEl) return;
|
||||
|
||||
var tabDiv = document.createElement('div');
|
||||
tabDiv.className = 'terminal-tab';
|
||||
tabDiv.setAttribute('data-tab-id', String(id));
|
||||
var label = document.createElement('span');
|
||||
label.className = 'terminal-tab-label';
|
||||
label.textContent = '终端 ' + id;
|
||||
label.onclick = function () { switchTerminalTab(id); };
|
||||
var closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'terminal-tab-close';
|
||||
closeBtn.title = '关闭';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.onclick = function (e) { e.stopPropagation(); removeTerminalTab(id); };
|
||||
tabDiv.appendChild(label);
|
||||
tabDiv.appendChild(closeBtn);
|
||||
var plusBtn = tabsEl.querySelector('.terminal-tab-new');
|
||||
tabsEl.insertBefore(tabDiv, plusBtn);
|
||||
|
||||
var paneDiv = document.createElement('div');
|
||||
paneDiv.id = paneId;
|
||||
paneDiv.className = 'terminal-pane';
|
||||
var containerDiv = document.createElement('div');
|
||||
containerDiv.id = containerId;
|
||||
containerDiv.className = 'terminal-container';
|
||||
paneDiv.appendChild(containerDiv);
|
||||
panesEl.appendChild(paneDiv);
|
||||
|
||||
var tab = { id: id, paneId: paneId, containerId: containerId, lineBuffer: '', cursorIndex: 0, running: false, term: null, fitAddon: null, history: [], historyIndex: -1 };
|
||||
terminals.push(tab);
|
||||
createTerminalInContainer(containerDiv, tab);
|
||||
switchTerminalTab(id);
|
||||
updateTerminalTabCloseVisibility();
|
||||
setTimeout(function () {
|
||||
try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function updateTerminalTabCloseVisibility() {
|
||||
var tabsEl = document.querySelector('.terminal-tabs');
|
||||
if (!tabsEl) return;
|
||||
var tabDivs = tabsEl.querySelectorAll('.terminal-tab');
|
||||
var showClose = terminals.length > 1;
|
||||
for (var i = 0; i < tabDivs.length; i++) {
|
||||
var btn = tabDivs[i].querySelector('.terminal-tab-close');
|
||||
if (btn) btn.style.display = showClose ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function removeTerminalTab(id) {
|
||||
if (terminals.length <= 1) return;
|
||||
var idx = -1;
|
||||
for (var i = 0; i < terminals.length; i++) { if (terminals[i].id === id) { idx = i; break; } }
|
||||
if (idx < 0) return;
|
||||
|
||||
var deletingCurrent = (currentTabId === id);
|
||||
var switchToIndex = deletingCurrent ? (idx > 0 ? idx - 1 : 0) : -1;
|
||||
|
||||
var tab = terminals[idx];
|
||||
if (tab.term && tab.term.dispose) tab.term.dispose();
|
||||
tab.term = null;
|
||||
tab.fitAddon = null;
|
||||
terminals.splice(idx, 1);
|
||||
|
||||
var tabDiv = document.querySelector('.terminal-tab[data-tab-id="' + id + '"]');
|
||||
var paneDiv = document.getElementById('terminal-pane-' + id);
|
||||
if (tabDiv && tabDiv.parentNode) tabDiv.parentNode.removeChild(tabDiv);
|
||||
if (paneDiv && paneDiv.parentNode) paneDiv.parentNode.removeChild(paneDiv);
|
||||
|
||||
var curIdxBeforeRenumber = -1;
|
||||
if (!deletingCurrent) {
|
||||
for (var i = 0; i < terminals.length; i++) {
|
||||
if (terminals[i].id === currentTabId) { curIdxBeforeRenumber = i; break; }
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < terminals.length; i++) {
|
||||
var t = terminals[i];
|
||||
t.id = i + 1;
|
||||
t.paneId = 'terminal-pane-' + (i + 1);
|
||||
t.containerId = 'terminal-container-' + (i + 1);
|
||||
}
|
||||
tabIdCounter = terminals.length;
|
||||
if (curIdxBeforeRenumber >= 0) currentTabId = terminals[curIdxBeforeRenumber].id;
|
||||
|
||||
var tabsEl = document.querySelector('.terminal-tabs');
|
||||
var panesEl = document.querySelector('.terminal-panes');
|
||||
if (tabsEl) {
|
||||
var tabDivs = tabsEl.querySelectorAll('.terminal-tab');
|
||||
for (var i = 0; i < tabDivs.length; i++) {
|
||||
var t = terminals[i];
|
||||
tabDivs[i].setAttribute('data-tab-id', String(t.id));
|
||||
var lbl = tabDivs[i].querySelector('.terminal-tab-label');
|
||||
if (lbl) lbl.textContent = '终端 ' + t.id;
|
||||
if (lbl) lbl.onclick = (function (tid) { return function () { switchTerminalTab(tid); }; })(t.id);
|
||||
var cb = tabDivs[i].querySelector('.terminal-tab-close');
|
||||
if (cb) cb.onclick = (function (tid) { return function (e) { e.stopPropagation(); removeTerminalTab(tid); }; })(t.id);
|
||||
}
|
||||
}
|
||||
if (panesEl) {
|
||||
var paneDivs = panesEl.querySelectorAll('.terminal-pane');
|
||||
for (var i = 0; i < paneDivs.length; i++) {
|
||||
var t = terminals[i];
|
||||
paneDivs[i].id = t.paneId;
|
||||
var cont = paneDivs[i].querySelector('.terminal-container');
|
||||
if (cont) cont.id = t.containerId;
|
||||
}
|
||||
}
|
||||
|
||||
updateTerminalTabCloseVisibility();
|
||||
|
||||
if (deletingCurrent && terminals.length > 0) {
|
||||
currentTabId = terminals[switchToIndex].id;
|
||||
switchTerminalTab(currentTabId);
|
||||
}
|
||||
}
|
||||
|
||||
function initTerminal() {
|
||||
var pane1 = document.getElementById('terminal-pane-1');
|
||||
var container1 = document.getElementById('terminal-container-1');
|
||||
if (!pane1 || !container1) return;
|
||||
if (inited) {
|
||||
var t = getCurrent();
|
||||
if (t && t.term) t.term.focus();
|
||||
terminals.forEach(function (tab) { try { if (tab.fitAddon) tab.fitAddon.fit(); } catch (e) {} });
|
||||
return;
|
||||
}
|
||||
inited = true;
|
||||
|
||||
if (typeof Terminal === 'undefined') {
|
||||
container1.innerHTML = '<p class="terminal-error">未加载 xterm.js,请刷新页面或检查网络。</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
currentTabId = 1;
|
||||
var tab = { id: 1, paneId: 'terminal-pane-1', containerId: 'terminal-container-1', lineBuffer: '', cursorIndex: 0, running: false, term: null, fitAddon: null, history: [], historyIndex: -1 };
|
||||
terminals.push(tab);
|
||||
createTerminalInContainer(container1, tab);
|
||||
|
||||
updateTerminalTabCloseVisibility();
|
||||
|
||||
setTimeout(function () {
|
||||
try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {}
|
||||
}, 100);
|
||||
|
||||
var resizeTimer;
|
||||
window.addEventListener('resize', function () {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(function () {
|
||||
terminals.forEach(function (t) { try { if (t.fitAddon) t.fitAddon.fit(); } catch (e) {} });
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
function terminalClear() {
|
||||
var t = getCurrent();
|
||||
if (!t || !t.term) return;
|
||||
t.term.clear();
|
||||
t.lineBuffer = '';
|
||||
if (t.cursorIndex !== undefined) t.cursorIndex = 0;
|
||||
writePrompt(t);
|
||||
t.term.focus();
|
||||
}
|
||||
|
||||
window.initTerminal = initTerminal;
|
||||
window.terminalClear = terminalClear;
|
||||
window.switchTerminalTab = switchTerminalTab;
|
||||
window.addTerminalTab = addTerminalTab;
|
||||
window.removeTerminalTab = removeTerminalTab;
|
||||
})();
|
||||
@@ -156,14 +156,20 @@ async function loadVulnerabilities(page = null) {
|
||||
function renderVulnerabilities(vulnerabilities) {
|
||||
const listContainer = document.getElementById('vulnerabilities-list');
|
||||
|
||||
// 处理空值情况
|
||||
// 处理空值情况(使用 data-i18n 以便语言切换时自动更新)
|
||||
if (!vulnerabilities || !Array.isArray(vulnerabilities)) {
|
||||
listContainer.innerHTML = '<div class="empty-state">暂无漏洞记录</div>';
|
||||
listContainer.innerHTML = '<div class="empty-state" data-i18n="vulnerabilityPage.noRecords">暂无漏洞记录</div>';
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listContainer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (vulnerabilities.length === 0) {
|
||||
listContainer.innerHTML = '<div class="empty-state">暂无漏洞记录</div>';
|
||||
listContainer.innerHTML = '<div class="empty-state" data-i18n="vulnerabilityPage.noRecords">暂无漏洞记录</div>';
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listContainer);
|
||||
}
|
||||
// 清空分页信息
|
||||
const paginationContainer = document.getElementById('vulnerability-pagination');
|
||||
if (paginationContainer) {
|
||||
@@ -328,7 +334,7 @@ async function changeVulnerabilityPageSize() {
|
||||
// 显示添加漏洞模态框
|
||||
function showAddVulnerabilityModal() {
|
||||
currentVulnerabilityId = null;
|
||||
document.getElementById('vulnerability-modal-title').textContent = '添加漏洞';
|
||||
document.getElementById('vulnerability-modal-title').textContent = (typeof window.t === 'function' ? window.t('vulnerability.addVuln') : '添加漏洞');
|
||||
|
||||
// 清空表单
|
||||
document.getElementById('vulnerability-conversation-id').value = '';
|
||||
@@ -353,7 +359,7 @@ async function editVulnerability(id) {
|
||||
|
||||
const vuln = await response.json();
|
||||
currentVulnerabilityId = id;
|
||||
document.getElementById('vulnerability-modal-title').textContent = '编辑漏洞';
|
||||
document.getElementById('vulnerability-modal-title').textContent = (typeof window.t === 'function' ? window.t('vulnerability.editVuln') : '编辑漏洞');
|
||||
|
||||
// 填充表单
|
||||
document.getElementById('vulnerability-conversation-id').value = vuln.conversation_id || '';
|
||||
|
||||
+30
-15
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API 文档 - CyberStrikeAI</title>
|
||||
<title data-i18n="apiDocs.pageTitle">API 文档 - CyberStrikeAI</title>
|
||||
<link rel="icon" type="image/png" href="/static/logo.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
@@ -22,6 +22,7 @@
|
||||
}
|
||||
|
||||
.api-docs-header {
|
||||
position: relative;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
@@ -833,9 +834,21 @@
|
||||
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
API 文档
|
||||
<span data-i18n="apiDocs.title">API 文档</span>
|
||||
</h1>
|
||||
<p>CyberStrikeAI 平台 API 接口文档,支持在线测试</p>
|
||||
<p data-i18n="apiDocs.subtitle">CyberStrikeAI 平台 API 接口文档,支持在线测试</p>
|
||||
<div class="api-docs-lang-switcher" style="position: absolute; top: 24px; right: 24px;">
|
||||
<div class="lang-switcher">
|
||||
<button type="button" class="btn-secondary lang-switcher-btn" onclick="typeof toggleLangDropdown === 'function' && toggleLangDropdown()" title="界面语言">
|
||||
<span class="lang-switcher-icon">🌐</span>
|
||||
<span id="current-lang-label">中文</span>
|
||||
</button>
|
||||
<div id="lang-dropdown" class="lang-dropdown" style="display: none;">
|
||||
<div class="lang-option" data-lang="zh-CN" onclick="typeof onLanguageSelect === 'function' && onLanguageSelect('zh-CN')">中文</div>
|
||||
<div class="lang-option" data-lang="en-US" onclick="typeof onLanguageSelect === 'function' && onLanguageSelect('en-US')">English</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auth-info-section" class="auth-info-section" style="display: none;">
|
||||
@@ -846,17 +859,17 @@
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
<h3 style="margin: 0; font-size: 1rem; font-weight: 600; color: var(--text-primary);">API 认证说明</h3>
|
||||
<h3 style="margin: 0; font-size: 1rem; font-weight: 600; color: var(--text-primary);" data-i18n="apiDocs.authTitle">API 认证说明</h3>
|
||||
</div>
|
||||
<svg id="auth-info-arrow" class="auth-info-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="transition: transform 0.2s ease;">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="auth-info-body" class="auth-info-body" style="display: none; color: var(--text-secondary); font-size: 0.875rem; line-height: 1.6; margin-top: 16px;">
|
||||
<p style="margin: 0 0 12px 0;"><strong>所有 API 接口都需要 Token 认证。</strong></p>
|
||||
<p style="margin: 0 0 12px 0;"><strong data-i18n="apiDocs.authAllNeedToken">所有 API 接口都需要 Token 认证。</strong></p>
|
||||
<div style="background: var(--bg-secondary); padding: 12px; border-radius: 6px; margin-bottom: 12px;">
|
||||
<p style="margin: 0 0 8px 0; font-weight: 500;">1. 获取 Token:</p>
|
||||
<p style="margin: 0 0 8px 0;">在前端页面登录后,Token 会自动保存。您也可以通过以下方式获取:</p>
|
||||
<p style="margin: 0 0 8px 0; font-weight: 500;" data-i18n="apiDocs.authGetToken">1. 获取 Token:</p>
|
||||
<p style="margin: 0 0 8px 0;" data-i18n="apiDocs.authGetTokenDesc">在前端页面登录后,Token 会自动保存。您也可以通过以下方式获取:</p>
|
||||
<pre style="background: var(--bg-primary); padding: 8px; border-radius: 4px; margin: 8px 0; overflow-x: auto; font-size: 0.8125rem;"><code>POST /api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -871,13 +884,13 @@ Content-Type: application/json
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div style="background: var(--bg-secondary); padding: 12px; border-radius: 6px; margin-bottom: 12px;">
|
||||
<p style="margin: 0 0 8px 0; font-weight: 500;">2. 使用 Token:</p>
|
||||
<p style="margin: 0 0 8px 0;">在请求头中添加 Authorization 字段:</p>
|
||||
<p style="margin: 0 0 8px 0; font-weight: 500;" data-i18n="apiDocs.authUseToken">2. 使用 Token:</p>
|
||||
<p style="margin: 0 0 8px 0;" data-i18n="apiDocs.authUseTokenDesc">在请求头中添加 Authorization 字段:</p>
|
||||
<pre style="background: var(--bg-primary); padding: 8px; border-radius: 4px; margin: 8px 0; overflow-x: auto; font-size: 0.8125rem;"><code>Authorization: Bearer your_token_here</code></pre>
|
||||
<p style="margin: 8px 0 0 0; font-size: 0.8125rem; color: var(--text-muted);">💡 提示:本页面会自动使用您已登录的 Token,无需手动填写。</p>
|
||||
<p style="margin: 8px 0 0 0; font-size: 0.8125rem; color: var(--text-muted);" data-i18n="apiDocs.authTip">💡 提示:本页面会自动使用您已登录的 Token,无需手动填写。</p>
|
||||
</div>
|
||||
<div id="token-status" style="display: none; background: rgba(0, 102, 255, 0.1); padding: 8px 12px; border-radius: 6px; border-left: 3px solid var(--accent-color);">
|
||||
<p style="margin: 0; font-size: 0.8125rem; color: var(--accent-color);">
|
||||
<p style="margin: 0; font-size: 0.8125rem; color: var(--accent-color);" data-i18n="apiDocs.tokenDetected">
|
||||
<strong>✓ 已检测到 Token</strong> - 您可以直接测试 API 接口
|
||||
</p>
|
||||
</div>
|
||||
@@ -899,10 +912,10 @@ Content-Type: application/json
|
||||
|
||||
<div class="api-docs-content">
|
||||
<div class="api-docs-sidebar">
|
||||
<h3>API 分组</h3>
|
||||
<h3 data-i18n="apiDocs.sidebarGroupTitle">API 分组</h3>
|
||||
<ul class="api-group-list" id="api-group-list">
|
||||
<li class="api-group-item">
|
||||
<a href="#" class="api-group-link active" data-group="all">全部接口</a>
|
||||
<a href="#" class="api-group-link active" data-group="all" data-i18n="apiDocs.allApis">全部接口</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -914,13 +927,15 @@ Content-Type: application/json
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<h3>加载中...</h3>
|
||||
<p>正在加载 API 文档</p>
|
||||
<h3 data-i18n="apiDocs.loading">加载中...</h3>
|
||||
<p data-i18n="apiDocs.loadingDesc">正在加载 API 文档</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/i18next@23.11.5/i18next.min.js"></script>
|
||||
<script src="/static/js/i18n.js"></script>
|
||||
<script src="/static/js/api-docs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+626
-543
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user