feat: 添加可视化Web UI管理控制台及相关功能模块

新增完整的Web UI管理控制台,包含以下主要功能:
1. 响应式设计的现代化界面
2. 实时监控系统状态和提供商统计
3. 配置管理功能,支持多种模型提供商
4. 文件上传和OAuth凭据管理
5. 路径路由调用示例和curl命令生成
6. 实时日志查看和事件流处理
7. 提供完整的UI文档说明

新增多个前端模块文件,包括导航、事件处理、文件上传等功能组件,并更新package.json添加multer依赖以支持文件上传功能。同时添加详细的UI_README.md文档说明所有功能特性和使用方法。
This commit is contained in:
hex2077 2025-11-11 18:53:39 +08:00
parent a212a71385
commit 80d4e16840
24 changed files with 10462 additions and 607 deletions

238
UI_README.md Normal file
View file

@ -0,0 +1,238 @@
# AIClient2API 可视化管理控制台
## 概述
AIClient2API 现在包含一个功能完整的可视化 Web UI 管理控制台允许您通过浏览器轻松管理配置、监控供应商池状态、查看实时日志、配置AI模型提供商等。
## 功能特性
### 🎨 现代化界面
- 响应式设计,支持桌面和移动设备
- 直观的仪表盘展示系统统计信息
- 侧边栏导航,方便快速切换功能模块
- 协议标签切换OpenAI协议/Claude协议
### 📊 实时监控
- **仪表盘**显示运行时间、系统信息、Node.js版本、服务器时间、内存使用
- **供应商池管理**:查看各提供商账户状态、使用统计、错误率
- **活动统计**:活动连接、活跃提供商、健康提供商数量
### ⚙️ 配置管理
- 在线修改 API 密钥、监听地址、端口
- 支持多种模型提供商:
- **Gemini CLI OAuth** - 支持突破限制的Gemini访问
- **OpenAI Custom** - 自定义OpenAI API配置
- **Claude Custom** - 自定义Claude API配置
- **Claude Kiro OAuth** - 突破限制/免费使用的Claude服务
- **Qwen OAuth** - 通义千问OAuth认证
- **OpenAI Responses** - OpenAI新版本API
- 编辑系统提示词
- 高级配置选项:
- 系统提示文件路径和模式
- 提示日志配置
- 请求重试机制(最大重试次数、基础延迟)
- OAuth令牌自动刷新设置
- 供应商池配置文件路径
### 🔧 上传配置管理
- 搜索配置文件功能
- 按关联状态过滤(已关联/未关联)
- 配置文件列表展示
- 配置统计信息(总数、已关联数、未关联数)
- 实时刷新配置列表
### 🛣️ 路径路由调用示例
- **即时切换**通过修改URL路径即可切换不同的AI模型提供商
- **跨协议调用**支持OpenAI协议调用Claude模型或Claude协议调用OpenAI模型
- **客户端配置指导**为Cherry-Studio、NextChat、Cline等客户端提供配置示例
- 提供完整的curl使用示例
- 一键复制端点路径功能
支持的路由路径示例:
- `/gemini-cli-oauth/v1/chat/completions` - Gemini CLI OAuth (OpenAI协议)
- `/gemini-cli-oauth/v1/messages` - Gemini CLI OAuth (Claude协议)
- `/openai-qwen-oauth/v1/chat/completions` - Qwen OAuth (OpenAI协议)
- `/openai-qwen-oauth/v1/messages` - Qwen OAuth (Claude协议)
- `/claude-custom/v1/chat/completions` - Claude Custom (OpenAI协议)
- `/claude-custom/v1/messages` - Claude Custom (Claude协议)
- `/claude-kiro-oauth/v1/chat/completions` - Claude Kiro OAuth (OpenAI协议)
- `/claude-kiro-oauth/v1/messages` - Claude Kiro OAuth (Claude协议)
- `/openai-custom/v1/chat/completions` - OpenAI Custom (OpenAI协议)
- `/openai-custom/v1/messages` - OpenAI Custom (Claude协议)
### 📜 实时日志
- 实时显示服务器输出日志
- 清空日志功能
- 自动滚动和手动滚动切换
- 日志缓冲区管理
### 🔔 通知系统
- 操作成功/失败提示
- 优雅的 Toast 通知
- 3秒自动消失
## 快速开始
### 启动服务器
服务器启动时会自动打开浏览器到管理控制台:
```bash
node src/api-server.js --port 3000 --api-key 123456
```
访问地址http://127.0.0.1:3000/
### 界面导航
1. **仪表盘** - 系统概览、统计信息和路径路由示例
2. **配置管理** - 修改服务器配置和提供商设置
3. **供应商池管理** - 管理多个API提供商账户
4. **上传配置管理** - 管理配置文件和搜索过滤
5. **实时日志** - 查看服务器运行日志
## API 端点
管理控制台使用以下 RESTful API 端点:
### 配置管理
- `GET /api/config` - 获取当前配置
- `POST /api/config` - 更新配置
- `GET /api/configs` - 获取配置文件列表
- `POST /api/configs/search` - 搜索配置文件
### 系统信息
- `GET /api/system` - 获取系统信息
- `GET /api/providers` - 获取供应商池信息
### 实时数据
- `GET /api/events` - Server-Sent Events 流,用于实时更新
### 静态文件
- `GET /` - 主页面
- `GET /index.html` - HTML页面
- `GET /app/styles.css` - 样式文件
- `GET /app/mobile.css` - 移动端样式
- `GET /app/app.js` - JavaScript主逻辑
- `GET /app/utils.js` - 工具函数
- `GET /app/config-manager.js` - 配置管理
- `GET /app/provider-manager.js` - 提供商管理
- `GET /app/event-stream.js` - 事件流处理
- `GET /app/event-handlers.js` - 事件处理器
- `GET /app/navigation.js` - 导航逻辑
- `GET /app/modal.js` - 模态框组件
- `GET /app/file-upload.js` - 文件上传
- `GET /app/upload-config-manager.js` - 上传配置管理
- `GET /app/routing-examples.js` - 路由示例
- `GET /app/constants.js` - 常量定义
## 技术实现
### 前端技术栈
- **HTML5** - 语义化结构,支持无障碍访问
- **CSS3** - 现代化样式CSS Variables、Flexbox、Grid
- **JavaScript (ES6+)** - 模块化交互逻辑
- **Server-Sent Events** - 实时数据推送
- **Font Awesome 6.4.0** - 图标库
### 后端集成
- 集成到现有 `api-server.js`
- 零额外依赖
- ES 模块语法
- 异步处理
### 实时通信
使用 Server-Sent Events 实现实时双向通信:
- 自动广播日志到所有连接的客户端
- 每5秒发送统计更新
- 轻量级实现,无需 WebSocket
## 文件结构
```
static/
├── index.html # 主页面
└── app/
├── styles.css # 主样式文件
├── mobile.css # 移动端样式
├── app.js # 主应用逻辑
├── constants.js # 常量定义
├── utils.js # 工具函数
├── config-manager.js # 配置管理
├── provider-manager.js # 提供商管理
├── upload-config-manager.js # 上传配置管理
├── event-stream.js # 事件流处理
├── event-handlers.js # 事件处理器
├── navigation.js # 导航逻辑
├── modal.js # 模态框组件
├── file-upload.js # 文件上传
└── routing-examples.js # 路由示例
```
## 支持的提供商
### 突破限制类型
- **Gemini CLI OAuth** - 通过OAuth突破Gemini API限制
- **Claude Kiro OAuth** - 免费使用的Claude服务
- **Qwen OAuth** - 通义千问OAuth认证
### 官方API/三方类型
- **OpenAI Custom** - 自定义OpenAI API端点
- **Claude Custom** - 自定义Claude API端点
- **OpenAI Responses** - OpenAI最新版本API
## 浏览器兼容性
- Chrome 60+
- Firefox 55+
- Safari 11+
- Edge 79+
## 常见问题
### Q: 服务器启动后浏览器没有自动打开?
A: 检查是否使用 localhost 或 127.0.0.1 启动服务器。自动打开仅在本地部署时启用。
### Q: 如何使用路径路由功能?
A: 在仪表盘的"路径路由调用示例"中查看不同提供商的使用示例。修改客户端的API端点URL即可切换不同的AI模型。
### Q: 如何配置OAuth提供商
A: 在配置管理页面选择对应的OAuth提供商填写项目ID和OAuth凭据支持文件路径或Base64编码
### Q: 上传配置文件有什么作用?
A: 上传配置文件可以将本地配置文件上传到服务器,方便管理和在不同环境间同步配置。
### Q: 如何查看更详细的提供商信息?
A: 在"供应商池"页面可以看到每个提供商的使用次数、错误次数、最后使用时间等详细信息。
### Q: 配置修改后需要重启服务器吗?
A: 大部分配置如系统提示、API密钥会立即生效但网络端口等更改需要重启服务器。
### Q: 支持哪些客户端配置?
A: 支持Cherry-Studio、NextChat、Cline等主流AI客户端只需将API端点设置为对应的路由路径即可。
## 路由使用指南
### 1. 选择提供商
根据需要访问的AI模型选择对应的提供商路径。
### 2. 选择协议格式
- **OpenAI协议**:使用 `/v1/chat/completions` 端点
- **Claude协议**:使用 `/v1/messages` 端点
### 3. 配置客户端
在AI客户端中设置
- **API端点**`http://localhost:3000/{提供商路径}/{协议路径}`
- **API密钥**:对应提供商的密钥
- **模型名称**:使用提供商支持的具体模型
### 4. 发送请求
使用对应的协议格式发送请求,可以实现跨协议调用。
## 贡献
欢迎提交 Issue 和 Pull Request 来改进这个管理控制台!
## 许可证
本项目使用与主项目相同的许可证。

157
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dotenv": "^16.4.5",
"google-auth-library": "^10.1.0",
"lodash": "^4.17.21",
"multer": "^2.0.2",
"open": "^10.2.0",
"undici": "^7.12.0",
"uuid": "^11.1.0"
@ -2402,6 +2403,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@ -2945,7 +2952,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/bundle-name": {
@ -2963,6 +2969,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -3166,6 +3183,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -4061,7 +4093,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/is-arrayish": {
@ -5115,6 +5146,15 @@
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -5203,12 +5243,51 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -5291,6 +5370,15 @@
"node": ">=8"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -5590,6 +5678,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@ -5931,6 +6033,23 @@
"node": ">=10"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-length": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@ -6148,6 +6267,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undici": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.12.0.tgz",
@ -6239,6 +6377,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
@ -6356,6 +6500,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View file

@ -6,6 +6,7 @@
"dotenv": "^16.4.5",
"google-auth-library": "^10.1.0",
"lodash": "^4.17.21",
"multer": "^2.0.2",
"open": "^10.2.0",
"undici": "^7.12.0",
"uuid": "^11.1.0"

129
src/api-manager.js Normal file
View file

