Compare commits

...

55 Commits

Author SHA1 Message Date
Supra4E8C
1007dbbf44 feat: adjust sidebar positioning and height for improved layout, adding padding for navbar integration 2025-12-07 17:45:36 +08:00
Supra4E8C
3046375b3c feat: update OAuth card visibility logic to retain iFlow card for Cookie login functionality and enhance comments for clarity 2025-12-07 17:38:08 +08:00
Supra4E8C
48956aa0a7 feat: enhance OpenAI model discovery with fallback to simple GET request for improved CORS handling and user notifications 2025-12-07 17:29:47 +08:00
Supra4E8C
d3db2680cf feat: add favicon support and enhance logo setup logic to ensure favicon updates alongside site logos 2025-12-07 17:21:06 +08:00
Supra4E8C
8e4132200d feat: refactor model price display and editing functionality with improved layout and interaction 2025-12-06 16:46:37 +08:00
Supra4E8C
fc10db3b0a feat: update layout for usage filter actions and chart line group to improve responsiveness and visual hierarchy 2025-12-06 16:36:23 +08:00
Supra4E8C
2bcaf15fe8 feat: enhance usage statistics overview with responsive design, improved layout, and sparkline charts for better data visualization 2025-12-06 16:32:47 +08:00
Supra4E8C
28750ab068 feat: implement responsive brand title behavior for mobile viewports with animation handling and CSS adjustments 2025-12-06 14:57:19 +08:00
Supra4E8C
69f808e180 feat: enhance file upload functionality to support multiple JSON file uploads with improved validation and notification handling 2025-12-06 13:16:09 +08:00
Supra4E8C
86edc1ee95 feat: implement OpenAI provider connection testing with UI integration, status updates, and internationalization support 2025-12-06 01:25:04 +08:00
Supra4E8C
112f86966d feat: add version check functionality with UI integration, status updates, and internationalization support 2025-12-06 00:15:44 +08:00
Supra4E8C
658814bf6a refactor: streamline model name handling in updateApiStatsTable function for improved readability 2025-12-05 19:02:49 +08:00
Supra4E8C
ac4f310fe8 feat: add sensitive value masking functionality to usage module and update UI for system info localization 2025-12-05 18:30:01 +08:00
Supra4E8C
ba6a461a40 feat: implement available models loading functionality with UI integration, status updates, and internationalization support 2025-12-05 02:01:21 +08:00
Supra4E8C
0e01ee0456 feat: add log search functionality with UI input, filtering logic, and internationalization support 2025-12-04 23:42:13 +08:00
Supra4E8C
d235cfde81 refactor: simplify gemini key retrieval logic by removing legacy key handling 2025-12-04 01:07:59 +08:00
Supra4E8C
4d419448e8 feat: implement chart line deletion functionality with UI controls and internationalization support 2025-12-04 00:55:24 +08:00
Supra4E8C
63c0e5ffe2 refactor: remove min-height from config management card for improved layout flexibility 2025-12-03 23:38:22 +08:00
Supra4E8C
79b73dd3a0 feat: implement dynamic chart line management with UI controls, internationalization, and enhanced data handling 2025-12-03 18:51:31 +08:00
Supra4E8C
9e41fa0aa7 feat: add model search functionality with UI components and internationalization support 2025-12-03 18:13:23 +08:00
Supra4E8C
a607b8d9c1 feat: implement OAuth excluded models configuration handling with fallback data loading and UI updates 2025-12-03 18:07:08 +08:00
Supra4E8C
9a540791f5 refactor: adjust YAML editor dimensions and layout for improved consistency in config management 2025-12-03 12:17:38 +08:00
Supra4E8C
b026285e65 feat: enhance provider item display with improved base URL styling and layout adjustments 2025-12-03 00:53:34 +08:00
Supra4E8C
fc8b02f58e feat: add error log selection and download functionality with UI updates and internationalization support 2025-12-03 00:49:27 +08:00
Supra4E8C
c77527cd13 feat: enhance excluded models management with UI components, internationalization, and data handling 2025-12-03 00:27:45 +08:00
Supra4E8C
d3630373ed feat: add OAuth excluded models management with UI integration and internationalization support 2025-12-03 00:01:16 +08:00
Supra4E8C
0114dad58d feat: implement endpoint cost calculation in API stats table with pricing support 2025-11-27 19:49:06 +08:00
Supra4E8C
ca14ab4917 feat: add cost period selection and update cost chart functionality 2025-11-27 19:39:59 +08:00
Supra4E8C
fd1956cb94 feat: implement model pricing functionality with UI elements, storage management, and cost calculation 2025-11-27 19:19:17 +08:00
Supra4E8C
b5d8d003e1 docs: update usage instructions in README files for clarity on static file access after build 2025-11-27 18:16:10 +08:00
Supra4E8C
96961d7b79 feat: add cached and reasoning token metrics with internationalization support 2025-11-27 18:04:47 +08:00
Supra4E8C
5415a61ad7 feat: add RPM and TPM metrics for the last 30 minutes with internationalization support 2025-11-27 17:59:47 +08:00
Supra4E8C
63a8b32c26 feat: expose manager instance to global scope for inline event handlers 2025-11-27 12:26:30 +08:00
hkfires
d8c06c7f6c feat: cap log fetch size and add limit query param 2025-11-24 20:53:37 +08:00
Supra4E8C
e3a2a34b70 更新 README_CN.md 2025-11-23 21:59:19 +08:00
Supra4E8C
f898d789da 更新 README.md 2025-11-23 21:58:30 +08:00
Supra4E8C
02faf18ceb refactor(docs): update README files with improved structure, feature descriptions, and usage instructions for better clarity and accessibility 2025-11-23 21:48:55 +08:00
Supra4E8C
efc6cb3863 feat(cookie-login): add iFlow Cookie login functionality with UI elements and internationalization support 2025-11-23 18:07:57 +08:00
Supra4E8C
970297f3ae feat(antigravity): implement Antigravity OAuth integration with UI elements and functionality 2025-11-23 17:56:17 +08:00
Supra4E8C
6962667171 style: increase max-height for key list to display more records at once 2025-11-23 17:15:40 +08:00
Supra4E8C
ef1be66cd6 style: enhance key table layout and adjust padding for improved aesthetics 2025-11-23 17:10:22 +08:00
Supra4E8C
ceddf7925f feat(api-keys): enhance API key display with new layout and styling 2025-11-23 12:31:17 +08:00
Supra4E8C
55c1cd84b3 feat(i18n): add support for 'antigravity' file type and update UI elements 2025-11-21 20:59:05 +08:00
Supra4E8C
111a1fe4ba Merge branch 'main' of https://github.com/router-for-me/Cli-Proxy-API-Management-Center 2025-11-21 17:54:05 +08:00
Supra4E8C
958b0b4e4b fix(i18n): update API endpoint references from /v1/model to /v1/models 2025-11-21 17:44:15 +08:00
hkfires
71d1436590 fix(api-keys): delegate key actions and safely encode values 2025-11-21 12:50:14 +08:00
Supra4E8C
d088be8e65 feat(openai): implement model discovery UI and functionality for fetching models 2025-11-21 12:35:46 +08:00
Supra4E8C
c8dc446268 style: update file-type badge colors for improved visibility 2025-11-21 12:04:15 +08:00
hkfires
1edafc637a feat: centralize config refresh handling and prevent races 2025-11-21 11:34:12 +08:00
hkfires
608be95020 feat(logs): refresh on reconnect and section activation 2025-11-21 10:59:21 +08:00
hkfires
323485445d refactor(config): reload editor and auth files via events 2025-11-21 10:36:04 +08:00
hkfires
e58d462153 refactor(api): add raw request helper and centralize headers 2025-11-21 10:16:06 +08:00
hkfires
a6344a6a61 refactor(usage): load stats via config events 2025-11-21 09:57:56 +08:00
hkfires
d2fc784116 refactor(settings): delegate config UI updates to module 2025-11-21 09:48:50 +08:00
hkfires
a8b8bdc11c refactor: centralize API client and config caching 2025-11-21 09:42:16 +08:00
24 changed files with 6227 additions and 826 deletions

216
README.md
View File

@@ -1,150 +1,49 @@
# Cli-Proxy-API-Management-Center # Cli-Proxy-API-Management-Center
This is a modern web interface for managing the CLI Proxy API. This is the modern WebUI for managing the CLI Proxy API.
[中文文档](README_CN.md) [中文文档](README_CN.md)
Main Project: Main Project: https://github.com/router-for-me/CLIProxyAPI
https://github.com/router-for-me/CLIProxyAPI Example URL: https://remote.router-for.me/
Minimum required version: ≥ 6.3.0 (recommended ≥ 6.5.0)
Example URL: Since 6.0.19 the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
https://remote.router-for.me/
Minimum required version: ≥ 6.0.0
Recommended version: ≥ 6.2.32
Since version 6.0.19, the WebUI has been rolled into the main program. You can access it by going to `/management.html` on the external port after firing up the main project.
## Features ## Features
### Authentication Management ### Capabilities
- Supports management key authentication - **Login & UX**: Auto-detects the current address (manual override/reset supported), encrypted auto-login, language/theme toggles, responsive layout with mobile sidebar.
- Configurable API base address - **Basic Settings**: Debug, proxy URL, request retries, quota fallback (auto-switch project/preview models), usage-statistics toggle, request logging & logging-to-file switches, WebSocket `/ws/*` auth switch.
- Real-time connection status detection - **Keys & Providers**: Manage proxy auth keys, Gemini/Codex/Claude configs, OpenAI-compatible providers (custom base URLs/headers/proxy/model aliases), Vertex AI credential import from service-account JSON with optional location.
- Auto-login with saved credentials - **Auth Files & OAuth**: Upload/download/search/paginate JSON credentials; type filters (Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty); delete-all; OAuth/Device flows for Codex, Anthropic (Claude), Antigravity (Google), Gemini CLI (optional project), Qwen; iFlow OAuth and cookie login.
- Language and theme switching - **Logs**: Live viewer with auto-refresh/incremental updates, download and clear; section appears when logging-to-file is enabled.
- **Usage Analytics**: Overview cards, hourly/daily toggles, up to three model lines per chart, per-API stats table (Chart.js).
### Basic Settings - **Config Management**: In-browser CodeMirror YAML editor for `/config.yaml` with reload/save, syntax highlighting, and status feedback.
- **Debug Mode**: Enable/disable debugging - **System Info & Versioning**: Connection/config cache status, last refresh time, server version/build date, and UI version in the footer.
- **Proxy Settings**: Configure proxy server URL - **Security & Preferences**: Masked secrets, secure local storage, persistent theme/language/sidebar state, real-time status feedback.
- **Request Retries**: Set the number of request retries
- **Quota Management**: Configure behavior when the quota is exceeded
- Auto-switch project when quota exceeded
- Switch to preview models when quota exceeded
### API Key Management
- **Proxy Service Authentication Key**: Manage API keys for the proxy service
- **Gemini API**: Manage Google Gemini generative language API keys
- **Codex API**: Manage OpenAI Codex API configuration
- **Claude API**: Manage Anthropic Claude API configuration
- **OpenAI-Compatible Providers**: Manage OpenAI-compatible third-party providers
### Authentication File Management
- Upload authentication JSON files
- Download existing authentication files
- Delete single or all authentication files
- Display file details
### Usage Statistics
- **Real-time Analytics**: Track API usage with interactive charts
- **Request Trends**: Visualize request patterns by hour/day
- **Token Usage**: Monitor token consumption over time
- **API Details**: Detailed statistics for each API endpoint
- **Success/Failure Rates**: Track API reliability metrics
### System Information
- **Connection Status**: Real-time connection monitoring
- **Configuration Status**: Track configuration loading state
- **Server Information**: Display server address and management key
- **Last Update**: Show when data was last refreshed
## How to Use ## How to Use
### 1. Using After CLI Proxy API Program Launch (Recommended) 1) **After CLI Proxy API is running (recommended)**
Once the CLI Proxy API program is up and running, you can access the WebUI at `http://your-server-IP:8317/management.html`. Visit `http://your-server:8317/management.html`.
### 2. Direct Use 2) **Direct static use after build**
Simply open the `index.html` file directly in your browser to use it. The single file `dist/index.html` generated by `npm run build`
### 3. Use a Local Server 3) **Local server**
#### Option A: Using Node.js (npm)
```bash ```bash
# Install dependencies
npm install npm install
npm start # http://localhost:3000
# Start the server on the default port (3000) npm run dev # optional dev port: 3090
npm start # or
```
#### Option B: Using Python
```bash
# Python 3.x
python -m http.server 8000 python -m http.server 8000
``` ```
Then open the corresponding localhost URL.
Then open `http://localhost:8000` in your browser. 4) **Configure connection**
The login page shows the detected address; you can override it, enter the management key, and click Connect. Saved credentials use encrypted local storage for auto-login.
### 3. Configure Connection Tip: The Logs navigation item appears after enabling "Logging to file" in Basic Settings.
1. Open the management interface.
2. On the login screen, enter:
- **Remote Address**: The current version automatically picks up the remote address from where you're connecting. But you can also set your own address if you prefer.
- **Management Key**: Your management key
3. Click the "Connect" button.
4. Once connected successfully, all features will be available.
## Interface Description
### Navigation Menu
- **Basic Settings**: Basic configurations like debugging, proxy, retries, etc.
- **API Keys**: Management of keys for various API services.
- **AI Providers**: Configuration for AI service providers.
- **Auth Files**: Upload and download management for authentication files.
- **Usage Stats**: Real-time analytics and usage statistics with interactive charts.
- **System Info**: Connection status and system information.
### Login Interface
- **Auto-connection**: Automatically attempts to connect using saved credentials
- **Custom Connection**: Manual configuration of API base address
- **Current Address Detection**: Automatically detects and uses current access address
- **Language Switching**: Support for multiple languages (English/Chinese)
- **Theme Switching**: Light and dark theme support
## Feature Highlights
### Modern UI
- Responsive design, supports all screen sizes
- Beautiful gradient colors and shadow effects
- Smooth animations and transition effects
- Intuitive icons and status indicators
- Dark/Light theme support with system preference detection
- Mobile-friendly sidebar with overlay
### Real-time Updates
- Configuration changes take effect immediately
- Real-time status feedback
- Automatic data refresh
- Live usage statistics with interactive charts
- Real-time connection status monitoring
### Security Features
- Masked display for keys
- Secure credential storage
- Auto-login with encrypted local storage
### Responsive Design
- Perfectly adapts to desktop and mobile devices
- Adaptive layout with collapsible sidebar
- Touch-friendly interactions
- Mobile menu with overlay
### Analytics & Monitoring
- Interactive charts powered by Chart.js
- Real-time usage statistics
- Request trend visualization
- Token consumption tracking
- API performance metrics
## Tech Stack ## Tech Stack
@@ -152,11 +51,17 @@ Then open `http://localhost:8000` in your browser.
- **Styling**: CSS3 + Flexbox/Grid with CSS Variables - **Styling**: CSS3 + Flexbox/Grid with CSS Variables
- **Icons**: Font Awesome 6.4.0 - **Icons**: Font Awesome 6.4.0
- **Charts**: Chart.js for interactive data visualization - **Charts**: Chart.js for interactive data visualization
- **Editor/Parsing**: CodeMirror + js-yaml
- **Fonts**: Segoe UI system font - **Fonts**: Segoe UI system font
- **API**: RESTful API calls with automatic authentication - **Internationalization**: Custom i18n (EN/CN) and theme system (light/dark)
- **Internationalization**: Custom i18n system with English/Chinese support - **API**: RESTful management endpoints with automatic authentication
- **Theme System**: CSS custom properties for dynamic theming - **Storage**: LocalStorage with lightweight encryption for preferences/credentials
- **Storage**: LocalStorage for user preferences and credentials
## Build & Development
- `npm run build` bundles everything into `dist/index.html` via webpack (`build.cjs`, `bundle-entry.js`, `build-scripts/prepare-html.js`).
- External CDNs remain for Font Awesome, Chart.js, and CodeMirror to keep the bundle lean.
- Development servers: `npm start` (3000) or `npm run dev` (3090); Python `http.server` also works for static hosting.
## Troubleshooting ## Troubleshooting
@@ -171,38 +76,31 @@ Then open `http://localhost:8000` in your browser.
2. Check your network connection. 2. Check your network connection.
3. Check the browser's console for any error messages. 3. Check the browser's console for any error messages.
## Development Information ### Logs & Config Editor
- Logs: Requires server-side logging-to-file; 404 indicates the server build is too old or logging is disabled.
- Config editor: Requires `/config.yaml` endpoint; keep YAML valid before saving.
### File Structure ### Usage Stats
- Enable "Usage statistics" if charts stay empty; data resets on server restart.
## Project Structure
``` ```
webui/ ├── index.html
├── index.html # Main page with responsive layout ├── styles.css
├── styles.css # Stylesheet with theme support ├── app.js
├── app.js # Application logic and API management ├── i18n.js
├── i18n.js # Internationalization support (EN/CN) ├── src/ # Core/modules/utils source code
├── package.json # Project configuration ├── build.cjs # Webpack build script
├── build.js # Build script for production ├── bundle-entry.js # Bundling entry
├── bundle-entry.js # Entry point for bundling
├── build-scripts/ # Build utilities ├── build-scripts/ # Build utilities
│ └── prepare-html.js # HTML preparation script │ └── prepare-html.js
├── logo.jpg # Application logo ├── dist/ # Bundled single-file output
├── LICENSE # MIT License ├── BUILD_RELEASE.md
├── README.md # English documentation ├── LICENSE
├── README_CN.md # Chinese documentation ├── README.md
└── BUILD_RELEASE.md # Build and release notes └── README_CN.md
``` ```
### API Calls
All API calls are handled through the `makeRequest` method of the `ManagerAPI` class, which includes:
- Automatic addition of authentication headers
- Error handling
- JSON response parsing
### State Management
- API address and key are saved in local storage
- Connection status is maintained in memory
- Real-time data refresh mechanism
## Contributing ## Contributing
We welcome Issues and Pull Requests to improve this project! We encourage more developers to contribute to the enhancement of this WebUI! We welcome Issues and Pull Requests to improve this project! We encourage more developers to contribute to the enhancement of this WebUI!

View File

@@ -1,149 +1,49 @@
# Cli-Proxy-API-Management-Center # Cli-Proxy-API-Management-Center
这是一个用于管理 CLI Proxy API 的现代化 Web 界面。 这是一个用于管理 CLI Proxy API 的现代化 Web 界面。
主项目 [English](README.md)
https://github.com/router-for-me/CLIProxyAPI
示例网站: 主项目: https://github.com/router-for-me/CLIProxyAPI
https://remote.router-for.me/ 示例网站: https://remote.router-for.me/
最低版本 ≥ 6.3.0(推荐 ≥ 6.5.0
最低可用版本 ≥ 6.0.0 自 6.0.19 起 WebUI 已集成到主程序中,启动后可通过 `/management.html` 访问。
推荐版本 ≥ 6.2.32
自6.0.19起WebUI已经集成在主程序中 可以通过主项目开启的外部端口的`/management.html`访问
## 功能特点 ## 功能特点
### 认证管理 ### 主要能力
- 支持管理密钥认证 - **登录与体验**: 自动检测当前地址(可自定义/重置),加密自动登录,语言/主题切换,响应式布局与移动端侧边栏。
- 可配置 API 基础地址 - **基础设置**: 调试、代理 URL、请求重试配额溢出自动切换项目/预览模型使用统计开关请求日志与文件日志开关WebSocket `/ws/*` 鉴权开关。
- 实时连接状态检测 - **密钥与提供商**: 管理代理服务密钥Gemini/Codex/Claude 配置OpenAI 兼容提供商(自定义 Base URL/Headers/Proxy/模型别名Vertex AI 服务账号导入(可选区域)。
- 自动登录保存的凭据 - **认证文件与 OAuth**: 上传/下载/搜索/分页 JSON 凭据类型筛选Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty一键删除全部Codex、Anthropic(Claude)、Antigravity(Google)、Gemini CLI可选项目、Qwen 设备码、iFlow OAuth 与 Cookie 登录。
- 语言和主题切换 - **日志**: 实时查看并增量刷新,支持下载和清空;启用“写入日志文件”后出现日志栏目。
- **使用统计**: 概览卡片、小时/天切换、最多三条模型曲线、按 API 统计表Chart.js
### 基础设置 - **配置管理**: 内置 CodeMirror YAML 编辑器,在线读取/保存 `/config.yaml`,语法高亮与状态提示。
- **调试模式**: 开启/关闭调试功能 - **系统与版本**: 连接/配置缓存状态、最后刷新时间,底栏显示服务版本、构建时间与 UI 版本。
- **代理设置**: 配置代理服务器 URL - **安全与偏好**: 密钥遮蔽、加密本地存储,主题/语言/侧边栏状态持久化,实时状态反馈。
- **请求重试**: 设置请求重试次数
- **配额管理**: 配置超出配额时的行为
- 超出配额时自动切换项目
- 超出配额时切换到预览模型
### API 密钥管理
- **代理服务认证密钥**: 管理用于代理服务的 API 密钥
- **Gemini API**: 管理 Google Gemini 生成式语言 API 密钥
- **Codex API**: 管理 OpenAI Codex API 配置
- **Claude API**: 管理 Anthropic Claude API 配置
- **OpenAI 兼容提供商**: 管理 OpenAI 兼容的第三方提供商
### 认证文件管理
- 上传认证 JSON 文件
- 下载现有认证文件
- 删除单个或所有认证文件
- 显示文件详细信息
### 使用统计
- **实时分析**: 通过交互式图表跟踪 API 使用情况
- **请求趋势**: 按小时/天可视化请求模式
- **Token 使用**: 监控 Token 消耗随时间变化
- **API 详情**: 每个 API 端点的详细统计
- **成功率/失败率**: 跟踪 API 可靠性指标
### 系统信息
- **连接状态**: 实时连接监控
- **配置状态**: 跟踪配置加载状态
- **服务器信息**: 显示服务器地址和管理密钥
- **最后更新**: 显示数据最后刷新时间
## 使用方法 ## 使用方法
### 1. 在CLI Proxy API程序启动后使用 (推荐) 1) **主程序启动后使用(推荐)**
在启动了CLI Proxy API程序后 访问`http://您的服务器IP:8317/management.html`使用 访问 `http://您的服务器:8317/management.html`
### 2. 直接使用 2) **构建后直接静态打开**
直接用浏览器打开 `index.html` 文件即可使用。 `npm run build` 生成的 `dist/index.html` 文件
### 3. 使用本地服务器 3) **本地服务器**
#### 方法A使用 Node.js (npm)
```bash ```bash
# 安装依赖
npm install npm install
npm start # 默认 http://localhost:3000
# 使用默认端口(3000 npm run dev # 可选开发端口 3090
npm start # 或
```
#### 方法B使用 Python
```bash
# Python 3.x
python -m http.server 8000 python -m http.server 8000
``` ```
然后在浏览器打开对应的 localhost 地址。
然后在浏览器中打开 `http://localhost:8000` 4) **配置连接**
登录页会显示自动检测的地址,可自行修改,填入管理密钥后点击连接。凭据将加密保存以便下次自动登录。
### 3. 配置连接 提示: 开启“写入日志文件”后才会显示“日志查看”导航。
1. 打开管理界面
2. 在登录界面上输入:
- **远程地址**: 现版本远程地址将会自动从您的访问地址中获取 当然您也可以自定义
- **管理密钥**: 您的管理密钥
3. 点击"连接"按钮
4. 连接成功后即可使用所有功能
## 界面说明
### 导航菜单
- **基础设置**: 调试、代理、重试等基本配置
- **API 密钥**: 各种 API 服务的密钥管理
- **AI 提供商**: AI 服务提供商配置
- **认证文件**: 认证文件的上传下载管理
- **使用统计**: 实时分析和使用统计,包含交互式图表
- **系统信息**: 连接状态和系统信息
### 登录界面
- **自动连接**: 使用保存的凭据自动尝试连接
- **自定义连接**: 手动配置 API 基础地址
- **当前地址检测**: 自动检测并使用当前访问地址
- **语言切换**: 支持多种语言(英文/中文)
- **主题切换**: 支持明暗主题
## 特性亮点
### 现代化 UI
- 响应式设计,支持各种屏幕尺寸
- 美观的渐变色彩和阴影效果
- 流畅的动画和过渡效果
- 直观的图标和状态指示
- 明暗主题支持,自动检测系统偏好
- 移动端友好的侧边栏和遮罩
### 实时更新
- 配置更改立即生效
- 实时状态反馈
- 自动数据刷新
- 实时使用统计和交互式图表
- 实时连接状态监控
### 安全特性
- 密钥遮蔽显示
- 安全凭据存储
- 加密本地存储自动登录
### 响应式设计
- 完美适配桌面和移动设备
- 自适应布局,可折叠侧边栏
- 触摸友好的交互
- 移动端菜单和遮罩
### 分析与监控
- Chart.js 驱动的交互式图表
- 实时使用统计
- 请求趋势可视化
- Token 消耗跟踪
- API 性能指标
## 技术栈 ## 技术栈
@@ -151,11 +51,16 @@ python -m http.server 8000
- **样式**: CSS3 + Flexbox/Grid支持 CSS 变量 - **样式**: CSS3 + Flexbox/Grid支持 CSS 变量
- **图标**: Font Awesome 6.4.0 - **图标**: Font Awesome 6.4.0
- **图表**: Chart.js 交互式数据可视化 - **图表**: Chart.js 交互式数据可视化
- **字体**: Segoe UI 系统字体 - **编辑/解析**: CodeMirror + js-yaml
- **API**: RESTful API 调用,自动认证 - **国际化**: 自定义 i18n中/英)与主题系统(明/暗)
- **国际化**: 自定义 i18n 系统,支持中英文 - **API**: RESTful 管理接口,自动附加认证
- **主题系统**: CSS 自定义属性动态主题 - **存储**: LocalStorage 轻量加密存储偏好与凭据
- **存储**: LocalStorage 用户偏好和凭据存储
## 构建与开发
- `npm run build` 通过 webpack`build.cjs``bundle-entry.js``build-scripts/prepare-html.js`)打包为 `dist/index.html`
- Font Awesome、Chart.js、CodeMirror 仍走 CDN减小打包体积。
- 开发可用 `npm start` (3000) / `npm run dev` (3090) 或 `python -m http.server` 静态托管。
## 故障排除 ## 故障排除
@@ -170,39 +75,32 @@ python -m http.server 8000
2. 检查网络连接 2. 检查网络连接
3. 查看浏览器控制台错误信息 3. 查看浏览器控制台错误信息
## 开发说明 ### 日志与配置编辑
- 日志: 需要服务端开启写文件日志;返回 404 说明版本过旧或未启用。
- 配置编辑: 依赖 `/config.yaml` 接口,保存前请确保 YAML 语法正确。
### 文件结构 ### 使用统计
- 若图表为空,请开启“使用统计”;数据在服务重启后会清空。
## 项目结构
``` ```
webui/ ├── index.html
├── index.html # 主页面,响应式布局 ├── styles.css
├── styles.css # 样式文件,支持主题 ├── app.js
├── app.js # 应用逻辑和 API 管理 ├── i18n.js
├── i18n.js # 国际化支持(中英文) ├── src/ # 核心/模块/工具源码
├── package.json # 项目配置 ├── build.cjs # Webpack 构建脚本
├── build.js # 生产环境构建脚本 ├── bundle-entry.js # 打包入口
├── bundle-entry.js # 打包入口文件
├── build-scripts/ # 构建工具 ├── build-scripts/ # 构建工具
│ └── prepare-html.js # HTML 准备脚本 │ └── prepare-html.js
├── logo.jpg # 应用图标 ├── dist/ # 打包输出单文件
├── LICENSE # MIT 许可证 ├── BUILD_RELEASE.md
├── README.md # 英文文档 ├── LICENSE
├── README_CN.md # 中文文档 ├── README.md
└── BUILD_RELEASE.md # 构建和发布说明 └── README_CN.md
``` ```
### API 调用
所有 API 调用都通过 `ManagerAPI` 类的 `makeRequest` 方法处理,包含:
- 自动添加认证头
- 错误处理
- JSON 响应解析
### 状态管理
- 本地存储保存 API 地址和密钥
- 内存中维护连接状态
- 实时数据刷新机制
## 贡献 ## 贡献
欢迎提交 Issue 和 Pull Request 来改进这个项目我们欢迎更多的大佬来对这个WebUI进行更新 欢迎提交 Issue 和 Pull Request 来改进这个项目!我们欢迎更多的大佬来对这个 WebUI 进行更新!
本项目采用MIT许可 本项目采用 MIT 许可

473
app.js
View File

