diff --git a/UI_README.md b/UI_README.md new file mode 100644 index 0000000..06e331d --- /dev/null +++ b/UI_README.md @@ -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 来改进这个管理控制台! + +## 许可证 + +本项目使用与主项目相同的许可证。 diff --git a/package-lock.json b/package-lock.json index 3779e5c..f852bba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b81c2b1..49b1fff 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/api-manager.js b/src/api-manager.js new file mode 100644 index 0000000..a17125d --- /dev/null +++ b/src/api-manager.js @@ -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} - 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} 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); + }); + }); +} \ No newline at end of file diff --git a/src/api-server.js b/src/api-server.js index 211da40..becc8db 100644 --- a/src/api-server.js +++ b/src/api-server.js @@ -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} 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); diff --git a/src/config-manager.js b/src/config-manager.js new file mode 100644 index 0000000..7e6f25a --- /dev/null +++ b/src/config-manager.js @@ -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} 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 }; \ No newline at end of file diff --git a/src/request-handler.js b/src/request-handler.js new file mode 100644 index 0000000..438b894 --- /dev/null +++ b/src/request-handler.js @@ -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); + } + }; +} \ No newline at end of file diff --git a/src/service-manager.js b/src/service-manager.js new file mode 100644 index 0000000..7b8c563 --- /dev/null +++ b/src/service-manager.js @@ -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} 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} 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); + } +} \ No newline at end of file diff --git a/src/ui-manager.js b/src/ui-manager.js new file mode 100644 index 0000000..0ae9e3d --- /dev/null +++ b/src/ui-manager.js @@ -0,0 +1,1359 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { promises as fs } from 'fs'; +import path from 'path'; +import multer from 'multer'; +import { getRequestBody } from './common.js'; +import { CONFIG } from './config-manager.js'; + +// 配置multer中间件 +const storage = multer.diskStorage({ + destination: async (req, file, cb) => { + try { + // multer在destination回调时req.body还未解析,先使用默认路径 + // 实际的provider会在文件上传完成后从req.body中获取 + const uploadPath = path.join(process.cwd(), 'configs', 'temp'); + await fs.mkdir(uploadPath, { recursive: true }); + cb(null, uploadPath); + } catch (error) { + cb(error); + } + }, + filename: (req, file, cb) => { + const timestamp = Date.now(); + const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); + cb(null, `${timestamp}_${sanitizedName}`); + } +}); + +const fileFilter = (req, file, cb) => { + const allowedTypes = ['.json', '.txt', '.key', '.pem', '.p12', '.pfx']; + const ext = path.extname(file.originalname).toLowerCase(); + if (allowedTypes.includes(ext)) { + cb(null, true); + } else { + cb(new Error('不支持的文件类型'), false); + } +}; + +const upload = multer({ + storage, + fileFilter, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB限制 + } +}); + +/** + * Serve static files for the UI + * @param {string} path - The request path + * @param {http.ServerResponse} res - The HTTP response object + */ +export async function serveStaticFiles(pathParam, res) { + const filePath = path.join(process.cwd(), 'static', pathParam === '/' || pathParam === '/index.html' ? 'index.html' : pathParam.replace('/static/', '')); + + if (existsSync(filePath)) { + const ext = path.extname(filePath); + const contentType = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.ico': 'image/x-icon' + }[ext] || 'text/plain'; + + res.writeHead(200, { 'Content-Type': contentType }); + res.end(readFileSync(filePath)); + return true; + } + return false; +} + +/** + * Handle UI management API requests + * @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} providerPoolManager - The provider pool manager instance + * @returns {Promise} - True if the request was handled by UI API + */ +export async function handleUIApiRequests(method, pathParam, req, res, currentConfig, providerPoolManager) { + // 文件上传API + if (method === 'POST' && pathParam === '/api/upload-oauth-credentials') { + const uploadMiddleware = upload.single('file'); + + uploadMiddleware(req, res, async (err) => { + if (err) { + console.error('文件上传错误:', err.message); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: err.message || '文件上传失败' + } + })); + return; + } + + try { + if (!req.file) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '没有文件被上传' + } + })); + return; + } + + // multer执行完成后,表单字段已解析到req.body中 + const provider = req.body.provider || 'common'; + const tempFilePath = req.file.path; + + // 根据实际的provider移动文件到正确的目录 + const targetDir = path.join(process.cwd(), 'configs', provider); + await fs.mkdir(targetDir, { recursive: true }); + + const targetFilePath = path.join(targetDir, req.file.filename); + await fs.rename(tempFilePath, targetFilePath); + + const relativePath = path.relative(process.cwd(), targetFilePath); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'add', + filePath: relativePath, + provider: provider, + timestamp: new Date().toISOString() + }); + + console.log(`[UI API] OAuth凭据文件已上传: ${targetFilePath} (提供商: ${provider})`); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: '文件上传成功', + filePath: relativePath, + originalName: req.file.originalname, + provider: provider + })); + + } catch (error) { + console.error('文件上传处理错误:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '文件上传处理失败: ' + error.message + } + })); + } + }); + return true; + } + + // Get configuration + if (method === 'GET' && pathParam === '/api/config') { + let systemPrompt = ''; + + if (currentConfig.SYSTEM_PROMPT_FILE_PATH && existsSync(currentConfig.SYSTEM_PROMPT_FILE_PATH)) { + try { + systemPrompt = readFileSync(currentConfig.SYSTEM_PROMPT_FILE_PATH, 'utf-8'); + } catch (e) { + console.warn('[UI API] Failed to read system prompt file:', e.message); + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + ...currentConfig, + systemPrompt + })); + return true; + } + + // Update configuration + if (method === 'POST' && pathParam === '/api/config') { + try { + const body = await getRequestBody(req); + const newConfig = body; + + // Update config values in memory + if (newConfig.REQUIRED_API_KEY !== undefined) currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; + if (newConfig.HOST !== undefined) currentConfig.HOST = newConfig.HOST; + if (newConfig.SERVER_PORT !== undefined) currentConfig.SERVER_PORT = newConfig.SERVER_PORT; + if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER; + if (newConfig.PROJECT_ID !== undefined) currentConfig.PROJECT_ID = newConfig.PROJECT_ID; + if (newConfig.OPENAI_API_KEY !== undefined) currentConfig.OPENAI_API_KEY = newConfig.OPENAI_API_KEY; + if (newConfig.OPENAI_BASE_URL !== undefined) currentConfig.OPENAI_BASE_URL = newConfig.OPENAI_BASE_URL; + if (newConfig.CLAUDE_API_KEY !== undefined) currentConfig.CLAUDE_API_KEY = newConfig.CLAUDE_API_KEY; + if (newConfig.CLAUDE_BASE_URL !== undefined) currentConfig.CLAUDE_BASE_URL = newConfig.CLAUDE_BASE_URL; + if (newConfig.GEMINI_OAUTH_CREDS_BASE64 !== undefined) currentConfig.GEMINI_OAUTH_CREDS_BASE64 = newConfig.GEMINI_OAUTH_CREDS_BASE64; + if (newConfig.GEMINI_OAUTH_CREDS_FILE_PATH !== undefined) currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH = newConfig.GEMINI_OAUTH_CREDS_FILE_PATH; + if (newConfig.KIRO_OAUTH_CREDS_BASE64 !== undefined) currentConfig.KIRO_OAUTH_CREDS_BASE64 = newConfig.KIRO_OAUTH_CREDS_BASE64; + if (newConfig.KIRO_OAUTH_CREDS_FILE_PATH !== undefined) currentConfig.KIRO_OAUTH_CREDS_FILE_PATH = newConfig.KIRO_OAUTH_CREDS_FILE_PATH; + if (newConfig.QWEN_OAUTH_CREDS_FILE_PATH !== undefined) currentConfig.QWEN_OAUTH_CREDS_FILE_PATH = newConfig.QWEN_OAUTH_CREDS_FILE_PATH; + if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) currentConfig.SYSTEM_PROMPT_FILE_PATH = newConfig.SYSTEM_PROMPT_FILE_PATH; + if (newConfig.SYSTEM_PROMPT_MODE !== undefined) currentConfig.SYSTEM_PROMPT_MODE = newConfig.SYSTEM_PROMPT_MODE; + if (newConfig.PROMPT_LOG_BASE_NAME !== undefined) currentConfig.PROMPT_LOG_BASE_NAME = newConfig.PROMPT_LOG_BASE_NAME; + if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE; + if (newConfig.REQUEST_MAX_RETRIES !== undefined) currentConfig.REQUEST_MAX_RETRIES = newConfig.REQUEST_MAX_RETRIES; + if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY; + if (newConfig.CRON_NEAR_MINUTES !== undefined) currentConfig.CRON_NEAR_MINUTES = newConfig.CRON_NEAR_MINUTES; + if (newConfig.CRON_REFRESH_TOKEN !== undefined) currentConfig.CRON_REFRESH_TOKEN = newConfig.CRON_REFRESH_TOKEN; + if (newConfig.PROVIDER_POOLS_FILE_PATH !== undefined) currentConfig.PROVIDER_POOLS_FILE_PATH = newConfig.PROVIDER_POOLS_FILE_PATH; + + // Handle system prompt update + if (newConfig.systemPrompt !== undefined) { + const promptPath = currentConfig.SYSTEM_PROMPT_FILE_PATH || 'input_system_prompt.txt'; + try { + const relativePath = path.relative(process.cwd(), promptPath); + writeFileSync(promptPath, newConfig.systemPrompt, 'utf-8'); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'update', + filePath: relativePath, + type: 'system_prompt', + timestamp: new Date().toISOString() + }); + + console.log('[UI API] System prompt updated'); + } catch (e) { + console.warn('[UI API] Failed to write system prompt:', e.message); + } + } + + // Update config.json file + try { + const configPath = 'config.json'; + + // Create a clean config object for saving (exclude runtime-only properties) + const configToSave = { + REQUIRED_API_KEY: currentConfig.REQUIRED_API_KEY, + SERVER_PORT: currentConfig.SERVER_PORT, + HOST: currentConfig.HOST, + MODEL_PROVIDER: currentConfig.MODEL_PROVIDER, + OPENAI_API_KEY: currentConfig.OPENAI_API_KEY, + OPENAI_BASE_URL: currentConfig.OPENAI_BASE_URL, + CLAUDE_API_KEY: currentConfig.CLAUDE_API_KEY, + CLAUDE_BASE_URL: currentConfig.CLAUDE_BASE_URL, + PROJECT_ID: currentConfig.PROJECT_ID, + GEMINI_OAUTH_CREDS_BASE64: currentConfig.GEMINI_OAUTH_CREDS_BASE64, + GEMINI_OAUTH_CREDS_FILE_PATH: currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH, + KIRO_OAUTH_CREDS_BASE64: currentConfig.KIRO_OAUTH_CREDS_BASE64, + KIRO_OAUTH_CREDS_FILE_PATH: currentConfig.KIRO_OAUTH_CREDS_FILE_PATH, + QWEN_OAUTH_CREDS_FILE_PATH: currentConfig.QWEN_OAUTH_CREDS_FILE_PATH, + SYSTEM_PROMPT_FILE_PATH: currentConfig.SYSTEM_PROMPT_FILE_PATH, + SYSTEM_PROMPT_MODE: currentConfig.SYSTEM_PROMPT_MODE, + PROMPT_LOG_BASE_NAME: currentConfig.PROMPT_LOG_BASE_NAME, + PROMPT_LOG_MODE: currentConfig.PROMPT_LOG_MODE, + REQUEST_MAX_RETRIES: currentConfig.REQUEST_MAX_RETRIES, + REQUEST_BASE_DELAY: currentConfig.REQUEST_BASE_DELAY, + CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES, + CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN, + PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH + }; + + writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); + console.log('[UI API] Configuration saved to config.json'); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'update', + filePath: 'config.json', + type: 'main_config', + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('[UI API] Failed to save configuration to file:', error.message); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to save configuration to file: ' + error.message, + partial: true // Indicate that memory config was updated but not saved + } + })); + return true; + } + + // Update the global CONFIG object to reflect changes immediately + Object.assign(CONFIG, currentConfig); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Configuration updated successfully', + details: 'Configuration has been updated in both memory and config.json file' + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } + } + + // Get system information + if (method === 'GET' && pathParam === '/api/system') { + const memUsage = process.memoryUsage(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + nodeVersion: process.version, + serverTime: new Date().toLocaleString(), + memoryUsage: `${Math.round(memUsage.heapUsed / 1024 / 1024)} MB / ${Math.round(memUsage.heapTotal / 1024 / 1024)} MB`, + uptime: process.uptime() + })); + return true; + } + + // Get provider pools summary + if (method === 'GET' && pathParam === '/api/providers') { + let providerPools = {}; + try { + if (providerPoolManager && providerPoolManager.providerPools) { + providerPools = providerPoolManager.providerPools; + } else if (currentConfig.PROVIDER_POOLS_FILE_PATH && existsSync(currentConfig.PROVIDER_POOLS_FILE_PATH)) { + const poolsData = JSON.parse(readFileSync(currentConfig.PROVIDER_POOLS_FILE_PATH, 'utf-8')); + providerPools = poolsData; + } + } catch (error) { + console.warn('[UI API] Failed to load provider pools:', error.message); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(providerPools)); + return true; + } + + // Get specific provider type details + const providerTypeMatch = pathParam.match(/^\/api\/providers\/([^\/]+)$/); + if (method === 'GET' && providerTypeMatch) { + const providerType = decodeURIComponent(providerTypeMatch[1]); + let providerPools = {}; + + try { + if (providerPoolManager && providerPoolManager.providerPools) { + providerPools = providerPoolManager.providerPools; + } else if (currentConfig.PROVIDER_POOLS_FILE_PATH && existsSync(currentConfig.PROVIDER_POOLS_FILE_PATH)) { + const poolsData = JSON.parse(readFileSync(currentConfig.PROVIDER_POOLS_FILE_PATH, 'utf-8')); + providerPools = poolsData; + } + } catch (error) { + console.warn('[UI API] Failed to load provider pools:', error.message); + } + + const providers = providerPools[providerType] || []; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + providerType, + providers, + totalCount: providers.length, + healthyCount: providers.filter(p => p.isHealthy).length + })); + return true; + } + + // Add new provider configuration + if (method === 'POST' && pathParam === '/api/providers') { + try { + const body = await getRequestBody(req); + const { providerType, providerConfig } = body; + + if (!providerType || !providerConfig) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'providerType and providerConfig are required' } })); + return true; + } + + // Generate UUID if not provided + if (!providerConfig.uuid) { + providerConfig.uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + // Set default values + providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true; + providerConfig.lastUsed = providerConfig.lastUsed || null; + providerConfig.usageCount = providerConfig.usageCount || 0; + providerConfig.errorCount = providerConfig.errorCount || 0; + providerConfig.lastErrorTime = providerConfig.lastErrorTime || null; + + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + console.warn('[UI API] Failed to read existing provider pools:', readError.message); + } + } + + // Add new provider to the appropriate type + if (!providerPools[providerType]) { + providerPools[providerType] = []; + } + providerPools[providerType].push(providerConfig); + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); + console.log(`[UI API] Added new provider to ${providerType}: ${providerConfig.uuid}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // Update CONFIG cache to maintain consistency + CONFIG.providerPools = providerPools; + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'add', + filePath: filePath, + providerType, + providerConfig, + timestamp: new Date().toISOString() + }); + + // 广播供应商更新事件 + broadcastEvent('provider_update', { + action: 'add', + providerType, + providerConfig, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Provider added successfully', + provider: providerConfig, + providerType + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } + } + + // Update specific provider configuration + const updateProviderMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)$/); + if (method === 'PUT' && updateProviderMatch) { + const providerType = decodeURIComponent(updateProviderMatch[1]); + const providerUuid = updateProviderMatch[2]; + + try { + const body = await getRequestBody(req); + const { providerConfig } = body; + + if (!providerConfig) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'providerConfig is required' } })); + return true; + } + + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + // Find and update the provider + const providers = providerPools[providerType] || []; + const providerIndex = providers.findIndex(p => p.uuid === providerUuid); + + if (providerIndex === -1) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider not found' } })); + return true; + } + + // Update provider while preserving certain fields + const existingProvider = providers[providerIndex]; + const updatedProvider = { + ...existingProvider, + ...providerConfig, + uuid: providerUuid, // Ensure UUID doesn't change + lastUsed: existingProvider.lastUsed, // Preserve usage stats + usageCount: existingProvider.usageCount, + errorCount: existingProvider.errorCount, + lastErrorTime: existingProvider.lastErrorTime + }; + + providerPools[providerType][providerIndex] = updatedProvider; + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); + console.log(`[UI API] Updated provider ${providerUuid} in ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // Update CONFIG cache to maintain consistency + CONFIG.providerPools = providerPools; + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'update', + filePath: filePath, + providerType, + providerConfig: updatedProvider, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Provider updated successfully', + provider: updatedProvider + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } + } + + // Delete specific provider configuration + if (method === 'DELETE' && updateProviderMatch) { + const providerType = decodeURIComponent(updateProviderMatch[1]); + const providerUuid = updateProviderMatch[2]; + + try { + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; + let providerPools = {}; + + // Load existing pools + if (existsSync(filePath)) { + try { + const fileContent = readFileSync(filePath, 'utf8'); + providerPools = JSON.parse(fileContent); + } catch (readError) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); + return true; + } + } + + // Find and remove the provider + const providers = providerPools[providerType] || []; + const providerIndex = providers.findIndex(p => p.uuid === providerUuid); + + if (providerIndex === -1) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider not found' } })); + return true; + } + + const deletedProvider = providers[providerIndex]; + providers.splice(providerIndex, 1); + + // Remove the entire provider type if no providers left + if (providers.length === 0) { + delete providerPools[providerType]; + } + + // Save to file + writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8'); + console.log(`[UI API] Deleted provider ${providerUuid} from ${providerType}`); + + // Update provider pool manager if available + if (providerPoolManager) { + providerPoolManager.providerPools = providerPools; + providerPoolManager.initializeProviderStatus(); + } + + // Update CONFIG cache to maintain consistency + CONFIG.providerPools = providerPools; + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'delete', + filePath: filePath, + providerType, + providerConfig: deletedProvider, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: 'Provider deleted successfully', + deletedProvider + })); + return true; + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: error.message } })); + return true; + } + } + + // Server-Sent Events for real-time updates + if (method === 'GET' && pathParam === '/api/events') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*' + }); + + res.write('\n'); + + // Store the response object for broadcasting + if (!global.eventClients) { + global.eventClients = []; + } + global.eventClients.push(res); + + // Keep connection alive + const keepAlive = setInterval(() => { + res.write(':\n\n'); + }, 30000); + + req.on('close', () => { + clearInterval(keepAlive); + global.eventClients = global.eventClients.filter(r => r !== res); + }); + + return true; + } + + // Get upload configuration files list + if (method === 'GET' && pathParam === '/api/upload-configs') { + try { + const configFiles = await scanConfigFiles(currentConfig, providerPoolManager); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(configFiles)); + return true; + } catch (error) { + console.error('[UI API] Failed to scan config files:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to scan config files: ' + error.message + } + })); + return true; + } + } + + // View specific configuration file + const viewConfigMatch = pathParam.match(/^\/api\/upload-configs\/view\/(.+)$/); + if (method === 'GET' && viewConfigMatch) { + try { + const filePath = decodeURIComponent(viewConfigMatch[1]); + const fullPath = path.join(process.cwd(), filePath); + + // 安全检查:确保文件路径在允许的目录内 + const allowedDirs = ['configs']; + const relativePath = path.relative(process.cwd(), fullPath); + const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); + + if (!isAllowed) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '访问被拒绝:只能查看configs目录下的文件' + } + })); + return true; + } + + if (!existsSync(fullPath)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '文件不存在' + } + })); + return true; + } + + const content = await fs.readFile(fullPath, 'utf8'); + const stats = await fs.stat(fullPath); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + path: relativePath, + content: content, + size: stats.size, + modified: stats.mtime.toISOString(), + name: path.basename(fullPath) + })); + return true; + } catch (error) { + console.error('[UI API] Failed to view config file:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to view config file: ' + error.message + } + })); + return true; + } + } + + // Delete specific configuration file + const deleteConfigMatch = pathParam.match(/^\/api\/upload-configs\/delete\/(.+)$/); + if (method === 'DELETE' && deleteConfigMatch) { + try { + const filePath = decodeURIComponent(deleteConfigMatch[1]); + const fullPath = path.join(process.cwd(), filePath); + + // 安全检查:确保文件路径在允许的目录内 + const allowedDirs = ['configs']; + const relativePath = path.relative(process.cwd(), fullPath); + const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); + + if (!isAllowed) { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '访问被拒绝:只能删除configs目录下的文件' + } + })); + return true; + } + + if (!existsSync(fullPath)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '文件不存在' + } + })); + return true; + } + + + await fs.unlink(fullPath); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'delete', + filePath: relativePath, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: '文件删除成功', + filePath: relativePath + })); + return true; + } catch (error) { + console.error('[UI API] Failed to delete config file:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to delete config file: ' + error.message + } + })); + return true; + } + } + + // Reload configuration files + if (method === 'POST' && pathParam === '/api/reload-config') { + try { + // Import config manager dynamically + const { initializeConfig } = await import('./config-manager.js'); + + // Reload main config + const newConfig = await initializeConfig(process.argv.slice(2), 'config.json'); + + // Update global CONFIG + Object.assign(CONFIG, newConfig); + + // 广播更新事件 + broadcastEvent('config_update', { + action: 'reload', + filePath: 'config.json', + providerPoolsPath: newConfig.PROVIDER_POOLS_FILE_PATH || null, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: '配置文件重新加载成功', + details: { + configReloaded: true, + configPath: 'config.json', + providerPoolsPath: newConfig.PROVIDER_POOLS_FILE_PATH || null + } + })); + return true; + } catch (error) { + console.error('[UI API] Failed to reload config files:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: '重新加载配置文件失败: ' + error.message + } + })); + return true; + } + } + + return false; +} + +/** + * Initialize UI management features + * @param {Object} config - The server configuration + */ +export function initializeUIManagement(config) { + // Initialize log broadcasting for UI + if (!global.eventClients) { + global.eventClients = []; + } + if (!global.logBuffer) { + global.logBuffer = []; + } + + // Override console.log to broadcast logs + const originalLog = console.log; + console.log = function(...args) { + originalLog.apply(console, args); + const message = args.map(arg => typeof arg === 'string' ? arg : JSON.stringify(arg)).join(' '); + const logEntry = { + timestamp: new Date().toISOString(), + level: 'info', + message: message + }; + global.logBuffer.push(logEntry); + if (global.logBuffer.length > 100) { + global.logBuffer.shift(); + } + broadcastEvent('log', logEntry); + }; + + // Override console.error to broadcast errors + const originalError = console.error; + console.error = function(...args) { + originalError.apply(console, args); + const message = args.map(arg => typeof arg === 'string' ? arg : JSON.stringify(arg)).join(' '); + const logEntry = { + timestamp: new Date().toISOString(), + level: 'error', + message: message + }; + global.logBuffer.push(logEntry); + if (global.logBuffer.length > 100) { + global.logBuffer.shift(); + } + broadcastEvent('log', logEntry); + }; +} + +/** + * Helper function to broadcast events to UI clients + * @param {string} eventType - The type of event + * @param {any} data - The data to broadcast + */ +export function broadcastEvent(eventType, data) { + if (global.eventClients && global.eventClients.length > 0) { + const payload = typeof data === 'string' ? data : JSON.stringify(data); + global.eventClients.forEach(client => { + client.write(`event: ${eventType}\n`); + client.write(`data: ${payload}\n\n`); + }); + } +} + +/** + * Scan and analyze configuration files + * @param {Object} currentConfig - The current configuration object + * @param {Object} providerPoolManager - Provider pool manager instance + * @returns {Promise} Array of configuration file objects + */ +async function scanConfigFiles(currentConfig, providerPoolManager) { + const configFiles = []; + + // 只扫描configs目录 + const configsPath = path.join(process.cwd(), 'configs'); + + if (!existsSync(configsPath)) { + console.log('[Config Scanner] configs directory not found, creating empty result'); + return configFiles; + } + + const usedPaths = new Set(); // 存储已使用的路径,用于判断关联状态 + + // 从配置中提取所有OAuth凭据文件路径 - 标准化路径格式 + if (currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH) { + const normalizedPath = currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); + usedPaths.add(currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH); + usedPaths.add(normalizedPath); + if (normalizedPath.startsWith('./')) { + usedPaths.add(normalizedPath.slice(2)); + } + } + if (currentConfig.KIRO_OAUTH_CREDS_FILE_PATH) { + const normalizedPath = currentConfig.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); + usedPaths.add(currentConfig.KIRO_OAUTH_CREDS_FILE_PATH); + usedPaths.add(normalizedPath); + if (normalizedPath.startsWith('./')) { + usedPaths.add(normalizedPath.slice(2)); + } + } + if (currentConfig.QWEN_OAUTH_CREDS_FILE_PATH) { + const normalizedPath = currentConfig.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); + usedPaths.add(currentConfig.QWEN_OAUTH_CREDS_FILE_PATH); + usedPaths.add(normalizedPath); + if (normalizedPath.startsWith('./')) { + usedPaths.add(normalizedPath.slice(2)); + } + } + + // 使用最新的供应商池数据 + let providerPools = currentConfig.providerPools; + if (providerPoolManager && providerPoolManager.providerPools) { + providerPools = providerPoolManager.providerPools; + } + + // 检查供应商池文件中的所有OAuth凭据路径 - 标准化路径格式 + if (providerPools) { + for (const [providerType, providers] of Object.entries(providerPools)) { + for (const provider of providers) { + if (provider.GEMINI_OAUTH_CREDS_FILE_PATH) { + const normalizedPath = provider.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); + usedPaths.add(provider.GEMINI_OAUTH_CREDS_FILE_PATH); + usedPaths.add(normalizedPath); + if (normalizedPath.startsWith('./')) { + usedPaths.add(normalizedPath.slice(2)); + } + } + if (provider.KIRO_OAUTH_CREDS_FILE_PATH) { + const normalizedPath = provider.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); + usedPaths.add(provider.KIRO_OAUTH_CREDS_FILE_PATH); + usedPaths.add(normalizedPath); + if (normalizedPath.startsWith('./')) { + usedPaths.add(normalizedPath.slice(2)); + } + } + if (provider.QWEN_OAUTH_CREDS_FILE_PATH) { + const normalizedPath = provider.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/'); + usedPaths.add(provider.QWEN_OAUTH_CREDS_FILE_PATH); + usedPaths.add(normalizedPath); + if (normalizedPath.startsWith('./')) { + usedPaths.add(normalizedPath.slice(2)); + } + } + } + } + } + + try { + // 扫描configs目录下的所有子目录和文件 + const configsFiles = await scanOAuthDirectory(configsPath, usedPaths, currentConfig); + configFiles.push(...configsFiles); + } catch (error) { + console.warn(`[Config Scanner] Failed to scan configs directory:`, error.message); + } + + return configFiles; +} + +/** + * Analyze OAuth configuration file and return metadata + * @param {string} filePath - Full path to the file + * @param {Set} usedPaths - Set of paths currently in use + * @returns {Promise} OAuth file information object + */ +async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { + try { + const stats = await fs.stat(filePath); + const ext = path.extname(filePath).toLowerCase(); + const filename = path.basename(filePath); + const relativePath = path.relative(process.cwd(), filePath); + + // 读取文件内容进行分析 + let content = ''; + let type = 'oauth_credentials'; + let isValid = true; + let errorMessage = ''; + let oauthProvider = 'unknown'; + let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig); + + try { + if (ext === '.json') { + const rawContent = await fs.readFile(filePath, 'utf8'); + const jsonData = JSON.parse(rawContent); + content = rawContent; + + // 识别OAuth提供商 + if (jsonData.apiKey || jsonData.api_key) { + type = 'api_key'; + } else if (jsonData.client_id || jsonData.client_secret) { + oauthProvider = 'oauth2'; + } else if (jsonData.access_token || jsonData.refresh_token) { + oauthProvider = 'token_based'; + } else if (jsonData.credentials) { + oauthProvider = 'service_account'; + } + + if (jsonData.base_url || jsonData.endpoint) { + if (jsonData.base_url.includes('openai.com')) { + oauthProvider = 'openai'; + } else if (jsonData.base_url.includes('anthropic.com')) { + oauthProvider = 'claude'; + } else if (jsonData.base_url.includes('googleapis.com')) { + oauthProvider = 'gemini'; + } + } + } else { + content = await fs.readFile(filePath, 'utf8'); + + if (ext === '.key' || ext === '.pem') { + if (content.includes('-----BEGIN') && content.includes('PRIVATE KEY-----')) { + oauthProvider = 'private_key'; + } + } else if (ext === '.txt') { + if (content.includes('api_key') || content.includes('apikey')) { + oauthProvider = 'api_key'; + } + } else if (ext === '.oauth' || ext === '.creds') { + oauthProvider = 'oauth_credentials'; + } + } + } catch (readError) { + isValid = false; + errorMessage = `无法读取文件: ${readError.message}`; + } + + return { + name: filename, + path: relativePath, + size: stats.size, + type: type, + provider: oauthProvider, + extension: ext, + modified: stats.mtime.toISOString(), + isValid: isValid, + errorMessage: errorMessage, + isUsed: isPathUsed(relativePath, filename, usedPaths), + usageInfo: usageInfo, // 新增详细关联信息 + preview: content.substring(0, 100) + (content.length > 100 ? '...' : '') + }; + } catch (error) { + console.warn(`[OAuth Analyzer] Failed to analyze file ${filePath}:`, error.message); + return null; + } +} + +/** + * Get detailed usage information for a file + * @param {string} relativePath - Relative file path + * @param {string} fileName - File name + * @param {Set} usedPaths - Set of used paths + * @param {Object} currentConfig - Current configuration + * @returns {Object} Usage information object + */ +function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { + const usageInfo = { + isUsed: false, + usageType: null, + usageDetails: [] + }; + + // 检查是否被使用 + const isUsed = isPathUsed(relativePath, fileName, usedPaths); + if (!isUsed) { + return usageInfo; + } + + usageInfo.isUsed = true; + + // 检查主要配置中的使用情况 + if (currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + usageInfo.usageType = 'main_config'; + usageInfo.usageDetails.push({ + type: '主要配置', + location: 'Gemini OAuth凭据文件路径', + configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' + }); + } + + if (currentConfig.KIRO_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + usageInfo.usageType = 'main_config'; + usageInfo.usageDetails.push({ + type: '主要配置', + location: 'Kiro OAuth凭据文件路径', + configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' + }); + } + + if (currentConfig.QWEN_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + usageInfo.usageType = 'main_config'; + usageInfo.usageDetails.push({ + type: '主要配置', + location: 'Qwen OAuth凭据文件路径', + configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' + }); + } + + // 检查供应商池中的使用情况 + if (currentConfig.providerPools) { + // 使用 flatMap 将双重循环优化为单层循环 O(n) + const allProviders = Object.entries(currentConfig.providerPools).flatMap( + ([providerType, providers]) => + providers.map((provider, index) => ({ provider, providerType, index })) + ); + + for (const { provider, providerType, index } of allProviders) { + const providerUsages = []; + + if (provider.GEMINI_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, provider.GEMINI_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, provider.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + providerUsages.push({ + type: '供应商池', + location: `Gemini OAuth凭据 (节点${index + 1})`, + providerType: providerType, + providerIndex: index, + configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' + }); + } + + if (provider.KIRO_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, provider.KIRO_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, provider.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + providerUsages.push({ + type: '供应商池', + location: `Kiro OAuth凭据 (节点${index + 1})`, + providerType: providerType, + providerIndex: index, + configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' + }); + } + + if (provider.QWEN_OAUTH_CREDS_FILE_PATH && + (pathsEqual(relativePath, provider.QWEN_OAUTH_CREDS_FILE_PATH) || + pathsEqual(relativePath, provider.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { + providerUsages.push({ + type: '供应商池', + location: `Qwen OAuth凭据 (节点${index + 1})`, + providerType: providerType, + providerIndex: index, + configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' + }); + } + + if (providerUsages.length > 0) { + usageInfo.usageType = 'provider_pool'; + usageInfo.usageDetails.push(...providerUsages); + } + } + } + + // 如果有多个使用位置,标记为多种用途 + if (usageInfo.usageDetails.length > 1) { + usageInfo.usageType = 'multiple'; + } + + return usageInfo; +} + +/** + * Scan OAuth directory for credential files + * @param {string} dirPath - Directory path to scan + * @param {Set} usedPaths - Set of used paths + * @param {Object} currentConfig - Current configuration + * @returns {Promise} Array of OAuth configuration file objects + */ +async function scanOAuthDirectory(dirPath, usedPaths, currentConfig) { + const oauthFiles = []; + + try { + const files = await fs.readdir(dirPath, { withFileTypes: true }); + + for (const file of files) { + const fullPath = path.join(dirPath, file.name); + + if (file.isFile()) { + const ext = path.extname(file.name).toLowerCase(); + // 只关注OAuth相关的文件类型 + if (['.json', '.oauth', '.creds', '.key', '.pem', '.txt'].includes(ext)) { + const fileInfo = await analyzeOAuthFile(fullPath, usedPaths, currentConfig); + if (fileInfo) { + oauthFiles.push(fileInfo); + } + } + } else if (file.isDirectory()) { + // 递归扫描子目录(限制深度) + const relativePath = path.relative(process.cwd(), fullPath); + if (relativePath.split(path.sep).length < 3) { // 最大深度3层 + const subFiles = await scanOAuthDirectory(fullPath, usedPaths, currentConfig); + oauthFiles.push(...subFiles); + } + } + } + } catch (error) { + console.warn(`[OAuth Scanner] Failed to scan directory ${dirPath}:`, error.message); + } + + return oauthFiles; +} + + +/** + * Normalize a path for cross-platform compatibility + * @param {string} filePath - The file path to normalize + * @returns {string} Normalized path using forward slashes + */ +function normalizePath(filePath) { + if (!filePath) return filePath; + + // Use path module to normalize and then convert to forward slashes + const normalized = path.normalize(filePath); + return normalized.replace(/\\/g, '/'); +} + +/** + * Extract filename from any path format + * @param {string} filePath - The file path + * @returns {string} Filename + */ +function getFileName(filePath) { + return path.basename(filePath); +} + +/** + * Check if two paths refer to the same file (cross-platform compatible) + * @param {string} path1 - First path + * @param {string} path2 - Second path + * @returns {boolean} True if paths refer to same file + */ +function pathsEqual(path1, path2) { + if (!path1 || !path2) return false; + + try { + // Normalize both paths + const normalized1 = normalizePath(path1); + const normalized2 = normalizePath(path2); + + // Direct match + if (normalized1 === normalized2) { + return true; + } + + // Remove leading './' if present + const clean1 = normalized1.replace(/^\.\//, ''); + const clean2 = normalized2.replace(/^\.\//, ''); + + if (clean1 === clean2) { + return true; + } + + // Check if one is a subset of the other (for relative vs absolute) + if (normalized1.endsWith('/' + clean2) || normalized2.endsWith('/' + clean1)) { + return true; + } + + return false; + } catch (error) { + console.warn(`[Path Comparison] Error comparing paths: ${path1} vs ${path2}`, error.message); + return false; + } +} + +/** + * Check if a file path is being used (cross-platform compatible) + * @param {string} relativePath - Relative path + * @param {string} fileName - File name + * @param {Set} usedPaths - Set of used paths + * @returns {boolean} True if the file is being used + */ +function isPathUsed(relativePath, fileName, usedPaths) { + if (!relativePath) return false; + + // Normalize the relative path + const normalizedRelativePath = normalizePath(relativePath); + const cleanRelativePath = normalizedRelativePath.replace(/^\.\//, ''); + + // Get the filename from relative path + const relativeFileName = getFileName(normalizedRelativePath); + + // 遍历所有已使用路径进行匹配 + for (const usedPath of usedPaths) { + if (!usedPath) continue; + + // 1. 直接路径匹配 + if (pathsEqual(relativePath, usedPath) || pathsEqual(relativePath, './' + usedPath)) { + return true; + } + + // 2. 标准化路径匹配 + if (pathsEqual(normalizedRelativePath, usedPath) || + pathsEqual(normalizedRelativePath, './' + usedPath)) { + return true; + } + + // 3. 清理后的路径匹配 + if (pathsEqual(cleanRelativePath, usedPath) || + pathsEqual(cleanRelativePath, './' + usedPath)) { + return true; + } + + // 4. 文件名匹配(确保不是误匹配) + const usedFileName = getFileName(usedPath); + if (usedFileName === fileName || usedFileName === relativeFileName) { + // 确保是同一个目录下的文件 + const usedDir = path.dirname(usedPath); + const relativeDir = path.dirname(normalizedRelativePath); + + if (pathsEqual(usedDir, relativeDir) || + pathsEqual(usedDir, cleanRelativePath.replace(/\/[^\/]+$/, '')) || + pathsEqual(relativeDir.replace(/^\.\//, ''), usedDir.replace(/^\.\//, ''))) { + return true; + } + } + + // 5. 绝对路径匹配(Windows和Unix) + try { + const resolvedUsedPath = path.resolve(usedPath); + const resolvedRelativePath = path.resolve(relativePath); + + if (resolvedUsedPath === resolvedRelativePath) { + return true; + } + } catch (error) { + // Ignore path resolution errors + } + } + + return false; +} \ No newline at end of file diff --git a/static/app/app.js b/static/app/app.js new file mode 100644 index 0000000..102f5ce --- /dev/null +++ b/static/app/app.js @@ -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 管理控制台已加载 - 模块化版本'); diff --git a/static/app/config-manager.js b/static/app/config-manager.js new file mode 100644 index 0000000..da2b2a3 --- /dev/null +++ b/static/app/config-manager.js @@ -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 +}; \ No newline at end of file diff --git a/static/app/constants.js b/static/app/constants.js new file mode 100644 index 0000000..6c8883c --- /dev/null +++ b/static/app/constants.js @@ -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 }; +} \ No newline at end of file diff --git a/static/app/event-handlers.js b/static/app/event-handlers.js new file mode 100644 index 0000000..85c634b --- /dev/null +++ b/static/app/event-handlers.js @@ -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 = ` + + 自动滚动: ${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 = ` + + 自动滚动: 关 + `; + } + } + }); + } +} + +/** + * 供应商配置切换处理 + */ +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 +}; \ No newline at end of file diff --git a/static/app/event-stream.js b/static/app/event-stream.js new file mode 100644 index 0000000..bb7103d --- /dev/null +++ b/static/app/event-stream.js @@ -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 = ` + [${time}] + [${logData.level.toUpperCase()}] + ${escapeHtml(logData.message)} + `; + + 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 = ' 已连接'; + } else { + statusBadge.classList.add('error'); + icon.style.color = 'var(--danger-color)'; + statusBadge.innerHTML = ' 连接断开'; + } +} + +/** + * 更新提供商状态 + * @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 +}; diff --git a/static/app/file-upload.js b/static/app/file-upload.js new file mode 100644 index 0000000..3ca445b --- /dev/null +++ b/static/app/file-upload.js @@ -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 +}; \ No newline at end of file diff --git a/static/app/mobile.css b/static/app/mobile.css new file mode 100644 index 0000000..dad0850 --- /dev/null +++ b/static/app/mobile.css @@ -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); + } +} \ No newline at end of file diff --git a/static/app/modal.js b/static/app/modal.js new file mode 100644 index 0000000..5cf8f5f --- /dev/null +++ b/static/app/modal.js @@ -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 = ` +
+
+