@ -0,0 +1,129 @@
import {
handleModelListRequest,
handleContentGenerationRequest,
API_ACTIONS,
ENDPOINT_TYPE
} from './common.js';
/**
* Handle API authentication and routing
* @param {string} method - The HTTP method
* @param {string} path - The request path
* @param {http.IncomingMessage} req - The HTTP request object
* @param {http.ServerResponse} res - The HTTP response object
* @param {Object} currentConfig - The current configuration object
* @param {Object} apiService - The API service instance
* @param {Object} providerPoolManager - The provider pool manager instance
* @param {string} promptLogFilename - The prompt log filename
* @returns {Promise<boolean>} - True if the request was handled by API
*/
export async function handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, promptLogFilename) {
// Health check endpoint
if (method === 'GET' && path === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'healthy',
timestamp: new Date().toISOString(),
provider: currentConfig.MODEL_PROVIDER
}));
return true;
}
// Ignore count_tokens requests
if (path.includes('/count_tokens')) {
console.log(`[Server] Ignoring count_tokens request: ${path}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
tokens: 0,
message: 'Token counting is not supported'
}));
return true;
}
// Route model list requests
if (method === 'GET') {
if (path === '/v1/models') {
await handleModelListRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_MODEL_LIST, currentConfig, providerPoolManager, currentConfig.uuid);
return true;
}
if (path === '/v1beta/models') {
await handleModelListRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_MODEL_LIST, currentConfig, providerPoolManager, currentConfig.uuid);
return true;
}
}
// Route content generation requests
if (method === 'POST') {
if (path === '/v1/chat/completions') {
await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_CHAT, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid);
return true;
}
if (path === '/v1/responses') {
await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_RESPONSES, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid);
return true;
}
const geminiUrlPattern = new RegExp(`/v1beta/models/(.+?):(${API_ACTIONS.GENERATE_CONTENT}|${API_ACTIONS.STREAM_GENERATE_CONTENT})`);
if (geminiUrlPattern.test(path)) {
await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_CONTENT, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid);
return true;
}
if (path === '/v1/messages') {
await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.CLAUDE_MESSAGE, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid);
return true;
}
}
return false;
}
/**
* Initialize API management features
* @param {Object} config - The server configuration
* @param {Object} services - The initialized services
* @param {Object} providerPoolManager - The provider pool manager instance
* @returns {Function} - The heartbeat and token refresh function
*/
export function initializeAPIManagement(config, services, providerPoolManager) {
return async function heartbeatAndRefreshToken() {
console.log(`[Heartbeat] Server is running. Current time: ${new Date().toLocaleString()}`);
// 循环遍历所有已初始化的服务适配器,并尝试刷新令牌
if (providerPoolManager) {
await providerPoolManager.performHealthChecks(); // 定期执行健康检查
}
for (const providerKey in services) {
const serviceAdapter = services[providerKey];
try {
// For pooled providers, refreshToken should be handled by individual instances
// For single instances, this remains relevant
await serviceAdapter.refreshToken();
// console.log(`[Token Refresh] Refreshed token for ${providerKey}`);
} catch (error) {
console.error(`[Token Refresh Error] Failed to refresh token for ${providerKey}: ${error.message}`);
// 如果是号池中的某个实例刷新失败,这里需要捕获并更新其状态
// 现有的 serviceInstances 存储的是每个配置对应的单例,而非池中的成员
// 这意味着如果一个池成员的 token 刷新失败,需要找到它并更新其在 poolManager 中的状态
// 暂时通过捕获错误日志来发现问题,更精细的控制需要在 refreshToken 中抛出更多信息
}
}
};
}
/**
* Helper function to read request body
* @param {http.IncomingMessage} req The HTTP request object.
* @returns {Promise<string>} The request body as string.
*/
export function readRequestBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
resolve(body);
});
req.on('error', err => {
reject(err);
});
});
}

View file

@ -1,3 +1,10 @@
import * as http from 'http';
import { initializeConfig, CONFIG, logProviderSpecificDetails } from './config-manager.js';
import { initApiService } from './service-manager.js';
import { initializeUIManagement } from './ui-manager.js';
import { initializeAPIManagement } from './api-manager.js';
import { createRequestHandler } from './request-handler.js';
/**
* @license
* Copyright 2025 Google LLC
@ -103,616 +110,32 @@
*
*/
import * as http from 'http';
import * as fs from 'fs'; // Import fs module
import { promises as pfs } from 'fs';
import 'dotenv/config'; // Import dotenv and configure it
import deepmerge from 'deepmerge';
import './converters/register-converters.js'; // 注册所有转换器
import { getServiceAdapter, serviceInstances } from './adapter.js';
import { ProviderPoolManager } from './provider-pool-manager.js';
import {
INPUT_SYSTEM_PROMPT_FILE,
API_ACTIONS,
MODEL_PROVIDER,
ENDPOINT_TYPE,
isAuthorized,
handleModelListRequest,
handleContentGenerationRequest,
handleError,
} from './common.js';
let CONFIG = {}; // Make CONFIG exportable
let PROMPT_LOG_FILENAME = ''; // Make PROMPT_LOG_FILENAME exportable
const ALL_MODEL_PROVIDERS = Object.values(MODEL_PROVIDER);
function normalizeConfiguredProviders(config) {
const fallbackProvider = MODEL_PROVIDER.GEMINI_CLI;
const dedupedProviders = [];
const addProvider = (value) => {
if (typeof value !== 'string') {
return;
}
const trimmed = value.trim();
if (!trimmed) {
return;
}
const matched = ALL_MODEL_PROVIDERS.find((provider) => provider.toLowerCase() === trimmed.toLowerCase());
if (!matched) {
console.warn(`[Config Warning] Unknown model provider '${trimmed}'. This entry will be ignored.`);
return;
}
if (!dedupedProviders.includes(matched)) {
dedupedProviders.push(matched);
}
};
const rawValue = config.MODEL_PROVIDER;
if (Array.isArray(rawValue)) {
rawValue.forEach((entry) => addProvider(typeof entry === 'string' ? entry : String(entry)));
} else if (typeof rawValue === 'string') {
rawValue.split(',').forEach(addProvider);
} else if (rawValue != null) {
addProvider(String(rawValue));
}
if (dedupedProviders.length === 0) {
dedupedProviders.push(fallbackProvider);
}
config.DEFAULT_MODEL_PROVIDERS = dedupedProviders;
config.MODEL_PROVIDER = dedupedProviders[0];
}
/**
* Initializes the server configuration from config.json and command-line arguments.
* @param {string[]} args - Command-line arguments.
* @param {string} [configFilePath='config.json'] - Path to the configuration file.
* @returns {Object} The initialized configuration object.
*/
async function initializeConfig(args = process.argv.slice(2), configFilePath = 'config.json') {
let currentConfig = {};
try {
const configData = fs.readFileSync(configFilePath, 'utf8');
currentConfig = JSON.parse(configData);
console.log('[Config] Loaded configuration from config.json');
} catch (error) {
console.error('[Config Error] Failed to load config.json:', error.message);
// Fallback to default values if config.json is not found or invalid
currentConfig = {
REQUIRED_API_KEY: "123456",
SERVER_PORT: 3000,
HOST: 'localhost',
MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI,
OPENAI_API_KEY: null,
OPENAI_BASE_URL: null,
CLAUDE_API_KEY: null,
CLAUDE_BASE_URL: null,
GEMINI_OAUTH_CREDS_BASE64: null,
GEMINI_OAUTH_CREDS_FILE_PATH: null,
KIRO_OAUTH_CREDS_BASE64: null,
KIRO_OAUTH_CREDS_FILE_PATH: null,
QWEN_OAUTH_CREDS_FILE_PATH: null,
PROJECT_ID: null,
SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value
SYSTEM_PROMPT_MODE: 'overwrite',
PROMPT_LOG_BASE_NAME: "prompt_log",
PROMPT_LOG_MODE: "none",
REQUEST_MAX_RETRIES: 3,
REQUEST_BASE_DELAY: 1000,
CRON_NEAR_MINUTES: 15,
CRON_REFRESH_TOKEN: true,
PROVIDER_POOLS_FILE_PATH: null // 新增号池配置文件路径
};
console.log('[Config] Using default configuration.');
}
// Parse command-line arguments
for (let i = 0; i < args.length; i++) {
if (args[i] === '--api-key') {
if (i + 1 < args.length) {
currentConfig.REQUIRED_API_KEY = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --api-key flag requires a value.`);
}
} else if (args[i] === '--log-prompts') {
if (i + 1 < args.length) {
const mode = args[i + 1];
if (mode === 'console' || mode === 'file') {
currentConfig.PROMPT_LOG_MODE = mode;
} else {
console.warn(`[Config Warning] Invalid mode for --log-prompts. Expected 'console' or 'file'. Prompt logging is disabled.`);
}
i++;
} else {
console.warn(`[Config Warning] --log-prompts flag requires a value.`);
}
} else if (args[i] === '--port') {
if (i + 1 < args.length) {
currentConfig.SERVER_PORT = parseInt(args[i + 1], 10);
i++;
} else {
console.warn(`[Config Warning] --port flag requires a value.`);
}
} else if (args[i] === '--model-provider') {
if (i + 1 < args.length) {
currentConfig.MODEL_PROVIDER = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --model-provider flag requires a value.`);
}
} else if (args[i] === '--openai-api-key') {
if (i + 1 < args.length) {
currentConfig.OPENAI_API_KEY = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --openai-api-key flag requires a value.`);
}
} else if (args[i] === '--openai-base-url') {
if (i + 1 < args.length) {
currentConfig.OPENAI_BASE_URL = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --openai-base-url flag requires a value.`);
}
} else if (args[i] === '--claude-api-key') {
if (i + 1 < args.length) {
currentConfig.CLAUDE_API_KEY = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --claude-api-key flag requires a value.`);
}
} else if (args[i] === '--claude-base-url') {
if (i + 1 < args.length) {
currentConfig.CLAUDE_BASE_URL = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --claude-base-url flag requires a value.`);
}
}
// Gemini-specific arguments
else if (args[i] === '--gemini-oauth-creds-base64') {
if (i + 1 < args.length) {
currentConfig.GEMINI_OAUTH_CREDS_BASE64 = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --gemini-oauth-creds-base64 flag requires a value.`);
}
} else if (args[i] === '--gemini-oauth-creds-file') {
if (i + 1 < args.length) {
currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --gemini-oauth-creds-file flag requires a value.`);
}
} else if (args[i] === '--project-id') {
if (i + 1 < args.length) {
currentConfig.PROJECT_ID = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --project-id flag requires a value.`);
}
} else if (args[i] === '--system-prompt-file') {
if (i + 1 < args.length) {
currentConfig.SYSTEM_PROMPT_FILE_PATH = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --system-prompt-file flag requires a value.`);
}
} else if (args[i] === '--system-prompt-mode') {
if (i + 1 < args.length) {
const mode = args[i + 1];
if (mode === 'overwrite' || mode === 'append') {
currentConfig.SYSTEM_PROMPT_MODE = mode;
} else {
console.warn(`[Config Warning] Invalid mode for --system-prompt-mode. Expected 'overwrite' or 'append'. Using default 'overwrite'.`);
}
i++;
} else {
console.warn(`[Config Warning] --system-prompt-mode flag requires a value.`);
}
} else if (args[i] === '--host') {
if (i + 1 < args.length) {
currentConfig.HOST = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --host flag requires a value.`);
}
} else if (args[i] === '--prompt-log-base-name') {
if (i + 1 < args.length) {
currentConfig.PROMPT_LOG_BASE_NAME = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --prompt-log-base-name flag requires a value.`);
}
} else if (args[i] === '--kiro-oauth-creds-base64') {
if (i + 1 < args.length) {
currentConfig.KIRO_OAUTH_CREDS_BASE64 = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --kiro-oauth-creds-base64 flag requires a value.`);
}
} else if (args[i] === '--kiro-oauth-creds-file') {
if (i + 1 < args.length) {
currentConfig.KIRO_OAUTH_CREDS_FILE_PATH = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --kiro-oauth-creds-file flag requires a value.`);
}
} else if (args[i] === '--qwen-oauth-creds-file') {
if (i + 1 < args.length) {
currentConfig.QWEN_OAUTH_CREDS_FILE_PATH = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --qwen-oauth-creds-file flag requires a value.`);
}
} else if (args[i] === '--cron-near-minutes') {
if (i + 1 < args.length) {
currentConfig.CRON_NEAR_MINUTES = parseInt(args[i + 1], 10);
i++;
} else {
console.warn(`[Config Warning] --cron-near-minutes flag requires a value.`);
}
} else if (args[i] === '--cron-refresh-token') {
if (i + 1 < args.length) {
currentConfig.CRON_REFRESH_TOKEN = args[i + 1].toLowerCase() === 'true';
i++;
} else {
console.warn(`[Config Warning] --cron-refresh-token flag requires a value.`);
}
} else if (args[i] === '--provider-pools-file') {
if (i + 1 < args.length) {
currentConfig.PROVIDER_POOLS_FILE_PATH = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --provider-pools-file flag requires a value.`);
}
}
}
normalizeConfiguredProviders(currentConfig);
if (!currentConfig.SYSTEM_PROMPT_FILE_PATH) {
currentConfig.SYSTEM_PROMPT_FILE_PATH = INPUT_SYSTEM_PROMPT_FILE;
}
currentConfig.SYSTEM_PROMPT_CONTENT = await getSystemPromptFileContent(currentConfig.SYSTEM_PROMPT_FILE_PATH);
// 加载号池配置
if (currentConfig.PROVIDER_POOLS_FILE_PATH) {
try {
const poolsData = await pfs.readFile(currentConfig.PROVIDER_POOLS_FILE_PATH, 'utf8');
currentConfig.providerPools = JSON.parse(poolsData);
console.log(`[Config] Loaded provider pools from ${currentConfig.PROVIDER_POOLS_FILE_PATH}`);
} catch (error) {
console.error(`[Config Error] Failed to load provider pools from ${currentConfig.PROVIDER_POOLS_FILE_PATH}: ${error.message}`);
currentConfig.providerPools = {};
}
} else {
currentConfig.providerPools = {};
}
// Set PROMPT_LOG_FILENAME based on the determined config
if (currentConfig.PROMPT_LOG_MODE === 'file') {
const now = new Date();
const pad = (num) => String(num).padStart(2, '0');
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
PROMPT_LOG_FILENAME = `${currentConfig.PROMPT_LOG_BASE_NAME}-${timestamp}.log`;
} else {
PROMPT_LOG_FILENAME = ''; // Clear if not logging to file
}
// Assign to the exported CONFIG
Object.assign(CONFIG, currentConfig);
return CONFIG;
}
/**
* Gets system prompt content from the specified file path.
* @param {string} filePath - Path to the system prompt file.
* @returns {Promise<string|null>} File content, or null if the file does not exist, is empty, or an error occurs.
*/
async function getSystemPromptFileContent(filePath) {
try {
await pfs.access(filePath, pfs.constants.F_OK);
} catch (error) {
if (error.code === 'ENOENT') {
console.warn(`[System Prompt] Specified system prompt file not found: ${filePath}`);
} else {
console.error(`[System Prompt] Error accessing system prompt file ${filePath}: ${error.message}`);
}
return null;
}
try {
const content = await pfs.readFile(filePath, 'utf8');
if (!content.trim()) {
return null;
}
console.log(`[System Prompt] Loaded system prompt from ${filePath}`);
return content;
} catch (error) {
console.error(`[System Prompt] Error reading system prompt file ${filePath}: ${error.message}`);
return null;
}
}
// 存储 ProviderPoolManager 实例
let providerPoolManager = null;
async function initApiService(config) {
if (config.providerPools && Object.keys(config.providerPools).length > 0) {
providerPoolManager = new ProviderPoolManager(config.providerPools, { globalConfig: config });
console.log('[Initialization] ProviderPoolManager initialized with configured pools.');
// 健康检查将在服务器完全启动后执行
} else {
console.log('[Initialization] No provider pools configured. Using single provider mode.');
}
// Initialize configured service adapters at startup
// 对于未纳入号池的提供者,提前初始化以避免首个请求的额外延迟
const providersToInit = new Set();
if (Array.isArray(config.DEFAULT_MODEL_PROVIDERS)) {
config.DEFAULT_MODEL_PROVIDERS.forEach((provider) => providersToInit.add(provider));
}
if (config.providerPools) {
Object.keys(config.providerPools).forEach((provider) => providersToInit.add(provider));
}
if (providersToInit.size === 0) {
ALL_MODEL_PROVIDERS.forEach((provider) => providersToInit.add(provider));
}
for (const provider of providersToInit) {
if (!ALL_MODEL_PROVIDERS.includes(provider)) {
console.warn(`[Initialization Warning] Skipping unknown model provider '${provider}' during adapter initialization.`);
continue;
}
if (config.providerPools && config.providerPools[provider] && config.providerPools[provider].length > 0) {
// 由号池管理器负责按需初始化
continue;
}
try {
console.log(`[Initialization] Initializing single service adapter for ${provider}...`);
getServiceAdapter({ ...config, MODEL_PROVIDER: provider });
} catch (error) {
console.warn(`[Initialization Warning] Failed to initialize single service adapter for ${provider}: ${error.message}`);
}
}
return serviceInstances; // Return the collection of initialized service instances
}
function logProviderSpecificDetails(provider, config) {
switch (provider) {
case MODEL_PROVIDER.OPENAI_CUSTOM:
console.log(` [openai-custom] API Key: ${config.OPENAI_API_KEY ? '******' : 'Not Set'}`);
console.log(` [openai-custom] Base URL: ${config.OPENAI_BASE_URL || 'Default'}`);
break;
case MODEL_PROVIDER.CLAUDE_CUSTOM:
console.log(` [claude-custom] API Key: ${config.CLAUDE_API_KEY ? '******' : 'Not Set'}`);
console.log(` [claude-custom] Base URL: ${config.CLAUDE_BASE_URL || 'Default'}`);
break;
case MODEL_PROVIDER.GEMINI_CLI:
if (config.GEMINI_OAUTH_CREDS_FILE_PATH) {
console.log(` [gemini-cli-oauth] OAuth Creds File Path: ${config.GEMINI_OAUTH_CREDS_FILE_PATH}`);
} else if (config.GEMINI_OAUTH_CREDS_BASE64) {
console.log(` [gemini-cli-oauth] OAuth Creds Source: Provided via Base64 string`);
} else {
console.log(` [gemini-cli-oauth] OAuth Creds: Default discovery`);
}
// console.log(` [gemini-cli-oauth] Project ID: ${config.PROJECT_ID || 'Auto-discovered'}`);
break;
case MODEL_PROVIDER.KIRO_API:
if (config.KIRO_OAUTH_CREDS_FILE_PATH) {
console.log(` [claude-kiro-oauth] OAuth Creds File Path: ${config.KIRO_OAUTH_CREDS_FILE_PATH}`);
} else if (config.KIRO_OAUTH_CREDS_BASE64) {
console.log(` [claude-kiro-oauth] OAuth Creds Source: Provided via Base64 string`);
} else {
console.log(` [claude-kiro-oauth] OAuth Creds: Default`);
}
break;
case MODEL_PROVIDER.QWEN_API:
console.log(` [openai-qwen-oauth] OAuth Creds File Path: ${config.QWEN_OAUTH_CREDS_FILE_PATH || 'Default'}`);
break;
default:
console.log(` [${provider}] Provider initialized.`);
}
}
async function getApiService(config) {
let serviceConfig = config;
if (providerPoolManager && config.providerPools && config.providerPools[config.MODEL_PROVIDER]) {
// 如果有号池管理器,并且当前模型提供者类型有对应的号池,则从号池中选择一个提供者配置
const selectedProviderConfig = providerPoolManager.selectProvider(config.MODEL_PROVIDER);
if (selectedProviderConfig) {
// 合并选中的提供者配置到当前请求的 config 中
serviceConfig = deepmerge(config, selectedProviderConfig);
delete serviceConfig.providerPools; // 移除 providerPools 属性
config.uuid = serviceConfig.uuid;
console.log(`[API Service] Using pooled configuration for ${config.MODEL_PROVIDER}: ${serviceConfig.uuid}`);
} else {
console.warn(`[API Service] No healthy provider found in pool for ${config.MODEL_PROVIDER}. Falling back to main config.`);
}
}
return getServiceAdapter(serviceConfig);
}
/**
* Main request handler. It authenticates the request, determines the endpoint type,
* and delegates to the appropriate specialized handler function.
* @param {http.IncomingMessage} req The HTTP request object.
* @param {http.ServerResponse} res The HTTP response object.
* @param {Object} currentConfig The current configuration object.
* @param {string} currentPromptLogFilename The current prompt log filename.
* @param {Object} apiService The initialized API service instance.
*/
function createRequestHandler(config) {
return async function requestHandler(req, res) {
// Deep copy the config for each request to allow dynamic modification
const currentConfig = deepmerge({}, config);
console.log(`\n${new Date().toLocaleString()}`);
console.log(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`);
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
let path = requestUrl.pathname;
const method = req.method;
if (method === 'OPTIONS') {
// 设置 CORS 头部,允许所有来源和方法
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-goog-api-key, Model-Provider'); // 添加 Model-Provider
// OPTIONS 请求通常返回 204 No Content
res.writeHead(204);
res.end();
return;
}
// Health check endpoint - no authentication required
if (method === 'GET' && path === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({
status: 'healthy',
timestamp: new Date().toISOString(),
provider: currentConfig.MODEL_PROVIDER
}));
}
// Ignore count_tokens requests
if (path.includes('/count_tokens')) {
console.log(`[Server] Ignoring count_tokens request: ${path}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({
tokens: 0,
message: 'Token counting is not supported'
}));
}
// Allow overriding MODEL_PROVIDER via request header
const modelProviderHeader = req.headers['model-provider'];
if (modelProviderHeader) {
currentConfig.MODEL_PROVIDER = modelProviderHeader;
console.log(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`);
//delete req.headers['model-provider']; // 保持不变,以便后端可以继续处理原始头
}
// Check if the first path segment matches a MODEL_PROVIDER and switch if it does
const pathSegments = path.split('/').filter(segment => segment.length > 0);
if (pathSegments.length > 0) {
const firstSegment = pathSegments[0];
// Check if firstSegment is a valid MODEL_PROVIDER value
const isValidProvider = Object.values(MODEL_PROVIDER).includes(firstSegment);
if (firstSegment && isValidProvider) {
currentConfig.MODEL_PROVIDER = firstSegment;
console.log(`[Config] MODEL_PROVIDER overridden by path segment to: ${currentConfig.MODEL_PROVIDER}`);
// Remove the first segment from the path to maintain routing consistency
pathSegments.shift();
path = '/' + pathSegments.join('/');
// Update the requestUrl pathname as well
requestUrl.pathname = path;
} else if (firstSegment && !isValidProvider) {
console.log(`[Config] Ignoring invalid MODEL_PROVIDER in path segment: ${firstSegment}`);
}
}
// 获取或选择 API Service 实例
let apiService;
try {
apiService = await getApiService(currentConfig);
} catch (error) {
handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` });
if (providerPoolManager) {
// 如果是号池模式,并且获取服务失败,则标记当前使用的提供者为不健康
// 这里需要一种机制来知道是哪个具体的号池成员导致了失败。
// 暂时简单的假设是 currentConfig 中包含的凭据就是来自号池选择的。
providerPoolManager.markProviderUnhealthy(currentConfig.MODEL_PROVIDER, {
uuid: currentConfig.uuid
});
}
return;
}
if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
}
try {
// Route model list requests
if (method === 'GET') {
if (path === '/v1/models') {
return await handleModelListRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_MODEL_LIST, currentConfig, providerPoolManager, currentConfig.uuid);
}
if (path === '/v1beta/models') {
return await handleModelListRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_MODEL_LIST, currentConfig, providerPoolManager, currentConfig.uuid);
}
}
// Route content generation requests
if (method === 'POST') {
if (path === '/v1/chat/completions') {
return await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_CHAT, currentConfig, PROMPT_LOG_FILENAME, providerPoolManager, currentConfig.uuid);
}
if (path === '/v1/responses') {
return await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_RESPONSES, currentConfig, PROMPT_LOG_FILENAME, providerPoolManager, currentConfig.uuid);
}
const geminiUrlPattern = new RegExp(`/v1beta/models/(.+?):(${API_ACTIONS.GENERATE_CONTENT}|${API_ACTIONS.STREAM_GENERATE_CONTENT})`);
if (geminiUrlPattern.test(path)) {
return await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_CONTENT, currentConfig, PROMPT_LOG_FILENAME, providerPoolManager, currentConfig.uuid);
}
if (path === '/v1/messages') {
return await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.CLAUDE_MESSAGE, currentConfig, PROMPT_LOG_FILENAME, providerPoolManager, currentConfig.uuid);
}
}
// Fallback for unmatched routes
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Not Found' } }));
} catch (error) {
handleError(res, error);
}
};
}
// 导入各个模块功能
import { getProviderPoolManager } from './service-manager.js';
// --- Server Initialization ---
async function startServer() {
await initializeConfig(); // Initialize CONFIG globally
const services = await initApiService(CONFIG); // Get service instance with the initialized CONFIG
const requestHandlerInstance = createRequestHandler(CONFIG); // Create request handler with CONFIG and service
// 定义心跳和令牌刷新函数
const heartbeatAndRefreshToken = async () => {
console.log(`[Heartbeat] Server is running. Current time: ${new Date().toLocaleString()}`);
// 循环遍历所有已初始化的服务适配器,并尝试刷新令牌
if (providerPoolManager) {
await providerPoolManager.performHealthChecks(); // 定期执行健康检查
}
for (const providerKey in services) {
const serviceAdapter = services[providerKey];
try {
// For pooled providers, refreshToken should be handled by individual instances
// For single instances, this remains relevant
await serviceAdapter.refreshToken();
// console.log(`[Token Refresh] Refreshed token for ${providerKey}`);
} catch (error) {
console.error(`[Token Refresh Error] Failed to refresh token for ${providerKey}: ${error.message}`);
// 如果是号池中的某个实例刷新失败,这里需要捕获并更新其状态
// 现有的 serviceInstances 存储的是每个配置对应的单例,而非池中的成员
// 这意味着如果一个池成员的 token 刷新失败,需要找到它并更新其在 poolManager 中的状态
// 暂时通过捕获错误日志来发现问题,更精细的控制需要在 refreshToken 中抛出更多信息
}
}
};
// Initialize configuration
await initializeConfig();
// Initialize API services
const services = await initApiService(CONFIG);
// Initialize UI management features
initializeUIManagement(CONFIG);
// Initialize API management and get heartbeat function
const heartbeatAndRefreshToken = initializeAPIManagement(CONFIG, services, getProviderPoolManager());
// Create request handler
const requestHandlerInstance = createRequestHandler(CONFIG, getProviderPoolManager());
const server = http.createServer(requestHandlerInstance);
server.listen(CONFIG.SERVER_PORT, CONFIG.HOST, () => {
server.listen(CONFIG.SERVER_PORT, CONFIG.HOST, async () => {
console.log(`--- Unified API Server Configuration ---`);
const configuredProviders = Array.isArray(CONFIG.DEFAULT_MODEL_PROVIDERS) && CONFIG.DEFAULT_MODEL_PROVIDERS.length > 0
? CONFIG.DEFAULT_MODEL_PROVIDERS
@ -728,7 +151,7 @@ async function startServer() {
console.log(` Host: ${CONFIG.HOST}`);
console.log(` Port: ${CONFIG.SERVER_PORT}`);
console.log(` Required API Key: ${CONFIG.REQUIRED_API_KEY}`);
console.log(` Prompt Logging: ${CONFIG.PROMPT_LOG_MODE}${PROMPT_LOG_FILENAME ? ` (to ${PROMPT_LOG_FILENAME})` : ''}`);
console.log(` Prompt Logging: ${CONFIG.PROMPT_LOG_MODE}${CONFIG.PROMPT_LOG_FILENAME ? ` (to ${CONFIG.PROMPT_LOG_FILENAME})` : ''}`);
console.log(`------------------------------------------`);
console.log(`\nUnified API Server running on http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}`);
console.log(`Supports multiple API formats:`);
@ -736,6 +159,25 @@ async function startServer() {
console.log(` • Gemini-compatible: /v1beta/models, /v1beta/models/{model}:generateContent`);
console.log(` • Claude-compatible: /v1/messages`);
console.log(` • Health check: /health`);
console.log(` • UI Management Console: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/`);
// Auto-open browser to UI (only if host is localhost or 127.0.0.1)
// if (CONFIG.HOST === 'localhost' || CONFIG.HOST === '127.0.0.1') {
try {
const open = (await import('open')).default;
setTimeout(() => {
open(`http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/`)
.then(() => {
console.log('[UI] Opened management console in default browser');
})
.catch(err => {
console.log('[UI] Please open manually: http://' + CONFIG.HOST + ':' + CONFIG.SERVER_PORT + '/');
});
}, 1000);
} catch (err) {
console.log(`[UI] Management console available at: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/`);
}
// }
if (CONFIG.CRON_REFRESH_TOKEN) {
console.log(` • Cron Near Minutes: ${CONFIG.CRON_NEAR_MINUTES}`);
@ -744,15 +186,15 @@ async function startServer() {
setInterval(heartbeatAndRefreshToken, CONFIG.CRON_NEAR_MINUTES * 60 * 1000);
}
// 服务器完全启动后,执行初始健康检查
if (providerPoolManager) {
const poolManager = getProviderPoolManager();
if (poolManager) {
console.log('[Initialization] Performing initial health checks for provider pools...');
providerPoolManager.performHealthChecks(true);
poolManager.performHealthChecks(true);
}
});
return server; // Return the server instance for testing purposes
}
startServer().catch(err => {
console.error("[Server] Failed to start server:", err.message);
process.exit(1);

365
src/config-manager.js Normal file
View file

@ -0,0 +1,365 @@
import * as fs from 'fs';
import { promises as pfs } from 'fs';
import { INPUT_SYSTEM_PROMPT_FILE, MODEL_PROVIDER } from './common.js';
export let CONFIG = {}; // Make CONFIG exportable
export let PROMPT_LOG_FILENAME = ''; // Make PROMPT_LOG_FILENAME exportable
const ALL_MODEL_PROVIDERS = Object.values(MODEL_PROVIDER);
function normalizeConfiguredProviders(config) {
const fallbackProvider = MODEL_PROVIDER.GEMINI_CLI;
const dedupedProviders = [];
const addProvider = (value) => {
if (typeof value !== 'string') {
return;
}
const trimmed = value.trim();
if (!trimmed) {
return;
}
const matched = ALL_MODEL_PROVIDERS.find((provider) => provider.toLowerCase() === trimmed.toLowerCase());
if (!matched) {
console.warn(`[Config Warning] Unknown model provider '${trimmed}'. This entry will be ignored.`);
return;
}
if (!dedupedProviders.includes(matched)) {
dedupedProviders.push(matched);
}
};
const rawValue = config.MODEL_PROVIDER;
if (Array.isArray(rawValue)) {
rawValue.forEach((entry) => addProvider(typeof entry === 'string' ? entry : String(entry)));
} else if (typeof rawValue === 'string') {
rawValue.split(',').forEach(addProvider);
} else if (rawValue != null) {
addProvider(String(rawValue));
}
if (dedupedProviders.length === 0) {
dedupedProviders.push(fallbackProvider);
}
config.DEFAULT_MODEL_PROVIDERS = dedupedProviders;
config.MODEL_PROVIDER = dedupedProviders[0];
}
/**
* Initializes the server configuration from config.json and command-line arguments.
* @param {string[]} args - Command-line arguments.
* @param {string} [configFilePath='config.json'] - Path to the configuration file.
* @returns {Object} The initialized configuration object.
*/
export async function initializeConfig(args = process.argv.slice(2), configFilePath = 'config.json') {
let currentConfig = {};
try {
const configData = fs.readFileSync(configFilePath, 'utf8');
currentConfig = JSON.parse(configData);
console.log('[Config] Loaded configuration from config.json');
} catch (error) {
console.error('[Config Error] Failed to load config.json:', error.message);
// Fallback to default values if config.json is not found or invalid
currentConfig = {
REQUIRED_API_KEY: "123456",
SERVER_PORT: 3000,
HOST: 'localhost',
MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI,
OPENAI_API_KEY: null,
OPENAI_BASE_URL: null,
CLAUDE_API_KEY: null,
CLAUDE_BASE_URL: null,
GEMINI_OAUTH_CREDS_BASE64: null,
GEMINI_OAUTH_CREDS_FILE_PATH: null,
KIRO_OAUTH_CREDS_BASE64: null,
KIRO_OAUTH_CREDS_FILE_PATH: null,
QWEN_OAUTH_CREDS_FILE_PATH: null,
PROJECT_ID: null,
SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value
SYSTEM_PROMPT_MODE: 'overwrite',
PROMPT_LOG_BASE_NAME: "prompt_log",
PROMPT_LOG_MODE: "none",
REQUEST_MAX_RETRIES: 3,
REQUEST_BASE_DELAY: 1000,
CRON_NEAR_MINUTES: 15,
CRON_REFRESH_TOKEN: true,
PROVIDER_POOLS_FILE_PATH: null // 新增号池配置文件路径
};
console.log('[Config] Using default configuration.');
}
// Parse command-line arguments
for (let i = 0; i < args.length; i++) {
if (args[i] === '--api-key') {
if (i + 1 < args.length) {
currentConfig.REQUIRED_API_KEY = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --api-key flag requires a value.`);
}
} else if (args[i] === '--log-prompts') {
if (i + 1 < args.length) {
const mode = args[i + 1];
if (mode === 'console' || mode === 'file') {
currentConfig.PROMPT_LOG_MODE = mode;
} else {
console.warn(`[Config Warning] Invalid mode for --log-prompts. Expected 'console' or 'file'. Prompt logging is disabled.`);
}
i++;
} else {
console.warn(`[Config Warning] --log-prompts flag requires a value.`);
}
} else if (args[i] === '--port') {
if (i + 1 < args.length) {
currentConfig.SERVER_PORT = parseInt(args[i + 1], 10);
i++;
} else {
console.warn(`[Config Warning] --port flag requires a value.`);
}
} else if (args[i] === '--model-provider') {
if (i + 1 < args.length) {
currentConfig.MODEL_PROVIDER = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --model-provider flag requires a value.`);
}
} else if (args[i] === '--openai-api-key') {
if (i + 1 < args.length) {
currentConfig.OPENAI_API_KEY = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --openai-api-key flag requires a value.`);
}
} else if (args[i] === '--openai-base-url') {
if (i + 1 < args.length) {
currentConfig.OPENAI_BASE_URL = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --openai-base-url flag requires a value.`);
}
} else if (args[i] === '--claude-api-key') {
if (i + 1 < args.length) {
currentConfig.CLAUDE_API_KEY = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --claude-api-key flag requires a value.`);
}
} else if (args[i] === '--claude-base-url') {
if (i + 1 < args.length) {
currentConfig.CLAUDE_BASE_URL = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --claude-base-url flag requires a value.`);
}
}
// Gemini-specific arguments
else if (args[i] === '--gemini-oauth-creds-base64') {
if (i + 1 < args.length) {
currentConfig.GEMINI_OAUTH_CREDS_BASE64 = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --gemini-oauth-creds-base64 flag requires a value.`);
}
} else if (args[i] === '--gemini-oauth-creds-file') {
if (i + 1 < args.length) {
currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --gemini-oauth-creds-file flag requires a value.`);
}
} else if (args[i] === '--project-id') {
if (i + 1 < args.length) {
currentConfig.PROJECT_ID = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --project-id flag requires a value.`);
}
} else if (args[i] === '--system-prompt-file') {
if (i + 1 < args.length) {
currentConfig.SYSTEM_PROMPT_FILE_PATH = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --system-prompt-file flag requires a value.`);
}
} else if (args[i] === '--system-prompt-mode') {
if (i + 1 < args.length) {
const mode = args[i + 1];
if (mode === 'overwrite' || mode === 'append') {
currentConfig.SYSTEM_PROMPT_MODE = mode;
} else {
console.warn(`[Config Warning] Invalid mode for --system-prompt-mode. Expected 'overwrite' or 'append'. Using default 'overwrite'.`);
}
i++;
} else {
console.warn(`[Config Warning] --system-prompt-mode flag requires a value.`);
}
} else if (args[i] === '--host') {
if (i + 1 < args.length) {
currentConfig.HOST = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --host flag requires a value.`);
}
} else if (args[i] === '--prompt-log-base-name') {
if (i + 1 < args.length) {
currentConfig.PROMPT_LOG_BASE_NAME = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --prompt-log-base-name flag requires a value.`);
}
} else if (args[i] === '--kiro-oauth-creds-base64') {
if (i + 1 < args.length) {
currentConfig.KIRO_OAUTH_CREDS_BASE64 = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --kiro-oauth-creds-base64 flag requires a value.`);
}
} else if (args[i] === '--kiro-oauth-creds-file') {
if (i + 1 < args.length) {
currentConfig.KIRO_OAUTH_CREDS_FILE_PATH = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --kiro-oauth-creds-file flag requires a value.`);
}
} else if (args[i] === '--qwen-oauth-creds-file') {
if (i + 1 < args.length) {
currentConfig.QWEN_OAUTH_CREDS_FILE_PATH = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --qwen-oauth-creds-file flag requires a value.`);
}
} else if (args[i] === '--cron-near-minutes') {
if (i + 1 < args.length) {
currentConfig.CRON_NEAR_MINUTES = parseInt(args[i + 1], 10);
i++;
} else {
console.warn(`[Config Warning] --cron-near-minutes flag requires a value.`);
}
} else if (args[i] === '--cron-refresh-token') {
if (i + 1 < args.length) {
currentConfig.CRON_REFRESH_TOKEN = args[i + 1].toLowerCase() === 'true';
i++;
} else {
console.warn(`[Config Warning] --cron-refresh-token flag requires a value.`);
}
} else if (args[i] === '--provider-pools-file') {
if (i + 1 < args.length) {
currentConfig.PROVIDER_POOLS_FILE_PATH = args[i + 1];
i++;
} else {
console.warn(`[Config Warning] --provider-pools-file flag requires a value.`);
}
}
}
normalizeConfiguredProviders(currentConfig);
if (!currentConfig.SYSTEM_PROMPT_FILE_PATH) {
currentConfig.SYSTEM_PROMPT_FILE_PATH = INPUT_SYSTEM_PROMPT_FILE;
}
currentConfig.SYSTEM_PROMPT_CONTENT = await getSystemPromptFileContent(currentConfig.SYSTEM_PROMPT_FILE_PATH);
// 加载号池配置
if (currentConfig.PROVIDER_POOLS_FILE_PATH) {
try {
const poolsData = await pfs.readFile(currentConfig.PROVIDER_POOLS_FILE_PATH, 'utf8');
currentConfig.providerPools = JSON.parse(poolsData);
console.log(`[Config] Loaded provider pools from ${currentConfig.PROVIDER_POOLS_FILE_PATH}`);
} catch (error) {
console.error(`[Config Error] Failed to load provider pools from ${currentConfig.PROVIDER_POOLS_FILE_PATH}: ${error.message}`);
currentConfig.providerPools = {};
}
} else {
currentConfig.providerPools = {};
}
// Set PROMPT_LOG_FILENAME based on the determined config
if (currentConfig.PROMPT_LOG_MODE === 'file') {
const now = new Date();
const pad = (num) => String(num).padStart(2, '0');
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
PROMPT_LOG_FILENAME = `${currentConfig.PROMPT_LOG_BASE_NAME}-${timestamp}.log`;
} else {
PROMPT_LOG_FILENAME = ''; // Clear if not logging to file
}
// Assign to the exported CONFIG
Object.assign(CONFIG, currentConfig);
return CONFIG;
}
/**
* Gets system prompt content from the specified file path.
* @param {string} filePath - Path to the system prompt file.
* @returns {Promise<string|null>} File content, or null if the file does not exist, is empty, or an error occurs.
*/
export async function getSystemPromptFileContent(filePath) {
try {
await pfs.access(filePath, pfs.constants.F_OK);
} catch (error) {
if (error.code === 'ENOENT') {
console.warn(`[System Prompt] Specified system prompt file not found: ${filePath}`);
} else {
console.error(`[System Prompt] Error accessing system prompt file ${filePath}: ${error.message}`);
}
return null;
}
try {
const content = await pfs.readFile(filePath, 'utf8');
if (!content.trim()) {
return null;
}
console.log(`[System Prompt] Loaded system prompt from ${filePath}`);
return content;
} catch (error) {
console.error(`[System Prompt] Error reading system prompt file ${filePath}: ${error.message}`);
return null;
}
}
/**
* Logs provider-specific configuration details
* @param {string} provider - The model provider
* @param {Object} config - The configuration object
*/
export function logProviderSpecificDetails(provider, config) {
switch (provider) {
case MODEL_PROVIDER.OPENAI_CUSTOM:
console.log(` [openai-custom] API Key: ${config.OPENAI_API_KEY ? '******' : 'Not Set'}`);
console.log(` [openai-custom] Base URL: ${config.OPENAI_BASE_URL || 'Default'}`);
break;
case MODEL_PROVIDER.CLAUDE_CUSTOM:
console.log(` [claude-custom] API Key: ${config.CLAUDE_API_KEY ? '******' : 'Not Set'}`);
console.log(` [claude-custom] Base URL: ${config.CLAUDE_BASE_URL || 'Default'}`);
break;
case MODEL_PROVIDER.GEMINI_CLI:
if (config.GEMINI_OAUTH_CREDS_FILE_PATH) {
console.log(` [gemini-cli-oauth] OAuth Creds File Path: ${config.GEMINI_OAUTH_CREDS_FILE_PATH}`);
} else if (config.GEMINI_OAUTH_CREDS_BASE64) {
console.log(` [gemini-cli-oauth] OAuth Creds Source: Provided via Base64 string`);
} else {
console.log(` [gemini-cli-oauth] OAuth Creds: Default discovery`);
}
// console.log(` [gemini-cli-oauth] Project ID: ${config.PROJECT_ID || 'Auto-discovered'}`);
break;
case MODEL_PROVIDER.KIRO_API:
if (config.KIRO_OAUTH_CREDS_FILE_PATH) {
console.log(` [claude-kiro-oauth] OAuth Creds File Path: ${config.KIRO_OAUTH_CREDS_FILE_PATH}`);
} else if (config.KIRO_OAUTH_CREDS_BASE64) {
console.log(` [claude-kiro-oauth] OAuth Creds Source: Provided via Base64 string`);
} else {
console.log(` [claude-kiro-oauth] OAuth Creds: Default`);
}
break;
case MODEL_PROVIDER.QWEN_API:
console.log(` [openai-qwen-oauth] OAuth Creds File Path: ${config.QWEN_OAUTH_CREDS_FILE_PATH || 'Default'}`);
break;
default:
console.log(` [${provider}] Provider initialized.`);
}
}
export { ALL_MODEL_PROVIDERS };

