Compare commits

...

70 Commits
v1.4.2 ... main

Author SHA1 Message Date
Supra4E8C
03a1644df7 chore(build): bump Vite build target to ES2020 and update compatibility docs 2026-02-13 22:41:53 +08:00
Supra4E8C
9a6a8ba7fa docs: update README for v6.8.x and add missing section 2026-02-13 20:56:29 +08:00
Supra4E8C
3b886e47d2 chore: add MIT License file 2026-02-13 20:41:10 +08:00
Supra4E8C
06201a9fc4 feat(ai-providers): add Gemini proxy URL support in provider edit UI 2026-02-13 20:38:54 +08:00
Supra4E8C
ef448806aa Merge pull request #104 from moxi000/dev
feat(quota): support dynamic Codex additional limits and i18n
2026-02-13 20:22:19 +08:00
moxi
8a33f5ab55 fix(quota): use i18n params for additional limits and keep primary/secondary mapping 2026-02-13 19:52:38 +08:00
Supra4E8C
ab3922f9e6 fix(usage): make api details card scrollable 2026-02-13 16:13:15 +08:00
Supra4E8C
5dbff4c3e0 fix(usage): make model stats card scrollable 2026-02-13 16:11:28 +08:00
Supra4E8C
4dde62ac58 chore(usage): remove unused formatTokensInMillions 2026-02-13 15:59:07 +08:00
Supra4E8C
1d3335746b fix(usage): aggregate openai provider credential stats 2026-02-13 15:58:01 +08:00
Supra4E8C
c6d00e8b3f fix(usage): make sorting and api expansion keyboard accessible 2026-02-13 15:27:16 +08:00
Supra4E8C
9ef7d439d2 fix(usage): update chart labels when locale changes 2026-02-13 15:24:00 +08:00
Supra4E8C
c53a231c41 fix(usage): include auth-index-only usage in credential stats 2026-02-13 15:21:16 +08:00
Supra4E8C
705e6dac54 feat(usage): match credentials by source ID using config store props 2026-02-13 15:06:31 +08:00
Supra4E8C
daef2521f1 feat(usage): resolve provider-based auth_index via SHA-256 matching
Fetch all provider configs (Gemini, Claude, Codex, Vertex, OpenAI) and
compute SHA-256 auth_index from their API keys to map unresolved
credential entries to friendly provider names.
2026-02-13 14:08:25 +08:00
moxi
0640edc9c9 fix(quota): avoid fallback mislabeling for additional codex limits 2026-02-13 13:58:49 +08:00
moxi
7068588c58 feat(quota): support dynamic codex additional limits with i18n 2026-02-13 13:52:41 +08:00
Supra4E8C
de0753f0ce feat(usage): resolve credential names from auth files by auth_index 2026-02-13 13:44:12 +08:00
Supra4E8C
d027d04f64 feat(usage): use adaptive token format instead of fixed millions 2026-02-13 13:35:33 +08:00
Supra4E8C
c4ca9be7b5 feat(usage): add last refresh timestamp in header 2026-02-13 13:33:47 +08:00
Supra4E8C
180a4ccab4 feat(usage): add cost trend chart with hourly/daily toggle 2026-02-13 13:31:36 +08:00
Supra4E8C
78512f8039 feat(usage): add token type breakdown stacked chart 2026-02-13 13:29:21 +08:00
Supra4E8C
7cdede6de8 feat(usage): add success rate column to model stats table 2026-02-13 13:26:27 +08:00
Supra4E8C
7ec5329576 feat(usage): add column sorting to model stats and API details tables 2026-02-13 13:25:03 +08:00
Supra4E8C
5d0232e5de feat(usage): add credential (auth index) breakdown card 2026-02-13 13:23:09 +08:00
Supra4E8C
15c5f742f4 feat(auth-files): support editing priority/excluded_models/disable_cooling and localize auth field editor 2026-02-13 12:13:20 +08:00
Supra4E8C
b4cd8c946d Improve AuthFilesPage filter tag alignment and count typography 2026-02-13 00:55:25 +08:00
Supra4E8C
ee9b9f6e14 Align status bar comments with implemented time window 2026-02-12 23:58:23 +08:00
Supra4E8C
01abe3dc02 Handle clipboard copy failures in auth files page 2026-02-12 23:58:11 +08:00
Supra4E8C
b957d05636 Localize visual config select option labels 2026-02-12 23:57:02 +08:00
Supra4E8C
2a4ccff96e Prevent overlapping log auto-refresh requests 2026-02-12 23:54:26 +08:00
Supra4E8C
b5f869ed25 Fix wildcard exclusion regex escaping in auth files 2026-02-12 23:53:44 +08:00
Supra4E8C
50c1b0f4b3 feat(usage): replace time-range select with custom dropdown 2026-02-12 22:25:38 +08:00
Supra4E8C
887600c03a feat(usage): add time range filter for stats and charts 2026-02-12 21:35:59 +08:00
Supra4E8C
0fdebacc0b feat(usage): persist chart line selections in localStorage 2026-02-12 20:45:56 +08:00
Supra4E8C
4d5bb7e575 fix(config-editor): preserve comments when saving config.yaml in visual mode 2026-02-12 20:26:38 +08:00
Supra4E8C
2d841c0a2f fix(provider-list): Modify the keyField function to support index parameters and ensure uniqueness
fix(ai-providers): Optimize configuration synchronization logic in OpenAI editing layout
2026-02-12 16:36:44 +08:00
Supra4E8C
e40c3488fe Merge pull request #98 from razorback16/main
feat(quota): add Claude OAuth usage quota detection
2026-02-12 15:50:42 +08:00
Supra4E8C
04686aafc8 fix(ai-providers): stabilize OpenAI key test state during editing 2026-02-12 15:46:00 +08:00
Supra4E8C
9476afc41c Merge pull request #102 from moxi000/feat/openai-ui-ux-optimization
feat(ai-providers): 优化 OpenAI 编辑页 UI,支持批量与按 Key 单独测试模型连通性
2026-02-12 15:23:11 +08:00
moxi
ab6a1a412c fix(ai-providers): 统一 OpenAI key 表头与内容居中对齐 2026-02-12 00:08:10 +08:00
moxi
2cf1e23351 fix(ai-providers): 修复 OpenAI 密钥测试状态与共享样式回归 2026-02-11 23:51:53 +08:00
moxi
0089d4a705 chore: 同步 package-lock 以匹配依赖变更 2026-02-11 23:34:45 +08:00
moxi
c726fbc379 feat(ai-providers): 优化 OpenAI 编辑页 UI 交互与对齐 2026-02-11 23:31:43 +08:00
Razorback16
83f6a1a9f9 feat(quota): add Claude OAuth usage quota detection
Add Claude quota section to the Quota Management page, using the
Anthropic OAuth usage API (api.anthropic.com/api/oauth/usage) to
display utilization across all rate limit windows (5-hour, 7-day,
Opus, Sonnet, etc.) and extra usage credits.
2026-02-09 14:12:07 -08:00
LTbinglingfeng
027ab483d4 refactor(providers): remove deprecated AI provider modal implementations and unused modal types 2026-02-09 00:54:24 +08:00
LTbinglingfeng
535c303aec fix(ai-providers): enforce required provider name for OpenAI-compatible save 2026-02-09 00:21:56 +08:00
LTbinglingfeng
6c2cd761ba refactor(core): harden API parsing and improve type safety 2026-02-08 09:42:00 +08:00
LTbinglingfeng
3783bec983 fix(auth-files): refresh OAuth excluded/model-alias state when returning to Auth Files page 2026-02-07 22:37:12 +08:00
Supra4E8C
b90239d39c Merge pull request #95 from router-for-me/revert-93-claude-quota
Revert "feat(ui): added claude quota display"
2026-02-07 14:01:16 +08:00
Supra4E8C
f8d66917fd Revert "feat(ui): added claude quota display" 2026-02-07 14:00:50 +08:00
LTbinglingfeng
36bfd0fa6a chore(i18n): align en/ru OAuth disablement wording with updated zh-CN copy 2026-02-07 12:43:56 +08:00
LTbinglingfeng
709ce4c8dd feat(config): warn restart required when commercial mode changes 2026-02-07 12:31:17 +08:00
LTbinglingfeng
525b152a76 fix(config): preserve mobile scroll after API key modal close and add one-click key copy 2026-02-07 12:22:16 +08:00
LTbinglingfeng
e053854544 feat(system): redesign system info page and move request-log controls from layout footer 2026-02-07 12:03:40 +08:00
LTbinglingfeng
0b54b6de64 fix(auth-files): add Kimi to OAuth quick-fill provider tags 2026-02-07 10:57:52 +08:00
hkfires
0c8686cefa feat(i18n): update OAuth exclusion terminology to "禁用" for clarity 2026-02-07 07:52:12 +08:00
LTbinglingfeng
385117d01a fix(i18n): switch language via popover menu and complete Russian Kimi translations 2026-02-07 01:13:11 +08:00
LTbinglingfeng
700bff1d03 fix(i18n): harden language switching and enforce language list consistency 2026-02-07 00:43:36 +08:00
Supra4E8C
680b24026c Merge pull request #91 from unchase/feat/ru-localization
Feat: Add Russian localization
2026-02-07 00:24:35 +08:00
LTbinglingfeng
2da4099d0b feat(oauth): add kimi provider support 2026-02-06 23:35:47 +08:00
LTbinglingfeng
8acef95e5a add .gitignore 2026-02-06 22:43:50 +08:00
LTbinglingfeng
c892d939c7 feat(quota-ui): normalize Gemini vertex quota groups and streamline auth card refresh UX 2026-02-06 22:28:01 +08:00
Chebotov Nickolay
50ab96c3ed feat: add language dropdown 2026-02-06 15:20:25 +03:00
Chebotov Nickolay
0bb8090686 fix: address language review feedback 2026-02-06 15:08:53 +03:00
LTbinglingfeng
cade2647d6 feat(quota): add normalization for Gemini CLI model IDs and update quota groups 2026-02-06 19:11:57 +08:00
LTbinglingfeng
3661530f5f fix(ui): make payload visual editor responsive on mobile 2026-02-06 18:38:37 +08:00
Chebotov Nickolay
d5ccef8b24 chore: restore package lock 2026-02-06 12:29:23 +03:00
Chebotov Nickolay
ad6a3bd732 feat: expand Russian localization 2026-02-06 12:26:46 +03:00
Chebotov Nickolay
ad1387d076 feat(i18n): add Russian locale and enable 'ru' language; translate core keys to Russian 2026-02-06 11:26:32 +03:00
107 changed files with 6468 additions and 2739 deletions

2
.gitignore vendored
View File

@@ -10,6 +10,7 @@ api.md
usage.json
CLAUDE.md
AGENTS.md
management-api*
antigravity_usage.json
codex_usage.json
style.md
@@ -18,6 +19,7 @@ node_modules
dist
dist-ssr
*.local
skills
# Editor directories and files
settings.local.json

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Router-For.ME
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,23 +1,23 @@
# CLI Proxy API Management Center
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
A single-file Web UI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
[中文文档](README_CN.md)
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
**Example URL**: https://remote.router-for.me/
**Minimum Required Version**: ≥ 6.3.0 (recommended ≥ 6.5.0)
**Minimum Required Version**: ≥ 6.8.0 (recommended ≥ 6.8.15)
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
Since version 6.0.19, the Web UI ships with the main program; access it via `/management.html` on the API port once the service is running.
## What this is (and isnt)
- This repository is the WebUI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
- This repository is the Web UI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
- It is **not** a proxy and does not forward traffic.
## Quick start
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
### Option A: Use the Web UI bundled in CLI Proxy API (recommended)
1. Start your CLI Proxy API service.
2. Open: `http://<host>:<api_port>/management.html`
@@ -32,7 +32,7 @@ npm install
npm run dev
```
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
Open `http://localhost:5173`, then connect to your CLI Proxy API backend instance.
### Option C: Build a single HTML file
@@ -42,7 +42,7 @@ npm run build
```
- Output: `dist/index.html` (all assets are inlined).
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
- For CLI Proxy API bundling, the release workflow renames it to `management.html`.
- To preview locally: `npm run preview`
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
@@ -74,19 +74,48 @@ See `api.md` for the full authentication rules, server-side limits, and edge cas
## What you can manage (mapped to the UI pages)
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project or preview models when limits reached), usage statistics, request logging, file logging, WebSocket auth.
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
- **AI Providers**:
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side chat/completions test).
- Gemini/Codex/Claude/Vertex key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side "chat/completions" test).
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards), configure OAuth model alias mappings.
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
- **Quota Management**: manage quota limits and usage for Claude, Antigravity, Codex, Gemini CLI, and other providers.
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
## Tech Stack
- React 19 + TypeScript 5.9
- Vite 7 (single-file build)
- Zustand (state management)
- Axios (HTTP client)
- react-router-dom v7 (HashRouter)
- Chart.js (data visualization)
- CodeMirror 6 (YAML editor)
- SCSS Modules (styling)
- i18next (internationalization)
## Internationalization
Currently supports three languages:
- English (en)
- Simplified Chinese (zh-CN)
- Russian (ru)
The UI language is automatically detected from browser settings and can be manually switched at the bottom of the page.
## Browser Compatibility
- Build target: `ES2020`
- Supports modern browsers (Chrome, Firefox, Safari, Edge)
- Responsive layout for mobile and tablet access
## Build & release notes
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).

View File

@@ -1,14 +1,14 @@
# CLI Proxy API 管理中心
用于管理与排障 **CLI Proxy API** 的单文件 WebUIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
用于管理与故障排查 **CLI Proxy API** 的单文件 Web UIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等管理操作。
[English](README.md)
**主项目**: https://github.com/router-for-me/CLIProxyAPI
**示例地址**: https://remote.router-for.me/
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0
**最低版本要求**: ≥ 6.8.0(推荐 ≥ 6.8.15
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
从6.0.19版本开始,Web UI 随主程序一起提供;服务运行后,通过 API 端口上的"/management.html"访问它。
## 这是什么(以及不是什么)
@@ -17,7 +17,7 @@ Since version 6.0.19, the WebUI ships with the main program; access it via `/man
## 快速开始
### 方式 A使用 CLIProxyAPI 自带的 WebUI推荐
### 方式 A使用 CLI Proxy API 自带的 Web UI推荐
1. 启动 CLI Proxy API 服务。
2. 打开:`http://<host>:<api_port>/management.html`
@@ -32,7 +32,7 @@ npm install
npm run dev
```
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 后端实例。
### 方式 C构建单文件 HTML
@@ -42,7 +42,7 @@ npm run build
```
- 构建产物:`dist/index.html`(资源已全部内联)。
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`
- 在 CLI Proxy API 的发布流程里会重命名为 `management.html`
- 本地预览:`npm run preview`
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
@@ -51,7 +51,7 @@ npm run build
### API 地址怎么填
以下格式均可WebUI 会自动归一化:
以下格式均可Web UI 会自动归一化:
- `localhost:8317`
- `http://192.168.1.10:8317`
@@ -64,29 +64,57 @@ npm run build
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
这与 WebUI 中API Keys页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
这与 Web UI 中"API Keys"页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
### 远程管理
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
完整鉴权规则、限制与边界情况请查看 `api.md`
## 功能一览(按页面对应)
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退切项目/切预览模型、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退达到上限时切换项目或预览模型、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **API Keys**:管理代理 `api-keys`(增/改/删)。
- **AI 提供商**
- Gemini/Codex/Claude 配置Base URL、Headers、代理、模型别名、排除模型、Prefix
- Gemini/Codex/Claude/Vertex 配置Base URL、Headers、代理、模型别名、排除模型、Prefix
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only查看单个凭据可用模型依赖后端支持管理 OAuth 排除模型(支持 `*` 通配符)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only查看单个凭据可用模型依赖后端支持管理 OAuth 排除模型(支持 `*` 通配符);配置 OAuth 模型别名映射
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
- **配额管理**:管理 Claude、Antigravity、Codex、Gemini CLI 等提供商的配额上限与使用情况。
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
- **配置文件**:浏览器内编辑 `/config.yaml`YAML 高亮 + 搜索),保存/重载。
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
## 技术栈
- React 19 + TypeScript 5.9
- Vite 7单文件构建
- Zustand状态管理
- AxiosHTTP 客户端)
- react-router-dom v7HashRouter
- Chart.js数据可视化
- CodeMirror 6YAML 编辑器)
- SCSS Modules样式
- i18next国际化
## 多语言支持
目前支持三种语言:
- 英文 (en)
- 简体中文 (zh-CN)
- 俄文 (ru)
界面语言会根据浏览器设置自动切换,也可在页面底部手动切换。
## 浏览器兼容性
- 构建目标:`ES2020`
- 支持 Chrome、Firefox、Safari、Edge 等现代浏览器
- 支持移动端响应式布局,可通过手机/平板访问
## 构建与发布说明
- 使用 Vite 输出 **单文件 HTML**`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。

39
package-lock.json generated
View File

@@ -9,7 +9,6 @@
"version": "0.0.0",
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@openai/codex": "^0.98.0",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
@@ -73,7 +72,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -468,7 +466,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -1244,18 +1241,6 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@openai/codex": {
"version": "0.98.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.98.0.tgz",
"integrity": "sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -1946,7 +1931,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2034,7 +2018,6 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
@@ -2352,7 +2335,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2402,13 +2384,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -2564,7 +2546,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -2829,7 +2810,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3306,7 +3286,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -3636,7 +3615,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3743,7 +3721,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3761,7 +3738,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3870,7 +3846,6 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -4053,7 +4028,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4130,7 +4104,6 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -4260,7 +4233,6 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
@@ -4288,7 +4260,6 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -13,7 +13,6 @@
},
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@openai/codex": "^0.98.0",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",

View File

@@ -1,14 +1,13 @@
import {
ReactNode,
createContext,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
import './PageTransition.scss';
interface PageTransitionProps {
@@ -27,8 +26,6 @@ const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
const IOS_EXIT_DIM_OPACITY = 0.72;
type LayerStatus = 'current' | 'exiting' | 'stacked';
type Layer = {
key: string;
location: Location;
@@ -39,16 +36,6 @@ type TransitionDirection = 'forward' | 'backward';
type TransitionVariant = 'vertical' | 'ios';
type PageTransitionLayerContextValue = {
status: LayerStatus;
};
const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(null);
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}
export function PageTransition({
render,
getRouteOrder,

View File

@@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';
export type LayerStatus = 'current' | 'exiting' | 'stacked';
type PageTransitionLayerContextValue = {
status: LayerStatus;
};
export const PageTransitionLayerContext =
createContext<PageTransitionLayerContextValue | null>(null);
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}

View File

@@ -0,0 +1,37 @@
.payloadRuleModelRow {
display: grid;
grid-template-columns: 1fr 160px auto;
gap: 8px;
align-items: center;
}
.payloadRuleModelRowProtocolFirst {
grid-template-columns: 160px 1fr auto;
}
.payloadRuleParamRow {
display: grid;
grid-template-columns: 1fr 140px 1fr auto;
gap: 8px;
align-items: center;
}
.payloadFilterModelRow {
display: grid;
grid-template-columns: 1fr 160px auto;
gap: 8px;
align-items: center;
}
@media (max-width: 900px) {
.payloadRuleModelRow,
.payloadRuleModelRowProtocolFirst,
.payloadRuleParamRow,
.payloadFilterModelRow {
grid-template-columns: minmax(0, 1fr);
}
.payloadRowActionButton {
width: 100%;
}
}

View File

@@ -6,6 +6,8 @@ import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconChevronDown } from '@/components/ui/icons';
import { ConfigSection } from '@/components/config/ConfigSection';
import { useNotificationStore } from '@/stores';
import styles from './VisualConfigEditor.module.scss';
import type {
PayloadFilterRule,
PayloadModelEntry,
@@ -200,6 +202,7 @@ function ApiKeysCardEditor({
onChange: (nextValue: string) => void;
}) {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const apiKeys = useMemo(
() =>
value
@@ -262,6 +265,34 @@ function ApiKeysCardEditor({
closeModal();
};
const handleCopy = async (apiKey: string) => {
const copyByExecCommand = () => {
const textarea = document.createElement('textarea');
textarea.value = apiKey;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
if (!copied) throw new Error('copy_failed');
};
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(apiKey);
} else {
copyByExecCommand();
}
showNotification(t('notification.link_copied'), 'success');
} catch {
showNotification(t('notification.copy_failed'), 'error');
}
};
return (
<div className="form-group" style={{ marginBottom: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
@@ -293,6 +324,9 @@ function ApiKeysCardEditor({
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => handleCopy(key)} disabled={disabled}>
{t('common.copy')}
</Button>
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}>
{t('config_management.visual.common.edit')}
</Button>
@@ -358,7 +392,7 @@ function StringListEditor({
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, index) => (
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input
className="input"
placeholder={placeholder}
@@ -392,8 +426,24 @@ function PayloadRulesEditor({
protocolFirst?: boolean;
onChange: (next: PayloadRule[]) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
);
const payloadValueTypeOptions = useMemo(
() =>
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
@@ -471,7 +521,15 @@ function PayloadRulesEditor({
gap: 12,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
{t('config_management.visual.common.delete')}
@@ -483,17 +541,15 @@ function PayloadRulesEditor({
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
<div
key={model.id}
style={{
display: 'grid',
gridTemplateColumns: protocolFirst ? '160px 1fr auto' : '1fr 160px auto',
gap: 8,
}}
className={[styles.payloadRuleModelRow, protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '']
.filter(Boolean)
.join(' ')}
>
{protocolFirst ? (
<>
<ToastSelect
value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
@@ -521,7 +577,7 @@ function PayloadRulesEditor({
/>
<ToastSelect
value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
@@ -532,7 +588,13 @@ function PayloadRulesEditor({
/>
</>
)}
<Button variant="ghost" size="sm" onClick={() => removeModel(ruleIndex, modelIndex)} disabled={disabled}>
<Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeModel(ruleIndex, modelIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
@@ -547,7 +609,7 @@ function PayloadRulesEditor({
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.params')}</div>
{(rule.params.length ? rule.params : []).map((param, paramIndex) => (
<div key={param.id} style={{ display: 'grid', gridTemplateColumns: '1fr 140px 1fr auto', gap: 8 }}>
<div key={param.id} className={styles.payloadRuleParamRow}>
<input
className="input"
placeholder={t('config_management.visual.payload_rules.json_path')}
@@ -557,7 +619,7 @@ function PayloadRulesEditor({
/>
<ToastSelect
value={param.valueType}
options={VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS}
options={payloadValueTypeOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.param_type')}
onChange={(nextValue) =>
@@ -571,7 +633,13 @@ function PayloadRulesEditor({
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
disabled={disabled}
/>
<Button variant="ghost" size="sm" onClick={() => removeParam(ruleIndex, paramIndex)} disabled={disabled}>
<Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeParam(ruleIndex, paramIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>
@@ -617,8 +685,16 @@ function PayloadFilterRulesEditor({
disabled?: boolean;
onChange: (next: PayloadFilterRule[]) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
@@ -658,7 +734,15 @@ function PayloadFilterRulesEditor({
gap: 12,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{t('config_management.visual.payload_rules.rule')} {ruleIndex + 1}</div>
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
{t('config_management.visual.common.delete')}
@@ -668,7 +752,7 @@ function PayloadFilterRulesEditor({
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
{rule.models.map((model, modelIndex) => (
<div key={model.id} style={{ display: 'grid', gridTemplateColumns: '1fr 160px auto', gap: 8 }}>
<div key={model.id} className={styles.payloadFilterModelRow}>
<input
className="input"
placeholder={t('config_management.visual.payload_rules.model_name')}
@@ -678,7 +762,7 @@ function PayloadFilterRulesEditor({
/>
<ToastSelect
value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
@@ -687,7 +771,13 @@ function PayloadFilterRulesEditor({
})
}
/>
<Button variant="ghost" size="sm" onClick={() => removeModel(ruleIndex, modelIndex)} disabled={disabled}>
<Button
variant="ghost"
size="sm"
className={styles.payloadRowActionButton}
onClick={() => removeModel(ruleIndex, modelIndex)}
disabled={disabled}
>
{t('config_management.visual.common.delete')}
</Button>
</div>

View File

@@ -10,8 +10,6 @@ import {
import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { PageTransition } from '@/components/common/PageTransition';
import { MainRoutes } from '@/router/MainRoutes';
import {
@@ -33,8 +31,10 @@ import {
useNotificationStore,
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
@@ -172,44 +172,36 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
};
export function MainLayout() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion);
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme);
const cycleTheme = useThemeStore((state) => state.cycleTheme);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const languageMenuRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullBrandName = 'CLI Proxy API Management Center';
const abbrBrandName = t('title.abbr');
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
const isLogsPage = location.pathname.startsWith('/logs');
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
@@ -241,7 +233,7 @@ export function MainLayout() {
};
}, []);
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏)对齐到内容区而非整窗
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
useLayoutEffect(() => {
const updateContentCenter = () => {
const el = contentRef.current;
@@ -269,6 +261,7 @@ export function MainLayout() {
resizeObserver.disconnect();
}
window.removeEventListener('resize', updateContentCenter);
document.documentElement.style.removeProperty('--content-center-x');
};
}, []);
@@ -286,18 +279,30 @@ export function MainLayout() {
}, []);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
if (!languageMenuOpen) {
return;
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
const handlePointerDown = (event: MouseEvent) => {
if (!languageMenuRef.current?.contains(event.target as Node)) {
setLanguageMenuOpen(false);
}
};
}, []);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setLanguageMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleEscape);
};
}, [languageMenuOpen]);
const handleBrandClick = useCallback(() => {
if (!brandExpanded) {
@@ -312,59 +317,20 @@ export function MainLayout() {
}
}, [brandExpanded]);
const openRequestLogModal = useCallback(() => {
setRequestLogTouched(false);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
const toggleLanguageMenu = useCallback(() => {
setLanguageMenuOpen((prev) => !prev);
}, []);
const handleVersionTap = useCallback(() => {
versionTapCount.current += 1;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
}, 1500);
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
versionTapTimer.current = null;
const handleLanguageSelect = useCallback(
(nextLanguage: string) => {
if (!isSupportedLanguage(nextLanguage)) {
return;
}
openRequestLogModal();
}
}, [openRequestLogModal]);
const handleRequestLogSave = async () => {
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: any) {
updateConfigValue('request-log', previous);
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
} finally {
setRequestLogSaving(false);
}
};
setLanguage(nextLanguage);
setLanguageMenuOpen(false);
},
[setLanguage]
);
useEffect(() => {
fetchConfig().catch(() => {
@@ -475,7 +441,8 @@ export function MainLayout() {
setCheckingVersion(true);
try {
const data = await versionApi.checkLatest();
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
const comparison = compareVersions(latest, serverVersion);
if (!latest) {
@@ -493,8 +460,11 @@ export function MainLayout() {
} else {
showNotification(t('system_info.version_is_latest'), 'success');
}
} catch (error: any) {
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
const suffix = message ? `: ${message}` : '';
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
} finally {
setCheckingVersion(false);
}
@@ -566,9 +536,36 @@ export function MainLayout() {
>
{headerIcons.update}
</Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
{headerIcons.language}
</Button>
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
<Button
variant="ghost"
size="sm"
onClick={toggleLanguageMenu}
title={t('language.switch')}
aria-label={t('language.switch')}
aria-haspopup="menu"
aria-expanded={languageMenuOpen}
>
{headerIcons.language}
</Button>
{languageMenuOpen && (
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
{LANGUAGE_ORDER.map((lang) => (
<button
key={lang}
type="button"
className={`language-menu-option ${language === lang ? 'active' : ''}`}
onClick={() => handleLanguageSelect(lang)}
role="menuitemradio"
aria-checked={language === lang}
>
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
{language === lang ? <span className="language-menu-check"></span> : null}
</button>
))}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto'
? headerIcons.autoTheme
@@ -612,57 +609,8 @@ export function MainLayout() {
scrollContainerRef={contentRef}
/>
</main>
<footer className="footer">
<span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span>
<span className="footer-version" onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span>
<span>
{t('footer.build_date')}:{' '}
{serverBuildDate
? new Date(serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown')}
</span>
</footer>
</div>
</div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div>
);
}

View File

@@ -285,7 +285,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
useLayoutEffect(() => {
// updateLines is called after layout is calculated, ensuring elements are in place.
updateLines();
const raf = requestAnimationFrame(updateLines);
window.addEventListener('resize', updateLines);
return () => {
@@ -295,7 +294,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
}, [updateLines, aliasNodes]);
useLayoutEffect(() => {
updateLines();
const raf = requestAnimationFrame(updateLines);
return () => cancelAnimationFrame(raf);
}, [providerGroupHeights, updateLines]);

View File

@@ -1,281 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useConfigStore, useNotificationStore } from '@/stores';
import { ampcodeApi } from '@/services/api';
import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
import type { AmpcodeFormState } from '../types';
interface AmpcodeModalProps {
isOpen: boolean;
disableControls: boolean;
onClose: () => void;
onBusyChange?: (busy: boolean) => void;
}
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const config = useConfigStore((state) => state.config);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [mappingsDirty, setMappingsDirty] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const initializedRef = useRef(false);
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
useEffect(() => {
onBusyChange?.(loading || saving);
}, [loading, saving, onBusyChange]);
useEffect(() => {
if (!isOpen) {
initializedRef.current = false;
setLoading(false);
setSaving(false);
setError('');
setLoaded(false);
setMappingsDirty(false);
setForm(buildAmpcodeFormState(null));
onBusyChange?.(false);
return;
}
if (initializedRef.current) return;
initializedRef.current = true;
setLoading(true);
setLoaded(false);
setMappingsDirty(false);
setError('');
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
void (async () => {
try {
const ampcode = await ampcodeApi.getAmpcode();
setLoaded(true);
updateConfigValue('ampcode', ampcode);
clearCache('ampcode');
setForm(buildAmpcodeFormState(ampcode));
} catch (err: unknown) {
setError(getErrorMessage(err) || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
})();
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
const clearAmpcodeUpstreamApiKey = async () => {
showConfirmation({
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setSaving(true);
setError('');
try {
await ampcodeApi.clearUpstreamApiKey();
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = { ...previous };
delete next.upstreamApiKey;
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
},
});
};
const performSaveAmpcode = async () => {
setSaving(true);
setError('');
try {
const upstreamUrl = form.upstreamUrl.trim();
const overrideKey = form.upstreamApiKey.trim();
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
if (upstreamUrl) {
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
} else {
await ampcodeApi.clearUpstreamUrl();
}
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
if (loaded || mappingsDirty) {
if (modelMappings.length) {
await ampcodeApi.saveModelMappings(modelMappings);
} else {
await ampcodeApi.clearModelMappings();
}
}
if (overrideKey) {
await ampcodeApi.updateUpstreamApiKey(overrideKey);
}
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = {
upstreamUrl: upstreamUrl || undefined,
forceModelMappings: form.forceModelMappings,
};
if (previous.upstreamApiKey) {
next.upstreamApiKey = previous.upstreamApiKey;
}
if (Array.isArray(previous.modelMappings)) {
next.modelMappings = previous.modelMappings;
}
if (overrideKey) {
next.upstreamApiKey = overrideKey;
}
if (loaded || mappingsDirty) {
if (modelMappings.length) {
next.modelMappings = modelMappings;
} else {
delete next.modelMappings;
}
}
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_updated'), 'success');
onClose();
} catch (err: unknown) {
const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const saveAmpcode = async () => {
if (!loaded && mappingsDirty) {
showConfirmation({
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
variant: 'secondary', // Not dangerous, just a warning
confirmText: t('common.confirm'),
onConfirm: performSaveAmpcode,
});
return;
}
await performSaveAmpcode();
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={t('ai_providers.ampcode_modal_title')}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
{t('common.save')}
</Button>
</>
}
>
{error && <div className="error-box">{error}</div>}
<Input
label={t('ai_providers.ampcode_upstream_url_label')}
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
value={form.upstreamUrl}
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
disabled={loading || saving}
hint={t('ai_providers.ampcode_upstream_url_hint')}
/>
<Input
label={t('ai_providers.ampcode_upstream_api_key_label')}
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
type="password"
value={form.upstreamApiKey}
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
disabled={loading || saving}
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
/>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
marginTop: -8,
marginBottom: 12,
flexWrap: 'wrap',
}}
>
<div className="hint" style={{ margin: 0 }}>
{t('ai_providers.ampcode_upstream_api_key_current', {
key: config?.ampcode?.upstreamApiKey
? maskApiKey(config.ampcode.upstreamApiKey)
: t('common.not_set'),
})}
</div>
<Button
variant="danger"
size="sm"
onClick={clearAmpcodeUpstreamApiKey}
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
>
{t('ai_providers.ampcode_clear_upstream_api_key')}
</Button>
</div>
<div className="form-group">
<ToggleSwitch
label={t('ai_providers.ampcode_force_model_mappings_label')}
checked={form.forceModelMappings}
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
disabled={loading || saving}
/>
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
</div>
<div className="form-group">
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
<ModelInputList
entries={form.mappingEntries}
onChange={(entries) => {
setMappingsDirty(true);
setForm((prev) => ({ ...prev, mappingEntries: entries }));
}}
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
disabled={loading || saving}
/>
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -1,129 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
export function ClaudeModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: ClaudeModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.claude_edit_modal_title')
: t('ai_providers.claude_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.claude_add_modal_key_label')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.claude_add_modal_url_label')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.claude_add_modal_proxy_label')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
</div>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -1,117 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
export function CodexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: CodexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.codex_edit_modal_title')
: t('ai_providers.codex_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.codex_add_modal_key_label')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.codex_add_modal_url_label')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.codex_add_modal_proxy_label')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -1,113 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { GeminiKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { GeminiFormState, ProviderModalProps } from '../types';
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): GeminiFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
headers: [],
excludedModels: [],
excludedText: '',
});
export function GeminiModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: GeminiModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
const handleSave = () => {
void onSave(form, editIndex);
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.gemini_edit_modal_title')
: t('ai_providers.gemini_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.gemini_add_modal_key_label')}
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.gemini_base_url_label')}
placeholder={t('ai_providers.gemini_base_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -125,6 +125,12 @@ export function GeminiSection({
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
)}
{item.proxyUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (

View File

@@ -1,194 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { modelsApi } from '@/services/api';
import type { ApiKeyEntry } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
import { buildOpenAIModelsEndpoint } from '../utils';
import styles from '@/pages/AiProvidersPage.module.scss';
interface OpenAIDiscoveryModalProps {
isOpen: boolean;
baseUrl: string;
headers: HeaderEntry[];
apiKeyEntries: ApiKeyEntry[];
onClose: () => void;
onApply: (selected: ModelInfo[]) => void;
}
export function OpenAIDiscoveryModal({
isOpen,
baseUrl,
headers,
apiKeyEntries,
onClose,
onApply,
}: OpenAIDiscoveryModalProps) {
const { t } = useTranslation();
const [endpoint, setEndpoint] = useState('');
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
const filteredModels = useMemo(() => {
const filter = search.trim().toLowerCase();
if (!filter) return models;
return models.filter((model) => {
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);
});
}, [models, search]);
const fetchOpenaiModelDiscovery = useCallback(
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
const trimmedBaseUrl = baseUrl.trim();
if (!trimmedBaseUrl) return;
setLoading(true);
setError('');
try {
const headerObject = buildHeaderObject(headers);
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
const list = await modelsApi.fetchModelsViaApiCall(
trimmedBaseUrl,
hasAuthHeader ? undefined : firstKey,
headerObject
);
setModels(list);
} catch (err: unknown) {
if (allowFallback) {
try {
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
setModels(list);
return;
} catch (fallbackErr: unknown) {
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
setModels([]);
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
}
} else {
setModels([]);
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
}
} finally {
setLoading(false);
}
},
[apiKeyEntries, baseUrl, headers, t]
);
useEffect(() => {
if (!isOpen) return;
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
setModels([]);
setSearch('');
setSelected(new Set());
setError('');
void fetchOpenaiModelDiscovery();
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
const toggleSelection = (name: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const handleApply = () => {
const selectedModels = models.filter((model) => selected.has(model.name));
onApply(selectedModels);
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={t('ai_providers.openai_models_fetch_title')}
width={720}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={loading}>
{t('ai_providers.openai_models_fetch_back')}
</Button>
<Button onClick={handleApply} disabled={loading}>
{t('ai_providers.openai_models_fetch_apply')}
</Button>
</>
}
>
<div className="hint" style={{ marginBottom: 8 }}>
{t('ai_providers.openai_models_fetch_hint')}
</div>
<div className="form-group">
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input className="input" readOnly value={endpoint} />
<Button
variant="secondary"
size="sm"
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
loading={loading}
>
{t('ai_providers.openai_models_fetch_refresh')}
</Button>
</div>
</div>
<Input
label={t('ai_providers.openai_models_search_label')}
placeholder={t('ai_providers.openai_models_search_placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
) : models.length === 0 ? (
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
) : filteredModels.length === 0 ? (
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
) : (
<div className={styles.modelDiscoveryList}>
{filteredModels.map((model) => {
const checked = selected.has(model.name);
return (
<label
key={model.name}
className={`${styles.modelDiscoveryRow} ${checked ? styles.modelDiscoveryRowSelected : ''}`}
>
<input type="checkbox" checked={checked} onChange={() => toggleSelection(model.name)} />
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && <span className={styles.modelDiscoveryAlias}>{model.alias}</span>}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
);
})}
</div>
)}
</Modal>
);
}

View File

@@ -1,433 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import { useNotificationStore } from '@/stores';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import type { ModelInfo } from '@/utils/models';
import styles from '@/pages/AiProvidersPage.module.scss';
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
const OPENAI_TEST_TIMEOUT_MS = 30_000;
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): OpenAIFormState => ({
name: '',
prefix: '',
baseUrl: '',
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
modelEntries: [{ name: '', alias: '' }],
testModel: undefined,
});
export function OpenAIModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: OpenAIModalProps) {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
const [discoveryOpen, setDiscoveryOpen] = useState(false);
const [testModel, setTestModel] = useState('');
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
const availableModels = useMemo(
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
[form.modelEntries]
);
useEffect(() => {
if (!isOpen) {
setDiscoveryOpen(false);
return;
}
if (initialData) {
const modelEntries = modelsToEntries(initialData.models);
setForm({
name: initialData.name,
prefix: initialData.prefix ?? '',
baseUrl: initialData.baseUrl,
headers: headersToEntries(initialData.headers),
testModel: initialData.testModel,
modelEntries,
apiKeyEntries: initialData.apiKeyEntries?.length
? initialData.apiKeyEntries
: [buildApiKeyEntry()],
});
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
const initialModel =
initialData.testModel && available.includes(initialData.testModel)
? initialData.testModel
: available[0] || '';
setTestModel(initialModel);
} else {
setForm(buildEmptyForm());
setTestModel('');
}
setTestStatus('idle');
setTestMessage('');
setDiscoveryOpen(false);
}, [initialData, isOpen]);
useEffect(() => {
if (!isOpen) return;
if (availableModels.length === 0) {
if (testModel) {
setTestModel('');
setTestStatus('idle');
setTestMessage('');
}
return;
}
if (!testModel || !availableModels.includes(testModel)) {
setTestModel(availableModels[0]);
setTestStatus('idle');
setTestMessage('');
}
}, [availableModels, isOpen, testModel]);
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
const list = entries.length ? entries : [buildApiKeyEntry()];
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
};
const removeEntry = (idx: number) => {
const next = list.filter((_, i) => i !== idx);
setForm((prev) => ({
...prev,
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
}));
};
const addEntry = () => {
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
};
return (
<div className="stack">
{list.map((entry, index) => (
<div key={index} className="item-row">
<div className="item-meta">
<Input
label={`${t('common.api_key')} #${index + 1}`}
value={entry.apiKey}
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
/>
<Input
label={t('common.proxy_url')}
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
/>
</div>
<div className="item-actions">
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={list.length <= 1 || isSaving}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
{t('ai_providers.openai_keys_add_btn')}
</Button>
</div>
);
};
const openOpenaiModelDiscovery = () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
setDiscoveryOpen(true);
};
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
if (!selectedModels.length) {
setDiscoveryOpen(false);
return;
}
const mergedMap = new Map<string, ModelEntry>();
form.modelEntries.forEach((entry) => {
const name = entry.name.trim();
if (!name) return;
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
});
let addedCount = 0;
selectedModels.forEach((model) => {
const name = model.name.trim();
if (!name || mergedMap.has(name)) return;
mergedMap.set(name, { name, alias: model.alias ?? '' });
addedCount += 1;
});
const mergedEntries = Array.from(mergedMap.values());
setForm((prev) => ({
...prev,
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
}));
setDiscoveryOpen(false);
if (addedCount > 0) {
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
}
};
const testOpenaiProviderConnection = async () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
const message = t('notification.openai_test_url_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
if (!endpoint) {
const message = t('notification.openai_test_url_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
if (!firstKeyEntry) {
const message = t('notification.openai_test_key_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const modelName = testModel.trim() || availableModels[0] || '';
if (!modelName) {
const message = t('notification.openai_test_model_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const customHeaders = buildHeaderObject(form.headers);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!headers.Authorization && !headers['authorization']) {
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
}
setTestStatus('loading');
setTestMessage(t('ai_providers.openai_test_running'));
try {
const result = await apiCallApi.request(
{
method: 'POST',
url: endpoint,
header: Object.keys(headers).length ? headers : undefined,
data: JSON.stringify({
model: modelName,
messages: [{ role: 'user', content: 'Hi' }],
stream: false,
max_tokens: 5,
}),
},
{ timeout: OPENAI_TEST_TIMEOUT_MS }
);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
setTestStatus('success');
setTestMessage(t('ai_providers.openai_test_success'));
} catch (err: unknown) {
setTestStatus('error');
const message = getErrorMessage(err);
const errorCode =
typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: string }).code) : '';
const isTimeout =
errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
if (isTimeout) {
setTestMessage(t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }));
} else {
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
}
}
};
return (
<>
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.openai_edit_modal_title')
: t('ai_providers.openai_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.openai_add_modal_name_label')}
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.openai_add_modal_url_label')}
value={form.baseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>
{editIndex !== null
? t('ai_providers.openai_edit_modal_models_label')
: t('ai_providers.openai_add_modal_models_label')}
</label>
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.openai_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
{t('ai_providers.openai_models_fetch_button')}
</Button>
</div>
<div className="form-group">
<label>{t('ai_providers.openai_test_title')}</label>
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<select
className={`input ${styles.openaiTestSelect}`}
value={testModel}
onChange={(e) => {
setTestModel(e.target.value);
setTestStatus('idle');
setTestMessage('');
}}
disabled={isSaving || availableModels.length === 0}
>
<option value="">
{availableModels.length
? t('ai_providers.openai_test_select_placeholder')
: t('ai_providers.openai_test_select_empty')}
</option>
{form.modelEntries
.filter((entry) => entry.name.trim())
.map((entry, idx) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
const label = alias && alias !== name ? `${name} (${alias})` : name;
return (
<option key={`${name}-${idx}`} value={name}>
{label}
</option>
);
})}
</select>
<Button
variant={testStatus === 'error' ? 'danger' : 'secondary'}
className={`${styles.openaiTestButton} ${testStatus === 'success' ? styles.openaiTestButtonSuccess : ''}`}
onClick={testOpenaiProviderConnection}
loading={testStatus === 'loading'}
disabled={isSaving || availableModels.length === 0}
>
{t('ai_providers.openai_test_action')}
</Button>
</div>
{testMessage && (
<div
className={`status-badge ${
testStatus === 'error' ? 'error' : testStatus === 'success' ? 'success' : 'muted'
}`}
>
{testMessage}
</div>
)}
</div>
<div className="form-group">
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
{renderKeyEntries(form.apiKeyEntries)}
</div>
</Modal>
<OpenAIDiscoveryModal
isOpen={discoveryOpen}
baseUrl={form.baseUrl}
headers={form.headers}
apiKeyEntries={form.apiKeyEntries}
onClose={() => setDiscoveryOpen(false)}
onApply={applyOpenaiModelDiscoverySelection}
/>
</>
);
}