管理 ${providerType} 供应商配置

+ +
+
+
+
+ 总账户数: + ${totalCount} +
+
+ 健康账户: + ${healthyCount} +
+
+ +
+
+ +
+ ${renderProviderList(providers)} +
+
+
+ `; + + // 添加到页面 + 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 ` +
+
+
+
${provider.uuid}
+
+ + + 健康状态: ${healthText} + | + 使用次数: ${provider.usageCount || 0} | + 最后使用: ${lastUsed} +
+
+
+ + +
+
+
+
+ ${renderProviderConfig(provider)} +
+
+
+ `; + }).join(''); +} + +/** + * 渲染供应商配置 + * @param {Object} provider - 供应商对象 + * @returns {string} HTML字符串 + */ +function renderProviderConfig(provider) { + // 获取字段映射,确保顺序一致 + const fieldOrder = getFieldOrder(provider); + + // 先渲染基础配置字段(checkModelName 和 checkHealth) + let html = '
'; + 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 += ` +
+ + +
+ `; + } else { + // checkModelName 字段始终显示 + html += ` +
+ + +
+ `; + } + }); + html += '
'; + + // 渲染其他配置字段,每行2列 + const otherFields = fieldOrder.filter(key => !baseFields.includes(key)); + + for (let i = 0; i < otherFields.length; i += 2) { + html += '
'; + + 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 += ` +
+ +
+ + +
+
+ `; + } else if (field1IsOAuthFilePath) { + // OAuth凭据文件路径字段,添加上传按钮 + html += ` +
+ +
+ + +
+
+ `; + } else { + html += ` +
+ + +
+ `; + } + + // 如果有第二个字段 + 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 += ` +
+ +
+ + +
+
+ `; + } else if (field2IsOAuthFilePath) { + // OAuth凭据文件路径字段,添加上传按钮 + html += ` +
+ +
+ + +
+
+ `; + } else { + html += ` +
+ + +
+ `; + } + } + + html += '
'; + } + + 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 = ` + + + `; + }, 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 = ` + + + `; +} + +/** + * 保存供应商 + * @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 = ` +