105
src/request-handler.js Normal file
View file

@ -0,0 +1,105 @@
import deepmerge from 'deepmerge';
import { handleError, isAuthorized } from './common.js';
import { handleUIApiRequests, serveStaticFiles } from './ui-manager.js';
import { handleAPIRequests } from './api-manager.js';
import { getApiService } from './service-manager.js';
import { getProviderPoolManager } from './service-manager.js';
import { MODEL_PROVIDER } from './common.js';
import { PROMPT_LOG_FILENAME } from './config-manager.js';
/**
* Main request handler. It authenticates the request, determines the endpoint type,
* and delegates to the appropriate specialized handler function.
* @param {Object} config - The server configuration
* @param {Object} providerPoolManager - The provider pool manager instance
* @returns {Function} - The request handler function
*/
export function createRequestHandler(config, providerPoolManager) {
return async function requestHandler(req, res) {
// Deep copy the config for each request to allow dynamic modification
const currentConfig = deepmerge({}, config);
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
let path = requestUrl.pathname;
const method = req.method;
// Handle CORS preflight requests
if (method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-goog-api-key, Model-Provider');
res.writeHead(204);
res.end();
return;
}
// Serve static files for UI
if (path.startsWith('/static/') || path === '/' || path === '/index.html' || path.startsWith('/app/')) {
const served = await serveStaticFiles(path, res);
if (served) return;
}
// Handle UI management API requests
const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
if (uiHandled) return;
console.log(`\n${new Date().toLocaleString()}`);
console.log(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`);
// Handle API requests
// Allow overriding MODEL_PROVIDER via request header
const modelProviderHeader = req.headers['model-provider'];
if (modelProviderHeader) {
currentConfig.MODEL_PROVIDER = modelProviderHeader;
console.log(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`);
}
// Check if the first path segment matches a MODEL_PROVIDER and switch if it does
const pathSegments = path.split('/').filter(segment => segment.length > 0);
if (pathSegments.length > 0) {
const firstSegment = pathSegments[0];
const isValidProvider = Object.values(MODEL_PROVIDER).includes(firstSegment);
if (firstSegment && isValidProvider) {
currentConfig.MODEL_PROVIDER = firstSegment;
console.log(`[Config] MODEL_PROVIDER overridden by path segment to: ${currentConfig.MODEL_PROVIDER}`);
pathSegments.shift();
path = '/' + pathSegments.join('/');
requestUrl.pathname = path;
} else if (firstSegment && !isValidProvider) {
console.log(`[Config] Ignoring invalid MODEL_PROVIDER in path segment: ${firstSegment}`);
}
}
// 获取或选择 API Service 实例
let apiService;
try {
apiService = await getApiService(currentConfig);
} catch (error) {
handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` });
const poolManager = getProviderPoolManager();
if (poolManager) {
poolManager.markProviderUnhealthy(currentConfig.MODEL_PROVIDER, {
uuid: currentConfig.uuid
});
}
return;
}
// Check authentication for API requests
if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
return;
}
try {
// Handle API requests
const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME);
if (apiHandled) return;
// Fallback for unmatched routes
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Not Found' } }));
} catch (error) {
handleError(res, error);
}
};
}

96
src/service-manager.js Normal file
View file

@ -0,0 +1,96 @@
import { getServiceAdapter, serviceInstances } from './adapter.js';
import { ProviderPoolManager } from './provider-pool-manager.js';
import deepmerge from 'deepmerge';
// 存储 ProviderPoolManager 实例
let providerPoolManager = null;
/**
* Initialize API services and provider pool manager
* @param {Object} config - The server configuration
* @returns {Promise<Object>} The initialized services
*/
export async function initApiService(config) {
if (config.providerPools && Object.keys(config.providerPools).length > 0) {
providerPoolManager = new ProviderPoolManager(config.providerPools, { globalConfig: config });
console.log('[Initialization] ProviderPoolManager initialized with configured pools.');
// 健康检查将在服务器完全启动后执行
} else {
console.log('[Initialization] No provider pools configured. Using single provider mode.');
}
// Initialize configured service adapters at startup
// 对于未纳入号池的提供者,提前初始化以避免首个请求的额外延迟
const providersToInit = new Set();
if (Array.isArray(config.DEFAULT_MODEL_PROVIDERS)) {
config.DEFAULT_MODEL_PROVIDERS.forEach((provider) => providersToInit.add(provider));
}
if (config.providerPools) {
Object.keys(config.providerPools).forEach((provider) => providersToInit.add(provider));
}
if (providersToInit.size === 0) {
const { ALL_MODEL_PROVIDERS } = await import('./config-manager.js');
ALL_MODEL_PROVIDERS.forEach((provider) => providersToInit.add(provider));
}
for (const provider of providersToInit) {
const { ALL_MODEL_PROVIDERS } = await import('./config-manager.js');
if (!ALL_MODEL_PROVIDERS.includes(provider)) {
console.warn(`[Initialization Warning] Skipping unknown model provider '${provider}' during adapter initialization.`);
continue;
}
if (config.providerPools && config.providerPools[provider] && config.providerPools[provider].length > 0) {
// 由号池管理器负责按需初始化
continue;
}
try {
console.log(`[Initialization] Initializing single service adapter for ${provider}...`);
getServiceAdapter({ ...config, MODEL_PROVIDER: provider });
} catch (error) {
console.warn(`[Initialization Warning] Failed to initialize single service adapter for ${provider}: ${error.message}`);
}
}
return serviceInstances; // Return the collection of initialized service instances
}
/**
* Get API service adapter, considering provider pools
* @param {Object} config - The current request configuration
* @returns {Promise<Object>} The API service adapter
*/
export async function getApiService(config) {
let serviceConfig = config;
if (providerPoolManager && config.providerPools && config.providerPools[config.MODEL_PROVIDER]) {
// 如果有号池管理器,并且当前模型提供者类型有对应的号池,则从号池中选择一个提供者配置
const selectedProviderConfig = providerPoolManager.selectProvider(config.MODEL_PROVIDER);
if (selectedProviderConfig) {
// 合并选中的提供者配置到当前请求的 config 中
serviceConfig = deepmerge(config, selectedProviderConfig);
delete serviceConfig.providerPools; // 移除 providerPools 属性
config.uuid = serviceConfig.uuid;
console.log(`[API Service] Using pooled configuration for ${config.MODEL_PROVIDER}: ${serviceConfig.uuid}`);
} else {
console.warn(`[API Service] No healthy provider found in pool for ${config.MODEL_PROVIDER}. Falling back to main config.`);
}
}
return getServiceAdapter(serviceConfig);
}
/**
* Get the provider pool manager instance
* @returns {Object} The provider pool manager
*/
export function getProviderPoolManager() {
return providerPoolManager;
}
/**
* Mark provider as unhealthy
* @param {string} provider - The model provider
* @param {Object} providerInfo - Provider information including uuid
*/
export function markProviderUnhealthy(provider, providerInfo) {
if (providerPoolManager) {
providerPoolManager.markProviderUnhealthy(provider, providerInfo);
}
}

1359
src/ui-manager.js Normal file

File diff suppressed because it is too large Load diff

150
static/app/app.js Normal file
View file

@ -0,0 +1,150 @@
// 主应用入口文件 - 模块化版本
// 导入所有模块
import {
providerStats,
REFRESH_INTERVALS
} from './constants.js';
import {
showToast,
getProviderStats
} from './utils.js';
import {
initFileUpload,
fileUploadHandler
} from './file-upload.js';
import {
initNavigation
} from './navigation.js';
import {
initEventListeners,
setDataLoaders,
setReloadConfig
} from './event-handlers.js';
import {
initEventStream,
setProviderLoaders,
setConfigLoaders
} from './event-stream.js';
import {
loadSystemInfo,
updateTimeDisplay,
loadProviders,
openProviderManager
} from './provider-manager.js';
import {
loadConfiguration,
saveConfiguration
} from './config-manager.js';
import {
showProviderManagerModal,
refreshProviderConfig
} from './modal.js';
import {
initRoutingExamples
} from './routing-examples.js';
import {
initUploadConfigManager,
loadConfigList,
viewConfig,
deleteConfig,
closeConfigModal,
copyConfigContent,
reloadConfig
} from './upload-config-manager.js';
/**
* 加载初始数据
*/
function loadInitialData() {
loadSystemInfo();
loadProviders();
loadConfiguration();
// showToast('数据已刷新', 'success');
}
/**
* 初始化应用
*/
function initApp() {
// 设置数据加载器
setDataLoaders(loadInitialData, saveConfiguration);
// 设置reloadConfig函数
setReloadConfig(reloadConfig);
// 设置提供商加载器
setProviderLoaders(loadProviders, refreshProviderConfig);
// 设置配置加载器
setConfigLoaders(loadConfigList);
// 初始化各个模块
initNavigation();
initEventListeners();
initEventStream();
initFileUpload(); // 初始化文件上传功能
initRoutingExamples(); // 初始化路径路由示例功能
initUploadConfigManager(); // 初始化上传配置管理功能
loadInitialData();
// 显示欢迎消息
showToast('欢迎使用AIClent2API管理控制台', 'success');
// 每5秒更新服务器时间和运行时间显示
setInterval(() => {
updateTimeDisplay();
}, 5000);
// 定期刷新系统信息
setInterval(() => {
loadProviders();
if (providerStats.activeProviders > 0) {
const stats = getProviderStats(providerStats);
console.log('=== 提供商统计报告 ===');
console.log(`活跃提供商: ${stats.activeProviders}`);
console.log(`健康提供商: ${stats.healthyProviders} (${stats.healthRatio})`);
console.log(`总账户数: ${stats.totalAccounts}`);
console.log(`总请求数: ${stats.totalRequests}`);
console.log(`总错误数: ${stats.totalErrors}`);
console.log(`成功率: ${stats.successRate}`);
console.log(`平均每提供商请求数: ${stats.avgUsagePerProvider}`);
console.log('========================');
}
}, REFRESH_INTERVALS.SYSTEM_INFO);
}
// DOM加载完成后初始化应用
document.addEventListener('DOMContentLoaded', initApp);
// 导出全局函数供其他模块使用
window.loadProviders = loadProviders;
window.openProviderManager = openProviderManager;
window.showProviderManagerModal = showProviderManagerModal;
window.refreshProviderConfig = refreshProviderConfig;
window.fileUploadHandler = fileUploadHandler;
// 上传配置管理相关全局函数
window.viewConfig = viewConfig;
window.deleteConfig = deleteConfig;
window.loadConfigList = loadConfigList;
window.closeConfigModal = closeConfigModal;
window.copyConfigContent = copyConfigContent;
window.reloadConfig = reloadConfig;
// 导出调试函数
window.getProviderStats = () => getProviderStats(providerStats);
console.log('AIClient2API 管理控制台已加载 - 模块化版本');

View file

@ -0,0 +1,215 @@
// 配置管理模块
import { showToast, formatUptime } from './utils.js';
import { handleProviderChange, handleGeminiCredsTypeChange, handleKiroCredsTypeChange } from './event-handlers.js';
/**
* 加载配置
*/
async function loadConfiguration() {
try {
const response = await fetch('/api/config');
const data = await response.json();
// 基础配置
const apiKeyEl = document.getElementById('apiKey');
const hostEl = document.getElementById('host');
const portEl = document.getElementById('port');
const modelProviderEl = document.getElementById('modelProvider');
const systemPromptEl = document.getElementById('systemPrompt');
if (apiKeyEl) apiKeyEl.value = data.REQUIRED_API_KEY || '';
if (hostEl) hostEl.value = data.HOST || '127.0.0.1';
if (portEl) portEl.value = data.SERVER_PORT || 3000;
if (modelProviderEl) modelProviderEl.value = data.MODEL_PROVIDER || 'gemini-cli-oauth';
if (systemPromptEl) systemPromptEl.value = data.systemPrompt || '';
// Gemini CLI OAuth
const projectIdEl = document.getElementById('projectId');
const geminiOauthCredsBase64El = document.getElementById('geminiOauthCredsBase64');
const geminiOauthCredsFilePathEl = document.getElementById('geminiOauthCredsFilePath');
if (projectIdEl) projectIdEl.value = data.PROJECT_ID || '';
if (geminiOauthCredsBase64El) geminiOauthCredsBase64El.value = data.GEMINI_OAUTH_CREDS_BASE64 || '';
if (geminiOauthCredsFilePathEl) geminiOauthCredsFilePathEl.value = data.GEMINI_OAUTH_CREDS_FILE_PATH || '';
// OpenAI Custom
const openaiApiKeyEl = document.getElementById('openaiApiKey');
const openaiBaseUrlEl = document.getElementById('openaiBaseUrl');
if (openaiApiKeyEl) openaiApiKeyEl.value = data.OPENAI_API_KEY || '';
if (openaiBaseUrlEl) openaiBaseUrlEl.value = data.OPENAI_BASE_URL || 'https://api.openai.com/v1';
// Claude Custom
const claudeApiKeyEl = document.getElementById('claudeApiKey');
const claudeBaseUrlEl = document.getElementById('claudeBaseUrl');
if (claudeApiKeyEl) claudeApiKeyEl.value = data.CLAUDE_API_KEY || '';
if (claudeBaseUrlEl) claudeBaseUrlEl.value = data.CLAUDE_BASE_URL || 'https://api.anthropic.com';
// Claude Kiro OAuth
const kiroOauthCredsBase64El = document.getElementById('kiroOauthCredsBase64');
const kiroOauthCredsFilePathEl = document.getElementById('kiroOauthCredsFilePath');
if (kiroOauthCredsBase64El) kiroOauthCredsBase64El.value = data.KIRO_OAUTH_CREDS_BASE64 || '';
if (kiroOauthCredsFilePathEl) kiroOauthCredsFilePathEl.value = data.KIRO_OAUTH_CREDS_FILE_PATH || '';
// Qwen OAuth
const qwenOauthCredsFilePathEl = document.getElementById('qwenOauthCredsFilePath');
if (qwenOauthCredsFilePathEl) qwenOauthCredsFilePathEl.value = data.QWEN_OAUTH_CREDS_FILE_PATH || '';
// OpenAI Responses
const openaiResponsesApiKeyEl = document.getElementById('openaiResponsesApiKey');
const openaiResponsesBaseUrlEl = document.getElementById('openaiResponsesBaseUrl');
if (openaiResponsesApiKeyEl) openaiResponsesApiKeyEl.value = data.OPENAI_API_KEY || '';
if (openaiResponsesBaseUrlEl) openaiResponsesBaseUrlEl.value = data.OPENAI_BASE_URL || 'https://api.openai.com/v1';
// 高级配置参数
const systemPromptFilePathEl = document.getElementById('systemPromptFilePath');
const systemPromptModeEl = document.getElementById('systemPromptMode');
const promptLogBaseNameEl = document.getElementById('promptLogBaseName');
const promptLogModeEl = document.getElementById('promptLogMode');
const requestMaxRetriesEl = document.getElementById('requestMaxRetries');
const requestBaseDelayEl = document.getElementById('requestBaseDelay');
const cronNearMinutesEl = document.getElementById('cronNearMinutes');
const cronRefreshTokenEl = document.getElementById('cronRefreshToken');
const providerPoolsFilePathEl = document.getElementById('providerPoolsFilePath');
if (systemPromptFilePathEl) systemPromptFilePathEl.value = data.SYSTEM_PROMPT_FILE_PATH || 'input_system_prompt.txt';
if (systemPromptModeEl) systemPromptModeEl.value = data.SYSTEM_PROMPT_MODE || 'overwrite';
if (promptLogBaseNameEl) promptLogBaseNameEl.value = data.PROMPT_LOG_BASE_NAME || 'prompt_log';
if (promptLogModeEl) promptLogModeEl.value = data.PROMPT_LOG_MODE || 'none';
if (requestMaxRetriesEl) requestMaxRetriesEl.value = data.REQUEST_MAX_RETRIES || 3;
if (requestBaseDelayEl) requestBaseDelayEl.value = data.REQUEST_BASE_DELAY || 1000;
if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1;
if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false;
if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json';
// 触发供应商配置显示
handleProviderChange();
// 根据Gemini凭据类型设置显示
const geminiCredsType = data.GEMINI_OAUTH_CREDS_BASE64 ? 'base64' : 'file';
const geminiRadio = document.querySelector(`input[name="geminiCredsType"][value="${geminiCredsType}"]`);
if (geminiRadio) {
geminiRadio.checked = true;
handleGeminiCredsTypeChange({ target: geminiRadio });
}
// 根据Kiro凭据类型设置显示
const kiroCredsType = data.KIRO_OAUTH_CREDS_BASE64 ? 'base64' : 'file';
const kiroRadio = document.querySelector(`input[name="kiroCredsType"][value="${kiroCredsType}"]`);
if (kiroRadio) {
kiroRadio.checked = true;
handleKiroCredsTypeChange({ target: kiroRadio });
}
// 检查并设置供应商池菜单显示状态
const providerPoolsFilePath = data.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json';
const providersMenuItem = document.querySelector('.nav-item[data-section="providers"]');
if (providerPoolsFilePath && providerPoolsFilePath.trim() !== '') {
if (providersMenuItem) providersMenuItem.style.display = 'flex';
} else {
if (providersMenuItem) providersMenuItem.style.display = 'none';
}
} catch (error) {
console.error('Failed to load configuration:', error);
}
}
/**
* 保存配置
*/
async function saveConfiguration() {
const config = {
REQUIRED_API_KEY: document.getElementById('apiKey')?.value || '',
HOST: document.getElementById('host')?.value || '127.0.0.1',
SERVER_PORT: parseInt(document.getElementById('port')?.value || 3000),
MODEL_PROVIDER: document.getElementById('modelProvider')?.value || 'gemini-cli-oauth',
systemPrompt: document.getElementById('systemPrompt')?.value || '',
};
// 根据不同供应商保存不同的配置
const provider = document.getElementById('modelProvider')?.value;
switch (provider) {
case 'gemini-cli-oauth':
config.PROJECT_ID = document.getElementById('projectId')?.value || '';
const geminiCredsType = document.querySelector('input[name="geminiCredsType"]:checked')?.value;
if (geminiCredsType === 'base64') {
config.GEMINI_OAUTH_CREDS_BASE64 = document.getElementById('geminiOauthCredsBase64')?.value || '';
config.GEMINI_OAUTH_CREDS_FILE_PATH = null;
} else {
config.GEMINI_OAUTH_CREDS_BASE64 = null;
config.GEMINI_OAUTH_CREDS_FILE_PATH = document.getElementById('geminiOauthCredsFilePath')?.value || '';
}
break;
case 'openai-custom':
config.OPENAI_API_KEY = document.getElementById('openaiApiKey')?.value || '';
config.OPENAI_BASE_URL = document.getElementById('openaiBaseUrl')?.value || '';
break;
case 'claude-custom':
config.CLAUDE_API_KEY = document.getElementById('claudeApiKey')?.value || '';
config.CLAUDE_BASE_URL = document.getElementById('claudeBaseUrl')?.value || '';
break;
case 'claude-kiro-oauth':
const kiroCredsType = document.querySelector('input[name="kiroCredsType"]:checked')?.value;
if (kiroCredsType === 'base64') {
config.KIRO_OAUTH_CREDS_BASE64 = document.getElementById('kiroOauthCredsBase64')?.value || '';
config.KIRO_OAUTH_CREDS_FILE_PATH = null;
} else {
config.KIRO_OAUTH_CREDS_BASE64 = null;
config.KIRO_OAUTH_CREDS_FILE_PATH = document.getElementById('kiroOauthCredsFilePath')?.value || '';
}
break;
case 'openai-qwen-oauth':
config.QWEN_OAUTH_CREDS_FILE_PATH = document.getElementById('qwenOauthCredsFilePath')?.value || '';
break;
case 'openaiResponses-custom':
config.OPENAI_API_KEY = document.getElementById('openaiResponsesApiKey')?.value || '';
config.OPENAI_BASE_URL = document.getElementById('openaiResponsesBaseUrl')?.value || '';
break;
}
// 保存高级配置参数
config.SYSTEM_PROMPT_FILE_PATH = document.getElementById('systemPromptFilePath')?.value || '';
config.SYSTEM_PROMPT_MODE = document.getElementById('systemPromptMode')?.value || '';
config.PROMPT_LOG_BASE_NAME = document.getElementById('promptLogBaseName')?.value || '';
config.PROMPT_LOG_MODE = document.getElementById('promptLogMode')?.value || '';
config.REQUEST_MAX_RETRIES = parseInt(document.getElementById('requestMaxRetries')?.value || 3);
config.REQUEST_BASE_DELAY = parseInt(document.getElementById('requestBaseDelay')?.value || 1000);
config.CRON_NEAR_MINUTES = parseInt(document.getElementById('cronNearMinutes')?.value || 1);
config.CRON_REFRESH_TOKEN = document.getElementById('cronRefreshToken')?.checked || false;
config.PROVIDER_POOLS_FILE_PATH = document.getElementById('providerPoolsFilePath')?.value || '';
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
});
if (response.ok) {
showToast('配置已保存', 'success');
} else {
showToast('保存配置失败', 'error');
}
} catch (error) {
console.error('Failed to save configuration:', error);
showToast('保存配置失败: ' + error.message, 'error');
}
}
export {
loadConfiguration,
saveConfiguration
};

66
static/app/constants.js Normal file
View file

@ -0,0 +1,66 @@
// 全局变量
let eventSource = null;
let autoScroll = true;
let logs = [];
// 提供商统计全局变量
let providerStats = {
totalRequests: 0,
totalErrors: 0,
activeProviders: 0,
healthyProviders: 0,
totalAccounts: 0,
lastUpdateTime: null,
providerTypeStats: {} // 详细按类型统计
};
// DOM元素
const elements = {
serverStatus: document.getElementById('serverStatus'),
refreshBtn: document.getElementById('refreshBtn'),
sections: document.querySelectorAll('.section'),
navItems: document.querySelectorAll('.nav-item'),
logsContainer: document.getElementById('logsContainer'),
clearLogsBtn: document.getElementById('clearLogs'),
toggleAutoScrollBtn: document.getElementById('toggleAutoScroll'),
saveConfigBtn: document.getElementById('saveConfig'),
resetConfigBtn: document.getElementById('resetConfig'),
toastContainer: document.getElementById('toastContainer'),
modelProvider: document.getElementById('modelProvider'),
};
// 定期刷新间隔
const REFRESH_INTERVALS = {
SYSTEM_INFO: 10000
};
// 导出所有常量
export {
eventSource,
autoScroll,
logs,
providerStats,
elements,
REFRESH_INTERVALS
};
// 更新函数
export function setEventSource(source) {
eventSource = source;
}
export function setAutoScroll(value) {
autoScroll = value;
}
export function addLog(log) {
logs.push(log);
}
export function clearLogs() {
logs = [];
}
export function updateProviderStats(newStats) {
providerStats = { ...providerStats, ...newStats };
}

View file

@ -0,0 +1,264 @@
// 事件监听器模块
import { elements, autoScroll, setAutoScroll, clearLogs } from './constants.js';
import { showToast } from './utils.js';
/**
* 初始化所有事件监听器
*/
function initEventListeners() {
// 刷新按钮
if (elements.refreshBtn) {
elements.refreshBtn.addEventListener('click', handleRefresh);
}
// 清空日志
if (elements.clearLogsBtn) {
elements.clearLogsBtn.addEventListener('click', () => {
clearLogs();
if (elements.logsContainer) {
elements.logsContainer.innerHTML = '';
}
showToast('日志已清空', 'success');
});
}
// 自动滚动切换
if (elements.toggleAutoScrollBtn) {
elements.toggleAutoScrollBtn.addEventListener('click', () => {
const newAutoScroll = !autoScroll;
setAutoScroll(newAutoScroll);
elements.toggleAutoScrollBtn.dataset.enabled = newAutoScroll;
elements.toggleAutoScrollBtn.innerHTML = `
<i class="fas fa-arrow-down"></i>
自动滚动: ${newAutoScroll ? '开' : '关'}
`;
});
}
// 保存配置
if (elements.saveConfigBtn) {
elements.saveConfigBtn.addEventListener('click', saveConfiguration);
}
// 重置配置
if (elements.resetConfigBtn) {
elements.resetConfigBtn.addEventListener('click', loadInitialData);
}
// 模型提供商切换
if (elements.modelProvider) {
elements.modelProvider.addEventListener('change', handleProviderChange);
}
// Gemini凭据类型切换
document.querySelectorAll('input[name="geminiCredsType"]').forEach(radio => {
radio.addEventListener('change', handleGeminiCredsTypeChange);
});
// Kiro凭据类型切换
document.querySelectorAll('input[name="kiroCredsType"]').forEach(radio => {
radio.addEventListener('change', handleKiroCredsTypeChange);
});
// 密码显示/隐藏切换
document.querySelectorAll('.password-toggle').forEach(button => {
button.addEventListener('click', handlePasswordToggle);
});
// 供应商池配置监听
const providerPoolsInput = document.getElementById('providerPoolsFilePath');
if (providerPoolsInput) {
providerPoolsInput.addEventListener('input', handleProviderPoolsConfigChange);
}
// 日志容器滚动
if (elements.logsContainer) {
elements.logsContainer.addEventListener('scroll', () => {
if (autoScroll) {
const isAtBottom = elements.logsContainer.scrollTop + elements.logsContainer.clientHeight
>= elements.logsContainer.scrollHeight - 5;
if (!isAtBottom) {
setAutoScroll(false);
elements.toggleAutoScrollBtn.dataset.enabled = false;
elements.toggleAutoScrollBtn.innerHTML = `
<i class="fas fa-arrow-down"></i>
自动滚动:
`;
}
}
});
}
}
/**
* 供应商配置切换处理
*/
function handleProviderChange() {
const selectedProvider = elements.modelProvider?.value;
if (!selectedProvider) return;
const allProviderConfigs = document.querySelectorAll('.provider-config');
// 隐藏所有供应商配置
allProviderConfigs.forEach(config => {
config.style.display = 'none';
});
// 显示当前选中的供应商配置
const targetConfig = document.querySelector(`[data-provider="${selectedProvider}"]`);
if (targetConfig) {
targetConfig.style.display = 'block';
}
}
/**
* Gemini凭据类型切换
* @param {Event} event - 事件对象
*/
function handleGeminiCredsTypeChange(event) {
const selectedType = event.target.value;
const base64Group = document.getElementById('geminiCredsBase64Group');
const fileGroup = document.getElementById('geminiCredsFileGroup');
if (selectedType === 'base64') {
if (base64Group) base64Group.style.display = 'block';
if (fileGroup) fileGroup.style.display = 'none';
} else {
if (base64Group) base64Group.style.display = 'none';
if (fileGroup) fileGroup.style.display = 'block';
}
}
/**
* Kiro凭据类型切换
* @param {Event} event - 事件对象
*/
function handleKiroCredsTypeChange(event) {
const selectedType = event.target.value;
const base64Group = document.getElementById('kiroCredsBase64Group');
const fileGroup = document.getElementById('kiroCredsFileGroup');
if (selectedType === 'base64') {
if (base64Group) base64Group.style.display = 'block';
if (fileGroup) fileGroup.style.display = 'none';
} else {
if (base64Group) base64Group.style.display = 'none';
if (fileGroup) fileGroup.style.display = 'block';
}
}
/**
* 密码显示/隐藏切换处理
* @param {Event} event - 事件对象
*/
function handlePasswordToggle(event) {
const button = event.target.closest('.password-toggle');
if (!button) return;
const targetId = button.getAttribute('data-target');
const input = document.getElementById(targetId);
const icon = button.querySelector('i');
if (!input || !icon) return;
if (input.type === 'password') {
input.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
input.type = 'password';
icon.className = 'fas fa-eye';
}
}
/**
* 供应商池配置变化处理
* @param {Event} event - 事件对象
*/
function handleProviderPoolsConfigChange(event) {
const filePath = event.target.value.trim();
const providersMenuItem = document.querySelector('.nav-item[data-section="providers"]');
if (filePath) {
// 显示供应商池菜单
if (providersMenuItem) providersMenuItem.style.display = 'flex';
} else {
// 隐藏供应商池菜单
if (providersMenuItem) providersMenuItem.style.display = 'none';
// 如果当前在供应商池页面,切换到仪表盘
if (providersMenuItem && providersMenuItem.classList.contains('active')) {
const dashboardItem = document.querySelector('.nav-item[data-section="dashboard"]');
const dashboardSection = document.getElementById('dashboard');
// 更新导航状态
document.querySelectorAll('.nav-item').forEach(nav => nav.classList.remove('active'));
document.querySelectorAll('.section').forEach(section => section.classList.remove('active'));
if (dashboardItem) dashboardItem.classList.add('active');
if (dashboardSection) dashboardSection.classList.add('active');
}
}
}
/**
* 密码显示/隐藏切换处理用于模态框中的密码输入框
* @param {HTMLElement} button - 按钮元素
*/
function handleProviderPasswordToggle(button) {
const targetKey = button.getAttribute('data-target');
const input = button.parentNode.querySelector(`input[data-config-key="${targetKey}"]`);
const icon = button.querySelector('i');
if (!input || !icon) return;
if (input.type === 'password') {
input.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
input.type = 'password';
icon.className = 'fas fa-eye';
}
}
// 数据加载函数(需要从主模块导入)
let loadInitialData;
let saveConfiguration;
let reloadConfig;
// 刷新处理函数
async function handleRefresh() {
try {
// 先刷新基础数据
if (loadInitialData) {
loadInitialData();
}
// 如果reloadConfig函数可用则也刷新配置
if (reloadConfig) {
await reloadConfig();
}
} catch (error) {
console.error('刷新失败:', error);
showToast('刷新失败: ' + error.message, 'error');
}
}
export function setDataLoaders(dataLoader, configSaver) {
loadInitialData = dataLoader;
saveConfiguration = configSaver;
}
export function setReloadConfig(configReloader) {
reloadConfig = configReloader;
}
export {
initEventListeners,
handleProviderChange,
handleGeminiCredsTypeChange,
handleKiroCredsTypeChange,
handlePasswordToggle,
handleProviderPoolsConfigChange,
handleProviderPasswordToggle
};

188
static/app/event-stream.js Normal file
View file

@ -0,0 +1,188 @@
// Server-Sent Events处理模块
import { eventSource, setEventSource, elements, addLog, autoScroll } from './constants.js';
/**
* Server-Sent Events初始化
*/
function initEventStream() {
if (eventSource) {
eventSource.close();
}
const newEventSource = new EventSource('/api/events');
setEventSource(newEventSource);
newEventSource.onopen = () => {
updateServerStatus(true);
console.log('EventStream connected');
};
newEventSource.onerror = () => {
updateServerStatus(false);
console.log('EventStream disconnected');
};
newEventSource.addEventListener('log', (event) => {
const data = JSON.parse(event.data);
addLogEntry(data);
});
newEventSource.addEventListener('provider', (event) => {
const data = JSON.parse(event.data);
updateProviderStatus(data);
});
newEventSource.addEventListener('provider_update', (event) => {
const data = JSON.parse(event.data);
handleProviderUpdate(data);
});
newEventSource.addEventListener('config_update', (event) => {
const data = JSON.parse(event.data);
handleConfigUpdate(data);
});
}
/**
* 添加日志条目
* @param {Object} logData - 日志数据
*/
function addLogEntry(logData) {
addLog(logData);
if (!elements.logsContainer) return;
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
const time = new Date(logData.timestamp).toLocaleTimeString();
const levelClass = `log-level-${logData.level}`;
logEntry.innerHTML = `
<span class="log-time">[${time}]</span>
<span class="${levelClass}">[${logData.level.toUpperCase()}]</span>
<span class="log-message">${escapeHtml(logData.message)}</span>
`;
elements.logsContainer.appendChild(logEntry);
if (autoScroll) {
elements.logsContainer.scrollTop = elements.logsContainer.scrollHeight;
}
}
/**
* 更新服务器状态
* @param {boolean} connected - 连接状态
*/
function updateServerStatus(connected) {
if (!elements.serverStatus) return;
const statusBadge = elements.serverStatus;
const icon = statusBadge.querySelector('i');
const text = statusBadge.querySelector('span') || statusBadge.childNodes[1];
if (connected) {
statusBadge.classList.remove('error');
icon.style.color = 'var(--success-color)';
statusBadge.innerHTML = '<i class="fas fa-circle"></i> 已连接';
} else {
statusBadge.classList.add('error');
icon.style.color = 'var(--danger-color)';
statusBadge.innerHTML = '<i class="fas fa-circle"></i> 连接断开';
}
}
/**
* 更新提供商状态
* @param {Object} data - 提供商数据
*/
function updateProviderStatus(data) {
// 触发重新加载提供商列表
if (typeof loadProviders === 'function') {
loadProviders();
}
}
/**
* 处理供应商更新事件
* @param {Object} data - 更新数据
*/
function handleProviderUpdate(data) {
if (data.action && data.providerType) {
// 如果当前打开的模态框是更新事件的供应商类型,则刷新该模态框
const modal = document.querySelector('.provider-modal');
if (modal && modal.getAttribute('data-provider-type') === data.providerType) {
if (typeof refreshProviderConfig === 'function') {
refreshProviderConfig(data.providerType);
}
} else {
// 否则更新主界面的供应商列表
if (typeof loadProviders === 'function') {
loadProviders();
}
}
}
}
// 导入工具函数
import { escapeHtml } from './utils.js';
// 需要从其他模块导入的函数
let loadProviders;
let refreshProviderConfig;
let loadConfigList;
export function setProviderLoaders(providerLoader, providerRefresher) {
loadProviders = providerLoader;
refreshProviderConfig = providerRefresher;
}
export function setConfigLoaders(configLoader) {
loadConfigList = configLoader;
}
/**
* 处理配置更新事件
* @param {Object} data - 更新数据
*/
function handleConfigUpdate(data) {
console.log('[ConfigUpdate] 收到配置更新事件:', data);
// 根据操作类型进行相应处理
switch (data.action) {
case 'delete':
// 文件删除事件,直接刷新配置文件列表
if (loadConfigList) {
loadConfigList();
console.log('[ConfigUpdate] 配置文件列表已刷新(文件删除)');
}
break;
case 'add':
case 'update':
// 文件添加或更新事件,刷新配置文件列表
if (loadConfigList) {
loadConfigList();
console.log('[ConfigUpdate] 配置文件列表已刷新(文件更新)');
}
break;
default:
// 未知操作类型,也刷新列表以确保同步
if (loadConfigList) {
loadConfigList();
console.log('[ConfigUpdate] 配置文件列表已刷新(默认)');
}
break;
}
}
export {
initEventStream,
addLogEntry,
updateServerStatus,
updateProviderStatus,
handleProviderUpdate
};

219
static/app/file-upload.js Normal file
View file

@ -0,0 +1,219 @@
// 文件上传功能模块
import { showToast } from './utils.js';
/**
* 文件上传处理器类
*/
class FileUploadHandler {
constructor() {
this.currentProvider = 'gemini'; // 默认提供商
this.initEventListeners();
}
/**
* 初始化事件监听器
*/
initEventListeners() {
// 监听所有上传按钮的点击事件
document.addEventListener('click', (event) => {
if (event.target.closest('.upload-btn')) {
const button = event.target.closest('.upload-btn');
const targetInputId = button.getAttribute('data-target');
if (targetInputId) {
this.handleFileUpload(button, targetInputId);
}
}
});
// 监听提供商切换事件
const modelProvider = document.getElementById('modelProvider');
if (modelProvider) {
modelProvider.addEventListener('change', (event) => {
this.updateCurrentProvider(event.target.value);
});
}
}
/**
* 更新当前提供商
* @param {string} provider - 选择的提供商
*/
updateCurrentProvider(provider) {
this.currentProvider = this.getProviderKey(provider);
}
/**
* 获取提供商对应的键名
* @param {string} provider - 提供商名称
* @returns {string} - 提供商标识
*/
getProviderKey(provider) {
const providerMap = {
'gemini-cli-oauth': 'gemini',
'claude-kiro-oauth': 'kiro',
'openai-qwen-oauth': 'qwen'
};
return providerMap[provider] || 'gemini';
}
/**
* 处理文件上传
* @param {HTMLElement} button - 上传按钮元素
* @param {string} targetInputId - 目标输入框ID
*/
async handleFileUpload(button, targetInputId) {
// 创建隐藏的文件输入元素
const fileInput = this.createFileInput();
// 设置文件选择回调
fileInput.onchange = async (event) => {
const file = event.target.files[0];
if (file) {
// 只有文件被实际选择后才显示加载状态并上传
this.setButtonLoading(button, true);
await this.uploadFile(file, targetInputId, button);
}
// 清理临时文件输入元素
fileInput.remove();
};
// 触发文件选择
fileInput.click();
}
/**
* 创建文件输入元素
* @returns {HTMLInputElement} - 文件输入元素
*/
createFileInput() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json,.txt,.key,.pem,.p12,.pfx';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
return fileInput;
}
/**
* 上传文件到服务器
* @param {File} file - 要上传的文件
* @param {string} targetInputId - 目标输入框ID
* @param {HTMLElement} button - 上传按钮
*/
async uploadFile(file, targetInputId, button) {
try {
// 验证文件类型
if (!this.validateFileType(file)) {
showToast('不支持的文件类型,请选择 JSON、TXT、KEY、PEM、P12 或 PFX 文件', 'error');
this.setButtonLoading(button, false);
return;
}
// 验证文件大小 (5MB 限制)
if (file.size > 5 * 1024 * 1024) {
showToast('文件大小不能超过 5MB', 'error');
this.setButtonLoading(button, false);
return;
}
// 创建 FormData
const formData = new FormData();
formData.append('file', file);
formData.append('provider', this.currentProvider);
formData.append('targetInputId', targetInputId);
// 发送上传请求
const response = await fetch('/api/upload-oauth-credentials', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || '上传失败');
}
const result = await response.json();
// 成功上传,设置文件路径到输入框
this.setFilePathToInput(targetInputId, result.filePath);
showToast('文件上传成功', 'success');
} catch (error) {
console.error('文件上传错误:', error);
showToast('文件上传失败: ' + error.message, 'error');
} finally {
this.setButtonLoading(button, false);
}
}
/**
* 验证文件类型
* @param {File} file - 要验证的文件
* @returns {boolean} - 是否为有效文件类型
*/
validateFileType(file) {
const allowedExtensions = ['.json', '.txt', '.key', '.pem', '.p12', '.pfx'];
const fileName = file.name.toLowerCase();
return allowedExtensions.some(ext => fileName.endsWith(ext));
}
/**
* 设置按钮加载状态
* @param {HTMLElement} button - 按钮元素
* @param {boolean} isLoading - 是否加载中
*/
setButtonLoading(button, isLoading) {
const icon = button.querySelector('i');
if (isLoading) {
button.disabled = true;
icon.className = 'fas fa-spinner fa-spin';
} else {
button.disabled = false;
icon.className = 'fas fa-upload';
}
}
/**
* 设置文件路径到输入框
* @param {string} inputId - 输入框ID
* @param {string} filePath - 文件路径
*/
setFilePathToInput(inputId, filePath) {
// console.log('设置文件路径到输入框:', inputId, filePath);
let input = document.getElementById(inputId);
if (input) {
// console.log('输入框元素存在,设置文件路径:', filePath);
input.value = filePath;
// 同时更新data-config-value属性用于编辑模式
if (input.hasAttribute('data-config-value')) {
input.setAttribute('data-config-value', filePath);
console.log('更新data-config-value属性:', filePath);
}
// 触发输入事件,通知其他监听器
input.dispatchEvent(new Event('input', { bubbles: true }));
} else {
console.error('无法找到输入框:', inputId);
}
}
}
/**
* 初始化文件上传功能
*/
function initFileUpload() {
// 文件上传功能是自初始化的单例
console.log('文件上传功能已初始化');
}
// 导出单例实例
const fileUploadHandler = new FileUploadHandler();
export {
fileUploadHandler,
FileUploadHandler,
initFileUpload
};

845
static/app/mobile.css Normal file
View file

@ -0,0 +1,845 @@
/* ========================================
移动端优化样式
======================================== */
/* 基础响应式文本隐藏 */
@media (max-width: 480px) {
.header-title {
display: inline;
}
.btn-text {
display: none;
}
.status-text {
display: none;
}
}
@media (min-width: 481px) {
.header-title,
.btn-text,
.status-text {
display: inline;
}
}
/* 移动端汉堡菜单按钮 */
.mobile-menu-toggle {
display: none;
background: none;
border: none;
font-size: 1.5rem;
color: var(--primary-color);
cursor: pointer;
padding: 0.5rem;
transition: var(--transition);
}
.mobile-menu-toggle:hover {
color: var(--secondary-color);
}
/* 移动端覆盖层 */
.mobile-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
opacity: 0;
transition: opacity 0.3s ease;
}
.mobile-overlay.active {
display: block;
opacity: 1;
}
/* ========================================
平板设备适配 (768px - 1024px)
======================================== */
@media (max-width: 1024px) {
.header-content {
padding: 1rem 1.5rem;
}
.content {
padding: 1.5rem;
}
.stats-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.config-form {
max-width: 100%;
}
}
/* ========================================
移动端适配 (最大宽度 768px)
======================================== */
@media (max-width: 768px) {
/* Header 优化 */
.header-content {
padding: 0.75rem 1rem;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.header h1 {
font-size: 1.25rem;
flex: 1 1 100%;
min-width: 0;
text-align: center;
order: -1;
}
.header h1 i {
margin-right: 0.25rem;
}
.header-controls {
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
flex: 1 1 100%;
}
.status-badge {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
/* 主内容区域 */
.main-content {
flex-direction: column;
}
/* 侧边栏移动端样式 */
.sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border-color);
padding: 0;
position: sticky;
top: 73px;
z-index: 90;
background: var(--bg-primary);
}
.sidebar-nav {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
padding: 0.5rem 1rem;
gap: 0.5rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
}
.nav-item {
padding: 0.625rem 1rem;
white-space: nowrap;
border-radius: 0.375rem;
border-right: none;
font-size: 0.875rem;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
touch-action: manipulation;
}
.nav-item.active {
background: var(--primary-color) !important;
color: white !important;
border-right: none;
}
.nav-item:active {
opacity: 0.8;
transform: scale(0.98);
}
.nav-item i {
width: 16px;
font-size: 0.875rem;
pointer-events: none;
}
.nav-item span {
pointer-events: none;
}
/* 内容区域 */
.content {
padding: 1rem;
}
.section h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
/* 统计卡片 */
.stats-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.stat-card {
padding: 1rem;
}
.stat-icon {
width: 48px;
height: 48px;
font-size: 1.25rem;
}
.stat-info h3 {
font-size: 1.5rem;
}
.stat-info p {
font-size: 0.8125rem;
}
/* 表单优化 */
.config-panel {
padding: 1rem;
border-radius: 0.375rem;
}
.form-row,
.config-row {
grid-template-columns: 1fr;
gap: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
font-size: 0.875rem;
margin-bottom: 0.375rem;
}
.form-control {
padding: 0.625rem;
font-size: 0.875rem;
}
textarea.form-control {
min-height: 100px;
}
/* 按钮优化 */
.btn {
padding: 0.625rem 1rem;
font-size: 0.875rem;
}
.form-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-actions .btn {
width: 100%;
}
/* 单选按钮组 */
.radio-group {
flex-direction: column;
gap: 0.75rem;
}
/* 高级配置区域 */
.advanced-config-section {
padding: 1rem;
margin-top: 1rem;
}
.advanced-config-section h3 {
font-size: 1.125rem;
margin-bottom: 1rem;
}
/* 系统信息面板 */
.system-info-panel {
padding: 1rem;
}
.system-info-panel h3 {
font-size: 1.125rem;
margin-bottom: 1rem;
}
.info-grid {
gap: 0.75rem;
}
.info-item {
padding: 0.75rem;
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
}
.info-label {
font-size: 0.8125rem;
}
.info-value {
font-size: 0.875rem;
}
/* 提供商列表 */
.providers-container {
padding: 0;
}
.providers-list {
gap: 0.75rem;
}
.provider-card {
padding: 1rem;
}
.provider-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.provider-actions {
width: 100%;
justify-content: flex-start;
gap: 0.5rem;
}
.provider-actions .btn {
flex: 1;
min-width: 0;
}
/* 提供商详情 */
.provider-summary {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.provider-summary-actions {
margin-left: 0;
width: 100%;
}
.provider-summary-actions .btn {
width: 100%;
}
/* 模态框优化 */
.provider-modal-content {
width: 95%;
max-width: 95%;
max-height: 90vh;
margin: 5vh auto;
padding: 1rem;
}
.provider-modal-header {
padding: 1rem;
margin: -1rem -1rem 1rem -1rem;
}
.provider-modal-header h2 {
font-size: 1.25rem;
}
.provider-modal-body {
padding: 0;
max-height: calc(90vh - 140px);
}
.config-section {
padding: 1rem;
margin-bottom: 1rem;
}
.config-section h3 {
font-size: 1.125rem;
margin-bottom: 0.75rem;
}
.config-item {
padding: 0.75rem;
gap: 0.5rem;
}
.config-label {
font-size: 0.8125rem;
min-width: 80px;
}
.config-value {
font-size: 0.875rem;
}
/* 日志容器 */
.logs-container {
height: calc(100vh - 280px);
min-height: 300px;
font-size: 0.75rem;
padding: 0.75rem;
}
.logs-controls {
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.logs-controls .btn {
flex: 1;
min-width: 120px;
}
.log-entry {
padding: 0.5rem;
margin-bottom: 0.375rem;
font-size: 0.75rem;
line-height: 1.4;
}
.log-timestamp {
font-size: 0.6875rem;
}
/* Toast 通知 */
.toast-container {
left: 1rem;
right: 1rem;
bottom: 1rem;
pointer-events: none;
}
.toast {
padding: 0.75rem 1rem;
font-size: 0.875rem;
border-radius: 0.375rem;
pointer-events: auto;
}
/* 密码输入框 */
.password-input-wrapper .form-control {
padding-right: 2.5rem;
}
.password-toggle {
right: 0.5rem;
padding: 0.375rem;
}
/* 切换开关 */
.toggle-switch {
transform: scale(0.9);
}
/* OAuth刷新切换 */
.oauth-refresh-toggle {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
/* 表单网格 */
.form-grid {
grid-template-columns: 1fr;
}
/* 路径路由示例移动端优化 */
.routing-examples-panel {
padding: 1rem;
margin-bottom: 1rem;
}
.routing-examples-panel h3 {
font-size: 1.125rem;
}
.routing-description {
font-size: 0.8125rem;
}
.routing-examples-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.routing-example-card {
border-radius: 0.375rem;
}
.routing-card-header {
padding: 0.75rem 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.routing-card-header h4 {
font-size: 0.9rem;
flex-basis: 100%;
margin-bottom: 0.25rem;
}
.routing-card-header i {
font-size: 1rem;
}
.provider-badge {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
}
.routing-card-content {
padding: 1rem;
}
.endpoint-path {
font-size: 0.75rem;
padding: 0.4rem 0.6rem;
padding-right: 2rem;
word-break: break-all;
}
.copy-btn {
right: 0.4rem;
padding: 0.2rem;
min-width: 32px;
min-height: 32px;
}
.usage-example pre {
font-size: 0.7rem;
padding: 0.75rem;
overflow-x: auto;
white-space: pre-wrap;
}
.routing-tips {
padding: 1rem;
}
.routing-tips h4 {
font-size: 0.9rem;
}
.routing-tips ul {
padding-left: 1.25rem;
}
.routing-tips li {
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.routing-tips code {
font-size: 0.7rem;
padding: 0.1rem 0.2rem;
}
/* 协议标签移动端优化 */
.protocol-tabs {
margin-bottom: 0.75rem;
}
.protocol-tab {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
}
/* 池配置部分 */
.pool-section {
margin-top: 1rem;
}
.pool-section small {
font-size: 0.75rem;
}
/* 系统提示部分 */
.system-prompt-section {
margin-top: 1rem;
}
.system-prompt-section textarea {
min-height: 120px;
}
}
/* ========================================
小屏幕移动设备 (最大宽度 480px)
======================================== */
@media (max-width: 480px) {
/* Header 进一步优化 */
.header h1 {
font-size: 1.125rem;
}
.header-controls {
width: 100%;
justify-content: space-between;
}
.status-badge {
flex: 1;
justify-content: center;
}
/* 按钮文字简化 */
#refreshBtn .fa-sync-alt::after {
content: '';
}
/* 侧边栏导航 */
.nav-item {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
.nav-item i {
margin-right: 0.375rem;
}
/* 统计卡片 */
.stat-icon {
width: 40px;
height: 40px;
font-size: 1.125rem;
}
.stat-info h3 {
font-size: 1.375rem;
}
/* 表单控件 */
.form-control {
font-size: 16px; /* 防止iOS自动缩放 */
}
select.form-control {
font-size: 16px;
}
/* 配置面板 */
.config-panel {
padding: 0.75rem;
}
.advanced-config-section {
padding: 0.75rem;
}
/* 提供商卡片 */
.provider-card {
padding: 0.75rem;
}
.provider-name {
font-size: 1rem;
}
/* 模态框 */
.provider-modal-content {
width: 98%;
max-width: 98%;
padding: 0.75rem;
}
.provider-modal-header {
padding: 0.75rem;
margin: -0.75rem -0.75rem 0.75rem -0.75rem;
}
.provider-modal-header h2 {
font-size: 1.125rem;
}
.close-modal {
width: 32px;
height: 32px;
font-size: 1.25rem;
}
/* 日志 */
.logs-container {
font-size: 0.6875rem;
padding: 0.5rem;
}
.log-entry {
padding: 0.375rem;
font-size: 0.6875rem;
}
/* Toast */
.toast {
font-size: 0.8125rem;
padding: 0.625rem 0.875rem;
}
}
/* ========================================
横屏模式优化
======================================== */
@media (max-width: 768px) and (orientation: landscape) {
.sidebar {
position: relative;
top: 0;
}
.sidebar-nav {
padding: 0.375rem 1rem;
}
.nav-item {
padding: 0.5rem 0.875rem;
}
.logs-container {
height: calc(100vh - 200px);
min-height: 200px;
}
.provider-modal-content {
max-height: 85vh;
}
.provider-modal-body {
max-height: calc(85vh - 120px);
}
}
/* ========================================
触摸优化
======================================== */
@media (hover: none) and (pointer: coarse) {
/* 增加可点击区域 */
.btn {
min-height: 10px;
padding: 0.5rem;
}
.nav-item {
min-height: 44px;
}
.password-toggle {
min-width: 44px;
min-height: 44px;
}
/* 移除悬停效果 */
.nav-item:hover {
background: transparent;
}
.nav-item:active {
background: var(--bg-secondary);
}
.btn:hover {
transform: none;
}
.btn:active {
transform: scale(0.98);
}
/* 优化滚动 */
.sidebar-nav,
.logs-container,
.provider-modal-body {
-webkit-overflow-scrolling: touch;
}
}
/* ========================================
暗色模式支持可选
======================================== */
@media (prefers-color-scheme: dark) {
/* 如果需要暗色模式,可以在这里添加样式 */
}
/* ========================================
打印样式
======================================== */
@media print {
.header-controls,
.sidebar,
.form-actions,
.logs-controls,
.provider-actions,
.mobile-menu-toggle,
.mobile-overlay {
display: none !important;
}
.main-content {
flex-direction: column;
}
.content {
padding: 0;
}
.section {
display: block !important;
page-break-after: always;
}
}
/* ========================================
辅助功能优化
======================================== */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 高对比度模式 */
@media (prefers-contrast: high) {
.btn {
border: 2px solid currentColor;
}
.form-control {
border: 2px solid var(--border-color);
}
.nav-item.active {
border-right: 4px solid var(--primary-color);
}
}

915
static/app/modal.js Normal file
View file

@ -0,0 +1,915 @@
// 模态框管理模块
import { showToast, getFieldLabel, getProviderTypeFields } from './utils.js';
import { handleProviderPasswordToggle } from './event-handlers.js';
/**
* 显示供应商管理模态框
* @param {Object} data - 供应商数据
*/
function showProviderManagerModal(data) {
const { providerType, providers, totalCount, healthyCount } = data;
// 移除已存在的模态框
const existingModal = document.querySelector('.provider-modal');
if (existingModal) {
// 清理事件监听器
if (existingModal.cleanup) {
existingModal.cleanup();
}
existingModal.remove();
}
// 创建模态框
const modal = document.createElement('div');
modal.className = 'provider-modal';
modal.setAttribute('data-provider-type', providerType);
modal.innerHTML = `
<div class="provider-modal-content">
<div class="provider-modal-header">
<h3><i class="fas fa-cogs"></i> ${providerType} </h3>
<button class="modal-close" onclick="window.closeProviderModal(this)">
<i class="fas fa-times"></i>
</button>
</div>
<div class="provider-modal-body">
<div class="provider-summary">
<div class="provider-summary-item">
<span class="label">总账户数:</span>
<span class="value">${totalCount}</span>
</div>
<div class="provider-summary-item">
<span class="label">健康账户:</span>
<span class="value">${healthyCount}</span>
</div>
<div class="provider-summary-actions">
<button class="btn btn-success" onclick="window.showAddProviderForm('${providerType}')">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<div class="provider-list" id="providerList">
${renderProviderList(providers)}
</div>
</div>
</div>
`;
// 添加到页面
document.body.appendChild(modal);
// 添加模态框事件监听
addModalEventListeners(modal);
}
/**
* 为模态框添加事件监听器
* @param {HTMLElement} modal - 模态框元素
*/
function addModalEventListeners(modal) {
// ESC键关闭模态框
const handleEscKey = (event) => {
if (event.key === 'Escape') {
modal.remove();
document.removeEventListener('keydown', handleEscKey);
}
};
// 点击背景关闭模态框
const handleBackgroundClick = (event) => {
if (event.target === modal) {
modal.remove();
document.removeEventListener('keydown', handleEscKey);
}
};
// 防止模态框内容区域点击时关闭模态框
const modalContent = modal.querySelector('.provider-modal-content');
const handleContentClick = (event) => {
event.stopPropagation();
};
// 密码切换按钮事件处理
const handlePasswordToggleClick = (event) => {
const button = event.target.closest('.password-toggle');
if (button) {
event.preventDefault();
event.stopPropagation();
handleProviderPasswordToggle(button);
}
};
// 上传按钮事件处理
const handleUploadButtonClick = (event) => {
const button = event.target.closest('.upload-btn');
if (button) {
event.preventDefault();
event.stopPropagation();
const targetInputId = button.getAttribute('data-target');
if (targetInputId && window.fileUploadHandler) {
window.fileUploadHandler.handleFileUpload(button, targetInputId);
}
}
};
// 添加事件监听器
document.addEventListener('keydown', handleEscKey);
modal.addEventListener('click', handleBackgroundClick);
if (modalContent) {
modalContent.addEventListener('click', handleContentClick);
modalContent.addEventListener('click', handlePasswordToggleClick);
modalContent.addEventListener('click', handleUploadButtonClick);
}
// 清理函数,在模态框关闭时调用
modal.cleanup = () => {
document.removeEventListener('keydown', handleEscKey);
modal.removeEventListener('click', handleBackgroundClick);
if (modalContent) {
modalContent.removeEventListener('click', handleContentClick);
modalContent.removeEventListener('click', handlePasswordToggleClick);
modalContent.removeEventListener('click', handleUploadButtonClick);
}
};
}
/**
* 关闭模态框并清理事件监听器
* @param {HTMLElement} button - 关闭按钮
*/
function closeProviderModal(button) {
const modal = button.closest('.provider-modal');
if (modal) {
if (modal.cleanup) {
modal.cleanup();
}
modal.remove();
}
}
/**
* 渲染供应商列表
* @param {Array} providers - 供应商数组
* @returns {string} HTML字符串
*/
function renderProviderList(providers) {
return providers.map(provider => {
const isHealthy = provider.isHealthy;
const lastUsed = provider.lastUsed ? new Date(provider.lastUsed).toLocaleString() : '从未使用';
const healthClass = isHealthy ? 'healthy' : 'unhealthy';
const healthIcon = isHealthy ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-warning';
const healthText = isHealthy ? '正常' : '异常';
return `
<div class="provider-item-detail ${healthClass}" data-uuid="${provider.uuid}">
<div class="provider-item-header" onclick="window.toggleProviderDetails('${provider.uuid}')">
<div class="provider-info">
<div class="provider-name">${provider.uuid}</div>
<div class="provider-meta">
<span class="health-status">
<i class="${healthIcon}"></i>
健康状态: ${healthText}
</span> |
使用次数: ${provider.usageCount || 0} |
最后使用: ${lastUsed}
</div>
</div>
<div class="provider-actions-group">
<button class="btn-small btn-edit" onclick="window.editProvider('${provider.uuid}', event)">
<i class="fas fa-edit"></i>
</button>
<button class="btn-small btn-delete" onclick="window.deleteProvider('${provider.uuid}', event)">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="provider-item-content" id="content-${provider.uuid}">
<div class="">
${renderProviderConfig(provider)}
</div>
</div>
</div>
`;
}).join('');
}
/**
* 渲染供应商配置
* @param {Object} provider - 供应商对象
* @returns {string} HTML字符串
*/
function renderProviderConfig(provider) {
// 获取字段映射,确保顺序一致
const fieldOrder = getFieldOrder(provider);
// 先渲染基础配置字段checkModelName 和 checkHealth
let html = '<div class="form-grid">';
const baseFields = ['checkModelName', 'checkHealth'];
baseFields.forEach(fieldKey => {
const displayLabel = getFieldLabel(fieldKey);
const value = provider[fieldKey];
const displayValue = value || '';
// 如果是 checkHealth 字段,使用下拉选择框
if (fieldKey === 'checkHealth') {
// 如果没有值,默认为 false
const actualValue = value !== undefined ? value : false;
const isEnabled = actualValue === true || actualValue === 'true';
html += `
<div class="config-item">
<label>${displayLabel}</label>
<select class="form-control"
data-config-key="${fieldKey}"
data-config-value="${actualValue}"
disabled>
<option value="true" ${isEnabled ? 'selected' : ''}>启用</option>
<option value="false" ${!isEnabled ? 'selected' : ''}>禁用</option>
</select>
</div>
`;
} else {
// checkModelName 字段始终显示
html += `
<div class="config-item">
<label>${displayLabel}</label>
<input type="text"
value="${displayValue}"
readonly
data-config-key="${fieldKey}"
data-config-value="${value || ''}">
</div>
`;
}
});
html += '</div>';
// 渲染其他配置字段每行2列
const otherFields = fieldOrder.filter(key => !baseFields.includes(key));
for (let i = 0; i < otherFields.length; i += 2) {
html += '<div class="form-grid">';
const field1Key = otherFields[i];
const field1Label = getFieldLabel(field1Key);
const field1Value = provider[field1Key];
const field1IsPassword = field1Key.toLowerCase().includes('key') || field1Key.toLowerCase().includes('password');
const field1IsOAuthFilePath = field1Key.includes('OAUTH_CREDS_FILE_PATH');
const field1DisplayValue = field1IsPassword && field1Value ? '••••••••' : (field1Value || '');
if (field1IsPassword) {
html += `
<div class="config-item">
<label>${field1Label}</label>
<div class="password-input-wrapper">
<input type="password"
value="${field1DisplayValue}"
readonly
data-config-key="${field1Key}"
data-config-value="${field1Value || ''}">
<button type="button" class="password-toggle" data-target="${field1Key}">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
`;
} else if (field1IsOAuthFilePath) {
// OAuth凭据文件路径字段添加上传按钮
html += `
<div class="config-item">
<label>${field1Label}</label>
<div class="file-input-group">
<input type="text"
id="edit-${provider.uuid}-${field1Key}"
value="${field1Value || ''}"
readonly
data-config-key="${field1Key}"
data-config-value="${field1Value || ''}">
<button type="button" class="btn btn-outline upload-btn" data-target="edit-${provider.uuid}-${field1Key}" aria-label="上传文件" disabled>
<i class="fas fa-upload"></i>
</button>
</div>
</div>
`;
} else {
html += `
<div class="config-item">
<label>${field1Label}</label>
<input type="text"
value="${field1DisplayValue}"
readonly
data-config-key="${field1Key}"
data-config-value="${field1Value || ''}">
</div>
`;
}
// 如果有第二个字段
if (i + 1 < otherFields.length) {
const field2Key = otherFields[i + 1];
const field2Label = getFieldLabel(field2Key);
const field2Value = provider[field2Key];
const field2IsPassword = field2Key.toLowerCase().includes('key') || field2Key.toLowerCase().includes('password');
const field2IsOAuthFilePath = field2Key.includes('OAUTH_CREDS_FILE_PATH');
const field2DisplayValue = field2IsPassword && field2Value ? '••••••••' : (field2Value || '');
if (field2IsPassword) {
html += `
<div class="config-item">
<label>${field2Label}</label>
<div class="password-input-wrapper">
<input type="password"
value="${field2DisplayValue}"
readonly
data-config-key="${field2Key}"
data-config-value="${field2Value || ''}">
<button type="button" class="password-toggle" data-target="${field2Key}">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
`;
} else if (field2IsOAuthFilePath) {
// OAuth凭据文件路径字段添加上传按钮
html += `
<div class="config-item">
<label>${field2Label}</label>
<div class="file-input-group">
<input type="text"
id="edit-${provider.uuid}-${field2Key}"
value="${field2Value || ''}"
readonly
data-config-key="${field2Key}"
data-config-value="${field2Value || ''}">
<button type="button" class="btn btn-outline upload-btn" data-target="edit-${provider.uuid}-${field2Key}" aria-label="上传文件" disabled>
<i class="fas fa-upload"></i>
</button>
</div>
</div>
`;
} else {
html += `
<div class="config-item">
<label>${field2Label}</label>
<input type="text"
value="${field2DisplayValue}"
readonly
data-config-key="${field2Key}"
data-config-value="${field2Value || ''}">
</div>
`;
}
}
html += '</div>';
}
return html;
}
/**
* 获取字段显示顺序
* @param {Object} provider - 供应商对象
* @returns {Array} 字段键数组
*/
function getFieldOrder(provider) {
const orderedFields = ['checkModelName', 'checkHealth'];
// 获取所有其他配置项
const otherFields = Object.keys(provider).filter(key =>
key !== 'isHealthy' && key !== 'lastUsed' && key !== 'usageCount' &&
key !== 'errorCount' && key !== 'lastErrorTime' && key !== 'uuid' &&
!orderedFields.includes(key)
);
// 按字母顺序排序其他字段
otherFields.sort();
return [...orderedFields, ...otherFields].filter(key => provider.hasOwnProperty(key));
}
/**
* 切换供应商详情显示
* @param {string} uuid - 供应商UUID
*/
function toggleProviderDetails(uuid) {
const content = document.getElementById(`content-${uuid}`);
if (content) {
content.classList.toggle('expanded');
}
}
/**
* 编辑供应商
* @param {string} uuid - 供应商UUID
* @param {Event} event - 事件对象
*/
function editProvider(uuid, event) {
event.stopPropagation();
const providerDetail = event.target.closest('.provider-item-detail');
const configInputs = providerDetail.querySelectorAll('input[data-config-key]');
const configSelects = providerDetail.querySelectorAll('select[data-config-key]');
const content = providerDetail.querySelector(`#content-${uuid}`);
// 如果还没有展开,则自动展开编辑框
if (content && !content.classList.contains('expanded')) {
toggleProviderDetails(uuid);
}
// 等待一小段时间让展开动画完成,然后切换输入框为可编辑状态
setTimeout(() => {
// 切换输入框为可编辑状态
configInputs.forEach(input => {
input.readOnly = false;
if (input.type === 'password') {
const actualValue = input.dataset.configValue;
input.value = actualValue;
}
});
// 启用文件上传按钮
const uploadButtons = providerDetail.querySelectorAll('.upload-btn');
uploadButtons.forEach(button => {
button.disabled = false;
});
// 启用下拉选择框
configSelects.forEach(select => {
select.disabled = false;
});
// 替换编辑按钮为保存和取消按钮
const actionsGroup = providerDetail.querySelector('.provider-actions-group');
actionsGroup.innerHTML = `
<button class="btn-small btn-save" onclick="window.saveProvider('${uuid}', event)">
<i class="fas fa-save"></i>
</button>
<button class="btn-small btn-cancel" onclick="window.cancelEdit('${uuid}', event)">
<i class="fas fa-times"></i>
</button>
`;
}, 100);
}
/**
* 取消编辑
* @param {string} uuid - 供应商UUID
* @param {Event} event - 事件对象
*/
function cancelEdit(uuid, event) {
event.stopPropagation();
const providerDetail = event.target.closest('.provider-item-detail');
const configInputs = providerDetail.querySelectorAll('input[data-config-key]');
const configSelects = providerDetail.querySelectorAll('select[data-config-key]');
// 恢复输入框为只读状态
configInputs.forEach(input => {
input.readOnly = true;
// 恢复显示为密码格式(如果有的话)
if (input.type === 'password') {
const actualValue = input.dataset.configValue;
input.value = actualValue ? '••••••••' : '';
}
});
// 禁用文件上传按钮
const uploadButtons = providerDetail.querySelectorAll('.upload-btn');
uploadButtons.forEach(button => {
button.disabled = true;
});
// 禁用下拉选择框
configSelects.forEach(select => {
select.disabled = true;
// 恢复原始值
const originalValue = select.dataset.configValue;
select.value = originalValue || '';
});
// 恢复原来的编辑和删除按钮
const actionsGroup = providerDetail.querySelector('.provider-actions-group');
actionsGroup.innerHTML = `
<button class="btn-small btn-edit" onclick="window.editProvider('${uuid}', event)">
<i class="fas fa-edit"></i>
</button>
<button class="btn-small btn-delete" onclick="window.deleteProvider('${uuid}', event)">
<i class="fas fa-trash"></i>
</button>
`;
}
/**
* 保存供应商
* @param {string} uuid - 供应商UUID
* @param {Event} event - 事件对象
*/
async function saveProvider(uuid, event) {
event.stopPropagation();
const providerDetail = event.target.closest('.provider-item-detail');
const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type');
const configInputs = providerDetail.querySelectorAll('input[data-config-key]');
const configSelects = providerDetail.querySelectorAll('select[data-config-key]');
const providerConfig = {};
configInputs.forEach(input => {
const key = input.dataset.configKey;
const value = input.value;
providerConfig[key] = value;
});
configSelects.forEach(select => {
const key = select.dataset.configKey;
const value = select.value === 'true';
providerConfig[key] = value;
});
try {
const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}/${uuid}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ providerConfig }),
});
if (response.ok) {
showToast('供应商配置更新成功', 'success');
// 重新获取该供应商类型的最新配置
await refreshProviderConfig(providerType);
} else {
const error = await response.json();
showToast('更新失败: ' + error.error.message, 'error');
}
} catch (error) {
console.error('Failed to update provider:', error);
showToast('更新失败: ' + error.message, 'error');
}
}
/**
* 删除供应商
* @param {string} uuid - 供应商UUID
* @param {Event} event - 事件对象
*/
async function deleteProvider(uuid, event) {
event.stopPropagation();
if (!confirm('确定要删除这个供应商配置吗?此操作不可恢复。')) {
return;
}
const providerDetail = event.target.closest('.provider-item-detail');
const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type');
try {
const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}/${uuid}`, {
method: 'DELETE',
});
if (response.ok) {
showToast('供应商配置删除成功', 'success');
// 重新获取最新配置
await refreshProviderConfig(providerType);
} else {
const error = await response.json();
showToast('删除失败: ' + error.error.message, 'error');
}
} catch (error) {
console.error('Failed to delete provider:', error);
showToast('删除失败: ' + error.message, 'error');
}
}
/**
* 重新获取并刷新供应商配置
* @param {string} providerType - 供应商类型
*/
async function refreshProviderConfig(providerType) {
try {
// 重新获取该供应商类型的最新数据
const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}`);
if (response.ok) {
const data = await response.json();
// 如果当前显示的是该供应商类型的模态框,则更新模态框
const modal = document.querySelector('.provider-modal');
if (modal && modal.getAttribute('data-provider-type') === providerType) {
// 更新统计信息
const totalCountElement = modal.querySelector('.provider-summary-item .value');
if (totalCountElement) {
totalCountElement.textContent = data.totalCount;
}
const healthyCountElement = modal.querySelectorAll('.provider-summary-item .value')[1];
if (healthyCountElement) {
healthyCountElement.textContent = data.healthyCount;
}
// 重新渲染供应商列表
const providerList = modal.querySelector('.provider-list');
if (providerList) {
providerList.innerHTML = renderProviderList(data.providers);
}
}
}
// 同时更新主界面的供应商统计数据
if (typeof window.loadProviders === 'function') {
await window.loadProviders();
}
} catch (error) {
console.error('Failed to refresh provider config:', error);
}
}
/**
* 显示添加供应商表单
* @param {string} providerType - 供应商类型
*/
function showAddProviderForm(providerType) {
const modal = document.querySelector('.provider-modal');
const existingForm = modal.querySelector('.add-provider-form');
if (existingForm) {
existingForm.remove();
return;
}
const form = document.createElement('div');
form.className = 'add-provider-form';
form.innerHTML = `
<h4><i class="fas fa-plus"></i> </h4>
<div class="form-grid">
<div class="form-group">
<label>检查模型名称</label>
<input type="text" id="newCheckModelName" placeholder="例如: gpt-3.5-turbo">
</div>
<div class="form-group">
<label>健康检查</label>
<select id="newCheckHealth">
<option value="true">启用</option>
<option value="false">禁用</option>
</select>
</div>
</div>
<div id="dynamicConfigFields">
<!-- 动态配置字段将在这里显示 -->
</div>
<div class="form-actions" style="margin-top: 15px;">
<button class="btn btn-success" onclick="window.addProvider('${providerType}')">
<i class="fas fa-save"></i>
</button>
<button class="btn btn-secondary" onclick="this.closest('.add-provider-form').remove()">
<i class="fas fa-times"></i>
</button>
</div>
`;
// 添加动态配置字段
addDynamicConfigFields(form, providerType);
// 为添加表单中的密码切换按钮绑定事件监听器
bindAddFormPasswordToggleListeners(form);
// 插入到提供商列表前面
const providerList = modal.querySelector('.provider-list');
providerList.parentNode.insertBefore(form, providerList);
}
/**
* 添加动态配置字段
* @param {HTMLElement} form - 表单元素
* @param {string} providerType - 供应商类型
*/
function addDynamicConfigFields(form, providerType) {
const configFields = form.querySelector('#dynamicConfigFields');
// 获取该提供商类型的字段配置
const providerFields = getProviderTypeFields(providerType);
let fields = '';
if (providerFields.length > 0) {
// 分组显示,每行两个字段
for (let i = 0; i < providerFields.length; i += 2) {
fields += '<div class="form-grid">';
const field1 = providerFields[i];
// 检查是否为密码类型字段
const isPassword1 = field1.type === 'password';
// 检查是否为OAuth凭据文件路径字段
const isOAuthFilePath1 = field1.id.includes('OauthCredsFilePath');
if (isPassword1) {
fields += `
<div class="form-group">
<label>${field1.label}</label>
<div class="password-input-wrapper">
<input type="password" id="new${field1.id}" placeholder="${field1.placeholder || ''}" value="${field1.value || ''}">
<button type="button" class="password-toggle" data-target="new${field1.id}">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
`;
} else if (isOAuthFilePath1) {
// OAuth凭据文件路径字段添加上传按钮
fields += `
<div class="form-group">
<label>${field1.label}</label>
<div class="file-input-group">
<input type="text" id="new${field1.id}" class="form-control" placeholder="${field1.placeholder || ''}" value="${field1.value || ''}">
<button type="button" class="btn btn-outline upload-btn" data-target="new${field1.id}" aria-label="上传文件">
<i class="fas fa-upload"></i>
</button>
</div>
</div>
`;
} else {
fields += `
<div class="form-group">
<label>${field1.label}</label>
<input type="${field1.type}" id="new${field1.id}" placeholder="${field1.placeholder || ''}" value="${field1.value || ''}">
</div>
`;
}
const field2 = providerFields[i + 1];
if (field2) {
// 检查是否为密码类型字段
const isPassword2 = field2.type === 'password';
// 检查是否为OAuth凭据文件路径字段
const isOAuthFilePath2 = field2.id.includes('OauthCredsFilePath');
if (isPassword2) {
fields += `
<div class="form-group">
<label>${field2.label}</label>
<div class="password-input-wrapper">
<input type="password" id="new${field2.id}" placeholder="${field2.placeholder || ''}" value="${field2.value || ''}">
<button type="button" class="password-toggle" data-target="new${field2.id}">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
`;
} else if (isOAuthFilePath2) {
// OAuth凭据文件路径字段添加上传按钮
fields += `
<div class="form-group">
<label>${field2.label}</label>
<div class="file-input-group">
<input type="text" id="new${field2.id}" class="form-control" placeholder="${field2.placeholder || ''}" value="${field2.value || ''}">
<button type="button" class="btn btn-outline upload-btn" data-target="new${field2.id}" aria-label="上传文件">
<i class="fas fa-upload"></i>
</button>
</div>
</div>
`;
} else {
fields += `
<div class="form-group">
<label>${field2.label}</label>
<input type="${field2.type}" id="new${field2.id}" placeholder="${field2.placeholder || ''}" value="${field2.value || ''}">
</div>
`;
}
}
fields += '</div>';
}
} else {
fields = '<p>不支持的提供商类型</p>';
}
configFields.innerHTML = fields;
}
/**
* 为添加新供应商表单中的密码切换按钮绑定事件监听器
* @param {HTMLElement} form - 表单元素
*/
function bindAddFormPasswordToggleListeners(form) {
const passwordToggles = form.querySelectorAll('.password-toggle');
passwordToggles.forEach(button => {
button.addEventListener('click', function() {
const targetId = this.getAttribute('data-target');
const input = document.getElementById(targetId);
const icon = this.querySelector('i');
if (!input || !icon) return;
if (input.type === 'password') {
input.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
input.type = 'password';
icon.className = 'fas fa-eye';
}
});
});
}
/**
* 添加新供应商
* @param {string} providerType - 供应商类型
*/
async function addProvider(providerType) {
const checkModelName = document.getElementById('newCheckModelName')?.value;
const checkHealth = document.getElementById('newCheckHealth')?.value === 'true';
if (!checkModelName) {
showToast('请输入检查模型名称', 'error');
return;
}
const providerConfig = {
checkModelName,
checkHealth
};
// 根据提供商类型收集配置
switch (providerType) {
case 'openai-custom':
providerConfig.OPENAI_API_KEY = document.getElementById('newOpenaiApiKey')?.value || '';
providerConfig.OPENAI_BASE_URL = document.getElementById('newOpenaiBaseUrl')?.value || '';
break;
case 'openaiResponses-custom':
providerConfig.OPENAI_API_KEY = document.getElementById('newOpenaiApiKey')?.value || '';
providerConfig.OPENAI_BASE_URL = document.getElementById('newOpenaiBaseUrl')?.value || '';
break;
case 'claude-custom':
providerConfig.CLAUDE_API_KEY = document.getElementById('newClaudeApiKey')?.value || '';
providerConfig.CLAUDE_BASE_URL = document.getElementById('newClaudeBaseUrl')?.value || '';
break;
case 'gemini-cli-oauth':
providerConfig.PROJECT_ID = document.getElementById('newProjectId')?.value || '';
providerConfig.GEMINI_OAUTH_CREDS_FILE_PATH = document.getElementById('newGeminiOauthCredsFilePath')?.value || '';
break;
case 'claude-kiro-oauth':
providerConfig.KIRO_OAUTH_CREDS_FILE_PATH = document.getElementById('newKiroOauthCredsFilePath')?.value || '';
break;
case 'openai-qwen-oauth':
providerConfig.QWEN_OAUTH_CREDS_FILE_PATH = document.getElementById('newQwenOauthCredsFilePath')?.value || '';
break;
}
try {
const response = await fetch('/api/providers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
providerType,
providerConfig
}),
});
if (response.ok) {
showToast('供应商配置添加成功', 'success');
// 移除添加表单
const form = document.querySelector('.add-provider-form');
if (form) {
form.remove();
}
// 重新获取最新配置数据
await refreshProviderConfig(providerType);
} else {
const error = await response.json();
showToast('添加失败: ' + error.error.message, 'error');
}
} catch (error) {
console.error('Failed to add provider:', error);
showToast('添加失败: ' + error.message, 'error');
}
}
// 导出所有函数并挂载到window对象供HTML调用
export {
showProviderManagerModal,
closeProviderModal,
toggleProviderDetails,
editProvider,
cancelEdit,
saveProvider,
deleteProvider,
refreshProviderConfig,
showAddProviderForm,
addProvider
};
// 将函数挂载到window对象
window.closeProviderModal = closeProviderModal;
window.toggleProviderDetails = toggleProviderDetails;
window.editProvider = editProvider;
window.cancelEdit = cancelEdit;
window.saveProvider = saveProvider;
window.deleteProvider = deleteProvider;
window.showAddProviderForm = showAddProviderForm;
window.addProvider = addProvider;