View File

@@ -87,7 +87,7 @@ export function OpenAISection({
<ProviderList<OpenAIProviderConfig>
items={configs}
loading={loading}
keyField={(item) => item.name}
keyField={(_, index) => `openai-provider-${index}`}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit}

View File

@@ -6,7 +6,7 @@ import { EmptyState } from '@/components/ui/EmptyState';
interface ProviderListProps<T> {
items: T[];
loading: boolean;
keyField: (item: T) => string;
keyField: (item: T, index: number) => string;
renderContent: (item: T, index: number) => ReactNode;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
@@ -48,7 +48,7 @@ export function ProviderList<T>({
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
return (
<div
key={keyField(item)}
key={keyField(item, index)}
className="item-row"
style={rowDisabled ? { opacity: 0.6 } : undefined}
>

View File

@@ -1,7 +1,7 @@
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom';
import { usePageTransitionLayer } from '@/components/common/PageTransition';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { useThemeStore } from '@/stores';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
@@ -135,8 +135,9 @@ export function ProviderNav() {
window.addEventListener('scroll', handleScroll, { passive: true });
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll);
handleScroll();
const raf = requestAnimationFrame(handleScroll);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
contentScroller?.removeEventListener('scroll', handleScroll);
@@ -168,7 +169,8 @@ export function ProviderNav() {
useLayoutEffect(() => {
if (!shouldShow) return;
updateIndicator(activeProvider);
const raf = requestAnimationFrame(() => updateIndicator(activeProvider));
return () => cancelAnimationFrame(raf);
}, [activeProvider, shouldShow, updateIndicator]);
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.

View File

@@ -1,118 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import type { ProviderModalProps, VertexFormState } from '../types';
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): VertexFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
modelEntries: [{ name: '', alias: '' }],
});
export function VertexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: VertexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.vertex_edit_modal_title')
: t('ai_providers.vertex_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.vertex_add_modal_key_label')}
placeholder={t('ai_providers.vertex_add_modal_key_placeholder')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.vertex_add_modal_url_label')}
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.vertex_add_modal_proxy_label')}
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.vertex_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.vertex_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -2,14 +2,6 @@ import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
import type { HeaderEntry } from '@/utils/headers';
import type { KeyStats, UsageDetail } from '@/utils/usage';
export type ProviderModal =
| { type: 'gemini'; index: number | null }
| { type: 'codex'; index: number | null }
| { type: 'claude'; index: number | null }
| { type: 'vertex'; index: number | null }
| { type: 'ampcode'; index: null }
| { type: 'openai'; index: number | null };
export interface ModelEntry {
name: string;
alias: string;
@@ -58,12 +50,3 @@ export interface ProviderSectionProps<TConfig> {
onDelete: (index: number) => void;
onToggle?: (index: number, enabled: boolean) => void;
}
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
isOpen: boolean;
editIndex: number | null;
initialData?: TConfig;
onClose: () => void;
onSave: (data: TPayload, index: number | null) => Promise<void>;
disabled?: boolean;
}

View File

@@ -5,5 +5,5 @@
export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';

View File

@@ -10,6 +10,10 @@ import type {
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
ClaudeExtraUsage,
ClaudeQuotaState,
ClaudeQuotaWindow,
ClaudeUsagePayload,
CodexRateLimitInfo,
CodexQuotaState,
CodexUsageWindow,
@@ -23,16 +27,21 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
CLAUDE_USAGE_URL,
CLAUDE_REQUEST_HEADERS,
CLAUDE_USAGE_WINDOW_KEYS,
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS,
normalizeAuthIndexValue,
normalizeGeminiCliModelId,
normalizeNumberValue,
normalizePlanType,
normalizeQuotaFraction,
normalizeStringValue,
parseAntigravityPayload,
parseClaudeUsagePayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
resolveCodexChatgptAccountId,
@@ -45,6 +54,7 @@ import {
createStatusError,
getStatusFromError,
isAntigravityFile,
isClaudeFile,
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
@@ -55,15 +65,17 @@ import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
@@ -201,11 +213,14 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const additionalRateLimits = payload.additional_rate_limits ?? payload.additionalRateLimits ?? [];
const windows: CodexQuotaWindow[] = [];
const addWindow = (
id: string,
labelKey: string,
label: string,
labelKey: string | undefined,
labelParams: Record<string, string | number> | undefined,
window?: CodexUsageWindow | null,
limitReached?: boolean,
allowed?: boolean
@@ -217,8 +232,9 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
windows.push({
id,
label: t(labelKey),
label,
labelKey,
labelParams,
usedPercent,
resetLabel,
});
@@ -233,12 +249,13 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const rawAllowed = rateLimit?.allowed;
const pickClassifiedWindows = (
limitInfo?: CodexRateLimitInfo | null
limitInfo?: CodexRateLimitInfo | null,
options?: { allowOrderFallback?: boolean }
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
const rawWindows = [
limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null,
limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null,
];
const allowOrderFallback = options?.allowOrderFallback ?? true;
const primaryWindow = limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null;
const secondaryWindow = limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null;
const rawWindows = [primaryWindow, secondaryWindow];
let fiveHourWindow: CodexUsageWindow | null = null;
let weeklyWindow: CodexUsageWindow | null = null;
@@ -253,20 +270,34 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
}
}
// For legacy payloads without window duration, fallback to primary/secondary ordering.
if (allowOrderFallback) {
if (!fiveHourWindow) {
fiveHourWindow = primaryWindow && primaryWindow !== weeklyWindow ? primaryWindow : null;
}
if (!weeklyWindow) {
weeklyWindow = secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null;
}
}
return { fiveHourWindow, weeklyWindow };
};
const rateWindows = pickClassifiedWindows(rateLimit);
addWindow(
WINDOW_META.codeFiveHour.id,
t(WINDOW_META.codeFiveHour.labelKey),
WINDOW_META.codeFiveHour.labelKey,
undefined,
rateWindows.fiveHourWindow,
rawLimitReached,
rawAllowed
);
addWindow(
WINDOW_META.codeWeekly.id,
t(WINDOW_META.codeWeekly.labelKey),
WINDOW_META.codeWeekly.labelKey,
undefined,
rateWindows.weeklyWindow,
rawLimitReached,
rawAllowed
@@ -277,19 +308,67 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const codeReviewAllowed = codeReviewLimit?.allowed;
addWindow(
WINDOW_META.codeReviewFiveHour.id,
t(WINDOW_META.codeReviewFiveHour.labelKey),
WINDOW_META.codeReviewFiveHour.labelKey,
undefined,
codeReviewWindows.fiveHourWindow,
codeReviewLimitReached,
codeReviewAllowed
);
addWindow(
WINDOW_META.codeReviewWeekly.id,
t(WINDOW_META.codeReviewWeekly.labelKey),
WINDOW_META.codeReviewWeekly.labelKey,
undefined,
codeReviewWindows.weeklyWindow,
codeReviewLimitReached,
codeReviewAllowed
);
const normalizeWindowId = (raw: string) =>
raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if (Array.isArray(additionalRateLimits)) {
additionalRateLimits.forEach((limitItem, index) => {
const rateInfo = limitItem?.rate_limit ?? limitItem?.rateLimit ?? null;
if (!rateInfo) return;
const limitName =
normalizeStringValue(limitItem?.limit_name ?? limitItem?.limitName) ??
normalizeStringValue(limitItem?.metered_feature ?? limitItem?.meteredFeature) ??
`additional-${index + 1}`;
const idPrefix = normalizeWindowId(limitName) || `additional-${index + 1}`;
const additionalPrimaryWindow = rateInfo.primary_window ?? rateInfo.primaryWindow ?? null;
const additionalSecondaryWindow = rateInfo.secondary_window ?? rateInfo.secondaryWindow ?? null;
const additionalLimitReached = rateInfo.limit_reached ?? rateInfo.limitReached;
const additionalAllowed = rateInfo.allowed;
addWindow(
`${idPrefix}-five-hour-${index}`,
t('codex_quota.additional_primary_window', { name: limitName }),
'codex_quota.additional_primary_window',
{ name: limitName },
additionalPrimaryWindow,
additionalLimitReached,
additionalAllowed
);
addWindow(
`${idPrefix}-weekly-${index}`,
t('codex_quota.additional_secondary_window', { name: limitName }),
'codex_quota.additional_secondary_window',
{ name: limitName },
additionalSecondaryWindow,
additionalLimitReached,
additionalAllowed
);
});
}
return windows;
};
@@ -368,7 +447,7 @@ const fetchGeminiCliQuota = async (
const parsedBuckets = buckets
.map((bucket) => {
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
const modelId = normalizeGeminiCliModelId(bucket.modelId ?? bucket.model_id);
if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
const remainingFractionRaw = normalizeQuotaFraction(
@@ -481,7 +560,9 @@ const renderCodexItems = (
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
const windowLabel = window.labelKey
? t(window.labelKey, window.labelParams as Record<string, string | number>)
: window.label;
return h(
'div',
@@ -557,6 +638,149 @@ const renderGeminiCliItems = (
});
};
const buildClaudeQuotaWindows = (
payload: ClaudeUsagePayload,
t: TFunction
): ClaudeQuotaWindow[] => {
const windows: ClaudeQuotaWindow[] = [];
for (const { key, id, labelKey } of CLAUDE_USAGE_WINDOW_KEYS) {
const window = payload[key as keyof ClaudeUsagePayload];
if (!window || typeof window !== 'object' || !('utilization' in window)) continue;
const typedWindow = window as { utilization: number; resets_at: string };
const usedPercent = normalizeNumberValue(typedWindow.utilization);
const resetLabel = formatQuotaResetTime(typedWindow.resets_at);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel,
});
}
return windows;
};
const fetchClaudeQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('claude_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CLAUDE_USAGE_URL,
header: { ...CLAUDE_REQUEST_HEADERS },
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseClaudeUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('claude_quota.empty_windows'));
}
const windows = buildClaudeQuotaWindows(payload, t);
return { windows, extraUsage: payload.extra_usage };
};
const renderClaudeItems = (
quota: ClaudeQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const extraUsage = quota.extraUsage ?? null;
const nodes: ReactNode[] = [];
if (extraUsage && extraUsage.is_enabled) {
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
nodes.push(
h(
'div',
{ key: 'extra', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.extra_usage_label')),
h('span', { className: styleMap.codexPlanValue }, usedLabel)
)
);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('claude_quota.empty_windows'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...windows.map((window) => {
const used = window.usedPercent;
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return h(
'div',
{ key: window.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, windowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
);
})
);
return h(Fragment, null, ...nodes);
};
export const CLAUDE_CONFIG: QuotaConfig<
ClaudeQuotaState,
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
> = {
type: 'claude',
i18nPrefix: 'claude_quota',
filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchClaudeQuota,
storeSelector: (state) => state.claudeQuota,
storeSetter: 'setClaudeQuota',
buildLoadingState: () => ({ status: 'loading', windows: [] }),
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
extraUsage: data.extraUsage,
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.claudeCard,
controlsClassName: styles.claudeControls,
controlClassName: styles.claudeControl,
gridClassName: styles.claudeGrid,
renderQuotaItems: renderClaudeItems,
};
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',

View File

@@ -10,6 +10,8 @@ interface HeaderInputListProps {
disabled?: boolean;
keyPlaceholder?: string;
valuePlaceholder?: string;
removeButtonTitle?: string;
removeButtonAriaLabel?: string;
}
export function HeaderInputList({
@@ -18,7 +20,9 @@ export function HeaderInputList({
addLabel,
disabled = false,
keyPlaceholder = 'X-Custom-Header',
valuePlaceholder = 'value'
valuePlaceholder = 'value',
removeButtonTitle = 'Remove',
removeButtonAriaLabel = 'Remove',
}: HeaderInputListProps) {
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
@@ -61,8 +65,8 @@ export function HeaderInputList({
size="sm"
onClick={() => removeEntry(index)}
disabled={disabled || currentEntries.length <= 1}
title="Remove"
aria-label="Remove"
title={removeButtonTitle}
aria-label={removeButtonAriaLabel}
>
<IconX size={14} />
</Button>

View File

@@ -16,11 +16,53 @@ const CLOSE_ANIMATION_DURATION = 350;
const MODAL_LOCK_CLASS = 'modal-open';
let activeModalCount = 0;
const scrollLockSnapshot = {
scrollY: 0,
contentScrollTop: 0,
contentEl: null as HTMLElement | null,
bodyPosition: '',
bodyTop: '',
bodyLeft: '',
bodyRight: '',
bodyWidth: '',
bodyOverflow: '',
htmlOverflow: '',
};
const resolveContentScrollContainer = () => {
if (typeof document === 'undefined') return null;
const contentEl = document.querySelector('.content');
return contentEl instanceof HTMLElement ? contentEl : null;
};
const lockScroll = () => {
if (typeof document === 'undefined') return;
if (activeModalCount === 0) {
document.body?.classList.add(MODAL_LOCK_CLASS);
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
const body = document.body;
const html = document.documentElement;
const contentEl = resolveContentScrollContainer();
scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0;
scrollLockSnapshot.contentEl = contentEl;
scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0;
scrollLockSnapshot.bodyPosition = body.style.position;
scrollLockSnapshot.bodyTop = body.style.top;
scrollLockSnapshot.bodyLeft = body.style.left;
scrollLockSnapshot.bodyRight = body.style.right;
scrollLockSnapshot.bodyWidth = body.style.width;
scrollLockSnapshot.bodyOverflow = body.style.overflow;
scrollLockSnapshot.htmlOverflow = html.style.overflow;
body.classList.add(MODAL_LOCK_CLASS);
html.classList.add(MODAL_LOCK_CLASS);
body.style.position = 'fixed';
body.style.top = `-${scrollLockSnapshot.scrollY}px`;
body.style.left = '0';
body.style.right = '0';
body.style.width = '100%';
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
}
activeModalCount += 1;
};
@@ -29,8 +71,31 @@ const unlockScroll = () => {
if (typeof document === 'undefined') return;
activeModalCount = Math.max(0, activeModalCount - 1);
if (activeModalCount === 0) {
document.body?.classList.remove(MODAL_LOCK_CLASS);
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
const body = document.body;
const html = document.documentElement;
const scrollY = scrollLockSnapshot.scrollY;
const contentScrollTop = scrollLockSnapshot.contentScrollTop;
const contentEl = scrollLockSnapshot.contentEl;
body.classList.remove(MODAL_LOCK_CLASS);
html.classList.remove(MODAL_LOCK_CLASS);
body.style.position = scrollLockSnapshot.bodyPosition;
body.style.top = scrollLockSnapshot.bodyTop;
body.style.left = scrollLockSnapshot.bodyLeft;
body.style.right = scrollLockSnapshot.bodyRight;
body.style.width = scrollLockSnapshot.bodyWidth;
body.style.overflow = scrollLockSnapshot.bodyOverflow;
html.style.overflow = scrollLockSnapshot.htmlOverflow;
if (contentEl) {
contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' });
}
window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' });
scrollLockSnapshot.scrollY = 0;
scrollLockSnapshot.contentScrollTop = 0;
scrollLockSnapshot.contentEl = null;
}
};

View File

@@ -6,10 +6,18 @@ import type { ModelEntry } from './modelInputListUtils';
interface ModelInputListProps {
entries: ModelEntry[];
onChange: (entries: ModelEntry[]) => void;
addLabel: string;
addLabel?: string;
disabled?: boolean;
namePlaceholder?: string;
aliasPlaceholder?: string;
hideAddButton?: boolean;
onAdd?: () => void;
className?: string;
rowClassName?: string;
inputClassName?: string;
removeButtonClassName?: string;
removeButtonTitle?: string;
removeButtonAriaLabel?: string;
}
export function ModelInputList({
@@ -18,9 +26,20 @@ export function ModelInputList({
addLabel,
disabled = false,
namePlaceholder = 'model-name',
aliasPlaceholder = 'alias (optional)'
aliasPlaceholder = 'alias (optional)',
hideAddButton = false,
onAdd,
className = '',
rowClassName = '',
inputClassName = '',
removeButtonClassName = '',
removeButtonTitle = 'Remove',
removeButtonAriaLabel = 'Remove',
}: ModelInputListProps) {
const currentEntries = entries.length ? entries : [{ name: '', alias: '' }];
const containerClassName = ['header-input-list', className].filter(Boolean).join(' ');
const inputClassNames = ['input', inputClassName].filter(Boolean).join(' ');
const rowClassNames = ['header-input-row', rowClassName].filter(Boolean).join(' ');
const updateEntry = (index: number, field: 'name' | 'alias', value: string) => {
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
@@ -28,7 +47,11 @@ export function ModelInputList({
};
const addEntry = () => {
onChange([...currentEntries, { name: '', alias: '' }]);
if (onAdd) {
onAdd();
} else {
onChange([...currentEntries, { name: '', alias: '' }]);
}
};
const removeEntry = (index: number) => {
@@ -37,12 +60,12 @@ export function ModelInputList({
};
return (
<div className="header-input-list">
<div className={containerClassName}>
{currentEntries.map((entry, index) => (
<Fragment key={index}>
<div className="header-input-row">
<div className={rowClassNames}>
<input
className="input"
className={inputClassNames}
placeholder={namePlaceholder}
value={entry.name}
onChange={(e) => updateEntry(index, 'name', e.target.value)}
@@ -50,7 +73,7 @@ export function ModelInputList({
/>
<span className="header-separator"></span>
<input
className="input"
className={inputClassNames}
placeholder={aliasPlaceholder}
value={entry.alias}
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
@@ -61,17 +84,20 @@ export function ModelInputList({
size="sm"
onClick={() => removeEntry(index)}
disabled={disabled || currentEntries.length <= 1}
title="Remove"
aria-label="Remove"
className={removeButtonClassName}
title={removeButtonTitle}
aria-label={removeButtonAriaLabel}
>
<IconX size={14} />
</Button>
</div>
</Fragment>
))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
{addLabel}
</Button>
{!hideAddButton && addLabel && (
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
{addLabel}
</Button>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
import { formatCompactNumber, formatUsd, type ApiStats } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ApiDetailsCardProps {
@@ -10,9 +10,14 @@ export interface ApiDetailsCardProps {
hasPrices: boolean;
}
type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost';
type SortDir = 'asc' | 'desc';
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
const { t } = useTranslation();
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<ApiSortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const toggleExpand = (endpoint: string) => {
setExpandedApis((prev) => {
@@ -26,65 +31,125 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
});
};
const handleSort = (key: ApiSortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir(key === 'endpoint' ? 'asc' : 'desc');
}
};
const sorted = useMemo(() => {
const list = [...apiStats];
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
switch (sortKey) {
case 'endpoint': return dir * a.endpoint.localeCompare(b.endpoint);
case 'requests': return dir * (a.totalRequests - b.totalRequests);
case 'tokens': return dir * (a.totalTokens - b.totalTokens);
case 'cost': return dir * (a.totalCost - b.totalCost);
default: return 0;
}
});
return list;
}, [apiStats, sortKey, sortDir]);
const arrow = (key: ApiSortKey) =>
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
return (
<Card title={t('usage_stats.api_details')}>
<Card title={t('usage_stats.api_details')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : apiStats.length > 0 ? (
<div className={styles.apiList}>
{apiStats.map((api) => (
<div key={api.endpoint} className={styles.apiItem}>
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
<div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
<span className={styles.requestCountCell}>
<span>
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.apiBadge}>
{t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
) : sorted.length > 0 ? (
<>
<div className={styles.apiSortBar}>
{([
['endpoint', 'usage_stats.api_endpoint'],
['requests', 'usage_stats.requests_count'],
['tokens', 'usage_stats.tokens_count'],
...(hasPrices ? [['cost', 'usage_stats.total_cost']] : []),
] as [ApiSortKey, string][]).map(([key, labelKey]) => (
<button
key={key}
type="button"
aria-pressed={sortKey === key}
className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
onClick={() => handleSort(key)}
>
{t(labelKey)}{arrow(key)}
</button>
))}
</div>
<div className={styles.detailsScroll}>
<div className={styles.apiList}>
{sorted.map((api, index) => {
const isExpanded = expandedApis.has(api.endpoint);
const panelId = `api-models-${index}`;
return (
<div key={api.endpoint} className={styles.apiItem}>
<button
type="button"
className={styles.apiHeader}
onClick={() => toggleExpand(api.endpoint)}
aria-expanded={isExpanded}
aria-controls={panelId}
>
<div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
<span className={styles.requestCountCell}>
<span>
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.apiBadge}>
{t('usage_stats.tokens_count')}: {formatCompactNumber(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
</span>
)}
</div>
</div>
<span className={styles.expandIcon}>
{isExpanded ? '▼' : '▶'}
</span>
</button>
{isExpanded && (
<div id={panelId} className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
<span className={styles.requestCountCell}>
<span>{stats.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.modelStat}>{formatCompactNumber(stats.tokens)}</span>
</div>
))}
</div>
)}
</div>
</div>
<span className={styles.expandIcon}>
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
</span>
</div>
{expandedApis.has(api.endpoint) && (
<div className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
<span className={styles.requestCountCell}>
<span>{stats.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
</div>
))}
</div>
)}
);
})}
</div>
))}
</div>
</div>
</>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}

View File

@@ -0,0 +1,144 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ScriptableContext } from 'chart.js';
import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
buildHourlyCostSeries,
buildDailyCostSeries,
formatUsd,
type ModelPrice
} from '@/utils/usage';
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
export interface CostTrendChartProps {
usage: UsagePayload | null;
loading: boolean;
isDark: boolean;
isMobile: boolean;
modelPrices: Record<string, ModelPrice>;
hourWindowHours?: number;
}
const COST_COLOR = '#f59e0b';
const COST_BG = 'rgba(245, 158, 11, 0.15)';
function buildGradient(ctx: ScriptableContext<'line'>) {
const chart = ctx.chart;
const area = chart.chartArea;
if (!area) return COST_BG;
const gradient = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom);
gradient.addColorStop(0, 'rgba(245, 158, 11, 0.28)');
gradient.addColorStop(0.6, 'rgba(245, 158, 11, 0.12)');
gradient.addColorStop(1, 'rgba(245, 158, 11, 0.02)');
return gradient;
}
export function CostTrendChart({
usage,
loading,
isDark,
isMobile,
modelPrices,
hourWindowHours
}: CostTrendChartProps) {
const { t } = useTranslation();
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
const hasPrices = Object.keys(modelPrices).length > 0;
const { chartData, chartOptions, hasData } = useMemo(() => {
if (!hasPrices || !usage) {
return { chartData: { labels: [], datasets: [] }, chartOptions: {}, hasData: false };
}
const series =
period === 'hour'
? buildHourlyCostSeries(usage, modelPrices, hourWindowHours)
: buildDailyCostSeries(usage, modelPrices);
const data = {
labels: series.labels,
datasets: [
{
label: t('usage_stats.total_cost'),
data: series.data,
borderColor: COST_COLOR,
backgroundColor: buildGradient,
pointBackgroundColor: COST_COLOR,
pointBorderColor: COST_COLOR,
fill: true,
tension: 0.35
}
]
};
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
const options = {
...baseOptions,
scales: {
...baseOptions.scales,
y: {
...baseOptions.scales?.y,
ticks: {
...(baseOptions.scales?.y && 'ticks' in baseOptions.scales.y ? baseOptions.scales.y.ticks : {}),
callback: (value: string | number) => formatUsd(Number(value))
}
}
}
};
return { chartData: data, chartOptions: options, hasData: series.hasData };
}, [usage, period, isDark, isMobile, modelPrices, hasPrices, hourWindowHours, t]);
return (
<Card
title={t('usage_stats.cost_trend')}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : !hasPrices ? (
<div className={styles.hint}>{t('usage_stats.cost_need_price')}</div>
) : !hasData ? (
<div className={styles.hint}>{t('usage_stats.cost_no_data')}</div>
) : (
<div className={styles.chartWrapper}>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,334 @@
import { useMemo, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import {
computeKeyStats,
collectUsageDetails,
buildCandidateUsageSourceIds,
formatCompactNumber
} from '@/utils/usage';
import { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
export interface CredentialStatsCardProps {
usage: UsagePayload | null;
loading: boolean;
geminiKeys: GeminiKeyConfig[];
claudeConfigs: ProviderKeyConfig[];
codexConfigs: ProviderKeyConfig[];
vertexConfigs: ProviderKeyConfig[];
openaiProviders: OpenAIProviderConfig[];
}
interface CredentialInfo {
name: string;
type: string;
}
interface CredentialRow {
key: string;
displayName: string;
type: string;
success: number;
failure: number;
total: number;
successRate: number;
}
interface CredentialBucket {
success: number;
failure: number;
}
function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
return null;
}
export function CredentialStatsCard({
usage,
loading,
geminiKeys,
claudeConfigs,
codexConfigs,
vertexConfigs,
openaiProviders,
}: CredentialStatsCardProps) {
const { t } = useTranslation();
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
// Fetch auth files for auth_index-based matching
useEffect(() => {
let cancelled = false;
authFilesApi
.list()
.then((res) => {
if (cancelled) return;
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
if (!Array.isArray(files)) return;
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const key = normalizeAuthIndexValue(rawAuthIndex);
if (key) {
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString(),
});
}
});
setAuthFileMap(map);
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
// Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates).
// Auth files are used purely for name resolution of unmatched source IDs.
const rows = useMemo((): CredentialRow[] => {
if (!usage) return [];
const { bySource } = computeKeyStats(usage);
const details = collectUsageDetails(usage);
const result: CredentialRow[] = [];
const consumedSourceIds = new Set<string>();
const authIndexToRowIndex = new Map<string, number>();
const sourceToAuthIndex = new Map<string, string>();
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
const target = result[index];
if (!target) return;
target.success += bucket.success;
target.failure += bucket.failure;
target.total = target.success + target.failure;
target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100;
};
// Aggregate all candidate source IDs for one provider config into a single row
const addConfigRow = (
apiKey: string,
prefix: string | undefined,
name: string,
type: string,
rowKey: string,
) => {
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
let success = 0;
let failure = 0;
candidates.forEach((id) => {
const bucket = bySource[id];
if (bucket) {
success += bucket.success;
failure += bucket.failure;
consumedSourceIds.add(id);
}
});
const total = success + failure;
if (total > 0) {
result.push({
key: rowKey,
displayName: name,
type,
success,
failure,
total,
successRate: (success / total) * 100,
});
}
};
// Provider rows — one row per config, stats merged across all its candidate source IDs
geminiKeys.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
claudeConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
codexConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
vertexConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).
openaiProviders.forEach((provider, providerIndex) => {
const prefix = provider.prefix;
const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
const candidates = new Set<string>();
buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
});
let success = 0;
let failure = 0;
candidates.forEach((id) => {
const bucket = bySource[id];
if (bucket) {
success += bucket.success;
failure += bucket.failure;
consumedSourceIds.add(id);
}
});
const total = success + failure;
if (total > 0) {
result.push({
key: `openai:${providerIndex}`,
displayName,
type: 'openai',
success,
failure,
total,
successRate: (success / total) * 100,
});
}
});
// Build source → auth file name mapping for remaining unmatched entries.
// Also collect fallback stats for details without source but with auth_index.
const sourceToAuthFile = new Map<string, CredentialInfo>();
details.forEach((d) => {
const authIdx = normalizeAuthIndexValue(d.auth_index);
if (!d.source) {
if (!authIdx) return;
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
if (d.failed === true) {
fallback.failure += 1;
} else {
fallback.success += 1;
}
fallbackByAuthIndex.set(authIdx, fallback);
return;
}
if (!authIdx || consumedSourceIds.has(d.source)) return;
if (!sourceToAuthIndex.has(d.source)) {
sourceToAuthIndex.set(d.source, authIdx);
}
if (!sourceToAuthFile.has(d.source)) {
const mapped = authFileMap.get(authIdx);
if (mapped) sourceToAuthFile.set(d.source, mapped);
}
});
// Remaining unmatched bySource entries — resolve name from auth files if possible
Object.entries(bySource).forEach(([key, bucket]) => {
if (consumedSourceIds.has(key)) return;
const total = bucket.success + bucket.failure;
const authFile = sourceToAuthFile.get(key);
const row = {
key,
displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key),
type: authFile?.type || '',
success: bucket.success,
failure: bucket.failure,
total,
successRate: total > 0 ? (bucket.success / total) * 100 : 100,
};
const rowIndex = result.push(row) - 1;
const authIdx = sourceToAuthIndex.get(key);
if (authIdx && !authIndexToRowIndex.has(authIdx)) {
authIndexToRowIndex.set(authIdx, rowIndex);
}
});
// Include requests that have auth_index but missing source.
fallbackByAuthIndex.forEach((bucket, authIdx) => {
if (bucket.success + bucket.failure === 0) return;
const mapped = authFileMap.get(authIdx);
let targetRowIndex = authIndexToRowIndex.get(authIdx);
if (targetRowIndex === undefined && mapped) {
const matchedIndex = result.findIndex(
(row) => row.displayName === mapped.name && row.type === mapped.type
);
if (matchedIndex >= 0) {
targetRowIndex = matchedIndex;
authIndexToRowIndex.set(authIdx, matchedIndex);
}
}
if (targetRowIndex !== undefined) {
mergeBucketToRow(targetRowIndex, bucket);
return;
}
const total = bucket.success + bucket.failure;
const rowIndex = result.push({
key: `auth:${authIdx}`,
displayName: mapped?.name || authIdx,
type: mapped?.type || '',
success: bucket.success,
failure: bucket.failure,
total,
successRate: (bucket.success / total) * 100
}) - 1;
authIndexToRowIndex.set(authIdx, rowIndex);
});
return result.sort((a, b) => b.total - a.total);
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
return (
<Card title={t('usage_stats.credential_stats')}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length > 0 ? (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.credential_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.success_rate')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<td className={styles.modelCell}>
<span>{row.displayName}</span>
{row.type && (
<span className={styles.credentialType}>{row.type}</span>
)}
</td>
<td>
<span className={styles.requestCountCell}>
<span>{formatCompactNumber(row.total)}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{row.success.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{row.failure.toLocaleString()}</span>)
</span>
</span>
</td>
<td>
<span
className={
row.successRate >= 95
? styles.statSuccess
: row.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{row.successRate.toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
import { formatCompactNumber, formatUsd } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat {
@@ -18,43 +19,137 @@ export interface ModelStatsCardProps {
hasPrices: boolean;
}
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
type SortDir = 'asc' | 'desc';
interface ModelStatWithRate extends ModelStat {
successRate: number;
}
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir(key === 'model' ? 'asc' : 'desc');
}
};
const sorted = useMemo((): ModelStatWithRate[] => {
const list: ModelStatWithRate[] = modelStats.map((s) => ({
...s,
successRate: s.requests > 0 ? (s.successCount / s.requests) * 100 : 100,
}));
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
});
return list;
}, [modelStats, sortKey, sortDir]);
const arrow = (key: SortKey) =>
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
return (
<Card title={t('usage_stats.models')}>
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : modelStats.length > 0 ? (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.model_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.tokens_count')}</th>
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
</tr>
</thead>
<tbody>
{modelStats.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
</span>
</span>
</td>
<td>{formatTokensInMillions(stat.tokens)}</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
) : sorted.length > 0 ? (
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('model')}
>
{t('usage_stats.model_name')}{arrow('model')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('requests')}
>
{t('usage_stats.requests_count')}{arrow('requests')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('tokens')}
>
{t('usage_stats.tokens_count')}{arrow('tokens')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('successRate')}
>
{t('usage_stats.success_rate')}{arrow('successRate')}
</button>
</th>
{hasPrices && (
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('cost')}
>
{t('usage_stats.total_cost')}{arrow('cost')}
</button>
</th>
)}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{sorted.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
</span>
</span>
</td>
<td>{formatCompactNumber(stat.tokens)}</td>
<td>
<span
className={
stat.successRate >= 95
? styles.statSuccess
: stat.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{stat.successRate.toFixed(1)}%
</span>
</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import {
formatTokensInMillions,
formatCompactNumber,
formatPerMinuteValue,
formatUsd,
calculateTokenBreakdown,
@@ -81,14 +81,14 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
accent: '#8b5cf6',
accentSoft: 'rgba(139, 92, 246, 0.18)',
accentBorder: 'rgba(139, 92, 246, 0.35)',
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
value: loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0),
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
</span>
<span className={styles.statMetaItem}>
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
</span>
</>
),
@@ -119,7 +119,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
</span>
),
trend: sparklines.tpm
@@ -135,7 +135,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
</span>
{!hasPrices && (
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>

View File

@@ -0,0 +1,145 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
buildHourlyTokenBreakdown,
buildDailyTokenBreakdown,
type TokenCategory
} from '@/utils/usage';
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
input: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.25)' },
output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' },
cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' },
reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }
};
const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning'];
export interface TokenBreakdownChartProps {
usage: UsagePayload | null;
loading: boolean;
isDark: boolean;
isMobile: boolean;
hourWindowHours?: number;
}
export function TokenBreakdownChart({
usage,
loading,
isDark,
isMobile,
hourWindowHours
}: TokenBreakdownChartProps) {
const { t } = useTranslation();
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
const { chartData, chartOptions } = useMemo(() => {
const series =
period === 'hour'
? buildHourlyTokenBreakdown(usage, hourWindowHours)
: buildDailyTokenBreakdown(usage);
const categoryLabels: Record<TokenCategory, string> = {
input: t('usage_stats.input_tokens'),
output: t('usage_stats.output_tokens'),
cached: t('usage_stats.cached_tokens'),
reasoning: t('usage_stats.reasoning_tokens')
};
const data = {
labels: series.labels,
datasets: CATEGORIES.map((cat) => ({
label: categoryLabels[cat],
data: series.dataByCategory[cat],
borderColor: TOKEN_COLORS[cat].border,
backgroundColor: TOKEN_COLORS[cat].bg,
pointBackgroundColor: TOKEN_COLORS[cat].border,
pointBorderColor: TOKEN_COLORS[cat].border,
fill: true,
tension: 0.35
}))
};
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
const options = {
...baseOptions,
scales: {
...baseOptions.scales,
y: {
...baseOptions.scales?.y,
stacked: true
},
x: {
...baseOptions.scales?.x,
stacked: true
}
}
};
return { chartData: data, chartOptions: options };
}, [usage, period, isDark, isMobile, hourWindowHours, t]);
return (
<Card
title={t('usage_stats.token_breakdown')}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : chartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{chartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}

View File

@@ -9,6 +9,7 @@ export interface UseChartDataOptions {
chartLines: string[];
isDark: boolean;
isMobile: boolean;
hourWindowHours?: number;
}
export interface UseChartDataReturn {
@@ -26,20 +27,21 @@ export function useChartData({
usage,
chartLines,
isDark,
isMobile
isMobile,
hourWindowHours
}: UseChartDataOptions): UseChartDataReturn {
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
const requestsChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
}, [usage, requestsPeriod, chartLines]);
return buildChartData(usage, requestsPeriod, 'requests', chartLines, { hourWindowHours });
}, [usage, requestsPeriod, chartLines, hourWindowHours]);
const tokensChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
}, [usage, tokensPeriod, chartLines]);
return buildChartData(usage, tokensPeriod, 'tokens', chartLines, { hourWindowHours });
}, [usage, tokensPeriod, chartLines, hourWindowHours]);
const requestsChartOptions = useMemo(
() =>

View File

@@ -17,6 +17,7 @@ export interface UseUsageDataReturn {
usage: UsagePayload | null;
loading: boolean;
error: string;
lastRefreshedAt: Date | null;
modelPrices: Record<string, ModelPrice>;
setModelPrices: (prices: Record<string, ModelPrice>) => void;
loadUsage: () => Promise<void>;
@@ -38,6 +39,7 @@ export function useUsageData(): UseUsageDataReturn {
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(null);
const loadUsage = useCallback(async () => {
@@ -45,8 +47,9 @@ export function useUsageData(): UseUsageDataReturn {
setError('');
try {
const data = await usageApi.getUsage();
const payload = data?.usage ?? data;
setUsage(payload);
const payload = (data?.usage ?? data) as unknown;
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
setLastRefreshedAt(new Date());
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
setError(message);
@@ -140,6 +143,7 @@ export function useUsageData(): UseUsageDataReturn {
usage,
loading,
error,
lastRefreshedAt,
modelPrices,
setModelPrices: handleSetModelPrices,
loadUsage,

View File

@@ -26,3 +26,12 @@ export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
export { PriceSettingsCard } from './PriceSettingsCard';
export type { PriceSettingsCardProps } from './PriceSettingsCard';
export { CredentialStatsCard } from './CredentialStatsCard';
export type { CredentialStatsCardProps } from './CredentialStatsCard';
export { TokenBreakdownChart } from './TokenBreakdownChart';
export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
export { CostTrendChart } from './CostTrendChart';
export type { CostTrendChartProps } from './CostTrendChart';

View File

@@ -13,7 +13,7 @@ interface UseApiOptions<T> {
successMessage?: string;
}
export function useApi<T = any, Args extends any[] = any[]>(
export function useApi<T = unknown, Args extends unknown[] = unknown[]>(
apiFunction: (...args: Args) => Promise<T>,
options: UseApiOptions<T> = {}
) {
@@ -38,8 +38,9 @@ export function useApi<T = any, Args extends any[] = any[]>(
options.onSuccess?.(result);
return result;
} catch (err) {
const errorObj = err as Error;
} catch (err: unknown) {
const errorObj =
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
setError(errorObj);
if (options.showErrorNotification !== false) {

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { useCallback, useMemo, useState } from 'react';
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
import type {
PayloadFilterRule,
PayloadParamValueType,
@@ -8,10 +8,6 @@ import type {
} from '@/types/visualConfig';
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
function hasOwn(obj: unknown, key: string): obj is Record<string, unknown> {
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@@ -48,53 +44,58 @@ function parseApiKeysText(raw: unknown): string {
return keys.join('\n');
}
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asRecord(parent[key]);
if (existing) return existing;
const next: Record<string, unknown> = {};
parent[key] = next;
return next;
type YamlDocument = ReturnType<typeof parseDocument>;
type YamlPath = string[];
function docHas(doc: YamlDocument, path: YamlPath): boolean {
return doc.hasIn(path);
}
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void {
const value = asRecord(parent[key]);
if (!value) return;
if (Object.keys(value).length === 0) delete parent[key];
function ensureMapInDoc(doc: YamlDocument, path: YamlPath): void {
const existing = doc.getIn(path, true);
if (isMap(existing)) return;
doc.setIn(path, {});
}
function setBoolean(obj: Record<string, unknown>, key: string, value: boolean): void {
function deleteIfMapEmpty(doc: YamlDocument, path: YamlPath): void {
const value = doc.getIn(path, true);
if (!isMap(value)) return;
if (value.items.length === 0) doc.deleteIn(path);
}
function setBooleanInDoc(doc: YamlDocument, path: YamlPath, value: boolean): void {
if (value) {
obj[key] = true;
doc.setIn(path, true);
return;
}
if (hasOwn(obj, key)) obj[key] = false;
if (docHas(doc, path)) doc.setIn(path, false);
}
function setString(obj: Record<string, unknown>, key: string, value: unknown): void {
function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
if (trimmed !== '') {
obj[key] = safe;
doc.setIn(path, safe);
return;
}
if (hasOwn(obj, key)) delete obj[key];
if (docHas(doc, path)) doc.deleteIn(path);
}
function setIntFromString(obj: Record<string, unknown>, key: string, value: unknown): void {
function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
if (trimmed === '') {
if (hasOwn(obj, key)) delete obj[key];
if (docHas(doc, path)) doc.deleteIn(path);
return;
}
const parsed = Number.parseInt(trimmed, 10);
if (Number.isFinite(parsed)) {
obj[key] = parsed;
doc.setIn(path, parsed);
return;
}
if (hasOwn(obj, key)) delete obj[key];
if (docHas(doc, path)) doc.deleteIn(path);
}
function deepClone<T>(value: T): T {
@@ -123,20 +124,47 @@ function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueTyp
return { valueType: 'string', value: String(raw ?? '') };
}
const PAYLOAD_PROTOCOL_VALUES = [
'openai',
'openai-response',
'gemini',
'claude',
'codex',
'antigravity',
] as const;
type PayloadProtocol = (typeof PAYLOAD_PROTOCOL_VALUES)[number];
function parsePayloadProtocol(raw: unknown): PayloadProtocol | undefined {
if (typeof raw !== 'string') return undefined;
return PAYLOAD_PROTOCOL_VALUES.includes(raw as PayloadProtocol)
? (raw as PayloadProtocol)
: undefined;
}
function parsePayloadRules(rules: unknown): PayloadRule[] {
if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => ({
id: `payload-rule-${index}`,
models: Array.isArray((rule as any)?.models)
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
id: `model-${index}-${modelIndex}`,
name: typeof model === 'string' ? model : model?.name || '',
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
}))
: [],
params: (rule as any)?.params
? Object.entries((rule as any).params as Record<string, unknown>).map(([path, value], pIndex) => {
return rules.map((rule, index) => {
const record = asRecord(rule) ?? {};
const modelsRaw = record.models;
const models = Array.isArray(modelsRaw)
? modelsRaw.map((model, modelIndex) => {
const modelRecord = asRecord(model);
const nameRaw =
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
return {
id: `model-${index}-${modelIndex}`,
name,
protocol: parsePayloadProtocol(modelRecord?.protocol),
};
})
: [];
const paramsRecord = asRecord(record.params);
const params = paramsRecord
? Object.entries(paramsRecord).map(([path, value], pIndex) => {
const parsedValue = parsePayloadParamValue(value);
return {
id: `param-${index}-${pIndex}`,
@@ -145,41 +173,55 @@ function parsePayloadRules(rules: unknown): PayloadRule[] {
value: parsedValue.value,
};
})
: [],
}));
: [];
return { id: `payload-rule-${index}`, models, params };
});
}
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => ({
id: `payload-filter-rule-${index}`,
models: Array.isArray((rule as any)?.models)
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
id: `filter-model-${index}-${modelIndex}`,
name: typeof model === 'string' ? model : model?.name || '',
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
}))
: [],
params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [],
}));
return rules.map((rule, index) => {
const record = asRecord(rule) ?? {};
const modelsRaw = record.models;
const models = Array.isArray(modelsRaw)
? modelsRaw.map((model, modelIndex) => {
const modelRecord = asRecord(model);
const nameRaw =
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
return {
id: `filter-model-${index}-${modelIndex}`,
name,
protocol: parsePayloadProtocol(modelRecord?.protocol),
};
})
: [];
const paramsRaw = record.params;
const params = Array.isArray(paramsRaw) ? paramsRaw.map(String) : [];
return { id: `payload-filter-rule-${index}`, models, params };
});
}
function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
function serializePayloadRulesForYaml(rules: PayloadRule[]): Array<Record<string, unknown>> {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, any> = { name: m.name.trim() };
const obj: Record<string, unknown> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
const params: Record<string, any> = {};
const params: Record<string, unknown> = {};
for (const param of rule.params || []) {
if (!param.path?.trim()) continue;
let value: any = param.value;
let value: unknown = param.value;
if (param.valueType === 'number') {
const num = Number(param.value);
value = Number.isFinite(num) ? num : param.value;
@@ -200,13 +242,15 @@ function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
.filter((rule) => rule.models.length > 0);
}
function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] {
function serializePayloadFilterRulesForYaml(
rules: PayloadFilterRule[]
): Array<Record<string, unknown>> {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, any> = { name: m.name.trim() };
const obj: Record<string, unknown> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
@@ -225,33 +269,45 @@ export function useVisualConfig() {
...DEFAULT_VISUAL_VALUES,
});
const baselineValues = useRef<VisualConfigValues>({ ...DEFAULT_VISUAL_VALUES });
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
...DEFAULT_VISUAL_VALUES,
});
const visualDirty = useMemo(() => {
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current);
}, [visualValues]);
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
}, [baselineValues, visualValues]);
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
try {
const parsed: any = parseYaml(yamlContent) || {};
const parsedRaw: unknown = parseYaml(yamlContent) || {};
const parsed = asRecord(parsedRaw) ?? {};
const tls = asRecord(parsed.tls);
const remoteManagement = asRecord(parsed['remote-management']);
const quotaExceeded = asRecord(parsed['quota-exceeded']);
const routing = asRecord(parsed.routing);
const payload = asRecord(parsed.payload);
const streaming = asRecord(parsed.streaming);
const newValues: VisualConfigValues = {
host: parsed.host || '',
host: typeof parsed.host === 'string' ? parsed.host : '',
port: String(parsed.port ?? ''),
tlsEnable: Boolean(parsed.tls?.enable),
tlsCert: parsed.tls?.cert || '',
tlsKey: parsed.tls?.key || '',
tlsEnable: Boolean(tls?.enable),
tlsCert: typeof tls?.cert === 'string' ? tls.cert : '',
tlsKey: typeof tls?.key === 'string' ? tls.key : '',
rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']),
rmSecretKey: parsed['remote-management']?.['secret-key'] || '',
rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']),
rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
rmSecretKey:
typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '',
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
rmPanelRepo:
parsed['remote-management']?.['panel-github-repository'] ??
parsed['remote-management']?.['panel-repo'] ??
'',
typeof remoteManagement?.['panel-github-repository'] === 'string'
? remoteManagement['panel-github-repository']
: typeof remoteManagement?.['panel-repo'] === 'string'
? remoteManagement['panel-repo']
: '',
authDir: parsed['auth-dir'] || '',
authDir: typeof parsed['auth-dir'] === 'string' ? parsed['auth-dir'] : '',
apiKeysText: parseApiKeysText(parsed['api-keys']),
debug: Boolean(parsed.debug),
@@ -260,113 +316,131 @@ export function useVisualConfig() {
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
proxyUrl: parsed['proxy-url'] || '',
proxyUrl: typeof parsed['proxy-url'] === 'string' ? parsed['proxy-url'] : '',
forceModelPrefix: Boolean(parsed['force-model-prefix']),
requestRetry: String(parsed['request-retry'] ?? ''),
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
wsAuth: Boolean(parsed['ws-auth']),
quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true),
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
quotaSwitchPreviewModel: Boolean(
parsed['quota-exceeded']?.['switch-preview-model'] ?? true
quotaExceeded?.['switch-preview-model'] ?? true
),
routingStrategy: (parsed.routing?.strategy || 'round-robin') as 'round-robin' | 'fill-first',
routingStrategy:
routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
payloadDefaultRules: parsePayloadRules(parsed.payload?.default),
payloadOverrideRules: parsePayloadRules(parsed.payload?.override),
payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter),
payloadDefaultRules: parsePayloadRules(payload?.default),
payloadOverrideRules: parsePayloadRules(payload?.override),
payloadFilterRules: parsePayloadFilterRules(payload?.filter),
streaming: {
keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''),
bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''),
keepaliveSeconds: String(streaming?.['keepalive-seconds'] ?? ''),
bootstrapRetries: String(streaming?.['bootstrap-retries'] ?? ''),
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
},
};
setVisualValuesState(newValues);
baselineValues.current = deepClone(newValues);
setBaselineValues(deepClone(newValues));
} catch {
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES);
setBaselineValues(deepClone(DEFAULT_VISUAL_VALUES));
}
}, []);
const applyVisualChangesToYaml = useCallback(
(currentYaml: string): string => {
try {
const parsed = (parseYaml(currentYaml) || {}) as Record<string, unknown>;
const doc = parseDocument(currentYaml);
if (doc.errors.length > 0) return currentYaml;
if (!isMap(doc.contents)) {
doc.contents = doc.createNode({}) as unknown as typeof doc.contents;
}
const values = visualValues;
setString(parsed, 'host', values.host);
setIntFromString(parsed, 'port', values.port);
setStringInDoc(doc, ['host'], values.host);
setIntFromStringInDoc(doc, ['port'], values.port);
if (
hasOwn(parsed, 'tls') ||
docHas(doc, ['tls']) ||
values.tlsEnable ||
values.tlsCert.trim() ||
values.tlsKey.trim()
) {
const tls = ensureRecord(parsed, 'tls');
setBoolean(tls, 'enable', values.tlsEnable);
setString(tls, 'cert', values.tlsCert);
setString(tls, 'key', values.tlsKey);
deleteIfEmpty(parsed, 'tls');
ensureMapInDoc(doc, ['tls']);
setBooleanInDoc(doc, ['tls', 'enable'], values.tlsEnable);
setStringInDoc(doc, ['tls', 'cert'], values.tlsCert);
setStringInDoc(doc, ['tls', 'key'], values.tlsKey);
deleteIfMapEmpty(doc, ['tls']);
}
if (
hasOwn(parsed, 'remote-management') ||
docHas(doc, ['remote-management']) ||
values.rmAllowRemote ||
values.rmSecretKey.trim() ||
values.rmDisableControlPanel ||
values.rmPanelRepo.trim()
) {
const rm = ensureRecord(parsed, 'remote-management');
setBoolean(rm, 'allow-remote', values.rmAllowRemote);
setString(rm, 'secret-key', values.rmSecretKey);
setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel);
setString(rm, 'panel-github-repository', values.rmPanelRepo);
if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo'];
deleteIfEmpty(parsed, 'remote-management');
ensureMapInDoc(doc, ['remote-management']);
setBooleanInDoc(doc, ['remote-management', 'allow-remote'], values.rmAllowRemote);
setStringInDoc(doc, ['remote-management', 'secret-key'], values.rmSecretKey);
setBooleanInDoc(
doc,
['remote-management', 'disable-control-panel'],
values.rmDisableControlPanel
);
setStringInDoc(doc, ['remote-management', 'panel-github-repository'], values.rmPanelRepo);
if (docHas(doc, ['remote-management', 'panel-repo'])) {
doc.deleteIn(['remote-management', 'panel-repo']);
}
deleteIfMapEmpty(doc, ['remote-management']);
}
setString(parsed, 'auth-dir', values.authDir);
if (values.apiKeysText !== baselineValues.current.apiKeysText) {
setStringInDoc(doc, ['auth-dir'], values.authDir);
if (values.apiKeysText !== baselineValues.apiKeysText) {
const apiKeys = values.apiKeysText
.split('\n')
.map((key) => key.trim())
.filter(Boolean);
if (apiKeys.length > 0) {
parsed['api-keys'] = apiKeys;
} else if (hasOwn(parsed, 'api-keys')) {
delete parsed['api-keys'];
doc.setIn(['api-keys'], apiKeys);
} else if (docHas(doc, ['api-keys'])) {
doc.deleteIn(['api-keys']);
}
}
setBoolean(parsed, 'debug', values.debug);
setBooleanInDoc(doc, ['debug'], values.debug);
setBoolean(parsed, 'commercial-mode', values.commercialMode);
setBoolean(parsed, 'logging-to-file', values.loggingToFile);
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb);
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled);
setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
setBooleanInDoc(doc, ['logging-to-file'], values.loggingToFile);
setIntFromStringInDoc(doc, ['logs-max-total-size-mb'], values.logsMaxTotalSizeMb);
setBooleanInDoc(doc, ['usage-statistics-enabled'], values.usageStatisticsEnabled);
setString(parsed, 'proxy-url', values.proxyUrl);
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix);
setIntFromString(parsed, 'request-retry', values.requestRetry);
setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval);
setBoolean(parsed, 'ws-auth', values.wsAuth);
setStringInDoc(doc, ['proxy-url'], values.proxyUrl);
setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix);
setIntFromStringInDoc(doc, ['request-retry'], values.requestRetry);
setIntFromStringInDoc(doc, ['max-retry-interval'], values.maxRetryInterval);
setBooleanInDoc(doc, ['ws-auth'], values.wsAuth);
if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) {
const quota = ensureRecord(parsed, 'quota-exceeded');
quota['switch-project'] = values.quotaSwitchProject;
quota['switch-preview-model'] = values.quotaSwitchPreviewModel;
deleteIfEmpty(parsed, 'quota-exceeded');
if (
docHas(doc, ['quota-exceeded']) ||
!values.quotaSwitchProject ||
!values.quotaSwitchPreviewModel
) {
ensureMapInDoc(doc, ['quota-exceeded']);
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
doc.setIn(
['quota-exceeded', 'switch-preview-model'],
values.quotaSwitchPreviewModel
);
deleteIfMapEmpty(doc, ['quota-exceeded']);
}
if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') {
const routing = ensureRecord(parsed, 'routing');
routing.strategy = values.routingStrategy;
deleteIfEmpty(parsed, 'routing');
if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
ensureMapInDoc(doc, ['routing']);
doc.setIn(['routing', 'strategy'], values.routingStrategy);
deleteIfMapEmpty(doc, ['routing']);
}
const keepaliveSeconds =
@@ -379,47 +453,60 @@ export function useVisualConfig() {
: '';
const streamingDefined =
hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim();
docHas(doc, ['streaming']) || keepaliveSeconds.trim() || bootstrapRetries.trim();
if (streamingDefined) {
const streaming = ensureRecord(parsed, 'streaming');
setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds);
setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries);
deleteIfEmpty(parsed, 'streaming');
ensureMapInDoc(doc, ['streaming']);
setIntFromStringInDoc(doc, ['streaming', 'keepalive-seconds'], keepaliveSeconds);
setIntFromStringInDoc(doc, ['streaming', 'bootstrap-retries'], bootstrapRetries);
deleteIfMapEmpty(doc, ['streaming']);
}
setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval);
setIntFromStringInDoc(
doc,
['nonstream-keepalive-interval'],
nonstreamKeepaliveInterval
);
if (
hasOwn(parsed, 'payload') ||
docHas(doc, ['payload']) ||
values.payloadDefaultRules.length > 0 ||
values.payloadOverrideRules.length > 0 ||
values.payloadFilterRules.length > 0
) {
const payload = ensureRecord(parsed, 'payload');
ensureMapInDoc(doc, ['payload']);
if (values.payloadDefaultRules.length > 0) {
payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules);
} else if (hasOwn(payload, 'default')) {
delete payload.default;
doc.setIn(
['payload', 'default'],
serializePayloadRulesForYaml(values.payloadDefaultRules)
);
} else if (docHas(doc, ['payload', 'default'])) {
doc.deleteIn(['payload', 'default']);
}
if (values.payloadOverrideRules.length > 0) {
payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules);
} else if (hasOwn(payload, 'override')) {
delete payload.override;
doc.setIn(
['payload', 'override'],
serializePayloadRulesForYaml(values.payloadOverrideRules)
);
} else if (docHas(doc, ['payload', 'override'])) {
doc.deleteIn(['payload', 'override']);
}
if (values.payloadFilterRules.length > 0) {
payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules);
} else if (hasOwn(payload, 'filter')) {
delete payload.filter;
doc.setIn(
['payload', 'filter'],
serializePayloadFilterRulesForYaml(values.payloadFilterRules)
);
} else if (docHas(doc, ['payload', 'filter'])) {
doc.deleteIn(['payload', 'filter']);
}
deleteIfEmpty(parsed, 'payload');
deleteIfMapEmpty(doc, ['payload']);
}
return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 });
return doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
} catch {
return currentYaml;
}
},
[visualValues]
[baselineValues, visualValues]
);
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
@@ -442,17 +529,66 @@ export function useVisualConfig() {
}
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
{ value: '', label: '默认' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'claude', label: 'Claude' },
{ value: 'codex', label: 'Codex' },
{ value: 'antigravity', label: 'Antigravity' },
{
value: '',
labelKey: 'config_management.visual.payload_rules.provider_default',
defaultLabel: 'Default',
},
{
value: 'openai',
labelKey: 'config_management.visual.payload_rules.provider_openai',
defaultLabel: 'OpenAI',
},
{
value: 'openai-response',
labelKey: 'config_management.visual.payload_rules.provider_openai_response',
defaultLabel: 'OpenAI Response',
},
{
value: 'gemini',
labelKey: 'config_management.visual.payload_rules.provider_gemini',
defaultLabel: 'Gemini',
},
{
value: 'claude',
labelKey: 'config_management.visual.payload_rules.provider_claude',
defaultLabel: 'Claude',
},
{
value: 'codex',
labelKey: 'config_management.visual.payload_rules.provider_codex',
defaultLabel: 'Codex',
},
{
value: 'antigravity',
labelKey: 'config_management.visual.payload_rules.provider_antigravity',
defaultLabel: 'Antigravity',
},
] as const;
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
{ value: 'string', label: '字符串' },
{ value: 'number', label: '数字' },
{ value: 'boolean', label: '布尔' },
{ value: 'json', label: 'JSON' },
] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>;
{
value: 'string',
labelKey: 'config_management.visual.payload_rules.value_type_string',
defaultLabel: 'String',
},
{
value: 'number',
labelKey: 'config_management.visual.payload_rules.value_type_number',
defaultLabel: 'Number',
},
{
value: 'boolean',
labelKey: 'config_management.visual.payload_rules.value_type_boolean',
defaultLabel: 'Boolean',
},
{
value: 'json',
labelKey: 'config_management.visual.payload_rules.value_type_json',
defaultLabel: 'JSON',
},
] as const satisfies ReadonlyArray<{
value: PayloadParamValueType;
labelKey: string;
defaultLabel: string;
}>;

View File

@@ -6,12 +6,14 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json';
import en from './locales/en.json';
import ru from './locales/ru.json';
import { getInitialLanguage } from '@/utils/language';
i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
en: { translation: en }
en: { translation: en },
ru: { translation: ru }
},
lng: getInitialLanguage(),
fallbackLng: 'zh-CN',