添加新供应商配置

+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+ `; + + // 添加动态配置字段 + 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 += '
'; + + const field1 = providerFields[i]; + // 检查是否为密码类型字段 + const isPassword1 = field1.type === 'password'; + // 检查是否为OAuth凭据文件路径字段 + const isOAuthFilePath1 = field1.id.includes('OauthCredsFilePath'); + + if (isPassword1) { + fields += ` +
+ +
+ + +
+
+ `; + } else if (isOAuthFilePath1) { + // OAuth凭据文件路径字段,添加上传按钮 + fields += ` +
+ +
+ + +
+
+ `; + } else { + fields += ` +
+ + +
+ `; + } + + const field2 = providerFields[i + 1]; + if (field2) { + // 检查是否为密码类型字段 + const isPassword2 = field2.type === 'password'; + // 检查是否为OAuth凭据文件路径字段 + const isOAuthFilePath2 = field2.id.includes('OauthCredsFilePath'); + + if (isPassword2) { + fields += ` +
+ +
+ + +
+
+ `; + } else if (isOAuthFilePath2) { + // OAuth凭据文件路径字段,添加上传按钮 + fields += ` +
+ +
+ + +
+
+ `; + } else { + fields += ` +
+ + +
+ `; + } + } + + fields += '
'; + } + } else { + fields = '

不支持的提供商类型

'; + } + + 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; \ No newline at end of file diff --git a/static/app/navigation.js b/static/app/navigation.js new file mode 100644 index 0000000..212eac8 --- /dev/null +++ b/static/app/navigation.js @@ -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 +}; \ No newline at end of file diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js new file mode 100644 index 0000000..a50adaf --- /dev/null +++ b/static/app/provider-manager.js @@ -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 = ` +
+
+ ${providerType} +
+
+ + ${healthyCount}/${totalCount} 健康 +
+
+
+
+ 总账户 + ${totalCount} +
+
+ 健康账户 + ${healthyCount} +
+
+ 使用次数 + ${usageCount} +
+
+ 错误次数 + ${errorCount} +
+
+ `; + + // 添加点击事件 - 整个供应商组都可以点击 + 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 = '

暂无供应商池配置

'; + } +} + +/** + * 更新提供商统计信息 + * @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 +}; \ No newline at end of file diff --git a/static/app/routing-examples.js b/static/app/routing-examples.js new file mode 100644 index 0000000..f5aa18d --- /dev/null +++ b/static/app/routing-examples.js @@ -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 +}; \ No newline at end of file diff --git a/static/app/styles.css b/static/app/styles.css new file mode 100644 index 0000000..e83807c --- /dev/null +++ b/static/app/styles.css @@ -0,0 +1,2647 @@ +/* CSS变量 */ +:root { + --primary-color: #4f46e5; + --secondary-color: #818cf8; + --success-color: #10b981; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f3f4f6; + --text-primary: #111827; + --text-secondary: #6b7280; + --border-color: #e5e7eb; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --transition: all 0.3s ease; +} + +/* 基础样式 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--bg-secondary); + color: var(--text-primary); + line-height: 1.6; +} + +/* 容器 */ +.container { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Header */ +.header { + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + box-shadow: var(--shadow-sm); + position: sticky; + top: 0; + z-index: 100; +} + +.header-content { + max-width: 1400px; + margin: 0 auto; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.header h1 { + font-size: 1.5rem; + font-weight: 600; + color: var(--primary-color); +} + +.header h1 i { + margin-right: 0.5rem; +} + +.header-controls { + display: flex; + gap: 1rem; + align-items: center; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--bg-tertiary); + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 500; +} + +.status-badge i { + color: var(--success-color); + animation: pulse 2s infinite; +} + +.status-badge.error i { + color: var(--danger-color); +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* 主要内容区域 */ +.main-content { + display: flex; + flex: 1; + max-width: 1400px; + width: 100%; + margin: 0 auto; +} + +/* 侧边栏 */ +.sidebar { + width: 240px; + background: var(--bg-primary); + border-right: 1px solid var(--border-color); + padding: 1.5rem 0; +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1.5rem; + color: var(--text-secondary); + text-decoration: none; + transition: var(--transition); + font-weight: 500; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; +} + +.nav-item:hover { + background: var(--bg-secondary); + color: var(--primary-color); +} + +.nav-item.active { + background: var(--bg-tertiary); + color: var(--primary-color); + border-right: 3px solid var(--primary-color); +} + +.nav-item i { + width: 20px; + text-align: center; +} + +/* 内容区域 */ +.content { + flex: 1; + padding: 2rem; + overflow-y: auto; +} + +.section { + display: none; +} + +.section.active { + display: block; +} + +.section h2 { + font-size: 1.875rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: var(--text-primary); +} + +/* 统计卡片 */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); + display: flex; + align-items: center; + gap: 1rem; + transition: var(--transition); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.stat-icon { + width: 60px; + height: 60px; + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: var(--primary-color); + background: var(--bg-tertiary); +} + +.stat-info h3 { + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.stat-info p { + color: var(--text-secondary); + font-size: 0.875rem; +} + +/* 表单样式 */ +.config-panel { + background: var(--bg-primary); + padding: 2rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); +} + +.config-form { + max-width: 800px; + margin: 0 auto; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text-primary); +} + +.form-control { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + font-size: 0.875rem; + transition: var(--transition); + background: var(--bg-primary); +} + +.form-control:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +textarea.form-control { + resize: vertical; + font-family: inherit; +} + +/* 密码输入框样式 */ +.password-input-group { + position: relative; +} + +.password-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.password-input-wrapper .form-control { + padding-right: 3rem; +} + +.password-input-wrapper input[type="password"], +.password-input-wrapper input[type="text"] { + flex: 1; + padding-right: 3rem; +} + +.password-toggle { + position: absolute; + right: 0.75rem; + background: none; + border: none; + cursor: pointer; + padding: 0.25rem; + color: var(--text-secondary); + transition: var(--transition); + z-index: 1; + width: auto; + flex-shrink: 0; +} + +.password-toggle:hover { + color: var(--primary-color); +} + +.password-toggle i { + font-size: 1rem; + width: 1rem; + text-align: center; +} + +/* 模态框中的密码输入框样式 */ +.config-item .password-input-wrapper { + position: relative; + width: 100%; +} + +.config-item .password-input-wrapper input { + width: 100%; + padding-right: 2.5rem; + box-sizing: border-box; +} + +.config-item .password-toggle { + position: absolute; + right: 0.5rem; + padding: 0.25rem; + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + transition: var(--transition); + width: auto; + height: auto; + line-height: 1; +} + +.config-item .password-toggle:hover { + color: var(--primary-color); +} + +/* 文件上传输入框样式 */ +.file-input-group { + position: relative; + display: flex; + align-items: center; + gap: 0; +} + +.file-input-group .form-control { + flex: 1; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + padding-right: 3rem; +} + +.upload-btn { + position: absolute; + right: 0; + top: 0; + height: 100%; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: none; + padding: 0.75rem 1rem; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-left: none; + cursor: pointer; + transition: var(--transition); + white-space: nowrap; + z-index: 1; + background: bottom; +} + +.upload-btn:hover { + background: var(--bg-tertiary); + color: var(--primary-color); + border-color: var(--primary-color); +} + +.upload-btn i { + font-size: 0.875rem; +} + +/* 单选按钮组 */ +.radio-group { + display: flex; + gap: 1.5rem; + margin-top: 0.5rem; +} + +.radio-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-weight: 500; + color: var(--text-primary); +} + +.radio-label input[type="radio"] { + margin: 0; + cursor: pointer; +} + +/* 供应商配置组 */ +.provider-config { + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + margin-top: 1rem; + background: var(--bg-secondary); +} + +/* 高级配置区域 */ +.advanced-config-section { + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + margin-top: 1.5rem; + background: var(--bg-secondary); +} + +.advanced-config-section h3 { + color: var(--text-primary); + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.advanced-config-section h3 i { + color: var(--primary-color); +} + +.config-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.config-row:last-child { + margin-bottom: 0; +} + +/* 复选框样式 */ +.form-group input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--primary-color); + cursor: pointer; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .config-row { + grid-template-columns: 1fr; + gap: 1.5rem; + } +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 2rem; + justify-content: flex-end; +} + +/* 按钮 */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + text-decoration: none; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + background: var(--primary-color); + color: white; + line-height: 0; +} + +.btn-primary:hover { + background: #4338ca; +} + +.btn-success { + background: var(--success-color); + color: white; +} + +.btn-success:hover { + background: #059669; +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: #e5e7eb; +} + +.btn-danger { + background: var(--danger-color); + color: white; +} + +.btn-danger:hover { + background: #dc2626; +} + +/* 提供商列表 */ +.providers-container { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); +} + +.providers-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.provider-item { + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + transition: var(--transition); +} + +.provider-item:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-sm); +} + +.provider-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.provider-name { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.provider-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +/* Path Routing Examples Panel */ +.routing-examples-panel { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); + margin-top: 1.5rem; + margin-bottom: 1.5rem; +} + +.routing-examples-panel h3 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.routing-examples-panel h3 i { + color: var(--primary-color); +} + +.routing-description { + color: var(--text-secondary); + font-size: 0.875rem; + margin-bottom: 1.5rem; + line-height: 1.5; +} + +.routing-examples-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.routing-example-card { + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; + transition: var(--transition); + background: var(--bg-secondary); +} + +.routing-example-card:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.routing-card-header { + background: var(--bg-primary); + padding: 1rem 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; + border-bottom: 1px solid var(--border-color); +} + +.routing-card-header i { + font-size: 1.25rem; + color: var(--primary-color); +} + +.routing-card-header h4 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; + flex: 1; +} + +.provider-badge { + padding: 0.25rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.provider-badge.official { + background: #dbeafe; + color: #1e40af; +} + +.provider-badge.oauth { + background: #d1fae5; + color: #065f46; +} + +.provider-badge.responses { + background: #fef3c7; + color: #92400e; +} + +.routing-card-content { + padding: 1.5rem; +} + +/* 协议标签样式 */ +.protocol-tabs { + display: flex; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.protocol-tab { + background: none; + border: none; + padding: 0.75rem 1rem; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + border-bottom: 2px solid transparent; + transition: var(--transition); + position: relative; +} + +.protocol-tab:hover { + color: var(--primary-color); + background: var(--bg-tertiary); +} + +.protocol-tab.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); + background: var(--bg-secondary); +} + +/* 协议内容区域 */ +.protocol-content { + display: none; + animation: fadeIn 0.3s ease; +} + +.protocol-content.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.endpoint-info { + margin-bottom: 1rem; +} + +.endpoint-info label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.endpoint-path { + display: inline-block; + background: var(--bg-primary); + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + font-family: 'Courier New', monospace; + font-size: 0.875rem; + color: var(--text-primary); + border: 1px solid var(--border-color); + position: relative; + padding-right: 2.5rem; +} + +.copy-btn { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem; + border-radius: 0.25rem; + transition: var(--transition); +} + +.copy-btn:hover { + background: var(--bg-tertiary); + color: var(--primary-color); +} + +.usage-example { + margin-top: 1rem; +} + +.usage-example label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.usage-example pre { + background: #1e1e1e; + color: #d4d4d4; + padding: 1rem; + border-radius: 0.375rem; + overflow-x: auto; + font-size: 0.75rem; + line-height: 1.4; + margin: 0; +} + +.usage-example code { + font-family: 'Courier New', monospace; + white-space: pre-wrap; +} + +.routing-tips { + background: var(--bg-secondary); + padding: 1.5rem; + border-radius: 0.5rem; + border-left: 4px solid var(--primary-color); +} + +.routing-tips h4 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.routing-tips h4 i { + color: var(--warning-color); +} + +.routing-tips ul { + margin: 0; + padding-left: 1.5rem; +} + +.routing-tips li { + margin-bottom: 0.75rem; + color: var(--text-secondary); + font-size: 0.875rem; + line-height: 1.5; +} + +.routing-tips code { + background: var(--bg-primary); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-family: 'Courier New', monospace; + font-size: 0.75rem; + color: var(--primary-color); +} + +/* System Info Panel in Dashboard */ +.system-info-panel { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); + margin-top: 1.5rem; +} + +.system-info-panel h3 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.info-item .info-label { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; +} + +.info-item .info-label i { + color: var(--primary-color); + width: 16px; + text-align: center; +} + +.info-item .info-value { + color: var(--text-primary); + font-size: 1rem; + font-weight: 600; + padding-left: 1.5rem; +} + +.status-healthy { + background: #d1fae5; + color: #065f46; +} + +.status-unhealthy { + background: #fee2e2; + color: #991b1b; +} + +.provider-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.provider-stat { + display: flex; + flex-direction: column; +} + +.provider-stat-label { + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.provider-stat-value { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +/* 日志 */ +.logs-controls { + display: flex; + gap: 1rem; + margin-bottom: 1rem; +} + +.logs-container { + background: #1e1e1e; + color: #d4d4d4; + padding: 1.5rem; + border-radius: 0.5rem; + height: 800px; + overflow-y: auto; + font-family: 'Courier New', monospace; + font-size: 0.875rem; + box-shadow: var(--shadow-md); +} + +.log-entry { + margin-bottom: 0.5rem; + padding: 0.25rem 0; +} + +.log-time { + color: #858585; +} + +.log-level-info { + color: #4ec9b0; +} + +.log-level-error { + color: #f48771; +} + +.log-level-warn { + color: #dcdcaa; +} + +/* 系统信息 */ +.system-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.info-card { + background: var(--bg-primary); + padding: 1.5rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); +} + +.info-card h3 { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-color); +} + +.info-row:last-child { + border-bottom: none; +} + +.info-label { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.info-value { + color: var(--text-primary); + font-weight: 500; + font-size: 0.875rem; +} + +/* 通知 */ +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1001; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.toast { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1rem 1.5rem; + box-shadow: var(--shadow-lg); + min-width: 300px; + animation: slideIn 0.3s ease; + pointer-events: auto; +} + +.toast.success { + border-left: 4px solid var(--success-color); +} + +.toast.error { + border-left: 4px solid var(--danger-color); +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* OAuth刷新切换开关 */ +.oauth-refresh-toggle { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 48px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); + transition: var(--transition); + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 2px; + background-color: white; + transition: var(--transition); + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +input:checked + .toggle-slider { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +input:checked + .toggle-slider:before { + transform: translateX(24px); +} + +.toggle-label { + font-weight: 500; + color: var(--text-primary); + font-size: 0.875rem; +} + +/* 系统提示区域 */ +.system-prompt-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-color); +} + +.pool-section .form-text { + margin-top: 0.5rem; + color: var(--text-secondary); + font-size: 0.75rem; + font-style: italic; +} + +/* 供应商类型显示 */ +.provider-type-text { + font-size: 16px; + font-weight: 600; + color: #2c3e50; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.3s ease; +} + +.provider-type-text:hover { + background: var(--bg-tertiary); + color: var(--primary-color); +} + +/* 模态框样式 */ +.provider-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.provider-modal-content { + background: white; + border-radius: 16px; + width: 95%; + max-width: 1200px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + transform: translateY(-50px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.provider-modal-header { + padding: 24px; + border-bottom: 1px solid #e9ecef; + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); +} + +.provider-modal-header h3 { + margin: 0; + color: #2c3e50; + font-size: 20px; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + font-size: 20px; + cursor: pointer; + color: #6c757d; + padding: 8px; + border-radius: 50%; + transition: all 0.3s ease; +} + +.modal-close:hover { + background: #e9ecef; + color: #495057; + transform: rotate(90deg); +} + +.provider-modal-body { + padding: 24px; + max-height: calc(85vh - 80px); + overflow-y: auto; +} + +.provider-summary { + display: flex; + gap: 24px; + align-items: center; + margin-bottom: 24px; + padding: 20px; + background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); + border-radius: 12px; + border: 1px solid #e9ecef; +} + +.provider-summary-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; +} + +.provider-summary-item .label { + font-size: 13px; + color: #6c757d; + margin-bottom: 8px; + font-weight: 500; +} + +.provider-summary-item .value { + font-size: 24px; + font-weight: bold; + color: #2c3e50; +} + +.provider-summary-actions { + margin-left: auto; + display: flex; + align-items: center; +} + +.provider-actions { + margin-bottom: 24px; +} + +.provider-item-detail { + border: 1px solid #e9ecef; + border-radius: 12px; + margin-bottom: 16px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; +} + +.provider-item-detail:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + transform: translateY(-1px); +} + +.provider-item-header { + padding: 20px; + background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + transition: all 0.3s ease; +} + +.provider-item-header:hover { + background: linear-gradient(135deg, #e9ecef 0%, #f8f9fa 100%); +} + +.provider-info { + flex: 1; +} + +.provider-name { + font-weight: 600; + margin-bottom: 8px; + color: #2c3e50; + font-size: 15px; +} + +.provider-meta { + font-size: 13px; + color: #6c757d; + line-height: 1.4; +} + +.provider-actions-group { + display: flex; + gap: 8px; + align-items: center; +} + +.btn-small { + padding: 8px 12px; + font-size: 12px; + border: none; + border-radius: 0.375rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; + transition: all 0.3s ease; +} + +.btn-edit { + background: linear-gradient(135deg, #007bff 0%, #6f42c1 100%); + color: white; + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); +} + +.btn-edit:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4); +} + +.btn-delete { + background: linear-gradient(135deg, #dc3545 0%, #fd7e14 100%); + color: white; + box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3); +} + +.btn-delete:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4); +} + +.btn-save { + background: linear-gradient(135deg, #28a745 0%, #20c997 100%); + color: white; + box-shadow: 0 2px 8px rgba(40, 167, 69, 0.3); +} + +.btn-save:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4); +} + +.btn-cancel { + background: linear-gradient(135deg, #6c757d 0%, #495057 100%); + color: white; + box-shadow: 0 2px 8px rgba(108, 117, 125, 0.3); +} + +.btn-cancel:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4); +} + +.provider-item-content { + padding: 20px; + display: none; + border-top: 1px solid #e9ecef; + background: white; +} + +.provider-item-content.expanded { + display: block; +} + +.config-item { + display: flex; + flex-direction: column; +} + +.config-item label { + font-size: 13px; + color: #495057; + margin-bottom: 8px; + font-weight: 500; +} + +.config-item input, .config-item textarea, .config-item select { + padding: 12px; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 13px; + transition: all 0.3s ease; +} + +.config-item input:focus, .config-item textarea:focus, .config-item select:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); +} + +.config-item input[readonly], .config-item select[disabled] { + background: #f8f9fa; + color: #6c757d; +} + +/* 模态框中的文件上传输入框样式 */ +.config-item .file-input-group { + position: relative; + display: flex; + align-items: center; + gap: 0; +} + +.config-item .file-input-group input { + flex: 1; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + padding-right: 3rem; + box-sizing: border-box; +} + +.add-provider-form { + background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); + padding: 24px; + border-radius: 12px; + margin-bottom: 24px; + border: 1px solid #e9ecef; +} + +.add-provider-form h4 { + margin: 0 0 20px 0; + color: #2c3e50; + font-size: 18px; + font-weight: 600; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; + margin-bottom: 20px; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group label { + font-size: 13px; + color: #495057; + margin-bottom: 8px; + font-weight: 500; +} + +.form-group input, .form-group select { + padding: 12px; + border: 2px solid #e9ecef; + border-radius: 8px; + font-size: 14px; + transition: all 0.3s ease; +} + +.form-group input:focus, .form-group select:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); +} + +/* 无提供商提示 */ +.no-providers { + text-align: center; + padding: 2rem; + color: var(--text-secondary); +} + +.no-providers p { + margin: 0; + font-size: 1rem; +} + +/* 无配置文件提示 */ +.no-configs { + text-align: center; + padding: 2rem; + color: var(--text-secondary); +} + +.no-configs p { + margin: 0; + font-size: 1rem; +} + +/* 响应式 */ +@media (max-width: 768px) { + .main-content { + flex-direction: column; + } + + .sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--border-color); + } + + .sidebar-nav { + flex-direction: row; + overflow-x: auto; + padding: 0 1rem; + } + + .form-row { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .oauth-refresh-toggle { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .provider-modal-content { + width: 98%; + max-height: 95vh; + } + + .provider-summary { + flex-direction: column; + align-items: flex-start; + gap: 16px; + } + + .provider-summary-actions { + margin-left: 0; + } + + .form-grid { + grid-template-columns: 1fr; + } +} + +/* 健康状态高亮样式 */ +.provider-item-detail.unhealthy { + border: 2px solid var(--warning-color); + background: linear-gradient(135deg, #fff3cd 0%, #ffffff 100%); + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.15); + animation: pulseWarning 2s infinite; +} + +.provider-item-detail.unhealthy:hover { + border-color: var(--warning-color); + box-shadow: 0 6px 20px rgba(245, 158, 11, 0.25); + transform: translateY(-2px); +} + +.provider-item-detail.unhealthy .provider-item-header { + background: linear-gradient(135deg, #fef3c7 0%, #ffffff 100%); + border-bottom: 1px solid rgba(245, 158, 11, 0.2); +} + +.provider-item-detail.healthy { + border: 1px solid #e9ecef; + background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); +} + +.provider-item-detail.healthy:hover { + border-color: var(--primary-color); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +.provider-item-detail.healthy .provider-item-header { + background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); +} + +.health-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: 6px; + transition: var(--transition); +} + +.provider-item-detail.unhealthy .health-status { + color: #92400e; + background: rgba(245, 158, 11, 0.1); +} + +.provider-item-detail.healthy .health-status { + color: #065f46; + background: rgba(16, 185, 129, 0.1); +} + +.text-success { + color: var(--success-color) !important; +} + +.text-warning { + color: var(--warning-color) !important; +} + +.text-danger { + color: var(--danger-color) !important; +} + +@keyframes pulseWarning { + 0%, 100% { + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.15); + } + 50% { + box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3); + } +} + +/* 异常状态的特殊标识 */ +.provider-item-detail.unhealthy::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: linear-gradient(135deg, var(--warning-color) 0%, #f59e0b 100%); + border-radius: 0 2px 2px 0; + z-index: 1; +} + +.provider-item-detail.unhealthy { + position: relative; +} + +/* 上传配置管理页面样式 */ +.upload-config-panel { + background: var(--bg-primary); + padding: 2rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); +} + +.config-search-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.search-controls { + display: grid; + grid-template-columns: 2fr 1fr auto; + gap: 1rem; + align-items: end; +} + +.search-input-group { + position: relative; + display: flex; + align-items: center; +} + +.search-input-group .form-control { + flex: 1; + padding-right: 3rem; +} + +.search-input-group .btn { + position: absolute; + right: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: 0.375rem; + cursor: pointer; + transition: var(--transition); +} + +.search-input-group .btn:hover { + background: #4338ca; + transform: translateY(-1px); +} + +.config-list-container { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; +} + +.config-list-header { + background: var(--bg-tertiary); + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.config-list-header h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.config-stats { + display: flex; + gap: 1rem; + align-items: center; +} + +.config-stats span { + font-size: 0.875rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; +} + +.status-used { + background: #d1fae5; + color: #065f46; +} + +.status-unused { + background: #fef3c7; + color: #92400e; +} + +.status-invalid { + background: #fee2e2; + color: #991b1b; +} + +.config-list { + overflow-y: auto; +} + +.config-item { + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + transition: var(--transition); + cursor: pointer; +} + +.config-item:hover { + background: var(--bg-secondary); +} + +.config-item:last-child { + border-bottom: none; +} + +.config-item-header { + display: flex; + justify-content: between; + align-items: center; + margin-bottom: 0.75rem; +} + +.config-item-name { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + flex: 1; +} + +.config-item-path { + font-size: 0.75rem; + color: var(--text-secondary); + font-family: 'Courier New', monospace; + background: var(--bg-tertiary); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + margin: 0 0.5rem; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.config-item-meta { + display: flex; + gap: 1rem; + align-items: center; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.config-item-type { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + background: var(--bg-tertiary); + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; +} + +.config-item-size { + font-family: 'Courier New', monospace; +} + +.config-item-modified { + font-family: 'Courier New', monospace; +} + +.config-item-status { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; +} + +.config-item.used .config-item-status { + background: #d1fae5; + color: #065f46; +} + +.config-item.unused .config-item-status { + background: #fef3c7; + color: #92400e; +} + +.config-item.invalid .config-item-status { + background: #fee2e2; + color: #991b1b; +} + +.config-item-details { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); + display: none; +} + +.config-item.expanded .config-item-details { + display: block; +} + +.config-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1rem; +} + +.config-detail-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.config-detail-label { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.config-detail-value { + font-size: 0.875rem; + color: var(--text-primary); + font-family: 'Courier New', monospace; + word-break: break-all; +} + +.config-item-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); +} + +.btn-small { + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + border: none; + border-radius: 0.25rem; + cursor: pointer; + transition: var(--transition); + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.btn-view { + background: var(--primary-color); + color: white; +} + +.btn-view:hover { + background: #4338ca; +} + +.btn-delete-small { + background: var(--danger-color); + color: white; +} + +.btn-delete-small:hover { + background: #dc2626; +} + +.config-item.expanded { + background: var(--bg-secondary); +} + +.config-item.expanded:hover { + background: #e5e7eb; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .search-controls { + grid-template-columns: 1fr; + gap: 1rem; + } + + .config-list-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .config-stats { + flex-wrap: wrap; + } + + .config-item-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .config-item-path { + max-width: 100%; + margin: 0; + } + + .config-item-meta { + flex-wrap: wrap; + gap: 0.5rem; + } + + .config-details-grid { + grid-template-columns: 1fr; + } + + .config-item-actions { + flex-wrap: wrap; + } +} + +/* 配置查看模态框样式 */ +.config-view-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.config-view-modal.show { + opacity: 1; + visibility: visible; +} + +.config-modal-content { + background: var(--bg-primary); + border-radius: 0.5rem; + width: 90%; + max-width: 800px; + max-height: 80vh; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + animation: modalSlideIn 0.3s ease; +} + +@keyframes modalSlideIn { + from { + transform: translateY(-50px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.config-modal-header { + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary); +} + +.config-modal-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.config-modal-body { + padding: 1.5rem; + flex: 1; + overflow-y: auto; +} + +.config-file-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--bg-secondary); + border-radius: 0.5rem; +} + +.file-info-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.file-info-item .info-label { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.file-info-item .info-value { + font-size: 0.875rem; + color: var(--text-primary); + font-family: 'Courier New', monospace; + word-break: break-all; +} + +.config-content { + margin-top: 1rem; +} + +.config-content label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.config-content-display { + background: #1e1e1e; + color: #d4d4d4; + padding: 1rem; + border-radius: 0.5rem; + font-family: 'Courier New', monospace; + font-size: 0.875rem; + line-height: 1.5; + max-height: 400px; + overflow-y: auto; + white-space: pre-wrap; + word-wrap: break-word; +} + +.config-modal-footer { + padding: 1.5rem; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 1rem; + background: var(--bg-secondary); +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .config-modal-content { + width: 95%; + max-height: 90vh; + } + + .config-file-info { + grid-template-columns: 1fr; + } + + .config-modal-footer { + flex-direction: column; + } +} + +/* 关联信息显示样式 */ +.config-usage-info { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + margin: 1rem 0; + padding: 1rem; + border-left: 4px solid var(--primary-color); +} + +.usage-info-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-color); +} + +.usage-info-header i { + color: var(--primary-color); + font-size: 0.875rem; +} + +.usage-info-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); +} + +.usage-details-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.usage-detail-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + transition: var(--transition); +} + +.usage-detail-item:hover { + border-color: var(--primary-color); + background: var(--bg-tertiary); +} + +.usage-detail-item i { + color: var(--primary-color); + font-size: 0.875rem; + width: 16px; + text-align: center; + flex-shrink: 0; +} + +.usage-detail-type { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-primary); + background: var(--primary-color); + color: white; + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; +} + +.usage-detail-location { + font-size: 0.875rem; + color: var(--text-secondary); + font-family: 'Courier New', monospace; + word-break: break-all; + flex: 1; +} + +/* 针对不同使用类型的特殊样式 */ +.config-item.used .config-usage-info { + border-left-color: var(--success-color); +} + +.config-item.used .config-usage-info .usage-info-header i { + color: var(--success-color); +} + +.config-item.used .usage-detail-item i { + color: var(--success-color); +} + +.config-item.used .usage-detail-type { + background: var(--success-color); +} + +/* 供应商池关联的特殊样式 */ +.usage-detail-item[data-usage-type="provider_pool"] { + background: linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%); + border-color: #0ea5e9; +} + +.usage-detail-item[data-usage-type="provider_pool"] i { + color: #0ea5e9; +} + +.usage-detail-item[data-usage-type="provider_pool"] .usage-detail-type { + background: #0ea5e9; +} + +/* 主要配置关联的特殊样式 */ +.usage-detail-item[data-usage-type="main_config"] { + background: linear-gradient(135deg, #f0fdf4 0%, #ffffff 100%); + border-color: var(--success-color); +} + +.usage-detail-item[data-usage-type="main_config"] i { + color: var(--success-color); +} + +.usage-detail-item[data-usage-type="main_config"] .usage-detail-type { + background: var(--success-color); +} + +/* 多种用途的特殊样式 */ +.usage-detail-item[data-usage-type="multiple"] { + background: linear-gradient(135deg, #fef3c7 0%, #ffffff 100%); + border-color: var(--warning-color); +} + +.usage-detail-item[data-usage-type="multiple"] i { + color: var(--warning-color); +} + +.usage-detail-item[data-usage-type="multiple"] .usage-detail-type { + background: var(--warning-color); +} + +/* 删除确认模态框样式 */ +.delete-confirm-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.delete-confirm-modal.show { + opacity: 1; + visibility: visible; +} + +.delete-modal-content { + background: var(--bg-primary); + border-radius: 0.75rem; + width: 90%; + max-width: 600px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 25px 80px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + animation: modalSlideIn 0.3s ease; + border: 2px solid transparent; +} + +.delete-confirm-modal.used .delete-modal-content { + border-color: var(--danger-color); + box-shadow: 0 25px 80px rgba(239, 68, 68, 0.3); +} + +.delete-confirm-modal.unused .delete-modal-content { + border-color: var(--warning-color); + box-shadow: 0 25px 80px rgba(245, 158, 11, 0.2); +} + +@keyframes modalSlideIn { + from { + transform: translateY(-50px) scale(0.9); + opacity: 0; + } + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} + +.delete-modal-header { + padding: 1.5rem 2rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-secondary); +} + +.delete-confirm-modal.used .delete-modal-header { + background: linear-gradient(135deg, #fee2e2 0%, #ffffff 100%); + border-bottom-color: #fecaca; +} + +.delete-confirm-modal.unused .delete-modal-header { + background: linear-gradient(135deg, #fef3c7 0%, #ffffff 100%); + border-bottom-color: #fed7aa; +} + +.delete-modal-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.delete-confirm-modal.used .delete-modal-header h3 { + color: #dc2626; +} + +.delete-confirm-modal.unused .delete-modal-header h3 { + color: #d97706; +} + +.delete-modal-body { + padding: 2rem; + flex: 1; + overflow-y: auto; + max-height: calc(85vh - 160px); +} + +.delete-warning { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1.5rem; + border-radius: 0.5rem; + margin-bottom: 1.5rem; + border: 2px solid; +} + +.delete-warning.warning-used { + background: linear-gradient(135deg, #fef2f2 0%, #ffffff 100%); + border-color: #fca5a5; + color: #dc2626; +} + +.delete-warning.warning-unused { + background: linear-gradient(135deg, #fffbeb 0%, #ffffff 100%); + border-color: #fed7aa; + color: #d97706; +} + +.warning-icon { + font-size: 1.5rem; + margin-top: 0.25rem; + flex-shrink: 0; +} + +.warning-content h4 { + margin: 0 0 0.75rem 0; + font-size: 1rem; + font-weight: 600; +} + +.warning-content p { + margin: 0; + font-size: 0.875rem; + line-height: 1.5; +} + +.config-info { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.config-info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid #e5e7eb; +} + +.config-info-item:last-child { + border-bottom: none; +} + +.config-info-item .info-label { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; +} + +.config-info-item .info-value { + font-size: 0.875rem; + color: var(--text-primary); + font-weight: 600; + font-family: 'Courier New', monospace; + text-align: right; + max-width: 60%; + word-break: break-all; +} + +.config-info-item .info-value.status-used { + color: var(--success-color); + background: #d1fae5; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.config-info-item .info-value.status-unused { + color: var(--warning-color); + background: #fef3c7; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.usage-alert { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1.5rem; + background: linear-gradient(135deg, #fee2e2 0%, #fef2f2 100%); + border: 1px solid #fca5a5; + border-radius: 0.5rem; + margin-top: 1rem; +} + +.alert-icon { + font-size: 1.25rem; + color: #dc2626; + margin-top: 0.125rem; + flex-shrink: 0; +} + +.alert-content h5 { + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + font-weight: 600; + color: #dc2626; +} + +.alert-content p { + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + color: #7f1d1d; + line-height: 1.5; +} + +.alert-content ul { + margin: 0 0 0.75rem 1.5rem; + padding: 0; + font-size: 0.875rem; + color: #7f1d1d; +} + +.alert-content li { + margin-bottom: 0.25rem; +} + +.alert-content strong { + color: #dc2626; + font-weight: 600; +} + +.delete-modal-footer { + padding: 1.5rem 2rem; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 1rem; + background: var(--bg-secondary); +} + +.delete-confirm-modal.used .delete-modal-footer { + background: linear-gradient(135deg, #fee2e2 0%, #f9fafb 100%); + border-top-color: #fecaca; +} + +.delete-confirm-modal.unused .delete-modal-footer { + background: linear-gradient(135deg, #fef3c7 0%, #f9fafb 100%); + border-top-color: #fed7aa; +} + +.btn-cancel-delete { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-cancel-delete:hover { + background: #e5e7eb; + transform: translateY(-1px); +} + +.btn-confirm-delete { + position: relative; + overflow: hidden; +} + +.delete-confirm-modal.used .btn-confirm-delete { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + color: white; + box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4); + animation: pulseDanger 2s infinite; +} + +.delete-confirm-modal.unused .btn-confirm-delete { + background: linear-gradient(135deg, #d97706 0%, #b45309 100%); + color: white; + box-shadow: 0 4px 15px rgba(217, 119, 6, 0.3); +} + +.delete-confirm-modal.used .btn-confirm-delete:hover { + background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(220, 38, 38, 0.5); +} + +.delete-confirm-modal.unused .btn-confirm-delete:hover { + background: linear-gradient(135deg, #b45309 0%, #92400e 100%); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(217, 119, 6, 0.4); +} + +@keyframes pulseDanger { + 0%, 100% { + box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4); + } + 50% { + box-shadow: 0 4px 15px rgba(220, 38, 38, 0.7); + } +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .usage-detail-item { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .usage-detail-type { + align-self: flex-start; + } + + .usage-detail-location { + align-self: stretch; + } + + .delete-modal-content { + width: 95%; + max-height: 90vh; + } + + .delete-modal-body { + padding: 1.5rem; + } + + .delete-modal-header, + .delete-modal-footer { + padding: 1rem 1.5rem; + } + + .config-info { + padding: 1rem; + } + + .config-info-item { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .config-info-item .info-value { + max-width: 100%; + text-align: left; + } + + .delete-modal-footer { + flex-direction: column; + } + + .delete-warning, + .usage-alert { + flex-direction: column; + gap: 0.75rem; + } +} diff --git a/static/app/upload-config-manager.js b/static/app/upload-config-manager.js new file mode 100644 index 0000000..6732049 --- /dev/null +++ b/static/app/upload-config-manager.js @@ -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 = '

未找到匹配的配置文件

'; + 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 = ` +
+
${config.name}
+
${config.path}
+
+
+
${formatFileSize(config.size)}
+
${formatDate(config.modified)}
+
+ + ${statusText} +
+
+
+
+
+
文件路径
+
${config.path}
+
+
+
文件大小
+
${formatFileSize(config.size)}
+
+
+
最后修改
+
${formatDate(config.modified)}
+
+
+
关联状态
+
${statusText}
+
+
+ ${usageInfoHtml} +
+ + +
+
+ `; + + // 添加按钮事件监听器 + 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 += ` +
+ + ${detail.type} + ${detail.location} +
+ `; + }); + + return ` +
+
+ + 关联详情 (${typeLabel}) +
+
+ ${detailsHtml} +
+
+ `; +} + +/** + * 格式化文件大小 + * @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 = ` +
+
+