75
static/app/navigation.js Normal file
View file

@ -0,0 +1,75 @@
// 导航功能模块
import { elements } from './constants.js';
/**
* 初始化导航功能
*/
function initNavigation() {
if (!elements.navItems || !elements.sections) {
console.warn('导航元素未找到');
return;
}
elements.navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const sectionId = item.dataset.section;
// 更新导航状态
elements.navItems.forEach(nav => nav.classList.remove('active'));
item.classList.add('active');
// 显示对应章节
elements.sections.forEach(section => {
section.classList.remove('active');
if (section.id === sectionId) {
section.classList.add('active');
}
});
});
});
}
/**
* 切换到指定章节
* @param {string} sectionId - 章节ID
*/
function switchToSection(sectionId) {
// 更新导航状态
elements.navItems.forEach(nav => {
nav.classList.remove('active');
if (nav.dataset.section === sectionId) {
nav.classList.add('active');
}
});
// 显示对应章节
elements.sections.forEach(section => {
section.classList.remove('active');
if (section.id === sectionId) {
section.classList.add('active');
}
});
}
/**
* 切换到仪表盘页面
*/
function switchToDashboard() {
switchToSection('dashboard');
}
/**
* 切换到提供商页面
*/
function switchToProviders() {
switchToSection('providers');
}
export {
initNavigation,
switchToSection,
switchToDashboard,
switchToProviders
};