@@ -14,12 +14,13 @@ import { aiProvidersModule } from './src/modules/ai-providers.js';
// 工具函数导入 // 工具函数导入
import { escapeHtml } from './src/utils/html.js'; import { escapeHtml } from './src/utils/html.js';
import { maskApiKey } from './src/utils/string.js'; import { maskApiKey, formatFileSize } from './src/utils/string.js';
import { normalizeArrayResponse } from './src/utils/array.js'; import { normalizeArrayResponse } from './src/utils/array.js';
import { debounce } from './src/utils/dom.js'; import { debounce } from './src/utils/dom.js';
import { import {
CACHE_EXPIRY_MS, CACHE_EXPIRY_MS,
MAX_LOG_LINES, MAX_LOG_LINES,
LOG_FETCH_LIMIT,
DEFAULT_AUTH_FILES_PAGE_SIZE, DEFAULT_AUTH_FILES_PAGE_SIZE,
MIN_AUTH_FILES_PAGE_SIZE, MIN_AUTH_FILES_PAGE_SIZE,
MAX_AUTH_FILES_PAGE_SIZE, MAX_AUTH_FILES_PAGE_SIZE,
@@ -31,35 +32,62 @@ import {
// 核心服务导入 // 核心服务导入
import { createErrorHandler } from './src/core/error-handler.js'; import { createErrorHandler } from './src/core/error-handler.js';
import { connectionModule } from './src/core/connection.js'; import { connectionModule } from './src/core/connection.js';
import { ApiClient } from './src/core/api-client.js';
import { ConfigService } from './src/core/config-service.js';
import { createEventBus } from './src/core/event-bus.js';
// CLI Proxy API 管理界面 JavaScript // CLI Proxy API 管理界面 JavaScript
class CLIProxyManager { class CLIProxyManager {
constructor() { constructor() {
// 仅保存基础地址(不含 /v0/management请求时自动补齐 // 事件总线
this.events = createEventBus();
// API 客户端(规范化基础地址、封装请求)
this.apiClient = new ApiClient({
onVersionUpdate: (headers) => this.updateVersionFromHeaders(headers)
});
const detectedBase = this.detectApiBaseFromLocation(); const detectedBase = this.detectApiBaseFromLocation();
this.apiBase = detectedBase; this.apiClient.setApiBase(detectedBase);
this.apiUrl = this.computeApiUrl(this.apiBase); this.apiBase = this.apiClient.apiBase;
this.apiUrl = this.apiClient.apiUrl;
this.managementKey = ''; this.managementKey = '';
this.isConnected = false; this.isConnected = false;
this.isLoggedIn = false; this.isLoggedIn = false;
this.uiVersion = null; this.uiVersion = null;
this.serverVersion = null; this.serverVersion = null;
this.serverBuildDate = null; this.serverBuildDate = null;
this.latestVersion = null;
this.versionCheckStatus = 'muted';
this.versionCheckMessage = i18n.t('system_info.version_check_idle');
// 配置缓存 - 改为分段缓存 // 配置缓存 - 改为分段缓存(交由 ConfigService 管理)
this.configCache = {}; // 改为对象,按配置段缓存
this.cacheTimestamps = {}; // 每个配置段的时间戳
this.cacheExpiry = CACHE_EXPIRY_MS; this.cacheExpiry = CACHE_EXPIRY_MS;
this.configService = new ConfigService({
apiClient: this.apiClient,
cacheExpiry: this.cacheExpiry
});
this.configCache = this.configService.cache;
this.cacheTimestamps = this.configService.cacheTimestamps;
this.availableModels = [];
this.availableModelApiKeysCache = null;
this.availableModelsLoading = false;
// 状态更新定时器 // 状态更新定时器
this.statusUpdateTimer = null; this.statusUpdateTimer = null;
this.lastConnectionStatusEmitted = null;
this.isGlobalRefreshInProgress = false;
this.registerCoreEventHandlers();
// 日志自动刷新定时器 // 日志自动刷新定时器
this.logsRefreshTimer = null; this.logsRefreshTimer = null;
// 当前展示的日志行 // 当前展示的日志行
this.allLogLines = [];
this.displayedLogLines = []; this.displayedLogLines = [];
this.logSearchQuery = '';
this.maxDisplayLogLines = MAX_LOG_LINES; this.maxDisplayLogLines = MAX_LOG_LINES;
this.logFetchLimit = LOG_FETCH_LIMIT;
// 日志时间戳(用于增量加载) // 日志时间戳(用于增量加载)
this.latestLogTimestamp = null; this.latestLogTimestamp = null;
@@ -77,6 +105,10 @@ class CLIProxyManager {
this.authFilesPageSizeKey = STORAGE_KEY_AUTH_FILES_PAGE_SIZE; this.authFilesPageSizeKey = STORAGE_KEY_AUTH_FILES_PAGE_SIZE;
this.loadAuthFilePreferences(); this.loadAuthFilePreferences();
// OAuth 模型排除列表状态
this.oauthExcludedModels = {};
this._oauthExcludedLoading = false;
// Vertex AI credential import state // Vertex AI credential import state
this.vertexImportState = { this.vertexImportState = {
file: null, file: null,
@@ -84,6 +116,20 @@ class CLIProxyManager {
result: null result: null
}; };
// 顶栏标题动画状态
this.brandCollapseTimer = null;
this.brandCollapseDelayMs = 5000;
this.brandIsCollapsed = false;
this.brandAnimationReady = false;
this.brandElements = {
toggle: null,
wrapper: null,
fullText: null,
shortText: null
};
this.brandResizeHandler = null;
this.brandToggleHandler = null;
// 主题管理 // 主题管理
this.currentTheme = 'light'; this.currentTheme = 'light';
@@ -134,6 +180,15 @@ class CLIProxyManager {
init() { init() {
this.initUiVersion(); this.initUiVersion();
this.initializeTheme(); this.initializeTheme();
this.registerCoreEventHandlers();
this.registerSettingsListeners();
this.registerUsageListeners();
if (typeof this.registerLogsListeners === 'function') {
this.registerLogsListeners();
}
if (typeof this.registerConfigEditorListeners === 'function') {
this.registerConfigEditorListeners();
}
this.checkLoginStatus(); this.checkLoginStatus();
this.bindEvents(); this.bindEvents();
this.setupNavigation(); this.setupNavigation();
@@ -145,6 +200,33 @@ class CLIProxyManager {
this.updateLoginConnectionInfo(); this.updateLoginConnectionInfo();
// 检查主机名,如果不是 localhost 或 127.0.0.1,则隐藏 OAuth 登录框 // 检查主机名,如果不是 localhost 或 127.0.0.1,则隐藏 OAuth 登录框
this.checkHostAndHideOAuth(); this.checkHostAndHideOAuth();
if (typeof this.registerAuthFilesListeners === 'function') {
this.registerAuthFilesListeners();
}
}
registerCoreEventHandlers() {
if (!this.events || typeof this.events.on !== 'function') {
return;
}
this.events.on('config:refresh-requested', async (event) => {
const detail = event?.detail || {};
const forceRefresh = detail.forceRefresh !== false;
// 避免并发触发导致重复请求
if (this.isGlobalRefreshInProgress) {
return;
}
await this.runGlobalRefresh(forceRefresh);
});
}
async runGlobalRefresh(forceRefresh = false) {
this.isGlobalRefreshInProgress = true;
try {
await this.loadAllData(forceRefresh);
} finally {
this.isGlobalRefreshInProgress = false;
}
} }
// 检查主机名并隐藏 OAuth 登录框 // 检查主机名并隐藏 OAuth 登录框
@@ -153,7 +235,7 @@ class CLIProxyManager {
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
if (!isLocalhost) { if (!isLocalhost) {
// 隐藏所有 OAuth 登录卡片 // 隐藏所有 OAuth 登录卡片(除了iFlow,因为它有Cookie登录功能可远程使用)
OAUTH_CARD_IDS.forEach(cardId => { OAUTH_CARD_IDS.forEach(cardId => {
const card = document.getElementById(cardId); const card = document.getElementById(cardId);
if (card) { if (card) {
@@ -161,20 +243,39 @@ class CLIProxyManager {
} }
}); });
// 对于 iFlow card,只隐藏 OAuth 部分,保留 Cookie 登录部分
const iflowCard = document.getElementById('iflow-oauth-card');
if (iflowCard) {
// 隐藏 OAuth 部分
const oauthContent = document.getElementById('iflow-oauth-content');
const oauthButton = iflowCard.querySelector('button[onclick*="startIflowOAuth"]');
const oauthStatus = document.getElementById('iflow-oauth-status');
const oauthUrlGroup = iflowCard.querySelector('.form-group:has(#iflow-oauth-url)');
if (oauthContent) oauthContent.style.display = 'none';
if (oauthButton) oauthButton.style.display = 'none';
if (oauthStatus) oauthStatus.style.display = 'none';
if (oauthUrlGroup) oauthUrlGroup.style.display = 'none';
// 保持整个card可见,因为Cookie登录部分仍然可用
iflowCard.style.display = 'block';
}
// 如果找不到具体的卡片 ID尝试通过类名查找 // 如果找不到具体的卡片 ID尝试通过类名查找
const oauthCardElements = document.querySelectorAll('.card'); const oauthCardElements = document.querySelectorAll('.card');
oauthCardElements.forEach(card => { oauthCardElements.forEach(card => {
const cardText = card.textContent || ''; const cardText = card.textContent || '';
// 不再隐藏包含 'iFlow' 的卡片
if (cardText.includes('Codex OAuth') || if (cardText.includes('Codex OAuth') ||
cardText.includes('Anthropic OAuth') || cardText.includes('Anthropic OAuth') ||
cardText.includes('Antigravity OAuth') ||
cardText.includes('Gemini CLI OAuth') || cardText.includes('Gemini CLI OAuth') ||
cardText.includes('Qwen OAuth') || cardText.includes('Qwen OAuth')) {
cardText.includes('iFlow OAuth')) {
card.style.display = 'none'; card.style.display = 'none';
} }
}); });
console.log(`当前主机名: ${hostname},已隐藏 OAuth 登录框`); console.log(`当前主机名: ${hostname},已隐藏 OAuth 登录框(保留 iFlow Cookie 登录)`);
} }
} }
@@ -213,6 +314,8 @@ class CLIProxyManager {
// 连接状态检查 // 连接状态检查
const connectionStatus = document.getElementById('connection-status'); const connectionStatus = document.getElementById('connection-status');
const refreshAll = document.getElementById('refresh-all'); const refreshAll = document.getElementById('refresh-all');
const availableModelsRefresh = document.getElementById('available-models-refresh');
const versionCheckBtn = document.getElementById('version-check-btn');
if (connectionStatus) { if (connectionStatus) {
connectionStatus.addEventListener('click', () => this.checkConnectionStatus()); connectionStatus.addEventListener('click', () => this.checkConnectionStatus());
@@ -220,6 +323,12 @@ class CLIProxyManager {
if (refreshAll) { if (refreshAll) {
refreshAll.addEventListener('click', () => this.refreshAllData()); refreshAll.addEventListener('click', () => this.refreshAllData());
} }
if (availableModelsRefresh) {
availableModelsRefresh.addEventListener('click', () => this.loadAvailableModels({ forceRefresh: true }));
}
if (versionCheckBtn) {
versionCheckBtn.addEventListener('click', () => this.checkLatestVersion());
}
// 基础设置 // 基础设置
const debugToggle = document.getElementById('debug-toggle'); const debugToggle = document.getElementById('debug-toggle');
@@ -268,13 +377,18 @@ class CLIProxyManager {
// 日志查看 // 日志查看
const refreshLogs = document.getElementById('refresh-logs'); const refreshLogs = document.getElementById('refresh-logs');
const selectErrorLog = document.getElementById('select-error-log');
const downloadLogs = document.getElementById('download-logs'); const downloadLogs = document.getElementById('download-logs');
const clearLogs = document.getElementById('clear-logs'); const clearLogs = document.getElementById('clear-logs');
const logsAutoRefreshToggle = document.getElementById('logs-auto-refresh-toggle'); const logsAutoRefreshToggle = document.getElementById('logs-auto-refresh-toggle');
const logsSearchInput = document.getElementById('logs-search-input');
if (refreshLogs) { if (refreshLogs) {
refreshLogs.addEventListener('click', () => this.refreshLogs()); refreshLogs.addEventListener('click', () => this.refreshLogs());
} }
if (selectErrorLog) {
selectErrorLog.addEventListener('click', () => this.openErrorLogsModal());
}
if (downloadLogs) { if (downloadLogs) {
downloadLogs.addEventListener('click', () => this.downloadLogs()); downloadLogs.addEventListener('click', () => this.downloadLogs());
} }
@@ -284,6 +398,14 @@ class CLIProxyManager {
if (logsAutoRefreshToggle) { if (logsAutoRefreshToggle) {
logsAutoRefreshToggle.addEventListener('change', (e) => this.toggleLogsAutoRefresh(e.target.checked)); logsAutoRefreshToggle.addEventListener('change', (e) => this.toggleLogsAutoRefresh(e.target.checked));
} }
if (logsSearchInput) {
const debouncedLogSearch = this.debounce((value) => {
this.updateLogSearchQuery(value);
}, 200);
logsSearchInput.addEventListener('input', (e) => {
debouncedLogSearch(e?.target?.value ?? '');
});
}
// API 密钥管理 // API 密钥管理
const addApiKey = document.getElementById('add-api-key'); const addApiKey = document.getElementById('add-api-key');
@@ -328,6 +450,17 @@ class CLIProxyManager {
this.bindAuthFilesPageSizeControl(); this.bindAuthFilesPageSizeControl();
this.syncAuthFileControls(); this.syncAuthFileControls();
// OAuth 排除列表
const oauthExcludedAdd = document.getElementById('oauth-excluded-add');
const oauthExcludedRefresh = document.getElementById('oauth-excluded-refresh');
if (oauthExcludedAdd) {
oauthExcludedAdd.addEventListener('click', () => this.openOauthExcludedEditor());
}
if (oauthExcludedRefresh) {
oauthExcludedRefresh.addEventListener('click', () => this.loadOauthExcludedModels(true));
}
// Vertex AI credential import // Vertex AI credential import
const vertexSelectFile = document.getElementById('vertex-select-file'); const vertexSelectFile = document.getElementById('vertex-select-file');
const vertexFileInput = document.getElementById('vertex-file-input'); const vertexFileInput = document.getElementById('vertex-file-input');
@@ -376,6 +509,21 @@ class CLIProxyManager {
anthropicCopyLink.addEventListener('click', () => this.copyAnthropicLink()); anthropicCopyLink.addEventListener('click', () => this.copyAnthropicLink());
} }
// Antigravity OAuth
const antigravityOauthBtn = document.getElementById('antigravity-oauth-btn');
const antigravityOpenLink = document.getElementById('antigravity-open-link');
const antigravityCopyLink = document.getElementById('antigravity-copy-link');
if (antigravityOauthBtn) {
antigravityOauthBtn.addEventListener('click', () => this.startAntigravityOAuth());
}
if (antigravityOpenLink) {
antigravityOpenLink.addEventListener('click', () => this.openAntigravityLink());
}
if (antigravityCopyLink) {
antigravityCopyLink.addEventListener('click', () => this.copyAntigravityLink());
}
// Gemini CLI OAuth // Gemini CLI OAuth
const geminiCliOauthBtn = document.getElementById('gemini-cli-oauth-btn'); const geminiCliOauthBtn = document.getElementById('gemini-cli-oauth-btn');
const geminiCliOpenLink = document.getElementById('gemini-cli-open-link'); const geminiCliOpenLink = document.getElementById('gemini-cli-open-link');
@@ -410,6 +558,7 @@ class CLIProxyManager {
const iflowOauthBtn = document.getElementById('iflow-oauth-btn'); const iflowOauthBtn = document.getElementById('iflow-oauth-btn');
const iflowOpenLink = document.getElementById('iflow-open-link'); const iflowOpenLink = document.getElementById('iflow-open-link');
const iflowCopyLink = document.getElementById('iflow-copy-link'); const iflowCopyLink = document.getElementById('iflow-copy-link');
const iflowCookieSubmit = document.getElementById('iflow-cookie-submit');
if (iflowOauthBtn) { if (iflowOauthBtn) {
iflowOauthBtn.addEventListener('click', () => this.startIflowOAuth()); iflowOauthBtn.addEventListener('click', () => this.startIflowOAuth());
@@ -420,6 +569,9 @@ class CLIProxyManager {
if (iflowCopyLink) { if (iflowCopyLink) {
iflowCopyLink.addEventListener('click', () => this.copyIflowLink()); iflowCopyLink.addEventListener('click', () => this.copyIflowLink());
} }
if (iflowCookieSubmit) {
iflowCookieSubmit.addEventListener('click', () => this.submitIflowCookieLogin());
}
// 使用统计 // 使用统计
const refreshUsageStats = document.getElementById('refresh-usage-stats'); const refreshUsageStats = document.getElementById('refresh-usage-stats');
@@ -427,7 +579,14 @@ class CLIProxyManager {
const requestsDayBtn = document.getElementById('requests-day-btn'); const requestsDayBtn = document.getElementById('requests-day-btn');
const tokensHourBtn = document.getElementById('tokens-hour-btn'); const tokensHourBtn = document.getElementById('tokens-hour-btn');
const tokensDayBtn = document.getElementById('tokens-day-btn'); const tokensDayBtn = document.getElementById('tokens-day-btn');
const costHourBtn = document.getElementById('cost-hour-btn');
const costDayBtn = document.getElementById('cost-day-btn');
const addChartLineBtn = document.getElementById('add-chart-line');
const chartLineSelects = document.querySelectorAll('.chart-line-select'); const chartLineSelects = document.querySelectorAll('.chart-line-select');
const chartLineDeleteButtons = document.querySelectorAll('.chart-line-delete');
const modelPriceForm = document.getElementById('model-price-form');
const resetModelPricesBtn = document.getElementById('reset-model-prices');
const modelPriceSelect = document.getElementById('model-price-model-select');
if (refreshUsageStats) { if (refreshUsageStats) {
refreshUsageStats.addEventListener('click', () => this.loadUsageStats()); refreshUsageStats.addEventListener('click', () => this.loadUsageStats());
@@ -444,6 +603,15 @@ class CLIProxyManager {
if (tokensDayBtn) { if (tokensDayBtn) {
tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day')); tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day'));
} }
if (costHourBtn) {
costHourBtn.addEventListener('click', () => this.switchCostPeriod('hour'));
}
if (costDayBtn) {
costDayBtn.addEventListener('click', () => this.switchCostPeriod('day'));
}
if (addChartLineBtn) {
addChartLineBtn.addEventListener('click', () => this.changeChartLineCount(1));
}
if (chartLineSelects.length) { if (chartLineSelects.length) {
chartLineSelects.forEach(select => { chartLineSelects.forEach(select => {
select.addEventListener('change', (event) => { select.addEventListener('change', (event) => {
@@ -452,6 +620,27 @@ class CLIProxyManager {
}); });
}); });
} }
if (chartLineDeleteButtons.length) {
chartLineDeleteButtons.forEach(button => {
button.addEventListener('click', () => {
const index = Number.parseInt(button.getAttribute('data-line-index'), 10);
this.removeChartLine(Number.isNaN(index) ? -1 : index);
});
});
}
this.updateChartLineControlsUI();
if (modelPriceForm) {
modelPriceForm.addEventListener('submit', (event) => {
event.preventDefault();
this.handleModelPriceSubmit();
});
}
if (resetModelPricesBtn) {
resetModelPricesBtn.addEventListener('click', () => this.handleModelPriceReset());
}
if (modelPriceSelect) {
modelPriceSelect.addEventListener('change', () => this.prefillModelPriceInputs());
}
// 模态框 // 模态框
const closeBtn = document.querySelector('.close'); const closeBtn = document.querySelector('.close');
@@ -509,6 +698,226 @@ class CLIProxyManager {
}); });
} }
// 顶栏标题动画与状态
isMobileViewport() {
return typeof window !== 'undefined' ? window.innerWidth <= 768 : false;
}
setupBrandTitleAnimation() {
const mainPage = document.getElementById('main-page');
if (mainPage && mainPage.style.display === 'none') {
return;
}
const toggle = document.getElementById('brand-name-toggle');
const wrapper = document.getElementById('brand-texts');
const fullText = document.querySelector('.brand-text-full');
const shortText = document.querySelector('.brand-text-short');
if (!toggle || !wrapper || !fullText || !shortText) {
return;
}
this.brandElements = { toggle, wrapper, fullText, shortText };
if (!this.brandToggleHandler) {
this.brandToggleHandler = () => this.handleBrandToggle();
toggle.addEventListener('click', this.brandToggleHandler);
}
if (!this.brandResizeHandler) {
this.brandResizeHandler = () => this.handleBrandResize();
window.addEventListener('resize', this.brandResizeHandler);
}
if (this.isMobileViewport()) {
this.applyMobileBrandState();
} else {
this.enableBrandAnimation();
}
}
enableBrandAnimation() {
const { toggle } = this.brandElements || {};
if (toggle) {
toggle.removeAttribute('aria-disabled');
toggle.style.pointerEvents = '';
}
this.brandAnimationReady = true;
}
applyMobileBrandState() {
const { toggle, wrapper, shortText } = this.brandElements || {};
if (!toggle || !wrapper || !shortText) {
return;
}
this.clearBrandCollapseTimer();
this.brandIsCollapsed = true;
this.brandAnimationReady = false;
toggle.classList.add('collapsed');
toggle.classList.remove('expanded');
toggle.setAttribute('aria-disabled', 'true');
toggle.style.pointerEvents = 'none';
const targetWidth = this.getBrandTextWidth(shortText);
this.applyBrandWidth(targetWidth, { animate: false });
}
getBrandTextWidth(element) {
if (!element) {
return 0;
}
const width = element.scrollWidth || element.getBoundingClientRect().width || 0;
return Number.isFinite(width) ? Math.ceil(width) : 0;
}
applyBrandWidth(targetWidth, { animate = true } = {}) {
const wrapper = this.brandElements?.wrapper;
if (!wrapper || !Number.isFinite(targetWidth)) {
return;
}
if (!animate) {
const previousTransition = wrapper.style.transition;
wrapper.style.transition = 'none';
wrapper.style.width = `${targetWidth}px`;
wrapper.getBoundingClientRect(); // 强制重绘以应用无动画的宽度
wrapper.style.transition = previousTransition;
return;
}
wrapper.style.width = `${targetWidth}px`;
}
updateBrandTextWidths(options = {}) {
const { wrapper, fullText, shortText } = this.brandElements || {};
if (!wrapper || !fullText || !shortText) {
return;
}
const targetSpan = this.brandIsCollapsed ? shortText : fullText;
const targetWidth = this.getBrandTextWidth(targetSpan);
this.applyBrandWidth(targetWidth, { animate: !options.immediate });
}
setBrandCollapsed(collapsed, options = {}) {
const { toggle, fullText, shortText } = this.brandElements || {};
if (!toggle || !fullText || !shortText) {
return;
}
this.brandIsCollapsed = collapsed;
const targetSpan = collapsed ? shortText : fullText;
const targetWidth = this.getBrandTextWidth(targetSpan);
this.applyBrandWidth(targetWidth, { animate: options.animate !== false });
toggle.classList.toggle('collapsed', collapsed);
toggle.classList.toggle('expanded', !collapsed);
}
handleBrandResize() {
if (!this.brandElements?.wrapper) {
return;
}
if (this.isMobileViewport()) {
this.applyMobileBrandState();
return;
}
if (!this.brandAnimationReady) {
this.enableBrandAnimation();
this.brandIsCollapsed = false;
this.setBrandCollapsed(false, { animate: false });
this.scheduleBrandCollapse(this.brandCollapseDelayMs);
return;
}
this.updateBrandTextWidths({ immediate: true });
}
scheduleBrandCollapse(delayMs = this.brandCollapseDelayMs) {
this.clearBrandCollapseTimer();
this.brandCollapseTimer = window.setTimeout(() => {
this.setBrandCollapsed(true);
this.brandCollapseTimer = null;
}, delayMs);
}
clearBrandCollapseTimer() {
if (this.brandCollapseTimer) {
clearTimeout(this.brandCollapseTimer);
this.brandCollapseTimer = null;
}
}
startBrandCollapseCycle() {
this.setupBrandTitleAnimation();
if (this.isMobileViewport()) {
this.applyMobileBrandState();
return;
}
if (!this.brandAnimationReady) {
return;
}
this.clearBrandCollapseTimer();
this.brandIsCollapsed = false;
this.setBrandCollapsed(false, { animate: false });
this.scheduleBrandCollapse(this.brandCollapseDelayMs);
}
resetBrandTitleState() {
this.clearBrandCollapseTimer();
const mainPage = document.getElementById('main-page');
if (this.isMobileViewport()) {
this.applyMobileBrandState();
return;
}
if (!this.brandAnimationReady || (mainPage && mainPage.style.display === 'none')) {
this.brandIsCollapsed = false;
return;
}
this.brandIsCollapsed = false;
this.setBrandCollapsed(false, { animate: false });
}
refreshBrandTitleAfterTextChange() {
if (this.isMobileViewport()) {
this.applyMobileBrandState();
return;
}
if (!this.brandAnimationReady) {
return;
}
this.updateBrandTextWidths({ immediate: true });
if (!this.brandIsCollapsed) {
this.scheduleBrandCollapse(this.brandCollapseDelayMs);
}
}
handleBrandToggle() {
if (!this.brandAnimationReady) {
return;
}
const nextCollapsed = !this.brandIsCollapsed;
this.setBrandCollapsed(nextCollapsed);
this.clearBrandCollapseTimer();
if (!nextCollapsed) {
// 展开后给用户留出一点时间阅读再收起
this.scheduleBrandCollapse(this.brandCollapseDelayMs + 1500);
}
}
// 显示通知 // 显示通知
showNotification(message, type = 'info') { showNotification(message, type = 'info') {
@@ -541,14 +950,27 @@ class CLIProxyManager {
// 使用统计状态 // 使用统计状态
requestsChart = null; requestsChart = null;
tokensChart = null; tokensChart = null;
costChart = null;
currentUsageData = null; currentUsageData = null;
chartLineSelections = ['none', 'none', 'none']; chartLineMaxCount = 9;
chartLineSelectIds = ['chart-line-select-0', 'chart-line-select-1', 'chart-line-select-2']; chartLineVisibleCount = 3;
chartLineSelections = Array(3).fill('none');
chartLineSelectionsInitialized = false;
chartLineSelectIds = Array.from({ length: 9 }, (_, idx) => `chart-line-select-${idx}`);
chartLineStyles = [ chartLineStyles = [
{ borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' }, { borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' },
{ borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.15)' }, { borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.15)' },
{ borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' } { borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' },
{ borderColor: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.15)' },
{ borderColor: '#ec4899', backgroundColor: 'rgba(236, 72, 153, 0.15)' },
{ borderColor: '#14b8a6', backgroundColor: 'rgba(20, 184, 166, 0.15)' },
{ borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.15)' },
{ borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.15)' },
{ borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.15)' }
]; ];
modelPriceStorageKey = 'cli-proxy-model-prices-v2';
modelPrices = {};
modelPriceInitialized = false;
showModal() { showModal() {
const modal = document.getElementById('modal'); const modal = document.getElementById('modal');
@@ -560,6 +982,9 @@ class CLIProxyManager {
// 关闭模态框 // 关闭模态框
closeModal() { closeModal() {
document.getElementById('modal').style.display = 'none'; document.getElementById('modal').style.display = 'none';
if (typeof this.closeOpenAIModelDiscovery === 'function') {
this.closeOpenAIModelDiscovery();
}
} }
} }
@@ -584,17 +1009,28 @@ Object.assign(
// 将工具函数绑定到原型上,供模块使用 // 将工具函数绑定到原型上,供模块使用
CLIProxyManager.prototype.escapeHtml = escapeHtml; CLIProxyManager.prototype.escapeHtml = escapeHtml;
CLIProxyManager.prototype.maskApiKey = maskApiKey; CLIProxyManager.prototype.maskApiKey = maskApiKey;
CLIProxyManager.prototype.formatFileSize = formatFileSize;
CLIProxyManager.prototype.normalizeArrayResponse = normalizeArrayResponse; CLIProxyManager.prototype.normalizeArrayResponse = normalizeArrayResponse;
CLIProxyManager.prototype.debounce = debounce; CLIProxyManager.prototype.debounce = debounce;
// 全局管理器实例 // 全局管理器实例
let manager; let manager;
// 让内联事件处理器可以访问到 manager 实例
function exposeManagerInstance(instance) {
if (typeof window !== 'undefined') {
window.manager = instance;
} else if (typeof globalThis !== 'undefined') {
globalThis.manager = instance;
}
}
// 尝试自动加载根目录 Logo支持多种常见文件名/扩展名) // 尝试自动加载根目录 Logo支持多种常见文件名/扩展名)
function setupSiteLogo() { function setupSiteLogo() {
const img = document.getElementById('site-logo'); const img = document.getElementById('site-logo');
const loginImg = document.getElementById('login-logo'); const loginImg = document.getElementById('login-logo');
if (!img && !loginImg) return; const favicon = document.getElementById('favicon-link');
if (!img && !loginImg && !favicon) return;
const inlineLogo = typeof window !== 'undefined' ? window.__INLINE_LOGO__ : null; const inlineLogo = typeof window !== 'undefined' ? window.__INLINE_LOGO__ : null;
if (inlineLogo) { if (inlineLogo) {
@@ -606,6 +1042,9 @@ function setupSiteLogo() {
loginImg.src = inlineLogo; loginImg.src = inlineLogo;
loginImg.style.display = 'inline-block'; loginImg.style.display = 'inline-block';
} }
if (favicon) {
favicon.href = inlineLogo;
}
return; return;
} }
@@ -627,6 +1066,9 @@ function setupSiteLogo() {
loginImg.src = test.src; loginImg.src = test.src;
loginImg.style.display = 'inline-block'; loginImg.style.display = 'inline-block';
} }
if (favicon) {
favicon.href = test.src;
}
}; };
test.onerror = () => { test.onerror = () => {
idx++; idx++;
@@ -644,4 +1086,5 @@ document.addEventListener('DOMContentLoaded', () => {
setupSiteLogo(); setupSiteLogo();
manager = new CLIProxyManager(); manager = new CLIProxyManager();
exposeManagerInstance(manager);
}); });

View File

@@ -169,42 +169,44 @@ function build() {
console.log(`使用版本号: ${version}`); console.log(`使用版本号: ${version}`);
html = html.replace(/__VERSION__/g, version); html = html.replace(/__VERSION__/g, version);
html = html.replace( html = html.replace(
'<link rel="stylesheet" href="styles.css">', '<link rel="stylesheet" href="styles.css">',
`<style> () => `<style>
${css} ${css}
</style>` </style>`
); );
html = html.replace(
'<script src="i18n.js"></script>',
() => `<script>
${i18n}
</script>`
);
html = html.replace( const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i;
'<script src="i18n.js"></script>', if (scriptTagRegex.test(html)) {
`<script> html = html.replace(
${i18n} scriptTagRegex,
</script>` () => `<script>
); ${app}
</script>`
const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i; );
if (scriptTagRegex.test(html)) {
html = html.replace(
scriptTagRegex,
`<script>
${app}
</script>`
);
} else { } else {
console.warn('未找到 app.js 脚本标签,未内联应用代码。'); console.warn('未找到 app.js 脚本标签,未内联应用代码。');
} }
const logoDataUrl = loadLogoDataUrl(); const logoDataUrl = loadLogoDataUrl();
if (logoDataUrl) { if (logoDataUrl) {
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`; const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
if (html.includes('</body>')) { const closingBodyTag = '</body>';
html = html.replace('</body>', `${logoScript}\n</body>`); const closingBodyIndex = html.lastIndexOf(closingBodyTag);
} else { if (closingBodyIndex !== -1) {
html += `\n${logoScript}`; html = `${html.slice(0, closingBodyIndex)}${logoScript}\n${closingBodyTag}${html.slice(closingBodyIndex + closingBodyTag.length)}`;
} } else {
} else { html += `\n${logoScript}`;
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。'); }
} else {
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。');
} }
const outputPath = path.join(distDir, 'index.html'); const outputPath = path.join(distDir, 'index.html');

326
i18n.js
View File

@@ -52,6 +52,7 @@ const i18n = {
// 页面标题 // 页面标题
'title.main': 'CLI Proxy API Management Center', 'title.main': 'CLI Proxy API Management Center',
'title.login': 'CLI Proxy API Management Center', 'title.login': 'CLI Proxy API Management Center',
'title.abbr': 'CPAMC',
// 自动登录 // 自动登录
'auto_login.title': '正在自动登录...', 'auto_login.title': '正在自动登录...',
@@ -94,7 +95,7 @@ const i18n = {
'nav.usage_stats': '使用统计', 'nav.usage_stats': '使用统计',
'nav.config_management': '配置管理', 'nav.config_management': '配置管理',
'nav.logs': '日志查看', 'nav.logs': '日志查看',
'nav.system_info': '系统信息', 'nav.system_info': '中心信息',
// 基础设置 // 基础设置
'basic_settings.title': '基础设置', 'basic_settings.title': '基础设置',
@@ -149,6 +150,10 @@ const i18n = {
'ai_providers.gemini_edit_modal_title': '编辑Gemini API密钥', 'ai_providers.gemini_edit_modal_title': '编辑Gemini API密钥',
'ai_providers.gemini_edit_modal_key_label': 'API密钥:', 'ai_providers.gemini_edit_modal_key_label': 'API密钥:',
'ai_providers.gemini_delete_confirm': '确定要删除这个Gemini密钥吗', 'ai_providers.gemini_delete_confirm': '确定要删除这个Gemini密钥吗',
'ai_providers.excluded_models_label': '排除的模型 (可选):',
'ai_providers.excluded_models_placeholder': '用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash',
'ai_providers.excluded_models_hint': '留空表示不过滤;保存时会自动去重并忽略空白。',
'ai_providers.excluded_models_count': '排除 {count} 个模型',
'ai_providers.codex_title': 'Codex API 配置', 'ai_providers.codex_title': 'Codex API 配置',
'ai_providers.codex_add_button': '添加配置', 'ai_providers.codex_add_button': '添加配置',
@@ -210,6 +215,21 @@ const i18n = {
'ai_providers.openai_model_name_placeholder': '模型名称,如 moonshotai/kimi-k2:free', 'ai_providers.openai_model_name_placeholder': '模型名称,如 moonshotai/kimi-k2:free',
'ai_providers.openai_model_alias_placeholder': '模型别名 (可选)', 'ai_providers.openai_model_alias_placeholder': '模型别名 (可选)',
'ai_providers.openai_models_add_btn': '添加模型', 'ai_providers.openai_models_add_btn': '添加模型',
'ai_providers.openai_models_fetch_button': '从 /v1/models 获取',
'ai_providers.openai_models_fetch_title': '从 /v1/models 选择模型',
'ai_providers.openai_models_fetch_hint': '使用上方 Base URL 调用 /v1/models 端点,附带首个 API KeyBearer与自定义请求头。',
'ai_providers.openai_models_fetch_url_label': '请求地址',
'ai_providers.openai_models_fetch_refresh': '重新获取',
'ai_providers.openai_models_fetch_loading': '正在从 /v1/models 获取模型列表...',
'ai_providers.openai_models_fetch_empty': '未获取到模型,请检查端点或鉴权信息。',
'ai_providers.openai_models_fetch_error': '获取模型失败',
'ai_providers.openai_models_fetch_back': '返回编辑',
'ai_providers.openai_models_fetch_apply': '添加所选模型',
'ai_providers.openai_models_search_label': '搜索模型',
'ai_providers.openai_models_search_placeholder': '按名称、别名或描述筛选',
'ai_providers.openai_models_search_empty': '没有匹配的模型,请更换关键字试试。',
'ai_providers.openai_models_fetch_invalid_url': '请先填写有效的 Base URL',
'ai_providers.openai_models_fetch_added': '已添加 {count} 个新模型',
'ai_providers.openai_edit_modal_title': '编辑OpenAI兼容提供商', 'ai_providers.openai_edit_modal_title': '编辑OpenAI兼容提供商',
'ai_providers.openai_edit_modal_name_label': '提供商名称:', 'ai_providers.openai_edit_modal_name_label': '提供商名称:',
'ai_providers.openai_edit_modal_url_label': 'Base URL:', 'ai_providers.openai_edit_modal_url_label': 'Base URL:',
@@ -218,6 +238,15 @@ const i18n = {
'ai_providers.openai_delete_confirm': '确定要删除这个OpenAI提供商吗', 'ai_providers.openai_delete_confirm': '确定要删除这个OpenAI提供商吗',
'ai_providers.openai_keys_count': '密钥数量', 'ai_providers.openai_keys_count': '密钥数量',
'ai_providers.openai_models_count': '模型数量', 'ai_providers.openai_models_count': '模型数量',
'ai_providers.openai_test_title': '连通性测试',
'ai_providers.openai_test_hint': '使用当前配置向 /v1/chat/completions 请求,验证是否可用。',
'ai_providers.openai_test_model_placeholder': '选择或输入要测试的模型',
'ai_providers.openai_test_action': '发送测试',
'ai_providers.openai_test_running': '正在发送测试请求...',
'ai_providers.openai_test_success': '测试成功,模型可用。',
'ai_providers.openai_test_failed': '测试失败',
'ai_providers.openai_test_select_placeholder': '从当前模型列表选择',
'ai_providers.openai_test_select_empty': '当前未配置模型,可直接输入',
// 认证文件管理 // 认证文件管理
@@ -260,6 +289,7 @@ const i18n = {
'auth_files.filter_aistudio': 'AIStudio', 'auth_files.filter_aistudio': 'AIStudio',
'auth_files.filter_claude': 'Claude', 'auth_files.filter_claude': 'Claude',
'auth_files.filter_codex': 'Codex', 'auth_files.filter_codex': 'Codex',
'auth_files.filter_antigravity': 'Antigravity',
'auth_files.filter_iflow': 'iFlow', 'auth_files.filter_iflow': 'iFlow',
'auth_files.filter_vertex': 'Vertex', 'auth_files.filter_vertex': 'Vertex',
'auth_files.filter_empty': '空文件', 'auth_files.filter_empty': '空文件',
@@ -270,6 +300,7 @@ const i18n = {
'auth_files.type_aistudio': 'AIStudio', 'auth_files.type_aistudio': 'AIStudio',
'auth_files.type_claude': 'Claude', 'auth_files.type_claude': 'Claude',
'auth_files.type_codex': 'Codex', 'auth_files.type_codex': 'Codex',
'auth_files.type_antigravity': 'Antigravity',
'auth_files.type_iflow': 'iFlow', 'auth_files.type_iflow': 'iFlow',
'auth_files.type_vertex': 'Vertex', 'auth_files.type_vertex': 'Vertex',
'auth_files.type_empty': '空文件', 'auth_files.type_empty': '空文件',
@@ -292,6 +323,40 @@ const i18n = {
'vertex_import.result_location': '区域', 'vertex_import.result_location': '区域',
'vertex_import.result_file': '存储文件', 'vertex_import.result_file': '存储文件',
// OAuth 排除模型
'oauth_excluded.title': 'OAuth 排除列表',
'oauth_excluded.description': '按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。',
'oauth_excluded.add': '新增排除',
'oauth_excluded.add_title': '新增提供商排除列表',
'oauth_excluded.edit_title': '编辑 {provider} 的排除列表',
'oauth_excluded.refresh': '刷新',
'oauth_excluded.refreshing': '刷新中...',
'oauth_excluded.provider_label': '提供商',
'oauth_excluded.provider_auto': '跟随当前过滤',
'oauth_excluded.provider_placeholder': '例如 gemini-cli / openai',
'oauth_excluded.provider_hint': '默认选中当前筛选的提供商,也可直接输入或选择其他名称。',
'oauth_excluded.models_label': '排除的模型',
'oauth_excluded.models_placeholder': 'gpt-4.1-mini\n*-preview',
'oauth_excluded.models_hint': '逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。',
'oauth_excluded.save': '保存/更新',
'oauth_excluded.saving': '正在保存...',
'oauth_excluded.save_success': '排除列表已更新',
'oauth_excluded.save_failed': '更新排除列表失败',
'oauth_excluded.delete': '删除提供商',
'oauth_excluded.delete_confirm': '确定要删除 {provider} 的排除列表吗?',
'oauth_excluded.delete_success': '已删除该提供商的排除列表',
'oauth_excluded.delete_failed': '删除排除列表失败',
'oauth_excluded.deleting': '正在删除...',
'oauth_excluded.no_models': '未配置排除模型',
'oauth_excluded.model_count': '排除 {count} 个模型',
'oauth_excluded.list_empty_all': '暂无任何提供商的排除列表,点击“新增排除”创建。',
'oauth_excluded.list_empty_filtered': '当前筛选下没有排除项,点击“新增排除”添加。',
'oauth_excluded.disconnected': '请先连接服务器以查看排除列表',
'oauth_excluded.load_failed': '加载排除列表失败',
'oauth_excluded.provider_required': '请先填写提供商名称',
'oauth_excluded.scope_all': '当前范围:全局(显示所有提供商)',
'oauth_excluded.scope_provider': '当前范围:{provider}',
// Codex OAuth // Codex OAuth
'auth_login.codex_oauth_title': 'Codex OAuth', 'auth_login.codex_oauth_title': 'Codex OAuth',
@@ -319,6 +384,19 @@ const i18n = {
'auth_login.anthropic_oauth_start_error': '启动 Anthropic OAuth 失败:', 'auth_login.anthropic_oauth_start_error': '启动 Anthropic OAuth 失败:',
'auth_login.anthropic_oauth_polling_error': '检查认证状态失败:', 'auth_login.anthropic_oauth_polling_error': '检查认证状态失败:',
// Antigravity OAuth
'auth_login.antigravity_oauth_title': 'Antigravity OAuth',
'auth_login.antigravity_oauth_button': '开始 Antigravity 登录',
'auth_login.antigravity_oauth_hint': '通过 OAuth 流程登录 AntigravityGoogle 账号)服务,自动获取并保存认证文件。',
'auth_login.antigravity_oauth_url_label': '授权链接:',
'auth_login.antigravity_open_link': '打开链接',
'auth_login.antigravity_copy_link': '复制链接',
'auth_login.antigravity_oauth_status_waiting': '等待认证中...',
'auth_login.antigravity_oauth_status_success': '认证成功!',
'auth_login.antigravity_oauth_status_error': '认证失败:',
'auth_login.antigravity_oauth_start_error': '启动 Antigravity OAuth 失败:',
'auth_login.antigravity_oauth_polling_error': '检查认证状态失败:',
// Gemini CLI OAuth // Gemini CLI OAuth
'auth_login.gemini_cli_oauth_title': 'Gemini CLI OAuth', 'auth_login.gemini_cli_oauth_title': 'Gemini CLI OAuth',
'auth_login.gemini_cli_oauth_button': '开始 Gemini CLI 登录', 'auth_login.gemini_cli_oauth_button': '开始 Gemini CLI 登录',
@@ -361,6 +439,20 @@ const i18n = {
'auth_login.iflow_oauth_status_error': '认证失败:', 'auth_login.iflow_oauth_status_error': '认证失败:',
'auth_login.iflow_oauth_start_error': '启动 iFlow OAuth 失败:', 'auth_login.iflow_oauth_start_error': '启动 iFlow OAuth 失败:',
'auth_login.iflow_oauth_polling_error': '检查认证状态失败:', 'auth_login.iflow_oauth_polling_error': '检查认证状态失败:',
'auth_login.iflow_cookie_title': 'iFlow Cookie 登录',
'auth_login.iflow_cookie_label': 'Cookie 内容:',
'auth_login.iflow_cookie_placeholder': '粘贴浏览器中的 Cookie例如 sessionid=...;',
'auth_login.iflow_cookie_hint': '直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。',
'auth_login.iflow_cookie_button': '提交 Cookie 登录',
'auth_login.iflow_cookie_status_success': 'Cookie 登录成功,凭据已保存。',
'auth_login.iflow_cookie_status_error': 'Cookie 登录失败:',
'auth_login.iflow_cookie_start_error': '提交 Cookie 登录失败:',
'auth_login.iflow_cookie_required': '请先填写 Cookie 内容',
'auth_login.iflow_cookie_result_title': 'Cookie 登录结果',
'auth_login.iflow_cookie_result_email': '账号',
'auth_login.iflow_cookie_result_expired': '过期时间',
'auth_login.iflow_cookie_result_path': '保存路径',
'auth_login.iflow_cookie_result_type': '类型',
// 使用统计 // 使用统计
'usage_stats.title': '使用统计', 'usage_stats.title': '使用统计',
@@ -368,6 +460,10 @@ const i18n = {
'usage_stats.success_requests': '成功请求', 'usage_stats.success_requests': '成功请求',
'usage_stats.failed_requests': '失败请求', 'usage_stats.failed_requests': '失败请求',
'usage_stats.total_tokens': '总Token数', 'usage_stats.total_tokens': '总Token数',
'usage_stats.cached_tokens': '缓存 Token 数',
'usage_stats.reasoning_tokens': '思考 Token 数',
'usage_stats.rpm_30m': 'RPM近30分钟',
'usage_stats.tpm_30m': 'TPM近30分钟',
'usage_stats.requests_trend': '请求趋势', 'usage_stats.requests_trend': '请求趋势',
'usage_stats.tokens_trend': 'Token 使用趋势', 'usage_stats.tokens_trend': 'Token 使用趋势',
'usage_stats.api_details': 'API 详细统计', 'usage_stats.api_details': 'API 详细统计',
@@ -377,7 +473,18 @@ const i18n = {
'usage_stats.chart_line_label_1': '曲线 1', 'usage_stats.chart_line_label_1': '曲线 1',
'usage_stats.chart_line_label_2': '曲线 2', 'usage_stats.chart_line_label_2': '曲线 2',
'usage_stats.chart_line_label_3': '曲线 3', 'usage_stats.chart_line_label_3': '曲线 3',
'usage_stats.chart_line_label_4': '曲线 4',
'usage_stats.chart_line_label_5': '曲线 5',
'usage_stats.chart_line_label_6': '曲线 6',
'usage_stats.chart_line_label_7': '曲线 7',
'usage_stats.chart_line_label_8': '曲线 8',
'usage_stats.chart_line_label_9': '曲线 9',
'usage_stats.chart_line_hidden': '不显示', 'usage_stats.chart_line_hidden': '不显示',
'usage_stats.chart_line_actions_label': '曲线数量',
'usage_stats.chart_line_add': '增加曲线',
'usage_stats.chart_line_all': '全部',
'usage_stats.chart_line_delete': '删除曲线',
'usage_stats.chart_line_hint': '最多同时显示 9 条模型曲线',
'usage_stats.no_data': '暂无数据', 'usage_stats.no_data': '暂无数据',
'usage_stats.loading_error': '加载失败', 'usage_stats.loading_error': '加载失败',
'usage_stats.api_endpoint': 'API端点', 'usage_stats.api_endpoint': 'API端点',
@@ -385,6 +492,25 @@ const i18n = {
'usage_stats.tokens_count': 'Token数量', 'usage_stats.tokens_count': 'Token数量',
'usage_stats.models': '模型统计', 'usage_stats.models': '模型统计',
'usage_stats.success_rate': '成功率', 'usage_stats.success_rate': '成功率',
'usage_stats.total_cost': '总花费',
'usage_stats.total_cost_hint': '基于已设置的模型单价',
'usage_stats.model_price_title': '模型价格',
'usage_stats.model_price_reset': '清除价格',
'usage_stats.model_price_model_label': '选择模型',
'usage_stats.model_price_select_placeholder': '选择模型',
'usage_stats.model_price_select_hint': '模型列表来自使用统计明细',
'usage_stats.model_price_prompt': '提示价格 ($/1M tokens)',
'usage_stats.model_price_completion': '补全价格 ($/1M tokens)',
'usage_stats.model_price_save': '保存价格',
'usage_stats.model_price_empty': '暂未设置任何模型价格',
'usage_stats.model_price_model': '模型',
'usage_stats.model_price_saved': '模型价格已保存',
'usage_stats.model_price_model_required': '请选择要设置价格的模型',
'usage_stats.cost_trend': '花费统计',
'usage_stats.cost_axis_label': '花费 ($)',
'usage_stats.cost_need_price': '请先设置模型价格',
'usage_stats.cost_need_usage': '暂无使用数据,无法计算花费',
'usage_stats.cost_no_data': '没有可计算的花费数据',
'stats.success': '成功', 'stats.success': '成功',
'stats.failure': '失败', 'stats.failure': '失败',
@@ -393,6 +519,15 @@ const i18n = {
'logs.refresh_button': '刷新日志', 'logs.refresh_button': '刷新日志',
'logs.clear_button': '清空日志', 'logs.clear_button': '清空日志',
'logs.download_button': '下载日志', 'logs.download_button': '下载日志',
'logs.error_log_button': '选择错误日志',
'logs.error_logs_modal_title': '错误请求日志',
'logs.error_logs_description': '请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。',
'logs.error_logs_empty': '暂无错误请求日志文件',
'logs.error_logs_load_error': '加载错误日志列表失败',
'logs.error_logs_size': '大小',
'logs.error_logs_modified': '最后修改',
'logs.error_logs_download': '下载',
'logs.error_log_download_success': '错误日志下载成功',
'logs.empty_title': '暂无日志记录', 'logs.empty_title': '暂无日志记录',
'logs.empty_desc': '当启用"日志记录到文件"功能后,日志将显示在这里', 'logs.empty_desc': '当启用"日志记录到文件"功能后,日志将显示在这里',
'logs.log_content': '日志内容', 'logs.log_content': '日志内容',
@@ -404,6 +539,9 @@ const i18n = {
'logs.auto_refresh': '自动刷新', 'logs.auto_refresh': '自动刷新',
'logs.auto_refresh_enabled': '自动刷新已开启', 'logs.auto_refresh_enabled': '自动刷新已开启',
'logs.auto_refresh_disabled': '自动刷新已关闭', 'logs.auto_refresh_disabled': '自动刷新已关闭',
'logs.search_placeholder': '搜索日志内容或关键字',
'logs.search_empty_title': '未找到匹配的日志',
'logs.search_empty_desc': '尝试更换关键字或清空搜索条件。',
'logs.lines': '行', 'logs.lines': '行',
'logs.removed': '已删除', 'logs.removed': '已删除',
'logs.upgrade_required_title': '需要升级 CLI Proxy API', 'logs.upgrade_required_title': '需要升级 CLI Proxy API',
@@ -429,7 +567,7 @@ const i18n = {
'config_management.editor_placeholder': 'key: value', 'config_management.editor_placeholder': 'key: value',
// 系统信息 // 系统信息
'system_info.title': '系统信息', 'system_info.title': '管理中心信息',
'system_info.connection_status_title': '连接状态', 'system_info.connection_status_title': '连接状态',
'system_info.api_status_label': 'API 状态:', 'system_info.api_status_label': 'API 状态:',
'system_info.config_status_label': '配置状态:', 'system_info.config_status_label': '配置状态:',
@@ -438,6 +576,24 @@ const i18n = {
'system_info.real_time_data': '实时数据', 'system_info.real_time_data': '实时数据',
'system_info.not_loaded': '未加载', 'system_info.not_loaded': '未加载',
'system_info.seconds_ago': '秒前', 'system_info.seconds_ago': '秒前',
'system_info.models_title': '可用模型列表',
'system_info.models_desc': '展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。',
'system_info.models_loading': '正在加载可用模型...',
'system_info.models_empty': '未从 /v1/models 获取到模型数据',
'system_info.models_error': '获取模型列表失败',
'system_info.models_count': '可用模型 {count} 个',
'system_info.version_check_title': '版本检查',
'system_info.version_check_desc': '调用 /latest-version 接口比对服务器版本,提示是否有可用更新。',
'system_info.version_current_label': '当前版本',
'system_info.version_latest_label': '最新版本',
'system_info.version_check_button': '检查更新',
'system_info.version_check_idle': '点击检查更新',
'system_info.version_checking': '正在检查最新版本...',
'system_info.version_update_available': '有新版本可用:{version}',
'system_info.version_is_latest': '当前已是最新版本',
'system_info.version_check_error': '检查更新失败',
'system_info.version_current_missing': '未获取到服务器版本号,暂无法比对',
'system_info.version_unknown': '未知',
// 通知消息 // 通知消息
'notification.debug_updated': '调试设置已更新', 'notification.debug_updated': '调试设置已更新',
@@ -472,6 +628,9 @@ const i18n = {
'notification.openai_provider_updated': 'OpenAI提供商更新成功', 'notification.openai_provider_updated': 'OpenAI提供商更新成功',
'notification.openai_provider_deleted': 'OpenAI提供商删除成功', 'notification.openai_provider_deleted': 'OpenAI提供商删除成功',
'notification.openai_model_name_required': '请填写模型名称', 'notification.openai_model_name_required': '请填写模型名称',
'notification.openai_test_url_required': '请先填写有效的 Base URL 以进行测试',
'notification.openai_test_key_required': '请至少填写一个 API 密钥以进行测试',
'notification.openai_test_model_required': '请选择或输入要测试的模型',
'notification.data_refreshed': '数据刷新成功', 'notification.data_refreshed': '数据刷新成功',
'notification.connection_required': '请先建立连接', 'notification.connection_required': '请先建立连接',
'notification.refresh_failed': '刷新失败', 'notification.refresh_failed': '刷新失败',
@@ -560,6 +719,7 @@ const i18n = {
// Page titles // Page titles
'title.main': 'CLI Proxy API Management Center', 'title.main': 'CLI Proxy API Management Center',
'title.login': 'CLI Proxy API Management Center', 'title.login': 'CLI Proxy API Management Center',
'title.abbr': 'CPAMC',
// Auto login // Auto login
'auto_login.title': 'Auto Login in Progress...', 'auto_login.title': 'Auto Login in Progress...',
@@ -602,7 +762,7 @@ const i18n = {
'nav.usage_stats': 'Usage Statistics', 'nav.usage_stats': 'Usage Statistics',
'nav.config_management': 'Config Management', 'nav.config_management': 'Config Management',
'nav.logs': 'Logs Viewer', 'nav.logs': 'Logs Viewer',
'nav.system_info': 'System Info', 'nav.system_info': 'Management Center Info',
// Basic settings // Basic settings
'basic_settings.title': 'Basic Settings', 'basic_settings.title': 'Basic Settings',
@@ -657,6 +817,10 @@ const i18n = {
'ai_providers.gemini_edit_modal_title': 'Edit Gemini API Key', 'ai_providers.gemini_edit_modal_title': 'Edit Gemini API Key',
'ai_providers.gemini_edit_modal_key_label': 'API Key:', 'ai_providers.gemini_edit_modal_key_label': 'API Key:',
'ai_providers.gemini_delete_confirm': 'Are you sure you want to delete this Gemini key?', 'ai_providers.gemini_delete_confirm': 'Are you sure you want to delete this Gemini key?',
'ai_providers.excluded_models_label': 'Excluded models (optional):',
'ai_providers.excluded_models_placeholder': 'Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash',
'ai_providers.excluded_models_hint': 'Leave empty to allow all models; values are trimmed and deduplicated automatically.',
'ai_providers.excluded_models_count': 'Excluding {count} models',
'ai_providers.codex_title': 'Codex API Configuration', 'ai_providers.codex_title': 'Codex API Configuration',
'ai_providers.codex_add_button': 'Add Configuration', 'ai_providers.codex_add_button': 'Add Configuration',
@@ -718,6 +882,21 @@ const i18n = {
'ai_providers.openai_model_name_placeholder': 'Model name, e.g. moonshotai/kimi-k2:free', 'ai_providers.openai_model_name_placeholder': 'Model name, e.g. moonshotai/kimi-k2:free',
'ai_providers.openai_model_alias_placeholder': 'Model alias (optional)', 'ai_providers.openai_model_alias_placeholder': 'Model alias (optional)',
'ai_providers.openai_models_add_btn': 'Add Model', 'ai_providers.openai_models_add_btn': 'Add Model',
'ai_providers.openai_models_fetch_button': 'Fetch via /v1/models',
'ai_providers.openai_models_fetch_title': 'Pick Models from /v1/models',
'ai_providers.openai_models_fetch_hint': 'Call the /v1/models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.',
'ai_providers.openai_models_fetch_url_label': 'Request URL',
'ai_providers.openai_models_fetch_refresh': 'Refresh',
'ai_providers.openai_models_fetch_loading': 'Fetching models from /v1/models...',
'ai_providers.openai_models_fetch_empty': 'No models returned. Please check the endpoint or auth.',
'ai_providers.openai_models_fetch_error': 'Failed to fetch models',
'ai_providers.openai_models_fetch_back': 'Back to edit',
'ai_providers.openai_models_fetch_apply': 'Add selected models',
'ai_providers.openai_models_search_label': 'Search models',
'ai_providers.openai_models_search_placeholder': 'Filter by name, alias, or description',
'ai_providers.openai_models_search_empty': 'No models match your search. Try a different keyword.',
'ai_providers.openai_models_fetch_invalid_url': 'Please enter a valid Base URL first',
'ai_providers.openai_models_fetch_added': '{count} new models added',
'ai_providers.openai_edit_modal_title': 'Edit OpenAI Compatible Provider', 'ai_providers.openai_edit_modal_title': 'Edit OpenAI Compatible Provider',
'ai_providers.openai_edit_modal_name_label': 'Provider Name:', 'ai_providers.openai_edit_modal_name_label': 'Provider Name:',
'ai_providers.openai_edit_modal_url_label': 'Base URL:', 'ai_providers.openai_edit_modal_url_label': 'Base URL:',
@@ -726,6 +905,15 @@ const i18n = {
'ai_providers.openai_delete_confirm': 'Are you sure you want to delete this OpenAI provider?', 'ai_providers.openai_delete_confirm': 'Are you sure you want to delete this OpenAI provider?',
'ai_providers.openai_keys_count': 'Keys Count', 'ai_providers.openai_keys_count': 'Keys Count',
'ai_providers.openai_models_count': 'Models Count', 'ai_providers.openai_models_count': 'Models Count',
'ai_providers.openai_test_title': 'Connection Test',
'ai_providers.openai_test_hint': 'Send a /v1/chat/completions request with the current settings to verify availability.',
'ai_providers.openai_test_model_placeholder': 'Model to test',
'ai_providers.openai_test_action': 'Run Test',
'ai_providers.openai_test_running': 'Sending test request...',
'ai_providers.openai_test_success': 'Test succeeded. The model responded.',
'ai_providers.openai_test_failed': 'Test failed',
'ai_providers.openai_test_select_placeholder': 'Choose from current models',
'ai_providers.openai_test_select_empty': 'No models configured, enter manually',
// Auth files management // Auth files management
@@ -768,6 +956,7 @@ const i18n = {
'auth_files.filter_aistudio': 'AIStudio', 'auth_files.filter_aistudio': 'AIStudio',
'auth_files.filter_claude': 'Claude', 'auth_files.filter_claude': 'Claude',
'auth_files.filter_codex': 'Codex', 'auth_files.filter_codex': 'Codex',
'auth_files.filter_antigravity': 'Antigravity',
'auth_files.filter_iflow': 'iFlow', 'auth_files.filter_iflow': 'iFlow',
'auth_files.filter_vertex': 'Vertex', 'auth_files.filter_vertex': 'Vertex',
'auth_files.filter_empty': 'Empty', 'auth_files.filter_empty': 'Empty',
@@ -778,6 +967,7 @@ const i18n = {
'auth_files.type_aistudio': 'AIStudio', 'auth_files.type_aistudio': 'AIStudio',
'auth_files.type_claude': 'Claude', 'auth_files.type_claude': 'Claude',
'auth_files.type_codex': 'Codex', 'auth_files.type_codex': 'Codex',
'auth_files.type_antigravity': 'Antigravity',
'auth_files.type_iflow': 'iFlow', 'auth_files.type_iflow': 'iFlow',
'auth_files.type_vertex': 'Vertex', 'auth_files.type_vertex': 'Vertex',
'auth_files.type_empty': 'Empty', 'auth_files.type_empty': 'Empty',
@@ -800,6 +990,40 @@ const i18n = {
'vertex_import.result_location': 'Region', 'vertex_import.result_location': 'Region',
'vertex_import.result_file': 'Persisted file', 'vertex_import.result_file': 'Persisted file',
// OAuth excluded models
'oauth_excluded.title': 'OAuth Excluded Models',
'oauth_excluded.description': 'Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.',
'oauth_excluded.add': 'Add Exclusion',
'oauth_excluded.add_title': 'Add provider exclusion',
'oauth_excluded.edit_title': 'Edit exclusions for {provider}',
'oauth_excluded.refresh': 'Refresh',
'oauth_excluded.refreshing': 'Refreshing...',
'oauth_excluded.provider_label': 'Provider',
'oauth_excluded.provider_auto': 'Follow current filter',
'oauth_excluded.provider_placeholder': 'e.g. gemini-cli',
'oauth_excluded.provider_hint': 'Defaults to the current filter; pick an existing provider or type a new name.',
'oauth_excluded.models_label': 'Models to exclude',
'oauth_excluded.models_placeholder': 'gpt-4.1-mini\n*-preview',
'oauth_excluded.models_hint': 'Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.',
'oauth_excluded.save': 'Save/Update',
'oauth_excluded.saving': 'Saving...',
'oauth_excluded.save_success': 'Excluded models updated',
'oauth_excluded.save_failed': 'Failed to update excluded models',
'oauth_excluded.delete': 'Delete Provider',
'oauth_excluded.delete_confirm': 'Delete the exclusion list for {provider}?',
'oauth_excluded.delete_success': 'Exclusion list removed',
'oauth_excluded.delete_failed': 'Failed to delete exclusion list',
'oauth_excluded.deleting': 'Deleting...',
'oauth_excluded.no_models': 'No excluded models',
'oauth_excluded.model_count': '{count} models excluded',
'oauth_excluded.list_empty_all': 'No exclusions yet—use “Add Exclusion” to create one.',
'oauth_excluded.list_empty_filtered': 'No exclusions in this scope; click “Add Exclusion” to add.',
'oauth_excluded.disconnected': 'Connect to the server to view exclusions',
'oauth_excluded.load_failed': 'Failed to load exclusion list',
'oauth_excluded.provider_required': 'Please enter a provider first',
'oauth_excluded.scope_all': 'Scope: All providers',
'oauth_excluded.scope_provider': 'Scope: {provider}',
// Codex OAuth // Codex OAuth
'auth_login.codex_oauth_title': 'Codex OAuth', 'auth_login.codex_oauth_title': 'Codex OAuth',
'auth_login.codex_oauth_button': 'Start Codex Login', 'auth_login.codex_oauth_button': 'Start Codex Login',
@@ -826,6 +1050,19 @@ const i18n = {
'auth_login.anthropic_oauth_start_error': 'Failed to start Anthropic OAuth:', 'auth_login.anthropic_oauth_start_error': 'Failed to start Anthropic OAuth:',
'auth_login.anthropic_oauth_polling_error': 'Failed to check authentication status:', 'auth_login.anthropic_oauth_polling_error': 'Failed to check authentication status:',
// Antigravity OAuth
'auth_login.antigravity_oauth_title': 'Antigravity OAuth',
'auth_login.antigravity_oauth_button': 'Start Antigravity Login',
'auth_login.antigravity_oauth_hint': 'Login to Antigravity service (Google account) through OAuth flow, automatically obtain and save authentication files.',
'auth_login.antigravity_oauth_url_label': 'Authorization URL:',
'auth_login.antigravity_open_link': 'Open Link',
'auth_login.antigravity_copy_link': 'Copy Link',
'auth_login.antigravity_oauth_status_waiting': 'Waiting for authentication...',
'auth_login.antigravity_oauth_status_success': 'Authentication successful!',
'auth_login.antigravity_oauth_status_error': 'Authentication failed:',
'auth_login.antigravity_oauth_start_error': 'Failed to start Antigravity OAuth:',
'auth_login.antigravity_oauth_polling_error': 'Failed to check authentication status:',
// Gemini CLI OAuth // Gemini CLI OAuth
'auth_login.gemini_cli_oauth_title': 'Gemini CLI OAuth', 'auth_login.gemini_cli_oauth_title': 'Gemini CLI OAuth',
'auth_login.gemini_cli_oauth_button': 'Start Gemini CLI Login', 'auth_login.gemini_cli_oauth_button': 'Start Gemini CLI Login',
@@ -868,6 +1105,20 @@ const i18n = {
'auth_login.iflow_oauth_status_error': 'Authentication failed:', 'auth_login.iflow_oauth_status_error': 'Authentication failed:',
'auth_login.iflow_oauth_start_error': 'Failed to start iFlow OAuth:', 'auth_login.iflow_oauth_start_error': 'Failed to start iFlow OAuth:',
'auth_login.iflow_oauth_polling_error': 'Failed to check authentication status:', 'auth_login.iflow_oauth_polling_error': 'Failed to check authentication status:',
'auth_login.iflow_cookie_title': 'iFlow Cookie Login',
'auth_login.iflow_cookie_label': 'Cookie Value:',
'auth_login.iflow_cookie_placeholder': 'Paste browser cookie, e.g. sessionid=...;',
'auth_login.iflow_cookie_hint': 'Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.',
'auth_login.iflow_cookie_button': 'Submit Cookie Login',
'auth_login.iflow_cookie_status_success': 'Cookie login succeeded and credentials are saved.',
'auth_login.iflow_cookie_status_error': 'Cookie login failed:',
'auth_login.iflow_cookie_start_error': 'Failed to submit cookie login:',
'auth_login.iflow_cookie_required': 'Please provide the Cookie value first.',
'auth_login.iflow_cookie_result_title': 'Cookie Login Result',
'auth_login.iflow_cookie_result_email': 'Account',
'auth_login.iflow_cookie_result_expired': 'Expires At',
'auth_login.iflow_cookie_result_path': 'Saved Path',
'auth_login.iflow_cookie_result_type': 'Type',
// Usage Statistics // Usage Statistics
'usage_stats.title': 'Usage Statistics', 'usage_stats.title': 'Usage Statistics',
@@ -875,6 +1126,10 @@ const i18n = {
'usage_stats.success_requests': 'Success Requests', 'usage_stats.success_requests': 'Success Requests',
'usage_stats.failed_requests': 'Failed Requests', 'usage_stats.failed_requests': 'Failed Requests',
'usage_stats.total_tokens': 'Total Tokens', 'usage_stats.total_tokens': 'Total Tokens',
'usage_stats.cached_tokens': 'Cached Tokens',
'usage_stats.reasoning_tokens': 'Reasoning Tokens',
'usage_stats.rpm_30m': 'RPM (last 30 min)',
'usage_stats.tpm_30m': 'TPM (last 30 min)',
'usage_stats.requests_trend': 'Request Trends', 'usage_stats.requests_trend': 'Request Trends',
'usage_stats.tokens_trend': 'Token Usage Trends', 'usage_stats.tokens_trend': 'Token Usage Trends',
'usage_stats.api_details': 'API Details', 'usage_stats.api_details': 'API Details',
@@ -884,7 +1139,18 @@ const i18n = {
'usage_stats.chart_line_label_1': 'Line 1', 'usage_stats.chart_line_label_1': 'Line 1',
'usage_stats.chart_line_label_2': 'Line 2', 'usage_stats.chart_line_label_2': 'Line 2',
'usage_stats.chart_line_label_3': 'Line 3', 'usage_stats.chart_line_label_3': 'Line 3',
'usage_stats.chart_line_label_4': 'Line 4',
'usage_stats.chart_line_label_5': 'Line 5',
'usage_stats.chart_line_label_6': 'Line 6',
'usage_stats.chart_line_label_7': 'Line 7',
'usage_stats.chart_line_label_8': 'Line 8',
'usage_stats.chart_line_label_9': 'Line 9',
'usage_stats.chart_line_hidden': 'Hide', 'usage_stats.chart_line_hidden': 'Hide',
'usage_stats.chart_line_actions_label': 'Lines to display',
'usage_stats.chart_line_add': 'Add line',
'usage_stats.chart_line_all': 'All',
'usage_stats.chart_line_delete': 'Delete line',
'usage_stats.chart_line_hint': 'Show up to 9 model lines at once',
'usage_stats.no_data': 'No Data Available', 'usage_stats.no_data': 'No Data Available',
'usage_stats.loading_error': 'Loading Failed', 'usage_stats.loading_error': 'Loading Failed',
'usage_stats.api_endpoint': 'API Endpoint', 'usage_stats.api_endpoint': 'API Endpoint',
@@ -892,6 +1158,25 @@ const i18n = {
'usage_stats.tokens_count': 'Token Count', 'usage_stats.tokens_count': 'Token Count',
'usage_stats.models': 'Model Statistics', 'usage_stats.models': 'Model Statistics',
'usage_stats.success_rate': 'Success Rate', 'usage_stats.success_rate': 'Success Rate',
'usage_stats.total_cost': 'Total Cost',
'usage_stats.total_cost_hint': 'Based on configured model pricing',
'usage_stats.model_price_title': 'Model Pricing',
'usage_stats.model_price_reset': 'Clear Prices',
'usage_stats.model_price_model_label': 'Model',
'usage_stats.model_price_select_placeholder': 'Choose a model',
'usage_stats.model_price_select_hint': 'Models come from usage details',
'usage_stats.model_price_prompt': 'Prompt price ($/1M tokens)',
'usage_stats.model_price_completion': 'Completion price ($/1M tokens)',
'usage_stats.model_price_save': 'Save Price',
'usage_stats.model_price_empty': 'No model prices set',
'usage_stats.model_price_model': 'Model',
'usage_stats.model_price_saved': 'Model price saved',
'usage_stats.model_price_model_required': 'Please choose a model to set pricing',
'usage_stats.cost_trend': 'Cost Overview',
'usage_stats.cost_axis_label': 'Cost ($)',
'usage_stats.cost_need_price': 'Set a model price to view cost stats',
'usage_stats.cost_need_usage': 'No usage data available to calculate cost',
'usage_stats.cost_no_data': 'No cost data yet',
'stats.success': 'Success', 'stats.success': 'Success',
'stats.failure': 'Failure', 'stats.failure': 'Failure',
@@ -900,6 +1185,15 @@ const i18n = {
'logs.refresh_button': 'Refresh Logs', 'logs.refresh_button': 'Refresh Logs',
'logs.clear_button': 'Clear Logs', 'logs.clear_button': 'Clear Logs',
'logs.download_button': 'Download Logs', 'logs.download_button': 'Download Logs',
'logs.error_log_button': 'Select Error Log',
'logs.error_logs_modal_title': 'Error Request Logs',
'logs.error_logs_description': 'Pick an error request log file to download (only generated when request logging is off).',
'logs.error_logs_empty': 'No error request log files found',
'logs.error_logs_load_error': 'Failed to load error log list',
'logs.error_logs_size': 'Size',
'logs.error_logs_modified': 'Last modified',
'logs.error_logs_download': 'Download',
'logs.error_log_download_success': 'Error log downloaded successfully',
'logs.empty_title': 'No Logs Available', 'logs.empty_title': 'No Logs Available',
'logs.empty_desc': 'When "Enable logging to file" is enabled, logs will be displayed here', 'logs.empty_desc': 'When "Enable logging to file" is enabled, logs will be displayed here',
'logs.log_content': 'Log Content', 'logs.log_content': 'Log Content',
@@ -911,6 +1205,9 @@ const i18n = {
'logs.auto_refresh': 'Auto Refresh', 'logs.auto_refresh': 'Auto Refresh',
'logs.auto_refresh_enabled': 'Auto refresh enabled', 'logs.auto_refresh_enabled': 'Auto refresh enabled',
'logs.auto_refresh_disabled': 'Auto refresh disabled', 'logs.auto_refresh_disabled': 'Auto refresh disabled',
'logs.search_placeholder': 'Search logs by content or keyword',
'logs.search_empty_title': 'No matching logs found',
'logs.search_empty_desc': 'Try a different keyword or clear the search filter.',
'logs.lines': 'lines', 'logs.lines': 'lines',
'logs.removed': 'Removed', 'logs.removed': 'Removed',
'logs.upgrade_required_title': 'Please Upgrade CLI Proxy API', 'logs.upgrade_required_title': 'Please Upgrade CLI Proxy API',
@@ -936,7 +1233,7 @@ const i18n = {
'config_management.editor_placeholder': 'key: value', 'config_management.editor_placeholder': 'key: value',
// System info // System info
'system_info.title': 'System Information', 'system_info.title': 'Management Center Info',
'system_info.connection_status_title': 'Connection Status', 'system_info.connection_status_title': 'Connection Status',
'system_info.api_status_label': 'API Status:', 'system_info.api_status_label': 'API Status:',
'system_info.config_status_label': 'Config Status:', 'system_info.config_status_label': 'Config Status:',
@@ -945,6 +1242,24 @@ const i18n = {
'system_info.real_time_data': 'Real-time Data', 'system_info.real_time_data': 'Real-time Data',
'system_info.not_loaded': 'Not Loaded', 'system_info.not_loaded': 'Not Loaded',
'system_info.seconds_ago': 'seconds ago', 'system_info.seconds_ago': 'seconds ago',
'system_info.models_title': 'Available Models',
'system_info.models_desc': 'Shows the /v1/models response and uses saved API keys for auth automatically.',
'system_info.models_loading': 'Loading available models...',
'system_info.models_empty': 'No models returned by /v1/models',
'system_info.models_error': 'Failed to load model list',
'system_info.models_count': '{count} available models',
'system_info.version_check_title': 'Update Check',
'system_info.version_check_desc': 'Call the /latest-version endpoint to compare with the server version and see if an update is available.',
'system_info.version_current_label': 'Current version',
'system_info.version_latest_label': 'Latest version',
'system_info.version_check_button': 'Check for updates',
'system_info.version_check_idle': 'Click to check for updates',
'system_info.version_checking': 'Checking for the latest version...',
'system_info.version_update_available': 'An update is available: {version}',
'system_info.version_is_latest': 'You are on the latest version',
'system_info.version_check_error': 'Update check failed',
'system_info.version_current_missing': 'Server version is unavailable; cannot compare',
'system_info.version_unknown': 'Unknown',
// Notification messages // Notification messages
'notification.debug_updated': 'Debug settings updated', 'notification.debug_updated': 'Debug settings updated',
@@ -979,6 +1294,9 @@ const i18n = {
'notification.openai_provider_updated': 'OpenAI provider updated successfully', 'notification.openai_provider_updated': 'OpenAI provider updated successfully',
'notification.openai_provider_deleted': 'OpenAI provider deleted successfully', 'notification.openai_provider_deleted': 'OpenAI provider deleted successfully',
'notification.openai_model_name_required': 'Model name is required', 'notification.openai_model_name_required': 'Model name is required',
'notification.openai_test_url_required': 'Please provide a valid Base URL before testing',
'notification.openai_test_key_required': 'Please add at least one API key before testing',
'notification.openai_test_model_required': 'Please select or enter a model to test',
'notification.data_refreshed': 'Data refreshed successfully', 'notification.data_refreshed': 'Data refreshed successfully',
'notification.connection_required': 'Please establish connection first', 'notification.connection_required': 'Please establish connection first',
'notification.refresh_failed': 'Refresh failed', 'notification.refresh_failed': 'Refresh failed',

View File

@@ -5,6 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="title.login">CLI Proxy API Management Center</title> <title data-i18n="title.login">CLI Proxy API Management Center</title>
<link rel="icon" type="image/x-icon" id="favicon-link">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
@@ -126,7 +127,12 @@
</button> </button>
<div class="top-navbar-brand"> <div class="top-navbar-brand">
<img id="site-logo" class="top-navbar-brand-logo" alt="Logo" style="display:none" /> <img id="site-logo" class="top-navbar-brand-logo" alt="Logo" style="display:none" />
<span class="top-navbar-brand-text" data-i18n="title.main">CLI Proxy API Management Center</span> <button class="top-navbar-brand-toggle expanded" id="brand-name-toggle" type="button" aria-label="展开标题">
<span class="brand-texts" id="brand-texts">
<span class="top-navbar-brand-text brand-text brand-text-full" data-i18n="title.main">CLI Proxy API Management Center</span>
<span class="top-navbar-brand-text brand-text brand-text-short" data-i18n="title.abbr">CPAMC</span>
</span>
</button>
</div> </div>
</div> </div>
<div class="top-navbar-actions"> <div class="top-navbar-actions">
@@ -182,7 +188,7 @@
<i class="fas fa-scroll"></i> <span data-i18n="nav.logs">日志查看</span> <i class="fas fa-scroll"></i> <span data-i18n="nav.logs">日志查看</span>
</a></li> </a></li>
<li data-i18n-tooltip="nav.system_info"><a href="#system-info" class="nav-item" data-section="system-info"> <li data-i18n-tooltip="nav.system_info"><a href="#system-info" class="nav-item" data-section="system-info">
<i class="fas fa-info-circle"></i> <span data-i18n="nav.system_info">系统信息</span> <i class="fas fa-info-circle"></i> <span data-i18n="nav.system_info">中心信息</span>
</a></li> </a></li>
</ul> </ul>
</nav> </nav>
@@ -502,6 +508,7 @@
<button class="filter-btn" data-type="aistudio" data-i18n-text="auth_files.filter_aistudio">AIStudio</button> <button class="filter-btn" data-type="aistudio" data-i18n-text="auth_files.filter_aistudio">AIStudio</button>
<button class="filter-btn" data-type="claude" data-i18n-text="auth_files.filter_claude">Claude</button> <button class="filter-btn" data-type="claude" data-i18n-text="auth_files.filter_claude">Claude</button>
<button class="filter-btn" data-type="codex" data-i18n-text="auth_files.filter_codex">Codex</button> <button class="filter-btn" data-type="codex" data-i18n-text="auth_files.filter_codex">Codex</button>
<button class="filter-btn" data-type="antigravity" data-i18n-text="auth_files.filter_antigravity">Antigravity</button>
<button class="filter-btn" data-type="iflow" data-i18n-text="auth_files.filter_iflow">iFlow</button> <button class="filter-btn" data-type="iflow" data-i18n-text="auth_files.filter_iflow">iFlow</button>
<button class="filter-btn" data-type="vertex" data-i18n-text="auth_files.filter_vertex">Vertex</button> <button class="filter-btn" data-type="vertex" data-i18n-text="auth_files.filter_vertex">Vertex</button>
<button class="filter-btn" data-type="empty" data-i18n-text="auth_files.filter_empty">Empty</button> <button class="filter-btn" data-type="empty" data-i18n-text="auth_files.filter_empty">Empty</button>
@@ -552,7 +559,32 @@
<i class="fas fa-chevron-right"></i> <i class="fas fa-chevron-right"></i>
</button> </button>
</div> </div>
<input type="file" id="auth-file-input" accept=".json" style="display: none;"> <input type="file" id="auth-file-input" accept=".json" multiple style="display: none;">
</div>
</div>
<!-- OAuth 排除列表 -->
<div class="card" id="oauth-excluded-card">
<div class="card-header card-header-with-filter">
<div class="header-left">
<h3><i class="fas fa-ban"></i> <span data-i18n="oauth_excluded.title">OAuth 排除列表</span></h3>
<div class="oauth-excluded-scope" id="oauth-excluded-scope"></div>
</div>
<div class="card-actions">
<button id="oauth-excluded-refresh" class="btn btn-secondary">
<i class="fas fa-sync-alt"></i> <span data-i18n="oauth_excluded.refresh">刷新</span>
</button>
<button id="oauth-excluded-add" class="btn btn-primary">
<i class="fas fa-plus"></i> <span data-i18n="oauth_excluded.add">新增排除</span>
</button>
</div>
</div>
<div class="card-content">
<p class="form-hint" data-i18n="oauth_excluded.description">为 OAuth/文件凭据配置模型黑名单,支持通配符。</p>
<div id="oauth-excluded-status" class="form-hint subtle"></div>
<div id="oauth-excluded-list" class="oauth-excluded-list oauth-excluded-grid provider-list">
<div class="loading-placeholder" data-i18n="common.loading">正在加载...</div>
</div>
</div> </div>
</div> </div>
@@ -627,6 +659,42 @@
</div> </div>
</div> </div>
<!-- Antigravity OAuth -->
<div class="card" id="antigravity-oauth-card">
<div class="card-header">
<h3><i class="fas fa-rocket"></i> <span
data-i18n="auth_login.antigravity_oauth_title">Antigravity OAuth</span></h3>
<button id="antigravity-oauth-btn" class="btn btn-primary">
<i class="fas fa-sign-in-alt"></i> <span
data-i18n="auth_login.antigravity_oauth_button">开始 Antigravity 登录</span>
</button>
</div>
<div class="card-content">
<p class="form-hint" style="margin-bottom: 20px;"
data-i18n="auth_login.antigravity_oauth_hint">
通过 OAuth 流程登录 AntigravityGoogle 账号)服务,自动获取并保存认证文件。
</p>
<div id="antigravity-oauth-content" style="display: none;">
<div class="form-group">
<label data-i18n="auth_login.antigravity_oauth_url_label">授权链接:</label>
<div class="input-group">
<input type="text" id="antigravity-oauth-url" readonly>
<button id="antigravity-open-link" class="btn btn-primary">
<i class="fas fa-external-link-alt"></i> <span
data-i18n="auth_login.antigravity_open_link">打开链接</span>
</button>
<button id="antigravity-copy-link" class="btn btn-secondary">
<i class="fas fa-copy"></i> <span
data-i18n="auth_login.antigravity_copy_link">复制链接</span>
</button>
</div>
</div>
<div id="antigravity-oauth-status" class="form-hint" style="margin-top: 10px;">
</div>
</div>
</div>
</div>
<!-- Gemini CLI OAuth --> <!-- Gemini CLI OAuth -->
<div class="card" id="gemini-cli-oauth-card"> <div class="card" id="gemini-cli-oauth-card">
<div class="card-header"> <div class="card-header">
@@ -741,6 +809,34 @@
<div id="iflow-oauth-status" class="form-hint" style="margin-top: 10px;"></div> <div id="iflow-oauth-status" class="form-hint" style="margin-top: 10px;"></div>
</div> </div>
</div> </div>
<div class="card-content" style="border-top: 1px solid var(--border-color); margin-top: 16px; padding-top: 16px;">
<h4 style="margin-bottom: 10px;" data-i18n="auth_login.iflow_cookie_title">iFlow Cookie 登录</h4>
<p class="form-hint" data-i18n="auth_login.iflow_cookie_hint">
直接提交 Cookie 完成登录并保存凭据,无需打开授权链接。
</p>
<div class="form-group">
<label for="iflow-cookie-input" data-i18n="auth_login.iflow_cookie_label">Cookie 内容:</label>
<textarea id="iflow-cookie-input" rows="3" data-i18n-placeholder="auth_login.iflow_cookie_placeholder" placeholder="粘贴浏览器中的 Cookie"></textarea>
</div>
<div class="form-actions">
<button id="iflow-cookie-submit" class="btn btn-primary">
<i class="fas fa-cookie-bite"></i> <span data-i18n="auth_login.iflow_cookie_button">提交 Cookie 登录</span>
</button>
</div>
<div id="iflow-cookie-status" class="form-hint" style="margin-top: 10px;"></div>
<div id="iflow-cookie-result" class="vertex-import-result" style="display: none;">
<div class="vertex-import-result-header">
<i class="fas fa-check-circle"></i>
<span data-i18n="auth_login.iflow_cookie_result_title">Cookie 登录结果</span>
</div>
<ul>
<li><span data-i18n="auth_login.iflow_cookie_result_email">账号</span>: <code id="iflow-cookie-result-email">-</code></li>
<li><span data-i18n="auth_login.iflow_cookie_result_expired">过期时间</span>: <code id="iflow-cookie-result-expired">-</code></li>
<li><span data-i18n="auth_login.iflow_cookie_result_path">保存路径</span>: <code id="iflow-cookie-result-path">-</code></li>
<li><span data-i18n="auth_login.iflow_cookie_result_type">类型</span>: <code id="iflow-cookie-result-type">-</code></li>
</ul>
</div>
</div>
</div> </div>
</section> </section>
@@ -749,8 +845,14 @@
<h2 data-i18n="logs.title">日志查看</h2> <h2 data-i18n="logs.title">日志查看</h2>
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header logs-header">
<h3><i class="fas fa-scroll"></i> <span data-i18n="logs.log_content">日志内容</span></h3> <div class="logs-header-main">
<h3><i class="fas fa-scroll"></i> <span data-i18n="logs.log_content">日志内容</span></h3>
<div class="logs-search">
<i class="fas fa-search"></i>
<input type="text" id="logs-search-input" aria-label="搜索日志" data-i18n-placeholder="logs.search_placeholder" placeholder="搜索日志...">
</div>
</div>
<div class="header-actions"> <div class="header-actions">
<div class="toggle-group" style="margin-right: 15px;"> <div class="toggle-group" style="margin-right: 15px;">
<label class="toggle-switch" style="margin-right: 5px;"> <label class="toggle-switch" style="margin-right: 5px;">
@@ -762,6 +864,9 @@
<button id="refresh-logs" class="btn btn-primary"> <button id="refresh-logs" class="btn btn-primary">
<i class="fas fa-sync-alt"></i> <span data-i18n="logs.refresh_button">刷新日志</span> <i class="fas fa-sync-alt"></i> <span data-i18n="logs.refresh_button">刷新日志</span>
</button> </button>
<button id="select-error-log" class="btn btn-secondary">
<i class="fas fa-file-circle-exclamation"></i> <span data-i18n="logs.error_log_button">选择错误日志</span>
</button>
<button id="download-logs" class="btn btn-secondary"> <button id="download-logs" class="btn btn-secondary">
<i class="fas fa-download"></i> <span data-i18n="logs.download_button">下载日志</span> <i class="fas fa-download"></i> <span data-i18n="logs.download_button">下载日志</span>
</button> </button>
@@ -776,6 +881,7 @@
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- 使用统计 --> <!-- 使用统计 -->
@@ -785,65 +891,215 @@
<!-- 概览统计卡片 --> <!-- 概览统计卡片 -->
<div class="stats-overview"> <div class="stats-overview">
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon"> <div class="stat-card-header">
<i class="fas fa-paper-plane"></i> <div class="stat-meta">
<div class="stat-label" data-i18n="usage_stats.total_requests">总请求数</div>
<div class="stat-number" id="total-requests">0</div>
<div class="stat-subtext stat-subtext-inline">
<span><span data-i18n="usage_stats.success_requests">成功请求</span> <span id="success-requests">0</span></span>
<span class="dot-divider"></span>
<span><span data-i18n="usage_stats.failed_requests">失败请求</span> <span id="failed-requests">0</span></span>
</div>
</div>
<div class="stat-icon">
<i class="fas fa-paper-plane"></i>
</div>
</div> </div>
<div class="stat-content"> <div class="stat-sparkline" aria-label="30分钟请求趋势">
<div class="stat-number" id="total-requests">0</div> <canvas id="requests-sparkline"></canvas>
<div class="stat-label" data-i18n="usage_stats.total_requests">总请求数</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon success"> <div class="stat-card-header">
<i class="fas fa-check-circle"></i> <div class="stat-meta">
<div class="stat-label" data-i18n="usage_stats.total_tokens">总Token数</div>
<div class="stat-number" id="total-tokens">0</div>
<div class="stat-subtext">
<span data-i18n="usage_stats.cached_tokens">缓存 Token 数</span>:
<span id="cached-tokens">0</span>
</div>
<div class="stat-subtext">
<span data-i18n="usage_stats.reasoning_tokens">思考 Token 数</span>:
<span id="reasoning-tokens">0</span>
</div>
</div>
<div class="stat-icon">
<i class="fas fa-coins"></i>
</div>
</div> </div>
<div class="stat-content"> <div class="stat-sparkline" aria-label="30分钟Token趋势">
<div class="stat-number" id="success-requests">0</div> <canvas id="tokens-sparkline"></canvas>
<div class="stat-label" data-i18n="usage_stats.success_requests">成功请求</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon error"> <div class="stat-card-header">
<i class="fas fa-exclamation-circle"></i> <div class="stat-meta">
<div class="stat-label" data-i18n="usage_stats.rpm_30m">RPM近30分钟</div>
<div class="stat-number" id="rpm-30m">0</div>
</div>
<div class="stat-icon">
<i class="fas fa-gauge-high"></i>
</div>
</div> </div>
<div class="stat-content"> <div class="stat-sparkline" aria-label="30分钟RPM趋势">
<div class="stat-number" id="failed-requests">0</div> <canvas id="rpm-sparkline"></canvas>
<div class="stat-label" data-i18n="usage_stats.failed_requests">失败请求</div>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon"> <div class="stat-card-header">
<i class="fas fa-coins"></i> <div class="stat-meta">
<div class="stat-label" data-i18n="usage_stats.tpm_30m">TPM近30分钟</div>
<div class="stat-number" id="tpm-30m">0</div>
</div>
<div class="stat-icon">
<i class="fas fa-stopwatch"></i>
</div>
</div> </div>
<div class="stat-content"> <div class="stat-sparkline" aria-label="30分钟TPM趋势">
<div class="stat-number" id="total-tokens">0</div> <canvas id="tpm-sparkline"></canvas>
<div class="stat-label" data-i18n="usage_stats.total_tokens">总Token数</div> </div>
</div>
<div class="stat-card cost-summary-card">
<div class="stat-card-header">
<div class="stat-meta">
<div class="stat-label" data-i18n="usage_stats.total_cost">总花费</div>
<div class="stat-number" id="total-cost">--</div>
<div class="stat-subtext" id="total-cost-hint" data-i18n="usage_stats.total_cost_hint">基于已设置的模型单价</div>
</div>
<div class="stat-icon">
<i class="fas fa-dollar-sign"></i>
</div>
</div>
<div class="stat-sparkline" aria-label="30分钟花费趋势">
<canvas id="cost-sparkline"></canvas>
</div> </div>
</div> </div>
</div> </div>
<!-- 图表曲线选择 --> <!-- 图表曲线选择 -->
<div class="usage-filter-bar"> <div class="usage-filter-bar" id="chart-line-bar">
<div class="usage-filter-group"> <div class="usage-filter-group usage-filter-actions">
<label data-i18n="usage_stats.chart_line_actions_label">曲线数量</label>
<div class="chart-line-actions">
<button type="button" class="btn btn-small" id="add-chart-line">
<i class="fas fa-plus"></i>
<span data-i18n="usage_stats.chart_line_add">增加曲线</span>
</button>
<span class="chart-line-count" id="chart-line-count">3/9</span>
</div>
<div class="chart-line-hint" data-i18n="usage_stats.chart_line_hint">最多显示 9 条模型曲线</div>
</div>
<div class="usage-filter-group chart-line-group" data-line-index="0">
<label for="chart-line-select-0" data-i18n="usage_stats.chart_line_label_1">曲线 1</label> <label for="chart-line-select-0" data-i18n="usage_stats.chart_line_label_1">曲线 1</label>
<select id="chart-line-select-0" class="model-filter-select chart-line-select" data-line-index="0" disabled> <div class="chart-line-control">
<option value="none" data-i18n="usage_stats.chart_line_hidden">不显示</option> <select id="chart-line-select-0" class="model-filter-select chart-line-select" data-line-index="0" disabled>
</select> <option value="all" data-i18n="usage_stats.chart_line_all">全部</option>
</select>
<button type="button" class="btn btn-small btn-danger chart-line-delete" data-line-index="0">
<i class="fas fa-trash"></i>
<span data-i18n="usage_stats.chart_line_delete">删除</span>
</button>
</div>
</div> </div>
<div class="usage-filter-group"> <div class="usage-filter-group chart-line-group" data-line-index="1">
<label for="chart-line-select-1" data-i18n="usage_stats.chart_line_label_2">曲线 2</label> <label for="chart-line-select-1" data-i18n="usage_stats.chart_line_label_2">曲线 2</label>
<select id="chart-line-select-1" class="model-filter-select chart-line-select" data-line-index="1" disabled> <div class="chart-line-control">
<option value="none" data-i18n="usage_stats.chart_line_hidden">不显示</option> <select id="chart-line-select-1" class="model-filter-select chart-line-select" data-line-index="1" disabled>
</select> <option value="all" data-i18n="usage_stats.chart_line_all">全部</option>
</select>
<button type="button" class="btn btn-small btn-danger chart-line-delete" data-line-index="1">
<i class="fas fa-trash"></i>
<span data-i18n="usage_stats.chart_line_delete">删除</span>
</button>
</div>
</div> </div>
<div class="usage-filter-group"> <div class="usage-filter-group chart-line-group" data-line-index="2">
<label for="chart-line-select-2" data-i18n="usage_stats.chart_line_label_3">曲线 3</label> <label for="chart-line-select-2" data-i18n="usage_stats.chart_line_label_3">曲线 3</label>
<select id="chart-line-select-2" class="model-filter-select chart-line-select" data-line-index="2" disabled> <div class="chart-line-control">
<option value="none" data-i18n="usage_stats.chart_line_hidden">不显示</option> <select id="chart-line-select-2" class="model-filter-select chart-line-select" data-line-index="2" disabled>
</select> <option value="all" data-i18n="usage_stats.chart_line_all">全部</option>
</select>
<button type="button" class="btn btn-small btn-danger chart-line-delete" data-line-index="2">
<i class="fas fa-trash"></i>
<span data-i18n="usage_stats.chart_line_delete">删除</span>
</button>
</div>
</div>
<div class="usage-filter-group chart-line-group chart-line-hidden" data-line-index="3">
<label for="chart-line-select-3" data-i18n="usage_stats.chart_line_label_4">曲线 4</label>
<div class="chart-line-control">
<select id="chart-line-select-3" class="model-filter-select chart-line-select" data-line-index="3" disabled>
<option value="all" data-i18n="usage_stats.chart_line_all">全部</option>
</select>
<button type="button" class="btn btn-small btn-danger chart-line-delete" data-line-index="3">
<i class="fas fa-trash"></i>
<span data-i18n="usage_stats.chart_line_delete">删除</span>
</button>
</div>
</div>
<div class="usage-filter-group chart-line-group chart-line-hidden" data-line-index="4">
<label for="chart-line-select-4" data-i18n="usage_stats.chart_line_label_5">曲线 5</label>
<div class="chart-line-control">
<select id="chart-line-select-4" class="model-filter-select chart-line-select" data-line-index="4" disabled>
<option value="all" data-i18n="usage_stats.chart_line_all">全部</option>
</select>
<button type="button" class="btn btn-small btn-danger chart-line-delete" data-line-index="4">
<i class="fas fa-trash"></i>
<span data-i18n="usage_stats.chart_line_delete">删除</span>
</button>
</div>
</div>
<div class="usage-filter-group chart-line-group chart-line-hidden" data-line-index="5">
<label for="chart-line-select-5" data-i18n="usage_stats.chart_line_label_6">曲线 6</label>
<div class="chart-line-control">
<select id="chart-line-select-5" class="model-filter-select chart-line-select" data-line-index="5" disabled>
<option value="all" data-i18n="usage_stats.chart_line_all">全部</option>
</select>
<button type="button" class="btn btn-small btn-danger chart-line-delete" data-line-index="5">
<i class="fas fa-trash"></i>
<span data-i18n="usage_stats.chart_line_delete">删除</span>
</button>
</div>
</div>
<div class="usage-filter-group chart-line-group chart-line-hidden" data-line-index="6">
<label for="chart-line-select-6" data-i18n="usage_stats.chart_line_label_7">曲线 7</label>
<div class="chart-line-control">
<select id="chart-line-select-6" class="model-filter-select chart-line-select" data-line-index="6" disabled>
<option value="all" data-i18n="usage_stats.chart_line_all">全部</option>
</select>
<button type="button" class="btn btn-small btn-danger chart-line-delete" data-line-index="6">
<i class="fas fa-trash"></i>
<span data-i18n="usage_stats.chart_line_delete">删除</span>
</button>
</div>
</div>
<div class="usage-filter-group chart-line-group chart-line-hidden" data-line-index="7">
<label for="chart-line-select-7" data-i18n="usage_stats.chart_line_label_8">曲线 8</label>
<div class="chart-line-control">
<select id="chart-line-select-7" class="model-filter-select chart-line-select" data-line-index="7" disabled>
<option value="all" data-i18n="usage_stats.chart_line_all">全部</option>
</select>
<button type="button" class="btn btn-small btn-danger chart-line-delete" data-line-index="7">
<i class="fas fa-trash"></i>
<span data-i18n="usage_stats.chart_line_delete">删除</span>
</button>
</div>
</div>
<div class="usage-filter-group chart-line-group chart-line-hidden" data-line-index="8">
<label for="chart-line-select-8" data-i18n="usage_stats.chart_line_label_9">曲线 9</label>
<div class="chart-line-control">
<select id="chart-line-select-8" class="model-filter-select chart-line-select" data-line-index="8" disabled>
<option value="all" data-i18n="usage_stats.chart_line_all">全部</option>
</select>
<button type="button" class="btn btn-small btn-danger chart-line-delete" data-line-index="8">
<i class="fas fa-trash"></i>
<span data-i18n="usage_stats.chart_line_delete">删除</span>
</button>
</div>
</div> </div>
</div> </div>
@@ -891,6 +1147,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card chart-card cost-chart-card">
<div class="card-header">
<h3><i class="fas fa-sack-dollar"></i> <span data-i18n="usage_stats.cost_trend">花费统计</span></h3>
<div class="chart-controls">
<button class="btn btn-small" data-period="hour" id="cost-hour-btn">
<span data-i18n="usage_stats.by_hour">按小时</span>
</button>
<button class="btn btn-small active" data-period="day" id="cost-day-btn">
<span data-i18n="usage_stats.by_day">按天</span>
</button>
</div>
</div>
<div class="card-content">
<div class="chart-container">
<canvas id="cost-chart"></canvas>
<div id="cost-chart-placeholder" class="chart-placeholder" data-i18n="usage_stats.cost_need_price">请先设置模型价格</div>
</div>
</div>
</div>
</div> </div>
<!-- API详细统计 --> <!-- API详细统计 -->
@@ -908,6 +1184,46 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card cost-config-card">
<div class="card-header">
<h3><i class="fas fa-tags"></i> <span data-i18n="usage_stats.model_price_title">模型价格</span></h3>
<div class="card-actions">
<button type="button" id="reset-model-prices" class="btn btn-secondary">
<i class="fas fa-rotate-left"></i> <span data-i18n="usage_stats.model_price_reset">清除价格</span>
</button>
</div>
</div>
<div class="card-content">
<form id="model-price-form" class="model-price-form">
<div class="form-group">
<label for="model-price-model-select" data-i18n="usage_stats.model_price_model_label">选择模型</label>
<select id="model-price-model-select" class="model-filter-select">
<option value="" data-i18n="usage_stats.model_price_select_placeholder">选择模型</option>
</select>
<p class="form-hint" data-i18n="usage_stats.model_price_select_hint">模型列表来自使用统计</p>
</div>
<div class="price-input-grid">
<div class="form-group">
<label for="model-price-prompt" data-i18n="usage_stats.model_price_prompt">提示价格 ($/1M tokens)</label>
<input type="number" step="0.0001" min="0" id="model-price-prompt" placeholder="0.0000">
</div>
<div class="form-group">
<label for="model-price-completion" data-i18n="usage_stats.model_price_completion">补全价格 ($/1M tokens)</label>
<input type="number" step="0.0001" min="0" id="model-price-completion" placeholder="0.0000">
</div>
</div>
<div class="price-form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> <span data-i18n="usage_stats.model_price_save">保存价格</span>
</button>
</div>
</form>
<div class="model-price-list" id="model-price-list">
<div class="loading-placeholder" data-i18n="common.loading">正在加载...</div>
</div>
</div>
</div>
</section> </section>
<!-- 配置管理 --> <!-- 配置管理 -->
@@ -936,9 +1252,23 @@
</div> </div>
</section> </section>
<!-- 系统信息 --> <!-- 管理中心信息 -->
<section id="system-info" class="content-section"> <section id="system-info" class="content-section">
<h2 data-i18n="system_info.title">系统信息</h2> <h2 data-i18n="system_info.title">管理中心信息</h2>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-layer-group"></i> <span data-i18n="system_info.models_title">可用模型列表</span></h3>
<button type="button" id="available-models-refresh" class="btn btn-secondary">
<i class="fas fa-sync-alt"></i> <span data-i18n="common.refresh">刷新</span>
</button>
</div>
<div class="card-content">
<p class="form-hint" data-i18n="system_info.models_desc">展示当前服务返回的 /v1/models 列表(使用服务器保存的 API Key 自动鉴权)。</p>
<div id="available-models-status" class="available-models-status" data-i18n="common.loading">加载中...</div>
<div id="available-models-list" class="available-models-list"></div>
</div>
</div>
<!-- 连接信息卡片 --> <!-- 连接信息卡片 -->
<div class="card"> <div class="card">
@@ -996,6 +1326,31 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card">
<div class="card-header">
<h3><i class="fas fa-arrows-rotate"></i> <span data-i18n="system_info.version_check_title">版本检查</span></h3>
</div>
<div class="card-content version-check">
<p class="form-hint" data-i18n="system_info.version_check_desc">调用 /latest-version 接口比对服务器版本,提示是否有可用更新。</p>
<div class="version-check-rows">
<div class="version-check-row">
<span class="status-label" data-i18n="system_info.version_current_label">当前版本</span>
<span id="version-check-current" class="version-check-value">-</span>
</div>
<div class="version-check-row">
<span class="status-label" data-i18n="system_info.version_latest_label">最新版本</span>
<span id="version-check-latest" class="version-check-value">-</span>
</div>
</div>
<div class="version-check-actions">
<button type="button" id="version-check-btn" class="btn btn-primary">
<i class="fas fa-search"></i> <span data-i18n="system_info.version_check_button">检查更新</span>
</button>
<span id="version-check-result" class="version-check-result" data-i18n="system_info.version_check_idle">点击检查更新</span>
</div>
</div>
</div>
</section> </section>
</div> </div>
<!-- /内容区域 --> <!-- /内容区域 -->

87
src/core/api-client.js Normal file
View File

@@ -0,0 +1,87 @@
// API 客户端:负责规范化基础地址、构造完整 URL、发送请求并回传版本信息
export class ApiClient {
constructor({ apiBase = '', managementKey = '', onVersionUpdate = null } = {}) {
this.apiBase = '';
this.apiUrl = '';
this.managementKey = managementKey || '';
this.onVersionUpdate = onVersionUpdate;
this.setApiBase(apiBase);
}
buildHeaders(options = {}) {
const customHeaders = options.headers || {};
const headers = {
'Authorization': `Bearer ${this.managementKey}`,
...customHeaders
};
const hasContentType = Object.keys(headers).some(key => key.toLowerCase() === 'content-type');
const body = options.body;
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
if (!hasContentType && !isFormData) {
headers['Content-Type'] = 'application/json';
}
return headers;
}
normalizeBase(input) {
let base = (input || '').trim();
if (!base) return '';
base = base.replace(/\/?v0\/management\/?$/i, '');
base = base.replace(/\/+$/i, '');
if (!/^https?:\/\//i.test(base)) {
base = 'http://' + base;
}
return base;
}
computeApiUrl(base) {
const normalized = this.normalizeBase(base);
if (!normalized) return '';
return normalized.replace(/\/$/, '') + '/v0/management';
}
setApiBase(newBase) {
this.apiBase = this.normalizeBase(newBase);
this.apiUrl = this.computeApiUrl(this.apiBase);
return this.apiUrl;
}
setManagementKey(key) {
this.managementKey = key || '';
}
async request(endpoint, options = {}) {
const url = `${this.apiUrl}${endpoint}`;
const headers = this.buildHeaders(options);
const response = await fetch(url, {
...options,
headers
});
if (typeof this.onVersionUpdate === 'function') {
this.onVersionUpdate(response.headers);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return await response.json();
}
// 返回原始 Response供下载/自定义解析使用
async requestRaw(endpoint, options = {}) {
const url = `${this.apiUrl}${endpoint}`;
const headers = this.buildHeaders(options);
const response = await fetch(url, {
...options,
headers
});
if (typeof this.onVersionUpdate === 'function') {
this.onVersionUpdate(response.headers);
}
return response;
}
}

View File

@@ -0,0 +1,70 @@
// 配置缓存服务:负责分段/全量读取配置与缓存控制,不涉及任何 DOM
export class ConfigService {
constructor({ apiClient, cacheExpiry }) {
this.apiClient = apiClient;
this.cacheExpiry = cacheExpiry;
this.cache = {};
this.cacheTimestamps = {};
}
isCacheValid(section = null) {
if (section) {
if (!(section in this.cache) || !(section in this.cacheTimestamps)) {
return false;
}
return (Date.now() - this.cacheTimestamps[section]) < this.cacheExpiry;
}
if (!this.cache['__full__'] || !this.cacheTimestamps['__full__']) {
return false;
}
return (Date.now() - this.cacheTimestamps['__full__']) < this.cacheExpiry;
}
clearCache(section = null) {
if (section) {
delete this.cache[section];
delete this.cacheTimestamps[section];
if (this.cache['__full__']) {
delete this.cache['__full__'][section];
}
return;
}
Object.keys(this.cache).forEach(key => delete this.cache[key]);
Object.keys(this.cacheTimestamps).forEach(key => delete this.cacheTimestamps[key]);
}
async getConfig(section = null, forceRefresh = false) {
const now = Date.now();
if (section && !forceRefresh && this.isCacheValid(section)) {
return this.cache[section];
}
if (!section && !forceRefresh && this.isCacheValid()) {
return this.cache['__full__'];
}
const config = await this.apiClient.request('/config');
if (section) {
this.cache[section] = config[section];
this.cacheTimestamps[section] = now;
if (this.cache['__full__']) {
this.cache['__full__'][section] = config[section];
} else {
this.cache['__full__'] = config;
this.cacheTimestamps['__full__'] = now;
}
return config[section];
}
this.cache['__full__'] = config;
this.cacheTimestamps['__full__'] = now;
Object.keys(config).forEach(key => {
this.cache[key] = config[key];
this.cacheTimestamps[key] = now;
});
return config;
}
}

View File

@@ -3,37 +3,63 @@
import { STATUS_UPDATE_INTERVAL_MS, DEFAULT_API_PORT } from '../utils/constants.js'; import { STATUS_UPDATE_INTERVAL_MS, DEFAULT_API_PORT } from '../utils/constants.js';
import { secureStorage } from '../utils/secure-storage.js'; import { secureStorage } from '../utils/secure-storage.js';
import { normalizeModelList, classifyModels } from '../utils/models.js';
const buildModelsEndpoint = (baseUrl) => {
if (!baseUrl) return '';
const trimmed = String(baseUrl).trim().replace(/\/+$/g, '');
if (!trimmed) return '';
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
};
const normalizeApiKeyList = (input) => {
if (!Array.isArray(input)) return [];
const seen = new Set();
const keys = [];
input.forEach(item => {
const value = typeof item === 'string'
? item
: (item && item['api-key'] ? item['api-key'] : '');
const trimmed = String(value || '').trim();
if (!trimmed || seen.has(trimmed)) {
return;
}
seen.add(trimmed);
keys.push(trimmed);
});
return keys;
};
export const connectionModule = { export const connectionModule = {
// 规范化基础地址,移除尾部斜杠与 /v0/management // 规范化基础地址,移除尾部斜杠与 /v0/management
normalizeBase(input) { normalizeBase(input) {
let base = (input || '').trim(); return this.apiClient.normalizeBase(input);
if (!base) return '';
// 若用户粘贴了完整地址,剥离后缀
base = base.replace(/\/?v0\/management\/?$/i, '');
base = base.replace(/\/+$/i, '');
// 自动补 http://
if (!/^https?:\/\//i.test(base)) {
base = 'http://' + base;
}
return base;
}, },
// 由基础地址生成完整管理 API 地址 // 由基础地址生成完整管理 API 地址
computeApiUrl(base) { computeApiUrl(base) {
const b = this.normalizeBase(base); return this.apiClient.computeApiUrl(base);
if (!b) return '';
return b.replace(/\/$/, '') + '/v0/management';
}, },
setApiBase(newBase) { setApiBase(newBase) {
this.apiBase = this.normalizeBase(newBase); this.apiClient.setApiBase(newBase);
this.apiUrl = this.computeApiUrl(this.apiBase); this.apiBase = this.apiClient.apiBase;
this.apiUrl = this.apiClient.apiUrl;
secureStorage.setItem('apiBase', this.apiBase); secureStorage.setItem('apiBase', this.apiBase);
secureStorage.setItem('apiUrl', this.apiUrl); // 兼容旧字段 secureStorage.setItem('apiUrl', this.apiUrl); // 兼容旧字段
this.updateLoginConnectionInfo(); this.updateLoginConnectionInfo();
}, },
setManagementKey(key, { persist = true } = {}) {
this.managementKey = key || '';
this.apiClient.setManagementKey(this.managementKey);
if (persist) {
secureStorage.setItem('managementKey', this.managementKey);
}
},
// 加载设置(简化版,仅加载内部状态) // 加载设置(简化版,仅加载内部状态)
loadSettings() { loadSettings() {
secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']); secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
@@ -51,9 +77,7 @@ export const connectionModule = {
this.setApiBase(this.detectApiBaseFromLocation()); this.setApiBase(this.detectApiBaseFromLocation());
} }
if (savedKey) { this.setManagementKey(savedKey || '', { persist: false });
this.managementKey = savedKey;
}
this.updateLoginConnectionInfo(); this.updateLoginConnectionInfo();
}, },
@@ -105,6 +129,56 @@ export const connectionModule = {
} }
}, },
renderVersionCheckStatus({
currentVersion,
latestVersion,
message,
status
} = {}) {
const resolvedCurrent = (typeof currentVersion === 'undefined' || currentVersion === null)
? this.serverVersion
: currentVersion;
const resolvedLatest = (typeof latestVersion === 'undefined' || latestVersion === null)
? this.latestVersion
: latestVersion;
const resolvedMessage = (typeof message === 'undefined' || message === null)
? (this.versionCheckMessage || i18n.t('system_info.version_check_idle'))
: message;
const resolvedStatus = status || this.versionCheckStatus || 'muted';
this.latestVersion = resolvedLatest || null;
this.versionCheckMessage = resolvedMessage;
this.versionCheckStatus = resolvedStatus;
const currentEl = document.getElementById('version-check-current');
if (currentEl) {
currentEl.textContent = resolvedCurrent || i18n.t('system_info.version_unknown');
}
const latestEl = document.getElementById('version-check-latest');
if (latestEl) {
latestEl.textContent = resolvedLatest || '-';
}
const resultEl = document.getElementById('version-check-result');
if (resultEl) {
resultEl.textContent = resolvedMessage;
resultEl.className = `version-check-result ${resolvedStatus}`.trim();
}
},
resetVersionCheckStatus() {
this.latestVersion = null;
this.versionCheckMessage = i18n.t('system_info.version_check_idle');
this.versionCheckStatus = 'muted';
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion: this.latestVersion,
message: this.versionCheckMessage,
status: this.versionCheckStatus
});
},
// 渲染底栏的版本与构建时间 // 渲染底栏的版本与构建时间
renderVersionInfo() { renderVersionInfo() {
const versionEl = document.getElementById('api-version'); const versionEl = document.getElementById('api-version');
@@ -125,12 +199,20 @@ export const connectionModule = {
const domVersion = this.readUiVersionFromDom(); const domVersion = this.readUiVersionFromDom();
uiVersionEl.textContent = this.uiVersion || domVersion || 'v0.0.0-dev'; uiVersionEl.textContent = this.uiVersion || domVersion || 'v0.0.0-dev';
} }
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion: this.latestVersion,
message: this.versionCheckMessage,
status: this.versionCheckStatus
});
}, },
// 清空版本信息(例如登出时) // 清空版本信息(例如登出时)
resetVersionInfo() { resetVersionInfo() {
this.serverVersion = null; this.serverVersion = null;
this.serverBuildDate = null; this.serverBuildDate = null;
this.resetVersionCheckStatus();
this.renderVersionInfo(); this.renderVersionInfo();
}, },
@@ -147,35 +229,301 @@ export const connectionModule = {
return buildDate; return buildDate;
}, },
// API 请求方法 parseVersionSegments(version) {
async makeRequest(endpoint, options = {}) { if (!version || typeof version !== 'string') return null;
const url = `${this.apiUrl}${endpoint}`; const cleaned = version.trim().replace(/^v/i, '');
const headers = { if (!cleaned) return null;
'Authorization': `Bearer ${this.managementKey}`, const parts = cleaned.split(/[^0-9]+/).filter(Boolean).map(segment => {
'Content-Type': 'application/json', const parsed = parseInt(segment, 10);
...options.headers return Number.isFinite(parsed) ? parsed : 0;
}; });
return parts.length ? parts : null;
},
compareVersions(latestVersion, currentVersion) {
const latestParts = this.parseVersionSegments(latestVersion);
const currentParts = this.parseVersionSegments(currentVersion);
if (!latestParts || !currentParts) {
return null;
}
const length = Math.max(latestParts.length, currentParts.length);
for (let i = 0; i < length; i++) {
const latest = latestParts[i] || 0;
const current = currentParts[i] || 0;
if (latest > current) return 1;
if (latest < current) return -1;
}
return 0;
},
async checkLatestVersion() {
if (!this.isConnected) {
const message = i18n.t('notification.connection_required');
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion: this.latestVersion,
message,
status: 'warning'
});
this.showNotification(message, 'error');
return;
}
const button = document.getElementById('version-check-btn');
const originalLabel = button ? button.innerHTML : '';
if (button) {
button.disabled = true;
button.innerHTML = `<div class="loading"></div> ${i18n.t('system_info.version_checking')}`;
}
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion: this.latestVersion,
message: i18n.t('system_info.version_checking'),
status: 'info'
});
try { try {
const response = await fetch(url, { const data = await this.makeRequest('/latest-version');
...options, const latestVersion = data?.['latest-version'] || data?.latest_version || '';
headers const latestParts = this.parseVersionSegments(latestVersion);
}); const currentParts = this.parseVersionSegments(this.serverVersion);
const comparison = (latestParts && currentParts)
? this.compareVersions(latestVersion, this.serverVersion)
: null;
let messageKey = 'system_info.version_check_error';
let statusClass = 'error';
this.updateVersionFromHeaders(response.headers); if (!latestParts) {
messageKey = 'system_info.version_check_error';
if (!response.ok) { } else if (!currentParts) {
const errorData = await response.json().catch(() => ({})); messageKey = 'system_info.version_current_missing';
throw new Error(errorData.error || `HTTP ${response.status}`); statusClass = 'warning';
} else if (comparison > 0) {
messageKey = 'system_info.version_update_available';
statusClass = 'warning';
} else {
messageKey = 'system_info.version_is_latest';
statusClass = 'success';
} }
return await response.json(); const message = i18n.t(messageKey, latestVersion ? { version: latestVersion } : undefined);
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion,
message,
status: statusClass
});
if (latestVersion && comparison !== null) {
const notifyKey = comparison > 0
? 'system_info.version_update_available'
: 'system_info.version_is_latest';
const notifyType = comparison > 0 ? 'warning' : 'success';
this.showNotification(i18n.t(notifyKey, { version: latestVersion }), notifyType);
}
} catch (error) {
const message = `${i18n.t('system_info.version_check_error')}: ${error.message}`;
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion: this.latestVersion,
message,
status: 'error'
});
this.showNotification(message, 'error');
} finally {
if (button) {
button.disabled = false;
button.innerHTML = originalLabel;
}
}
},
// API 请求方法
async makeRequest(endpoint, options = {}) {
try {
return await this.apiClient.request(endpoint, options);
} catch (error) { } catch (error) {
console.error('API请求失败:', error); console.error('API请求失败:', error);
throw error; throw error;
} }
}, },
buildAvailableModelsEndpoint() {
return buildModelsEndpoint(this.apiBase || this.apiClient?.apiBase || '');
},
setAvailableModelsStatus(message = '', type = 'info') {
const statusEl = document.getElementById('available-models-status');
if (!statusEl) return;
statusEl.textContent = message || '';
statusEl.className = `available-models-status ${type}`;
},
renderAvailableModels(models = []) {
const listEl = document.getElementById('available-models-list');
if (!listEl) return;
if (!models.length) {
listEl.innerHTML = `
<div class="available-models-empty">
<i class="fas fa-inbox"></i>
<span>${i18n.t('system_info.models_empty')}</span>
</div>
`;
return;
}
const language = (i18n?.currentLanguage || '').toLowerCase();
const otherLabel = language.startsWith('zh') ? '其他' : 'Other';
const groups = classifyModels(models, { otherLabel });
const groupHtml = groups.map(group => {
const pills = group.items.map(model => {
const name = this.escapeHtml(model.name || '');
const alias = model.alias ? `<span class="model-alias">${this.escapeHtml(model.alias)}</span>` : '';
const description = model.description ? this.escapeHtml(model.description) : '';
const titleAttr = description ? ` title="${description}"` : '';
return `
<span class="provider-model-tag available-model-tag"${titleAttr}>
<span class="model-name">${name}</span>
${alias}
</span>
`;
}).join('');
const label = this.escapeHtml(group.label || group.id || '');
return `
<div class="available-model-group">
<div class="available-model-group-header">
<div class="available-model-group-title">
<span class="available-model-group-label">${label}</span>
<span class="available-model-group-count">${group.items.length}</span>
</div>
</div>
<div class="available-model-group-body">
${pills}
</div>
</div>
`;
}).join('');
listEl.innerHTML = groupHtml;
},
clearAvailableModels(messageKey = 'system_info.models_empty') {
this.availableModels = [];
this.availableModelApiKeysCache = null;
const listEl = document.getElementById('available-models-list');
if (listEl) {
listEl.innerHTML = '';
}
this.setAvailableModelsStatus(i18n.t(messageKey), 'warning');
},
async resolveApiKeysForModels({ config = null, forceRefresh = false } = {}) {
if (!forceRefresh && Array.isArray(this.availableModelApiKeysCache) && this.availableModelApiKeysCache.length) {
return this.availableModelApiKeysCache;
}
const configKeys = normalizeApiKeyList(config?.['api-keys'] || this.configCache?.['api-keys']);
if (configKeys.length) {
this.availableModelApiKeysCache = configKeys;
return configKeys;
}
try {
const data = await this.makeRequest('/api-keys');
const keys = normalizeApiKeyList(data?.['api-keys']);
if (keys.length) {
this.availableModelApiKeysCache = keys;
}
return keys;
} catch (error) {
console.warn('自动获取 API Key 失败:', error);
return [];
}
},
async loadAvailableModels({ config = null, forceRefresh = false } = {}) {
const listEl = document.getElementById('available-models-list');
const statusEl = document.getElementById('available-models-status');
if (!listEl || !statusEl) {
return;
}
if (!this.isConnected) {
this.setAvailableModelsStatus(i18n.t('common.disconnected'), 'warning');
listEl.innerHTML = '';
return;
}
const endpoint = this.buildAvailableModelsEndpoint();
if (!endpoint) {
this.setAvailableModelsStatus(i18n.t('system_info.models_error'), 'error');
listEl.innerHTML = `
<div class="available-models-empty">
<i class="fas fa-exclamation-circle"></i>
<span>${i18n.t('login.error_invalid')}</span>
</div>
`;
return;
}
this.availableModelsLoading = true;
this.setAvailableModelsStatus(i18n.t('system_info.models_loading'), 'info');
listEl.innerHTML = '<div class="available-models-placeholder"><i class="fas fa-spinner fa-spin"></i></div>';
try {
const headers = {};
const keys = await this.resolveApiKeysForModels({ config, forceRefresh });
if (keys.length) {
headers.Authorization = `Bearer ${keys[0]}`;
}
const response = await fetch(endpoint, { headers });
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
let data;
try {
data = await response.json();
} catch (err) {
const text = await response.text();
throw new Error(text || err.message || 'Invalid JSON');
}
const models = normalizeModelList(data, { dedupe: true });
this.availableModels = models;
if (!models.length) {
this.setAvailableModelsStatus(i18n.t('system_info.models_empty'), 'warning');
this.renderAvailableModels([]);
return;
}
this.setAvailableModelsStatus(i18n.t('system_info.models_count', { count: models.length }), 'success');
this.renderAvailableModels(models);
} catch (error) {
console.error('加载可用模型失败:', error);
this.availableModels = [];
this.setAvailableModelsStatus(`${i18n.t('system_info.models_error')}: ${error.message}`, 'error');
listEl.innerHTML = `
<div class="available-models-empty">
<i class="fas fa-exclamation-circle"></i>
<span>${this.escapeHtml(error.message || '')}</span>
</div>
`;
} finally {
this.availableModelsLoading = false;
}
},
// 测试连接(简化版,用于内部调用) // 测试连接(简化版,用于内部调用)
async testConnection() { async testConnection() {
try { try {
@@ -226,6 +574,11 @@ export const connectionModule = {
apiStatus.textContent = i18n.t('common.disconnected'); apiStatus.textContent = i18n.t('common.disconnected');
configStatus.textContent = i18n.t('system_info.not_loaded'); configStatus.textContent = i18n.t('system_info.not_loaded');
configStatus.style.color = '#6b7280'; configStatus.style.color = '#6b7280';
this.setAvailableModelsStatus(i18n.t('common.disconnected'), 'warning');
const modelsList = document.getElementById('available-models-list');
if (modelsList) {
modelsList.innerHTML = '';
}
} }
lastUpdate.textContent = new Date().toLocaleString('zh-CN'); lastUpdate.textContent = new Date().toLocaleString('zh-CN');
@@ -236,6 +589,17 @@ export const connectionModule = {
// 更新连接信息显示 // 更新连接信息显示
this.updateConnectionInfo(); this.updateConnectionInfo();
if (this.events && typeof this.events.emit === 'function') {
const shouldEmit = this.lastConnectionStatusEmitted !== this.isConnected;
if (shouldEmit) {
this.events.emit('connection:status-changed', {
isConnected: this.isConnected,
apiBase: this.apiBase
});
this.lastConnectionStatusEmitted = this.isConnected;
}
}
}, },
// 检查连接状态 // 检查连接状态
@@ -270,66 +634,15 @@ export const connectionModule = {
// 检查缓存是否有效 // 检查缓存是否有效
isCacheValid(section = null) { isCacheValid(section = null) {
if (section) { return this.configService.isCacheValid(section);
// 检查特定配置段的缓存
// 注意:配置值可能是 false、0、'' 等 falsy 值,不能用 ! 判断
if (!(section in this.configCache) || !(section in this.cacheTimestamps)) {
return false;
}
return (Date.now() - this.cacheTimestamps[section]) < this.cacheExpiry;
}
// 检查全局缓存(兼容旧代码)
if (!this.configCache['__full__'] || !this.cacheTimestamps['__full__']) {
return false;
}
return (Date.now() - this.cacheTimestamps['__full__']) < this.cacheExpiry;
}, },
// 获取配置(优先使用缓存,支持按段获取) // 获取配置(优先使用缓存,支持按段获取)
async getConfig(section = null, forceRefresh = false) { async getConfig(section = null, forceRefresh = false) {
const now = Date.now();
// 如果请求特定配置段且该段缓存有效
if (section && !forceRefresh && this.isCacheValid(section)) {
this.updateConnectionStatus();
return this.configCache[section];
}
// 如果请求全部配置且全局缓存有效
if (!section && !forceRefresh && this.isCacheValid()) {
this.updateConnectionStatus();
return this.configCache['__full__'];
}
try { try {
const config = await this.makeRequest('/config'); const config = await this.configService.getConfig(section, forceRefresh);
this.configCache = this.configService.cache;
if (section) { this.cacheTimestamps = this.configService.cacheTimestamps;
// 缓存特定配置段
this.configCache[section] = config[section];
this.cacheTimestamps[section] = now;
// 同时更新全局缓存中的这一段
if (this.configCache['__full__']) {
this.configCache['__full__'][section] = config[section];
} else {
// 如果全局缓存不存在,也创建它
this.configCache['__full__'] = config;
this.cacheTimestamps['__full__'] = now;
}
this.updateConnectionStatus();
return config[section];
}
// 缓存全部配置
this.configCache['__full__'] = config;
this.cacheTimestamps['__full__'] = now;
// 同时缓存各个配置段
Object.keys(config).forEach(key => {
this.configCache[key] = config[key];
this.cacheTimestamps[key] = now;
});
this.updateConnectionStatus(); this.updateConnectionStatus();
return config; return config;
} catch (error) { } catch (error) {
@@ -340,19 +653,15 @@ export const connectionModule = {
// 清除缓存(支持清除特定配置段) // 清除缓存(支持清除特定配置段)
clearCache(section = null) { clearCache(section = null) {
if (section) { this.configService.clearCache(section);
// 清除特定配置段的缓存 this.configCache = this.configService.cache;
delete this.configCache[section]; this.cacheTimestamps = this.configService.cacheTimestamps;
delete this.cacheTimestamps[section]; if (!section || section === 'api-keys') {
// 同时清除全局缓存中的这一段 this.availableModelApiKeysCache = null;
if (this.configCache['__full__']) { }
delete this.configCache['__full__'][section]; if (!section) {
}
} else {
// 清除所有缓存
this.configCache = {};
this.cacheTimestamps = {};
this.configYamlCache = ''; this.configYamlCache = '';
this.availableModels = [];
} }
}, },
@@ -400,15 +709,16 @@ export const connectionModule = {
// 从配置中提取并设置各个设置项现在传递keyStats // 从配置中提取并设置各个设置项现在传递keyStats
await this.updateSettingsFromConfig(config, keyStats); await this.updateSettingsFromConfig(config, keyStats);
// 认证文件需要单独加载,因为不在配置中 await this.loadAvailableModels({ config, forceRefresh });
await this.loadAuthFiles(keyStats);
// 使用统计需要单独加载复用已获取的usage数据 if (this.events && typeof this.events.emit === 'function') {
await this.loadUsageStats(usageData); this.events.emit('data:config-loaded', {
config,
// 加载配置文件编辑器内容 usageData,
await this.loadConfigFileEditor(forceRefresh); keyStats,
this.refreshConfigEditor(); forceRefresh
});
}
console.log('配置加载完成,使用缓存:', !forceRefresh && this.isCacheValid()); console.log('配置加载完成,使用缓存:', !forceRefresh && this.isCacheValid());
} catch (error) { } catch (error) {
@@ -416,78 +726,11 @@ export const connectionModule = {
} }
}, },
// 从配置对象更新所有设置 // 从配置对象更新所有设置 —— 委派给 settings 模块,保持兼容旧调用
async updateSettingsFromConfig(config, keyStats = null) { async updateSettingsFromConfig(config, keyStats = null) {
// 调试设置 if (typeof this.applySettingsFromConfig === 'function') {
if (config.debug !== undefined) { return this.applySettingsFromConfig(config, keyStats);
document.getElementById('debug-toggle').checked = config.debug;
} }
// 代理设置
if (config['proxy-url'] !== undefined) {
document.getElementById('proxy-url').value = config['proxy-url'] || '';
}
// 请求重试设置
if (config['request-retry'] !== undefined) {
document.getElementById('request-retry').value = config['request-retry'];
}
// 配额超出行为
if (config['quota-exceeded']) {
if (config['quota-exceeded']['switch-project'] !== undefined) {
document.getElementById('switch-project-toggle').checked = config['quota-exceeded']['switch-project'];
}
if (config['quota-exceeded']['switch-preview-model'] !== undefined) {
document.getElementById('switch-preview-model-toggle').checked = config['quota-exceeded']['switch-preview-model'];
}
}
if (config['usage-statistics-enabled'] !== undefined) {
const usageToggle = document.getElementById('usage-statistics-enabled-toggle');
if (usageToggle) {
usageToggle.checked = config['usage-statistics-enabled'];
}
}
// 日志记录设置
if (config['logging-to-file'] !== undefined) {
const loggingToggle = document.getElementById('logging-to-file-toggle');
if (loggingToggle) {
loggingToggle.checked = config['logging-to-file'];
}
// 显示或隐藏日志查看栏目
this.toggleLogsNavItem(config['logging-to-file']);
}
if (config['request-log'] !== undefined) {
const requestLogToggle = document.getElementById('request-log-toggle');
if (requestLogToggle) {
requestLogToggle.checked = config['request-log'];
}
}
if (config['ws-auth'] !== undefined) {
const wsAuthToggle = document.getElementById('ws-auth-toggle');
if (wsAuthToggle) {
wsAuthToggle.checked = config['ws-auth'];
}
}
// API 密钥
if (config['api-keys']) {
this.renderApiKeys(config['api-keys']);
}
// Gemini keys
await this.renderGeminiKeys(this.getGeminiKeysFromConfig(config), keyStats);
// Codex 密钥
await this.renderCodexKeys(Array.isArray(config['codex-api-key']) ? config['codex-api-key'] : [], keyStats);
// Claude 密钥
await this.renderClaudeKeys(Array.isArray(config['claude-api-key']) ? config['claude-api-key'] : [], keyStats);
// OpenAI 兼容提供商
await this.renderOpenAIProviders(Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : [], keyStats);
}, },
detectApiBaseFromLocation() { detectApiBaseFromLocation() {

10
src/core/event-bus.js Normal file
View File

@@ -0,0 +1,10 @@
// 轻量事件总线,避免模块之间的直接耦合
export function createEventBus() {
const target = new EventTarget();
const on = (type, listener) => target.addEventListener(type, listener);
const off = (type, listener) => target.removeEventListener(type, listener);
const emit = (type, detail = {}) => target.dispatchEvent(new CustomEvent(type, { detail }));
return { on, off, emit };
}

View File

@@ -2,6 +2,8 @@
// 这些函数依赖于 CLIProxyManager 实例上的 makeRequest/getConfig/clearCache/showNotification 等能力, // 这些函数依赖于 CLIProxyManager 实例上的 makeRequest/getConfig/clearCache/showNotification 等能力,
// 以及 apiKeysModule 中的工具方法(如 applyHeadersToConfig/renderHeaderBadges // 以及 apiKeysModule 中的工具方法(如 applyHeadersToConfig/renderHeaderBadges
import { normalizeModelList } from '../utils/models.js';
const getStatsBySource = (stats) => { const getStatsBySource = (stats) => {
if (stats && typeof stats === 'object' && stats.bySource) { if (stats && typeof stats === 'object' && stats.bySource) {
return stats.bySource; return stats.bySource;
@@ -9,6 +11,84 @@ const getStatsBySource = (stats) => {
return stats || {}; return stats || {};
}; };
const buildModelEndpoint = (baseUrl) => {
if (!baseUrl) return '';
const trimmed = String(baseUrl).trim().replace(/\/+$/g, '');
if (!trimmed) return '';
// 如果 base 已以 /v1 结尾,直接拼 /models否则拼 /v1/models避免丢失中间路径
if (trimmed.endsWith('/v1')) {
return `${trimmed}/models`;
}
return `${trimmed}/v1/models`;
};
const buildChatCompletionsEndpoint = (baseUrl) => {
if (!baseUrl) return '';
const trimmed = String(baseUrl).trim().replace(/\/+$/g, '');
if (!trimmed) return '';
if (trimmed.endsWith('/chat/completions')) {
return trimmed;
}
if (trimmed.endsWith('/v1')) {
return `${trimmed}/chat/completions`;
}
return `${trimmed}/v1/chat/completions`;
};
const normalizeExcludedModels = (input) => {
const rawList = Array.isArray(input)
? input
: (typeof input === 'string' ? input.split(/[\n,]/) : []);
const seen = new Set();
const normalized = [];
rawList.forEach(item => {
if (item === undefined || item === null) {
return;
}
const trimmed = String(item).trim();
if (!trimmed) return;
const key = trimmed.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
normalized.push(trimmed);
});
return normalized;
};
export function collectExcludedModels(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return [];
return normalizeExcludedModels(textarea.value);
}
export function setExcludedModelsValue(textareaId, models = []) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
textarea.value = normalizeExcludedModels(models).join('\n');
}
export function renderExcludedModelBadges(models) {
const normalized = normalizeExcludedModels(models);
if (!normalized.length) {
return '';
}
const badges = normalized.map(model => `
<span class="provider-model-tag excluded-model-tag">
<span class="model-name">${this.escapeHtml(model)}</span>
</span>
`).join('');
return `
<div class="item-subtitle">${i18n.t('ai_providers.excluded_models_count', { count: normalized.length })}</div>
<div class="provider-models excluded-models">
${badges}
</div>
`;
}
export async function loadGeminiKeys() { export async function loadGeminiKeys() {
try { try {
const config = await this.getConfig(); const config = await this.getConfig();
@@ -25,25 +105,7 @@ export function getGeminiKeysFromConfig(config) {
} }
const geminiKeys = Array.isArray(config['gemini-api-key']) ? config['gemini-api-key'] : []; const geminiKeys = Array.isArray(config['gemini-api-key']) ? config['gemini-api-key'] : [];
if (geminiKeys.length > 0) { return geminiKeys;
return geminiKeys;
}
const legacyKeys = Array.isArray(config['generative-language-api-key']) ? config['generative-language-api-key'] : [];
return legacyKeys
.map(item => {
if (item && typeof item === 'object') {
return { ...item };
}
if (typeof item === 'string') {
const trimmed = item.trim();
if (trimmed) {
return { 'api-key': trimmed };
}
}
return null;
})
.filter(Boolean);
} }
export async function renderGeminiKeys(keys, keyStats = null) { export async function renderGeminiKeys(keys, keyStats = null) {
@@ -94,6 +156,7 @@ export async function renderGeminiKeys(keys, keyStats = null) {
const configJson = JSON.stringify(config).replace(/"/g, '&quot;'); const configJson = JSON.stringify(config).replace(/"/g, '&quot;');
const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '&quot;'); const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '&quot;');
const baseUrl = config['base-url'] || config['base_url'] || ''; const baseUrl = config['base-url'] || config['base_url'] || '';
const excludedModelsHtml = this.renderExcludedModelBadges(config['excluded-models']);
return ` return `
<div class="key-item"> <div class="key-item">
<div class="item-content"> <div class="item-content">
@@ -101,6 +164,7 @@ export async function renderGeminiKeys(keys, keyStats = null) {
<div class="item-subtitle">${i18n.t('common.api_key')}: ${maskedDisplay}</div> <div class="item-subtitle">${i18n.t('common.api_key')}: ${maskedDisplay}</div>
${baseUrl ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>` : ''} ${baseUrl ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>` : ''}
${this.renderHeaderBadges(config.headers)} ${this.renderHeaderBadges(config.headers)}
${excludedModelsHtml}
<div class="item-stats"> <div class="item-stats">
<span class="stat-badge stat-success"> <span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success} <i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success}
@@ -141,6 +205,11 @@ export function showAddGeminiKeyModal() {
<div id="new-gemini-headers-wrapper" class="header-input-list"></div> <div id="new-gemini-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('new-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button> <button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('new-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
</div> </div>
<div class="form-group">
<label for="new-gemini-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="new-gemini-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button> <button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.addGeminiKey()">${i18n.t('common.add')}</button> <button class="btn btn-primary" onclick="manager.addGeminiKey()">${i18n.t('common.add')}</button>
@@ -150,11 +219,13 @@ export function showAddGeminiKeyModal() {
modal.style.display = 'block'; modal.style.display = 'block';
this.populateGeminiKeyFields('new-gemini-keys-wrapper'); this.populateGeminiKeyFields('new-gemini-keys-wrapper');
this.populateHeaderFields('new-gemini-headers-wrapper'); this.populateHeaderFields('new-gemini-headers-wrapper');
this.setExcludedModelsValue('new-gemini-excluded-models');
} }
export async function addGeminiKey() { export async function addGeminiKey() {
const entries = this.collectGeminiKeyFieldInputs('new-gemini-keys-wrapper'); const entries = this.collectGeminiKeyFieldInputs('new-gemini-keys-wrapper');
const headers = this.collectHeaderInputs('new-gemini-headers-wrapper'); const headers = this.collectHeaderInputs('new-gemini-headers-wrapper');
const excludedModels = this.collectExcludedModels('new-gemini-excluded-models');
if (!entries.length) { if (!entries.length) {
this.showNotification(i18n.t('notification.gemini_multi_input_required'), 'error'); this.showNotification(i18n.t('notification.gemini_multi_input_required'), 'error');
@@ -195,6 +266,7 @@ export async function addGeminiKey() {
} else { } else {
delete newConfig['base-url']; delete newConfig['base-url'];
} }
newConfig['excluded-models'] = excludedModels;
this.applyHeadersToConfig(newConfig, headers); this.applyHeadersToConfig(newConfig, headers);
const nextKeys = [...currentKeys, newConfig]; const nextKeys = [...currentKeys, newConfig];
@@ -321,6 +393,11 @@ export function editGeminiKey(index, config) {
<div id="edit-gemini-headers-wrapper" class="header-input-list"></div> <div id="edit-gemini-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('edit-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button> <button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('edit-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
</div> </div>
<div class="form-group">
<label for="edit-gemini-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="edit-gemini-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button> <button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.updateGeminiKey(${index})">${i18n.t('common.update')}</button> <button class="btn btn-primary" onclick="manager.updateGeminiKey(${index})">${i18n.t('common.update')}</button>
@@ -330,6 +407,7 @@ export function editGeminiKey(index, config) {
modal.style.display = 'block'; modal.style.display = 'block';
this.populateGeminiKeyFields('edit-gemini-keys-wrapper', [config], { allowRemoval: false }); this.populateGeminiKeyFields('edit-gemini-keys-wrapper', [config], { allowRemoval: false });
this.populateHeaderFields('edit-gemini-headers-wrapper', config.headers || null); this.populateHeaderFields('edit-gemini-headers-wrapper', config.headers || null);
this.setExcludedModelsValue('edit-gemini-excluded-models', config['excluded-models'] || []);
} }
export async function updateGeminiKey(index) { export async function updateGeminiKey(index) {
@@ -342,6 +420,7 @@ export async function updateGeminiKey(index) {
const newKey = entry['api-key']; const newKey = entry['api-key'];
const baseUrl = entry['base-url'] || ''; const baseUrl = entry['base-url'] || '';
const headers = this.collectHeaderInputs('edit-gemini-headers-wrapper'); const headers = this.collectHeaderInputs('edit-gemini-headers-wrapper');
const excludedModels = this.collectExcludedModels('edit-gemini-excluded-models');
if (!newKey) { if (!newKey) {
this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error'); this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error');
@@ -356,6 +435,7 @@ export async function updateGeminiKey(index) {
} else { } else {
delete newConfig['base-url']; delete newConfig['base-url'];
} }
newConfig['excluded-models'] = excludedModels;
this.applyHeadersToConfig(newConfig, headers); this.applyHeadersToConfig(newConfig, headers);
await this.makeRequest('/gemini-api-key', { await this.makeRequest('/gemini-api-key', {
@@ -427,6 +507,7 @@ export async function renderCodexKeys(keys, keyStats = null) {
const maskedDisplay = this.escapeHtml(masked); const maskedDisplay = this.escapeHtml(masked);
const usageStats = (rawKey && (statsBySource[rawKey] || statsBySource[masked])) || { success: 0, failure: 0 }; const usageStats = (rawKey && (statsBySource[rawKey] || statsBySource[masked])) || { success: 0, failure: 0 };
const deleteArg = JSON.stringify(rawKey).replace(/"/g, '&quot;'); const deleteArg = JSON.stringify(rawKey).replace(/"/g, '&quot;');
const excludedModelsHtml = this.renderExcludedModelBadges(config['excluded-models']);
return ` return `
<div class="provider-item"> <div class="provider-item">
<div class="item-content"> <div class="item-content">
@@ -435,6 +516,7 @@ export async function renderCodexKeys(keys, keyStats = null) {
${config['base-url'] ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}</div>` : ''} ${config['base-url'] ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}</div>` : ''}
${config['proxy-url'] ? `<div class="item-subtitle">${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}</div>` : ''} ${config['proxy-url'] ? `<div class="item-subtitle">${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}</div>` : ''}
${this.renderHeaderBadges(config.headers)} ${this.renderHeaderBadges(config.headers)}
${excludedModelsHtml}
<div class="item-stats"> <div class="item-stats">
<span class="stat-badge stat-success"> <span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success} <i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success}
@@ -475,6 +557,11 @@ export function showAddCodexKeyModal() {
<label for="new-codex-proxy">${i18n.t('ai_providers.codex_add_modal_proxy_label')}</label> <label for="new-codex-proxy">${i18n.t('ai_providers.codex_add_modal_proxy_label')}</label>
<input type="text" id="new-codex-proxy" placeholder="${i18n.t('ai_providers.codex_add_modal_proxy_placeholder')}"> <input type="text" id="new-codex-proxy" placeholder="${i18n.t('ai_providers.codex_add_modal_proxy_placeholder')}">
</div> </div>
<div class="form-group">
<label for="new-codex-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="new-codex-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="form-group"> <div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label> <label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p> <p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
@@ -489,6 +576,7 @@ export function showAddCodexKeyModal() {
modal.style.display = 'block'; modal.style.display = 'block';
this.populateHeaderFields('new-codex-headers-wrapper'); this.populateHeaderFields('new-codex-headers-wrapper');
this.setExcludedModelsValue('new-codex-excluded-models');
} }
export async function addCodexKey() { export async function addCodexKey() {
@@ -496,6 +584,7 @@ export async function addCodexKey() {
const baseUrl = document.getElementById('new-codex-url').value.trim(); const baseUrl = document.getElementById('new-codex-url').value.trim();
const proxyUrl = document.getElementById('new-codex-proxy').value.trim(); const proxyUrl = document.getElementById('new-codex-proxy').value.trim();
const headers = this.collectHeaderInputs('new-codex-headers-wrapper'); const headers = this.collectHeaderInputs('new-codex-headers-wrapper');
const excludedModels = this.collectExcludedModels('new-codex-excluded-models');
if (!apiKey) { if (!apiKey) {
this.showNotification(i18n.t('notification.field_required'), 'error'); this.showNotification(i18n.t('notification.field_required'), 'error');
@@ -510,7 +599,7 @@ export async function addCodexKey() {
const data = await this.makeRequest('/codex-api-key'); const data = await this.makeRequest('/codex-api-key');
const currentKeys = this.normalizeArrayResponse(data, 'codex-api-key').map(item => ({ ...item })); const currentKeys = this.normalizeArrayResponse(data, 'codex-api-key').map(item => ({ ...item }));
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers); const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers, excludedModels);
currentKeys.push(newConfig); currentKeys.push(newConfig);
@@ -546,6 +635,11 @@ export function editCodexKey(index, config) {
<label for="edit-codex-proxy">${i18n.t('ai_providers.codex_edit_modal_proxy_label')}</label> <label for="edit-codex-proxy">${i18n.t('ai_providers.codex_edit_modal_proxy_label')}</label>
<input type="text" id="edit-codex-proxy" value="${config['proxy-url'] || ''}"> <input type="text" id="edit-codex-proxy" value="${config['proxy-url'] || ''}">
</div> </div>
<div class="form-group">
<label for="edit-codex-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="edit-codex-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="form-group"> <div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label> <label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p> <p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
@@ -560,6 +654,7 @@ export function editCodexKey(index, config) {
modal.style.display = 'block'; modal.style.display = 'block';
this.populateHeaderFields('edit-codex-headers-wrapper', config.headers || null); this.populateHeaderFields('edit-codex-headers-wrapper', config.headers || null);
this.setExcludedModelsValue('edit-codex-excluded-models', config['excluded-models'] || []);
} }
export async function updateCodexKey(index) { export async function updateCodexKey(index) {
@@ -567,6 +662,7 @@ export async function updateCodexKey(index) {
const baseUrl = document.getElementById('edit-codex-url').value.trim(); const baseUrl = document.getElementById('edit-codex-url').value.trim();
const proxyUrl = document.getElementById('edit-codex-proxy').value.trim(); const proxyUrl = document.getElementById('edit-codex-proxy').value.trim();
const headers = this.collectHeaderInputs('edit-codex-headers-wrapper'); const headers = this.collectHeaderInputs('edit-codex-headers-wrapper');
const excludedModels = this.collectExcludedModels('edit-codex-excluded-models');
if (!apiKey) { if (!apiKey) {
this.showNotification(i18n.t('notification.field_required'), 'error'); this.showNotification(i18n.t('notification.field_required'), 'error');
@@ -586,7 +682,7 @@ export async function updateCodexKey(index) {
} }
const original = currentList[index] ? { ...currentList[index] } : {}; const original = currentList[index] ? { ...currentList[index] } : {};
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers); const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers, excludedModels);
await this.makeRequest('/codex-api-key', { await this.makeRequest('/codex-api-key', {
method: 'PATCH', method: 'PATCH',
@@ -952,7 +1048,7 @@ export async function renderOpenAIProviders(providers, keyStats = null) {
<div class="provider-item"> <div class="provider-item">
<div class="item-content"> <div class="item-content">
<div class="item-title">${this.escapeHtml(name)}</div> <div class="item-title">${this.escapeHtml(name)}</div>
<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div> <div class="item-subtitle provider-base-url" title="${this.escapeHtml(baseUrl)}">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>
${this.renderHeaderBadges(item.headers)} ${this.renderHeaderBadges(item.headers)}
<div class="item-subtitle">${i18n.t('ai_providers.openai_keys_count')}: ${apiKeyEntries.length}</div> <div class="item-subtitle">${i18n.t('ai_providers.openai_keys_count')}: ${apiKeyEntries.length}</div>
<div class="item-subtitle">${i18n.t('ai_providers.openai_models_count')}: ${models.length}</div> <div class="item-subtitle">${i18n.t('ai_providers.openai_models_count')}: ${models.length}</div>
@@ -979,6 +1075,494 @@ export async function renderOpenAIProviders(providers, keyStats = null) {
}).join(''); }).join('');
} }
const getOpenAIContext = (mode = 'new') => {
const isEdit = mode === 'edit';
return {
mode: isEdit ? 'edit' : 'new',
baseUrlInputId: isEdit ? 'edit-provider-url' : 'new-provider-url',
apiKeyWrapperId: isEdit ? 'edit-openai-keys-wrapper' : 'new-openai-keys-wrapper',
headerWrapperId: isEdit ? 'edit-openai-headers-wrapper' : 'new-openai-headers-wrapper',
modelWrapperId: isEdit ? 'edit-provider-models-wrapper' : 'new-provider-models-wrapper'
};
};
function ensureOpenAIModelDiscoveryCard(manager) {
let overlay = document.getElementById('openai-model-discovery');
if (overlay) {
return overlay;
}
overlay = document.createElement('div');
overlay.id = 'openai-model-discovery';
overlay.className = 'model-discovery-overlay';
overlay.innerHTML = `
<div class="model-discovery-card">
<div class="model-discovery-header">
<div class="model-discovery-title">
<h3>${i18n.t('ai_providers.openai_models_fetch_title')}</h3>
<p class="form-hint">${i18n.t('ai_providers.openai_models_fetch_hint')}</p>
</div>
<button type="button" class="btn btn-secondary" id="openai-model-discovery-back">${i18n.t('ai_providers.openai_models_fetch_back')}</button>
</div>
<div class="form-group">
<label>${i18n.t('ai_providers.openai_models_fetch_url_label')}</label>
<div class="input-group">
<input type="text" id="openai-model-discovery-url" readonly>
<button type="button" class="btn btn-secondary" id="openai-model-discovery-refresh">${i18n.t('ai_providers.openai_models_fetch_refresh')}</button>
</div>
</div>
<div class="form-group">
<label for="openai-model-discovery-search">${i18n.t('ai_providers.openai_models_search_label')}</label>
<input type="text" id="openai-model-discovery-search" placeholder="${i18n.t('ai_providers.openai_models_search_placeholder')}">
</div>
<div id="openai-model-discovery-status" class="model-discovery-status"></div>
<div id="openai-model-discovery-list" class="model-discovery-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="openai-model-discovery-cancel">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" id="openai-model-discovery-apply">${i18n.t('ai_providers.openai_models_fetch_apply')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const bind = (id, handler) => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('click', handler);
}
};
bind('openai-model-discovery-back', () => manager.closeOpenAIModelDiscovery());
bind('openai-model-discovery-cancel', () => manager.closeOpenAIModelDiscovery());
bind('openai-model-discovery-refresh', () => manager.refreshOpenAIModelDiscovery());
bind('openai-model-discovery-apply', () => manager.applyOpenAIModelDiscoverySelection());
const searchInput = document.getElementById('openai-model-discovery-search');
if (searchInput) {
searchInput.addEventListener('input', (event) => {
const query = event?.target?.value || '';
manager.setOpenAIModelDiscoverySearch(query);
});
}
return overlay;
}
export function setOpenAIModelDiscoveryStatus(message = '', type = 'info') {
const status = document.getElementById('openai-model-discovery-status');
if (!status) return;
status.textContent = message;
status.className = `model-discovery-status ${type}`;
}
export function setOpenAIModelDiscoverySearch(query = '') {
if (!this.openAIModelDiscoveryContext) return;
const normalized = (query || '').trim();
this.openAIModelDiscoveryContext.modelSearchQuery = normalized;
const models = this.openAIModelDiscoveryContext.discoveredModels || [];
this.renderOpenAIModelDiscoveryList(models);
}
export function renderOpenAIModelDiscoveryList(models = []) {
const list = document.getElementById('openai-model-discovery-list');
if (!list) return;
const context = this.openAIModelDiscoveryContext || {};
const filter = (context.modelSearchQuery || '').trim().toLowerCase();
const filtered = models
.map((model, index) => ({ model, index }))
.filter(({ model }) => {
if (!filter) return true;
const name = (model?.name || '').toLowerCase();
const alias = (model?.alias || '').toLowerCase();
const desc = (model?.description || '').toLowerCase();
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
});
if (!models.length) {
list.innerHTML = `
<div class="model-discovery-empty">
<i class="fas fa-box-open"></i>
<span>${i18n.t('ai_providers.openai_models_fetch_empty')}</span>
</div>
`;
return;
}
if (!filtered.length) {
list.innerHTML = `
<div class="model-discovery-empty">
<i class="fas fa-search"></i>
<span>${i18n.t('ai_providers.openai_models_search_empty')}</span>
</div>
`;
return;
}
list.innerHTML = filtered.map(({ model, index }) => {
const name = this.escapeHtml(model?.name || '');
const alias = model?.alias ? `<span class="model-discovery-alias">${this.escapeHtml(model.alias)}</span>` : '';
const desc = model?.description ? `<div class="model-discovery-desc">${this.escapeHtml(model.description)}</div>` : '';
return `
<label class="model-discovery-row">
<input type="checkbox" class="model-discovery-checkbox" data-model-index="${index}">
<div class="model-discovery-meta">
<div class="model-discovery-name">${name} ${alias}</div>
${desc}
</div>
</label>
`;
}).join('');
}
export function openOpenAIModelDiscovery(mode = 'new') {
const context = getOpenAIContext(mode);
const baseInput = document.getElementById(context.baseUrlInputId);
const baseUrl = baseInput ? baseInput.value.trim() : '';
if (!baseUrl) {
this.showNotification(i18n.t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
const endpoint = buildModelEndpoint(baseUrl);
if (!endpoint) {
this.showNotification(i18n.t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
const apiKeyEntries = this.collectApiKeyEntryInputs(context.apiKeyWrapperId);
const firstKey = Array.isArray(apiKeyEntries) ? apiKeyEntries.find(entry => entry && entry['api-key']) : null;
const headers = this.collectHeaderInputs(context.headerWrapperId) || {};
if (firstKey && !headers.Authorization && !headers.authorization) {
headers.Authorization = `Bearer ${firstKey['api-key']}`;
}
ensureOpenAIModelDiscoveryCard(this).classList.add('active');
this.openAIModelDiscoveryContext = {
...context,
endpoint,
headers,
discoveredModels: [],
modelSearchQuery: ''
};
const urlInput = document.getElementById('openai-model-discovery-url');
if (urlInput) {
urlInput.value = endpoint;
}
const searchInput = document.getElementById('openai-model-discovery-search');
if (searchInput) {
searchInput.value = '';
}
this.renderOpenAIModelDiscoveryList([]);
this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_loading'), 'info');
this.refreshOpenAIModelDiscovery();
}
export async function refreshOpenAIModelDiscovery() {
const context = this.openAIModelDiscoveryContext;
if (!context || !context.endpoint) {
return;
}
this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_loading'), 'info');
const list = document.getElementById('openai-model-discovery-list');
if (list) {
list.innerHTML = '<div class="model-discovery-empty"><i class="fas fa-spinner fa-spin"></i></div>';
}
try {
let response;
let usedSimpleRequest = false;
try {
// 首先尝试正常的带自定义headers的请求
response = await fetch(context.endpoint, {
headers: context.headers || {}
});
} catch (error) {
// 如果fetch失败(通常是CORS预检失败),尝试简单GET请求
console.warn('Normal fetch failed, trying simple GET request:', error);
usedSimpleRequest = true;
response = await fetch(context.endpoint, {
method: 'GET',
mode: 'cors',
credentials: 'omit'
// 不发送自定义headers,避免触发OPTIONS预检
});
}
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
let data;
try {
data = await response.json();
} catch (err) {
const text = await response.text();
throw new Error(text || err.message || 'Invalid JSON');
}
const models = normalizeModelList(data);
context.discoveredModels = models;
this.renderOpenAIModelDiscoveryList(models);
if (!models.length) {
this.setOpenAIModelDiscoveryStatus(i18n.t('ai_providers.openai_models_fetch_empty'), 'warning');
} else {
if (usedSimpleRequest) {
// 如果使用了简单请求,提示用户
console.info('Models fetched using simple request (without custom headers)');
}
this.setOpenAIModelDiscoveryStatus('', 'info');
}
} catch (error) {
context.discoveredModels = [];
this.renderOpenAIModelDiscoveryList([]);
this.setOpenAIModelDiscoveryStatus(`${i18n.t('ai_providers.openai_models_fetch_error')}: ${error.message}`, 'error');
}
}
export function applyOpenAIModelDiscoverySelection() {
const context = this.openAIModelDiscoveryContext;
if (!context || !Array.isArray(context.discoveredModels) || !context.discoveredModels.length) {
this.closeOpenAIModelDiscovery();
return;
}
const list = document.getElementById('openai-model-discovery-list');
if (!list) {
this.closeOpenAIModelDiscovery();
return;
}
const selectedIndices = Array.from(list.querySelectorAll('.model-discovery-checkbox:checked'))
.map(input => Number.parseInt(input.getAttribute('data-model-index') || '-1', 10))
.filter(index => Number.isFinite(index) && index >= 0 && index < context.discoveredModels.length);
const selectedModels = selectedIndices.map(index => context.discoveredModels[index]);
if (!selectedModels.length) {
this.closeOpenAIModelDiscovery();
return;
}
const existing = this.collectModelInputs(context.modelWrapperId);
const mergedMap = new Map();
existing.forEach(model => {
if (model && model.name) {
mergedMap.set(model.name, { ...model });
}
});
let addedCount = 0;
selectedModels.forEach(model => {
const name = model && model.name;
if (!name) return;
if (!mergedMap.has(name)) {
mergedMap.set(name, { name, ...(model.alias ? { alias: model.alias } : {}) });
addedCount++;
}
});
this.populateModelFields(context.modelWrapperId, Array.from(mergedMap.values()));
if (context.mode === 'edit' && typeof this.populateOpenAITestModelOptions === 'function') {
this.populateOpenAITestModelOptions(Array.from(mergedMap.values()), { preserveInput: true });
}
this.closeOpenAIModelDiscovery();
if (addedCount > 0) {
const template = i18n.t('ai_providers.openai_models_fetch_added');
const message = template.replace('{count}', addedCount);
this.showNotification(message, 'success');
}
}
export function closeOpenAIModelDiscovery() {
const overlay = document.getElementById('openai-model-discovery');
if (overlay) {
overlay.classList.remove('active');
}
this.openAIModelDiscoveryContext = null;
}
export function populateOpenAITestModelOptions(models = [], { preserveInput = true } = {}) {
const select = document.getElementById('openai-test-model-select');
const input = document.getElementById('openai-test-model-input');
if (!select) return;
const names = [];
const seen = new Set();
(Array.isArray(models) ? models : []).forEach(model => {
const name = model?.name ? String(model.name).trim() : '';
if (!name || seen.has(name)) return;
seen.add(name);
names.push(name);
});
if (!names.length) {
select.disabled = true;
select.innerHTML = `<option value="">${i18n.t('ai_providers.openai_test_select_empty')}</option>`;
if (input && !preserveInput) {
input.value = '';
}
return;
}
select.disabled = false;
const placeholder = `<option value="">${i18n.t('ai_providers.openai_test_select_placeholder')}</option>`;
const options = names.map(name => `<option value="${this.escapeHtml(name)}">${this.escapeHtml(name)}</option>`).join('');
select.innerHTML = `${placeholder}${options}`;
if (input) {
if (!preserveInput || !input.value) {
const firstName = names[0];
if (firstName) {
input.value = firstName;
select.value = firstName;
return;
}
}
const current = input.value.trim();
if (current && names.includes(current)) {
select.value = current;
} else {
select.value = '';
}
}
}
export function setOpenAITestStatus(message = '', type = 'info') {
const statusEl = document.getElementById('openai-test-status');
if (!statusEl) return;
statusEl.textContent = message || '';
statusEl.className = `openai-test-status ${type || ''}`.trim();
}
const setOpenAITestButtonState = (state = 'idle') => {
const button = document.getElementById('openai-test-button');
if (!button) return;
button.disabled = state === 'loading';
button.classList.remove('openai-test-btn-success', 'openai-test-btn-error');
switch (state) {
case 'loading':
button.innerHTML = `<i class="fas fa-spinner fa-spin"></i>`;
break;
case 'success':
button.classList.add('openai-test-btn-success');
button.innerHTML = `<i class="fas fa-check"></i>`;
break;
case 'error':
button.classList.add('openai-test-btn-error');
button.innerHTML = `<i class="fas fa-times"></i>`;
break;
default:
button.innerHTML = `<i class="fas fa-stethoscope"></i> ${i18n.t('ai_providers.openai_test_action')}`;
break;
}
};
export async function testOpenAIProviderConnection() {
const baseUrlInput = document.getElementById('edit-provider-url');
const baseUrl = baseUrlInput ? baseUrlInput.value.trim() : '';
if (!baseUrl) {
const message = i18n.t('notification.openai_test_url_required');
this.setOpenAITestStatus(message, 'error');
this.showNotification(message, 'error');
return;
}
const endpoint = buildChatCompletionsEndpoint(baseUrl);
if (!endpoint) {
const message = i18n.t('notification.openai_test_url_required');
this.setOpenAITestStatus(message, 'error');
this.showNotification(message, 'error');
return;
}
const apiKeyEntries = this.collectApiKeyEntryInputs('edit-openai-keys-wrapper');
const firstKeyEntry = Array.isArray(apiKeyEntries) ? apiKeyEntries.find(entry => entry && entry['api-key']) : null;
if (!firstKeyEntry) {
const message = i18n.t('notification.openai_test_key_required');
this.setOpenAITestStatus(message, 'error');
this.showNotification(message, 'error');
return;
}
const models = this.collectModelInputs('edit-provider-models-wrapper');
this.populateOpenAITestModelOptions(models);
const modelInput = document.getElementById('openai-test-model-input');
let modelName = modelInput ? modelInput.value.trim() : '';
if (!modelName) {
const firstModel = Array.isArray(models) ? models.find(model => model && model.name) : null;
if (firstModel && firstModel.name) {
modelName = firstModel.name;
if (modelInput) {
modelInput.value = firstModel.name;
}
}
}
if (!modelName) {
const message = i18n.t('notification.openai_test_model_required');
this.setOpenAITestStatus(message, 'error');
this.showNotification(message, 'error');
return;
}
const customHeaders = this.collectHeaderInputs('edit-openai-headers-wrapper') || {};
const headers = {
'Content-Type': 'application/json',
...customHeaders
};
if (!headers.Authorization && !headers.authorization) {
headers.Authorization = `Bearer ${firstKeyEntry['api-key']}`;
}
this.setOpenAITestStatus('', 'info');
setOpenAITestButtonState('loading');
try {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: modelName,
messages: [{ role: 'user', content: 'Hi' }],
stream: false,
max_tokens: 5
})
});
const rawText = await response.text();
if (!response.ok) {
let errorMessage = `${response.status} ${response.statusText}`;
try {
const parsed = rawText ? JSON.parse(rawText) : null;
errorMessage = parsed?.error?.message || parsed?.message || errorMessage;
} catch (error) {
if (rawText) {
errorMessage = rawText;
}
}
throw new Error(errorMessage);
}
this.setOpenAITestStatus('', 'info');
setOpenAITestButtonState('success');
} catch (error) {
this.setOpenAITestStatus(`${i18n.t('ai_providers.openai_test_failed')}: ${error.message}`, 'error');
setOpenAITestButtonState('error');
}
}
export function showAddOpenAIProviderModal() { export function showAddOpenAIProviderModal() {
const modal = document.getElementById('modal'); const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body'); const modalBody = document.getElementById('modal-body');
@@ -1009,7 +1593,12 @@ export function showAddOpenAIProviderModal() {
<label>${i18n.t('ai_providers.openai_add_modal_models_label')}</label> <label>${i18n.t('ai_providers.openai_add_modal_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.openai_models_hint')}</p> <p class="form-hint">${i18n.t('ai_providers.openai_models_hint')}</p>
<div id="new-provider-models-wrapper" class="model-input-list"></div> <div id="new-provider-models-wrapper" class="model-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('new-provider-models-wrapper')">${i18n.t('ai_providers.openai_models_add_btn')}</button> <div class="model-actions-inline">
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('new-provider-models-wrapper')">${i18n.t('ai_providers.openai_models_add_btn')}</button>
<button type="button" class="btn btn-secondary" onclick="manager.openOpenAIModelDiscovery('new')">
<i class="fas fa-download"></i> ${i18n.t('ai_providers.openai_models_fetch_button')}
</button>
</div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button> <button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
@@ -1104,7 +1693,24 @@ export function editOpenAIProvider(index, provider) {
<label>${i18n.t('ai_providers.openai_edit_modal_models_label')}</label> <label>${i18n.t('ai_providers.openai_edit_modal_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.openai_models_hint')}</p> <p class="form-hint">${i18n.t('ai_providers.openai_models_hint')}</p>
<div id="edit-provider-models-wrapper" class="model-input-list"></div> <div id="edit-provider-models-wrapper" class="model-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('edit-provider-models-wrapper')">${i18n.t('ai_providers.openai_models_add_btn')}</button> <div class="model-actions-inline">
<button type="button" class="btn btn-secondary" onclick="manager.addModelField('edit-provider-models-wrapper')">${i18n.t('ai_providers.openai_models_add_btn')}</button>
<button type="button" class="btn btn-secondary" onclick="manager.openOpenAIModelDiscovery('edit')">
<i class="fas fa-download"></i> ${i18n.t('ai_providers.openai_models_fetch_button')}
</button>
</div>
</div>
<div class="form-group">
<label>${i18n.t('ai_providers.openai_test_title')}</label>
<p class="form-hint">${i18n.t('ai_providers.openai_test_hint')}</p>
<div class="input-group openai-test-group">
<select id="openai-test-model-select" aria-label="${i18n.t('ai_providers.openai_test_model_placeholder')}"></select>
<input type="text" id="openai-test-model-input" placeholder="${i18n.t('ai_providers.openai_test_model_placeholder')}">
<button type="button" class="btn btn-secondary" id="openai-test-button" onclick="manager.testOpenAIProviderConnection()">
<i class="fas fa-stethoscope"></i> ${i18n.t('ai_providers.openai_test_action')}
</button>
</div>
<div id="openai-test-status" class="openai-test-status"></div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button> <button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
@@ -1116,6 +1722,28 @@ export function editOpenAIProvider(index, provider) {
this.populateModelFields('edit-provider-models-wrapper', models); this.populateModelFields('edit-provider-models-wrapper', models);
this.populateHeaderFields('edit-openai-headers-wrapper', provider?.headers || null); this.populateHeaderFields('edit-openai-headers-wrapper', provider?.headers || null);
this.populateApiKeyEntryFields('edit-openai-keys-wrapper', apiKeyEntries); this.populateApiKeyEntryFields('edit-openai-keys-wrapper', apiKeyEntries);
this.populateOpenAITestModelOptions(models);
this.setOpenAITestStatus('', 'info');
setOpenAITestButtonState('idle');
const modelWrapper = document.getElementById('edit-provider-models-wrapper');
if (modelWrapper) {
modelWrapper.addEventListener('input', () => {
const currentModels = this.collectModelInputs('edit-provider-models-wrapper');
this.populateOpenAITestModelOptions(currentModels, { preserveInput: true });
});
}
const modelSelect = document.getElementById('openai-test-model-select');
if (modelSelect) {
modelSelect.addEventListener('change', (event) => {
const value = event?.target?.value || '';
const input = document.getElementById('openai-test-model-input');
if (input && value) {
input.value = value;
}
});
}
} }
export async function updateOpenAIProvider(index) { export async function updateOpenAIProvider(index) {
@@ -1292,9 +1920,22 @@ export const aiProvidersModule = {
editOpenAIProvider, editOpenAIProvider,
updateOpenAIProvider, updateOpenAIProvider,
deleteOpenAIProvider, deleteOpenAIProvider,
openOpenAIModelDiscovery,
refreshOpenAIModelDiscovery,
renderOpenAIModelDiscoveryList,
setOpenAIModelDiscoveryStatus,
setOpenAIModelDiscoverySearch,
applyOpenAIModelDiscoverySelection,
closeOpenAIModelDiscovery,
populateOpenAITestModelOptions,
setOpenAITestStatus,
testOpenAIProviderConnection,
addModelField, addModelField,
populateModelFields, populateModelFields,
collectModelInputs, collectModelInputs,
renderModelBadges, renderModelBadges,
renderExcludedModelBadges,
collectExcludedModels,
setExcludedModelsValue,
validateOpenAIProviderInput validateOpenAIProviderInput
}; };

View File

@@ -26,27 +26,36 @@ export const apiKeysModule = {
return; return;
} }
container.innerHTML = keys.map((key, index) => { const rows = keys.map((key, index) => {
const normalizedKey = typeof key === 'string' ? key : String(key ?? ''); const normalizedKey = typeof key === 'string' ? key : String(key ?? '');
const maskedDisplay = this.escapeHtml(this.maskApiKey(normalizedKey)); const maskedDisplay = this.escapeHtml(this.maskApiKey(normalizedKey));
const keyArgument = JSON.stringify(normalizedKey).replace(/"/g, '&quot;'); const keyArgument = encodeURIComponent(normalizedKey);
return ` return `
<div class="key-item"> <div class="key-table-row">
<div class="item-content"> <div class="key-badge">#${index + 1}</div>
<div class="item-title">${i18n.t('api_keys.item_title')} #${index + 1}</div> <div class="key-table-value">
<div class="item-value">${maskedDisplay}</div> <div class="item-title">${i18n.t('api_keys.item_title')}</div>
</div> <div class="key-value">${maskedDisplay}</div>
<div class="item-actions"> </div>
<button class="btn btn-secondary" onclick="manager.editApiKey(${index}, ${keyArgument})"> <div class="item-actions compact">
<i class="fas fa-edit"></i> <button class="btn btn-secondary" data-action="edit-api-key" data-index="${index}" data-key="${keyArgument}">
</button> <i class="fas fa-edit"></i>
<button class="btn btn-danger" onclick="manager.deleteApiKey(${index})"> </button>
<i class="fas fa-trash"></i> <button class="btn btn-danger" data-action="delete-api-key" data-index="${index}">
</button> <i class="fas fa-trash"></i>
</button>
</div>
</div> </div>
`;
}).join('');
container.innerHTML = `
<div class="key-table">
${rows}
</div> </div>
`; `;
}).join('');
this.bindApiKeyListEvents(container);
}, },
// 注意: escapeHtml, maskApiKey, normalizeArrayResponse // 注意: escapeHtml, maskApiKey, normalizeArrayResponse
@@ -221,7 +230,7 @@ export const apiKeysModule = {
}, },
// 构造Codex配置保持未展示的字段 // 构造Codex配置保持未展示的字段
buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null) { buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null, excludedModels = null) {
const result = { const result = {
...original, ...original,
'api-key': apiKey, 'api-key': apiKey,
@@ -229,6 +238,9 @@ export const apiKeysModule = {
'proxy-url': proxyUrl || '' 'proxy-url': proxyUrl || ''
}; };
this.applyHeadersToConfig(result, headers); this.applyHeadersToConfig(result, headers);
if (Array.isArray(excludedModels)) {
result['excluded-models'] = excludedModels;
}
return result; return result;
}, },
@@ -336,5 +348,43 @@ export const apiKeysModule = {
} catch (error) { } catch (error) {
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error'); this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
} }
},
bindApiKeyListEvents(container = null) {
if (this.apiKeyListEventsBound) {
return;
}
const listContainer = container || document.getElementById('api-keys-list');
if (!listContainer) return;
listContainer.addEventListener('click', (event) => {
const button = event.target.closest('[data-action][data-index]');
if (!button || !listContainer.contains(button)) return;
const action = button.dataset.action;
const index = Number(button.dataset.index);
if (!Number.isFinite(index)) return;
switch (action) {
case 'edit-api-key': {
const rawKey = button.dataset.key || '';
let decodedKey = '';
try {
decodedKey = decodeURIComponent(rawKey);
} catch (e) {
decodedKey = rawKey;
}
this.editApiKey(index, decodedKey);
break;
}
case 'delete-api-key':
this.deleteApiKey(index);
break;
default:
break;
}
});
this.apiKeyListEventsBound = true;
} }
}; };

View File

@@ -172,6 +172,9 @@ export const authFilesModule = {
case 'codex': case 'codex':
typeDisplayKey = 'auth_files.type_codex'; typeDisplayKey = 'auth_files.type_codex';
break; break;
case 'antigravity':
typeDisplayKey = 'auth_files.type_antigravity';
break;
case 'iflow': case 'iflow':
typeDisplayKey = 'auth_files.type_iflow'; typeDisplayKey = 'auth_files.type_iflow';
break; break;
@@ -467,6 +470,7 @@ export const authFilesModule = {
{ type: 'aistudio', labelKey: 'auth_files.filter_aistudio' }, { type: 'aistudio', labelKey: 'auth_files.filter_aistudio' },
{ type: 'claude', labelKey: 'auth_files.filter_claude' }, { type: 'claude', labelKey: 'auth_files.filter_claude' },
{ type: 'codex', labelKey: 'auth_files.filter_codex' }, { type: 'codex', labelKey: 'auth_files.filter_codex' },
{ type: 'antigravity', labelKey: 'auth_files.filter_antigravity' },
{ type: 'iflow', labelKey: 'auth_files.filter_iflow' }, { type: 'iflow', labelKey: 'auth_files.filter_iflow' },
{ type: 'vertex', labelKey: 'auth_files.filter_vertex' }, { type: 'vertex', labelKey: 'auth_files.filter_vertex' },
{ type: 'empty', labelKey: 'auth_files.filter_empty' } { type: 'empty', labelKey: 'auth_files.filter_empty' }
@@ -536,6 +540,7 @@ export const authFilesModule = {
} }
this.refreshFilterButtonTexts(); this.refreshFilterButtonTexts();
this.renderOauthExcludedModels();
}, },
generateDynamicTypeLabel(type) { generateDynamicTypeLabel(type) {
@@ -724,16 +729,11 @@ export const authFilesModule = {
this.vertexImportState.loading = true; this.vertexImportState.loading = true;
this.updateVertexImportButtonState(); this.updateVertexImportButtonState();
const response = await fetch(`${this.apiUrl}/vertex/import`, { const response = await this.apiClient.requestRaw('/vertex/import', {
method: 'POST', method: 'POST',
headers: {
'Authorization': `Bearer ${this.managementKey}`
},
body: formData body: formData
}); });
this.updateVersionFromHeaders(response.headers);
if (!response.ok) { if (!response.ok) {
let errorMessage = `HTTP ${response.status}`; let errorMessage = `HTTP ${response.status}`;
try { try {
@@ -879,13 +879,7 @@ export const authFilesModule = {
async downloadAuthFile(filename) { async downloadAuthFile(filename) {
try { try {
const response = await fetch(`${this.apiUrl}/auth-files/download?name=${encodeURIComponent(filename)}`, { const response = await this.apiClient.requestRaw(`/auth-files/download?name=${encodeURIComponent(filename)}`);
headers: {
'Authorization': `Bearer ${this.managementKey}`
}
});
this.updateVersionFromHeaders(response.headers);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
@@ -1004,42 +998,635 @@ export const authFilesModule = {
// 处理文件上传 // 处理文件上传
async handleFileUpload(event) { async handleFileUpload(event) {
const file = event.target.files[0]; const input = event?.target;
if (!file) return; const files = Array.from(input?.files || []);
if (input) {
input.value = '';
}
if (!files.length) return;
if (!file.name.endsWith('.json')) { const validFiles = [];
const invalidFiles = [];
files.forEach(file => {
if (file && file.name.endsWith('.json')) {
validFiles.push(file);
} else if (file) {
invalidFiles.push(file.name);
}
});
if (invalidFiles.length) {
this.showNotification(i18n.t('auth_files.upload_error_json'), 'error'); this.showNotification(i18n.t('auth_files.upload_error_json'), 'error');
event.target.value = ''; }
if (!validFiles.length) return;
let successCount = 0;
const failed = [];
for (const file of validFiles) {
try {
await this.uploadSingleAuthFile(file);
successCount++;
} catch (error) {
failed.push({ name: file.name, message: error.message });
}
}
if (successCount > 0) {
this.clearCache();
await this.loadAuthFiles();
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
this.showNotification(`${i18n.t('auth_files.upload_success')}${suffix}`, failed.length ? 'warning' : 'success');
}
if (failed.length) {
const details = failed.map(item => `${item.name}: ${item.message}`).join('; ');
this.showNotification(`${i18n.t('notification.upload_failed')}: ${details}`, 'error');
}
},
async uploadSingleAuthFile(file) {
const formData = new FormData();
formData.append('file', file, file.name);
const response = await this.apiClient.requestRaw('/auth-files', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
},
normalizeOauthExcludedMap(payload = {}) {
const raw = (payload && (payload['oauth-excluded-models'] || payload.items)) || payload || {};
if (!raw || typeof raw !== 'object') {
return {};
}
const normalized = {};
Object.entries(raw).forEach(([provider, models]) => {
const key = typeof provider === 'string' ? provider.trim() : '';
if (!key) return;
const list = Array.isArray(models)
? models.map(item => String(item || '').trim()).filter(Boolean)
: [];
normalized[key.toLowerCase()] = list;
});
return normalized;
},
resolveOauthExcludedFromConfig(config = null) {
const sources = [];
if (config && typeof config === 'object') {
sources.push(config);
}
if (this.configCache && typeof this.configCache === 'object') {
if (this.configCache['oauth-excluded-models'] !== undefined) {
sources.push({ 'oauth-excluded-models': this.configCache['oauth-excluded-models'] });
}
if (this.configCache['__full__']) {
sources.push(this.configCache['__full__']);
}
}
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
if (Object.prototype.hasOwnProperty.call(source, 'oauth-excluded-models')) {
return {
map: this.normalizeOauthExcludedMap(source),
found: true
};
}
}
return { map: {}, found: false };
},
applyOauthExcludedFromConfig(config = null, options = {}) {
const { render = true } = options || {};
const { map, found } = this.resolveOauthExcludedFromConfig(config);
if (!found) {
return false;
}
this.oauthExcludedModels = map;
this._oauthExcludedLoading = false;
this.setOauthExcludedStatus('');
this.updateOauthExcludedButtonsState(false);
if (render) {
this.renderOauthExcludedModels();
}
return true;
},
getFilteredOauthExcludedMap(filterType = this.currentAuthFileFilter) {
const map = this.oauthExcludedModels || {};
if (!map || typeof map !== 'object') {
return {};
}
const type = (filterType || 'all').toLowerCase();
if (type === 'all') {
return map;
}
const result = {};
Object.entries(map).forEach(([provider, models]) => {
if ((provider || '').toLowerCase() === type) {
result[provider] = models;
}
});
return result;
},
findOauthExcludedEntry(provider) {
if (!provider || provider === 'all') {
return null;
}
const normalized = provider.toLowerCase();
const map = this.oauthExcludedModels || {};
for (const [key, models] of Object.entries(map)) {
if ((key || '').toLowerCase() === normalized) {
return { provider: key, models: Array.isArray(models) ? models : [] };
}
}
return null;
},
setOauthExcludedForm(provider = '', models = null) {
const providerSelect = document.getElementById('oauth-excluded-provider-select');
const modelsInput = document.getElementById('oauth-excluded-models');
const normalizedProvider = (provider || '').trim();
if (providerSelect) {
const options = Array.from(providerSelect.options || []);
let match = options.find(opt => (opt.value || '').toLowerCase() === normalizedProvider.toLowerCase());
if (!match && normalizedProvider) {
match = new Option(this.generateDynamicTypeLabel(normalizedProvider) || normalizedProvider, normalizedProvider);
providerSelect.appendChild(match);
}
if (normalizedProvider && match) {
providerSelect.value = match.value;
} else {
providerSelect.value = 'auto';
}
}
if (modelsInput && models !== null && models !== undefined) {
const list = Array.isArray(models) ? models : [];
modelsInput.value = list.map(item => item || '').join('\n');
}
},
syncOauthExcludedFormWithFilter(overrideModels = false) {
const filterType = (this.currentAuthFileFilter || 'all').toLowerCase();
const entry = this.findOauthExcludedEntry(filterType);
if (filterType === 'all') {
if (overrideModels) {
if (entry) {
this.setOauthExcludedForm(entry.provider, entry.models);
} else {
this.setOauthExcludedForm('', '');
}
}
return; return;
} }
if (overrideModels) {
this.setOauthExcludedForm(filterType, entry ? entry.models : []);
} else {
this.setOauthExcludedForm(filterType);
}
},
getOauthExcludedProviderValue() {
const providerSelect = document.getElementById('oauth-excluded-provider-select');
const filterFallback = (this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all')
? this.currentAuthFileFilter
: '';
let selected = (providerSelect && providerSelect.value) ? providerSelect.value.trim() : '';
if (!selected || selected === 'auto') {
return filterFallback;
}
return selected;
},
refreshOauthProviderOptions() {
const providerSelect = document.getElementById('oauth-excluded-provider-select');
if (!providerSelect) return;
const allowedProviders = ['gemini-cli', 'vertex', 'aistudio', 'antigravity', 'claude', 'codex', 'qwen', 'iflow'];
const mapProviders = Object.keys(this.oauthExcludedModels || {});
const filterType = (this.currentAuthFileFilter || '').toLowerCase();
const providers = Array.from(new Set([...allowedProviders, ...mapProviders].filter(Boolean)));
const prevValue = providerSelect.value || 'auto';
providerSelect.innerHTML = '';
const addOption = (value, textKey, fallbackText = null) => {
const opt = document.createElement('option');
opt.value = value;
if (textKey) {
opt.setAttribute('data-i18n-text', textKey);
opt.textContent = i18n.t(textKey);
} else {
opt.textContent = fallbackText || value;
}
providerSelect.appendChild(opt);
};
addOption('auto', 'oauth_excluded.provider_auto');
providers.sort((a, b) => a.localeCompare(b)).forEach(item => addOption(item, null, this.generateDynamicTypeLabel(item) || item));
const restoreValue = (() => {
if (prevValue && Array.from(providerSelect.options).some(opt => opt.value === prevValue)) {
return prevValue;
}
if (filterType && Array.from(providerSelect.options).some(opt => opt.value === filterType)) {
return filterType;
}
return 'auto';
})();
providerSelect.value = restoreValue;
},
parseOauthExcludedModelsInput(input = '') {
const tokens = (input || '').split(/[\n,]/).map(token => token.trim()).filter(Boolean);
const unique = [];
tokens.forEach(token => {
if (!unique.includes(token)) {
unique.push(token);
}
});
return unique;
},
openOauthExcludedEditor(provider = '', models = null) {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
if (!modal || !modalBody) return;
const normalizedProvider = (provider || '').trim();
const fallbackProvider = normalizedProvider
|| ((this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all') ? this.currentAuthFileFilter : '');
let targetModels = models;
if ((targetModels === null || targetModels === undefined) && fallbackProvider) {
const existing = this.findOauthExcludedEntry(fallbackProvider);
if (existing) {
targetModels = existing.models;
}
}
modalBody.innerHTML = `
<h3>${fallbackProvider
? i18n.t('oauth_excluded.edit_title', { provider: this.generateDynamicTypeLabel(fallbackProvider) })
: i18n.t('oauth_excluded.add_title')
}</h3>
<div class="provider-item oauth-excluded-editor-card">
<div class="item-content">
<div class="form-group">
<label for="oauth-excluded-provider-select" data-i18n="oauth_excluded.provider_label">${i18n.t('oauth_excluded.provider_label')}</label>
<select id="oauth-excluded-provider-select"></select>
<p class="form-hint" data-i18n="oauth_excluded.provider_hint">${i18n.t('oauth_excluded.provider_hint')}</p>
</div>
<div class="form-group">
<label for="oauth-excluded-models" data-i18n="oauth_excluded.models_label">${i18n.t('oauth_excluded.models_label')}</label>
<textarea id="oauth-excluded-models" rows="5" data-i18n-placeholder="oauth_excluded.models_placeholder" placeholder="${i18n.t('oauth_excluded.models_placeholder')}"></textarea>
<p class="form-hint" data-i18n="oauth_excluded.models_hint">${i18n.t('oauth_excluded.models_hint')}</p>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" id="oauth-excluded-save">
<i class="fas fa-save"></i> ${i18n.t('oauth_excluded.save')}
</button>
<button class="btn btn-danger" id="oauth-excluded-delete">
<i class="fas fa-trash"></i> ${i18n.t('oauth_excluded.delete')}
</button>
<button class="btn btn-secondary" onclick="manager.closeModal()">
${i18n.t('common.cancel')}
</button>
</div>
`;
this.refreshOauthProviderOptions();
this.setOauthExcludedForm(fallbackProvider, targetModels != null ? targetModels : []);
this.showModal();
const saveBtn = document.getElementById('oauth-excluded-save');
if (saveBtn) {
saveBtn.onclick = () => this.saveOauthExcludedEntry();
}
const deleteBtn = document.getElementById('oauth-excluded-delete');
if (deleteBtn) {
deleteBtn.onclick = () => this.deleteOauthExcludedEntry();
}
const providerSelect = document.getElementById('oauth-excluded-provider-select');
const syncDeleteState = () => {
if (deleteBtn) {
deleteBtn.disabled = !this.getOauthExcludedProviderValue();
}
};
if (providerSelect) {
providerSelect.addEventListener('change', syncDeleteState);
}
syncDeleteState();
this.updateOauthExcludedButtonsState(false);
},
buildOauthExcludedItem(provider, models = []) {
const providerLabel = this.generateDynamicTypeLabel(provider) || provider;
const normalizedModels = Array.isArray(models) ? models.filter(Boolean) : [];
const tags = normalizedModels.length
? normalizedModels.map(model => `<span class="provider-model-tag"><span class="model-name">${this.escapeHtml(String(model))}</span></span>`).join('')
: `<span class="oauth-excluded-empty">${i18n.t('oauth_excluded.no_models')}</span>`;
const modelCount = normalizedModels.length;
return `
<div class="provider-item oauth-excluded-card" data-provider="${this.escapeHtml(provider)}">
<div class="item-content">
<div class="item-title">${this.escapeHtml(providerLabel)}</div>
<div class="item-meta">
<span class="item-subtitle">
${modelCount > 0
? i18n.t('oauth_excluded.model_count', { count: modelCount })
: i18n.t('oauth_excluded.no_models')}
</span>
</div>
<div class="provider-models oauth-excluded-tags">
${tags}
</div>
</div>
<div class="item-actions">
<button class="btn btn-secondary" data-action="edit" data-provider="${this.escapeHtml(provider)}">
<i class="fas fa-pen"></i>
</button>
<button class="btn btn-danger" data-action="delete" data-provider="${this.escapeHtml(provider)}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
},
renderOauthExcludedModels(filterType = this.currentAuthFileFilter) {
const container = document.getElementById('oauth-excluded-list');
const scopeEl = document.getElementById('oauth-excluded-scope');
if (!container) return;
const currentType = (filterType || 'all').toLowerCase();
const map = this.getFilteredOauthExcludedMap(currentType);
const providers = Object.keys(map || {});
if (scopeEl) {
const label = currentType === 'all'
? i18n.t('oauth_excluded.scope_all')
: i18n.t('oauth_excluded.scope_provider', { provider: this.generateDynamicTypeLabel(currentType) });
scopeEl.textContent = label;
}
if (!this.isConnected) {
container.innerHTML = `<div class="oauth-excluded-empty">${i18n.t('oauth_excluded.disconnected')}</div>`;
return;
}
if (this._oauthExcludedLoading) {
container.innerHTML = `<div class="loading-placeholder">${i18n.t('common.loading')}</div>`;
return;
}
if (!providers.length) {
const emptyKey = currentType === 'all'
? 'oauth_excluded.list_empty_all'
: 'oauth_excluded.list_empty_filtered';
container.innerHTML = `<div class="oauth-excluded-empty">${i18n.t(emptyKey)}</div>`;
return;
}
const itemsHtml = providers
.sort((a, b) => a.localeCompare(b))
.map(provider => this.buildOauthExcludedItem(provider, map[provider]))
.join('');
container.innerHTML = itemsHtml;
this.refreshOauthProviderOptions();
this.bindOauthExcludedActionEvents();
},
bindOauthExcludedActionEvents() {
const container = document.getElementById('oauth-excluded-list');
if (!container) return;
if (container._oauthExcludedListener) {
container.removeEventListener('click', container._oauthExcludedListener);
}
const listener = (event) => {
const button = event.target.closest('button[data-action]');
if (!button || !container.contains(button)) return;
const provider = button.dataset.provider;
if (!provider) return;
const entry = this.findOauthExcludedEntry(provider);
if (button.dataset.action === 'edit') {
this.openOauthExcludedEditor(provider, entry ? entry.models : []);
} else if (button.dataset.action === 'delete') {
this.deleteOauthExcludedEntry(provider);
}
};
container._oauthExcludedListener = listener;
container.addEventListener('click', listener);
},
updateOauthExcludedButtonsState(isLoading = false) {
const refreshBtn = document.getElementById('oauth-excluded-refresh');
const saveBtn = document.getElementById('oauth-excluded-save');
const deleteBtn = document.getElementById('oauth-excluded-delete');
const addBtn = document.getElementById('oauth-excluded-add');
const disabled = isLoading || !this.isConnected;
[refreshBtn, saveBtn, deleteBtn, addBtn].forEach(btn => {
if (btn) {
btn.disabled = disabled;
}
});
},
setOauthExcludedStatus(message = '') {
const statusEl = document.getElementById('oauth-excluded-status');
if (statusEl) {
statusEl.textContent = message || '';
}
},
async loadOauthExcludedModels(forceRefresh = false) {
if (!this.isConnected) {
this.renderOauthExcludedModels();
this.updateOauthExcludedButtonsState();
return;
}
if (this._oauthExcludedLoading) {
return;
}
this._oauthExcludedLoading = true;
this.updateOauthExcludedButtonsState(true);
this.setOauthExcludedStatus(i18n.t('oauth_excluded.refreshing'));
this.renderOauthExcludedModels();
try { try {
const formData = new FormData(); let targetMap = {};
formData.append('file', file, file.name); let hasData = false;
const response = await fetch(`${this.apiUrl}/auth-files`, { if (!forceRefresh) {
method: 'POST', const { map, found } = this.resolveOauthExcludedFromConfig();
headers: { if (found) {
'Authorization': `Bearer ${this.managementKey}` targetMap = map;
}, hasData = true;
body: formData }
});
this.updateVersionFromHeaders(response.headers);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
} }
this.clearCache(); // 清除缓存 if (!hasData) {
await this.loadAuthFiles(); try {
this.showNotification(i18n.t('auth_files.upload_success'), 'success'); const configSection = await this.getConfig('oauth-excluded-models', forceRefresh);
if (configSection !== undefined) {
targetMap = this.normalizeOauthExcludedMap(configSection);
hasData = true;
}
} catch (configError) {
console.warn('从配置获取 OAuth 排除列表失败,尝试回退接口:', configError);
}
}
if (!hasData) {
const data = await this.makeRequest('/oauth-excluded-models');
targetMap = this.normalizeOauthExcludedMap(data);
hasData = true;
}
this.oauthExcludedModels = targetMap;
this.setOauthExcludedStatus('');
} catch (error) { } catch (error) {
this.showNotification(`${i18n.t('notification.upload_failed')}: ${error.message}`, 'error'); console.error('加载 OAuth 排除列表失败:', error);
const message = `${i18n.t('oauth_excluded.load_failed')}: ${error.message}`;
this.setOauthExcludedStatus(message);
this.showNotification(message, 'error');
} finally { } finally {
// 清空文件输入框,允许重复上传同一文件 this._oauthExcludedLoading = false;
event.target.value = ''; this.updateOauthExcludedButtonsState(false);
this.renderOauthExcludedModels();
} }
},
async saveOauthExcludedEntry() {
if (!this.isConnected) {
this.showNotification(i18n.t('notification.connection_required'), 'error');
return;
}
const modelsInput = document.getElementById('oauth-excluded-models');
if (!modelsInput) return;
const providerValue = this.getOauthExcludedProviderValue();
if (!providerValue) {
this.showNotification(i18n.t('oauth_excluded.provider_required'), 'error');
return;
}
const models = this.parseOauthExcludedModelsInput(modelsInput.value);
this.updateOauthExcludedButtonsState(true);
this.setOauthExcludedStatus(i18n.t('oauth_excluded.saving'));
try {
await this.makeRequest('/oauth-excluded-models', {
method: 'PATCH',
body: JSON.stringify({
provider: providerValue,
models
})
});
const successKey = models.length === 0 ? 'oauth_excluded.delete_success' : 'oauth_excluded.save_success';
this.showNotification(i18n.t(successKey), 'success');
this.clearCache('oauth-excluded-models');
await this.loadOauthExcludedModels(true);
this.closeModal();
} catch (error) {
this.showNotification(`${i18n.t('oauth_excluded.save_failed')}: ${error.message}`, 'error');
} finally {
this.setOauthExcludedStatus('');
this.updateOauthExcludedButtonsState(false);
}
},
async deleteOauthExcludedEntry(providerOverride = null) {
if (!this.isConnected) {
this.showNotification(i18n.t('notification.connection_required'), 'error');
return;
}
const providerValue = (providerOverride || this.getOauthExcludedProviderValue() || '').trim();
if (!providerValue) {
this.showNotification(i18n.t('oauth_excluded.provider_required'), 'error');
return;
}
if (!confirm(i18n.t('oauth_excluded.delete_confirm', { provider: providerValue }))) {
return;
}
this.updateOauthExcludedButtonsState(true);
this.setOauthExcludedStatus(i18n.t('oauth_excluded.deleting'));
try {
await this.makeRequest(`/oauth-excluded-models?provider=${encodeURIComponent(providerValue)}`, { method: 'DELETE' });
this.showNotification(i18n.t('oauth_excluded.delete_success'), 'success');
this.clearCache('oauth-excluded-models');
await this.loadOauthExcludedModels(true);
this.closeModal();
} catch (error) {
this.showNotification(`${i18n.t('oauth_excluded.delete_failed')}: ${error.message}`, 'error');
} finally {
this.setOauthExcludedStatus('');
this.updateOauthExcludedButtonsState(false);
}
},
registerAuthFilesListeners() {
if (!this.events || typeof this.events.on !== 'function') {
return;
}
this.events.on('data:config-loaded', async (event) => {
const detail = event?.detail || {};
const config = detail.config || {};
const keyStats = detail.keyStats || null;
try {
await this.loadAuthFiles(keyStats);
} catch (error) {
console.error('加载认证文件失败:', error);
}
try {
const applied = this.applyOauthExcludedFromConfig(config, { render: true });
if (!applied) {
await this.loadOauthExcludedModels(true);
}
} catch (error) {
console.error('加载 OAuth 排除列表失败:', error);
}
});
this.events.on('connection:status-changed', (event) => {
const detail = event?.detail || {};
this.updateOauthExcludedButtonsState(false);
if (detail.isConnected) {
if (!this.applyOauthExcludedFromConfig(null, { render: true })) {
this.renderOauthExcludedModels();
}
} else {
this.renderOauthExcludedModels();
}
});
} }
}; };

View File

@@ -32,7 +32,6 @@ export const configEditorModule = {
} }
}); });
editorInstance.setSize('100%', '100%');
editorInstance.on('change', () => { editorInstance.on('change', () => {
this.isConfigEditorDirty = true; this.isConfigEditorDirty = true;
this.updateConfigEditorStatus('info', i18n.t('config_management.status_dirty')); this.updateConfigEditorStatus('info', i18n.t('config_management.status_dirty'));
@@ -152,16 +151,13 @@ export const configEditorModule = {
const requestUrl = '/config.yaml'; const requestUrl = '/config.yaml';
try { try {
const response = await fetch(`${this.apiUrl}${requestUrl}`, { const response = await this.apiClient.requestRaw(requestUrl, {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${this.managementKey}`,
'Accept': 'application/yaml' 'Accept': 'application/yaml'
} }
}); });
this.updateVersionFromHeaders(response.headers);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text().catch(() => ''); const errorText = await response.text().catch(() => '');
const message = errorText || `HTTP ${response.status}`; const message = errorText || `HTTP ${response.status}`;
@@ -212,7 +208,9 @@ export const configEditorModule = {
this.showNotification(i18n.t('config_management.save_success'), 'success'); this.showNotification(i18n.t('config_management.save_success'), 'success');
this.updateConfigEditorStatus('success', i18n.t('config_management.status_saved')); this.updateConfigEditorStatus('success', i18n.t('config_management.status_saved'));
this.clearCache(); this.clearCache();
await this.loadAllData(true); if (this.events && typeof this.events.emit === 'function') {
this.events.emit('config:refresh-requested', { forceRefresh: true });
}
} catch (error) { } catch (error) {
const errorMessage = `${i18n.t('config_management.status_save_failed')}: ${error.message}`; const errorMessage = `${i18n.t('config_management.status_save_failed')}: ${error.message}`;
this.updateConfigEditorStatus('error', errorMessage); this.updateConfigEditorStatus('error', errorMessage);
@@ -227,18 +225,15 @@ export const configEditorModule = {
}, },
async writeConfigFile(endpoint, yamlText) { async writeConfigFile(endpoint, yamlText) {
const response = await fetch(`${this.apiUrl}${endpoint}`, { const response = await this.apiClient.requestRaw(endpoint, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': `Bearer ${this.managementKey}`,
'Content-Type': 'application/yaml', 'Content-Type': 'application/yaml',
'Accept': 'application/json, text/plain, */*' 'Accept': 'application/json, text/plain, */*'
}, },
body: yamlText body: yamlText
}); });
this.updateVersionFromHeaders(response.headers);
if (!response.ok) { if (!response.ok) {
const contentType = response.headers.get('content-type') || ''; const contentType = response.headers.get('content-type') || '';
let errorText = ''; let errorText = '';
@@ -258,5 +253,20 @@ export const configEditorModule = {
throw new Error(data.message || data.error || 'Server rejected the update'); throw new Error(data.message || data.error || 'Server rejected the update');
} }
} }
},
registerConfigEditorListeners() {
if (!this.events || typeof this.events.on !== 'function') {
return;
}
this.events.on('data:config-loaded', async (event) => {
const detail = event?.detail || {};
try {
await this.loadConfigFileEditor(detail.forceRefresh || false);
this.refreshConfigEditor();
} catch (error) {
console.error('加载配置文件失败:', error);
}
});
} }
}; };

View File

@@ -12,15 +12,26 @@ export const languageModule = {
}, },
toggleLanguage() { toggleLanguage() {
if (this.isLanguageRefreshInProgress) {
return;
}
this.isLanguageRefreshInProgress = true;
const currentLang = i18n.currentLanguage; const currentLang = i18n.currentLanguage;
const newLang = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN'; const newLang = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN';
i18n.setLanguage(newLang); i18n.setLanguage(newLang);
this.refreshBrandTitleAfterTextChange();
this.updateThemeButtons(); this.updateThemeButtons();
this.updateConnectionStatus(); this.updateConnectionStatus();
if (this.isLoggedIn && this.isConnected) { if (this.isLoggedIn && this.isConnected && this.events && typeof this.events.emit === 'function') {
this.loadAllData(true); this.events.emit('config:refresh-requested', { forceRefresh: true });
} }
// 简单释放锁,避免短时间内的重复触发
setTimeout(() => {
this.isLanguageRefreshInProgress = false;
}, 500);
} }
}; };

View File

@@ -39,7 +39,7 @@ export const loginModule = {
async attemptAutoLogin(apiBase, managementKey) { async attemptAutoLogin(apiBase, managementKey) {
try { try {
this.setApiBase(apiBase); this.setApiBase(apiBase);
this.managementKey = managementKey; this.setManagementKey(managementKey);
const savedProxy = localStorage.getItem('proxyUrl'); const savedProxy = localStorage.getItem('proxyUrl');
if (savedProxy) { if (savedProxy) {
@@ -66,6 +66,7 @@ export const loginModule = {
document.getElementById('login-page').style.display = 'flex'; document.getElementById('login-page').style.display = 'flex';
document.getElementById('main-page').style.display = 'none'; document.getElementById('main-page').style.display = 'none';
this.isLoggedIn = false; this.isLoggedIn = false;
this.resetBrandTitleState();
this.updateLoginConnectionInfo(); this.updateLoginConnectionInfo();
}, },
@@ -74,13 +75,13 @@ export const loginModule = {
document.getElementById('main-page').style.display = 'block'; document.getElementById('main-page').style.display = 'block';
this.isLoggedIn = true; this.isLoggedIn = true;
this.updateConnectionInfo(); this.updateConnectionInfo();
this.startBrandCollapseCycle();
}, },
async login(apiBase, managementKey) { async login(apiBase, managementKey) {
try { try {
this.setApiBase(apiBase); this.setApiBase(apiBase);
this.managementKey = managementKey; this.setManagementKey(managementKey);
secureStorage.setItem('managementKey', this.managementKey);
await this.testConnection(); await this.testConnection();
@@ -101,6 +102,15 @@ export const loginModule = {
this.clearCache(); this.clearCache();
this.stopStatusUpdateTimer(); this.stopStatusUpdateTimer();
this.resetVersionInfo(); this.resetVersionInfo();
this.setManagementKey('', { persist: false });
this.oauthExcludedModels = {};
this._oauthExcludedLoading = false;
if (typeof this.renderOauthExcludedModels === 'function') {
this.renderOauthExcludedModels('all');
}
if (typeof this.clearAvailableModels === 'function') {
this.clearAvailableModels('common.disconnected');
}
localStorage.removeItem('isLoggedIn'); localStorage.removeItem('isLoggedIn');
secureStorage.removeItem('managementKey'); secureStorage.removeItem('managementKey');
@@ -132,8 +142,7 @@ export const loginModule = {
} }
this.hideLoginError(); this.hideLoginError();
this.managementKey = managementKey; this.setManagementKey(managementKey);
secureStorage.setItem('managementKey', this.managementKey);
await this.login(this.apiBase, this.managementKey); await this.login(this.apiBase, this.managementKey);
} catch (error) { } catch (error) {
@@ -210,6 +219,7 @@ export const loginModule = {
if (loginKeyInput && savedKey) { if (loginKeyInput && savedKey) {
loginKeyInput.value = savedKey; loginKeyInput.value = savedKey;
} }
this.setManagementKey(savedKey || '', { persist: false });
this.setupLoginAutoSave(); this.setupLoginAutoSave();
}, },
@@ -220,9 +230,9 @@ export const loginModule = {
const resetButton = document.getElementById('login-reset-api-base'); const resetButton = document.getElementById('login-reset-api-base');
const saveKey = (val) => { const saveKey = (val) => {
if (val.trim()) { const trimmed = val.trim();
this.managementKey = val; if (trimmed) {
secureStorage.setItem('managementKey', this.managementKey); this.setManagementKey(trimmed);
} }
}; };
const saveKeyDebounced = this.debounce(saveKey, 500); const saveKeyDebounced = this.debounce(saveKey, 500);

View File

@@ -20,8 +20,20 @@ export const logsModule = {
} }
let url = '/logs'; let url = '/logs';
const params = new URLSearchParams();
if (incremental && this.latestLogTimestamp) { if (incremental && this.latestLogTimestamp) {
url += `?after=${this.latestLogTimestamp}`; params.set('after', this.latestLogTimestamp);
}
const logFetchLimit = Number.isFinite(this.logFetchLimit) ? this.logFetchLimit : 2500;
if (logFetchLimit > 0) {
params.set('limit', logFetchLimit);
}
const queryString = params.toString();
if (queryString) {
url += `?${queryString}`;
} }
const response = await this.makeRequest(url, { const response = await this.makeRequest(url, {
@@ -38,20 +50,19 @@ export const logsModule = {
} else if (!incremental && response.lines.length > 0) { } else if (!incremental && response.lines.length > 0) {
this.renderLogs(response.lines, response['line-count'] || response.lines.length, true); this.renderLogs(response.lines, response['line-count'] || response.lines.length, true);
} else if (!incremental) { } else if (!incremental) {
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
i18n.t('logs.empty_desc') + '</p></div>';
this.latestLogTimestamp = null; this.latestLogTimestamp = null;
this.renderLogs([], 0, false);
} }
} else if (!incremental) { } else if (!incremental) {
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
i18n.t('logs.empty_desc') + '</p></div>';
this.latestLogTimestamp = null; this.latestLogTimestamp = null;
this.renderLogs([], 0, false);
} }
} catch (error) { } catch (error) {
console.error('加载日志失败:', error); console.error('加载日志失败:', error);
if (!incremental) { if (!incremental) {
this.allLogLines = [];
this.displayedLogLines = [];
this.latestLogTimestamp = null;
const is404 = error.message && (error.message.includes('404') || error.message.includes('Not Found')); const is404 = error.message && (error.message.includes('404') || error.message.includes('Not Found'));
if (is404) { if (is404) {
@@ -70,7 +81,17 @@ export const logsModule = {
const logsContent = document.getElementById('logs-content'); const logsContent = document.getElementById('logs-content');
if (!logsContent) return; if (!logsContent) return;
if (!lines || lines.length === 0) { const sourceLines = Array.isArray(lines) ? lines : [];
const filteredLines = sourceLines.filter(line => !line.includes('/v0/management/'));
let displayedLines = filteredLines;
if (filteredLines.length > this.maxDisplayLogLines) {
const linesToRemove = filteredLines.length - this.maxDisplayLogLines;
displayedLines = filteredLines.slice(linesToRemove);
}
this.allLogLines = displayedLines.slice();
if (displayedLines.length === 0) {
this.displayedLogLines = []; this.displayedLogLines = [];
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' + logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' + i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
@@ -78,14 +99,15 @@ export const logsModule = {
return; return;
} }
const filteredLines = lines.filter(line => !line.includes('/v0/management/')); const visibleLines = this.filterLogLinesBySearch(displayedLines);
let displayedLines = filteredLines; this.displayedLogLines = visibleLines.slice();
if (filteredLines.length > this.maxDisplayLogLines) {
const linesToRemove = filteredLines.length - this.maxDisplayLogLines;
displayedLines = filteredLines.slice(linesToRemove);
}
this.displayedLogLines = displayedLines.slice(); if (visibleLines.length === 0) {
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-search"></i><p data-i18n="logs.search_empty_title">' +
i18n.t('logs.search_empty_title') + '</p><p data-i18n="logs.search_empty_desc">' +
i18n.t('logs.search_empty_desc') + '</p></div>';
return;
}
const displayedLineCount = this.displayedLogLines.length; const displayedLineCount = this.displayedLogLines.length;
logsContent.innerHTML = ` logsContent.innerHTML = `
@@ -95,7 +117,7 @@ export const logsModule = {
<pre class="logs-text">${this.buildLogsHtml(this.displayedLogLines)}</pre> <pre class="logs-text">${this.buildLogsHtml(this.displayedLogLines)}</pre>
`; `;
if (scrollToBottom) { if (scrollToBottom && !this.logSearchQuery) {
const logsTextElement = logsContent.querySelector('.logs-text'); const logsTextElement = logsContent.querySelector('.logs-text');
if (logsTextElement) { if (logsTextElement) {
logsTextElement.scrollTop = logsTextElement.scrollHeight; logsTextElement.scrollTop = logsTextElement.scrollHeight;
@@ -126,9 +148,21 @@ export const logsModule = {
const isAtBottom = logsTextElement.scrollHeight - logsTextElement.scrollTop - logsTextElement.clientHeight < 50; const isAtBottom = logsTextElement.scrollHeight - logsTextElement.scrollTop - logsTextElement.clientHeight < 50;
this.displayedLogLines = this.displayedLogLines.concat(filteredNewLines); const baseLines = Array.isArray(this.allLogLines) && this.allLogLines.length > 0
if (this.displayedLogLines.length > this.maxDisplayLogLines) { ? this.allLogLines
this.displayedLogLines = this.displayedLogLines.slice(this.displayedLogLines.length - this.maxDisplayLogLines); : (Array.isArray(this.displayedLogLines) ? this.displayedLogLines : []);
this.allLogLines = baseLines.concat(filteredNewLines);
if (this.allLogLines.length > this.maxDisplayLogLines) {
this.allLogLines = this.allLogLines.slice(this.allLogLines.length - this.maxDisplayLogLines);
}
const visibleLines = this.filterLogLinesBySearch(this.allLogLines);
this.displayedLogLines = visibleLines.slice();
if (visibleLines.length === 0) {
this.renderLogs(this.allLogLines, this.allLogLines.length, false);
return;
} }
logsTextElement.innerHTML = this.buildLogsHtml(this.displayedLogLines); logsTextElement.innerHTML = this.buildLogsHtml(this.displayedLogLines);
@@ -138,11 +172,44 @@ export const logsModule = {
logsInfoElement.innerHTML = `<span><i class="fas fa-list-ol"></i> ${displayedLines} ${i18n.t('logs.lines')}</span>`; logsInfoElement.innerHTML = `<span><i class="fas fa-list-ol"></i> ${displayedLines} ${i18n.t('logs.lines')}</span>`;
} }
if (isAtBottom) { if (isAtBottom && !this.logSearchQuery) {
logsTextElement.scrollTop = logsTextElement.scrollHeight; logsTextElement.scrollTop = logsTextElement.scrollHeight;
} }
}, },
filterLogLinesBySearch(lines) {
const keyword = (this.logSearchQuery || '').toLowerCase();
if (!keyword) {
return Array.isArray(lines) ? lines.slice() : [];
}
if (!Array.isArray(lines) || lines.length === 0) {
return [];
}
return lines.filter(line => (line || '').toLowerCase().includes(keyword));
},
updateLogSearchQuery(value = '') {
const normalized = (value || '').trim();
if (this.logSearchQuery === normalized) {
return;
}
this.logSearchQuery = normalized;
this.applyLogSearchFilter();
},
applyLogSearchFilter() {
const logsContent = document.getElementById('logs-content');
if (!logsContent) return;
if (logsContent.querySelector('.upgrade-notice') || logsContent.querySelector('.error-state')) {
return;
}
const baseLines = Array.isArray(this.allLogLines) ? this.allLogLines : [];
if (baseLines.length === 0 && logsContent.querySelector('.loading-placeholder')) {
return;
}
this.renderLogs(baseLines, baseLines.length, false);
},
buildLogsHtml(lines) { buildLogsHtml(lines) {
if (!lines || lines.length === 0) { if (!lines || lines.length === 0) {
return ''; return '';
@@ -334,6 +401,162 @@ export const logsModule = {
return null; return null;
}, },
async openErrorLogsModal() {
const modalBody = document.getElementById('modal-body');
if (!modalBody) return;
modalBody.innerHTML = `
<h3>${i18n.t('logs.error_logs_modal_title')}</h3>
<div class="provider-item">
<div class="item-content">
<p class="form-hint">${i18n.t('logs.error_logs_description')}</p>
<div class="loading-placeholder">${i18n.t('common.loading')}</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.close')}</button>
</div>
`;
this.showModal();
try {
const response = await this.makeRequest('/request-error-logs', {
method: 'GET'
});
const files = Array.isArray(response?.files) ? response.files.slice() : [];
if (files.length > 1) {
files.sort((a, b) => (b.modified || 0) - (a.modified || 0));
}
modalBody.innerHTML = this.buildErrorLogsModal(files);
this.showModal();
this.bindErrorLogDownloadButtons();
} catch (error) {
console.error('加载错误日志列表失败:', error);
modalBody.innerHTML = `
<h3>${i18n.t('logs.error_logs_modal_title')}</h3>
<div class="provider-item">
<div class="item-content">
<div class="error-state">
<i class="fas fa-exclamation-triangle"></i>
<p>${i18n.t('logs.error_logs_load_error')}</p>
<p>${this.escapeHtml(error.message || '')}</p>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.close')}</button>
</div>
`;
this.showNotification(`${i18n.t('logs.error_logs_load_error')}: ${error.message}`, 'error');
}
},
buildErrorLogsModal(files) {
const listHtml = Array.isArray(files) && files.length > 0
? files.map(file => this.buildErrorLogCard(file)).join('')
: `
<div class="empty-state">
<i class="fas fa-inbox"></i>
<h3>${i18n.t('logs.error_logs_empty')}</h3>
<p>${i18n.t('logs.error_logs_description')}</p>
</div>
`;
return `
<h3>${i18n.t('logs.error_logs_modal_title')}</h3>
<p class="form-hint">${i18n.t('logs.error_logs_description')}</p>
<div class="provider-list">
${listHtml}
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.close')}</button>
</div>
`;
},
buildErrorLogCard(file) {
const name = file?.name || '';
const size = typeof file?.size === 'number' ? this.formatFileSize(file.size) : '-';
const modified = file?.modified ? this.formatErrorLogTime(file.modified) : '-';
return `
<div class="provider-item">
<div class="item-content">
<div class="item-title">${this.escapeHtml(name)}</div>
<div class="item-subtitle">${i18n.t('logs.error_logs_size')}: ${this.escapeHtml(size)}</div>
<div class="item-subtitle">${i18n.t('logs.error_logs_modified')}: ${this.escapeHtml(modified)}</div>
</div>
<div class="item-actions">
<button class="btn btn-secondary error-log-download-btn" data-log-name="${this.escapeHtml(name)}">
<i class="fas fa-download"></i> ${i18n.t('logs.error_logs_download')}
</button>
</div>
</div>
`;
},
bindErrorLogDownloadButtons() {
const modalBody = document.getElementById('modal-body');
if (!modalBody) return;
const buttons = modalBody.querySelectorAll('.error-log-download-btn');
buttons.forEach(button => {
button.onclick = () => {
const filename = button.getAttribute('data-log-name');
if (filename) {
this.downloadErrorLog(filename);
}
};
});
},
formatErrorLogTime(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return '-';
}
const date = new Date(numeric * 1000);
if (Number.isNaN(date.getTime())) {
return '-';
}
const locale = i18n?.currentLanguage || undefined;
return date.toLocaleString(locale);
},
async downloadErrorLog(filename) {
if (!filename) return;
try {
const response = await this.apiClient.requestRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
method: 'GET'
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
try {
const errorData = await response.json();
if (errorData && errorData.error) {
errorMessage = errorData.error;
}
} catch (parseError) {
// ignore JSON parse error and use default message
}
throw new Error(errorMessage);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
this.showNotification(i18n.t('logs.error_log_download_success'), 'success');
} catch (error) {
console.error('下载错误日志失败:', error);
this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error');
}
},
async downloadLogs() { async downloadLogs() {
try { try {
const response = await this.makeRequest('/logs', { const response = await this.makeRequest('/logs', {
@@ -406,5 +629,29 @@ export const logsModule = {
} }
this.showNotification(i18n.t('logs.auto_refresh_disabled'), 'info'); this.showNotification(i18n.t('logs.auto_refresh_disabled'), 'info');
} }
},
registerLogsListeners() {
if (!this.events || typeof this.events.on !== 'function') {
return;
}
this.events.on('connection:status-changed', (event) => {
const detail = event?.detail || {};
if (detail.isConnected) {
// 仅在日志页激活时刷新,避免非日志页面触发请求
const logsSection = document.getElementById('logs');
if (logsSection && logsSection.classList.contains('active')) {
this.refreshLogs(false);
}
} else {
this.latestLogTimestamp = null;
}
});
this.events.on('navigation:section-activated', (event) => {
const detail = event?.detail || {};
if (detail.sectionId === 'logs' && this.isConnected) {
this.refreshLogs(false);
}
});
} }
}; };

View File

@@ -15,12 +15,13 @@ export const navigationModule = {
section.classList.add('active'); section.classList.add('active');
} }
if (sectionId === 'logs') { if (sectionId === 'config-management') {
this.refreshLogs(false);
} else if (sectionId === 'config-management') {
this.loadConfigFileEditor(); this.loadConfigFileEditor();
this.refreshConfigEditor(); this.refreshConfigEditor();
} }
if (this.events && typeof this.events.emit === 'function') {
this.events.emit('navigation:section-activated', { sectionId });
}
}); });
}); });
}, },

View File

@@ -300,6 +300,135 @@ export const oauthModule = {
} }
}, },
// ===== Antigravity OAuth 相关方法 =====
// 开始 Antigravity OAuth 流程
async startAntigravityOAuth() {
try {
const response = await this.makeRequest('/antigravity-auth-url?is_webui=1');
const authUrl = response.url;
const state = response.state || this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('antigravity-oauth-url');
const content = document.getElementById('antigravity-oauth-content');
const status = document.getElementById('antigravity-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.antigravity_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startAntigravityOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.antigravity_oauth_start_error')} ${error.message}`, 'error');
}
},
// 打开 Antigravity 授权链接
openAntigravityLink() {
const urlInput = document.getElementById('antigravity-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 Antigravity 授权链接
async copyAntigravityLink() {
const urlInput = document.getElementById('antigravity-oauth-url');
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
urlInput.select();
document.execCommand('copy');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
},
// 开始轮询 Antigravity OAuth 状态
startAntigravityOAuthPolling(state) {
if (!state) {
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
const pollInterval = setInterval(async () => {
try {
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
const status = response.status;
const statusElement = document.getElementById('antigravity-oauth-status');
if (status === 'ok') {
clearInterval(pollInterval);
this.resetAntigravityOAuthUI();
this.showNotification(i18n.t('auth_login.antigravity_oauth_status_success'), 'success');
this.loadAuthFiles();
} else if (status === 'error') {
clearInterval(pollInterval);
const errorMessage = response.error || 'Unknown error';
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.antigravity_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.antigravity_oauth_status_error')} ${errorMessage}`, 'error');
setTimeout(() => {
this.resetAntigravityOAuthUI();
}, 3000);
} else if (status === 'wait') {
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.antigravity_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('antigravity-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.antigravity_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.antigravity_oauth_polling_error')} ${error.message}`, 'error');
setTimeout(() => {
this.resetAntigravityOAuthUI();
}, 3000);
}
}, 2000);
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 Antigravity OAuth UI 到初始状态
resetAntigravityOAuthUI() {
const urlInput = document.getElementById('antigravity-oauth-url');
const content = document.getElementById('antigravity-oauth-content');
const status = document.getElementById('antigravity-oauth-status');
if (urlInput) {
urlInput.value = '';
}
if (content) {
content.style.display = 'none';
}
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
},
// ===== Gemini CLI OAuth 相关方法 ===== // ===== Gemini CLI OAuth 相关方法 =====
// 开始 Gemini CLI OAuth 流程 // 开始 Gemini CLI OAuth 流程
@@ -733,6 +862,88 @@ export const oauthModule = {
status.style.color = ''; status.style.color = '';
status.className = ''; status.className = '';
} }
},
// 提交 iFlow Cookie 登录
async submitIflowCookieLogin() {
const cookieInput = document.getElementById('iflow-cookie-input');
const statusEl = document.getElementById('iflow-cookie-status');
const submitBtn = document.getElementById('iflow-cookie-submit');
const cookieValue = cookieInput ? cookieInput.value.trim() : '';
this.renderIflowCookieResult(null);
if (!cookieValue) {
this.showNotification(i18n.t('auth_login.iflow_cookie_required'), 'error');
if (statusEl) {
statusEl.textContent = `${i18n.t('auth_login.iflow_cookie_status_error')} ${i18n.t('auth_login.iflow_cookie_required')}`;
statusEl.style.color = 'var(--error-text)';
}
return;
}
try {
if (submitBtn) {
submitBtn.disabled = true;
}
if (statusEl) {
statusEl.textContent = i18n.t('auth_login.iflow_oauth_status_waiting');
statusEl.style.color = 'var(--warning-text)';
}
const response = await this.makeRequest('/iflow-auth-url', {
method: 'POST',
body: JSON.stringify({ cookie: cookieValue })
});
this.renderIflowCookieResult(response);
if (statusEl) {
statusEl.textContent = i18n.t('auth_login.iflow_cookie_status_success');
statusEl.style.color = 'var(--success-text)';
}
if (cookieInput) {
cookieInput.value = '';
}
this.showNotification(i18n.t('auth_login.iflow_cookie_status_success'), 'success');
this.loadAuthFiles();
} catch (error) {
if (statusEl) {
statusEl.textContent = `${i18n.t('auth_login.iflow_cookie_status_error')} ${error.message}`;
statusEl.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.iflow_cookie_start_error')} ${error.message}`, 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
}
}
},
renderIflowCookieResult(result = null) {
const container = document.getElementById('iflow-cookie-result');
const emailEl = document.getElementById('iflow-cookie-result-email');
const expiredEl = document.getElementById('iflow-cookie-result-expired');
const pathEl = document.getElementById('iflow-cookie-result-path');
const typeEl = document.getElementById('iflow-cookie-result-type');
if (!container || !emailEl || !expiredEl || !pathEl || !typeEl) {
return;
}
if (!result) {
container.style.display = 'none';
emailEl.textContent = '-';
expiredEl.textContent = '-';
pathEl.textContent = '-';
typeEl.textContent = '-';
return;
}
emailEl.textContent = result.email || '-';
expiredEl.textContent = result.expired || '-';
pathEl.textContent = result.saved_path || result.savedPath || result.path || '-';
typeEl.textContent = result.type || '-';
container.style.display = 'block';
} }
}; };

View File

@@ -275,6 +275,119 @@ export async function updateSwitchPreviewModel(enabled) {
} }
} }
// 统一应用配置到界面,供 connection 模块或事件总线调用
export async function applySettingsFromConfig(config = {}, keyStats = null) {
if (!config || typeof config !== 'object') {
return;
}
// 调试设置
if (config.debug !== undefined) {
const toggle = document.getElementById('debug-toggle');
if (toggle) {
toggle.checked = config.debug;
}
}
// 代理设置
if (config['proxy-url'] !== undefined) {
const proxyInput = document.getElementById('proxy-url');
if (proxyInput) {
proxyInput.value = config['proxy-url'] || '';
}
}
// 请求重试设置
if (config['request-retry'] !== undefined) {
const retryInput = document.getElementById('request-retry');
if (retryInput) {
retryInput.value = config['request-retry'];
}
}
// 配额超出行为
if (config['quota-exceeded']) {
if (config['quota-exceeded']['switch-project'] !== undefined) {
const toggle = document.getElementById('switch-project-toggle');
if (toggle) {
toggle.checked = config['quota-exceeded']['switch-project'];
}
}
if (config['quota-exceeded']['switch-preview-model'] !== undefined) {
const toggle = document.getElementById('switch-preview-model-toggle');
if (toggle) {
toggle.checked = config['quota-exceeded']['switch-preview-model'];
}
}
}
if (config['usage-statistics-enabled'] !== undefined) {
const usageToggle = document.getElementById('usage-statistics-enabled-toggle');
if (usageToggle) {
usageToggle.checked = config['usage-statistics-enabled'];
}
}
// 日志记录设置
if (config['logging-to-file'] !== undefined) {
const loggingToggle = document.getElementById('logging-to-file-toggle');
if (loggingToggle) {
loggingToggle.checked = config['logging-to-file'];
}
if (typeof this.toggleLogsNavItem === 'function') {
this.toggleLogsNavItem(config['logging-to-file']);
}
}
if (config['request-log'] !== undefined) {
const requestLogToggle = document.getElementById('request-log-toggle');
if (requestLogToggle) {
requestLogToggle.checked = config['request-log'];
}
}
if (config['ws-auth'] !== undefined) {
const wsAuthToggle = document.getElementById('ws-auth-toggle');
if (wsAuthToggle) {
wsAuthToggle.checked = config['ws-auth'];
}
}
// API 密钥
if (config['api-keys'] && typeof this.renderApiKeys === 'function') {
this.renderApiKeys(config['api-keys']);
}
// Gemini keys
if (typeof this.renderGeminiKeys === 'function') {
await this.renderGeminiKeys(this.getGeminiKeysFromConfig(config), keyStats);
}
// Codex 密钥
if (typeof this.renderCodexKeys === 'function') {
await this.renderCodexKeys(Array.isArray(config['codex-api-key']) ? config['codex-api-key'] : [], keyStats);
}
// Claude 密钥
if (typeof this.renderClaudeKeys === 'function') {
await this.renderClaudeKeys(Array.isArray(config['claude-api-key']) ? config['claude-api-key'] : [], keyStats);
}
// OpenAI 兼容提供商
if (typeof this.renderOpenAIProviders === 'function') {
await this.renderOpenAIProviders(Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : [], keyStats);
}
}
// 设置模块订阅全局事件,减少与连接层耦合
export function registerSettingsListeners() {
if (!this.events || typeof this.events.on !== 'function') {
return;
}
this.events.on('data:config-loaded', (event) => {
const detail = event?.detail || {};
this.applySettingsFromConfig(detail.config || {}, detail.keyStats || null);
});
}
export const settingsModule = { export const settingsModule = {
updateDebug, updateDebug,
updateProxyUrl, updateProxyUrl,
@@ -292,5 +405,7 @@ export const settingsModule = {
updateWsAuth, updateWsAuth,
updateLoggingToFile, updateLoggingToFile,
updateSwitchProject, updateSwitchProject,
updateSwitchPreviewModel updateSwitchPreviewModel,
applySettingsFromConfig,
registerSettingsListeners
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,12 @@ export const OAUTH_MAX_POLL_DURATION_MS = 5 * 60 * 1000;
*/ */
export const MAX_LOG_LINES = 2000; export const MAX_LOG_LINES = 2000;
/**
* 日志接口获取数量上限
* 限制后端返回的日志行数,避免一次拉取过多数据
*/
export const LOG_FETCH_LIMIT = 2500;
/** /**
* 认证文件列表默认每页显示数量 * 认证文件列表默认每页显示数量
*/ */
@@ -105,13 +111,14 @@ export const REQUEST_TIMEOUT_MS = 30 * 1000;
/** /**
* OAuth 卡片元素 ID 列表 * OAuth 卡片元素 ID 列表
* 用于根据主机环境隐藏/显示不同的 OAuth 选项 * 用于根据主机环境隐藏/显示不同的 OAuth 选项
* 注意: iflow-oauth-card 不在此列表中,因为它包含Cookie登录功能,该功能可在远程使用
*/ */
export const OAUTH_CARD_IDS = [ export const OAUTH_CARD_IDS = [
'codex-oauth-card', 'codex-oauth-card',
'anthropic-oauth-card', 'anthropic-oauth-card',
'antigravity-oauth-card',
'gemini-cli-oauth-card', 'gemini-cli-oauth-card',
'qwen-oauth-card', 'qwen-oauth-card'
'iflow-oauth-card'
]; ];
/** /**
@@ -120,6 +127,7 @@ export const OAUTH_CARD_IDS = [
export const OAUTH_PROVIDERS = { export const OAUTH_PROVIDERS = {
CODEX: 'codex', CODEX: 'codex',
ANTHROPIC: 'anthropic', ANTHROPIC: 'anthropic',
ANTIGRAVITY: 'antigravity',
GEMINI_CLI: 'gemini-cli', GEMINI_CLI: 'gemini-cli',
QWEN: 'qwen', QWEN: 'qwen',
IFLOW: 'iflow' IFLOW: 'iflow'

104
src/utils/models.js Normal file
View File

@@ -0,0 +1,104 @@
/**
* 模型工具函数
* 提供模型列表的规范化与去重能力
*/
export function normalizeModelList(payload, { dedupe = false } = {}) {
const toModel = (entry) => {
if (typeof entry === 'string') {
return { name: entry };
}
if (!entry || typeof entry !== 'object') {
return null;
}
const name = entry.id || entry.name || entry.model || entry.value;
if (!name) return null;
const alias = entry.alias || entry.display_name || entry.displayName;
const description = entry.description || entry.note || entry.comment;
const model = { name: String(name) };
if (alias && alias !== name) {
model.alias = String(alias);
}
if (description) {
model.description = String(description);
}
return model;
};
let models = [];
if (Array.isArray(payload)) {
models = payload.map(toModel).filter(Boolean);
} else if (payload && typeof payload === 'object') {
if (Array.isArray(payload.data)) {
models = payload.data.map(toModel).filter(Boolean);
} else if (Array.isArray(payload.models)) {
models = payload.models.map(toModel).filter(Boolean);
}
}
if (!dedupe) {
return models;
}
const seen = new Set();
return models.filter(model => {
const key = (model?.name || '').toLowerCase();
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
const MODEL_CATEGORIES = [
{ id: 'gpt', label: 'GPT', patterns: [/gpt/i, /\bo\d\b/i, /\bo\d+\.?/i, /\bchatgpt/i] },
{ id: 'claude', label: 'Claude', patterns: [/claude/i] },
{ id: 'gemini', label: 'Gemini', patterns: [/gemini/i, /\bgai\b/i] },
{ id: 'kimi', label: 'Kimi', patterns: [/kimi/i] },
{ id: 'qwen', label: 'Qwen', patterns: [/qwen/i] },
{ id: 'glm', label: 'GLM', patterns: [/glm/i, /chatglm/i] },
{ id: 'grok', label: 'Grok', patterns: [/grok/i] },
{ id: 'deepseek', label: 'DeepSeek', patterns: [/deepseek/i] }
];
function matchCategory(text) {
for (const category of MODEL_CATEGORIES) {
if (category.patterns.some(pattern => pattern.test(text))) {
return category.id;
}
}
return null;
}
export function classifyModels(models = [], { otherLabel = 'Other' } = {}) {
const groups = MODEL_CATEGORIES.map(category => ({
id: category.id,
label: category.label,
items: []
}));
const otherGroup = { id: 'other', label: otherLabel, items: [] };
models.forEach(model => {
const name = (model?.name || '').toString();
const alias = (model?.alias || '').toString();
const haystack = `${name} ${alias}`.toLowerCase();
const matchedId = matchCategory(haystack);
const target = matchedId ? groups.find(group => group.id === matchedId) : null;
if (target) {
target.items.push(model);
} else {
otherGroup.items.push(model);
}
});
const populatedGroups = groups.filter(group => group.items.length > 0);
if (otherGroup.items.length) {
populatedGroups.push(otherGroup);
}
return populatedGroups;
}

1044
styles.css

File diff suppressed because it is too large Load Diff