配置文件: ${fileData.name}

+ +
+
+
+
+ 文件路径: + ${fileData.path} +
+
+ 文件大小: + ${formatFileSize(fileData.size)} +
+
+ 最后修改: + ${formatDate(fileData.modified)} +
+
+
+ +
${escapeHtml(fileData.content)}
+
+
+ +
+ `; + + // 添加到页面 + 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 = ` +
+
+

${title}

+ +
+
+
+
+ +
+
+ ${isUsed ? + '

⚠️ 此配置已被系统使用

删除已关联的配置文件可能会影响系统正常运行。请确保您了解删除的后果。

' : + '

🗑️ 确认删除配置文件

此操作将永久删除配置文件,且无法撤销。

' + } +
+
+ +
+
+ 文件名: + ${config.name} +
+
+ 文件路径: + ${config.path} +
+
+ 文件大小: + ${formatFileSize(config.size)} +
+
+ 关联状态: + + ${isUsed ? '已关联' : '未关联'} + +
+
+ + ${isUsed ? ` +
+
+ +
+
+
关联详情
+

此配置文件正在被系统使用,删除后可能会导致:

+
    +
  • 相关的AI服务无法正常工作
  • +
  • 配置管理中的设置失效
  • +
  • 供应商池配置丢失
  • +
+

建议:请先在配置管理中解除文件引用后再删除。

+
+
+ ` : ''} +
+ +
+ `; + + // 添加到页面 + 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 +}; \ No newline at end of file diff --git a/static/app/utils.js b/static/app/utils.js new file mode 100644 index 0000000..49554e9 --- /dev/null +++ b/static/app/utils.js @@ -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 = ` +
${escapeHtml(message)}
+ `; + + // 获取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 +}; \ No newline at end of file diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..03dc69e --- /dev/null +++ b/static/index.html @@ -0,0 +1,774 @@ + + + + + + + + AIClient2API - 管理控制台 + + + + + +
+ +
+
+

AIClient2API 管理控制台

+
+ + 连接中... + + +
+
+
+ + +
+ + + + +
+ +
+

系统概览

+
+
+
+ +
+
+

--

+

运行时间

+
+
+
+ +
+

系统信息

+
+
+ + Node.js版本 + + -- +
+
+ + 服务器时间 + + -- +
+
+ + 内存使用 + + -- +
+
+
+ + +
+

路径路由调用示例

+

通过不同路径路由访问不同的AI模型提供商,支持灵活的模型切换

+ +
+
+
+ +

Gemini CLI OAuth

+ 突破限制 +
+
+ +
+ + +
+ + +
+
+ + /gemini-cli-oauth/v1/chat/completions + +
+
+ +
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
+  }'
+
+
+ + +
+
+ + /gemini-cli-oauth/v1/messages + +
+
+ +
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!"}]
+  }'
+
+
+
+
+ +
+
+ +

Qwen OAuth

+ 突破限制 +
+
+ +
+ + +
+ + +
+
+ + /openai-qwen-oauth/v1/chat/completions + +
+
+ +
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
+  }'
+
+
+ + +
+
+ + /openai-qwen-oauth/v1/messages + +
+
+ +
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!"}]
+  }'
+
+
+
+
+ +
+
+ +

Claude Custom

+ 官方API/三方 +
+
+ +
+ + +
+ + +
+
+ + /claude-custom/v1/chat/completions + +
+
+ +
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
+  }'
+
+
+ + +
+
+ + /claude-custom/v1/messages + +
+
+ +
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!"}]
+  }'
+
+
+
+
+ +
+
+ +

Claude Kiro OAuth

+ 突破限制/免费使用 +
+
+ +
+ + +
+ + +
+
+ + /claude-kiro-oauth/v1/chat/completions + +
+
+ +
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
+  }'
+
+
+ + +
+
+ + /claude-kiro-oauth/v1/messages + +
+
+ +
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!"}]
+  }'
+
+
+
+
+ +
+
+ +

OpenAI Custom

+ 官方API/三方 +
+
+ +
+ + +
+ + +
+
+ + /openai-custom/v1/chat/completions + +
+
+ +
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
+  }'
+
+
+ + +
+
+ + /openai-custom/v1/messages + +
+
+ +
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!"}]
+  }'
+
+
+
+
+ +
+ +
+

使用提示

+
    +
  • 即时切换: 通过修改URL路径即可切换不同的AI模型提供商
  • +
  • 客户端配置: 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径
  • +
  • 跨协议调用: 支持OpenAI协议调用Claude模型,或Claude协议调用OpenAI模型
  • +
+
+
+
+ + +
+

配置管理

+
+
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + +
+

高级配置

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + + 配置了供应商池后,可在供应商池管理中查看详细信息 +
+ + +
+ + +
+
+ +
+ + +
+
+
+
+ + +
+

上传配置管理

+
+ +
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+
+
+
+ + +
+
+

配置文件列表

+
+ 共 0 个配置文件 + 已关联: 0 + 未关联: 0 +
+
+
+ +
+
+
+
+ + +
+

供应商池管理

+ +
+
+
+ +
+
+

0

+

活动连接

+
+
+
+
+ +
+
+

0

+

活跃提供商

+
+
+
+
+ +
+
+

0

+

健康提供商

+
+
+
+
+
+ +
+
+
+ + +
+

实时日志

+
+ + +
+
+ +
+
+ +
+
+
+ + +
+ + + + + +