View file

@ -0,0 +1,305 @@
// 供应商管理功能模块
import { providerStats, updateProviderStats } from './constants.js';
import { showToast } from './utils.js';
// 保存初始服务器时间和运行时间
let initialServerTime = null;
let initialUptime = null;
let initialLoadTime = null;
/**
* 加载系统信息
*/
async function loadSystemInfo() {
try {
const response = await fetch('/api/system');
const data = await response.json();
const nodeVersionEl = document.getElementById('nodeVersion');
const serverTimeEl = document.getElementById('serverTime');
const memoryUsageEl = document.getElementById('memoryUsage');
const uptimeEl = document.getElementById('uptime');
if (nodeVersionEl) nodeVersionEl.textContent = data.nodeVersion || '--';
if (memoryUsageEl) memoryUsageEl.textContent = data.memoryUsage || '--';
// 保存初始时间用于本地计算
if (data.serverTime && data.uptime !== undefined) {
initialServerTime = new Date(data.serverTime);
initialUptime = data.uptime;
initialLoadTime = Date.now();
}
// 初始显示
if (serverTimeEl) serverTimeEl.textContent = data.serverTime || '--';
if (uptimeEl) uptimeEl.textContent = data.uptime ? formatUptime(data.uptime) : '--';
} catch (error) {
console.error('Failed to load system info:', error);
}
}
/**
* 更新服务器时间和运行时间显示本地计算
*/
function updateTimeDisplay() {
if (!initialServerTime || initialUptime === null || !initialLoadTime) {
return;
}
const serverTimeEl = document.getElementById('serverTime');
const uptimeEl = document.getElementById('uptime');
// 计算经过的秒数
const elapsedSeconds = Math.floor((Date.now() - initialLoadTime) / 1000);
// 更新服务器时间
if (serverTimeEl) {
const currentServerTime = new Date(initialServerTime.getTime() + elapsedSeconds * 1000);
serverTimeEl.textContent = currentServerTime.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
// 更新运行时间
if (uptimeEl) {
const currentUptime = initialUptime + elapsedSeconds;
uptimeEl.textContent = formatUptime(currentUptime);
}
}
/**
* 加载提供商列表
*/
async function loadProviders() {
try {
const response = await fetch('/api/providers');
const data = await response.json();
renderProviders(data);
} catch (error) {
console.error('Failed to load providers:', error);
}
}
/**
* 渲染提供商列表
* @param {Object} providers - 提供商数据
*/
function renderProviders(providers) {
const container = document.getElementById('providersList');
if (!container) return;
container.innerHTML = '';
// 检查是否有供应商池数据
const hasProviders = Object.keys(providers).length > 0;
const statsGrid = document.querySelector('#providers .stats-grid');
if (hasProviders) {
// 显示统计卡片
if (statsGrid) statsGrid.style.display = 'grid';
// 计算总统计
let totalAccounts = 0;
let totalHealthy = 0;
// 定义提供商显示顺序
const providerDisplayOrder = [
'gemini-cli-oauth',
'openai-custom',
'claude-custom',
'claude-kiro-oauth',
'openai-qwen-oauth',
'openaiResponses-custom'
];
// 获取所有提供商类型并按指定顺序排序
const allProviderTypes = Object.keys(providers);
const sortedProviderTypes = providerDisplayOrder.filter(type => allProviderTypes.includes(type))
.concat(allProviderTypes.filter(type => !providerDisplayOrder.includes(type)));
// 按照排序后的提供商类型渲染
sortedProviderTypes.forEach((providerType) => {
const accounts = providers[providerType];
const providerDiv = document.createElement('div');
providerDiv.className = 'provider-item';
providerDiv.dataset.providerType = providerType;
providerDiv.style.cursor = 'pointer';
const healthyCount = accounts.filter(acc => acc.isHealthy).length;
const totalCount = accounts.length;
const usageCount = accounts.reduce((sum, acc) => sum + (acc.usageCount || 0), 0);
const errorCount = accounts.reduce((sum, acc) => sum + (acc.errorCount || 0), 0);
totalAccounts += totalCount;
totalHealthy += healthyCount;
// 更新全局统计变量
if (!providerStats.providerTypeStats[providerType]) {
providerStats.providerTypeStats[providerType] = {
totalAccounts: 0,
healthyAccounts: 0,
totalUsage: 0,
totalErrors: 0,
lastUpdate: null
};
}
const typeStats = providerStats.providerTypeStats[providerType];
typeStats.totalAccounts = totalCount;
typeStats.healthyAccounts = healthyCount;
typeStats.totalUsage = usageCount;
typeStats.totalErrors = errorCount;
typeStats.lastUpdate = new Date().toISOString();
providerDiv.innerHTML = `
<div class="provider-header">
<div class="provider-name">
<span class="provider-type-text">${providerType}</span>
</div>
<div class="provider-status status-${healthyCount === totalCount ? 'healthy' : 'unhealthy'}">
<i class="fas fa-${healthyCount === totalCount ? 'check-circle' : 'exclamation-triangle'}"></i>
<span>${healthyCount}/${totalCount} 健康</span>
</div>
</div>
<div class="provider-stats">
<div class="provider-stat">
<span class="provider-stat-label">总账户</span>
<span class="provider-stat-value">${totalCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">健康账户</span>
<span class="provider-stat-value">${healthyCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">使用次数</span>
<span class="provider-stat-value">${usageCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">错误次数</span>
<span class="provider-stat-value">${errorCount}</span>
</div>
</div>
`;
// 添加点击事件 - 整个供应商组都可以点击
providerDiv.addEventListener('click', (e) => {
e.preventDefault();
openProviderManager(providerType);
});
container.appendChild(providerDiv);
});
// 更新统计卡片数据
const activeProviders = Object.keys(providers).length;
updateProviderStatsDisplay(activeProviders, totalHealthy, totalAccounts);
} else {
// 隐藏统计卡片
if (statsGrid) statsGrid.style.display = 'none';
// 显示无数据提示
container.innerHTML = '<div class="no-providers"><p>暂无供应商池配置</p></div>';
}
}
/**
* 更新提供商统计信息
* @param {number} activeProviders - 活跃提供商数
* @param {number} healthyProviders - 健康提供商数
* @param {number} totalAccounts - 总账户数
*/
function updateProviderStatsDisplay(activeProviders, healthyProviders, totalAccounts) {
// 更新全局统计变量
const newStats = {
activeProviders,
healthyProviders,
totalAccounts,
lastUpdateTime: new Date().toISOString()
};
updateProviderStats(newStats);
// 计算总请求数和错误数
let totalUsage = 0;
let totalErrors = 0;
Object.values(providerStats.providerTypeStats).forEach(typeStats => {
totalUsage += typeStats.totalUsage || 0;
totalErrors += typeStats.totalErrors || 0;
});
const finalStats = {
...newStats,
totalRequests: totalUsage,
totalErrors: totalErrors
};
updateProviderStats(finalStats);
// 修改:根据使用次数统计"活跃提供商"和"活动连接"
// "活跃提供商":统计有使用次数(usageCount > 0)的提供商类型数量
let activeProvidersByUsage = 0;
Object.entries(providerStats.providerTypeStats).forEach(([providerType, typeStats]) => {
if (typeStats.totalUsage > 0) {
activeProvidersByUsage++;
}
});
// "活动连接":统计所有提供商账户的使用次数总和
const activeConnections = totalUsage;
// 更新页面显示
const activeProvidersEl = document.getElementById('activeProviders');
const healthyProvidersEl = document.getElementById('healthyProviders');
const activeConnectionsEl = document.getElementById('activeConnections');
if (activeProvidersEl) activeProvidersEl.textContent = activeProvidersByUsage;
if (healthyProvidersEl) healthyProvidersEl.textContent = healthyProviders;
if (activeConnectionsEl) activeConnectionsEl.textContent = activeConnections;
// 打印调试信息到控制台
console.log('Provider Stats Updated:', {
activeProviders,
activeProvidersByUsage,
healthyProviders,
totalAccounts,
totalUsage,
totalErrors,
providerTypeStats: providerStats.providerTypeStats
});
}
/**
* 打开供应商管理模态框
* @param {string} providerType - 提供商类型
*/
async function openProviderManager(providerType) {
try {
const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}`);
const data = await response.json();
showProviderManagerModal(data);
} catch (error) {
console.error('Failed to load provider details:', error);
showToast('加载供应商详情失败', 'error');
}
}
// 导入工具函数
import { formatUptime } from './utils.js';
export {
loadSystemInfo,
updateTimeDisplay,
loadProviders,
renderProviders,
updateProviderStatsDisplay,
openProviderManager
};

View file

@ -0,0 +1,337 @@
// 路径路由示例功能模块
import { showToast } from './utils.js';
/**
* 初始化路径路由示例功能
*/
function initRoutingExamples() {
// 延迟初始化确保所有DOM都加载完成
setTimeout(() => {
initProtocolTabs();
initCopyButtons();
initCardInteractions();
}, 100);
}
/**
* 初始化协议标签切换功能
*/
function initProtocolTabs() {
// 使用事件委托方式绑定点击事件
document.addEventListener('click', function(e) {
// 检查点击的是不是协议标签或者其子元素
const tab = e.target.classList.contains('protocol-tab') ? e.target : e.target.closest('.protocol-tab');
if (tab) {
e.preventDefault();
e.stopPropagation();
const targetProtocol = tab.dataset.protocol;
const card = tab.closest('.routing-example-card');
if (!card) {
return;
}
// 移除当前卡片中所有标签和内容的活动状态
const cardTabs = card.querySelectorAll('.protocol-tab');
const cardContents = card.querySelectorAll('.protocol-content');
cardTabs.forEach(t => t.classList.remove('active'));
cardContents.forEach(c => c.classList.remove('active'));
// 为当前标签和对应内容添加活动状态
tab.classList.add('active');
// 使用更精确的选择器来查找对应的内容
const targetContent = card.querySelector(`.protocol-content[data-protocol="${targetProtocol}"]`);
if (targetContent) {
targetContent.classList.add('active');
}
}
});
}
/**
* 初始化复制按钮功能
*/
function initCopyButtons() {
document.addEventListener('click', async function(e) {
if (e.target.closest('.copy-btn')) {
e.stopPropagation();
const button = e.target.closest('.copy-btn');
const path = button.dataset.path;
if (!path) return;
try {
await navigator.clipboard.writeText(path);
showToast(`路径已复制: ${path}`, 'success');
// 临时更改按钮图标
const icon = button.querySelector('i');
if (icon) {
const originalClass = icon.className;
icon.className = 'fas fa-check';
button.style.color = 'var(--success-color)';
setTimeout(() => {
icon.className = originalClass;
button.style.color = '';
}, 2000);
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
showToast('复制失败', 'error');
}
}
});
}
/**
* 初始化卡片交互功能
*/
function initCardInteractions() {
const routingCards = document.querySelectorAll('.routing-example-card');
routingCards.forEach(card => {
// 添加悬停效果
card.addEventListener('mouseenter', () => {
card.style.transform = 'translateY(-4px)';
card.style.boxShadow = 'var(--shadow-lg)';
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
card.style.boxShadow = '';
});
});
}
/**
* 获取所有可用的路由端点
* @returns {Array} 路由端点数组
*/
function getAvailableRoutes() {
return [
{
provider: 'claude-custom',
name: 'Claude Custom',
paths: {
openai: '/claude-custom/v1/chat/completions',
claude: '/claude-custom/v1/messages'
},
description: '官方Claude API',
badge: '官方API',
badgeClass: 'official'
},
{
provider: 'claude-kiro-oauth',
name: 'Claude Kiro OAuth',
paths: {
openai: '/claude-kiro-oauth/v1/chat/completions',
claude: '/claude-kiro-oauth/v1/messages'
},
description: '免费使用Claude Sonnet 4.5',
badge: '免费使用',
badgeClass: 'oauth'
},
{
provider: 'openai-custom',
name: 'OpenAI Custom',
paths: {
openai: '/openai-custom/v1/chat/completions',
claude: '/openai-custom/v1/messages'
},
description: '官方OpenAI API',
badge: '官方API',
badgeClass: 'official'
},
{
provider: 'gemini-cli-oauth',
name: 'Gemini CLI OAuth',
paths: {
openai: '/gemini-cli-oauth/v1/chat/completions',
claude: '/gemini-cli-oauth/v1/messages'
},
description: '突破Gemini免费限制',
badge: '突破限制',
badgeClass: 'oauth'
},
{
provider: 'openai-qwen-oauth',
name: 'Qwen OAuth',
paths: {
openai: '/openai-qwen-oauth/v1/chat/completions',
claude: '/openai-qwen-oauth/v1/messages'
},
description: 'Qwen Code Plus',
badge: '代码专用',
badgeClass: 'oauth'
},
{
provider: 'openaiResponses-custom',
name: 'OpenAI Responses',
paths: {
openai: '/openaiResponses-custom/v1/responses',
claude: '/openaiResponses-custom/v1/messages'
},
description: '结构化对话API',
badge: '结构化对话',
badgeClass: 'responses'
}
];
}
/**
* 高亮显示特定提供商路由
* @param {string} provider - 提供商标识
*/
function highlightProviderRoute(provider) {
const card = document.querySelector(`[data-provider="${provider}"]`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.style.borderColor = 'var(--success-color)';
card.style.boxShadow = '0 0 0 3px rgba(16, 185, 129, 0.1)';
setTimeout(() => {
card.style.borderColor = '';
card.style.boxShadow = '';
}, 3000);
showToast(`已定位到: ${provider}`, 'success');
}
}
/**
* 复制curl命令示例
* @param {string} provider - 提供商标识
* @param {Object} options - 选项参数
*/
async function copyCurlExample(provider, options = {}) {
const routes = getAvailableRoutes();
const route = routes.find(r => r.provider === provider);
if (!route) {
showToast('未找到对应的路由', 'error');
return;
}
const { protocol = 'openai', model = 'default-model', message = 'Hello!' } = options;
const path = route.paths[protocol];
if (!path) {
showToast('未找到对应的协议路径', 'error');
return;
}
let curlCommand = '';
// 根据不同提供商和协议生成对应的curl命令
switch (provider) {
case 'claude-custom':
case 'claude-kiro-oauth':
if (protocol === 'openai') {
curlCommand = `curl http://localhost:3000${path} \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-d '{
"model": "${model}",
"messages": [{"role": "user", "content": "${message}"}],
"max_tokens": 1000
}'`;
} else {
curlCommand = `curl http://localhost:3000${path} \\
-H "Content-Type: application/json" \\
-d '{
"model": "${model}",
"max_tokens": 1000,
"messages": [{"role": "user", "content": "${message}"}]
}'`;
}
break;
case 'openai-custom':
case 'openai-qwen-oauth':
if (protocol === 'openai') {
curlCommand = `curl http://localhost:3000${path} \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-d '{
"model": "${model}",
"messages": [{"role": "user", "content": "${message}"}],
"max_tokens": 1000
}'`;
} else {
curlCommand = `curl http://localhost:3000${path} \\
-H "Content-Type: application/json" \\
-H "X-API-Key: YOUR_API_KEY" \\
-d '{
"model": "${model}",
"max_tokens": 1000,
"messages": [{"role": "user", "content": "${message}"}]
}'`;
}
break;
case 'gemini-cli-oauth':
if (protocol === 'openai') {
curlCommand = `curl http://localhost:3000${path} \\
-H "Content-Type: application/json" \\
-d '{
"model": "gemini-2.0-flash-exp",
"messages": [{"role": "user", "content": "${message}"}],
"max_tokens": 1000
}'`;
} else {
curlCommand = `curl http://localhost:3000${path} \\
-H "Content-Type: application/json" \\
-d '{
"model": "gemini-2.0-flash-exp",
"max_tokens": 1000,
"messages": [{"role": "user", "content": "${message}"}]
}'`;
}
break;
case 'openaiResponses-custom':
if (protocol === 'openai') {
curlCommand = `curl http://localhost:3000${path} \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-d '{
"model": "${model}",
"input": "${message}",
"max_output_tokens": 1000
}'`;
} else {
curlCommand = `curl http://localhost:3000${path} \\
-H "Content-Type: application/json" \\
-H "X-API-Key: YOUR_API_KEY" \\
-d '{
"model": "${model}",
"max_tokens": 1000,
"messages": [{"role": "user", "content": "${message}"}]
}'`;
}
break;
}
try {
await navigator.clipboard.writeText(curlCommand);
showToast('curl命令已复制到剪贴板', 'success');
} catch (error) {
console.error('Failed to copy curl command:', error);
showToast('复制失败', 'error');
}
}
export {
initRoutingExamples,
getAvailableRoutes,
highlightProviderRoute,
copyCurlExample
};