View File

@@ -38,13 +38,16 @@
"quota_update_required": "Please update the CPA version or check for updates",
"quota_check_credential": "Please check the credential status",
"copy": "Copy",
"status": "Status",
"action": "Action",
"custom_headers_label": "Custom Headers",
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
"custom_headers_add": "Add Header",
"custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header",
"custom_headers_value_placeholder": "Header value",
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
"model_alias_placeholder": "Model alias (optional)"
"model_alias_placeholder": "Model alias (optional)",
"invalid_provider_index": "Invalid provider index."
},
"title": {
"main": "CLI Proxy API Management Center",
@@ -191,6 +194,8 @@
"gemini_keys_add_btn": "Add Key",
"gemini_base_url_label": "Base URL (Optional):",
"gemini_base_url_placeholder": "e.g.: https://generativelanguage.googleapis.com",
"gemini_add_modal_proxy_label": "Proxy URL (Optional):",
"gemini_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"gemini_edit_modal_title": "Edit Gemini API Key",
"gemini_edit_modal_key_label": "API Key:",
"gemini_delete_confirm": "Are you sure you want to delete this Gemini key?",
@@ -333,7 +338,13 @@
"openai_test_success": "Test succeeded. The model responded.",
"openai_test_failed": "Test failed",
"openai_test_select_placeholder": "Choose from current models",
"openai_test_select_empty": "No models configured. Add models first"
"openai_test_select_empty": "No models configured. Add models first",
"openai_test_single_action": "Test",
"openai_test_all_action": "Test All Keys",
"openai_test_all_hint": "Test connection status for all keys",
"openai_test_all_success": "All {{count}} keys passed the test",
"openai_test_all_failed": "All {{count}} keys failed the test",
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed"
},
"auth_files": {
"title": "Auth Files Management",
@@ -376,6 +387,7 @@
"filter_qwen": "Qwen",
"filter_gemini": "Gemini",
"filter_gemini-cli": "GeminiCLI",
"filter_kimi": "Kimi",
"filter_aistudio": "AIStudio",
"filter_claude": "Claude",
"filter_codex": "Codex",
@@ -387,6 +399,7 @@
"type_qwen": "Qwen",
"type_gemini": "Gemini",
"type_gemini-cli": "GeminiCLI",
"type_kimi": "Kimi",
"type_aistudio": "AIStudio",
"type_claude": "Claude",
"type_codex": "Codex",
@@ -403,23 +416,30 @@
"models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.",
"models_unsupported": "This feature is not supported in the current version",
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
"models_excluded_badge": "Excluded",
"models_excluded_hint": "This model is excluded by OAuth",
"models_excluded_badge": "Disabled",
"models_excluded_hint": "This OAuth model is disabled",
"status_toggle_label": "Enabled",
"status_enabled_success": "\"{{name}}\" enabled",
"status_disabled_success": "\"{{name}}\" disabled",
"prefix_proxy_button": "Edit prefix/proxy_url",
"prefix_proxy_loading": "Loading credential...",
"prefix_proxy_source_label": "Credential JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_proxy_button": "Edit Auth Fields",
"auth_field_editor_title": "Edit Auth Fields - {{name}}",
"prefix_proxy_loading": "Loading auth file...",
"prefix_proxy_source_label": "Auth file JSON (preview)",
"prefix_label": "Prefix (prefix)",
"proxy_url_label": "Proxy URL (proxy_url)",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
"card_tools_title": "Tools",
"quota_refresh_single": "Refresh quota",
"quota_refresh_hint": "Refresh quota for this credential only",
"priority_label": "Priority (priority)",
"priority_placeholder": "e.g. 10 or -1",
"priority_hint": "Integers only. Invalid values are ignored. Larger value means higher priority.",
"excluded_models_label": "Excluded models (excluded_models)",
"excluded_models_placeholder": "Comma or newline separated, e.g. model-a, gpt-5-*, *-preview",
"excluded_models_hint": "Saved as an array and normalized by trim/lowercase/dedup/sort.",
"disable_cooling_label": "Disable cooling (disable_cooling)",
"disable_cooling_placeholder": "e.g. true / false / 1 / 0",
"disable_cooling_hint": "Supports booleans, numeric 0/non-0, and strings like true/false/1/0; unparseable values are ignored.",
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
},
@@ -427,7 +447,7 @@
"title": "Antigravity Quota",
"empty_title": "No Antigravity Auth Files",
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
"idle": "Not loaded. Click Refresh Button.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
@@ -435,11 +455,31 @@
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All"
},
"claude_quota": {
"title": "Claude Quota",
"empty_title": "No Claude OAuth Files",
"empty_desc": "Log in with Claude OAuth to view quota.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_windows": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"five_hour": "5-hour limit",
"seven_day": "7-day limit",
"seven_day_oauth_apps": "7-day OAuth apps",
"seven_day_opus": "7-day Opus",
"seven_day_sonnet": "7-day Sonnet",
"seven_day_cowork": "7-day Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Extra Usage"
},
"codex_quota": {
"title": "Codex Quota",
"empty_title": "No Codex Auth Files",
"empty_desc": "Upload a Codex credential to view quota.",
"idle": "Not loaded. Click Refresh Button.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
@@ -452,6 +492,8 @@
"secondary_window": "Weekly limit",
"code_review_primary_window": "Code review 5-hour limit",
"code_review_secondary_window": "Code review weekly limit",
"additional_primary_window": "{{name}} 5-hour limit",
"additional_secondary_window": "{{name}} weekly limit",
"plan_label": "Plan",
"plan_plus": "Plus",
"plan_team": "Team",
@@ -461,7 +503,7 @@
"title": "Gemini CLI Quota",
"empty_title": "No Gemini CLI Auth Files",
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
"idle": "Not loaded. Click Refresh Button.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
@@ -491,43 +533,43 @@
"result_file": "Persisted file"
},
"oauth_excluded": {
"title": "OAuth Excluded Models",
"description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.",
"add": "Add Exclusion",
"add_title": "Add provider exclusion",
"edit_title": "Edit exclusions for {{provider}}",
"title": "OAuth Model Disablement",
"description": "Per-provider model disablement is shown as cards; click a card to edit or delete. Wildcards * are supported and the scope follows the auth file filter.",
"add": "Add Disablement",
"add_title": "Add provider model disablement",
"edit_title": "Edit model disablement for {{provider}}",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"provider_label": "Provider",
"provider_auto": "Follow current filter",
"provider_placeholder": "e.g. gemini-cli",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"models_label": "Models to exclude",
"models_label": "Models to disable",
"models_loading": "Loading models...",
"models_unsupported": "Current CPA version does not support fetching model lists.",
"models_loaded": "{{count}} models loaded. Check the models to exclude.",
"models_loaded": "{{count}} models loaded. Check the models to disable.",
"no_models_available": "No models available for this provider.",
"save": "Save/Update",
"saving": "Saving...",
"save_success": "Excluded models updated",
"save_failed": "Failed to update excluded models",
"save_success": "Model disablement updated",
"save_failed": "Failed to update model disablement",
"delete": "Delete Provider",
"delete_confirm": "Delete the exclusion list for {{provider}}?",
"delete_success": "Exclusion list removed",
"delete_failed": "Failed to delete exclusion list",
"delete_confirm": "Delete model disablement for {{provider}}?",
"delete_success": "Provider model disablement removed",
"delete_failed": "Failed to delete model disablement",
"deleting": "Deleting...",
"no_models": "No excluded models",
"model_count": "{{count}} models excluded",
"list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.",
"list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.",
"disconnected": "Connect to the server to view exclusions",
"load_failed": "Failed to load exclusion list",
"no_models": "No disabled models configured",
"model_count": "{{count}} models disabled",
"list_empty_all": "No provider model disablement yet; click “Add Disablement” to create one.",
"list_empty_filtered": "No disabled items in this scope; click “Add Disablement” to add.",
"disconnected": "Connect to the server to view model disablement",
"load_failed": "Failed to load model disablement",
"provider_required": "Please enter a provider first",
"scope_all": "Scope: All providers",
"scope_provider": "Scope: {{provider}}",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required": "Current CPA version does not support OAuth model disablement. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
"upgrade_required_desc": "The current server version does not support fetching OAuth model disablement. Please upgrade to the latest CPA (CLI Proxy API) version and try again."
},
"oauth_model_alias": {
"title": "OAuth Model Aliases",
@@ -640,6 +682,17 @@
"gemini_cli_oauth_status_error": "Authentication failed:",
"gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:",
"gemini_cli_oauth_polling_error": "Failed to check authentication status:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "Start Kimi Login",
"kimi_oauth_hint": "Login to Kimi service through OAuth device flow, automatically obtain and save authentication files.",
"kimi_oauth_url_label": "Authorization URL:",
"kimi_open_link": "Open Link",
"kimi_copy_link": "Copy Link",
"kimi_oauth_status_waiting": "Waiting for authentication...",
"kimi_oauth_status_success": "Authentication successful!",
"kimi_oauth_status_error": "Authentication failed:",
"kimi_oauth_start_error": "Failed to start Kimi OAuth:",
"kimi_oauth_polling_error": "Failed to check authentication status:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Start Qwen Login",
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
@@ -711,6 +764,11 @@
"api_details": "API Details",
"by_hour": "By Hour",
"by_day": "By Day",
"range_filter": "Time Range",
"range_all": "All Time",
"range_7h": "Last 7 Hours",
"range_24h": "Last 24 Hours",
"range_7d": "Last 7 Days",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
@@ -758,7 +816,13 @@
"cost_axis_label": "Cost ($)",
"cost_need_price": "Set a model price to view cost stats",
"cost_need_usage": "No usage data available to calculate cost",
"cost_no_data": "No cost data yet"
"cost_no_data": "No cost data yet",
"credential_stats": "Credential Statistics",
"credential_name": "Credential",
"token_breakdown": "Token Type Breakdown",
"input_tokens": "Input Tokens",
"output_tokens": "Output Tokens",
"last_updated": "Updated"
},
"stats": {
"success": "Success",
@@ -877,9 +941,9 @@
"debug": "Debug Mode",
"debug_desc": "Enable verbose debug logging",
"commercial_mode": "Commercial Mode",
"commercial_mode_desc": "Disable high-overhead middleware to reduce memory under high concurrency",
"commercial_mode_desc": "Disable high-overhead middleware to support high concurrency",
"logging_to_file": "Log to File",
"logging_to_file_desc": "Save logs to rotating files",
"logging_to_file_desc": "Save logs to files",
"usage_statistics": "Usage Statistics",
"usage_statistics_desc": "Collect usage statistics",
"logs_max_size": "Log File Size Limit (MB)"
@@ -956,6 +1020,17 @@
"add_param": "Add Parameter",
"no_rules": "No rules",
"add_rule": "Add Rule",
"provider_default": "Default",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"value_type_string": "String",
"value_type_number": "Number",
"value_type_boolean": "Boolean",
"value_type_json": "JSON",
"value_string": "String value",
"value_number": "Number value (e.g., 0.7)",
"value_boolean": "true or false",
@@ -979,6 +1054,7 @@
},
"system_info": {
"title": "Management Center Info",
"about_title": "CLI Proxy API Management Center",
"connection_status_title": "Connection Status",
"api_status_label": "API Status:",
"config_status_label": "Config Status:",
@@ -1083,12 +1159,15 @@
"gemini_api_key": "Gemini API key",
"codex_api_key": "Codex API key",
"claude_api_key": "Claude API key",
"commercial_mode_restart_required": "Commercial mode setting changed. Please restart the service for it to take effect",
"copy_failed": "Copy failed",
"link_copied": "Link copied to clipboard"
},
"language": {
"switch": "Language",
"chinese": "中文",
"english": "English"
"english": "English",
"russian": "Русский"
},
"theme": {
"switch": "Theme",

1195
src/i18n/locales/ru.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -38,13 +38,16 @@
"quota_update_required": "请更新 CPA 版本或检查更新",
"quota_check_credential": "请检查凭证状态",
"copy": "复制",
"status": "状态",
"action": "操作",
"custom_headers_label": "自定义请求头",
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
"custom_headers_add": "添加请求头",
"custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header",
"custom_headers_value_placeholder": "Header 值",
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
"model_alias_placeholder": "模型别名 (可选)"
"model_alias_placeholder": "模型别名 (可选)",
"invalid_provider_index": "无效的提供商索引。"
},
"title": {
"main": "CLI Proxy API Management Center",
@@ -191,6 +194,8 @@
"gemini_keys_add_btn": "添加密钥",
"gemini_base_url_label": "Base URL (可选)",
"gemini_base_url_placeholder": "例如: https://generativelanguage.googleapis.com",
"gemini_add_modal_proxy_label": "代理 URL (可选):",
"gemini_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"gemini_edit_modal_title": "编辑Gemini API密钥",
"gemini_edit_modal_key_label": "API密钥:",
"gemini_delete_confirm": "确定要删除这个Gemini密钥吗",
@@ -333,7 +338,13 @@
"openai_test_success": "测试成功,模型可用。",
"openai_test_failed": "测试失败",
"openai_test_select_placeholder": "从当前模型列表选择",
"openai_test_select_empty": "当前未配置模型,请先添加模型"
"openai_test_select_empty": "当前未配置模型,请先添加模型",
"openai_test_single_action": "测试",
"openai_test_all_action": "一键测试全部密钥",
"openai_test_all_hint": "测试所有密钥的连接状态",
"openai_test_all_success": "所有 {{count}} 个密钥测试通过",
"openai_test_all_failed": "所有 {{count}} 个密钥测试失败",
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败"
},
"auth_files": {
"title": "认证文件管理",
@@ -376,6 +387,7 @@
"filter_qwen": "Qwen",
"filter_gemini": "Gemini",
"filter_gemini-cli": "GeminiCLI",
"filter_kimi": "Kimi",
"filter_aistudio": "AIStudio",
"filter_claude": "Claude",
"filter_codex": "Codex",
@@ -387,6 +399,7 @@
"type_qwen": "Qwen",
"type_gemini": "Gemini",
"type_gemini-cli": "GeminiCLI",
"type_kimi": "Kimi",
"type_aistudio": "AIStudio",
"type_claude": "Claude",
"type_codex": "Codex",
@@ -403,23 +416,30 @@
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
"models_unsupported": "当前版本不支持此功能",
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
"models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除",
"models_excluded_badge": "已禁用",
"models_excluded_hint": "此 OAuth 模型已被禁用",
"status_toggle_label": "启用",
"status_enabled_success": "已启用 \"{{name}}\"",
"status_disabled_success": "已停用 \"{{name}}\"",
"prefix_proxy_button": "配置 prefix/proxy_url",
"prefix_proxy_loading": "正在加载凭证文件...",
"prefix_proxy_source_label": "凭证 JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_proxy_button": "编辑认证文件字段",
"auth_field_editor_title": "编辑认证文件字段 - {{name}}",
"prefix_proxy_loading": "正在加载认证文件...",
"prefix_proxy_source_label": "认证文件 JSON预览",
"prefix_label": "前缀prefix",
"proxy_url_label": "代理 URLproxy_url",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
"card_tools_title": "配置面板",
"quota_refresh_single": "刷新额度",
"quota_refresh_hint": "仅刷新当前凭证的额度数据",
"priority_label": "优先级priority",
"priority_placeholder": "例如: 10 或 -1",
"priority_hint": "仅支持整数;非法值会被忽略。数值越大优先级越高。",
"excluded_models_label": "排除模型excluded_models",
"excluded_models_placeholder": "用逗号或换行分隔,例如: model-a, gpt-5-*, *-preview",
"excluded_models_hint": "保存为数组;会自动 trim、小写、去重并排序。",
"disable_cooling_label": "禁用冷却disable_cooling",
"disable_cooling_placeholder": "例如: true / false / 1 / 0",
"disable_cooling_hint": "支持布尔值、0/非0 数字或字符串 true/false/1/0无法解析时忽略。",
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
},
@@ -427,7 +447,7 @@
"title": "Antigravity 额度",
"empty_title": "暂无 Antigravity 认证",
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
@@ -435,11 +455,31 @@
"refresh_button": "刷新额度",
"fetch_all": "获取全部"
},
"claude_quota": {
"title": "Claude 额度",
"empty_title": "暂无 Claude OAuth 认证",
"empty_desc": "使用 Claude OAuth 登录后即可查看额度。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_windows": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"five_hour": "5 小时限额",
"seven_day": "7 天限额",
"seven_day_oauth_apps": "7 天 OAuth 应用",
"seven_day_opus": "7 天 Opus",
"seven_day_sonnet": "7 天 Sonnet",
"seven_day_cowork": "7 天 Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "额外用量"
},
"codex_quota": {
"title": "Codex 额度",
"empty_title": "暂无 Codex 认证",
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
@@ -452,6 +492,8 @@
"secondary_window": "周限额",
"code_review_primary_window": "代码审查 5 小时限额",
"code_review_secondary_window": "代码审查周限额",
"additional_primary_window": "{{name}} 5 小时限额",
"additional_secondary_window": "{{name}} 周限额",
"plan_label": "套餐",
"plan_plus": "Plus",
"plan_team": "Team",
@@ -461,7 +503,7 @@
"title": "Gemini CLI 额度",
"empty_title": "暂无 Gemini CLI 认证",
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
@@ -491,43 +533,43 @@
"result_file": "存储文件"
},
"oauth_excluded": {
"title": "OAuth 排除列表",
"title": "OAuth 模型禁用",
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
"add": "新增排除",
"add_title": "新增提供商排除列表",
"edit_title": "编辑 {{provider}} 的排除列表",
"add": "新增禁用",
"add_title": "新增提供商模型禁用",
"edit_title": "编辑 {{provider}} 的模型禁用",
"refresh": "刷新",
"refreshing": "刷新中...",
"provider_label": "提供商",
"provider_auto": "跟随当前过滤",
"provider_placeholder": "例如 gemini-cli / openai",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"models_label": "排除的模型",
"models_label": "禁用的模型",
"models_loading": "正在加载模型列表...",
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
"models_loaded": "已加载 {{count}} 个模型,勾选要禁用的模型。",
"no_models_available": "该提供商暂无可用模型列表。",
"save": "保存/更新",
"saving": "正在保存...",
"save_success": "排除列表已更新",
"save_failed": "更新排除列表失败",
"save_success": "模型禁用已更新",
"save_failed": "更新模型禁用失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的排除列表吗?",
"delete_success": "已删除该提供商的排除列表",
"delete_failed": "删除排除列表失败",
"delete_confirm": "确定要删除 {{provider}} 的模型禁用吗?",
"delete_success": "已删除该提供商的模型禁用",
"delete_failed": "删除模型禁用失败",
"deleting": "正在删除...",
"no_models": "未配置排除模型",
"model_count": "排除 {{count}} 个模型",
"list_empty_all": "暂无任何提供商的排除列表,点击“新增排除”创建。",
"list_empty_filtered": "当前筛选下没有排除项,点击“新增排除”添加。",
"disconnected": "请先连接服务器以查看排除列表",
"load_failed": "加载排除列表失败",
"no_models": "未配置禁用模型",
"model_count": "禁用 {{count}} 个模型",
"list_empty_all": "暂无任何提供商的模型禁用,点击“新增禁用”创建。",
"list_empty_filtered": "当前筛选下没有禁用项,点击“新增禁用”添加。",
"disconnected": "请先连接服务器以查看模型禁用",
"load_failed": "加载模型禁用失败",
"provider_required": "请先填写提供商名称",
"scope_all": "当前范围:全局(显示所有提供商)",
"scope_provider": "当前范围:{{provider}}",
"upgrade_required": "当前 CPA 版本不支持模型排除列表,请升级 CPA 版本",
"upgrade_required": "当前 CPA 版本不支持 OAuth 模型禁用,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
"upgrade_required_desc": "当前服务器版本不支持获取 OAuth 模型禁用功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"oauth_model_alias": {
"title": "OAuth 模型别名",
@@ -640,6 +682,17 @@
"gemini_cli_oauth_status_error": "认证失败:",
"gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:",
"gemini_cli_oauth_polling_error": "检查认证状态失败:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "开始 Kimi 登录",
"kimi_oauth_hint": "通过设备授权流程登录 Kimi 服务,自动获取并保存认证文件。",
"kimi_oauth_url_label": "授权链接:",
"kimi_open_link": "打开链接",
"kimi_copy_link": "复制链接",
"kimi_oauth_status_waiting": "等待认证中...",
"kimi_oauth_status_success": "认证成功!",
"kimi_oauth_status_error": "认证失败:",
"kimi_oauth_start_error": "启动 Kimi OAuth 失败:",
"kimi_oauth_polling_error": "检查认证状态失败:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "开始 Qwen 登录",
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
@@ -711,6 +764,11 @@
"api_details": "API 详细统计",
"by_hour": "按小时",
"by_day": "按天",
"range_filter": "时间范围",
"range_all": "全部时间",
"range_7h": "最近7小时",
"range_24h": "最近24小时",
"range_7d": "最近7天",
"refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
@@ -758,7 +816,13 @@
"cost_axis_label": "花费 ($)",
"cost_need_price": "请先设置模型价格",
"cost_need_usage": "暂无使用数据,无法计算花费",
"cost_no_data": "没有可计算的花费数据"
"cost_no_data": "没有可计算的花费数据",
"credential_stats": "凭证统计",
"credential_name": "凭证",
"token_breakdown": "Token 类型分布",
"input_tokens": "输入 Tokens",
"output_tokens": "输出 Tokens",
"last_updated": "更新于"
},
"stats": {
"success": "成功",
@@ -877,9 +941,9 @@
"debug": "调试模式",
"debug_desc": "启用详细的调试日志",
"commercial_mode": "商业模式",
"commercial_mode_desc": "禁用高开销中间件以减少高并发内存",
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
"logging_to_file": "写入日志文件",
"logging_to_file_desc": "将日志保存到滚动文件",
"logging_to_file_desc": "将日志保存到文件",
"usage_statistics": "使用统计",
"usage_statistics_desc": "收集使用统计信息",
"logs_max_size": "日志文件大小限制 (MB)"
@@ -956,6 +1020,17 @@
"add_param": "添加参数",
"no_rules": "暂无规则",
"add_rule": "添加规则",
"provider_default": "默认",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"value_type_string": "字符串",
"value_type_number": "数字",
"value_type_boolean": "布尔",
"value_type_json": "JSON",
"value_string": "字符串值",
"value_number": "数字值 (如 0.7)",
"value_boolean": "true 或 false",
@@ -979,6 +1054,7 @@
},
"system_info": {
"title": "管理中心信息",
"about_title": "CLI Proxy API Management Center",
"connection_status_title": "连接状态",
"api_status_label": "API 状态:",
"config_status_label": "配置状态:",
@@ -1083,12 +1159,15 @@
"gemini_api_key": "Gemini API密钥",
"codex_api_key": "Codex API密钥",
"claude_api_key": "Claude API密钥",
"commercial_mode_restart_required": "商业模式开关已变更,请重启服务后生效",
"copy_failed": "复制失败",
"link_copied": "已复制"
},
"language": {
"switch": "语言",
"chinese": "中文",
"english": "English"
"english": "English",
"russian": "Русский"
},
"theme": {
"switch": "主题",

View File

@@ -302,6 +302,8 @@ export function AiProvidersAmpcodeEditPage() {
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={loading || saving || disableControls}
/>
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>

View File

@@ -210,7 +210,7 @@ export function AiProvidersClaudeEditPage() {
<Card>
{error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div>
<div className="hint">{t('common.invalid_provider_index')}</div>
) : (
<>
<Input
@@ -245,6 +245,8 @@ export function AiProvidersClaudeEditPage() {
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="form-group">
@@ -255,6 +257,8 @@ export function AiProvidersClaudeEditPage() {
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
</div>

View File

@@ -210,7 +210,7 @@ export function AiProvidersCodexEditPage() {
<Card>
{error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div>
<div className="hint">{t('common.invalid_provider_index')}</div>
) : (
<>
<Input
@@ -245,6 +245,8 @@ export function AiProvidersCodexEditPage() {
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="form-group">

View File

@@ -21,6 +21,7 @@ const buildEmptyForm = (): GeminiFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
excludedModels: [],
excludedText: '',
@@ -138,6 +139,7 @@ export function AiProvidersGeminiEditPage() {
apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined,
baseUrl: form.baseUrl?.trim() || undefined,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers),
excludedModels: parseExcludedModels(form.excludedText),
};
@@ -193,7 +195,7 @@ export function AiProvidersGeminiEditPage() {
<Card>
{error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div>
<div className="hint">{t('common.invalid_provider_index')}</div>
) : (
<>
<Input
@@ -218,12 +220,21 @@ export function AiProvidersGeminiEditPage() {
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
disabled={disableControls || saving}
/>
<Input
label={t('ai_providers.gemini_add_modal_proxy_label')}
placeholder={t('ai_providers.gemini_add_modal_proxy_placeholder')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
disabled={disableControls || saving}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="form-group">

View File

@@ -10,6 +10,7 @@ import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildApiKeyEntry } from '@/components/providers/utils';
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
type LocationState = { fromAiProviders?: boolean } | null;
@@ -29,6 +30,9 @@ export type OpenAIEditOutletContext = {
setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>;
testMessage: string;
setTestMessage: Dispatch<SetStateAction<string>>;
keyTestStatuses: KeyTestStatus[];
setDraftKeyTestStatus: (keyIndex: number, status: KeyTestStatus) => void;
resetDraftKeyTestStatuses: (count: number) => void;
availableModels: string[];
handleBack: () => void;
handleSave: () => Promise<void>;
@@ -73,8 +77,6 @@ export function AiProvidersOpenAIEditLayout() {
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const isCacheValid = useConfigStore((state) => state.isCacheValid);
const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
@@ -99,11 +101,14 @@ export function AiProvidersOpenAIEditLayout() {
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
const setDraftKeyTestStatus = useOpenAIEditDraftStore((state) => state.setDraftKeyTestStatus);
const resetDraftKeyTestStatuses = useOpenAIEditDraftStore((state) => state.resetDraftKeyTestStatuses);
const form = draft?.form ?? buildEmptyForm();
const testModel = draft?.testModel ?? '';
const testStatus = draft?.testStatus ?? 'idle';
const testMessage = draft?.testMessage ?? '';
const keyTestStatuses = draft?.keyTestStatuses ?? [];
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
(action) => {
@@ -134,6 +139,20 @@ export function AiProvidersOpenAIEditLayout() {
[draftKey, setDraftTestMessage]
);
const handleSetDraftKeyTestStatus = useCallback(
(keyIndex: number, status: KeyTestStatus) => {
setDraftKeyTestStatus(draftKey, keyIndex, status);
},
[draftKey, setDraftKeyTestStatus]
);
const handleResetDraftKeyTestStatuses = useCallback(
(count: number) => {
resetDraftKeyTestStatuses(draftKey, count);
},
[draftKey, resetDraftKeyTestStatuses]
);
const initialData = useMemo(() => {
if (editIndex === null) return undefined;
return providers[editIndex];
@@ -215,6 +234,7 @@ export function AiProvidersOpenAIEditLayout() {
testModel: initialTestModel,
testStatus: 'idle',
testMessage: '',
keyTestStatuses: [],
});
} else {
initDraft(draftKey, {
@@ -222,6 +242,7 @@ export function AiProvidersOpenAIEditLayout() {
testModel: '',
testStatus: 'idle',
testMessage: '',
keyTestStatuses: [],
});
}
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
@@ -243,7 +264,7 @@ export function AiProvidersOpenAIEditLayout() {
setTestStatus('idle');
setTestMessage('');
}
}, [availableModels, loading, testModel]);
}, [availableModels, loading, setTestMessage, setTestModel, setTestStatus, testModel]);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {
@@ -280,12 +301,20 @@ export function AiProvidersOpenAIEditLayout() {
);
const handleSave = useCallback(async () => {
const name = form.name.trim();
const baseUrl = form.baseUrl.trim();
if (!name || !baseUrl) {
showNotification(t('notification.openai_provider_required'), 'error');
return;
}
setSaving(true);
try {
const payload: OpenAIProviderConfig = {
name: form.name.trim(),
name,
prefix: form.prefix?.trim() || undefined,
baseUrl: form.baseUrl.trim(),
baseUrl,
headers: buildHeaderObject(form.headers),
apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({
apiKey: entry.apiKey.trim(),
@@ -304,9 +333,18 @@ export function AiProvidersOpenAIEditLayout() {
: [...providers, payload];
await providersApi.saveOpenAIProviders(nextList);
setProviders(nextList);
updateConfigValue('openai-compatibility', nextList);
clearCache('openai-compatibility');
let syncedProviders = nextList;
try {
const latest = await fetchConfig('openai-compatibility', true);
if (Array.isArray(latest)) {
syncedProviders = latest as OpenAIProviderConfig[];
}
} catch {
// 保存成功后刷新失败时,回退到本地计算结果,避免页面数据为空或回退
}
setProviders(syncedProviders);
showNotification(
editIndex !== null
? t('notification.openai_provider_updated')
@@ -320,15 +358,14 @@ export function AiProvidersOpenAIEditLayout() {
setSaving(false);
}
}, [
clearCache,
editIndex,
fetchConfig,
form,
handleBack,
providers,
testModel,
showNotification,
t,
updateConfigValue,
]);
const resolvedLoading = !draft?.initialized;
@@ -351,6 +388,9 @@ export function AiProvidersOpenAIEditLayout() {
setTestStatus,
testMessage,
setTestMessage,
keyTestStatuses,
setDraftKeyTestStatus: handleSetDraftKeyTestStatus,
resetDraftKeyTestStatuses: handleResetDraftKeyTestStatuses,
availableModels,
handleBack,
handleSave,

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useCallback, useMemo, useRef, useState } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
@@ -14,6 +14,7 @@ import type { ApiKeyEntry } from '@/types';
import { buildHeaderObject } from '@/utils/headers';
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
import styles from './AiProvidersPage.module.scss';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -25,6 +26,72 @@ const getErrorMessage = (err: unknown) => {
return '';
};
// Status icon components
function StatusLoadingIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className={styles.statusIconSpin}>
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path
d="M8 1A7 7 0 0 1 8 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function StatusSuccessIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="8" fill="var(--success-color, #22c55e)" />
<path
d="M4.5 8L7 10.5L11.5 6"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function StatusErrorIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="8" fill="var(--danger-color, #ef4444)" />
<path
d="M5 5L11 11M11 5L5 11"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function StatusIdleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="var(--text-tertiary, #9ca3af)" strokeWidth="2" />
</svg>
);
}
function StatusIcon({ status }: { status: KeyTestStatus['status'] }) {
switch (status) {
case 'loading':
return <StatusLoadingIcon />;
case 'success':
return <StatusSuccessIcon />;
case 'error':
return <StatusErrorIcon />;
default:
return <StatusIdleIcon />;
}
}
export function AiProvidersOpenAIEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -44,6 +111,9 @@ export function AiProvidersOpenAIEditPage() {
setTestStatus,
testMessage,
setTestMessage,
keyTestStatuses,
setDraftKeyTestStatus,
resetDraftKeyTestStatuses,
availableModels,
handleBack,
handleSave,
@@ -54,6 +124,7 @@ export function AiProvidersOpenAIEditPage() {
: t('ai_providers.openai_add_modal_title');
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
const [isTestingKeys, setIsTestingKeys] = useState(false);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -65,80 +136,131 @@ export function AiProvidersOpenAIEditPage() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleBack]);
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex;
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTestingKeys;
const hasConfiguredModels = form.modelEntries.some((entry) => entry.name.trim());
const hasTestableKeys = form.apiKeyEntries.some((entry) => entry.apiKey?.trim());
const connectivityConfigSignature = useMemo(() => {
const headersSignature = form.headers
.map((entry) => `${entry.key.trim()}:${entry.value.trim()}`)
.join('|');
const modelsSignature = form.modelEntries
.map((entry) => `${entry.name.trim()}:${entry.alias.trim()}`)
.join('|');
return [form.baseUrl.trim(), testModel.trim(), headersSignature, modelsSignature].join('||');
}, [form.baseUrl, form.headers, form.modelEntries, testModel]);
const previousConnectivityConfigRef = useRef(connectivityConfigSignature);
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
const list = entries.length ? entries : [buildApiKeyEntry()];
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
};
const removeEntry = (idx: number) => {
const next = list.filter((_, i) => i !== idx);
setForm((prev) => ({
...prev,
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
}));
};
const addEntry = () => {
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
};
return (
<div className="stack">
{list.map((entry, index) => (
<div key={index} className="item-row">
<div className="item-meta">
<Input
label={`${t('common.api_key')} #${index + 1}`}
value={entry.apiKey}
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
disabled={saving || disableControls}
/>
<Input
label={t('common.proxy_url')}
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
disabled={saving || disableControls}
/>
</div>
<div className="item-actions">
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={saving || disableControls || list.length <= 1}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
<Button
variant="secondary"
size="sm"
onClick={addEntry}
disabled={saving || disableControls}
>
{t('ai_providers.openai_keys_add_btn')}
</Button>
</div>
);
};
const openOpenaiModelDiscovery = () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
useEffect(() => {
if (previousConnectivityConfigRef.current === connectivityConfigSignature) {
return;
}
navigate('models');
};
previousConnectivityConfigRef.current = connectivityConfigSignature;
resetDraftKeyTestStatuses(form.apiKeyEntries.length);
setTestStatus('idle');
setTestMessage('');
}, [
connectivityConfigSignature,
form.apiKeyEntries.length,
resetDraftKeyTestStatuses,
setTestStatus,
setTestMessage,
]);
// Test a single key by index
const runSingleKeyTest = useCallback(
async (keyIndex: number): Promise<boolean> => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('notification.openai_test_url_required'), 'error');
return false;
}
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
if (!endpoint) {
showNotification(t('notification.openai_test_url_required'), 'error');
return false;
}
const keyEntry = form.apiKeyEntries[keyIndex];
if (!keyEntry?.apiKey?.trim()) {
setDraftKeyTestStatus(keyIndex, { status: 'error', message: t('notification.openai_test_key_required') });
return false;
}
const modelName = testModel.trim() || availableModels[0] || '';
if (!modelName) {
showNotification(t('notification.openai_test_model_required'), 'error');
return false;
}
const customHeaders = buildHeaderObject(form.headers);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!headers.Authorization && !headers['authorization']) {
headers.Authorization = `Bearer ${keyEntry.apiKey.trim()}`;
}
// Set loading state for this key
setDraftKeyTestStatus(keyIndex, { status: 'loading', message: '' });
try {
const result = await apiCallApi.request(
{
method: 'POST',
url: endpoint,
header: Object.keys(headers).length ? headers : undefined,
data: JSON.stringify({
model: modelName,
messages: [{ role: 'user', content: 'Hi' }],
stream: false,
max_tokens: 5,
}),
},
{ timeout: OPENAI_TEST_TIMEOUT_MS }
);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
setDraftKeyTestStatus(keyIndex, { status: 'success', message: '' });
return true;
} catch (err: unknown) {
const message = getErrorMessage(err);
const errorCode =
typeof err === 'object' && err !== null && 'code' in err
? String((err as { code?: string }).code)
: '';
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
const errorMessage = isTimeout
? t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
: message;
setDraftKeyTestStatus(keyIndex, { status: 'error', message: errorMessage });
return false;
}
},
[form.baseUrl, form.apiKeyEntries, form.headers, testModel, availableModels, t, setDraftKeyTestStatus, showNotification]
);
const testSingleKey = useCallback(
async (keyIndex: number): Promise<boolean> => {
if (isTestingKeys) return false;
setIsTestingKeys(true);
try {
return await runSingleKeyTest(keyIndex);
} finally {
setIsTestingKeys(false);
}
},
[isTestingKeys, runSingleKeyTest]
);
// Test all keys
const testAllKeys = useCallback(async () => {
if (isTestingKeys) return;
const testOpenaiProviderConnection = async () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
const message = t('notification.openai_test_url_required');
@@ -157,15 +279,6 @@ export function AiProvidersOpenAIEditPage() {
return;
}
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
if (!firstKeyEntry) {
const message = t('notification.openai_test_key_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const modelName = testModel.trim() || availableModels[0] || '';
if (!modelName) {
const message = t('notification.openai_test_model_required');
@@ -175,56 +288,194 @@ export function AiProvidersOpenAIEditPage() {
return;
}
const customHeaders = buildHeaderObject(form.headers);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!headers.Authorization && !headers['authorization']) {
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
const validKeyIndexes = form.apiKeyEntries
.map((entry, index) => (entry.apiKey?.trim() ? index : -1))
.filter((index) => index >= 0);
if (validKeyIndexes.length === 0) {
const message = t('notification.openai_test_key_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
setIsTestingKeys(true);
setTestStatus('loading');
setTestMessage(t('ai_providers.openai_test_running'));
resetDraftKeyTestStatuses(form.apiKeyEntries.length);
try {
const result = await apiCallApi.request(
{
method: 'POST',
url: endpoint,
header: Object.keys(headers).length ? headers : undefined,
data: JSON.stringify({
model: modelName,
messages: [{ role: 'user', content: 'Hi' }],
stream: false,
max_tokens: 5,
}),
},
{ timeout: OPENAI_TEST_TIMEOUT_MS }
);
const results = await Promise.all(validKeyIndexes.map((index) => runSingleKeyTest(index)));
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
const successCount = results.filter(Boolean).length;
const failCount = validKeyIndexes.length - successCount;
setTestStatus('success');
setTestMessage(t('ai_providers.openai_test_success'));
} catch (err: unknown) {
setTestStatus('error');
const message = getErrorMessage(err);
const errorCode =
typeof err === 'object' && err !== null && 'code' in err
? String((err as { code?: string }).code)
: '';
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
if (isTimeout) {
setTestMessage(
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
);
if (failCount === 0) {
const message = t('ai_providers.openai_test_all_success', { count: successCount });
setTestStatus('success');
setTestMessage(message);
showNotification(message, 'success');
} else if (successCount === 0) {
const message = t('ai_providers.openai_test_all_failed', { count: failCount });
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
} else {
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
const message = t('ai_providers.openai_test_all_partial', { success: successCount, failed: failCount });
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'warning');
}
} finally {
setIsTestingKeys(false);
}
}, [
isTestingKeys,
form.baseUrl,
form.apiKeyEntries,
testModel,
availableModels,
t,
setTestStatus,
setTestMessage,
resetDraftKeyTestStatuses,
runSingleKeyTest,
showNotification,
]);
const openOpenaiModelDiscovery = () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
navigate('models');
};
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
const list = entries.length ? entries : [buildApiKeyEntry()];
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
setDraftKeyTestStatus(idx, { status: 'idle', message: '' });
setTestStatus('idle');
setTestMessage('');
};
const removeEntry = (idx: number) => {
const next = list.filter((_, i) => i !== idx);
const nextLength = next.length ? next.length : 1;
setForm((prev) => ({
...prev,
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
}));
resetDraftKeyTestStatuses(nextLength);
setTestStatus('idle');
setTestMessage('');
};
const addEntry = () => {
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
resetDraftKeyTestStatuses(list.length + 1);
setTestStatus('idle');
setTestMessage('');
};
return (
<div className={styles.keyEntriesList}>
<div className={styles.keyEntriesToolbar}>
<span className={styles.keyEntriesCount}>
{t('ai_providers.openai_keys_count')}: {list.length}
</span>
<Button
variant="secondary"
size="sm"
onClick={addEntry}
disabled={saving || disableControls || isTestingKeys}
className={styles.addKeyButton}
>
{t('ai_providers.openai_keys_add_btn')}
</Button>
</div>
<div className={styles.keyTableShell}>
{/* 表头 */}
<div className={styles.keyTableHeader}>
<div className={styles.keyTableColIndex}>#</div>
<div className={styles.keyTableColStatus}>{t('common.status')}</div>
<div className={styles.keyTableColKey}>{t('common.api_key')}</div>
<div className={styles.keyTableColProxy}>{t('common.proxy_url')}</div>
<div className={styles.keyTableColAction}>{t('common.action')}</div>
</div>
{/* 数据行 */}
{list.map((entry, index) => {
const keyStatus = keyTestStatuses[index]?.status ?? 'idle';
const canTestKey = Boolean(entry.apiKey?.trim()) && hasConfiguredModels;
return (
<div key={index} className={styles.keyTableRow}>
{/* 序号 */}
<div className={styles.keyTableColIndex}>{index + 1}</div>
{/* 状态指示灯 */}
<div
className={styles.keyTableColStatus}
title={keyTestStatuses[index]?.message || ''}
>
<StatusIcon status={keyStatus} />
</div>
{/* Key 输入框 */}
<div className={styles.keyTableColKey}>
<input
type="text"
value={entry.apiKey}
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
disabled={saving || disableControls || isTestingKeys}
className={`input ${styles.keyTableInput}`}
placeholder={t('ai_providers.openai_key_placeholder')}
/>
</div>
{/* Proxy 输入框 */}
<div className={styles.keyTableColProxy}>
<input
type="text"
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
disabled={saving || disableControls || isTestingKeys}
className={`input ${styles.keyTableInput}`}
placeholder={t('ai_providers.openai_proxy_placeholder')}
/>
</div>
{/* 操作按钮 */}
<div className={styles.keyTableColAction}>
<Button
variant="secondary"
size="sm"
onClick={() => void testSingleKey(index)}
disabled={saving || disableControls || isTestingKeys || !canTestKey}
loading={keyStatus === 'loading'}
>
{t('ai_providers.openai_test_single_action')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={saving || disableControls || isTestingKeys || list.length <= 1}
>
{t('common.delete')}
</Button>
</div>
</div>
);
})}
</div>
</div>
);
};
return (
@@ -245,14 +496,14 @@ export function AiProvidersOpenAIEditPage() {
>
<Card>
{invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div>
<div className="hint">{t('common.invalid_provider_index')}</div>
) : (
<>
<Input
label={t('ai_providers.openai_add_modal_name_label')}
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
disabled={saving || disableControls}
disabled={saving || disableControls || isTestingKeys}
/>
<Input
label={t('ai_providers.prefix_label')}
@@ -260,13 +511,13 @@ export function AiProvidersOpenAIEditPage() {
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
disabled={saving || disableControls}
disabled={saving || disableControls || isTestingKeys}
/>
<Input
label={t('ai_providers.openai_add_modal_url_label')}
value={form.baseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
disabled={saving || disableControls}
disabled={saving || disableControls || isTestingKeys}
/>
<HeaderInputList
@@ -275,77 +526,109 @@ export function AiProvidersOpenAIEditPage() {
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
disabled={saving || disableControls}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={saving || disableControls || isTestingKeys}
/>
<div className="form-group">
<label>
{hasIndexParam
? t('ai_providers.openai_edit_modal_models_label')
: t('ai_providers.openai_add_modal_models_label')}
</label>
{/* 模型配置区域 - 统一布局 */}
<div className={styles.modelConfigSection}>
{/* 标题行 */}
<div className={styles.modelConfigHeader}>
<label className={styles.modelConfigTitle}>
{hasIndexParam
? t('ai_providers.openai_edit_modal_models_label')
: t('ai_providers.openai_add_modal_models_label')}
</label>
<div className={styles.modelConfigToolbar}>
<Button
variant="secondary"
size="sm"
onClick={() => setForm((prev) => ({
...prev,
modelEntries: [...prev.modelEntries, { name: '', alias: '' }]
}))}
disabled={saving || disableControls || isTestingKeys}
>
{t('ai_providers.openai_models_add_btn')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={openOpenaiModelDiscovery}
disabled={saving || disableControls || isTestingKeys}
>
{t('ai_providers.openai_models_fetch_button')}
</Button>
</div>
</div>
{/* 提示文本 */}
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
{/* 模型列表 */}
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.openai_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={saving || disableControls}
disabled={saving || disableControls || isTestingKeys}
hideAddButton
className={styles.modelInputList}
rowClassName={styles.modelInputRow}
inputClassName={styles.modelInputField}
removeButtonClassName={styles.modelRowRemoveButton}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
/>
<Button
variant="secondary"
size="sm"
onClick={openOpenaiModelDiscovery}
disabled={saving || disableControls}
>
{t('ai_providers.openai_models_fetch_button')}
</Button>
</div>
<div className="form-group">
<label>{t('ai_providers.openai_test_title')}</label>
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<select
className={`input ${styles.openaiTestSelect}`}
value={testModel}
onChange={(e) => {
setTestModel(e.target.value);
setTestStatus('idle');
setTestMessage('');
}}
disabled={saving || disableControls || availableModels.length === 0}
>
<option value="">
{availableModels.length
? t('ai_providers.openai_test_select_placeholder')
: t('ai_providers.openai_test_select_empty')}
</option>
{form.modelEntries
.filter((entry) => entry.name.trim())
.map((entry, idx) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
const label = alias && alias !== name ? `${name} (${alias})` : name;
return (
<option key={`${name}-${idx}`} value={name}>
{label}
</option>
);
})}
</select>
<Button
variant={testStatus === 'error' ? 'danger' : 'secondary'}
className={`${styles.openaiTestButton} ${
testStatus === 'success' ? styles.openaiTestButtonSuccess : ''
}`}
onClick={() => void testOpenaiProviderConnection()}
loading={testStatus === 'loading'}
disabled={saving || disableControls || availableModels.length === 0}
>
{t('ai_providers.openai_test_action')}
</Button>
{/* 测试区域 */}
<div className={styles.modelTestPanel}>
<div className={styles.modelTestMeta}>
<label className={styles.modelTestLabel}>{t('ai_providers.openai_test_title')}</label>
<span className={styles.modelTestHint}>{t('ai_providers.openai_test_hint')}</span>
</div>
<div className={styles.modelTestControls}>
<select
className={`input ${styles.openaiTestSelect}`}
value={testModel}
onChange={(e) => {
setTestModel(e.target.value);
setTestStatus('idle');
setTestMessage('');
}}
disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || availableModels.length === 0}
>
<option value="">
{availableModels.length
? t('ai_providers.openai_test_select_placeholder')
: t('ai_providers.openai_test_select_empty')}
</option>
{form.modelEntries
.filter((entry) => entry.name.trim())
.map((entry, idx) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
const label = alias && alias !== name ? `${name} (${alias})` : name;
return (
<option key={`${name}-${idx}`} value={name}>
{label}
</option>
);
})}
</select>
<Button
variant={testStatus === 'error' ? 'danger' : 'secondary'}
size="sm"
onClick={() => void testAllKeys()}
loading={testStatus === 'loading'}
disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || !hasConfiguredModels || !hasTestableKeys}
title={t('ai_providers.openai_test_all_hint')}
className={styles.modelTestAllButton}
>
{t('ai_providers.openai_test_all_action')}
</Button>
</div>
</div>
{testMessage && (
<div
@@ -362,8 +645,11 @@ export function AiProvidersOpenAIEditPage() {
)}
</div>
<div className="form-group">
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
<div className={`form-group ${styles.keyEntriesSection}`}>
<div className={styles.keyEntriesHeader}>
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
<span className={styles.keyEntriesHint}>{t('ai_providers.openai_keys_hint')}</span>
</div>
{renderKeyEntries(form.apiKeyEntries)}
</div>
</>

View File

@@ -387,19 +387,6 @@
}
}
// 连通性测试按钮高度对齐
.openaiTestSelect {
flex: 1 1 0;
min-width: 0;
}
.openaiTestButton {
flex: 1 1 0;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
}
// 状态监测栏
.statusBar {
display: flex;
@@ -473,6 +460,318 @@
background: var(--failure-badge-bg, #fee2e2);
}
// ============================================
// Model Config Section - Unified Layout
// ============================================
.modelConfigSection {
margin-bottom: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.modelConfigHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
flex-wrap: wrap;
@include mobile {
align-items: flex-start;
}
}
.modelConfigTitle {
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
line-height: 1.4;
}
.modelConfigToolbar {
display: flex;
align-items: center;
gap: $spacing-xs;
flex-wrap: wrap;
justify-content: flex-end;
@include mobile {
width: 100%;
justify-content: flex-start;
}
:global(.btn) {
white-space: nowrap;
}
}
.modelInputList {
gap: $spacing-xs;
}
.modelInputRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
gap: $spacing-sm;
align-items: center;
@include mobile {
grid-template-columns: minmax(0, 1fr) auto;
row-gap: $spacing-xs;
> :nth-child(2) {
display: none;
}
> :nth-child(3) {
grid-column: 1 / 3;
}
> :nth-child(4) {
grid-column: 2 / 3;
grid-row: 1 / 2;
}
}
}
.modelInputField {
min-width: 0;
}
.modelRowRemoveButton {
justify-self: center;
}
.modelTestPanel {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-md;
margin-top: $spacing-sm;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.modelTestMeta {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.modelTestLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
line-height: 1.4;
}
.modelTestHint {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.modelTestControls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $spacing-xs;
flex: 1;
min-width: 0;
@include mobile {
justify-content: flex-start;
}
}
// ============================================
// Key Entry Styles - Table Design
// ============================================
.keyEntriesSection {
margin-bottom: 0;
}
.keyEntriesHeader {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: $spacing-sm;
label {
margin: 0;
}
}
.keyEntriesHint {
font-size: 13px;
line-height: 1.4;
color: var(--text-secondary);
}
.keyEntriesList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.keyEntriesToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
flex-wrap: wrap;
}
.keyEntriesCount {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.keyTableShell {
overflow-x: auto;
border-radius: $radius-md;
}
// 表头
.keyTableHeader {
display: grid;
grid-template-columns: 46px 56px minmax(220px, 1.4fr) minmax(200px, 1.1fr) 180px;
gap: $spacing-sm;
min-width: 760px;
padding: 10px $spacing-md;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-bottom: none;
border-radius: $radius-md $radius-md 0 0;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: none;
align-items: center;
text-align: center;
}
// 数据行
.keyTableRow {
display: grid;
grid-template-columns: 46px 56px minmax(220px, 1.4fr) minmax(200px, 1.1fr) 180px;
gap: $spacing-sm;
min-width: 760px;
padding: 10px $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-top: none;
align-items: center;
&:last-child {
border-radius: 0 0 $radius-md $radius-md;
}
&:hover {
background: var(--bg-tertiary);
}
}
// 列定义
.keyTableColIndex {
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--text-tertiary);
}
.keyTableColStatus {
display: flex;
align-items: center;
justify-content: center;
svg {
display: block;
}
}
.keyTableColKey,
.keyTableColProxy {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
}
.keyTableColAction {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-xs;
flex-shrink: 0;
white-space: nowrap;
}
.keyTableInput {
width: 100%;
padding: 8px 10px;
font-size: 14px;
min-height: 38px;
text-align: center;
}
.addKeyButton {
align-self: auto;
margin-top: 0;
}
.openaiTestSelect {
flex: 1 1 260px;
min-width: 180px;
max-width: 380px;
@include mobile {
min-width: 0;
max-width: none;
}
}
.modelTestAllButton {
white-space: nowrap;
flex-shrink: 0;
}
.statusIconWrapper {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--text-secondary);
flex-shrink: 0;
}
.statusIconSpin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 暗色主题适配
:global([data-theme='dark']) {
.headerBadge {

View File

@@ -218,7 +218,7 @@ export function AiProvidersVertexEditPage() {
<Card>
{error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div>
<div className="hint">{t('common.invalid_provider_index')}</div>
) : (
<>
<Input
@@ -256,6 +256,8 @@ export function AiProvidersVertexEditPage() {
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="form-group">
@@ -266,6 +268,8 @@ export function AiProvidersVertexEditPage() {
addLabel={t('ai_providers.vertex_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>

View File

@@ -26,6 +26,7 @@ const OAUTH_PROVIDER_PRESETS = [
'claude',
'codex',
'qwen',
'kimi',
'iflow',
];

View File

@@ -29,6 +29,7 @@ const OAUTH_PROVIDER_PRESETS = [
'claude',
'codex',
'qwen',
'kimi',
'iflow',
];

View File

@@ -80,12 +80,13 @@
.filterTag {
display: inline-flex;
align-items: center;
align-items: baseline;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
line-height: 1;
border: 1px solid transparent;
cursor: pointer;
transition: all $transition-fast;
@@ -101,12 +102,19 @@
}
.filterTagLabel {
display: inline-flex;
align-items: baseline;
white-space: nowrap;
}
.filterTagCount {
display: inline-flex;
align-items: baseline;
justify-content: flex-end;
min-width: 2ch;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
@@ -185,10 +193,10 @@
}
.fileGridQuotaManaged {
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
@@ -414,6 +422,24 @@
padding: $spacing-sm 0;
}
.quotaMessageAction {
width: 100%;
border: none;
background: none;
cursor: pointer;
text-decoration: underline;
&:hover:not(:disabled) {
color: var(--text-primary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
text-decoration: none;
}
}
.quotaError {
font-size: 12px;
color: var(--danger-color);
@@ -487,17 +513,6 @@
gap: $spacing-md;
}
.fileCardLayoutQuota {
display: grid;
grid-template-columns: 1fr 156px;
gap: $spacing-md;
align-items: stretch;
@include mobile {
grid-template-columns: 1fr;
}
}
.fileCardMain {
display: flex;
flex-direction: column;
@@ -506,41 +521,6 @@
min-width: 0;
}
.fileCardSidebar {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding-left: $spacing-md;
border-left: 1px dashed var(--border-color);
@include mobile {
border-left: none;
border-top: 1px dashed var(--border-color);
padding-left: 0;
padding-top: $spacing-md;
}
}
.fileCardSidebarHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-xs;
}
.fileCardSidebarTitle {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
}
.fileCardSidebarHint {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.cardHeader {
display: flex;
align-items: center;
@@ -813,7 +793,7 @@
border-radius: $radius-md;
}
// OAuth 排除列表
// OAuth 模型禁用
.excludedList {
display: flex;
flex-direction: column;
@@ -861,7 +841,7 @@
flex-shrink: 0;
}
// OAuth 排除列表表单:提供商快捷标签
// OAuth 模型禁用表单:提供商快捷标签
.providerField {
display: flex;
flex-direction: column;

View File

@@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
@@ -17,7 +18,6 @@ import {
IconChevronUp,
IconDownload,
IconInfo,
IconRefreshCw,
IconTrash2,
} from '@/components/ui/icons';
import type { TFunction } from 'i18next';
@@ -49,6 +49,10 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
light: { bg: '#e8f5e9', text: '#2e7d32' },
dark: { bg: '#1b5e20', text: '#81c784' },
},
kimi: {
light: { bg: '#fff4e5', text: '#ad6800' },
dark: { bg: '#7c4a03', text: '#ffd591' },
},
gemini: {
light: { bg: '#e3f2fd', text: '#1565c0' },
dark: { bg: '#0d47a1', text: '#64b5f6' },
@@ -91,6 +95,9 @@ const MIN_CARD_PAGE_SIZE = 3;
const MAX_CARD_PAGE_SIZE = 30;
const MAX_AUTH_FILE_SIZE = 50 * 1024;
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
@@ -166,6 +173,34 @@ const writeAuthFilesUiState = (state: AuthFilesUiState) => {
}
};
const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fallback below
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
return copied;
} catch {
return false;
}
};
interface PrefixProxyEditorState {
fileName: string;
loading: boolean;
@@ -176,7 +211,54 @@ interface PrefixProxyEditorState {
json: Record<string, unknown> | null;
prefix: string;
proxyUrl: string;
priority: string;
excludedModelsText: string;
disableCooling: string;
}
const parsePriorityValue = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isInteger(value) ? value : undefined;
}
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed || !INTEGER_STRING_PATTERN.test(trimmed)) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isSafeInteger(parsed) ? parsed : undefined;
};
const normalizeExcludedModels = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const normalized: string[] = [];
value.forEach((entry) => {
const model = String(entry ?? '')
.trim()
.toLowerCase();
if (!model || seen.has(model)) return;
seen.add(model);
normalized.push(model);
});
return normalized.sort((a, b) => a.localeCompare(b));
};
const parseExcludedModelsText = (value: string): string[] =>
normalizeExcludedModels(value.split(/[\n,]+/));
const parseDisableCoolingValue = (value: unknown): boolean | undefined => {
if (typeof value === 'boolean') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value !== 0;
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (TRUTHY_TEXT_VALUES.has(normalized)) return true;
if (FALSY_TEXT_VALUES.has(normalized)) return false;
return undefined;
};
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -248,6 +330,8 @@ export function AuthFilesPage() {
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const navigate = useNavigate();
const [files, setFiles] = useState<AuthFileItem[]>([]);
@@ -407,11 +491,36 @@ export function AuthFilesPage() {
if ('proxy_url' in next || prefixProxyEditor.proxyUrl.trim()) {
next.proxy_url = prefixProxyEditor.proxyUrl;
}
const parsedPriority = parsePriorityValue(prefixProxyEditor.priority);
if (parsedPriority !== undefined) {
next.priority = parsedPriority;
} else if ('priority' in next) {
delete next.priority;
}
const excludedModels = parseExcludedModelsText(prefixProxyEditor.excludedModelsText);
if (excludedModels.length > 0) {
next.excluded_models = excludedModels;
} else if ('excluded_models' in next) {
delete next.excluded_models;
}
const parsedDisableCooling = parseDisableCoolingValue(prefixProxyEditor.disableCooling);
if (parsedDisableCooling !== undefined) {
next.disable_cooling = parsedDisableCooling;
} else if ('disable_cooling' in next) {
delete next.disable_cooling;
}
return JSON.stringify(next);
}, [
prefixProxyEditor?.json,
prefixProxyEditor?.prefix,
prefixProxyEditor?.proxyUrl,
prefixProxyEditor?.priority,
prefixProxyEditor?.excludedModelsText,
prefixProxyEditor?.disableCooling,
prefixProxyEditor?.rawText,
]);
@@ -504,7 +613,7 @@ export function AuthFilesPage() {
}
}, []);
// 加载 OAuth 排除列表
// 加载 OAuth 模型禁用
const loadExcluded = useCallback(async () => {
try {
const res = await authFilesApi.getOauthExcludedModels();
@@ -563,14 +672,15 @@ export function AuthFilesPage() {
useHeaderRefresh(handleHeaderRefresh);
useEffect(() => {
if (!isCurrentLayer) return;
loadFiles();
loadKeyStats();
loadExcluded();
loadModelAlias();
}, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
}, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
useInterval(loadKeyStats, isCurrentLayer ? 240_000 : null);
// 提取所有存在的类型
const existingTypes = useMemo(() => {
@@ -822,6 +932,9 @@ export function AuthFilesPage() {
json: null,
prefix: '',
proxyUrl: '',
priority: '',
excludedModelsText: '',
disableCooling: '',
});
try {
@@ -863,6 +976,9 @@ export function AuthFilesPage() {
const originalText = JSON.stringify(json);
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
const priority = parsePriorityValue(json.priority);
const excludedModels = normalizeExcludedModels(json.excluded_models);
const disableCooling = parseDisableCoolingValue(json.disable_cooling);
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
@@ -874,6 +990,10 @@ export function AuthFilesPage() {
json,
prefix,
proxyUrl,
priority: priority !== undefined ? String(priority) : '',
excludedModelsText: excludedModels.join('\n'),
disableCooling:
disableCooling === undefined ? '' : disableCooling ? 'true' : 'false',
error: null,
};
});
@@ -887,11 +1007,17 @@ export function AuthFilesPage() {
}
};
const handlePrefixProxyChange = (field: 'prefix' | 'proxyUrl', value: string) => {
const handlePrefixProxyChange = (
field: 'prefix' | 'proxyUrl' | 'priority' | 'excludedModelsText' | 'disableCooling',
value: string
) => {
setPrefixProxyEditor((prev) => {
if (!prev) return prev;
if (field === 'prefix') return { ...prev, prefix: value };
return { ...prev, proxyUrl: value };
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
if (field === 'priority') return { ...prev, priority: value };
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
return { ...prev, disableCooling: value };
});
};
@@ -1009,14 +1135,28 @@ export function AuthFilesPage() {
}
};
const copyTextWithNotification = async (text: string) => {
const copied = await copyToClipboard(text);
showNotification(
copied
? t('notification.link_copied', { defaultValue: 'Copied to clipboard' })
: t('notification.copy_failed', { defaultValue: 'Copy failed' }),
copied ? 'success' : 'error'
);
};
// 检查模型是否被 OAuth 排除
const isModelExcluded = (modelId: string, providerType: string): boolean => {
const providerKey = normalizeProviderKey(providerType);
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
return excludedModels.some((pattern) => {
if (pattern.includes('*')) {
// 支持通配符匹配
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
// 支持通配符匹配:先转义正则特殊字符,再将 * 视为通配符
const regexSafePattern = pattern
.split('*')
.map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*');
const regex = new RegExp(`^${regexSafePattern}$`, 'i');
return regex.test(modelId);
}
return pattern.toLowerCase() === modelId.toLowerCase();
@@ -1468,11 +1608,14 @@ export function AuthFilesPage() {
return GEMINI_CLI_CONFIG;
};
const getQuotaState = (type: QuotaProviderType, fileName: string) => {
if (type === 'antigravity') return antigravityQuota[fileName];
if (type === 'codex') return codexQuota[fileName];
return geminiCliQuota[fileName];
};
const getQuotaState = useCallback(
(type: QuotaProviderType, fileName: string) => {
if (type === 'antigravity') return antigravityQuota[fileName];
if (type === 'codex') return codexQuota[fileName];
return geminiCliQuota[fileName];
},
[antigravityQuota, codexQuota, geminiCliQuota]
);
const updateQuotaState = useCallback(
(
@@ -1547,6 +1690,7 @@ export function AuthFilesPage() {
| { status?: string; error?: string; errorStatus?: number }
| undefined;
const quotaStatus = quota?.status ?? 'idle';
const canRefreshQuota = !disableControls && !item.disabled;
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
@@ -1558,7 +1702,14 @@ export function AuthFilesPage() {
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
<button
type="button"
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
onClick={() => void refreshQuotaForFile(item, quotaType)}
disabled={!canRefreshQuota}
>
{t(`${config.i18nPrefix}.idle`)}
</button>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${config.i18nPrefix}.load_failed`, {
@@ -1586,8 +1737,6 @@ export function AuthFilesPage() {
quotaFilterType && resolveQuotaType(item) === quotaFilterType ? quotaFilterType : null;
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
const quotaState = quotaType ? getQuotaState(quotaType, item.name) : undefined;
const quotaRefreshing = quotaState?.status === 'loading';
const providerCardClass =
quotaType === 'antigravity'
@@ -1604,7 +1753,7 @@ export function AuthFilesPage() {
className={`${styles.fileCard} ${providerCardClass} ${item.disabled ? styles.fileCardDisabled : ''}`}
>
<div
className={`${styles.fileCardLayout} ${showQuotaLayout ? styles.fileCardLayoutQuota : ''}`}
className={styles.fileCardLayout}
>
<div className={styles.fileCardMain}>
<div className={styles.cardHeader}>
@@ -1722,29 +1871,6 @@ export function AuthFilesPage() {
)}
</div>
</div>
{showQuotaLayout && quotaType && (
<div className={styles.fileCardSidebar}>
<div className={styles.fileCardSidebarHeader}>
<span className={styles.fileCardSidebarTitle}>
{t('auth_files.card_tools_title')}
</span>
<Button
variant="secondary"
size="sm"
className={styles.iconButton}
onClick={() => void refreshQuotaForFile(item, quotaType)}
disabled={disableControls || item.disabled}
loading={quotaRefreshing}
title={t('auth_files.quota_refresh_single')}
aria-label={t('auth_files.quota_refresh_single')}
>
{!quotaRefreshing && <IconRefreshCw className={styles.actionIcon} size={16} />}
</Button>
</div>
<div className={styles.fileCardSidebarHint}>{t('auth_files.quota_refresh_hint')}</div>
</div>
)}
</div>
</div>
);
@@ -1886,7 +2012,7 @@ export function AuthFilesPage() {
)}
</Card>
{/* OAuth 排除列表卡片 */}
{/* OAuth 模型禁用卡片 */}
<Card
title={t('oauth_excluded.title')}
extra={
@@ -2053,9 +2179,7 @@ export function AuthFilesPage() {
onClick={() => {
if (selectedFile) {
const text = JSON.stringify(selectedFile, null, 2);
navigator.clipboard.writeText(text).then(() => {
showNotification(t('notification.link_copied'), 'success');
});
void copyTextWithNotification(text);
}
}}
>
@@ -2111,16 +2235,12 @@ export function AuthFilesPage() {
key={model.id}
className={`${styles.modelItem} ${isExcluded ? styles.modelItemExcluded : ''}`}
onClick={() => {
navigator.clipboard.writeText(model.id);
showNotification(
t('notification.link_copied', { defaultValue: '已复制到剪贴板' }),
'success'
);
void copyTextWithNotification(model.id);
}}
title={
isExcluded
? t('auth_files.models_excluded_hint', {
defaultValue: '此模型已被 OAuth 排除',
defaultValue: '此 OAuth 模型已被禁用',
})
: t('common.copy', { defaultValue: '点击复制' })
}
@@ -2132,7 +2252,7 @@ export function AuthFilesPage() {
{model.type && <span className={styles.modelType}>{model.type}</span>}
{isExcluded && (
<span className={styles.modelExcludedBadge}>
{t('auth_files.models_excluded_badge', { defaultValue: '已排除' })}
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
</span>
)}
</div>
@@ -2142,7 +2262,7 @@ export function AuthFilesPage() {
)}
</Modal>
{/* prefix/proxy_url 编辑弹窗 */}
{/* 认证文件字段编辑弹窗 */}
<Modal
open={Boolean(prefixProxyEditor)}
onClose={() => setPrefixProxyEditor(null)}
@@ -2150,7 +2270,7 @@ export function AuthFilesPage() {
width={720}
title={
prefixProxyEditor?.fileName
? `${t('auth_files.prefix_proxy_button')} - ${prefixProxyEditor.fileName}`
? t('auth_files.auth_field_editor_title', { name: prefixProxyEditor.fileName })
: t('auth_files.prefix_proxy_button')
}
footer={
@@ -2218,6 +2338,42 @@ export function AuthFilesPage() {
}
onChange={(e) => handlePrefixProxyChange('proxyUrl', e.target.value)}
/>
<Input
label={t('auth_files.priority_label')}
value={prefixProxyEditor.priority}
placeholder={t('auth_files.priority_placeholder')}
hint={t('auth_files.priority_hint')}
disabled={
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
}
onChange={(e) => handlePrefixProxyChange('priority', e.target.value)}
/>
<div className="form-group">
<label>{t('auth_files.excluded_models_label')}</label>
<textarea
className="input"
value={prefixProxyEditor.excludedModelsText}
placeholder={t('auth_files.excluded_models_placeholder')}
rows={4}
disabled={
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
}
onChange={(e) =>
handlePrefixProxyChange('excludedModelsText', e.target.value)
}
/>
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
</div>
<Input
label={t('auth_files.disable_cooling_label')}
value={prefixProxyEditor.disableCooling}
placeholder={t('auth_files.disable_cooling_placeholder')}
hint={t('auth_files.disable_cooling_hint')}
disabled={
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
}
onChange={(e) => handlePrefixProxyChange('disableCooling', e.target.value)}
/>
</div>
</>
)}

View File

@@ -5,6 +5,7 @@ import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
import { parse as parseYaml } from 'yaml';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
@@ -17,6 +18,16 @@ import styles from './ConfigPage.module.scss';
type ConfigEditorTab = 'visual' | 'source';
function readCommercialModeFromYaml(yamlContent: string): boolean {
try {
const parsed = parseYaml(yamlContent);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
return Boolean((parsed as Record<string, unknown>)['commercial-mode']);
} catch {
return false;
}
}
export function ConfigPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
@@ -78,13 +89,19 @@ export function ConfigPage() {
const handleSave = async () => {
setSaving(true);
try {
const previousCommercialMode = readCommercialModeFromYaml(content);
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
const nextCommercialMode = readCommercialModeFromYaml(nextContent);
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
await configFileApi.saveConfigYaml(nextContent);
const latestContent = await configFileApi.fetchConfigYaml();
setDirty(false);
setContent(latestContent);
loadVisualValuesFromYaml(latestContent);
showNotification(t('config_management.save_success'), 'success');
if (commercialModeChanged) {
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');

View File

@@ -62,14 +62,23 @@ export function DashboardPage() {
apiKeysCache.current = [];
}, [apiBase, config?.apiKeys]);
const normalizeApiKeyList = (input: any): string[] => {
const normalizeApiKeyList = (input: unknown): string[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const keys: string[] = [];
input.forEach((item) => {
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
const trimmed = String(value || '').trim();
const record =
item !== null && typeof item === 'object' && !Array.isArray(item)
? (item as Record<string, unknown>)
: null;
const value =
typeof item === 'string'
? item
: record
? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key)
: '';
const trimmed = String(value ?? '').trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
keys.push(trimmed);

View File

@@ -167,9 +167,24 @@
font-size: 14px;
}
// 语言切换按钮
.languageBtn {
// 语言下拉选择
.languageSelect {
white-space: nowrap;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px 12px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
height: 40px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
// 连接信息框

View File

@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/Input';
import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import type { ApiError } from '@/types';
import styles from './LoginPage.module.scss';
@@ -13,11 +15,20 @@ import styles from './LoginPage.module.scss';
/**
* 将 API 错误转换为本地化的用户友好消息
*/
function getLocalizedErrorMessage(error: any, t: (key: string) => string): string {
const apiError = error as ApiError;
const status = apiError?.status;
const code = apiError?.code;
const message = apiError?.message || '';
type RedirectState = { from?: { pathname?: string } };
function getLocalizedErrorMessage(error: unknown, t: (key: string) => string): string {
const apiError = error as Partial<ApiError>;
const status = typeof apiError.status === 'number' ? apiError.status : undefined;
const code = typeof apiError.code === 'string' ? apiError.code : undefined;
const message =
error instanceof Error
? error.message
: typeof apiError.message === 'string'
? apiError.message
: typeof error === 'string'
? error
: '';
// 根据 HTTP 状态码判断
if (status === 401) {
@@ -59,7 +70,7 @@ export function LoginPage() {
const location = useLocation();
const { showNotification } = useNotificationStore();
const language = useLanguageStore((state) => state.language);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const login = useAuthStore((state) => state.login);
const restoreSession = useAuthStore((state) => state.restoreSession);
@@ -78,7 +89,16 @@ export function LoginPage() {
const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
const handleLanguageChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const selectedLanguage = event.target.value;
if (!isSupportedLanguage(selectedLanguage)) {
return;
}
setLanguage(selectedLanguage);
},
[setLanguage]
);
useEffect(() => {
const init = async () => {
@@ -88,7 +108,7 @@ export function LoginPage() {
setAutoLoginSuccess(true);
// 延迟跳转,让用户看到成功动画
setTimeout(() => {
const redirect = (location.state as any)?.from?.pathname || '/';
const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
navigate(redirect, { replace: true });
}, 1500);
} else {
@@ -124,7 +144,7 @@ export function LoginPage() {
});
showNotification(t('common.connected_status'), 'success');
navigate('/', { replace: true });
} catch (err: any) {
} catch (err: unknown) {
const message = getLocalizedErrorMessage(err, t);
setError(message);
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
@@ -144,7 +164,7 @@ export function LoginPage() {
);
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
const redirect = (location.state as any)?.from?.pathname || '/';
const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
@@ -185,17 +205,19 @@ export function LoginPage() {
<div className={styles.loginHeader}>
<div className={styles.titleRow}>
<div className={styles.title}>{t('title.login')}</div>
<Button
type="button"
variant="ghost"
size="sm"
className={styles.languageBtn}
onClick={toggleLanguage}
<select
className={styles.languageSelect}
value={language}
onChange={handleLanguageChange}
title={t('language.switch')}
aria-label={t('language.switch')}
>
{nextLanguageLabel}
</Button>
{LANGUAGE_ORDER.map((lang) => (
<option key={lang} value={lang}>
{t(LANGUAGE_LABEL_KEYS[lang])}
</option>
))}
</select>
</div>
<div className={styles.subtitle}>{t('login.subtitle')}</div>
</div>

View File

@@ -400,6 +400,8 @@ export function LogsPage() {
startY: number;
fired: boolean;
} | null>(null);
const logRequestInFlightRef = useRef(false);
const pendingFullReloadRef = useRef(false);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0);
@@ -424,6 +426,15 @@ export function LogsPage() {
return;
}
if (logRequestInFlightRef.current) {
if (!incremental) {
pendingFullReloadRef.current = true;
}
return;
}
logRequestInFlightRef.current = true;
if (!incremental) {
setLoading(true);
}
@@ -474,6 +485,11 @@ export function LogsPage() {
if (!incremental) {
setLoading(false);
}
logRequestInFlightRef.current = false;
if (pendingFullReloadRef.current) {
pendingFullReloadRef.current = false;
void loadLogs(false);
}
}
};

View File

@@ -12,6 +12,8 @@ import iconCodexDark from '@/assets/icons/codex_drak.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg';
@@ -54,11 +56,27 @@ interface VertexImportState {
result?: VertexImportResult;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (isRecord(error) && typeof error.message === 'string') return error.message;
return typeof error === 'string' ? error : '';
}
function getErrorStatus(error: unknown): number | undefined {
if (!isRecord(error)) return undefined;
return typeof error.status === 'number' ? error.status : undefined;
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } },
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
];
@@ -124,8 +142,8 @@ export function OAuthPage() {
window.clearInterval(timer);
delete timers.current[provider];
}
} catch (err: any) {
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
} catch (err: unknown) {
updateProviderState(provider, { status: 'error', error: getErrorMessage(err), polling: false });
window.clearInterval(timer);
delete timers.current[provider];
}
@@ -156,9 +174,13 @@ export function OAuthPage() {
if (res.state) {
startPolling(provider, res.state);
}
} catch (err: any) {
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
} catch (err: unknown) {
const message = getErrorMessage(err);
updateProviderState(provider, { status: 'error', error: message, polling: false });
showNotification(
`${t(getAuthKey(provider, 'oauth_start_error'))}${message ? ` ${message}` : ''}`,
'error'
);
}
};
@@ -187,13 +209,15 @@ export function OAuthPage() {
await oauthApi.submitCallback(provider, redirectUrl);
updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' });
showNotification(t('auth_login.oauth_callback_success'), 'success');
} catch (err: any) {
} catch (err: unknown) {
const status = getErrorStatus(err);
const message = getErrorMessage(err);
const errorMessage =
err?.status === 404
status === 404
? t('auth_login.oauth_callback_upgrade_hint', {
defaultValue: 'Please update CLI Proxy API or check the connection.'
})
: err?.message;
: message || undefined;
updateProviderState(provider, {
callbackSubmitting: false,
callbackStatus: 'error',
@@ -233,15 +257,19 @@ export function OAuthPage() {
}));
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
}
} catch (err: any) {
if (err?.status === 409) {
} catch (err: unknown) {
if (getErrorStatus(err) === 409) {
const message = t('auth_login.iflow_cookie_config_duplicate');
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
showNotification(message, 'warning');
return;
}
setIflowCookie((prev) => ({ ...prev, loading: false, error: err?.message, errorType: 'error' }));
showNotification(`${t('auth_login.iflow_cookie_start_error')} ${err?.message || ''}`, 'error');
const message = getErrorMessage(err);
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' }));
showNotification(
`${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`,
'error'
);
}
};
@@ -289,8 +317,8 @@ export function OAuthPage() {
};
setVertexState((prev) => ({ ...prev, loading: false, result }));
showNotification(t('vertex_import.success'), 'success');
} catch (err: any) {
const message = err?.message || '';
} catch (err: unknown) {
const message = getErrorMessage(err);
setVertexState((prev) => ({
...prev,
loading: false,

View File

@@ -103,6 +103,7 @@
}
.antigravityGrid,
.claudeGrid,
.codexGrid,
.geminiCliGrid {
display: grid;
@@ -115,6 +116,7 @@
}
.antigravityControls,
.claudeControls,
.codexControls,
.geminiCliControls {
display: flex;
@@ -125,6 +127,7 @@
}
.antigravityControl,
.claudeControl,
.codexControl,
.geminiCliControl {
display: flex;
@@ -145,6 +148,12 @@
align-items: center;
}
.claudeCard {
background-image: linear-gradient(180deg,
rgba(252, 228, 236, 0.18),
rgba(252, 228, 236, 0));
}
.antigravityCard {
background-image: linear-gradient(180deg,
rgba(224, 247, 250, 0.12),

View File

@@ -10,6 +10,7 @@ import { authFilesApi, configFileApi } from '@/services/api';
import {
QuotaSection,
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG
} from '@/components/quota';
@@ -69,6 +70,12 @@ export function QuotaPage() {
{error && <div className={styles.errorBox}>{error}</div>}
<QuotaSection
config={CLAUDE_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={ANTIGRAVITY_CONFIG}
files={files}

View File

@@ -15,6 +15,108 @@
gap: $spacing-xl;
}
.aboutCard {
overflow: hidden;
}
.aboutHeader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
gap: $spacing-md;
padding: $spacing-lg 0 $spacing-xl;
}
.aboutLogo {
width: 108px;
height: 108px;
border-radius: 26px;
object-fit: cover;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
}
.aboutTitle {
width: min(100%, 920px);
font-size: clamp(28px, 4.2vw, 44px);
font-weight: 800;
line-height: 1.12;
color: var(--text-primary);
letter-spacing: -0.02em;
text-align: center;
text-wrap: balance;
white-space: normal;
overflow-wrap: anywhere;
}
.aboutInfoGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $spacing-md;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
.infoTile {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 120px;
padding: $spacing-md $spacing-lg;
border-radius: $radius-lg;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
text-align: left;
}
.tapTile {
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
color: inherit;
padding: $spacing-md $spacing-lg;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-1px);
border-color: var(--primary-color);
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.15);
}
&:active {
transform: translateY(0);
}
}
.tileLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.tileValue {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.25;
word-break: break-word;
}
.tileSub {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.aboutActions {
display: flex;
justify-content: flex-end;
margin-top: $spacing-lg;
}
.section {
display: flex;
flex-direction: column;
@@ -231,3 +333,29 @@
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.aboutLogo {
width: 92px;
height: 92px;
border-radius: 22px;
}
.aboutTitle {
width: min(100%, 24ch);
font-size: clamp(22px, 6.6vw, 34px);
font-weight: 700;
line-height: 1.18;
letter-spacing: -0.012em;
}
}
@media (max-width: 520px) {
.aboutTitle {
width: min(100%, 19ch);
font-size: clamp(20px, 7.2vw, 28px);
font-weight: 600;
line-height: 1.22;
letter-spacing: -0.006em;
}
}

View File

@@ -2,11 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
import { configApi } from '@/services/api';
import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import iconGemini from '@/assets/icons/gemini.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
@@ -39,6 +43,8 @@ export function SystemPage() {
const auth = useAuthStore();
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const models = useModelsStore((state) => state.models);
const modelsLoading = useModelsStore((state) => state.loading);
@@ -46,14 +52,29 @@ export function SystemPage() {
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const apiKeysCache = useRef<string[]>([]);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const otherLabel = useMemo(
() => (i18n.language?.toLowerCase().startsWith('zh') ? '其他' : 'Other'),
[i18n.language]
);
const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]);
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = auth.connectionStatus === 'connected' && Boolean(config);
const appVersion = __APP_VERSION__ || t('system_info.version_unknown');
const apiVersion = auth.serverVersion || t('system_info.version_unknown');
const buildTime = auth.serverBuildDate
? new Date(auth.serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown');
const getIconForCategory = (categoryId: string): string | null => {
const iconEntry = MODEL_CATEGORY_ICONS[categoryId];
@@ -62,14 +83,23 @@ export function SystemPage() {
return resolvedTheme === 'dark' ? iconEntry.dark : iconEntry.light;
};
const normalizeApiKeyList = (input: any): string[] => {
const normalizeApiKeyList = (input: unknown): string[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const keys: string[] = [];
input.forEach((item) => {
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
const trimmed = String(value || '').trim();
const record =
item !== null && typeof item === 'object' && !Array.isArray(item)
? (item as Record<string, unknown>)
: null;
const value =
typeof item === 'string'
? item
: record
? (record['api-key'] ?? record['apiKey'] ?? record.key ?? record.Key)
: '';
const trimmed = String(value ?? '').trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
keys.push(trimmed);
@@ -130,9 +160,12 @@ export function SystemPage() {
type: hasModels ? 'success' : 'warning',
message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty')
});
} catch (err: any) {
const message = `${t('system_info.models_error')}: ${err?.message || ''}`;
setModelStatus({ type: 'error', message });
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : '';
const suffix = message ? `: ${message}` : '';
const text = `${t('system_info.models_error')}${suffix}`;
setModelStatus({ type: 'error', message: text });
}
};
@@ -152,12 +185,85 @@ export function SystemPage() {
});
};
const openRequestLogModal = useCallback(() => {
setRequestLogTouched(false);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleInfoVersionTap = useCallback(() => {
versionTapCount.current += 1;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
versionTapTimer.current = null;
openRequestLogModal();
return;
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
versionTapTimer.current = null;
}, 1500);
}, [openRequestLogModal]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
}, []);
const handleRequestLogSave = async () => {
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
updateConfigValue('request-log', previous);
showNotification(
`${t('notification.update_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setRequestLogSaving(false);
}
};
useEffect(() => {
fetchConfig().catch(() => {
// ignore
});
}, [fetchConfig]);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
};
}, []);
useEffect(() => {
fetchModels();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -167,33 +273,43 @@ export function SystemPage() {
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('system_info.title')}</h1>
<div className={styles.content}>
<Card
title={t('system_info.connection_status_title')}
extra={
<Card className={styles.aboutCard}>
<div className={styles.aboutHeader}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
</div>
<div className={styles.aboutInfoGrid}>
<button
type="button"
className={`${styles.infoTile} ${styles.tapTile}`}
onClick={handleInfoVersionTap}
>
<div className={styles.tileLabel}>{t('footer.version')}</div>
<div className={styles.tileValue}>{appVersion}</div>
</button>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
<div className={styles.tileValue}>{apiVersion}</div>
</div>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
<div className={styles.tileValue}>{buildTime}</div>
</div>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('connection.status')}</div>
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status`)}</div>
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
</div>
</div>
<div className={styles.aboutActions}>
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
{t('common.refresh')}
</Button>
}
>
<div className="grid cols-2">
<div className="stat-card">
<div className="stat-label">{t('connection.server_address')}</div>
<div className="stat-value">{auth.apiBase || '-'}</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('footer.api_version')}</div>
<div className="stat-value">{auth.serverVersion || t('system_info.version_unknown')}</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('footer.build_date')}</div>
<div className="stat-value">
{auth.serverBuildDate ? new Date(auth.serverBuildDate).toLocaleString() : t('system_info.version_unknown')}
</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('connection.status')}</div>
<div className="stat-value">{t(`common.${auth.connectionStatus}_status` as any)}</div>
</div>
</div>
</Card>
@@ -312,6 +428,40 @@ export function SystemPage() {
</div>
</Card>
</div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div>
);
}

View File

@@ -25,6 +25,121 @@
flex-wrap: wrap;
}
.lastRefreshed {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
.timeRangeGroup {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.timeRangeLabel {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.timeRangeSelectWrap {
position: relative;
display: inline-flex;
align-items: center;
}
.timeRangeSelect {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 164px;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
box-shadow: var(--shadow);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
text-align: left;
&:hover {
border-color: var(--border-hover);
}
&:focus {
outline: none;
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
}
&[aria-expanded='true'] {
border-color: var(--primary-color);
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
.timeRangeSelectedText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeRangeSelectIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.timeRangeDropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 6px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 4px;
}
.timeRangeOption {
padding: 8px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
font-weight: 500;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
background: var(--bg-secondary);
}
}
.timeRangeOptionActive {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.10);
font-weight: 600;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
@@ -314,6 +429,42 @@
}
// API List (80%比例)
.apiSortBar {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.apiSortBtn {
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
}
.apiSortBtnActive {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.10);
color: var(--text-primary);
font-weight: 600;
}
.apiList {
display: flex;
flex-direction: column;
@@ -328,16 +479,27 @@
}
.apiHeader {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--bg-tertiary);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
}
.apiInfo {
@@ -418,6 +580,26 @@
}
}
// Fixed-height cards with internal scrolling (API details / model stats)
.detailsFixedCard {
display: flex;
flex-direction: column;
height: 520px;
overflow: hidden;
@include mobile {
height: 420px;
}
}
.detailsScroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
// Table (80%比例)
.tableWrapper {
overflow-x: auto;
@@ -441,6 +623,15 @@
white-space: nowrap;
}
th.sortableHeader {
user-select: none;
transition: color 0.15s ease;
&:hover {
color: var(--text-primary);
}
}
td {
color: var(--text-primary);
}
@@ -450,12 +641,44 @@
}
}
.sortHeaderButton {
display: inline-flex;
align-items: center;
width: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: $radius-sm;
}
}
.modelCell {
font-weight: 500;
max-width: 240px;
word-break: break-all;
}
.credentialType {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: $radius-full;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
vertical-align: middle;
}
.requestCountCell {
display: inline-flex;
align-items: baseline;

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
@@ -13,9 +13,10 @@ import {
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconChevronDown } from '@/components/ui/icons';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore } from '@/stores';
import { useThemeStore, useConfigStore } from '@/stores';
import {
StatCards,
UsageChart,
@@ -23,11 +24,20 @@ import {
ApiDetailsCard,
ModelStatsCard,
PriceSettingsCard,
CredentialStatsCard,
TokenBreakdownChart,
CostTrendChart,
useUsageData,
useSparklines,
useChartData
} from '@/components/usage';
import { getModelNamesFromUsage, getApiStats, getModelStats } from '@/utils/usage';
import {
getModelNamesFromUsage,
getApiStats,
getModelStats,
filterUsageByTimeRange,
type UsageTimeRange
} from '@/utils/usage';
import styles from './UsagePage.module.scss';
// Register Chart.js components
@@ -42,17 +52,93 @@ ChartJS.register(
Filler
);
const CHART_LINES_STORAGE_KEY = 'cli-proxy-usage-chart-lines-v1';
const TIME_RANGE_STORAGE_KEY = 'cli-proxy-usage-time-range-v1';
const DEFAULT_CHART_LINES = ['all'];
const DEFAULT_TIME_RANGE: UsageTimeRange = '24h';
const MAX_CHART_LINES = 9;
const TIME_RANGE_OPTIONS: ReadonlyArray<{ value: UsageTimeRange; labelKey: string }> = [
{ value: 'all', labelKey: 'usage_stats.range_all' },
{ value: '7h', labelKey: 'usage_stats.range_7h' },
{ value: '24h', labelKey: 'usage_stats.range_24h' },
{ value: '7d', labelKey: 'usage_stats.range_7d' },
];
const HOUR_WINDOW_BY_TIME_RANGE: Record<Exclude<UsageTimeRange, 'all'>, number> = {
'7h': 7,
'24h': 24,
'7d': 7 * 24
};
const isUsageTimeRange = (value: unknown): value is UsageTimeRange =>
value === '7h' || value === '24h' || value === '7d' || value === 'all';
const normalizeChartLines = (value: unknown, maxLines = MAX_CHART_LINES): string[] => {
if (!Array.isArray(value)) {
return DEFAULT_CHART_LINES;
}
const filtered = value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
.slice(0, maxLines);
return filtered.length ? filtered : DEFAULT_CHART_LINES;
};
const loadChartLines = (): string[] => {
try {
if (typeof localStorage === 'undefined') {
return DEFAULT_CHART_LINES;
}
const raw = localStorage.getItem(CHART_LINES_STORAGE_KEY);
if (!raw) {
return DEFAULT_CHART_LINES;
}
return normalizeChartLines(JSON.parse(raw));
} catch {
return DEFAULT_CHART_LINES;
}
};
const loadTimeRange = (): UsageTimeRange => {
try {
if (typeof localStorage === 'undefined') {
return DEFAULT_TIME_RANGE;
}
const raw = localStorage.getItem(TIME_RANGE_STORAGE_KEY);
return isUsageTimeRange(raw) ? raw : DEFAULT_TIME_RANGE;
} catch {
return DEFAULT_TIME_RANGE;
}
};
export function UsagePage() {
const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)');
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
const config = useConfigStore((state) => state.config);
// Time range dropdown
const [timeRangeOpen, setTimeRangeOpen] = useState(false);
const timeRangeRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!timeRangeOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (!timeRangeRef.current?.contains(event.target as Node)) setTimeRangeOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [timeRangeOpen]);
// Data hook
const {
usage,
loading,
error,
lastRefreshedAt,
modelPrices,
setModelPrices,
loadUsage,
@@ -67,8 +153,41 @@ export function UsagePage() {
useHeaderRefresh(loadUsage);
// Chart lines state
const [chartLines, setChartLines] = useState<string[]>(['all']);
const MAX_CHART_LINES = 9;
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
const filteredUsage = useMemo(
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
[usage, timeRange]
);
const hourWindowHours =
timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
const handleChartLinesChange = useCallback((lines: string[]) => {
setChartLines(normalizeChartLines(lines));
}, []);
useEffect(() => {
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(CHART_LINES_STORAGE_KEY, JSON.stringify(chartLines));
} catch {
// Ignore storage errors.
}
}, [chartLines]);
useEffect(() => {
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(TIME_RANGE_STORAGE_KEY, timeRange);
} catch {
// Ignore storage errors.
}
}, [timeRange]);
// Sparklines hook
const {
@@ -77,7 +196,7 @@ export function UsagePage() {
rpmSparkline,
tpmSparkline,
costSparkline
} = useSparklines({ usage, loading });
} = useSparklines({ usage: filteredUsage, loading });
// Chart data hook
const {
@@ -89,12 +208,18 @@ export function UsagePage() {
tokensChartData,
requestsChartOptions,
tokensChartOptions
} = useChartData({ usage, chartLines, isDark, isMobile });
} = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours });
// Derived data
const modelNames = useMemo(() => getModelNamesFromUsage(usage), [usage]);
const apiStats = useMemo(() => getApiStats(usage, modelPrices), [usage, modelPrices]);
const modelStats = useMemo(() => getModelStats(usage, modelPrices), [usage, modelPrices]);
const apiStats = useMemo(
() => getApiStats(filteredUsage, modelPrices),
[filteredUsage, modelPrices]
);
const modelStats = useMemo(
() => getModelStats(filteredUsage, modelPrices),
[filteredUsage, modelPrices]
);
const hasPrices = Object.keys(modelPrices).length > 0;
return (
@@ -111,6 +236,47 @@ export function UsagePage() {
<div className={styles.header}>
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
<div className={styles.headerActions}>
<div className={styles.timeRangeGroup}>
<span className={styles.timeRangeLabel}>{t('usage_stats.range_filter')}</span>
<div className={styles.timeRangeSelectWrap} ref={timeRangeRef}>
<button
type="button"
className={styles.timeRangeSelect}
onClick={() => setTimeRangeOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={timeRangeOpen}
>
<span className={styles.timeRangeSelectedText}>
{t(TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.labelKey ?? 'usage_stats.range_24h')}
</span>
<span className={styles.timeRangeSelectIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{timeRangeOpen && (
<div className={styles.timeRangeDropdown} role="listbox" aria-label={t('usage_stats.range_filter')}>
{TIME_RANGE_OPTIONS.map((opt) => {
const active = opt.value === timeRange;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
className={`${styles.timeRangeOption} ${active ? styles.timeRangeOptionActive : ''}`}
onClick={() => {
setTimeRange(opt.value);
setTimeRangeOpen(false);
}}
>
{t(opt.labelKey)}
</button>
);
})}
</div>
)}
</div>
</div>
<Button
variant="secondary"
size="sm"
@@ -144,6 +310,11 @@ export function UsagePage() {
style={{ display: 'none' }}
onChange={handleImportChange}
/>
{lastRefreshedAt && (
<span className={styles.lastRefreshed}>
{t('usage_stats.last_updated')}: {lastRefreshedAt.toLocaleTimeString()}
</span>
)}
</div>
</div>
@@ -151,7 +322,7 @@ export function UsagePage() {
{/* Stats Overview Cards */}
<StatCards
usage={usage}
usage={filteredUsage}
loading={loading}
modelPrices={modelPrices}
sparklines={{
@@ -168,7 +339,7 @@ export function UsagePage() {
chartLines={chartLines}
modelNames={modelNames}
maxLines={MAX_CHART_LINES}
onChange={setChartLines}
onChange={handleChartLinesChange}
/>
{/* Charts Grid */}
@@ -195,12 +366,42 @@ export function UsagePage() {
/>
</div>
{/* Token Breakdown Chart */}
<TokenBreakdownChart
usage={filteredUsage}
loading={loading}
isDark={isDark}
isMobile={isMobile}
hourWindowHours={hourWindowHours}
/>
{/* Cost Trend Chart */}
<CostTrendChart
usage={filteredUsage}
loading={loading}
isDark={isDark}
isMobile={isMobile}
modelPrices={modelPrices}
hourWindowHours={hourWindowHours}
/>
{/* Details Grid */}
<div className={styles.detailsGrid}>
<ApiDetailsCard apiStats={apiStats} loading={loading} hasPrices={hasPrices} />
<ModelStatsCard modelStats={modelStats} loading={loading} hasPrices={hasPrices} />
</div>
{/* Credential Stats */}
<CredentialStatsCard
usage={filteredUsage}
loading={loading}
geminiKeys={config?.geminiApiKeys || []}
claudeConfigs={config?.claudeApiKeys || []}
codexConfigs={config?.codexApiKeys || []}
vertexConfigs={config?.vertexApiKeys || []}
openaiProviders={config?.openaiCompatibility || []}
/>
{/* Price Settings */}
<PriceSettingsCard
modelNames={modelNames}

View File

@@ -19,7 +19,7 @@ export const ampcodeApi = {
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
const data = await apiClient.get('/ampcode/model-mappings');
const data = await apiClient.get<Record<string, unknown>>('/ampcode/model-mappings');
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
return normalizeAmpcodeModelMappings(list);
},

View File

@@ -13,14 +13,14 @@ export interface ApiCallRequest {
data?: string;
}
export interface ApiCallResult<T = any> {
export interface ApiCallResult<T = unknown> {
statusCode: number;
header: Record<string, string[]>;
bodyText: string;
body: T | null;
}
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
const normalizeBody = (input: unknown): { bodyText: string; body: unknown | null } => {
if (input === undefined || input === null) {
return { bodyText: '', body: null };
}
@@ -46,13 +46,24 @@ const normalizeBody = (input: unknown): { bodyText: string; body: any | null } =
};
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
const status = result.statusCode;
const body = result.body;
const bodyText = result.bodyText;
let message = '';
if (body && typeof body === 'object') {
message = body?.error?.message || body?.error || body?.message || '';
if (isRecord(body)) {
const errorValue = body.error;
if (isRecord(errorValue) && typeof errorValue.message === 'string') {
message = errorValue.message;
} else if (typeof errorValue === 'string') {
message = errorValue;
}
if (!message && typeof body.message === 'string') {
message = body.message;
}
} else if (typeof body === 'string') {
message = body;
}
@@ -71,7 +82,7 @@ export const apiCallApi = {
payload: ApiCallRequest,
config?: AxiosRequestConfig
): Promise<ApiCallResult> => {
const response = await apiClient.post('/api-call', payload, config);
const response = await apiClient.post<Record<string, unknown>>('/api-call', payload, config);
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
const { bodyText, body } = normalizeBody(response?.body);

View File

@@ -6,9 +6,9 @@ import { apiClient } from './client';
export const apiKeysApi = {
async list(): Promise<string[]> {
const data = await apiClient.get('/api-keys');
const keys = (data && (data['api-keys'] ?? data.apiKeys)) as unknown;
return Array.isArray(keys) ? (keys as string[]) : [];
const data = await apiClient.get<Record<string, unknown>>('/api-keys');
const keys = data['api-keys'] ?? data.apiKeys;
return Array.isArray(keys) ? keys.map((key) => String(key)) : [];
},
replace: (keys: string[]) => apiClient.put('/api-keys', keys),

View File

@@ -171,15 +171,25 @@ export const authFilesApi = {
// 获取认证凭证支持的模型
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);
return (data && Array.isArray(data['models'])) ? data['models'] : [];
const data = await apiClient.get<Record<string, unknown>>(
`/auth-files/models?name=${encodeURIComponent(name)}`
);
const models = data.models ?? data['models'];
return Array.isArray(models)
? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[])
: [];
},
// 获取指定 channel 的模型定义
async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
if (!normalizedChannel) return [];
const data = await apiClient.get(`/model-definitions/${encodeURIComponent(normalizedChannel)}`);
return (data && Array.isArray(data['models'])) ? data['models'] : [];
const data = await apiClient.get<Record<string, unknown>>(
`/model-definitions/${encodeURIComponent(normalizedChannel)}`
);
const models = data.models ?? data['models'];
return Array.isArray(models)
? (models as { id: string; display_name?: string; type?: string; owned_by?: string }[])
: [];
}
};

View File

@@ -62,7 +62,10 @@ class ApiClient {
return `${normalized}${MANAGEMENT_API_PREFIX}`;
}
private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
private readHeader(
headers: Record<string, unknown> | undefined,
keys: string[]
): string | null {
if (!headers) return null;
const normalizeValue = (value: unknown): string | null => {
@@ -75,7 +78,7 @@ class ApiClient {
return text ? text : null;
};
const headerGetter = (headers as { get?: (name: string) => any }).get;
const headerGetter = (headers as { get?: (name: string) => unknown }).get;
if (typeof headerGetter === 'function') {
for (const key of keys) {
const match = normalizeValue(headerGetter.call(headers, key));
@@ -84,8 +87,8 @@ class ApiClient {
}
const entries =
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
typeof (headers as { entries?: () => Iterable<[string, unknown]> }).entries === 'function'
? Array.from((headers as { entries: () => Iterable<[string, unknown]> }).entries())
: Object.entries(headers);
const normalized = Object.fromEntries(
@@ -147,10 +150,22 @@ class ApiClient {
/**
* 错误处理
*/
private handleError(error: any): ApiError {
private handleError(error: unknown): ApiError {
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
if (axios.isAxiosError(error)) {
const responseData = error.response?.data as any;
const message = responseData?.error || responseData?.message || error.message || 'Request failed';
const responseData: unknown = error.response?.data;
const responseRecord = isRecord(responseData) ? responseData : null;
const errorValue = responseRecord?.error;
const message =
typeof errorValue === 'string'
? errorValue
: isRecord(errorValue) && typeof errorValue.message === 'string'
? errorValue.message
: typeof responseRecord?.message === 'string'
? responseRecord.message
: error.message || 'Request failed';
const apiError = new Error(message) as ApiError;
apiError.name = 'ApiError';
apiError.status = error.response?.status;
@@ -166,7 +181,9 @@ class ApiClient {
return apiError;
}
const fallback = new Error(error?.message || 'Unknown error occurred') as ApiError;
const fallbackMessage =
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error occurred';
const fallback = new Error(fallbackMessage) as ApiError;
fallback.name = 'ApiError';
return fallback;
}
@@ -174,7 +191,7 @@ class ApiClient {
/**
* GET 请求
*/
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
async get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.get<T>(url, config);
return response.data;
}
@@ -182,7 +199,7 @@ class ApiClient {
/**
* POST 请求
*/
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.post<T>(url, data, config);
return response.data;
}
@@ -190,7 +207,7 @@ class ApiClient {
/**
* PUT 请求
*/
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.put<T>(url, data, config);
return response.data;
}
@@ -198,7 +215,7 @@ class ApiClient {
/**
* PATCH 请求
*/
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
async patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.patch<T>(url, data, config);
return response.data;
}
@@ -206,7 +223,7 @@ class ApiClient {
/**
* DELETE 请求
*/
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
async delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.delete<T>(url, config);
return response.data;
}
@@ -221,7 +238,11 @@ class ApiClient {
/**
* 发送 FormData
*/
async postForm<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<T> {
async postForm<T = unknown>(
url: string,
formData: FormData,
config?: AxiosRequestConfig
): Promise<T> {
const response = await this.instance.post<T>(url, formData, {
...config,
headers: {

View File

@@ -72,8 +72,10 @@ export const configApi = {
* 获取日志总大小上限MB
*/
async getLogsMaxTotalSizeMb(): Promise<number> {
const data = await apiClient.get('/logs-max-total-size-mb');
return data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
const data = await apiClient.get<Record<string, unknown>>('/logs-max-total-size-mb');
const value = data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
},
/**
@@ -91,8 +93,8 @@ export const configApi = {
* 获取强制模型前缀开关
*/
async getForceModelPrefix(): Promise<boolean> {
const data = await apiClient.get('/force-model-prefix');
return data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false;
const data = await apiClient.get<Record<string, unknown>>('/force-model-prefix');
return Boolean(data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false);
},
/**
@@ -104,8 +106,9 @@ export const configApi = {
* 获取路由策略
*/
async getRoutingStrategy(): Promise<string> {
const data = await apiClient.get('/routing/strategy');
return data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy ?? 'round-robin';
const data = await apiClient.get<Record<string, unknown>>('/routing/strategy');
const strategy = data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy;
return typeof strategy === 'string' ? strategy : 'round-robin';
},
/**

View File

@@ -10,7 +10,7 @@ export const configFileApi = {
responseType: 'text',
headers: { Accept: 'application/yaml, text/yaml, text/plain' }
});
const data = response.data as any;
const data: unknown = response.data;
if (typeof data === 'string') return data;
if (data === undefined || data === null) return '';
return String(data);

View File

@@ -9,6 +9,7 @@ export type OAuthProvider =
| 'anthropic'
| 'antigravity'
| 'gemini-cli'
| 'kimi'
| 'qwen';
export interface OAuthStartResponse {

View File

@@ -18,12 +18,22 @@ import type {
const serializeHeaders = (headers?: Record<string, string>) => (headers && Object.keys(headers).length ? headers : undefined);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const extractArrayPayload = (data: unknown, key: string): unknown[] => {
if (Array.isArray(data)) return data;
if (!isRecord(data)) return [];
const candidate = data[key] ?? data.items ?? data.data ?? data;
return Array.isArray(candidate) ? candidate : [];
};
const serializeModelAliases = (models?: ModelAlias[]) =>
Array.isArray(models)
? models
.map((model) => {
if (!model?.name) return null;
const payload: Record<string, any> = { name: model.name };
const payload: Record<string, unknown> = { name: model.name };
if (model.alias && model.alias !== model.name) {
payload.alias = model.alias;
}
@@ -39,7 +49,7 @@ const serializeModelAliases = (models?: ModelAlias[]) =>
: undefined;
const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
const payload: Record<string, any> = { 'api-key': entry.apiKey };
const payload: Record<string, unknown> = { 'api-key': entry.apiKey };
if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl;
const headers = serializeHeaders(entry.headers);
if (headers) payload.headers = headers;
@@ -47,7 +57,7 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
};
const serializeProviderKey = (config: ProviderKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
@@ -74,7 +84,7 @@ const serializeVertexModelAliases = (models?: ModelAlias[]) =>
: undefined;
const serializeVertexKey = (config: ProviderKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
@@ -86,9 +96,10 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
};
const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers;
if (config.excludedModels && config.excludedModels.length) {
@@ -98,7 +109,7 @@ const serializeGeminiKey = (config: GeminiKeyConfig) => {
};
const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
const payload: Record<string, any> = {
const payload: Record<string, unknown> = {
name: provider.name,
'base-url': provider.baseUrl,
'api-key-entries': Array.isArray(provider.apiKeyEntries)
@@ -118,8 +129,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
export const providersApi = {
async getGeminiKeys(): Promise<GeminiKeyConfig[]> {
const data = await apiClient.get('/gemini-api-key');
const list = (data && (data['gemini-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
const list = extractArrayPayload(data, 'gemini-api-key');
return list.map((item) => normalizeGeminiKeyConfig(item)).filter(Boolean) as GeminiKeyConfig[];
},
@@ -134,8 +144,7 @@ export const providersApi = {
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/codex-api-key');
const list = (data && (data['codex-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
const list = extractArrayPayload(data, 'codex-api-key');
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
@@ -150,8 +159,7 @@ export const providersApi = {
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/claude-api-key');
const list = (data && (data['claude-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
const list = extractArrayPayload(data, 'claude-api-key');
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
@@ -166,8 +174,7 @@ export const providersApi = {
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/vertex-api-key');
const list = (data && (data['vertex-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
const list = extractArrayPayload(data, 'vertex-api-key');
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
@@ -182,8 +189,7 @@ export const providersApi = {
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
const data = await apiClient.get('/openai-compatibility');
const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
const list = extractArrayPayload(data, 'openai-compatibility');
return list.map((item) => normalizeOpenAIProvider(item)).filter(Boolean) as OpenAIProviderConfig[];
},

View File

@@ -10,7 +10,10 @@ import type {
import type { Config } from '@/types/config';
import { buildHeaderObject } from '@/utils/headers';
const normalizeBoolean = (value: any): boolean | undefined => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const normalizeBoolean = (value: unknown): boolean | undefined => {
if (value === undefined || value === null) return undefined;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
@@ -22,11 +25,17 @@ const normalizeBoolean = (value: any): boolean | undefined => {
return Boolean(value);
};
const normalizeModelAliases = (models: any): ModelAlias[] => {
const normalizeModelAliases = (models: unknown): ModelAlias[] => {
if (!Array.isArray(models)) return [];
return models
.map((item) => {
if (!item) return null;
if (item === undefined || item === null) return null;
if (typeof item === 'string') {
const trimmed = item.trim();
return trimmed ? ({ name: trimmed } satisfies ModelAlias) : null;
}
if (!isRecord(item)) return null;
const name = item.name || item.id || item.model;
if (!name) return null;
const alias = item.alias || item.display_name || item.displayName;
@@ -37,7 +46,10 @@ const normalizeModelAliases = (models: any): ModelAlias[] => {
entry.alias = String(alias);
}
if (priority !== undefined) {
entry.priority = Number(priority);
const parsed = Number(priority);
if (Number.isFinite(parsed)) {
entry.priority = parsed;
}
}
if (testModel) {
entry.testModel = String(testModel);
@@ -47,13 +59,17 @@ const normalizeModelAliases = (models: any): ModelAlias[] => {
.filter(Boolean) as ModelAlias[];
};
const normalizeHeaders = (headers: any) => {
const normalizeHeaders = (headers: unknown) => {
if (!headers || typeof headers !== 'object') return undefined;
const normalized = buildHeaderObject(headers as Record<string, string>);
const normalized = buildHeaderObject(
Array.isArray(headers)
? (headers as Array<{ key: string; value: string }>)
: (headers as Record<string, string | undefined | null>)
);
return Object.keys(normalized).length ? normalized : undefined;
};
const normalizeExcludedModels = (input: any): string[] => {
const normalizeExcludedModels = (input: unknown): string[] => {
const rawList = Array.isArray(input) ? input : typeof input === 'string' ? input.split(/[\n,]/) : [];
const seen = new Set<string>();
const normalized: string[] = [];
@@ -70,20 +86,22 @@ const normalizeExcludedModels = (input: any): string[] => {
return normalized;
};
const normalizePrefix = (value: any): string | undefined => {
const normalizePrefix = (value: unknown): string | undefined => {
if (value === undefined || value === null) return undefined;
const trimmed = String(value).trim();
return trimmed ? trimmed : undefined;
};
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
if (!entry) return null;
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
if (entry === undefined || entry === null) return null;
const record = isRecord(entry) ? entry : null;
const apiKey =
record?.['api-key'] ?? record?.apiKey ?? record?.key ?? (typeof entry === 'string' ? entry : '');
const trimmed = String(apiKey || '').trim();
if (!trimmed) return null;
const proxyUrl = entry['proxy-url'] ?? entry.proxyUrl;
const headers = normalizeHeaders(entry.headers);
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
const headers = record ? normalizeHeaders(record.headers) : undefined;
return {
apiKey: trimmed,
@@ -92,33 +110,38 @@ const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
};
};
const normalizeProviderKeyConfig = (item: any): ProviderKeyConfig | null => {
if (!item) return null;
const apiKey = item['api-key'] ?? item.apiKey ?? (typeof item === 'string' ? item : '');
const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null => {
if (item === undefined || item === null) return null;
const record = isRecord(item) ? item : null;
const apiKey = record?.['api-key'] ?? record?.apiKey ?? (typeof item === 'string' ? item : '');
const trimmed = String(apiKey || '').trim();
if (!trimmed) return null;
const config: ProviderKeyConfig = { apiKey: trimmed };
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
if (prefix) config.prefix = prefix;
const baseUrl = item['base-url'] ?? item.baseUrl;
const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
const baseUrl = record ? record['base-url'] ?? record.baseUrl : undefined;
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
if (baseUrl) config.baseUrl = String(baseUrl);
if (proxyUrl) config.proxyUrl = String(proxyUrl);
const headers = normalizeHeaders(item.headers);
const headers = normalizeHeaders(record?.headers);
if (headers) config.headers = headers;
const models = normalizeModelAliases(item.models);
const models = normalizeModelAliases(record?.models);
if (models.length) config.models = models;
const excludedModels = normalizeExcludedModels(
item['excluded-models'] ?? item.excludedModels ?? item['excluded_models'] ?? item.excluded_models
record?.['excluded-models'] ??
record?.excludedModels ??
record?.['excluded_models'] ??
record?.excluded_models
);
if (excludedModels.length) config.excludedModels = excludedModels;
return config;
};
const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
if (!item) return null;
let apiKey = item['api-key'] ?? item.apiKey;
const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
if (item === undefined || item === null) return null;
const record = isRecord(item) ? item : null;
let apiKey = record?.['api-key'] ?? record?.apiKey;
if (!apiKey && typeof item === 'string') {
apiKey = item;
}
@@ -126,19 +149,21 @@ const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
if (!trimmed) return null;
const config: GeminiKeyConfig = { apiKey: trimmed };
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
if (prefix) config.prefix = prefix;
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
const baseUrl = record ? record['base-url'] ?? record.baseUrl ?? record['base_url'] : undefined;
if (baseUrl) config.baseUrl = String(baseUrl);
const headers = normalizeHeaders(item.headers);
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl ?? record['proxy_url'] : undefined;
if (proxyUrl) config.proxyUrl = String(proxyUrl);
const headers = normalizeHeaders(record?.headers);
if (headers) config.headers = headers;
const excludedModels = normalizeExcludedModels(item['excluded-models'] ?? item.excludedModels);
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
if (excludedModels.length) config.excludedModels = excludedModels;
return config;
};
const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null => {
if (!provider || typeof provider !== 'object') return null;
const normalizeOpenAIProvider = (provider: unknown): OpenAIProviderConfig | null => {
if (!isRecord(provider)) return null;
const name = provider.name || provider.id;
const baseUrl = provider['base-url'] ?? provider.baseUrl;
if (!name || !baseUrl) return null;
@@ -146,11 +171,11 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
let apiKeyEntries: ApiKeyEntry[] = [];
if (Array.isArray(provider['api-key-entries'])) {
apiKeyEntries = provider['api-key-entries']
.map((entry: any) => normalizeApiKeyEntry(entry))
.map((entry) => normalizeApiKeyEntry(entry))
.filter(Boolean) as ApiKeyEntry[];
} else if (Array.isArray(provider['api-keys'])) {
apiKeyEntries = provider['api-keys']
.map((key: any) => normalizeApiKeyEntry({ 'api-key': key }))
.map((key) => normalizeApiKeyEntry({ 'api-key': key }))
.filter(Boolean) as ApiKeyEntry[];
}
@@ -174,10 +199,10 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
return result;
};
const normalizeOauthExcluded = (payload: any): Record<string, string[]> | undefined => {
if (!payload || typeof payload !== 'object') return undefined;
const normalizeOauthExcluded = (payload: unknown): Record<string, string[]> | undefined => {
if (!isRecord(payload)) return undefined;
const source = payload['oauth-excluded-models'] ?? payload.items ?? payload;
if (!source || typeof source !== 'object') return undefined;
if (!isRecord(source)) return undefined;
const map: Record<string, string[]> = {};
Object.entries(source).forEach(([provider, models]) => {
const key = String(provider || '').trim();
@@ -188,13 +213,13 @@ const normalizeOauthExcluded = (payload: any): Record<string, string[]> | undefi
return map;
};
const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => {
const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const mappings: AmpcodeModelMapping[] = [];
input.forEach((entry) => {
if (!entry || typeof entry !== 'object') return;
if (!isRecord(entry)) return;
const from = String(entry.from ?? entry['from'] ?? '').trim();
const to = String(entry.to ?? entry['to'] ?? '').trim();
if (!from || !to) return;
@@ -207,9 +232,10 @@ const normalizeAmpcodeModelMappings = (input: any): AmpcodeModelMapping[] => {
return mappings;
};
const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
const source = payload?.ampcode ?? payload;
if (!source || typeof source !== 'object') return undefined;
const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined => {
const sourceRaw = isRecord(payload) ? (payload.ampcode ?? payload) : payload;
if (!isRecord(sourceRaw)) return undefined;
const source = sourceRaw;
const config: AmpcodeConfig = {};
const upstreamUrl = source['upstream-url'] ?? source.upstreamUrl ?? source['upstream_url'];
@@ -237,70 +263,94 @@ const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
/**
* 规范化 /config 返回值
*/
export const normalizeConfigResponse = (raw: any): Config => {
const config: Config = { raw: raw || {} };
if (!raw || typeof raw !== 'object') {
export const normalizeConfigResponse = (raw: unknown): Config => {
const config: Config = { raw: isRecord(raw) ? raw : {} };
if (!isRecord(raw)) {
return config;
}
config.debug = raw.debug;
config.proxyUrl = raw['proxy-url'] ?? raw.proxyUrl;
config.requestRetry = raw['request-retry'] ?? raw.requestRetry;
config.debug = normalizeBoolean(raw.debug);
const proxyUrl = raw['proxy-url'] ?? raw.proxyUrl;
config.proxyUrl =
typeof proxyUrl === 'string' ? proxyUrl : proxyUrl === undefined || proxyUrl === null ? undefined : String(proxyUrl);
const requestRetry = raw['request-retry'] ?? raw.requestRetry;
if (typeof requestRetry === 'number' && Number.isFinite(requestRetry)) {
config.requestRetry = requestRetry;
} else if (typeof requestRetry === 'string' && requestRetry.trim() !== '') {
const parsed = Number(requestRetry);
if (Number.isFinite(parsed)) {
config.requestRetry = parsed;
}
}
const quota = raw['quota-exceeded'] ?? raw.quotaExceeded;
if (quota && typeof quota === 'object') {
if (isRecord(quota)) {
config.quotaExceeded = {
switchProject: quota['switch-project'] ?? quota.switchProject,
switchPreviewModel: quota['switch-preview-model'] ?? quota.switchPreviewModel
switchProject: normalizeBoolean(quota['switch-project'] ?? quota.switchProject),
switchPreviewModel: normalizeBoolean(quota['switch-preview-model'] ?? quota.switchPreviewModel)
};
}
config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled;
config.requestLog = raw['request-log'] ?? raw.requestLog;
config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile;
config.logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
config.wsAuth = raw['ws-auth'] ?? raw.wsAuth;
config.forceModelPrefix = raw['force-model-prefix'] ?? raw.forceModelPrefix;
const routing = raw.routing;
if (routing && typeof routing === 'object') {
config.routingStrategy = routing.strategy ?? routing['strategy'];
} else {
config.routingStrategy = raw['routing-strategy'] ?? raw.routingStrategy;
config.usageStatisticsEnabled = normalizeBoolean(
raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled
);
config.requestLog = normalizeBoolean(raw['request-log'] ?? raw.requestLog);
config.loggingToFile = normalizeBoolean(raw['logging-to-file'] ?? raw.loggingToFile);
const logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
if (typeof logsMaxTotalSizeMb === 'number' && Number.isFinite(logsMaxTotalSizeMb)) {
config.logsMaxTotalSizeMb = logsMaxTotalSizeMb;
} else if (typeof logsMaxTotalSizeMb === 'string' && logsMaxTotalSizeMb.trim() !== '') {
const parsed = Number(logsMaxTotalSizeMb);
if (Number.isFinite(parsed)) {
config.logsMaxTotalSizeMb = parsed;
}
}
config.wsAuth = normalizeBoolean(raw['ws-auth'] ?? raw.wsAuth);
config.forceModelPrefix = normalizeBoolean(raw['force-model-prefix'] ?? raw.forceModelPrefix);
const routing = raw.routing;
const strategyRaw = isRecord(routing)
? (routing.strategy ?? routing['strategy'])
: (raw['routing-strategy'] ?? raw.routingStrategy);
if (strategyRaw !== undefined && strategyRaw !== null) {
config.routingStrategy = String(strategyRaw);
}
const apiKeysRaw = raw['api-keys'] ?? raw.apiKeys;
if (Array.isArray(apiKeysRaw)) {
config.apiKeys = apiKeysRaw.map((key) => String(key)).filter((key) => key.trim() !== '');
}
config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys;
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
if (Array.isArray(geminiList)) {
config.geminiApiKeys = geminiList
.map((item: any) => normalizeGeminiKeyConfig(item))
.map((item) => normalizeGeminiKeyConfig(item))
.filter(Boolean) as GeminiKeyConfig[];
}
const codexList = raw['codex-api-key'] ?? raw.codexApiKey ?? raw.codexApiKeys;
if (Array.isArray(codexList)) {
config.codexApiKeys = codexList
.map((item: any) => normalizeProviderKeyConfig(item))
.map((item) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const claudeList = raw['claude-api-key'] ?? raw.claudeApiKey ?? raw.claudeApiKeys;
if (Array.isArray(claudeList)) {
config.claudeApiKeys = claudeList
.map((item: any) => normalizeProviderKeyConfig(item))
.map((item) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys;
if (Array.isArray(vertexList)) {
config.vertexApiKeys = vertexList
.map((item: any) => normalizeProviderKeyConfig(item))
.map((item) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
if (Array.isArray(openaiList)) {
config.openaiCompatibility = openaiList
.map((item: any) => normalizeOpenAIProvider(item))
.map((item) => normalizeOpenAIProvider(item))
.filter(Boolean) as OpenAIProviderConfig[];
}

View File

@@ -26,7 +26,7 @@ export const usageApi = {
/**
* 获取使用统计原始数据
*/
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
getUsage: () => apiClient.get<Record<string, unknown>>('/usage', { timeout: USAGE_TIMEOUT_MS }),
/**
* 导出使用统计快照
@@ -42,10 +42,10 @@ export const usageApi = {
/**
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
*/
async getKeyStats(usageData?: any): Promise<KeyStats> {
async getKeyStats(usageData?: unknown): Promise<KeyStats> {
let payload = usageData;
if (!payload) {
const response = await apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS });
const response = await apiClient.get<Record<string, unknown>>('/usage', { timeout: USAGE_TIMEOUT_MS });
payload = response?.usage ?? response;
}
return computeKeyStats(payload);

View File

@@ -5,5 +5,5 @@
import { apiClient } from './client';
export const versionApi = {
checkLatest: () => apiClient.get('/latest-version')
checkLatest: () => apiClient.get<Record<string, unknown>>('/latest-version')
};

View File

@@ -13,7 +13,7 @@ class SecureStorageService {
/**
* 存储数据
*/
setItem(key: string, value: any, options: StorageOptions = {}): void {
setItem(key: string, value: unknown, options: StorageOptions = {}): void {
const { encrypt = true } = options;
if (value === null || value === undefined) {
@@ -30,7 +30,7 @@ class SecureStorageService {
/**
* 获取数据
*/
getItem<T = any>(key: string, options: StorageOptions = {}): T | null {
getItem<T = unknown>(key: string, options: StorageOptions = {}): T | null {
const { encrypt = true } = options;
const raw = localStorage.getItem(key);
@@ -84,7 +84,7 @@ class SecureStorageService {
return;
}
let parsed: any = raw;
let parsed: unknown = raw;
try {
parsed = JSON.parse(raw);
} catch {

View File

@@ -117,10 +117,16 @@ export const useAuthStore = create<AuthStoreState>()(
} else {
localStorage.removeItem('isLoggedIn');
}
} catch (error: any) {
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: 'Connection failed';
set({
connectionStatus: 'error',
connectionError: error.message || 'Connection failed'
connectionError: message || 'Connection failed'
});
throw error;
}

View File

@@ -10,7 +10,7 @@ import { configApi } from '@/services/api/config';
import { CACHE_EXPIRY_MS } from '@/utils/constants';
interface ConfigCache {
data: any;
data: unknown;
timestamp: number;
}
@@ -21,8 +21,11 @@ interface ConfigState {
error: string | null;
// 操作
fetchConfig: (section?: RawConfigSection, forceRefresh?: boolean) => Promise<Config | any>;
updateConfigValue: (section: RawConfigSection, value: any) => void;
fetchConfig: {
(section?: undefined, forceRefresh?: boolean): Promise<Config>;
(section: RawConfigSection, forceRefresh?: boolean): Promise<unknown>;
};
updateConfigValue: (section: RawConfigSection, value: unknown) => void;
clearCache: (section?: RawConfigSection) => void;
isCacheValid: (section?: RawConfigSection) => boolean;
}
@@ -105,7 +108,7 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
loading: false,
error: null,
fetchConfig: async (section, forceRefresh = false) => {
fetchConfig: (async (section?: RawConfigSection, forceRefresh: boolean = false) => {
const { cache, isCacheValid } = get();
// 检查缓存
@@ -163,10 +166,12 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
});
return section ? extractSectionValue(data, section) : data;
} catch (error: any) {
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to fetch config';
if (requestId === configRequestToken) {
set({
error: error.message || 'Failed to fetch config',
error: message || 'Failed to fetch config',
loading: false
});
}
@@ -176,7 +181,7 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
inFlightConfigRequest = null;
}
}
},
}) as ConfigState['fetchConfig'],
updateConfigValue: (section, value) => {
set((state) => {
@@ -186,61 +191,61 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
switch (section) {
case 'debug':
nextConfig.debug = value;
nextConfig.debug = value as Config['debug'];
break;
case 'proxy-url':
nextConfig.proxyUrl = value;
nextConfig.proxyUrl = value as Config['proxyUrl'];
break;
case 'request-retry':
nextConfig.requestRetry = value;
nextConfig.requestRetry = value as Config['requestRetry'];
break;
case 'quota-exceeded':
nextConfig.quotaExceeded = value;
nextConfig.quotaExceeded = value as Config['quotaExceeded'];
break;
case 'usage-statistics-enabled':
nextConfig.usageStatisticsEnabled = value;
nextConfig.usageStatisticsEnabled = value as Config['usageStatisticsEnabled'];
break;
case 'request-log':
nextConfig.requestLog = value;
nextConfig.requestLog = value as Config['requestLog'];
break;
case 'logging-to-file':
nextConfig.loggingToFile = value;
nextConfig.loggingToFile = value as Config['loggingToFile'];
break;
case 'logs-max-total-size-mb':
nextConfig.logsMaxTotalSizeMb = value;
nextConfig.logsMaxTotalSizeMb = value as Config['logsMaxTotalSizeMb'];
break;
case 'ws-auth':
nextConfig.wsAuth = value;
nextConfig.wsAuth = value as Config['wsAuth'];
break;
case 'force-model-prefix':
nextConfig.forceModelPrefix = value;
nextConfig.forceModelPrefix = value as Config['forceModelPrefix'];
break;
case 'routing/strategy':
nextConfig.routingStrategy = value;
nextConfig.routingStrategy = value as Config['routingStrategy'];
break;
case 'api-keys':
nextConfig.apiKeys = value;
nextConfig.apiKeys = value as Config['apiKeys'];
break;
case 'ampcode':
nextConfig.ampcode = value;
nextConfig.ampcode = value as Config['ampcode'];
break;
case 'gemini-api-key':
nextConfig.geminiApiKeys = value;
nextConfig.geminiApiKeys = value as Config['geminiApiKeys'];
break;
case 'codex-api-key':
nextConfig.codexApiKeys = value;
nextConfig.codexApiKeys = value as Config['codexApiKeys'];
break;
case 'claude-api-key':
nextConfig.claudeApiKeys = value;
nextConfig.claudeApiKeys = value as Config['claudeApiKeys'];
break;
case 'vertex-api-key':
nextConfig.vertexApiKeys = value;
nextConfig.vertexApiKeys = value as Config['vertexApiKeys'];
break;
case 'openai-compatibility':
nextConfig.openaiCompatibility = value;
nextConfig.openaiCompatibility = value as Config['openaiCompatibility'];
break;
case 'oauth-excluded-models':
nextConfig.oauthExcludedModels = value;
nextConfig.oauthExcludedModels = value as Config['oauthExcludedModels'];
break;
default:
break;

View File

@@ -6,13 +6,13 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import { LANGUAGE_ORDER, STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import i18n from '@/i18n';
import { getInitialLanguage } from '@/utils/language';
import { getInitialLanguage, isSupportedLanguage } from '@/utils/language';
interface LanguageState {
language: Language;
setLanguage: (language: Language) => void;
setLanguage: (language: string) => void;
toggleLanguage: () => void;
}
@@ -22,6 +22,9 @@ export const useLanguageStore = create<LanguageState>()(
language: getInitialLanguage(),
setLanguage: (language) => {
if (!isSupportedLanguage(language)) {
return;
}
// 切换 i18next 语言
i18n.changeLanguage(language);
set({ language });
@@ -29,12 +32,24 @@ export const useLanguageStore = create<LanguageState>()(
toggleLanguage: () => {
const { language, setLanguage } = get();
const newLanguage: Language = language === 'zh-CN' ? 'en' : 'zh-CN';
setLanguage(newLanguage);
const currentIndex = LANGUAGE_ORDER.indexOf(language);
const nextLanguage = LANGUAGE_ORDER[(currentIndex + 1) % LANGUAGE_ORDER.length];
setLanguage(nextLanguage);
}
}),
{
name: STORAGE_KEY_LANGUAGE
name: STORAGE_KEY_LANGUAGE,
merge: (persistedState, currentState) => {
const nextLanguage = (persistedState as Partial<LanguageState>)?.language;
if (typeof nextLanguage === 'string' && isSupportedLanguage(nextLanguage)) {
return {
...currentState,
...(persistedState as Partial<LanguageState>),
language: nextLanguage
};
}
return currentState;
}
}
)
);

View File

@@ -52,8 +52,9 @@ export const useModelsStore = create<ModelsState>((set, get) => ({
});
return list;
} catch (error: any) {
const message = error?.message || 'Failed to fetch models';
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Failed to fetch models';
set({
error: message,
loading: false,

View File

@@ -15,12 +15,18 @@ import { buildApiKeyEntry } from '@/components/providers/utils';
export type OpenAITestStatus = 'idle' | 'loading' | 'success' | 'error';
export type KeyTestStatus = {
status: OpenAITestStatus;
message: string;
};
export type OpenAIEditDraft = {
initialized: boolean;
form: OpenAIFormState;
testModel: string;
testStatus: OpenAITestStatus;
testMessage: string;
keyTestStatuses: KeyTestStatus[];
};
interface OpenAIEditDraftState {
@@ -31,6 +37,8 @@ interface OpenAIEditDraftState {
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
setDraftKeyTestStatus: (draftKey: string, keyIndex: number, status: KeyTestStatus) => void;
resetDraftKeyTestStatuses: (draftKey: string, count: number) => void;
clearDraft: (key: string) => void;
}
@@ -53,6 +61,7 @@ const buildEmptyDraft = (): OpenAIEditDraft => ({
testModel: '',
testStatus: 'idle',
testMessage: '',
keyTestStatuses: [],
});
export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) => ({
@@ -135,6 +144,38 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) =
});
},
setDraftKeyTestStatus: (draftKey, keyIndex, status) => {
if (!draftKey) return;
set((state) => {
const existing = state.drafts[draftKey] ?? buildEmptyDraft();
const nextStatuses = [...existing.keyTestStatuses];
nextStatuses[keyIndex] = status;
return {
drafts: {
...state.drafts,
[draftKey]: { ...existing, initialized: true, keyTestStatuses: nextStatuses },
},
};
});
},
resetDraftKeyTestStatuses: (draftKey, count) => {
if (!draftKey) return;
set((state) => {
const existing = state.drafts[draftKey] ?? buildEmptyDraft();
return {
drafts: {
...state.drafts,
[draftKey]: {
...existing,
initialized: true,
keyTestStatuses: Array.from({ length: count }, () => ({ status: 'idle', message: '' })),
},
},
};
});
},
clearDraft: (key) => {
if (!key) return;
set((state) => {

View File

@@ -3,15 +3,17 @@
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
interface QuotaStoreState {
antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
@@ -26,12 +28,17 @@ const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
export const useQuotaStore = create<QuotaStoreState>((set) => ({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
})),
setClaudeQuota: (updater) =>
set((state) => ({
claudeQuota: resolveUpdater(updater, state.claudeQuota)
})),
setCodexQuota: (updater) =>
set((state) => ({
codexQuota: resolveUpdater(updater, state.codexQuota)
@@ -43,6 +50,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
clearQuotaCache: () =>
set({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {}
})

View File

@@ -190,6 +190,67 @@
gap: $spacing-xs;
flex-shrink: 0;
.language-menu {
position: relative;
display: inline-flex;
align-items: center;
.language-menu-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: $z-dropdown;
min-width: 164px;
padding: $spacing-xs;
display: flex;
flex-direction: column;
gap: 2px;
}
.language-menu-option {
width: 100%;
border: none;
border-radius: $radius-sm;
background: transparent;
color: var(--text-primary);
cursor: pointer;
padding: 8px 10px;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color $transition-fast, color $transition-fast;
&:hover {
background: var(--bg-secondary);
}
&:focus-visible {
outline: none;
background: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
&.active {
color: var(--primary-color);
font-weight: 600;
}
}
.language-menu-check {
font-size: 13px;
line-height: 1;
}
@media (max-width: $breakpoint-mobile) {
.language-menu-popover {
right: auto;
left: 0;
}
}
}
svg {
display: block;
}
@@ -387,27 +448,6 @@
}
}
.footer {
padding: $spacing-md $spacing-lg;
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-secondary);
font-size: 14px;
flex-wrap: wrap;
gap: $spacing-sm;
.footer-version {
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
}
}
.grid {
display: grid;
gap: $spacing-lg;

View File

@@ -17,8 +17,8 @@ export interface ApiClientConfig {
export interface RequestOptions {
method?: HttpMethod;
headers?: Record<string, string>;
params?: Record<string, any>;
data?: any;
params?: Record<string, unknown>;
data?: unknown;
}
// 服务器版本信息
@@ -31,6 +31,6 @@ export interface ServerVersion {
export type ApiError = Error & {
status?: number;
code?: string;
details?: any;
data?: any;
details?: unknown;
data?: unknown;
};

View File

@@ -5,6 +5,7 @@
export type AuthFileType =
| 'qwen'
| 'kimi'
| 'gemini'
| 'gemini-cli'
| 'aistudio'
@@ -25,7 +26,7 @@ export interface AuthFileItem {
runtimeOnly?: boolean | string;
disabled?: boolean;
modified?: number;
[key: string]: any;
[key: string]: unknown;
}
export interface AuthFilesResponse {

View File

@@ -4,7 +4,7 @@
export type Theme = 'light' | 'dark' | 'auto';
export type Language = 'zh-CN' | 'en';
export type Language = 'zh-CN' | 'en' | 'ru';
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
@@ -15,7 +15,7 @@ export interface Notification {
duration?: number;
}
export interface ApiResponse<T = any> {
export interface ApiResponse<T = unknown> {
data?: T;
error?: string;
message?: string;

View File

@@ -31,7 +31,7 @@ export interface Config {
vertexApiKeys?: ProviderKeyConfig[];
openaiCompatibility?: OpenAIProviderConfig[];
oauthExcludedModels?: Record<string, string[]>;
raw?: Record<string, any>;
raw?: Record<string, unknown>;
}
export type RawConfigSection =

View File

@@ -11,7 +11,7 @@ export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
details?: any;
details?: unknown;
}
// 日志筛选

View File

@@ -9,6 +9,7 @@ export type OAuthProvider =
| 'anthropic'
| 'antigravity'
| 'gemini-cli'
| 'kimi'
| 'qwen';
// OAuth 流程状态

View File

@@ -20,6 +20,7 @@ export interface GeminiKeyConfig {
apiKey: string;
prefix?: string;
baseUrl?: string;
proxyUrl?: string;
headers?: Record<string, string>;
excludedModels?: string[];
}
@@ -43,5 +44,5 @@ export interface OpenAIProviderConfig {
models?: ModelAlias[];
priority?: number;
testModel?: string;
[key: string]: any;
[key: string]: unknown;
}

View File

@@ -88,6 +88,15 @@ export interface CodexRateLimitInfo {
secondaryWindow?: CodexUsageWindow | null;
}
export interface CodexAdditionalRateLimit {
limit_name?: string;
limitName?: string;
metered_feature?: string;
meteredFeature?: string;
rate_limit?: CodexRateLimitInfo | null;
rateLimit?: CodexRateLimitInfo | null;
}
export interface CodexUsagePayload {
plan_type?: string;
planType?: string;
@@ -95,6 +104,48 @@ export interface CodexUsagePayload {
rateLimit?: CodexRateLimitInfo | null;
code_review_rate_limit?: CodexRateLimitInfo | null;
codeReviewRateLimit?: CodexRateLimitInfo | null;
additional_rate_limits?: CodexAdditionalRateLimit[] | null;
additionalRateLimits?: CodexAdditionalRateLimit[] | null;
}
// Claude API payload types
export interface ClaudeUsageWindow {
utilization: number;
resets_at: string;
}
export interface ClaudeExtraUsage {
is_enabled: boolean;
monthly_limit: number;
used_credits: number;
utilization: number | null;
}
export interface ClaudeUsagePayload {
five_hour?: ClaudeUsageWindow | null;
seven_day?: ClaudeUsageWindow | null;
seven_day_oauth_apps?: ClaudeUsageWindow | null;
seven_day_opus?: ClaudeUsageWindow | null;
seven_day_sonnet?: ClaudeUsageWindow | null;
seven_day_cowork?: ClaudeUsageWindow | null;
iguana_necktie?: ClaudeUsageWindow | null;
extra_usage?: ClaudeExtraUsage | null;
}
export interface ClaudeQuotaWindow {
id: string;
label: string;
labelKey?: string;
usedPercent: number | null;
resetLabel: string;
}
export interface ClaudeQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
windows: ClaudeQuotaWindow[];
extraUsage?: ClaudeExtraUsage | null;
error?: string;
errorStatus?: number;
}
// Quota state types
@@ -134,6 +185,7 @@ export interface CodexQuotaWindow {
id: string;
label: string;
labelKey?: string;
labelParams?: Record<string, string | number>;
usedPercent: number | null;
resetLabel: string;
}

View File

@@ -10,7 +10,7 @@ export type PayloadParamEntry = {
export type PayloadModelEntry = {
id: string;
name: string;
protocol?: 'openai' | 'gemini' | 'claude' | 'codex' | 'antigravity';
protocol?: 'openai' | 'openai-response' | 'gemini' | 'claude' | 'codex' | 'antigravity';
};
export type PayloadRule = {

View File

@@ -3,6 +3,12 @@
* 从原项目 src/utils/constants.js 迁移
*/
import type { Language } from '@/types';
const defineLanguageOrder = <T extends readonly Language[]>(
languages: T & ([Language] extends [T[number]] ? unknown : never)
) => languages;
// 缓存过期时间(毫秒)
export const CACHE_EXPIRY_MS = 30 * 1000; // 与基线保持一致,减少管理端压力
@@ -33,6 +39,15 @@ export const STORAGE_KEY_LANGUAGE = 'cli-proxy-language';
export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
// 语言配置
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const);
export const LANGUAGE_LABEL_KEYS: Record<Language, string> = {
'zh-CN': 'language.chinese',
en: 'language.english',
ru: 'language.russian'
};
export const SUPPORTED_LANGUAGES = LANGUAGE_ORDER;
// 通知持续时间
export const NOTIFICATION_DURATION_MS = 3000;
@@ -42,6 +57,7 @@ export const OAUTH_CARD_IDS = [
'anthropic-oauth-card',
'antigravity-oauth-card',
'gemini-cli-oauth-card',
'kimi-oauth-card',
'qwen-oauth-card'
];
export const OAUTH_PROVIDERS = {
@@ -49,6 +65,7 @@ export const OAUTH_PROVIDERS = {
ANTHROPIC: 'anthropic',
ANTIGRAVITY: 'antigravity',
GEMINI_CLI: 'gemini-cli',
KIMI: 'kimi',
QWEN: 'qwen'
} as const;

View File

@@ -15,13 +15,13 @@ export function normalizeArrayResponse<T>(data: T | T[] | null | undefined): T[]
/**
* 防抖函数
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
export function debounce<This, Args extends unknown[], Return>(
func: (this: This, ...args: Args) => Return,
delay: number
): (...args: Parameters<T>) => void {
): (this: This, ...args: Args) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: Parameters<T>) {
return function (this: This, ...args: Args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
@@ -30,13 +30,13 @@ export function debounce<T extends (...args: any[]) => any>(
/**
* 节流函数
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
export function throttle<This, Args extends unknown[], Return>(
func: (this: This, ...args: Args) => Return,
limit: number
): (...args: Parameters<T>) => void {
): (this: This, ...args: Args) => void {
let inThrottle: boolean;
return function (this: any, ...args: Parameters<T>) {
return function (this: This, ...args: Args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
@@ -67,16 +67,17 @@ export function generateId(): string {
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as any;
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as any;
if (obj instanceof Date) return new Date(obj.getTime()) as unknown as T;
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as unknown as T;
const clonedObj = {} as T;
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
clonedObj[key] = deepClone((obj as any)[key]);
const source = obj as Record<string, unknown>;
const cloned: Record<string, unknown> = {};
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
cloned[key] = deepClone(source[key]);
}
}
return clonedObj;
return cloned as unknown as T;
}
/**

View File

@@ -1,15 +1,18 @@
import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants';
export const isSupportedLanguage = (value: string): value is Language =>
SUPPORTED_LANGUAGES.includes(value as Language);
const parseStoredLanguage = (value: string): Language | null => {
try {
const parsed = JSON.parse(value);
const candidate = parsed?.state?.language ?? parsed?.language ?? parsed;
if (candidate === 'zh-CN' || candidate === 'en') {
if (typeof candidate === 'string' && isSupportedLanguage(candidate)) {
return candidate;
}
} catch {
if (value === 'zh-CN' || value === 'en') {
if (isSupportedLanguage(value)) {
return value;
}
}
@@ -36,7 +39,10 @@ const getBrowserLanguage = (): Language => {
return 'zh-CN';
}
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
return raw.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
const lower = raw.toLowerCase();
if (lower.startsWith('zh')) return 'zh-CN';
if (lower.startsWith('ru')) return 'ru';
return 'en';
};
export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage();

Some files were not shown because too many files have changed in this diff Show More