2647
static/app/styles.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,785 @@
// 上传配置管理功能模块
import { showToast } from './utils.js';
let allConfigs = []; // 存储所有配置数据
let filteredConfigs = []; // 存储过滤后的配置数据
/**
* 搜索配置
* @param {string} searchTerm - 搜索关键词
* @param {string} statusFilter - 状态过滤
*/
function searchConfigs(searchTerm = '', statusFilter = '') {
if (!allConfigs.length) {
console.log('没有配置数据可搜索');
return;
}
filteredConfigs = allConfigs.filter(config => {
// 搜索过滤
const matchesSearch = !searchTerm ||
config.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
config.path.toLowerCase().includes(searchTerm.toLowerCase()) ||
(config.content && config.content.toLowerCase().includes(searchTerm.toLowerCase()));
// 状态过滤 - 从布尔值 isUsed 转换为状态字符串
const configStatus = config.isUsed ? 'used' : 'unused';
const matchesStatus = !statusFilter || configStatus === statusFilter;
return matchesSearch && matchesStatus;
});
renderConfigList();
updateStats();
}
/**
* 渲染配置列表
*/
function renderConfigList() {
const container = document.getElementById('configList');
if (!container) return;
container.innerHTML = '';
if (!filteredConfigs.length) {
container.innerHTML = '<div class="no-configs"><p>未找到匹配的配置文件</p></div>';
return;
}
filteredConfigs.forEach((config, index) => {
const configItem = createConfigItemElement(config, index);
container.appendChild(configItem);
});
}
/**
* 创建配置项元素
* @param {Object} config - 配置数据
* @param {number} index - 索引
* @returns {HTMLElement} 配置项元素
*/
function createConfigItemElement(config, index) {
// 从布尔值 isUsed 转换为状态字符串用于显示
const configStatus = config.isUsed ? 'used' : 'unused';
const item = document.createElement('div');
item.className = `config-item ${configStatus}`;
item.dataset.index = index;
const statusIcon = config.isUsed ? 'fa-check-circle' : 'fa-circle';
const statusText = config.isUsed ? '已关联' : '未关联';
const typeIcon = config.type === 'oauth' ? 'fa-key' :
config.type === 'api-key' ? 'fa-lock' :
config.type === 'provider-pool' ? 'fa-network-wired' :
config.type === 'system-prompt' ? 'fa-file-text' : 'fa-cog';
// 生成关联详情HTML
const usageInfoHtml = generateUsageInfoHtml(config);
item.innerHTML = `
<div class="config-item-header">
<div class="config-item-name">${config.name}</div>
<div class="config-item-path" title="${config.path}">${config.path}</div>
</div>
<div class="config-item-meta">
<div class="config-item-size">${formatFileSize(config.size)}</div>
<div class="config-item-modified">${formatDate(config.modified)}</div>
<div class="config-item-status">
<i class="fas ${statusIcon}"></i>
${statusText}
</div>
</div>
<div class="config-item-details">
<div class="config-details-grid">
<div class="config-detail-item">
<div class="config-detail-label">文件路径</div>
<div class="config-detail-value">${config.path}</div>
</div>
<div class="config-detail-item">
<div class="config-detail-label">文件大小</div>
<div class="config-detail-value">${formatFileSize(config.size)}</div>
</div>
<div class="config-detail-item">
<div class="config-detail-label">最后修改</div>
<div class="config-detail-value">${formatDate(config.modified)}</div>
</div>
<div class="config-detail-item">
<div class="config-detail-label">关联状态</div>
<div class="config-detail-value">${statusText}</div>
</div>
</div>
${usageInfoHtml}
<div class="config-item-actions">
<button class="btn-small btn-view" data-path="${config.path}">
<i class="fas fa-eye"></i>
</button>
<button class="btn-small btn-delete-small" data-path="${config.path}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
// 添加按钮事件监听器
const viewBtn = item.querySelector('.btn-view');
const deleteBtn = item.querySelector('.btn-delete-small');
if (viewBtn) {
viewBtn.addEventListener('click', (e) => {
e.stopPropagation();
viewConfig(config.path);
});
}
if (deleteBtn) {
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
deleteConfig(config.path);
});
}
// 添加点击事件展开/折叠详情
item.addEventListener('click', (e) => {
if (!e.target.closest('.config-item-actions')) {
item.classList.toggle('expanded');
}
});
return item;
}
/**
* 生成关联详情HTML
* @param {Object} config - 配置数据
* @returns {string} HTML字符串
*/
function generateUsageInfoHtml(config) {
if (!config.usageInfo || !config.usageInfo.isUsed) {
return '';
}
const { usageType, usageDetails } = config.usageInfo;
if (!usageDetails || usageDetails.length === 0) {
return '';
}
const typeLabels = {
'main_config': '主要配置',
'provider_pool': '供应商池',
'multiple': '多种用途'
};
const typeLabel = typeLabels[usageType] || '未知用途';
let detailsHtml = '';
usageDetails.forEach(detail => {
const icon = detail.type === '主要配置' ? 'fa-cog' : 'fa-network-wired';
const usageTypeKey = detail.type === '主要配置' ? 'main_config' : 'provider_pool';
detailsHtml += `
<div class="usage-detail-item" data-usage-type="${usageTypeKey}">
<i class="fas ${icon}"></i>
<span class="usage-detail-type">${detail.type}</span>
<span class="usage-detail-location">${detail.location}</span>
</div>
`;
});
return `
<div class="config-usage-info">
<div class="usage-info-header">
<i class="fas fa-link"></i>
<span class="usage-info-title">关联详情 (${typeLabel})</span>
</div>
<div class="usage-details-list">
${detailsHtml}
</div>
</div>
`;
}
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string} 格式化后的大小
*/
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* 格式化日期
* @param {string} dateString - 日期字符串
* @returns {string} 格式化后的日期
*/
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* 更新统计信息
*/
function updateStats() {
const totalCount = filteredConfigs.length;
const usedCount = filteredConfigs.filter(config => config.isUsed).length;
const unusedCount = filteredConfigs.filter(config => !config.isUsed).length;
const totalEl = document.getElementById('configCount');
const usedEl = document.getElementById('usedConfigCount');
const unusedEl = document.getElementById('unusedConfigCount');
if (totalEl) totalEl.textContent = `${totalCount} 个配置文件`;
if (usedEl) usedEl.textContent = `已关联: ${usedCount}`;
if (unusedEl) unusedEl.textContent = `未关联: ${unusedCount}`;
}
/**
* 加载配置文件列表
*/
async function loadConfigList() {
try {
const response = await fetch('/api/upload-configs');
if (response.ok) {
allConfigs = await response.json();
filteredConfigs = [...allConfigs];
renderConfigList();
updateStats();
// showToast('配置文件列表已刷新', 'success');
} else {
throw new Error('获取配置文件列表失败');
}
} catch (error) {
console.error('加载配置列表失败:', error);
showToast('加载配置列表失败: ' + error.message, 'error');
// 使用模拟数据作为示例
allConfigs = generateMockConfigData();
filteredConfigs = [...allConfigs];
renderConfigList();
updateStats();
}
}
/**
* 生成模拟配置数据用于演示
* @returns {Array} 模拟配置数据
*/
function generateMockConfigData() {
return [
{
name: 'provider_pools.json',
path: './provider_pools.json',
type: 'provider-pool',
size: 2048,
modified: '2025-11-11T04:30:00.000Z',
isUsed: true,
content: JSON.stringify({
"gemini-cli-oauth": [
{
"GEMINI_OAUTH_CREDS_FILE_PATH": "~/.gemini/oauth/creds.json",
"PROJECT_ID": "test-project"
}
]
}, null, 2)
},
{
name: 'config.json',
path: './config.json',
type: 'other',
size: 1024,
modified: '2025-11-10T12:00:00.000Z',
isUsed: true,
content: JSON.stringify({
"REQUIRED_API_KEY": "123456",
"SERVER_PORT": 3000
}, null, 2)
},
{
name: 'oauth_creds.json',
path: '~/.gemini/oauth/creds.json',
type: 'oauth',
size: 512,
modified: '2025-11-09T08:30:00.000Z',
isUsed: false,
content: '{"client_id": "test", "client_secret": "test"}'
},
{
name: 'input_system_prompt.txt',
path: './input_system_prompt.txt',
type: 'system-prompt',
size: 256,
modified: '2025-11-08T15:20:00.000Z',
isUsed: true,
content: '你是一个有用的AI助手...'
},
{
name: 'invalid_config.json',
path: './invalid_config.json',
type: 'other',
size: 128,
modified: '2025-11-07T10:15:00.000Z',
isUsed: false,
content: '{"invalid": json}'
}
];
}
/**
* 查看配置
* @param {string} path - 文件路径
*/
async function viewConfig(path) {
try {
const response = await fetch(`/api/upload-configs/view/${encodeURIComponent(path)}`);
if (response.ok) {
const fileData = await response.json();
showConfigModal(fileData);
} else {
const error = await response.json();
showToast('查看配置失败: ' + error.error.message, 'error');
}
} catch (error) {
console.error('查看配置失败:', error);
showToast('查看配置失败: ' + error.message, 'error');
}
}
/**
* 显示配置模态框
* @param {Object} fileData - 文件数据
*/
function showConfigModal(fileData) {
// 创建模态框
const modal = document.createElement('div');
modal.className = 'config-view-modal';
modal.innerHTML = `
<div class="config-modal-content">
<div class="config-modal-header">
<h3>配置文件: ${fileData.name}</h3>
<button class="modal-close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="config-modal-body">
<div class="config-file-info">
<div class="file-info-item">
<span class="info-label">文件路径:</span>
<span class="info-value">${fileData.path}</span>
</div>
<div class="file-info-item">
<span class="info-label">文件大小:</span>
<span class="info-value">${formatFileSize(fileData.size)}</span>
</div>
<div class="file-info-item">
<span class="info-label">最后修改:</span>
<span class="info-value">${formatDate(fileData.modified)}</span>
</div>
</div>
<div class="config-content">
<label>文件内容:</label>
<pre class="config-content-display">${escapeHtml(fileData.content)}</pre>
</div>
</div>
<div class="config-modal-footer">
<button class="btn btn-secondary btn-close-modal">关闭</button>
<button class="btn btn-primary btn-copy-content" data-path="${fileData.path}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
`;
// 添加到页面
document.body.appendChild(modal);
// 添加按钮事件监听器
const closeBtn = modal.querySelector('.btn-close-modal');
const copyBtn = modal.querySelector('.btn-copy-content');
const modalCloseBtn = modal.querySelector('.modal-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
closeConfigModal();
});
}
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const path = copyBtn.dataset.path;
copyConfigContent(path);
});
}
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', () => {
closeConfigModal();
});
}
// 显示模态框
setTimeout(() => modal.classList.add('show'), 10);
}
/**
* 关闭配置模态框
*/
function closeConfigModal() {
const modal = document.querySelector('.config-view-modal');
if (modal) {
modal.classList.remove('show');
setTimeout(() => modal.remove(), 300);
}
}
/**
* 复制配置内容
* @param {string} path - 文件路径
*/
async function copyConfigContent(path) {
try {
const response = await fetch(`/api/upload-configs/view/${encodeURIComponent(path)}`);
if (response.ok) {
const fileData = await response.json();
// 尝试使用现代 Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(fileData.content);
showToast('内容已复制到剪贴板', 'success');
} else {
// 降级方案:使用传统的 document.execCommand
const textarea = document.createElement('textarea');
textarea.value = fileData.content;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showToast('内容已复制到剪贴板', 'success');
} else {
showToast('复制失败,请手动复制', 'error');
}
} catch (err) {
console.error('复制失败:', err);
showToast('复制失败,请手动复制', 'error');
} finally {
document.body.removeChild(textarea);
}
}
} else {
showToast('获取配置内容失败', 'error');
}
} catch (error) {
console.error('复制失败:', error);
showToast('复制失败: ' + error.message, 'error');
}
}
/**
* HTML转义
* @param {string} text - 要转义的文本
* @returns {string} 转义后的文本
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 显示删除确认模态框
* @param {Object} config - 配置数据
*/
function showDeleteConfirmModal(config) {
const isUsed = config.isUsed;
const modalClass = isUsed ? 'delete-confirm-modal used' : 'delete-confirm-modal unused';
const title = isUsed ? '删除已关联配置' : '删除配置文件';
const icon = isUsed ? 'fas fa-exclamation-triangle' : 'fas fa-trash';
const buttonClass = isUsed ? 'btn btn-danger' : 'btn btn-warning';
const modal = document.createElement('div');
modal.className = modalClass;
modal.innerHTML = `
<div class="delete-modal-content">
<div class="delete-modal-header">
<h3><i class="${icon}"></i> ${title}</h3>
<button class="modal-close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="delete-modal-body">
<div class="delete-warning ${isUsed ? 'warning-used' : 'warning-unused'}">
<div class="warning-icon">
<i class="${icon}"></i>
</div>
<div class="warning-content">
${isUsed ?
'<h4>⚠️ 此配置已被系统使用</h4><p>删除已关联的配置文件可能会影响系统正常运行。请确保您了解删除的后果。</p>' :
'<h4>🗑️ 确认删除配置文件</h4><p>此操作将永久删除配置文件,且无法撤销。</p>'
}
</div>
</div>
<div class="config-info">
<div class="config-info-item">
<span class="info-label">文件名:</span>
<span class="info-value">${config.name}</span>
</div>
<div class="config-info-item">
<span class="info-label">文件路径:</span>
<span class="info-value">${config.path}</span>
</div>
<div class="config-info-item">
<span class="info-label">文件大小:</span>
<span class="info-value">${formatFileSize(config.size)}</span>
</div>
<div class="config-info-item">
<span class="info-label">关联状态:</span>
<span class="info-value status-${isUsed ? 'used' : 'unused'}">
${isUsed ? '已关联' : '未关联'}
</span>
</div>
</div>
${isUsed ? `
<div class="usage-alert">
<div class="alert-icon">
<i class="fas fa-info-circle"></i>
</div>
<div class="alert-content">
<h5>关联详情</h5>
<p>此配置文件正在被系统使用删除后可能会导致:</p>
<ul>
<li>相关的AI服务无法正常工作</li>
<li>配置管理中的设置失效</li>
<li>供应商池配置丢失</li>
</ul>
<p><strong>建议</strong></p>
</div>
</div>
` : ''}
</div>
<div class="delete-modal-footer">
<button class="btn btn-secondary btn-cancel-delete">取消</button>
<button class="${buttonClass} btn-confirm-delete" data-path="${config.path}">
<i class="fas fa-${isUsed ? 'exclamation-triangle' : 'trash'}"></i>
${isUsed ? '强制删除' : '确认删除'}
</button>
</div>
</div>
`;
// 添加到页面
document.body.appendChild(modal);
// 添加事件监听器
const closeBtn = modal.querySelector('.modal-close');
const cancelBtn = modal.querySelector('.btn-cancel-delete');
const confirmBtn = modal.querySelector('.btn-confirm-delete');
const closeModal = () => {
modal.classList.remove('show');
setTimeout(() => modal.remove(), 300);
};
if (closeBtn) {
closeBtn.addEventListener('click', closeModal);
}
if (cancelBtn) {
cancelBtn.addEventListener('click', closeModal);
}
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
const path = confirmBtn.dataset.path;
performDelete(path);
closeModal();
});
}
// 点击外部关闭
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
});
// ESC键关闭
const handleEsc = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', handleEsc);
}
};
document.addEventListener('keydown', handleEsc);
// 显示模态框
setTimeout(() => modal.classList.add('show'), 10);
}
/**
* 执行删除操作
* @param {string} path - 文件路径
*/
async function performDelete(path) {
try {
const response = await fetch(`/api/upload-configs/delete/${encodeURIComponent(path)}`, {
method: 'DELETE'
});
if (response.ok) {
const result = await response.json();
showToast(result.message, 'success');
// 从本地列表中移除
allConfigs = allConfigs.filter(c => c.path !== path);
filteredConfigs = filteredConfigs.filter(c => c.path !== path);
renderConfigList();
updateStats();
} else {
const error = await response.json();
showToast('删除失败: ' + error.error.message, 'error');
}
} catch (error) {
console.error('删除配置失败:', error);
showToast('删除配置失败: ' + error.message, 'error');
}
}
/**
* 删除配置
* @param {string} path - 文件路径
*/
async function deleteConfig(path) {
const config = filteredConfigs.find(c => c.path === path) || allConfigs.find(c => c.path === path);
if (!config) {
showToast('配置文件不存在', 'error');
return;
}
// 显示删除确认模态框
showDeleteConfirmModal(config);
}
/**
* 初始化上传配置管理页面
*/
function initUploadConfigManager() {
// 绑定搜索事件
const searchInput = document.getElementById('configSearch');
const searchBtn = document.getElementById('searchConfigBtn');
const statusFilter = document.getElementById('configStatusFilter');
const refreshBtn = document.getElementById('refreshConfigList');
if (searchInput) {
searchInput.addEventListener('input', debounce(() => {
const searchTerm = searchInput.value.trim();
const currentStatusFilter = statusFilter?.value || '';
searchConfigs(searchTerm, currentStatusFilter);
}, 300));
}
if (searchBtn) {
searchBtn.addEventListener('click', () => {
const searchTerm = searchInput?.value.trim() || '';
const currentStatusFilter = statusFilter?.value || '';
searchConfigs(searchTerm, currentStatusFilter);
});
}
if (statusFilter) {
statusFilter.addEventListener('change', () => {
const searchTerm = searchInput?.value.trim() || '';
const currentStatusFilter = statusFilter.value;
searchConfigs(searchTerm, currentStatusFilter);
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', loadConfigList);
}
// 初始加载配置列表
loadConfigList();
}
/**
* 重新加载配置文件
*/
async function reloadConfig() {
try {
const response = await fetch('/api/reload-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const result = await response.json();
showToast(result.message, 'success');
// 重新加载配置列表以反映最新的关联状态
await loadConfigList();
// 发送自定义事件通知其他组件配置已重新加载
window.dispatchEvent(new CustomEvent('configReloaded', {
detail: result.details
}));
} else {
const error = await response.json();
throw new Error(error.error?.message || '重载配置失败');
}
} catch (error) {
console.error('重载配置失败:', error);
showToast('重载配置失败: ' + error.message, 'error');
}
}
/**
* 防抖函数
* @param {Function} func - 要防抖的函数
* @param {number} wait - 等待时间毫秒
* @returns {Function} 防抖后的函数
*/
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// 导出函数
export {
initUploadConfigManager,
searchConfigs,
loadConfigList,
viewConfig,
deleteConfig,
closeConfigModal,
copyConfigContent,
reloadConfig
};

182
static/app/utils.js Normal file
View file

@ -0,0 +1,182 @@
// 工具函数
/**
* 格式化运行时间
* @param {number} seconds - 秒数
* @returns {string} 格式化的时间字符串
*/
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${days}${hours}小时 ${minutes}${secs}`;
}
/**
* HTML转义
* @param {string} text - 要转义的文本
* @returns {string} 转义后的文本
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 显示提示消息
* @param {string} message - 提示消息
* @param {string} type - 消息类型 (info, success, error)
*/
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<div>${escapeHtml(message)}</div>
`;
// 获取toast容器
const toastContainer = document.getElementById('toastContainer') || document.querySelector('.toast-container');
if (toastContainer) {
toastContainer.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
}
/**
* 获取字段显示文案
* @param {string} key - 字段键
* @returns {string} 显示文案
*/
function getFieldLabel(key) {
const labelMap = {
'checkModelName': '检查模型名称',
'checkHealth': '健康检查',
'OPENAI_API_KEY': 'OpenAI API Key',
'OPENAI_BASE_URL': 'OpenAI Base URL',
'CLAUDE_API_KEY': 'Claude API Key',
'CLAUDE_BASE_URL': 'Claude Base URL',
'PROJECT_ID': '项目ID',
'GEMINI_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径',
'KIRO_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径',
'QWEN_OAUTH_CREDS_FILE_PATH': 'OAuth凭据文件路径'
};
return labelMap[key] || key;
}
/**
* 获取提供商类型的字段配置
* @param {string} providerType - 提供商类型
* @returns {Array} 字段配置数组
*/
function getProviderTypeFields(providerType) {
const fieldConfigs = {
'openai-custom': [
{
id: 'OpenaiApiKey',
label: 'OpenAI API Key',
type: 'password',
placeholder: 'sk-...'
},
{
id: 'OpenaiBaseUrl',
label: 'OpenAI Base URL',
type: 'text',
value: 'https://api.openai.com/v1'
}
],
'openaiResponses-custom': [
{
id: 'OpenaiApiKey',
label: 'OpenAI API Key',
type: 'password',
placeholder: 'sk-...'
},
{
id: 'OpenaiBaseUrl',
label: 'OpenAI Base URL',
type: 'text',
value: 'https://api.openai.com/v1'
}
],
'claude-custom': [
{
id: 'ClaudeApiKey',
label: 'Claude API Key',
type: 'password',
placeholder: 'sk-ant-...'
},
{
id: 'ClaudeBaseUrl',
label: 'Claude Base URL',
type: 'text',
value: 'https://api.anthropic.com'
}
],
'gemini-cli-oauth': [
{
id: 'ProjectId',
label: '项目ID',
type: 'text',
placeholder: 'Google Cloud项目ID'
},
{
id: 'GeminiOauthCredsFilePath',
label: 'OAuth凭据文件路径',
type: 'text',
placeholder: '例如: ~/.gemini/oauth_creds.json'
}
],
'claude-kiro-oauth': [
{
id: 'KiroOauthCredsFilePath',
label: 'OAuth凭据文件路径',
type: 'text',
placeholder: '例如: ~/.aws/sso/cache/kiro-auth-token.json'
}
],
'openai-qwen-oauth': [
{
id: 'QwenOauthCredsFilePath',
label: 'OAuth凭据文件路径',
type: 'text',
placeholder: '例如: ~/.qwen/oauth_creds.json'
}
]
};
return fieldConfigs[providerType] || [];
}
/**
* 调试函数获取当前提供商统计信息
* @param {Object} providerStats - 提供商统计对象
* @returns {Object} 扩展的统计信息
*/
function getProviderStats(providerStats) {
return {
...providerStats,
// 添加计算得出的统计信息
successRate: providerStats.totalRequests > 0 ?
((providerStats.totalRequests - providerStats.totalErrors) / providerStats.totalRequests * 100).toFixed(2) + '%' : '0%',
avgUsagePerProvider: providerStats.activeProviders > 0 ?
Math.round(providerStats.totalRequests / providerStats.activeProviders) : 0,
healthRatio: providerStats.totalAccounts > 0 ?
(providerStats.healthyProviders / providerStats.totalAccounts * 100).toFixed(2) + '%' : '0%'
};
}
// 导出所有工具函数
export {
formatUptime,
escapeHtml,
showToast,
getFieldLabel,
getProviderTypeFields,
getProviderStats
};

774
static/index.html Normal file
View file

@ -0,0 +1,774 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="theme-color" content="#4f46e5">
<meta name="description" content="AIClient2API 管理控制台 - 统一管理AI服务提供商">
<title>AIClient2API - 管理控制台</title>
<link rel="stylesheet" href="app/styles.css">
<link rel="stylesheet" href="app/mobile.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<div class="header-content">
<h1><i class="fas fa-robot"></i> <span class="header-title">AIClient2API 管理控制台</span></h1>
<div class="header-controls">
<span class="status-badge" id="serverStatus">
<i class="fas fa-circle"></i> <span class="status-text">连接中...</span>
</span>
<button class="btn btn-primary" id="refreshBtn" aria-label="刷新数据">
<i class="fas fa-sync-alt"></i> <span class="btn-text">重载</span>
</button>
</div>
</div>
</header>
<!-- Main Content -->
<div class="main-content">
<!-- Sidebar -->
<aside class="sidebar" role="navigation" aria-label="主导航">
<nav class="sidebar-nav">
<a href="#dashboard" class="nav-item active" data-section="dashboard" aria-label="仪表盘">
<i class="fas fa-tachometer-alt" aria-hidden="true"></i> <span>仪表盘</span>
</a>
<a href="#config" class="nav-item" data-section="config" aria-label="配置管理">
<i class="fas fa-cog" aria-hidden="true"></i> <span>配置管理</span>
</a>
<a href="#providers" class="nav-item" data-section="providers" aria-label="供应商池管理">
<i class="fas fa-network-wired" aria-hidden="true"></i> <span>供应商池管理</span>
</a>
<a href="#upload-config" class="nav-item" data-section="upload-config" aria-label="上传配置管理">
<i class="fas fa-upload" aria-hidden="true"></i> <span>上传配置管理</span>
</a>
<a href="#logs" class="nav-item" data-section="logs" aria-label="实时日志">
<i class="fas fa-file-alt" aria-hidden="true"></i> <span>实时日志</span>
</a>
</nav>
</aside>
<!-- Content Area -->
<main class="content" role="main">
<!-- Dashboard Section -->
<section id="dashboard" class="section active" aria-labelledby="dashboard-title">
<h2 id="dashboard-title">系统概览</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-clock"></i>
</div>
<div class="stat-info">
<h3 id="uptime">--</h3>
<p>运行时间</p>
</div>
</div>
</div>
<!-- System Information Panel -->
<div class="system-info-panel">
<h3>系统信息</h3>
<div class="info-grid">
<div class="info-item">
<span class="info-label">
<i class="fas fa-code"></i> Node.js版本
</span>
<span class="info-value" id="nodeVersion">--</span>
</div>
<div class="info-item">
<span class="info-label">
<i class="fas fa-clock"></i> 服务器时间
</span>
<span class="info-value" id="serverTime">--</span>
</div>
<div class="info-item">
<span class="info-label">
<i class="fas fa-memory"></i> 内存使用
</span>
<span class="info-value" id="memoryUsage">--</span>
</div>
</div>
</div>
<!-- Path Routing Examples Panel -->
<div class="routing-examples-panel">
<h3><i class="fas fa-route"></i> 路径路由调用示例</h3>
<p class="routing-description">通过不同路径路由访问不同的AI模型提供商支持灵活的模型切换</p>
<div class="routing-examples-grid">
<div class="routing-example-card" data-provider="gemini-cli-oauth-card">
<div class="routing-card-header">
<i class="fas fa-gem"></i>
<h4>Gemini CLI OAuth</h4>
<span class="provider-badge oauth">突破限制</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/gemini-cli-oauth/v1/chat/completions</code>
<button class="copy-btn" data-path="/gemini-cli-oauth/v1/chat/completions">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (OpenAI格式):</label>
<pre><code>curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"model": "gemini-2.0-flash-exp",
"messages": [{"role": "user", "content": "Hello!"}],
"max_tokens": 1000
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/gemini-cli-oauth/v1/messages</code>
<button class="copy-btn" data-path="/gemini-cli-oauth/v1/messages">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (Claude格式):</label>
<pre><code>curl http://localhost:3000/gemini-cli-oauth/v1/messages \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{
"model": "gemini-2.0-flash-exp",
"max_tokens": 1000,
"messages": [{"role": "user", "content": "Hello!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="openai-qwen-oauth-card">
<div class="routing-card-header">
<i class="fas fa-code"></i>
<h4>Qwen OAuth</h4>
<span class="provider-badge oauth">突破限制</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/openai-qwen-oauth/v1/chat/completions</code>
<button class="copy-btn" data-path="/openai-qwen-oauth/v1/chat/completions">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (OpenAI格式):</label>
<pre><code>curl http://localhost:3000/openai-qwen-oauth/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"model": "qwen-turbo",
"messages": [{"role": "user", "content": "Hello!"}],
"max_tokens": 1000
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/openai-qwen-oauth/v1/messages</code>
<button class="copy-btn" data-path="/openai-qwen-oauth/v1/messages">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (Claude格式):</label>
<pre><code>curl http://localhost:3000/openai-qwen-oauth/v1/messages \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{
"model": "qwen-turbo",
"max_tokens": 1000,
"messages": [{"role": "user", "content": "Hello!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="claude-custom-card">
<div class="routing-card-header">
<i class="fas fa-brain"></i>
<h4>Claude Custom</h4>
<span class="provider-badge official">官方API/三方</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/claude-custom/v1/chat/completions</code>
<button class="copy-btn" data-path="/claude-custom/v1/chat/completions">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (OpenAI格式):</label>
<pre><code>curl http://localhost:3000/claude-custom/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"model": "claude-3-sonnet-20240229",
"messages": [{"role": "user", "content": "Hello!"}],
"max_tokens": 1000
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/claude-custom/v1/messages</code>
<button class="copy-btn" data-path="/claude-custom/v1/messages">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (Claude格式):</label>
<pre><code>curl http://localhost:3000/claude-custom/v1/messages \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{
"model": "claude-3-sonnet-20240229",
"max_tokens": 1000,
"messages": [{"role": "user", "content": "Hello!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="claude-kiro-oauth-card">
<div class="routing-card-header">
<i class="fas fa-robot"></i>
<h4>Claude Kiro OAuth</h4>
<span class="provider-badge oauth">突破限制/免费使用</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/claude-kiro-oauth/v1/chat/completions</code>
<button class="copy-btn" data-path="/claude-kiro-oauth/v1/chat/completions">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (OpenAI格式):</label>
<pre><code>curl http://localhost:3000/claude-kiro-oauth/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Hello!"}],
"max_tokens": 1000
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/claude-kiro-oauth/v1/messages</code>
<button class="copy-btn" data-path="/claude-kiro-oauth/v1/messages">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (Claude格式):</label>
<pre><code>curl http://localhost:3000/claude-kiro-oauth/v1/messages \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{
"model": "claude-3-5-sonnet-20241022",
"max_tokens": 1000,
"messages": [{"role": "user", "content": "Hello!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
<div class="routing-example-card" data-provider="openai-custom-card">
<div class="routing-card-header">
<i class="fas fa-comments"></i>
<h4>OpenAI Custom</h4>
<span class="provider-badge official">官方API/三方</span>
</div>
<div class="routing-card-content">
<!-- 协议标签切换 -->
<div class="protocol-tabs">
<button class="protocol-tab" data-protocol="openai">OpenAI协议</button>
<button class="protocol-tab active" data-protocol="claude">Claude协议</button>
</div>
<!-- OpenAI协议示例 -->
<div class="protocol-content" data-protocol="openai">
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/openai-custom/v1/chat/completions</code>
<button class="copy-btn" data-path="/openai-custom/v1/chat/completions">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (OpenAI格式):</label>
<pre><code>curl http://localhost:3000/openai-custom/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello!"}],
"max_tokens": 1000
}'</code></pre>
</div>
</div>
<!-- Claude协议示例 -->
<div class="protocol-content active" data-protocol="claude">
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/openai-custom/v1/messages</code>
<button class="copy-btn" data-path="/openai-custom/v1/messages">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (Claude格式):</label>
<pre><code>curl http://localhost:3000/openai-custom/v1/messages \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-d '{
"model": "gpt-4",
"max_tokens": 1000,
"messages": [{"role": "user", "content": "Hello!"}]
}'</code></pre>
</div>
</div>
</div>
</div>
</div>
<div class="routing-tips">
<h4><i class="fas fa-lightbulb"></i> 使用提示</h4>
<ul>
<li><strong>即时切换:</strong> 通过修改URL路径即可切换不同的AI模型提供商</li>
<li><strong>客户端配置:</strong> 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径</li>
<li><strong>跨协议调用:</strong> 支持OpenAI协议调用Claude模型或Claude协议调用OpenAI模型</li>
</ul>
</div>
</div>
</section>
<!-- Configuration Section -->
<section id="config" class="section" aria-labelledby="config-title">
<h2 id="config-title">配置管理</h2>
<div class="config-panel">
<div class="config-form">
<div class="form-group password-input-group">
<label for="apiKey">API密钥</label>
<div class="password-input-wrapper">
<input type="password" id="apiKey" class="form-control" placeholder="请输入API密钥" autocomplete="off">
<button type="button" class="password-toggle" data-target="apiKey" aria-label="显示/隐藏密码">
<i class="fas fa-eye" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="host">监听地址</label>
<input type="text" id="host" class="form-control" value="127.0.0.1">
</div>
<div class="form-group">
<label for="port">端口</label>
<input type="number" id="port" class="form-control" value="3000">
</div>
</div>
<div class="form-group">
<label for="modelProvider">模型提供商</label>
<select id="modelProvider" class="form-control">
<option value="gemini-cli-oauth">Gemini CLI OAuth</option>
<option value="openai-custom">OpenAI Custom</option>
<option value="claude-custom">Claude Custom</option>
<option value="claude-kiro-oauth">Claude Kiro OAuth</option>
<option value="openai-qwen-oauth">Qwen OAuth</option>
<option value="openaiResponses-custom">OpenAI Responses</option>
</select>
</div>
<!-- Gemini CLI OAuth 配置 -->
<div class="provider-config" data-provider="gemini-cli-oauth">
<div class="form-group">
<label for="projectId">项目ID</label>
<input type="text" id="projectId" class="form-control" placeholder="Google Cloud项目ID">
</div>
<div class="form-group">
<label>OAuth凭据</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="geminiCredsType" value="file" checked>
文件路径
</label>
<label class="radio-label">
<input type="radio" name="geminiCredsType" value="base64">
Base64编码
</label>
</div>
</div>
<div class="form-group" id="geminiCredsBase64Group">
<label for="geminiOauthCredsBase64">OAuth凭据 (Base64)</label>
<textarea id="geminiOauthCredsBase64" class="form-control" rows="3" placeholder="请输入Base64编码的OAuth凭据"></textarea>
</div>
<div class="form-group" id="geminiCredsFileGroup" style="display: none;">
<label for="geminiOauthCredsFilePath">OAuth凭据文件路径</label>
<div class="file-input-group">
<input type="text" id="geminiOauthCredsFilePath" class="form-control" placeholder="例如: ~/.gemini/oauth_creds.json">
<button type="button" class="btn btn-outline upload-btn" data-target="geminiOauthCredsFilePath" aria-label="上传文件">
<i class="fas fa-upload"></i>
</button>
</div>
</div>
</div>
<!-- OpenAI Custom 配置 -->
<div class="provider-config" data-provider="openai-custom" style="display: none;">
<div class="form-group password-input-group">
<label for="openaiApiKey">OpenAI API Key</label>
<div class="password-input-wrapper">
<input type="password" id="openaiApiKey" class="form-control" placeholder="sk-...">
<button type="button" class="password-toggle" data-target="openaiApiKey">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="openaiBaseUrl">OpenAI Base URL</label>
<input type="text" id="openaiBaseUrl" class="form-control" value="https://api.openai.com/v1" placeholder="例如: https://api.openai.com/v1">
</div>
</div>
<!-- Claude Custom 配置 -->
<div class="provider-config" data-provider="claude-custom" style="display: none;">
<div class="form-group password-input-group">
<label for="claudeApiKey">Claude API Key</label>
<div class="password-input-wrapper">
<input type="password" id="claudeApiKey" class="form-control" placeholder="sk-ant-...">
<button type="button" class="password-toggle" data-target="claudeApiKey">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="claudeBaseUrl">Claude Base URL</label>
<input type="text" id="claudeBaseUrl" class="form-control" value="https://api.anthropic.com" placeholder="例如: https://api.anthropic.com">
</div>
</div>
<!-- Claude Kiro OAuth 配置 -->
<div class="provider-config" data-provider="claude-kiro-oauth" style="display: none;">
<div class="form-group">
<label>OAuth凭据</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="kiroCredsType" value="file" checked>
文件路径
</label>
<label class="radio-label">
<input type="radio" name="kiroCredsType" value="base64">
Base64编码
</label>
</div>
</div>
<div class="form-group" id="kiroCredsBase64Group">
<label for="kiroOauthCredsBase64">OAuth凭据 (Base64)</label>
<textarea id="kiroOauthCredsBase64" class="form-control" rows="3" placeholder="请输入Base64编码的OAuth凭据"></textarea>
</div>
<div class="form-group" id="kiroCredsFileGroup" style="display: none;">
<label for="kiroOauthCredsFilePath">OAuth凭据文件路径</label>
<div class="file-input-group">
<input type="text" id="kiroOauthCredsFilePath" class="form-control" placeholder="例如: ~/.aws/sso/cache/kiro-auth-token.json">
<button type="button" class="btn btn-outline upload-btn" data-target="kiroOauthCredsFilePath" aria-label="上传文件">
<i class="fas fa-upload"></i>
</button>
</div>
</div>
</div>
<!-- Qwen OAuth 配置 -->
<div class="provider-config" data-provider="openai-qwen-oauth" style="display: none;">
<div class="form-group">
<label for="qwenOauthCredsFilePath">OAuth凭据文件路径</label>
<div class="file-input-group">
<input type="text" id="qwenOauthCredsFilePath" class="form-control" placeholder="例如: ~/.qwen/oauth_creds.json">
<button type="button" class="btn btn-outline upload-btn" data-target="qwenOauthCredsFilePath" aria-label="上传文件">
<i class="fas fa-upload"></i>
</button>
</div>
</div>
</div>
<!-- OpenAI Responses 配置 -->
<div class="provider-config" data-provider="openaiResponses-custom" style="display: none;">
<div class="form-group password-input-group">
<label for="openaiResponsesApiKey">OpenAI API Key</label>
<div class="password-input-wrapper">
<input type="password" id="openaiResponsesApiKey" class="form-control" placeholder="sk-...">
<button type="button" class="password-toggle" data-target="openaiResponsesApiKey">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="openaiResponsesBaseUrl">OpenAI Base URL</label>
<input type="text" id="openaiResponsesBaseUrl" class="form-control" value="https://api.openai.com/v1" placeholder="例如: https://api.openai.com/v1">
</div>
</div>
<!-- 高级配置区域 -->
<div class="advanced-config-section">
<h3><i class="fas fa-cogs"></i> 高级配置</h3>
<div class="config-row">
<div class="form-group">
<label for="systemPromptFilePath">系统提示文件路径</label>
<input type="text" id="systemPromptFilePath" class="form-control" placeholder="例如: input_system_prompt.txt">
</div>
<div class="form-group">
<label for="systemPromptMode">系统提示模式</label>
<select id="systemPromptMode" class="form-control">
<option value="overwrite">覆盖 (overwrite)</option>
<option value="append">追加 (append)</option>
</select>
</div>
</div>
<div class="config-row">
<div class="form-group">
<label for="promptLogBaseName">提示日志基础名称</label>
<input type="text" id="promptLogBaseName" class="form-control" placeholder="例如: prompt_log">
</div>
<div class="form-group">
<label for="promptLogMode">提示日志模式</label>
<select id="promptLogMode" class="form-control">
<option value="none">无 (none)</option>
<option value="console">控制台 (console)</option>
<option value="file">文件 (file)</option>
</select>
</div>
</div>
<div class="config-row">
<div class="form-group">
<label for="requestMaxRetries">最大重试次数</label>
<input type="number" id="requestMaxRetries" class="form-control" min="0" max="10" value="3">
</div>
<div class="form-group">
<label for="requestBaseDelay">重试基础延迟(毫秒)</label>
<input type="number" id="requestBaseDelay" class="form-control" min="0" step="100" value="1000">
</div>
</div>
<div class="config-row">
<div class="form-group">
<label for="cronNearMinutes">OAuth令牌刷新间隔(分钟)</label>
<input type="number" id="cronNearMinutes" class="form-control" min="1" max="60" value="1">
</div>
<div class="form-group">
<label for="cronNearMinutes">启用OAuth令牌自动刷新</label>
<label class="toggle-switch">
<input type="checkbox" id="cronRefreshToken">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="form-group pool-section">
<label for="providerPoolsFilePath">供应商池配置文件路径</label>
<input type="text" id="providerPoolsFilePath" class="form-control" placeholder="例如: provider_pools.json">
<small class="form-text">配置了供应商池后,可在供应商池管理中查看详细信息</small>
</div>
<!-- 系统提示配置移到最下面 -->
<div class="form-group system-prompt-section">
<label for="systemPrompt">系统提示</label>
<textarea id="systemPrompt" class="form-control" rows="4" placeholder="输入系统提示..."></textarea>
</div>
</div>
<div class="form-actions">
<button class="btn btn-success" id="saveConfig">
<i class="fas fa-save"></i> 保存配置
</button>
<button class="btn btn-secondary" id="resetConfig">
<i class="fas fa-undo"></i> 重置
</button>
</div>
</div>
</div>
</section>
<!-- Upload Configuration Section -->
<section id="upload-config" class="section" aria-labelledby="upload-config-title">
<h2 id="upload-config-title">上传配置管理</h2>
<div class="upload-config-panel">
<!-- 搜索和过滤区域 -->
<div class="config-search-panel">
<div class="search-controls">
<div class="form-group">
<label for="configSearch">搜索配置</label>
<div class="search-input-group">
<input type="text" id="configSearch" class="form-control" placeholder="输入文件名">
<button type="button" class="btn btn-outline" id="searchConfigBtn" aria-label="搜索配置">
<i class="fas fa-search"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="configStatusFilter">关联状态</label>
<select id="configStatusFilter" class="form-control">
<option value="">全部状态</option>
<option value="used">已关联</option>
<option value="unused">未关联</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="config-actions">
<button class="btn btn-primary" id="refreshConfigList" aria-label="刷新配置列表">
<i class="fas fa-sync-alt"></i> 刷新
</button>
</div>
</div>
</div>
</div>
<!-- 配置列表 -->
<div class="config-list-container">
<div class="config-list-header">
<h3>配置文件列表</h3>
<div class="config-stats">
<span id="configCount">共 0 个配置文件</span>
<span id="usedConfigCount" class="status-used">已关联: 0</span>
<span id="unusedConfigCount" class="status-unused">未关联: 0</span>
</div>
</div>
<div id="configList" class="config-list">
<!-- 配置文件列表将在这里动态生成 -->
</div>
</div>
</div>
</section>
<!-- Provider Pools Section -->
<section id="providers" class="section" aria-labelledby="providers-title">
<h2 id="providers-title">供应商池管理</h2>
<!-- Provider Pool Stats -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-server"></i>
</div>
<div class="stat-info">
<h3 id="activeConnections">0</h3>
<p>活动连接</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-network-wired"></i>
</div>
<div class="stat-info">
<h3 id="activeProviders">0</h3>
<p>活跃提供商</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-check-circle"></i>
</div>
<div class="stat-info">
<h3 id="healthyProviders">0</h3>
<p>健康提供商</p>
</div>
</div>
</div>
<div class="providers-container">
<div id="providersList" class="providers-list">
<!-- Providers will be loaded here -->
</div>
</div>
</section>
<!-- Logs Section -->
<section id="logs" class="section" aria-labelledby="logs-title">
<h2 id="logs-title">实时日志</h2>
<div class="logs-controls">
<button class="btn btn-danger" id="clearLogs" aria-label="清空所有日志">
<i class="fas fa-trash" aria-hidden="true"></i> <span>清空日志</span>
</button>
<button class="btn btn-primary" id="toggleAutoScroll" data-enabled="true" aria-label="切换自动滚动">
<i class="fas fa-arrow-down" aria-hidden="true"></i> <span>自动滚动: 开</span>
</button>
</div>
<div class="logs-container" id="logsContainer" role="log" aria-live="polite" aria-atomic="false">
<!-- Logs will appear here -->
</div>
</section>
</main>
</div>
</div>
<!-- Toast Notifications -->
<div id="toastContainer" class="toast-container"></div>
<!-- Scripts -->
<script type="module" src="app/app.js"></script>
</body>
</html>