mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-18 02:30:51 +08:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f8b421d68 | ||
|
|
3769447604 | ||
|
|
7d4c400084 | ||
|
|
32bf103f15 | ||
|
|
47c3874244 | ||
|
|
b7794a91b4 | ||
|
|
470ff51579 | ||
|
|
d09ea6aeab | ||
|
|
8a4eb267f0 | ||
|
|
63db0b11bc | ||
|
|
8dfa71b81e | ||
|
|
d140fe1061 | ||
|
|
b702cd6e4c | ||
|
|
211f9f280c | ||
|
|
6d96c92233 | ||
|
|
52cf9d86c0 | ||
|
|
a2507b1373 | ||
|
|
1f8c4331c7 | ||
|
|
faadc3ea3e | ||
|
|
32b576123c | ||
|
|
5dce24e3ea | ||
|
|
bf824f8561 | ||
|
|
3a7ddfdff1 | ||
|
|
431ec1e0f5 | ||
|
|
e2368ddfd7 | ||
|
|
6f4bc7c3bb | ||
|
|
3937a403b1 | ||
|
|
f003a34dc0 | ||
|
|
dc4ceabc7b | ||
|
|
e13d7f5e0f | ||
|
|
03a1644df7 | ||
|
|
9a6a8ba7fa | ||
|
|
3b886e47d2 | ||
|
|
06201a9fc4 | ||
|
|
ef448806aa | ||
|
|
8a33f5ab55 | ||
|
|
ab3922f9e6 | ||
|
|
5dbff4c3e0 | ||
|
|
4dde62ac58 | ||
|
|
1d3335746b | ||
|
|
c6d00e8b3f | ||
|
|
9ef7d439d2 | ||
|
|
c53a231c41 | ||
|
|
705e6dac54 | ||
|
|
daef2521f1 | ||
|
|
0640edc9c9 | ||
|
|
7068588c58 | ||
|
|
de0753f0ce | ||
|
|
d027d04f64 | ||
|
|
c4ca9be7b5 | ||
|
|
180a4ccab4 | ||
|
|
78512f8039 | ||
|
|
7cdede6de8 | ||
|
|
7ec5329576 | ||
|
|
5d0232e5de | ||
|
|
15c5f742f4 | ||
|
|
b4cd8c946d | ||
|
|
ee9b9f6e14 | ||
|
|
01abe3dc02 | ||
|
|
b957d05636 | ||
|
|
2a4ccff96e | ||
|
|
b5f869ed25 | ||
|
|
50c1b0f4b3 | ||
|
|
887600c03a | ||
|
|
0fdebacc0b | ||
|
|
4d5bb7e575 | ||
|
|
2d841c0a2f | ||
|
|
e40c3488fe | ||
|
|
04686aafc8 | ||
|
|
9476afc41c | ||
|
|
ab6a1a412c | ||
|
|
2cf1e23351 | ||
|
|
0089d4a705 | ||
|
|
c726fbc379 | ||
|
|
83f6a1a9f9 | ||
|
|
027ab483d4 | ||
|
|
535c303aec | ||
|
|
6c2cd761ba | ||
|
|
3783bec983 | ||
|
|
b90239d39c | ||
|
|
f8d66917fd | ||
|
|
36bfd0fa6a | ||
|
|
709ce4c8dd | ||
|
|
525b152a76 | ||
|
|
e053854544 | ||
|
|
0b54b6de64 | ||
|
|
0c8686cefa | ||
|
|
385117d01a | ||
|
|
700bff1d03 | ||
|
|
680b24026c | ||
|
|
2da4099d0b | ||
|
|
8acef95e5a | ||
|
|
c892d939c7 | ||
|
|
50ab96c3ed | ||
|
|
0bb8090686 | ||
|
|
cade2647d6 | ||
|
|
3661530f5f | ||
|
|
d5ccef8b24 | ||
|
|
ad6a3bd732 | ||
|
|
ad1387d076 |
@@ -1,20 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: { browser: true, es2020: true },
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
],
|
|
||||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
plugins: ['react-refresh'],
|
|
||||||
rules: {
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,6 +10,7 @@ api.md
|
|||||||
usage.json
|
usage.json
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
management-api*
|
||||||
antigravity_usage.json
|
antigravity_usage.json
|
||||||
codex_usage.json
|
codex_usage.json
|
||||||
style.md
|
style.md
|
||||||
@@ -18,6 +19,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
skills
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
settings.local.json
|
settings.local.json
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||||
51
README.md
51
README.md
@@ -1,23 +1,23 @@
|
|||||||
# CLI Proxy API Management Center
|
# 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)
|
[中文文档](README_CN.md)
|
||||||
|
|
||||||
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
|
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
|
||||||
**Example URL**: https://remote.router-for.me/
|
**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 isn’t)
|
## What this is (and isn’t)
|
||||||
|
|
||||||
- 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.
|
- It is **not** a proxy and does not forward traffic.
|
||||||
|
|
||||||
## Quick start
|
## 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.
|
1. Start your CLI Proxy API service.
|
||||||
2. Open: `http://<host>:<api_port>/management.html`
|
2. Open: `http://<host>:<api_port>/management.html`
|
||||||
@@ -32,7 +32,7 @@ npm install
|
|||||||
npm run dev
|
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
|
### Option C: Build a single HTML file
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Output: `dist/index.html` (all assets are inlined).
|
- 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`
|
- 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.
|
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)
|
## What you can manage (mapped to the UI pages)
|
||||||
|
|
||||||
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
|
- **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).
|
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
|
||||||
- **AI Providers**:
|
- **AI Providers**:
|
||||||
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
|
- 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).
|
- 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).
|
- 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Build & release notes
|
||||||
|
|
||||||
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
|
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
|
||||||
|
|||||||
52
README_CN.md
52
README_CN.md
@@ -1,14 +1,14 @@
|
|||||||
# CLI Proxy API 管理中心
|
# CLI Proxy API 管理中心
|
||||||
|
|
||||||
用于管理与排障 **CLI Proxy API** 的单文件 WebUI(React + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
|
用于管理与故障排查 **CLI Proxy API** 的单文件 Web UI(React + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等管理操作。
|
||||||
|
|
||||||
[English](README.md)
|
[English](README.md)
|
||||||
|
|
||||||
**主项目**: https://github.com/router-for-me/CLIProxyAPI
|
**主项目**: https://github.com/router-for-me/CLIProxyAPI
|
||||||
**示例地址**: https://remote.router-for.me/
|
**示例地址**: https://remote.router-for.me/
|
||||||
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0)
|
**最低版本要求**: ≥ 6.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 服务。
|
1. 启动 CLI Proxy API 服务。
|
||||||
2. 打开:`http://<host>:<api_port>/management.html`
|
2. 打开:`http://<host>:<api_port>/management.html`
|
||||||
@@ -32,7 +32,7 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
|
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 后端实例。
|
||||||
|
|
||||||
### 方式 C:构建单文件 HTML
|
### 方式 C:构建单文件 HTML
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 构建产物:`dist/index.html`(资源已全部内联)。
|
- 构建产物:`dist/index.html`(资源已全部内联)。
|
||||||
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`。
|
- 在 CLI Proxy API 的发布流程里会重命名为 `management.html`。
|
||||||
- 本地预览:`npm run preview`
|
- 本地预览:`npm run preview`
|
||||||
|
|
||||||
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
|
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
|
||||||
@@ -51,7 +51,7 @@ npm run build
|
|||||||
|
|
||||||
### API 地址怎么填
|
### API 地址怎么填
|
||||||
|
|
||||||
以下格式均可,WebUI 会自动归一化:
|
以下格式均可,Web UI 会自动归一化:
|
||||||
|
|
||||||
- `localhost:8317`
|
- `localhost:8317`
|
||||||
- `http://192.168.1.10:8317`
|
- `http://192.168.1.10:8317`
|
||||||
@@ -64,29 +64,57 @@ npm run build
|
|||||||
|
|
||||||
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
|
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
|
||||||
|
|
||||||
这与 WebUI 中“API Keys”页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
|
这与 Web UI 中"API Keys"页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
|
||||||
|
|
||||||
### 远程管理
|
### 远程管理
|
||||||
|
|
||||||
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
|
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
|
||||||
完整鉴权规则、限制与边界情况请查看 `api.md`。
|
|
||||||
|
|
||||||
## 功能一览(按页面对应)
|
## 功能一览(按页面对应)
|
||||||
|
|
||||||
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
|
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
|
||||||
- **基础设置**:调试开关、代理 URL、请求重试、配额回退(切项目/切预览模型)、使用统计、请求日志、文件日志、WebSocket 鉴权。
|
- **基础设置**:调试开关、代理 URL、请求重试、配额回退(达到上限时切换项目或预览模型)、使用统计、请求日志、文件日志、WebSocket 鉴权。
|
||||||
- **API Keys**:管理代理 `api-keys`(增/改/删)。
|
- **API Keys**:管理代理 `api-keys`(增/改/删)。
|
||||||
- **AI 提供商**:
|
- **AI 提供商**:
|
||||||
- Gemini/Codex/Claude 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
|
- Gemini/Codex/Claude/Vertex 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
|
||||||
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
|
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
|
||||||
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
|
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
|
||||||
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符)。
|
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符);配置 OAuth 模型别名映射。
|
||||||
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
|
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
|
||||||
|
- **配额管理**:管理 Claude、Antigravity、Codex、Gemini CLI 等提供商的配额上限与使用情况。
|
||||||
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
|
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
|
||||||
- **配置文件**:浏览器内编辑 `/config.yaml`(YAML 高亮 + 搜索),保存/重载。
|
- **配置文件**:浏览器内编辑 `/config.yaml`(YAML 高亮 + 搜索),保存/重载。
|
||||||
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
|
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
|
||||||
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
|
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- React 19 + TypeScript 5.9
|
||||||
|
- Vite 7(单文件构建)
|
||||||
|
- Zustand(状态管理)
|
||||||
|
- Axios(HTTP 客户端)
|
||||||
|
- react-router-dom v7(HashRouter)
|
||||||
|
- Chart.js(数据可视化)
|
||||||
|
- CodeMirror 6(YAML 编辑器)
|
||||||
|
- SCSS Modules(样式)
|
||||||
|
- i18next(国际化)
|
||||||
|
|
||||||
|
## 多语言支持
|
||||||
|
|
||||||
|
目前支持三种语言:
|
||||||
|
|
||||||
|
- 英文 (en)
|
||||||
|
- 简体中文 (zh-CN)
|
||||||
|
- 俄文 (ru)
|
||||||
|
|
||||||
|
界面语言会根据浏览器设置自动切换,也可在页面底部手动切换。
|
||||||
|
|
||||||
|
## 浏览器兼容性
|
||||||
|
|
||||||
|
- 构建目标:`ES2020`
|
||||||
|
- 支持 Chrome、Firefox、Safari、Edge 等现代浏览器
|
||||||
|
- 支持移动端响应式布局,可通过手机/平板访问
|
||||||
|
|
||||||
## 构建与发布说明
|
## 构建与发布说明
|
||||||
|
|
||||||
- 使用 Vite 输出 **单文件 HTML**(`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
|
- 使用 Vite 输出 **单文件 HTML**(`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
|
||||||
|
|||||||
53
package-lock.json
generated
53
package-lock.json
generated
@@ -9,7 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@openai/codex": "^0.98.0",
|
"@codemirror/merge": "^6.12.0",
|
||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
@@ -73,7 +73,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -431,6 +430,19 @@
|
|||||||
"crelt": "^1.0.5"
|
"crelt": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/merge": {
|
||||||
|
"version": "6.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.0.tgz",
|
||||||
|
"integrity": "sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"style-mod": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/search": {
|
"node_modules/@codemirror/search": {
|
||||||
"version": "6.5.11",
|
"version": "6.5.11",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
|
||||||
@@ -468,7 +480,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
|
||||||
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -1244,18 +1255,6 @@
|
|||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
|
||||||
@@ -1946,7 +1945,6 @@
|
|||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -2034,7 +2032,6 @@
|
|||||||
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.48.1",
|
"@typescript-eslint/scope-manager": "8.48.1",
|
||||||
"@typescript-eslint/types": "8.48.1",
|
"@typescript-eslint/types": "8.48.1",
|
||||||
@@ -2352,7 +2349,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2402,13 +2398,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.5",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2564,7 +2560,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -2829,7 +2824,6 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3306,7 +3300,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
@@ -3636,7 +3629,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3743,7 +3735,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3761,7 +3752,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -3870,7 +3860,6 @@
|
|||||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4053,7 +4042,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4130,7 +4118,6 @@
|
|||||||
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -4260,7 +4247,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
@@ -4288,7 +4274,6 @@
|
|||||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@openai/codex": "^0.98.0",
|
"@codemirror/merge": "^6.12.0",
|
||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
ReactNode,
|
ReactNode,
|
||||||
createContext,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useLocation, type Location } from 'react-router-dom';
|
import { useLocation, type Location } from 'react-router-dom';
|
||||||
import gsap from 'gsap';
|
import gsap from 'gsap';
|
||||||
|
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
|
||||||
import './PageTransition.scss';
|
import './PageTransition.scss';
|
||||||
|
|
||||||
interface PageTransitionProps {
|
interface PageTransitionProps {
|
||||||
@@ -27,8 +26,6 @@ const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
|
|||||||
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
|
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
|
||||||
const IOS_EXIT_DIM_OPACITY = 0.72;
|
const IOS_EXIT_DIM_OPACITY = 0.72;
|
||||||
|
|
||||||
type LayerStatus = 'current' | 'exiting' | 'stacked';
|
|
||||||
|
|
||||||
type Layer = {
|
type Layer = {
|
||||||
key: string;
|
key: string;
|
||||||
location: Location;
|
location: Location;
|
||||||
@@ -39,16 +36,6 @@ type TransitionDirection = 'forward' | 'backward';
|
|||||||
|
|
||||||
type TransitionVariant = 'vertical' | 'ios';
|
type TransitionVariant = 'vertical' | 'ios';
|
||||||
|
|
||||||
type PageTransitionLayerContextValue = {
|
|
||||||
status: LayerStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(null);
|
|
||||||
|
|
||||||
export function usePageTransitionLayer() {
|
|
||||||
return useContext(PageTransitionLayerContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageTransition({
|
export function PageTransition({
|
||||||
render,
|
render,
|
||||||
getRouteOrder,
|
getRouteOrder,
|
||||||
|
|||||||
15
src/components/common/PageTransitionLayer.ts
Normal file
15
src/components/common/PageTransitionLayer.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
import './SplashScreen.scss';
|
import './SplashScreen.scss';
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ interface SplashScreenProps {
|
|||||||
const FADE_OUT_DURATION = 400;
|
const FADE_OUT_DURATION = 400;
|
||||||
|
|
||||||
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
|
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fadeOut) return;
|
if (!fadeOut) return;
|
||||||
const finishTimer = setTimeout(() => {
|
const finishTimer = setTimeout(() => {
|
||||||
@@ -25,8 +28,8 @@ export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
|
|||||||
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
|
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
|
||||||
<div className="splash-content">
|
<div className="splash-content">
|
||||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
|
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
|
||||||
<h1 className="splash-title">CLI Proxy API</h1>
|
<h1 className="splash-title">{t('splash.title')}</h1>
|
||||||
<p className="splash-subtitle">Management Center</p>
|
<p className="splash-subtitle">{t('splash.subtitle')}</p>
|
||||||
<div className="splash-loader">
|
<div className="splash-loader">
|
||||||
<div className="splash-loader-bar" />
|
<div className="splash-loader-bar" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
176
src/components/config/DiffModal.module.scss
Normal file
176
src/components/config/DiffModal.module.scss
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.diffModal {
|
||||||
|
:global(.modal-body) {
|
||||||
|
padding: $spacing-md $spacing-lg;
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 70vh;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffList {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffCard {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffCardHeader {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px dashed var(--border-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffColumns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffColumn {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffColumnHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineMeta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineRange {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextRange {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeList {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 280px;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLine {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLine:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLineChanged {
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLineNumber {
|
||||||
|
padding: 7px 10px 7px 8px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
border-right: 1px solid color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
user-select: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLineText {
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.content {
|
||||||
|
height: 65vh;
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diffColumns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineMeta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLine {
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLineNumber {
|
||||||
|
padding: 6px 6px 6px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLineText {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/components/config/DiffModal.tsx
Normal file
196
src/components/config/DiffModal.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Text } from '@codemirror/state';
|
||||||
|
import { Chunk } from '@codemirror/merge';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import styles from './DiffModal.module.scss';
|
||||||
|
|
||||||
|
type DiffModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
original: string;
|
||||||
|
modified: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiffChunkCard = {
|
||||||
|
id: string;
|
||||||
|
current: DiffSide;
|
||||||
|
modified: DiffSide;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LineRange = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiffSideLine = {
|
||||||
|
lineNumber: number;
|
||||||
|
text: string;
|
||||||
|
changed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiffSide = {
|
||||||
|
changedRangeLabel: string;
|
||||||
|
contextRangeLabel: string;
|
||||||
|
lines: DiffSideLine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFF_CONTEXT_LINES = 2;
|
||||||
|
|
||||||
|
const clampPos = (doc: Text, pos: number) => Math.max(0, Math.min(pos, doc.length));
|
||||||
|
|
||||||
|
const getLineRangeLabel = (range: LineRange): string => {
|
||||||
|
return range.start === range.end ? String(range.start) : `${range.start}-${range.end}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChangedLineRange = (doc: Text, from: number, to: number): LineRange => {
|
||||||
|
const start = clampPos(doc, from);
|
||||||
|
const end = clampPos(doc, to);
|
||||||
|
if (start === end) {
|
||||||
|
const linePos = Math.min(start, doc.length);
|
||||||
|
const anchorLine = doc.lineAt(linePos).number;
|
||||||
|
return { start: anchorLine, end: anchorLine };
|
||||||
|
}
|
||||||
|
const startLine = doc.lineAt(start).number;
|
||||||
|
const endLine = doc.lineAt(Math.max(start, end - 1)).number;
|
||||||
|
return { start: startLine, end: endLine };
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandContextRange = (doc: Text, range: LineRange): LineRange => ({
|
||||||
|
start: Math.max(1, range.start - DIFF_CONTEXT_LINES),
|
||||||
|
end: Math.min(doc.lines, range.end + DIFF_CONTEXT_LINES)
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSideLines = (doc: Text, contextRange: LineRange, changedRange: LineRange): DiffSideLine[] => {
|
||||||
|
const lines: DiffSideLine[] = [];
|
||||||
|
for (let lineNumber = contextRange.start; lineNumber <= contextRange.end; lineNumber += 1) {
|
||||||
|
lines.push({
|
||||||
|
lineNumber,
|
||||||
|
text: doc.line(lineNumber).text,
|
||||||
|
changed: lineNumber >= changedRange.start && lineNumber <= changedRange.end
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DiffModal({
|
||||||
|
open,
|
||||||
|
original,
|
||||||
|
modified,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
loading = false
|
||||||
|
}: DiffModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const diffCards = useMemo<DiffChunkCard[]>(() => {
|
||||||
|
const currentDoc = Text.of(original.split('\n'));
|
||||||
|
const modifiedDoc = Text.of(modified.split('\n'));
|
||||||
|
const chunks = Chunk.build(currentDoc, modifiedDoc);
|
||||||
|
|
||||||
|
return chunks.map((chunk, index) => {
|
||||||
|
const currentChangedRange = getChangedLineRange(currentDoc, chunk.fromA, chunk.toA);
|
||||||
|
const modifiedChangedRange = getChangedLineRange(modifiedDoc, chunk.fromB, chunk.toB);
|
||||||
|
const currentContextRange = expandContextRange(currentDoc, currentChangedRange);
|
||||||
|
const modifiedContextRange = expandContextRange(modifiedDoc, modifiedChangedRange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${index}-${chunk.fromA}-${chunk.toA}-${chunk.fromB}-${chunk.toB}`,
|
||||||
|
current: {
|
||||||
|
changedRangeLabel: getLineRangeLabel(currentChangedRange),
|
||||||
|
contextRangeLabel: getLineRangeLabel(currentContextRange),
|
||||||
|
lines: buildSideLines(currentDoc, currentContextRange, currentChangedRange)
|
||||||
|
},
|
||||||
|
modified: {
|
||||||
|
changedRangeLabel: getLineRangeLabel(modifiedChangedRange),
|
||||||
|
contextRangeLabel: getLineRangeLabel(modifiedContextRange),
|
||||||
|
lines: buildSideLines(modifiedDoc, modifiedContextRange, modifiedChangedRange)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [modified, original]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t('config_management.diff.title')}
|
||||||
|
onClose={onCancel}
|
||||||
|
width="min(1200px, 90vw)"
|
||||||
|
className={styles.diffModal}
|
||||||
|
closeDisabled={loading}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onCancel} disabled={loading}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm} loading={loading} disabled={loading}>
|
||||||
|
{t('config_management.diff.confirm')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{diffCards.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>{t('config_management.diff.no_changes')}</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.diffList}>
|
||||||
|
{diffCards.map((card, index) => (
|
||||||
|
<article key={card.id} className={styles.diffCard}>
|
||||||
|
<div className={styles.diffCardHeader}>#{index + 1}</div>
|
||||||
|
<div className={styles.diffColumns}>
|
||||||
|
<section className={styles.diffColumn}>
|
||||||
|
<header className={styles.diffColumnHeader}>
|
||||||
|
<span>{t('config_management.diff.current')}</span>
|
||||||
|
<span className={styles.lineMeta}>
|
||||||
|
<span className={styles.lineRange}>L{card.current.changedRangeLabel}</span>
|
||||||
|
<span className={styles.contextRange}>
|
||||||
|
±{DIFF_CONTEXT_LINES}: L{card.current.contextRangeLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className={styles.codeList}>
|
||||||
|
{card.current.lines.map((line) => (
|
||||||
|
<div
|
||||||
|
key={`${card.id}-a-${line.lineNumber}`}
|
||||||
|
className={`${styles.codeLine} ${line.changed ? styles.codeLineChanged : ''}`}
|
||||||
|
>
|
||||||
|
<span className={styles.codeLineNumber}>{line.lineNumber}</span>
|
||||||
|
<code className={styles.codeLineText}>{line.text || ' '}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className={styles.diffColumn}>
|
||||||
|
<header className={styles.diffColumnHeader}>
|
||||||
|
<span>{t('config_management.diff.modified')}</span>
|
||||||
|
<span className={styles.lineMeta}>
|
||||||
|
<span className={styles.lineRange}>L{card.modified.changedRangeLabel}</span>
|
||||||
|
<span className={styles.contextRange}>
|
||||||
|
±{DIFF_CONTEXT_LINES}: L{card.modified.contextRangeLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className={styles.codeList}>
|
||||||
|
{card.modified.lines.map((line) => (
|
||||||
|
<div
|
||||||
|
key={`${card.id}-b-${line.lineNumber}`}
|
||||||
|
className={`${styles.codeLine} ${line.changed ? styles.codeLineChanged : ''}`}
|
||||||
|
>
|
||||||
|
<span className={styles.codeLineNumber}>{line.lineNumber}</span>
|
||||||
|
<code className={styles.codeLineText}>{line.text || ' '}</code>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/config/VisualConfigEditor.module.scss
Normal file
37
src/components/config/VisualConfigEditor.module.scss
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
import { useMemo, useState, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import { IconChevronDown } from '@/components/ui/icons';
|
|
||||||
import { ConfigSection } from '@/components/config/ConfigSection';
|
import { ConfigSection } from '@/components/config/ConfigSection';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import styles from './VisualConfigEditor.module.scss';
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
import type {
|
import type {
|
||||||
PayloadFilterRule,
|
PayloadFilterRule,
|
||||||
PayloadModelEntry,
|
PayloadModelEntry,
|
||||||
@@ -78,118 +81,6 @@ function Divider() {
|
|||||||
return <div style={{ height: 1, background: 'var(--border-color)', margin: '16px 0' }} />;
|
return <div style={{ height: 1, background: 'var(--border-color)', margin: '16px 0' }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToastSelectOption = { value: string; label: string };
|
|
||||||
|
|
||||||
function ToastSelect({
|
|
||||||
value,
|
|
||||||
options,
|
|
||||||
disabled,
|
|
||||||
ariaLabel,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
options: ReadonlyArray<ToastSelectOption>;
|
|
||||||
disabled?: boolean;
|
|
||||||
ariaLabel: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const selectedOption = options.find((opt) => opt.value === value) ?? options[0];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
if (!containerRef.current.contains(event.target as Node)) setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} style={{ position: 'relative' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="input"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
aria-expanded={open}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 8,
|
|
||||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
||||||
textAlign: 'left',
|
|
||||||
width: '100%',
|
|
||||||
appearance: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
|
|
||||||
{selectedOption?.label ?? ''}
|
|
||||||
</span>
|
|
||||||
<IconChevronDown size={16} style={{ opacity: 0.6, flex: '0 0 auto' }} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && !disabled && (
|
|
||||||
<div
|
|
||||||
role="listbox"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 'calc(100% + 6px)',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 1000,
|
|
||||||
background: 'var(--bg-primary)',
|
|
||||||
border: '1px solid var(--border-color)',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 6,
|
|
||||||
boxShadow: 'var(--shadow)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 6,
|
|
||||||
maxHeight: 260,
|
|
||||||
overflowY: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{options.map((opt) => {
|
|
||||||
const active = opt.value === value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
role="option"
|
|
||||||
aria-selected={active}
|
|
||||||
onClick={() => {
|
|
||||||
onChange(opt.value);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: 10,
|
|
||||||
border: active ? '1px solid rgba(59, 130, 246, 0.5)' : '1px solid var(--border-color)',
|
|
||||||
background: active ? 'rgba(59, 130, 246, 0.10)' : 'var(--bg-primary)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'left',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApiKeysCardEditor({
|
function ApiKeysCardEditor({
|
||||||
value,
|
value,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -200,6 +91,7 @@ function ApiKeysCardEditor({
|
|||||||
onChange: (nextValue: string) => void;
|
onChange: (nextValue: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
const apiKeys = useMemo(
|
const apiKeys = useMemo(
|
||||||
() =>
|
() =>
|
||||||
value
|
value
|
||||||
@@ -214,6 +106,13 @@ function ApiKeysCardEditor({
|
|||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState('');
|
||||||
|
|
||||||
|
function generateSecureApiKey(): string {
|
||||||
|
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const array = new Uint8Array(17);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return 'sk-' + Array.from(array, (b) => charset[b % charset.length]).join('');
|
||||||
|
}
|
||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
setEditingIndex(null);
|
setEditingIndex(null);
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
@@ -262,6 +161,19 @@ function ApiKeysCardEditor({
|
|||||||
closeModal();
|
closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (apiKey: string) => {
|
||||||
|
const copied = await copyToClipboard(apiKey);
|
||||||
|
showNotification(
|
||||||
|
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
|
||||||
|
copied ? 'success' : 'error'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
setInputValue(generateSecureApiKey());
|
||||||
|
setFormError('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
@@ -293,6 +205,9 @@ function ApiKeysCardEditor({
|
|||||||
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="item-actions">
|
<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}>
|
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disabled}>
|
||||||
{t('config_management.visual.common.edit')}
|
{t('config_management.visual.common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -330,6 +245,18 @@ function ApiKeysCardEditor({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
error={formError || undefined}
|
error={formError || undefined}
|
||||||
hint={t('config_management.visual.api_keys.input_hint')}
|
hint={t('config_management.visual.api_keys.input_hint')}
|
||||||
|
style={{ paddingRight: 148 }}
|
||||||
|
rightElement={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{t('config_management.visual.api_keys.generate')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +285,7 @@ function StringListEditor({
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{items.map((item, index) => (
|
{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
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -394,6 +321,22 @@ function PayloadRulesEditor({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const rules = value.length ? value : [];
|
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]
|
||||||
|
);
|
||||||
|
const payloadValueTypeOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||||
|
})),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||||
@@ -471,7 +414,15 @@ function PayloadRulesEditor({
|
|||||||
gap: 12,
|
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>
|
<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}>
|
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||||
{t('config_management.visual.common.delete')}
|
{t('config_management.visual.common.delete')}
|
||||||
@@ -483,17 +434,15 @@ function PayloadRulesEditor({
|
|||||||
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
|
{(rule.models.length ? rule.models : []).map((model, modelIndex) => (
|
||||||
<div
|
<div
|
||||||
key={model.id}
|
key={model.id}
|
||||||
style={{
|
className={[styles.payloadRuleModelRow, protocolFirst ? styles.payloadRuleModelRowProtocolFirst : '']
|
||||||
display: 'grid',
|
.filter(Boolean)
|
||||||
gridTemplateColumns: protocolFirst ? '160px 1fr auto' : '1fr 160px auto',
|
.join(' ')}
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{protocolFirst ? (
|
{protocolFirst ? (
|
||||||
<>
|
<>
|
||||||
<ToastSelect
|
<Select
|
||||||
value={model.protocol ?? ''}
|
value={model.protocol ?? ''}
|
||||||
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
|
options={protocolOptions}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||||
onChange={(nextValue) =>
|
onChange={(nextValue) =>
|
||||||
@@ -519,9 +468,9 @@ function PayloadRulesEditor({
|
|||||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<ToastSelect
|
<Select
|
||||||
value={model.protocol ?? ''}
|
value={model.protocol ?? ''}
|
||||||
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
|
options={protocolOptions}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||||
onChange={(nextValue) =>
|
onChange={(nextValue) =>
|
||||||
@@ -532,7 +481,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')}
|
{t('config_management.visual.common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -547,7 +502,7 @@ function PayloadRulesEditor({
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<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>
|
<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) => (
|
{(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
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={t('config_management.visual.payload_rules.json_path')}
|
placeholder={t('config_management.visual.payload_rules.json_path')}
|
||||||
@@ -555,9 +510,9 @@ function PayloadRulesEditor({
|
|||||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<ToastSelect
|
<Select
|
||||||
value={param.valueType}
|
value={param.valueType}
|
||||||
options={VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS}
|
options={payloadValueTypeOptions}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
ariaLabel={t('config_management.visual.payload_rules.param_type')}
|
ariaLabel={t('config_management.visual.payload_rules.param_type')}
|
||||||
onChange={(nextValue) =>
|
onChange={(nextValue) =>
|
||||||
@@ -571,7 +526,13 @@ function PayloadRulesEditor({
|
|||||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
onChange={(e) => updateParam(ruleIndex, paramIndex, { value: e.target.value })}
|
||||||
disabled={disabled}
|
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')}
|
{t('config_management.visual.common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -619,6 +580,14 @@ function PayloadFilterRulesEditor({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const rules = value.length ? value : [];
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||||
@@ -658,7 +627,15 @@ function PayloadFilterRulesEditor({
|
|||||||
gap: 12,
|
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>
|
<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}>
|
<Button variant="ghost" size="sm" onClick={() => removeRule(ruleIndex)} disabled={disabled}>
|
||||||
{t('config_management.visual.common.delete')}
|
{t('config_management.visual.common.delete')}
|
||||||
@@ -668,7 +645,7 @@ function PayloadFilterRulesEditor({
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<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>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('config_management.visual.payload_rules.models')}</div>
|
||||||
{rule.models.map((model, modelIndex) => (
|
{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
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={t('config_management.visual.payload_rules.model_name')}
|
placeholder={t('config_management.visual.payload_rules.model_name')}
|
||||||
@@ -676,9 +653,9 @@ function PayloadFilterRulesEditor({
|
|||||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<ToastSelect
|
<Select
|
||||||
value={model.protocol ?? ''}
|
value={model.protocol ?? ''}
|
||||||
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
|
options={protocolOptions}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||||
onChange={(nextValue) =>
|
onChange={(nextValue) =>
|
||||||
@@ -687,7 +664,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')}
|
{t('config_management.visual.common.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -923,7 +906,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
|
|||||||
/>
|
/>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>{t('config_management.visual.sections.network.routing_strategy')}</label>
|
<label>{t('config_management.visual.sections.network.routing_strategy')}</label>
|
||||||
<ToastSelect
|
<Select
|
||||||
value={values.routingStrategy}
|
value={values.routingStrategy}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'round-robin', label: t('config_management.visual.sections.network.strategy_round_robin') },
|
{ value: 'round-robin', label: t('config_management.visual.sections.network.strategy_round_robin') },
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
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 { PageTransition } from '@/components/common/PageTransition';
|
||||||
import { MainRoutes } from '@/router/MainRoutes';
|
import { MainRoutes } from '@/router/MainRoutes';
|
||||||
import {
|
import {
|
||||||
@@ -33,8 +31,10 @@ import {
|
|||||||
useNotificationStore,
|
useNotificationStore,
|
||||||
useThemeStore,
|
useThemeStore,
|
||||||
} from '@/stores';
|
} from '@/stores';
|
||||||
import { configApi, versionApi } from '@/services/api';
|
import { versionApi } from '@/services/api';
|
||||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||||
|
import { isSupportedLanguage } from '@/utils/language';
|
||||||
|
|
||||||
const sidebarIcons: Record<string, ReactNode> = {
|
const sidebarIcons: Record<string, ReactNode> = {
|
||||||
dashboard: <IconLayoutDashboard size={18} />,
|
dashboard: <IconLayoutDashboard size={18} />,
|
||||||
@@ -172,44 +172,36 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const apiBase = useAuthStore((state) => state.apiBase);
|
const apiBase = useAuthStore((state) => state.apiBase);
|
||||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||||
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
|
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const logout = useAuthStore((state) => state.logout);
|
const logout = useAuthStore((state) => state.logout);
|
||||||
|
|
||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
|
||||||
|
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const theme = useThemeStore((state) => state.theme);
|
||||||
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
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 [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||||
|
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
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 contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const headerRef = useRef<HTMLElement | 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 fullBrandName = 'CLI Proxy API Management Center';
|
||||||
const abbrBrandName = t('title.abbr');
|
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');
|
const isLogsPage = location.pathname.startsWith('/logs');
|
||||||
|
|
||||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||||
@@ -241,7 +233,7 @@ export function MainLayout() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 将主内容区的中心点写入 CSS 变量,供底部浮层(如配置面板操作栏)对齐到内容区而非整窗
|
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const updateContentCenter = () => {
|
const updateContentCenter = () => {
|
||||||
const el = contentRef.current;
|
const el = contentRef.current;
|
||||||
@@ -269,6 +261,7 @@ export function MainLayout() {
|
|||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
}
|
}
|
||||||
window.removeEventListener('resize', updateContentCenter);
|
window.removeEventListener('resize', updateContentCenter);
|
||||||
|
document.documentElement.style.removeProperty('--content-center-x');
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -286,18 +279,30 @@ export function MainLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requestLogModalOpen && !requestLogTouched) {
|
if (!languageMenuOpen) {
|
||||||
setRequestLogDraft(requestLogEnabled);
|
return;
|
||||||
}
|
}
|
||||||
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
return () => {
|
if (!languageMenuRef.current?.contains(event.target as Node)) {
|
||||||
if (versionTapTimer.current) {
|
setLanguageMenuOpen(false);
|
||||||
clearTimeout(versionTapTimer.current);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
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(() => {
|
const handleBrandClick = useCallback(() => {
|
||||||
if (!brandExpanded) {
|
if (!brandExpanded) {
|
||||||
@@ -312,59 +317,20 @@ export function MainLayout() {
|
|||||||
}
|
}
|
||||||
}, [brandExpanded]);
|
}, [brandExpanded]);
|
||||||
|
|
||||||
const openRequestLogModal = useCallback(() => {
|
const toggleLanguageMenu = useCallback(() => {
|
||||||
setRequestLogTouched(false);
|
setLanguageMenuOpen((prev) => !prev);
|
||||||
setRequestLogDraft(requestLogEnabled);
|
|
||||||
setRequestLogModalOpen(true);
|
|
||||||
}, [requestLogEnabled]);
|
|
||||||
|
|
||||||
const handleRequestLogClose = useCallback(() => {
|
|
||||||
setRequestLogModalOpen(false);
|
|
||||||
setRequestLogTouched(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleVersionTap = useCallback(() => {
|
const handleLanguageSelect = useCallback(
|
||||||
versionTapCount.current += 1;
|
(nextLanguage: string) => {
|
||||||
if (versionTapTimer.current) {
|
if (!isSupportedLanguage(nextLanguage)) {
|
||||||
clearTimeout(versionTapTimer.current);
|
return;
|
||||||
}
|
|
||||||
versionTapTimer.current = setTimeout(() => {
|
|
||||||
versionTapCount.current = 0;
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
if (versionTapCount.current >= 7) {
|
|
||||||
versionTapCount.current = 0;
|
|
||||||
if (versionTapTimer.current) {
|
|
||||||
clearTimeout(versionTapTimer.current);
|
|
||||||
versionTapTimer.current = null;
|
|
||||||
}
|
}
|
||||||
openRequestLogModal();
|
setLanguage(nextLanguage);
|
||||||
}
|
setLanguageMenuOpen(false);
|
||||||
}, [openRequestLogModal]);
|
},
|
||||||
|
[setLanguage]
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig().catch(() => {
|
fetchConfig().catch(() => {
|
||||||
@@ -475,7 +441,8 @@ export function MainLayout() {
|
|||||||
setCheckingVersion(true);
|
setCheckingVersion(true);
|
||||||
try {
|
try {
|
||||||
const data = await versionApi.checkLatest();
|
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);
|
const comparison = compareVersions(latest, serverVersion);
|
||||||
|
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
@@ -493,8 +460,11 @@ export function MainLayout() {
|
|||||||
} else {
|
} else {
|
||||||
showNotification(t('system_info.version_is_latest'), 'success');
|
showNotification(t('system_info.version_is_latest'), 'success');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
|
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 {
|
} finally {
|
||||||
setCheckingVersion(false);
|
setCheckingVersion(false);
|
||||||
}
|
}
|
||||||
@@ -566,9 +536,36 @@ export function MainLayout() {
|
|||||||
>
|
>
|
||||||
{headerIcons.update}
|
{headerIcons.update}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
||||||
{headerIcons.language}
|
<Button
|
||||||
</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')}>
|
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||||
{theme === 'auto'
|
{theme === 'auto'
|
||||||
? headerIcons.autoTheme
|
? headerIcons.autoTheme
|
||||||
@@ -612,57 +609,8 @@ export function MainLayout() {
|
|||||||
scrollContainerRef={contentRef}
|
scrollContainerRef={contentRef}
|
||||||
/>
|
/>
|
||||||
</main>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
&.selected {
|
&.selected {
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
box-shadow: 0 0 0 2px rgba($primary-color, 0.18);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export interface ModelMappingDiagramProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_COLORS = [
|
const PROVIDER_COLORS = [
|
||||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
|
'#8b8680', '#10b981', '#f59e0b', '#c65746',
|
||||||
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
|
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -285,7 +285,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
// updateLines is called after layout is calculated, ensuring elements are in place.
|
// updateLines is called after layout is calculated, ensuring elements are in place.
|
||||||
updateLines();
|
|
||||||
const raf = requestAnimationFrame(updateLines);
|
const raf = requestAnimationFrame(updateLines);
|
||||||
window.addEventListener('resize', updateLines);
|
window.addEventListener('resize', updateLines);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -295,7 +294,6 @@ export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappi
|
|||||||
}, [updateLines, aliasNodes]);
|
}, [updateLines, aliasNodes]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
updateLines();
|
|
||||||
const raf = requestAnimationFrame(updateLines);
|
const raf = requestAnimationFrame(updateLines);
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [providerGroupHeights, updateLines]);
|
}, [providerGroupHeights, updateLines]);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -125,6 +125,12 @@ export function GeminiSection({
|
|||||||
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
</div>
|
</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 && (
|
{headerEntries.length > 0 && (
|
||||||
<div className={styles.headerBadgeList}>
|
<div className={styles.headerBadgeList}>
|
||||||
{headerEntries.map(([key, value]) => (
|
{headerEntries.map(([key, value]) => (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -87,7 +87,7 @@ export function OpenAISection({
|
|||||||
<ProviderList<OpenAIProviderConfig>
|
<ProviderList<OpenAIProviderConfig>
|
||||||
items={configs}
|
items={configs}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
keyField={(item) => item.name}
|
keyField={(_, index) => `openai-provider-${index}`}
|
||||||
emptyTitle={t('ai_providers.openai_empty_title')}
|
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||||
emptyDescription={t('ai_providers.openai_empty_desc')}
|
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { EmptyState } from '@/components/ui/EmptyState';
|
|||||||
interface ProviderListProps<T> {
|
interface ProviderListProps<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
keyField: (item: T) => string;
|
keyField: (item: T, index: number) => string;
|
||||||
renderContent: (item: T, index: number) => ReactNode;
|
renderContent: (item: T, index: number) => ReactNode;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
@@ -48,7 +48,7 @@ export function ProviderList<T>({
|
|||||||
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={keyField(item)}
|
key={keyField(item, index)}
|
||||||
className="item-row"
|
className="item-row"
|
||||||
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,12 +17,12 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
max-width: inherit;
|
max-width: inherit;
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(59, 130, 246, 0.15);
|
background: rgba($primary-color, 0.16);
|
||||||
box-shadow: inset 0 0 0 2px var(--primary-color);
|
box-shadow: inset 0 0 0 2px var(--primary-color);
|
||||||
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,19 +104,13 @@
|
|||||||
// 暗色主题适配
|
// 暗色主题适配
|
||||||
:global([data-theme='dark']) {
|
:global([data-theme='dark']) {
|
||||||
.navList {
|
.navList {
|
||||||
background: rgba(30, 30, 30, 0.7);
|
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navItem {
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator {
|
.indicator {
|
||||||
background: rgba(59, 130, 246, 0.25);
|
background: rgba($primary-color, 0.28);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { usePageTransitionLayer } from '@/components/common/PageTransition';
|
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
@@ -135,8 +135,9 @@ export function ProviderNav() {
|
|||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
window.addEventListener('resize', handleScroll);
|
window.addEventListener('resize', handleScroll);
|
||||||
handleScroll();
|
const raf = requestAnimationFrame(handleScroll);
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
window.removeEventListener('scroll', handleScroll);
|
window.removeEventListener('scroll', handleScroll);
|
||||||
window.removeEventListener('resize', handleScroll);
|
window.removeEventListener('resize', handleScroll);
|
||||||
contentScroller?.removeEventListener('scroll', handleScroll);
|
contentScroller?.removeEventListener('scroll', handleScroll);
|
||||||
@@ -168,7 +169,8 @@ export function ProviderNav() {
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!shouldShow) return;
|
if (!shouldShow) return;
|
||||||
updateIndicator(activeProvider);
|
const raf = requestAnimationFrame(() => updateIndicator(activeProvider));
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [activeProvider, shouldShow, updateIndicator]);
|
}, [activeProvider, shouldShow, updateIndicator]);
|
||||||
|
|
||||||
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
|
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
|
||||||
|
|||||||
@@ -1,36 +1,143 @@
|
|||||||
import { calculateStatusBarData } from '@/utils/usage';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { StatusBarData, StatusBlockDetail } from '@/utils/usage';
|
||||||
|
import defaultStyles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
|
||||||
interface ProviderStatusBarProps {
|
/**
|
||||||
statusData: ReturnType<typeof calculateStatusBarData>;
|
* 根据成功率 (0–1) 在三个色标之间做 RGB 线性插值
|
||||||
|
* 0 → 红 (#ef4444) → 0.5 → 金黄 (#facc15) → 1 → 绿 (#22c55e)
|
||||||
|
*/
|
||||||
|
const COLOR_STOPS = [
|
||||||
|
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||||
|
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||||
|
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function rateToColor(rate: number): string {
|
||||||
|
const t = Math.max(0, Math.min(1, rate));
|
||||||
|
const segment = t < 0.5 ? 0 : 1;
|
||||||
|
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
|
||||||
|
const from = COLOR_STOPS[segment];
|
||||||
|
const to = COLOR_STOPS[segment + 1];
|
||||||
|
const r = Math.round(from.r + (to.r - from.r) * localT);
|
||||||
|
const g = Math.round(from.g + (to.g - from.g) * localT);
|
||||||
|
const b = Math.round(from.b + (to.b - from.b) * localT);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
|
function formatTime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const h = date.getHours().toString().padStart(2, '0');
|
||||||
|
const m = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${h}:${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StylesModule = Record<string, string>;
|
||||||
|
|
||||||
|
interface ProviderStatusBarProps {
|
||||||
|
statusData: StatusBarData;
|
||||||
|
styles?: StylesModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderStatusBar({ statusData, styles: stylesProp }: ProviderStatusBarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const s = (stylesProp || defaultStyles) as StylesModule;
|
||||||
|
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
|
||||||
|
const blocksRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
const rateClass = !hasData
|
const rateClass = !hasData
|
||||||
? ''
|
? ''
|
||||||
: statusData.successRate >= 90
|
: statusData.successRate >= 90
|
||||||
? styles.statusRateHigh
|
? s.statusRateHigh
|
||||||
: statusData.successRate >= 50
|
: statusData.successRate >= 50
|
||||||
? styles.statusRateMedium
|
? s.statusRateMedium
|
||||||
: styles.statusRateLow;
|
: s.statusRateLow;
|
||||||
|
|
||||||
|
// 点击外部关闭 tooltip(移动端)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTooltip === null) return;
|
||||||
|
const handler = (e: PointerEvent) => {
|
||||||
|
if (blocksRef.current && !blocksRef.current.contains(e.target as Node)) {
|
||||||
|
setActiveTooltip(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', handler);
|
||||||
|
return () => document.removeEventListener('pointerdown', handler);
|
||||||
|
}, [activeTooltip]);
|
||||||
|
|
||||||
|
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
|
||||||
|
if (e.pointerType === 'mouse') {
|
||||||
|
setActiveTooltip(idx);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (e.pointerType === 'mouse') {
|
||||||
|
setActiveTooltip(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
|
||||||
|
if (e.pointerType === 'touch') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTooltip((prev) => (prev === idx ? null : idx));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTooltipPositionClass = (idx: number, total: number): string => {
|
||||||
|
if (idx <= 2) return s.statusTooltipLeft;
|
||||||
|
if (idx >= total - 3) return s.statusTooltipRight;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
|
||||||
|
const total = detail.success + detail.failure;
|
||||||
|
const posClass = getTooltipPositionClass(idx, statusData.blockDetails.length);
|
||||||
|
const timeRange = `${formatTime(detail.startTime)} – ${formatTime(detail.endTime)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${s.statusTooltip} ${posClass}`}>
|
||||||
|
<span className={s.tooltipTime}>{timeRange}</span>
|
||||||
|
{total > 0 ? (
|
||||||
|
<span className={s.tooltipStats}>
|
||||||
|
<span className={s.tooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
|
||||||
|
<span className={s.tooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
|
||||||
|
<span className={s.tooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={s.tooltipStats}>{t('status_bar.no_requests')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.statusBar}>
|
<div className={s.statusBar}>
|
||||||
<div className={styles.statusBlocks}>
|
<div className={s.statusBlocks} ref={blocksRef}>
|
||||||
{statusData.blocks.map((state, idx) => {
|
{statusData.blockDetails.map((detail, idx) => {
|
||||||
const blockClass =
|
const isIdle = detail.rate === -1;
|
||||||
state === 'success'
|
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||||
? styles.statusBlockSuccess
|
const isActive = activeTooltip === idx;
|
||||||
: state === 'failure'
|
|
||||||
? styles.statusBlockFailure
|
return (
|
||||||
: state === 'mixed'
|
<div
|
||||||
? styles.statusBlockMixed
|
key={idx}
|
||||||
: styles.statusBlockIdle;
|
className={`${s.statusBlockWrapper} ${isActive ? s.statusBlockActive : ''}`}
|
||||||
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${s.statusBlock} ${isIdle ? s.statusBlockIdle : ''}`}
|
||||||
|
style={blockStyle}
|
||||||
|
/>
|
||||||
|
{isActive && renderTooltip(detail, idx)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<span className={`${styles.statusRate} ${rateClass}`}>
|
<span className={`${s.statusRate} ${rateClass}`}>
|
||||||
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,6 @@ import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
|
|||||||
import type { HeaderEntry } from '@/utils/headers';
|
import type { HeaderEntry } from '@/utils/headers';
|
||||||
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
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 {
|
export interface ModelEntry {
|
||||||
name: string;
|
name: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -58,12 +50,3 @@ export interface ProviderSectionProps<TConfig> {
|
|||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onToggle?: (index: number, enabled: boolean) => 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const normalizeClaudeBaseUrl = (baseUrl: string): string => {
|
||||||
|
let trimmed = String(baseUrl || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return 'https://api.anthropic.com';
|
||||||
|
}
|
||||||
|
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
|
||||||
|
trimmed = trimmed.replace(/\/+$/g, '');
|
||||||
|
if (!/^https?:\/\//i.test(trimmed)) {
|
||||||
|
trimmed = `http://${trimmed}`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||||
if (!trimmed) return '';
|
if (!trimmed) return '';
|
||||||
@@ -58,6 +71,18 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
|||||||
return `${trimmed}/chat/completions`;
|
return `${trimmed}/chat/completions`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const buildClaudeMessagesEndpoint = (baseUrl: string): string => {
|
||||||
|
const trimmed = normalizeClaudeBaseUrl(baseUrl);
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (trimmed.endsWith('/v1/messages')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (trimmed.endsWith('/v1')) {
|
||||||
|
return `${trimmed}/messages`;
|
||||||
|
}
|
||||||
|
return `${trimmed}/v1/messages`;
|
||||||
|
};
|
||||||
|
|
||||||
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
||||||
export const getStatsBySource = (
|
export const getStatsBySource = (
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
|||||||
|
|
||||||
type ViewMode = 'paged' | 'all';
|
type ViewMode = 'paged' | 'all';
|
||||||
|
|
||||||
const MAX_ITEMS_PER_PAGE = 14;
|
const MAX_ITEMS_PER_PAGE = 25;
|
||||||
const MAX_SHOW_ALL_THRESHOLD = 30;
|
const MAX_SHOW_ALL_THRESHOLD = 30;
|
||||||
|
|
||||||
interface QuotaPaginationState<T> {
|
interface QuotaPaginationState<T> {
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
export { QuotaSection } from './QuotaSection';
|
export { QuotaSection } from './QuotaSection';
|
||||||
export { QuotaCard } from './QuotaCard';
|
export { QuotaCard } from './QuotaCard';
|
||||||
export { useQuotaLoader } from './useQuotaLoader';
|
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';
|
export type { QuotaConfig } from './quotaConfigs';
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import type {
|
|||||||
AntigravityModelsPayload,
|
AntigravityModelsPayload,
|
||||||
AntigravityQuotaState,
|
AntigravityQuotaState,
|
||||||
AuthFileItem,
|
AuthFileItem,
|
||||||
|
ClaudeExtraUsage,
|
||||||
|
ClaudeQuotaState,
|
||||||
|
ClaudeQuotaWindow,
|
||||||
|
ClaudeUsagePayload,
|
||||||
CodexRateLimitInfo,
|
CodexRateLimitInfo,
|
||||||
CodexQuotaState,
|
CodexQuotaState,
|
||||||
CodexUsageWindow,
|
CodexUsageWindow,
|
||||||
@@ -23,16 +27,21 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api
|
|||||||
import {
|
import {
|
||||||
ANTIGRAVITY_QUOTA_URLS,
|
ANTIGRAVITY_QUOTA_URLS,
|
||||||
ANTIGRAVITY_REQUEST_HEADERS,
|
ANTIGRAVITY_REQUEST_HEADERS,
|
||||||
|
CLAUDE_USAGE_URL,
|
||||||
|
CLAUDE_REQUEST_HEADERS,
|
||||||
|
CLAUDE_USAGE_WINDOW_KEYS,
|
||||||
CODEX_USAGE_URL,
|
CODEX_USAGE_URL,
|
||||||
CODEX_REQUEST_HEADERS,
|
CODEX_REQUEST_HEADERS,
|
||||||
GEMINI_CLI_QUOTA_URL,
|
GEMINI_CLI_QUOTA_URL,
|
||||||
GEMINI_CLI_REQUEST_HEADERS,
|
GEMINI_CLI_REQUEST_HEADERS,
|
||||||
normalizeAuthIndexValue,
|
normalizeAuthIndexValue,
|
||||||
|
normalizeGeminiCliModelId,
|
||||||
normalizeNumberValue,
|
normalizeNumberValue,
|
||||||
normalizePlanType,
|
normalizePlanType,
|
||||||
normalizeQuotaFraction,
|
normalizeQuotaFraction,
|
||||||
normalizeStringValue,
|
normalizeStringValue,
|
||||||
parseAntigravityPayload,
|
parseAntigravityPayload,
|
||||||
|
parseClaudeUsagePayload,
|
||||||
parseCodexUsagePayload,
|
parseCodexUsagePayload,
|
||||||
parseGeminiCliQuotaPayload,
|
parseGeminiCliQuotaPayload,
|
||||||
resolveCodexChatgptAccountId,
|
resolveCodexChatgptAccountId,
|
||||||
@@ -45,6 +54,7 @@ import {
|
|||||||
createStatusError,
|
createStatusError,
|
||||||
getStatusFromError,
|
getStatusFromError,
|
||||||
isAntigravityFile,
|
isAntigravityFile,
|
||||||
|
isClaudeFile,
|
||||||
isCodexFile,
|
isCodexFile,
|
||||||
isDisabledAuthFile,
|
isDisabledAuthFile,
|
||||||
isGeminiCliFile,
|
isGeminiCliFile,
|
||||||
@@ -55,15 +65,17 @@ import styles from '@/pages/QuotaPage.module.scss';
|
|||||||
|
|
||||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
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';
|
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||||
|
|
||||||
export interface QuotaStore {
|
export interface QuotaStore {
|
||||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||||
|
claudeQuota: Record<string, ClaudeQuotaState>;
|
||||||
codexQuota: Record<string, CodexQuotaState>;
|
codexQuota: Record<string, CodexQuotaState>;
|
||||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||||
|
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
|
||||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||||
clearQuotaCache: () => void;
|
clearQuotaCache: () => void;
|
||||||
@@ -201,11 +213,14 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
|
|||||||
|
|
||||||
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
||||||
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
||||||
|
const additionalRateLimits = payload.additional_rate_limits ?? payload.additionalRateLimits ?? [];
|
||||||
const windows: CodexQuotaWindow[] = [];
|
const windows: CodexQuotaWindow[] = [];
|
||||||
|
|
||||||
const addWindow = (
|
const addWindow = (
|
||||||
id: string,
|
id: string,
|
||||||
labelKey: string,
|
label: string,
|
||||||
|
labelKey: string | undefined,
|
||||||
|
labelParams: Record<string, string | number> | undefined,
|
||||||
window?: CodexUsageWindow | null,
|
window?: CodexUsageWindow | null,
|
||||||
limitReached?: boolean,
|
limitReached?: boolean,
|
||||||
allowed?: boolean
|
allowed?: boolean
|
||||||
@@ -217,8 +232,9 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
|
|||||||
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
||||||
windows.push({
|
windows.push({
|
||||||
id,
|
id,
|
||||||
label: t(labelKey),
|
label,
|
||||||
labelKey,
|
labelKey,
|
||||||
|
labelParams,
|
||||||
usedPercent,
|
usedPercent,
|
||||||
resetLabel,
|
resetLabel,
|
||||||
});
|
});
|
||||||
@@ -233,12 +249,13 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
|
|||||||
const rawAllowed = rateLimit?.allowed;
|
const rawAllowed = rateLimit?.allowed;
|
||||||
|
|
||||||
const pickClassifiedWindows = (
|
const pickClassifiedWindows = (
|
||||||
limitInfo?: CodexRateLimitInfo | null
|
limitInfo?: CodexRateLimitInfo | null,
|
||||||
|
options?: { allowOrderFallback?: boolean }
|
||||||
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
|
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
|
||||||
const rawWindows = [
|
const allowOrderFallback = options?.allowOrderFallback ?? true;
|
||||||
limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null,
|
const primaryWindow = limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null;
|
||||||
limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null,
|
const secondaryWindow = limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null;
|
||||||
];
|
const rawWindows = [primaryWindow, secondaryWindow];
|
||||||
|
|
||||||
let fiveHourWindow: CodexUsageWindow | null = null;
|
let fiveHourWindow: CodexUsageWindow | null = null;
|
||||||
let weeklyWindow: 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 };
|
return { fiveHourWindow, weeklyWindow };
|
||||||
};
|
};
|
||||||
|
|
||||||
const rateWindows = pickClassifiedWindows(rateLimit);
|
const rateWindows = pickClassifiedWindows(rateLimit);
|
||||||
addWindow(
|
addWindow(
|
||||||
WINDOW_META.codeFiveHour.id,
|
WINDOW_META.codeFiveHour.id,
|
||||||
|
t(WINDOW_META.codeFiveHour.labelKey),
|
||||||
WINDOW_META.codeFiveHour.labelKey,
|
WINDOW_META.codeFiveHour.labelKey,
|
||||||
|
undefined,
|
||||||
rateWindows.fiveHourWindow,
|
rateWindows.fiveHourWindow,
|
||||||
rawLimitReached,
|
rawLimitReached,
|
||||||
rawAllowed
|
rawAllowed
|
||||||
);
|
);
|
||||||
addWindow(
|
addWindow(
|
||||||
WINDOW_META.codeWeekly.id,
|
WINDOW_META.codeWeekly.id,
|
||||||
|
t(WINDOW_META.codeWeekly.labelKey),
|
||||||
WINDOW_META.codeWeekly.labelKey,
|
WINDOW_META.codeWeekly.labelKey,
|
||||||
|
undefined,
|
||||||
rateWindows.weeklyWindow,
|
rateWindows.weeklyWindow,
|
||||||
rawLimitReached,
|
rawLimitReached,
|
||||||
rawAllowed
|
rawAllowed
|
||||||
@@ -277,19 +308,67 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
|
|||||||
const codeReviewAllowed = codeReviewLimit?.allowed;
|
const codeReviewAllowed = codeReviewLimit?.allowed;
|
||||||
addWindow(
|
addWindow(
|
||||||
WINDOW_META.codeReviewFiveHour.id,
|
WINDOW_META.codeReviewFiveHour.id,
|
||||||
|
t(WINDOW_META.codeReviewFiveHour.labelKey),
|
||||||
WINDOW_META.codeReviewFiveHour.labelKey,
|
WINDOW_META.codeReviewFiveHour.labelKey,
|
||||||
|
undefined,
|
||||||
codeReviewWindows.fiveHourWindow,
|
codeReviewWindows.fiveHourWindow,
|
||||||
codeReviewLimitReached,
|
codeReviewLimitReached,
|
||||||
codeReviewAllowed
|
codeReviewAllowed
|
||||||
);
|
);
|
||||||
addWindow(
|
addWindow(
|
||||||
WINDOW_META.codeReviewWeekly.id,
|
WINDOW_META.codeReviewWeekly.id,
|
||||||
|
t(WINDOW_META.codeReviewWeekly.labelKey),
|
||||||
WINDOW_META.codeReviewWeekly.labelKey,
|
WINDOW_META.codeReviewWeekly.labelKey,
|
||||||
|
undefined,
|
||||||
codeReviewWindows.weeklyWindow,
|
codeReviewWindows.weeklyWindow,
|
||||||
codeReviewLimitReached,
|
codeReviewLimitReached,
|
||||||
codeReviewAllowed
|
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;
|
return windows;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -368,7 +447,7 @@ const fetchGeminiCliQuota = async (
|
|||||||
|
|
||||||
const parsedBuckets = buckets
|
const parsedBuckets = buckets
|
||||||
.map((bucket) => {
|
.map((bucket) => {
|
||||||
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
const modelId = normalizeGeminiCliModelId(bucket.modelId ?? bucket.model_id);
|
||||||
if (!modelId) return null;
|
if (!modelId) return null;
|
||||||
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||||
const remainingFractionRaw = normalizeQuotaFraction(
|
const remainingFractionRaw = normalizeQuotaFraction(
|
||||||
@@ -481,7 +560,9 @@ const renderCodexItems = (
|
|||||||
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
|
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 remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
|
||||||
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
|
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(
|
return h(
|
||||||
'div',
|
'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[]> = {
|
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||||
type: 'antigravity',
|
type: 'antigravity',
|
||||||
i18nPrefix: 'antigravity_quota',
|
i18nPrefix: 'antigravity_quota',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface HeaderInputListProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
keyPlaceholder?: string;
|
keyPlaceholder?: string;
|
||||||
valuePlaceholder?: string;
|
valuePlaceholder?: string;
|
||||||
|
removeButtonTitle?: string;
|
||||||
|
removeButtonAriaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderInputList({
|
export function HeaderInputList({
|
||||||
@@ -18,7 +20,9 @@ export function HeaderInputList({
|
|||||||
addLabel,
|
addLabel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
keyPlaceholder = 'X-Custom-Header',
|
keyPlaceholder = 'X-Custom-Header',
|
||||||
valuePlaceholder = 'value'
|
valuePlaceholder = 'value',
|
||||||
|
removeButtonTitle = 'Remove',
|
||||||
|
removeButtonAriaLabel = 'Remove',
|
||||||
}: HeaderInputListProps) {
|
}: HeaderInputListProps) {
|
||||||
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
|
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
|
||||||
|
|
||||||
@@ -61,8 +65,8 @@ export function HeaderInputList({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeEntry(index)}
|
onClick={() => removeEntry(index)}
|
||||||
disabled={disabled || currentEntries.length <= 1}
|
disabled={disabled || currentEntries.length <= 1}
|
||||||
title="Remove"
|
title={removeButtonTitle}
|
||||||
aria-label="Remove"
|
aria-label={removeButtonAriaLabel}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,11 +16,53 @@ const CLOSE_ANIMATION_DURATION = 350;
|
|||||||
const MODAL_LOCK_CLASS = 'modal-open';
|
const MODAL_LOCK_CLASS = 'modal-open';
|
||||||
let activeModalCount = 0;
|
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 = () => {
|
const lockScroll = () => {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
if (activeModalCount === 0) {
|
if (activeModalCount === 0) {
|
||||||
document.body?.classList.add(MODAL_LOCK_CLASS);
|
const body = document.body;
|
||||||
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
|
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;
|
activeModalCount += 1;
|
||||||
};
|
};
|
||||||
@@ -29,8 +71,31 @@ const unlockScroll = () => {
|
|||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
activeModalCount = Math.max(0, activeModalCount - 1);
|
activeModalCount = Math.max(0, activeModalCount - 1);
|
||||||
if (activeModalCount === 0) {
|
if (activeModalCount === 0) {
|
||||||
document.body?.classList.remove(MODAL_LOCK_CLASS);
|
const body = document.body;
|
||||||
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,18 @@ import type { ModelEntry } from './modelInputListUtils';
|
|||||||
interface ModelInputListProps {
|
interface ModelInputListProps {
|
||||||
entries: ModelEntry[];
|
entries: ModelEntry[];
|
||||||
onChange: (entries: ModelEntry[]) => void;
|
onChange: (entries: ModelEntry[]) => void;
|
||||||
addLabel: string;
|
addLabel?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
namePlaceholder?: string;
|
namePlaceholder?: string;
|
||||||
aliasPlaceholder?: string;
|
aliasPlaceholder?: string;
|
||||||
|
hideAddButton?: boolean;
|
||||||
|
onAdd?: () => void;
|
||||||
|
className?: string;
|
||||||
|
rowClassName?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
removeButtonClassName?: string;
|
||||||
|
removeButtonTitle?: string;
|
||||||
|
removeButtonAriaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelInputList({
|
export function ModelInputList({
|
||||||
@@ -18,9 +26,20 @@ export function ModelInputList({
|
|||||||
addLabel,
|
addLabel,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
namePlaceholder = 'model-name',
|
namePlaceholder = 'model-name',
|
||||||
aliasPlaceholder = 'alias (optional)'
|
aliasPlaceholder = 'alias (optional)',
|
||||||
|
hideAddButton = false,
|
||||||
|
onAdd,
|
||||||
|
className = '',
|
||||||
|
rowClassName = '',
|
||||||
|
inputClassName = '',
|
||||||
|
removeButtonClassName = '',
|
||||||
|
removeButtonTitle = 'Remove',
|
||||||
|
removeButtonAriaLabel = 'Remove',
|
||||||
}: ModelInputListProps) {
|
}: ModelInputListProps) {
|
||||||
const currentEntries = entries.length ? entries : [{ name: '', alias: '' }];
|
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 updateEntry = (index: number, field: 'name' | 'alias', value: string) => {
|
||||||
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
|
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
|
||||||
@@ -28,7 +47,11 @@ export function ModelInputList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addEntry = () => {
|
const addEntry = () => {
|
||||||
onChange([...currentEntries, { name: '', alias: '' }]);
|
if (onAdd) {
|
||||||
|
onAdd();
|
||||||
|
} else {
|
||||||
|
onChange([...currentEntries, { name: '', alias: '' }]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEntry = (index: number) => {
|
const removeEntry = (index: number) => {
|
||||||
@@ -37,12 +60,12 @@ export function ModelInputList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="header-input-list">
|
<div className={containerClassName}>
|
||||||
{currentEntries.map((entry, index) => (
|
{currentEntries.map((entry, index) => (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
<div className="header-input-row">
|
<div className={rowClassNames}>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className={inputClassNames}
|
||||||
placeholder={namePlaceholder}
|
placeholder={namePlaceholder}
|
||||||
value={entry.name}
|
value={entry.name}
|
||||||
onChange={(e) => updateEntry(index, 'name', e.target.value)}
|
onChange={(e) => updateEntry(index, 'name', e.target.value)}
|
||||||
@@ -50,7 +73,7 @@ export function ModelInputList({
|
|||||||
/>
|
/>
|
||||||
<span className="header-separator">→</span>
|
<span className="header-separator">→</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className={inputClassNames}
|
||||||
placeholder={aliasPlaceholder}
|
placeholder={aliasPlaceholder}
|
||||||
value={entry.alias}
|
value={entry.alias}
|
||||||
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
|
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
|
||||||
@@ -61,17 +84,20 @@ export function ModelInputList({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeEntry(index)}
|
onClick={() => removeEntry(index)}
|
||||||
disabled={disabled || currentEntries.length <= 1}
|
disabled={disabled || currentEntries.length <= 1}
|
||||||
title="Remove"
|
className={removeButtonClassName}
|
||||||
aria-label="Remove"
|
title={removeButtonTitle}
|
||||||
|
aria-label={removeButtonAriaLabel}
|
||||||
>
|
>
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
{!hideAddButton && addLabel && (
|
||||||
{addLabel}
|
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
||||||
</Button>
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
110
src/components/ui/Select.module.scss
Normal file
110
src/components/ui/Select.module.scss
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
@use '../../styles/mixins' as *;
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapFullWidth {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
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;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-expanded='true'] {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerText {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
[aria-expanded='true'] > & {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
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;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
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;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionActive {
|
||||||
|
border-color: rgba($primary-color, 0.5);
|
||||||
|
background: rgba($primary-color, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
94
src/components/ui/Select.tsx
Normal file
94
src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { IconChevronDown } from './icons';
|
||||||
|
import styles from './Select.module.scss';
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps {
|
||||||
|
value: string;
|
||||||
|
options: ReadonlyArray<SelectOption>;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
ariaLabel,
|
||||||
|
fullWidth = true
|
||||||
|
}: SelectProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || disabled) return;
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [disabled, open]);
|
||||||
|
|
||||||
|
const isOpen = open && !disabled;
|
||||||
|
|
||||||
|
const selected = options.find((o) => o.value === value);
|
||||||
|
const displayText = selected?.label ?? placeholder ?? '';
|
||||||
|
const isPlaceholder = !selected && placeholder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
|
||||||
|
ref={wrapRef}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.trigger}
|
||||||
|
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
<span className={styles.triggerIcon} aria-hidden="true">
|
||||||
|
<IconChevronDown size={14} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className={styles.dropdown} role="listbox" aria-label={ariaLabel}>
|
||||||
|
{options.map((opt) => {
|
||||||
|
const active = opt.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
className={`${styles.option} ${active ? styles.optionActive : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(opt.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
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';
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
export interface ApiDetailsCardProps {
|
export interface ApiDetailsCardProps {
|
||||||
@@ -10,9 +10,14 @@ export interface ApiDetailsCardProps {
|
|||||||
hasPrices: boolean;
|
hasPrices: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||||
|
const [sortKey, setSortKey] = useState<ApiSortKey>('requests');
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
|
|
||||||
const toggleExpand = (endpoint: string) => {
|
const toggleExpand = (endpoint: string) => {
|
||||||
setExpandedApis((prev) => {
|
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 (
|
return (
|
||||||
<Card title={t('usage_stats.api_details')}>
|
<Card title={t('usage_stats.api_details')} className={styles.detailsFixedCard}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={styles.hint}>{t('common.loading')}</div>
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
) : apiStats.length > 0 ? (
|
) : sorted.length > 0 ? (
|
||||||
<div className={styles.apiList}>
|
<>
|
||||||
{apiStats.map((api) => (
|
<div className={styles.apiSortBar}>
|
||||||
<div key={api.endpoint} className={styles.apiItem}>
|
{([
|
||||||
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
|
['endpoint', 'usage_stats.api_endpoint'],
|
||||||
<div className={styles.apiInfo}>
|
['requests', 'usage_stats.requests_count'],
|
||||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
['tokens', 'usage_stats.tokens_count'],
|
||||||
<div className={styles.apiStats}>
|
...(hasPrices ? [['cost', 'usage_stats.total_cost']] : []),
|
||||||
<span className={styles.apiBadge}>
|
] as [ApiSortKey, string][]).map(([key, labelKey]) => (
|
||||||
<span className={styles.requestCountCell}>
|
<button
|
||||||
<span>
|
key={key}
|
||||||
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
|
type="button"
|
||||||
</span>
|
aria-pressed={sortKey === key}
|
||||||
<span className={styles.requestBreakdown}>
|
className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
|
||||||
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
|
onClick={() => handleSort(key)}
|
||||||
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
|
>
|
||||||
</span>
|
{t(labelKey)}{arrow(key)}
|
||||||
</span>
|
</button>
|
||||||
</span>
|
))}
|
||||||
<span className={styles.apiBadge}>
|
</div>
|
||||||
{t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)}
|
<div className={styles.detailsScroll}>
|
||||||
</span>
|
<div className={styles.apiList}>
|
||||||
{hasPrices && api.totalCost > 0 && (
|
{sorted.map((api, index) => {
|
||||||
<span className={styles.apiBadge}>
|
const isExpanded = expandedApis.has(api.endpoint);
|
||||||
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
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>
|
</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>
|
||||||
</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>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
import styles from '@/pages/UsagePage.module.scss';
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
export interface ChartLineSelectorProps {
|
export interface ChartLineSelectorProps {
|
||||||
@@ -41,6 +43,14 @@ export function ChartLineSelector({
|
|||||||
onChange(newLines);
|
onChange(newLines);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'all', label: t('usage_stats.chart_line_all') },
|
||||||
|
...modelNames.map((name) => ({ value: name, label: name }))
|
||||||
|
],
|
||||||
|
[modelNames, t]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={t('usage_stats.chart_line_actions_label')}
|
title={t('usage_stats.chart_line_actions_label')}
|
||||||
@@ -66,18 +76,11 @@ export function ChartLineSelector({
|
|||||||
<span className={styles.chartLineLabel}>
|
<span className={styles.chartLineLabel}>
|
||||||
{t(`usage_stats.chart_line_label_${index + 1}`)}
|
{t(`usage_stats.chart_line_label_${index + 1}`)}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<Select
|
||||||
value={line}
|
value={line}
|
||||||
onChange={(e) => handleChange(index, e.target.value)}
|
options={options}
|
||||||
className={styles.select}
|
onChange={(value) => handleChange(index, value)}
|
||||||
>
|
/>
|
||||||
<option value="all">{t('usage_stats.chart_line_all')}</option>
|
|
||||||
{modelNames.map((name) => (
|
|
||||||
<option key={name} value={name}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{chartLines.length > 1 && (
|
{chartLines.length > 1 && (
|
||||||
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
|
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
|
||||||
{t('usage_stats.chart_line_delete')}
|
{t('usage_stats.chart_line_delete')}
|
||||||
|
|||||||
144
src/components/usage/CostTrendChart.tsx
Normal file
144
src/components/usage/CostTrendChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
src/components/usage/CredentialStatsCard.tsx
Normal file
336
src/components/usage/CredentialStatsCard.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
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')} className={styles.detailsFixedCard}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : rows.length > 0 ? (
|
||||||
|
<div className={styles.detailsScroll}>
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
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';
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
export interface ModelStat {
|
export interface ModelStat {
|
||||||
@@ -18,43 +19,137 @@ export interface ModelStatsCardProps {
|
|||||||
hasPrices: boolean;
|
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) {
|
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<Card title={t('usage_stats.models')}>
|
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={styles.hint}>{t('common.loading')}</div>
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
) : modelStats.length > 0 ? (
|
) : sorted.length > 0 ? (
|
||||||
<div className={styles.tableWrapper}>
|
<div className={styles.detailsScroll}>
|
||||||
<table className={styles.table}>
|
<div className={styles.tableWrapper}>
|
||||||
<thead>
|
<table className={styles.table}>
|
||||||
<tr>
|
<thead>
|
||||||
<th>{t('usage_stats.model_name')}</th>
|
<tr>
|
||||||
<th>{t('usage_stats.requests_count')}</th>
|
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
|
||||||
<th>{t('usage_stats.tokens_count')}</th>
|
<button
|
||||||
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
|
type="button"
|
||||||
</tr>
|
className={styles.sortHeaderButton}
|
||||||
</thead>
|
onClick={() => handleSort('model')}
|
||||||
<tbody>
|
>
|
||||||
{modelStats.map((stat) => (
|
{t('usage_stats.model_name')}{arrow('model')}
|
||||||
<tr key={stat.model}>
|
</button>
|
||||||
<td className={styles.modelCell}>{stat.model}</td>
|
</th>
|
||||||
<td>
|
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
|
||||||
<span className={styles.requestCountCell}>
|
<button
|
||||||
<span>{stat.requests.toLocaleString()}</span>
|
type="button"
|
||||||
<span className={styles.requestBreakdown}>
|
className={styles.sortHeaderButton}
|
||||||
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
onClick={() => handleSort('requests')}
|
||||||
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
>
|
||||||
</span>
|
{t('usage_stats.requests_count')}{arrow('requests')}
|
||||||
</span>
|
</button>
|
||||||
</td>
|
</th>
|
||||||
<td>{formatTokensInMillions(stat.tokens)}</td>
|
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
|
||||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
<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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
import type { ModelPrice } from '@/utils/usage';
|
import type { ModelPrice } from '@/utils/usage';
|
||||||
import styles from '@/pages/UsagePage.module.scss';
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
@@ -19,11 +21,18 @@ export function PriceSettingsCard({
|
|||||||
}: PriceSettingsCardProps) {
|
}: PriceSettingsCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Add form state
|
||||||
const [selectedModel, setSelectedModel] = useState('');
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
const [promptPrice, setPromptPrice] = useState('');
|
const [promptPrice, setPromptPrice] = useState('');
|
||||||
const [completionPrice, setCompletionPrice] = useState('');
|
const [completionPrice, setCompletionPrice] = useState('');
|
||||||
const [cachePrice, setCachePrice] = useState('');
|
const [cachePrice, setCachePrice] = useState('');
|
||||||
|
|
||||||
|
// Edit modal state
|
||||||
|
const [editModel, setEditModel] = useState<string | null>(null);
|
||||||
|
const [editPrompt, setEditPrompt] = useState('');
|
||||||
|
const [editCompletion, setEditCompletion] = useState('');
|
||||||
|
const [editCache, setEditCache] = useState('');
|
||||||
|
|
||||||
const handleSavePrice = () => {
|
const handleSavePrice = () => {
|
||||||
if (!selectedModel) return;
|
if (!selectedModel) return;
|
||||||
const prompt = parseFloat(promptPrice) || 0;
|
const prompt = parseFloat(promptPrice) || 0;
|
||||||
@@ -43,12 +52,22 @@ export function PriceSettingsCard({
|
|||||||
onPricesChange(newPrices);
|
onPricesChange(newPrices);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditPrice = (model: string) => {
|
const handleOpenEdit = (model: string) => {
|
||||||
const price = modelPrices[model];
|
const price = modelPrices[model];
|
||||||
setSelectedModel(model);
|
setEditModel(model);
|
||||||
setPromptPrice(price?.prompt?.toString() || '');
|
setEditPrompt(price?.prompt?.toString() || '');
|
||||||
setCompletionPrice(price?.completion?.toString() || '');
|
setEditCompletion(price?.completion?.toString() || '');
|
||||||
setCachePrice(price?.cache?.toString() || '');
|
setEditCache(price?.cache?.toString() || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
if (!editModel) return;
|
||||||
|
const prompt = parseFloat(editPrompt) || 0;
|
||||||
|
const completion = parseFloat(editCompletion) || 0;
|
||||||
|
const cache = editCache.trim() === '' ? prompt : parseFloat(editCache) || 0;
|
||||||
|
const newPrices = { ...modelPrices, [editModel]: { prompt, completion, cache } };
|
||||||
|
onPricesChange(newPrices);
|
||||||
|
setEditModel(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModelSelect = (value: string) => {
|
const handleModelSelect = (value: string) => {
|
||||||
@@ -65,6 +84,14 @@ export function PriceSettingsCard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: '', label: t('usage_stats.model_price_select_placeholder') },
|
||||||
|
...modelNames.map((name) => ({ value: name, label: name }))
|
||||||
|
],
|
||||||
|
[modelNames, t]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={t('usage_stats.model_price_settings')}>
|
<Card title={t('usage_stats.model_price_settings')}>
|
||||||
<div className={styles.pricingSection}>
|
<div className={styles.pricingSection}>
|
||||||
@@ -73,18 +100,12 @@ export function PriceSettingsCard({
|
|||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formField}>
|
<div className={styles.formField}>
|
||||||
<label>{t('usage_stats.model_name')}</label>
|
<label>{t('usage_stats.model_name')}</label>
|
||||||
<select
|
<Select
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
onChange={(e) => handleModelSelect(e.target.value)}
|
options={options}
|
||||||
className={styles.select}
|
onChange={handleModelSelect}
|
||||||
>
|
placeholder={t('usage_stats.model_price_select_placeholder')}
|
||||||
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
|
/>
|
||||||
{modelNames.map((name) => (
|
|
||||||
<option key={name} value={name}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formField}>
|
<div className={styles.formField}>
|
||||||
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||||
@@ -144,7 +165,7 @@ export function PriceSettingsCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.priceActions}>
|
<div className={styles.priceActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
|
<Button variant="secondary" size="sm" onClick={() => handleOpenEdit(model)}>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
|
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
|
||||||
@@ -159,6 +180,57 @@ export function PriceSettingsCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
open={editModel !== null}
|
||||||
|
title={editModel ?? ''}
|
||||||
|
onClose={() => setEditModel(null)}
|
||||||
|
footer={
|
||||||
|
<div className={styles.priceActions}>
|
||||||
|
<Button variant="secondary" onClick={() => setEditModel(null)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={handleSaveEdit}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
<div className={styles.editModalBody}>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editPrompt}
|
||||||
|
onChange={(e) => setEditPrompt(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editCompletion}
|
||||||
|
onChange={(e) => setEditCompletion(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editCache}
|
||||||
|
onChange={(e) => setEditCache(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
180
src/components/usage/ServiceHealthCard.tsx
Normal file
180
src/components/usage/ServiceHealthCard.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
collectUsageDetails,
|
||||||
|
calculateServiceHealthData,
|
||||||
|
type ServiceHealthData,
|
||||||
|
type StatusBlockDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import type { UsagePayload } from './hooks/useUsageData';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
const COLOR_STOPS = [
|
||||||
|
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||||
|
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||||
|
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function rateToColor(rate: number): string {
|
||||||
|
const t = Math.max(0, Math.min(1, rate));
|
||||||
|
const segment = t < 0.5 ? 0 : 1;
|
||||||
|
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
|
||||||
|
const from = COLOR_STOPS[segment];
|
||||||
|
const to = COLOR_STOPS[segment + 1];
|
||||||
|
const r = Math.round(from.r + (to.r - from.r) * localT);
|
||||||
|
const g = Math.round(from.g + (to.g - from.g) * localT);
|
||||||
|
const b = Math.round(from.b + (to.b - from.b) * localT);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const day = date.getDate().toString().padStart(2, '0');
|
||||||
|
const h = date.getHours().toString().padStart(2, '0');
|
||||||
|
const m = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${month}/${day} ${h}:${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceHealthCardProps {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const healthData: ServiceHealthData = useMemo(() => {
|
||||||
|
const details = usage ? collectUsageDetails(usage) : [];
|
||||||
|
return calculateServiceHealthData(details);
|
||||||
|
}, [usage]);
|
||||||
|
|
||||||
|
const hasData = healthData.totalSuccess + healthData.totalFailure > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTooltip === null) return;
|
||||||
|
const handler = (e: PointerEvent) => {
|
||||||
|
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
|
||||||
|
setActiveTooltip(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', handler);
|
||||||
|
return () => document.removeEventListener('pointerdown', handler);
|
||||||
|
}, [activeTooltip]);
|
||||||
|
|
||||||
|
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
|
||||||
|
if (e.pointerType === 'mouse') {
|
||||||
|
setActiveTooltip(idx);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (e.pointerType === 'mouse') {
|
||||||
|
setActiveTooltip(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
|
||||||
|
if (e.pointerType === 'touch') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveTooltip((prev) => (prev === idx ? null : idx));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTooltipPositionClass = (idx: number): string => {
|
||||||
|
const col = Math.floor(idx / healthData.rows);
|
||||||
|
if (col <= 2) return styles.healthTooltipLeft;
|
||||||
|
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTooltipVerticalClass = (idx: number): string => {
|
||||||
|
const row = idx % healthData.rows;
|
||||||
|
if (row <= 1) return styles.healthTooltipBelow;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
|
||||||
|
const total = detail.success + detail.failure;
|
||||||
|
const posClass = getTooltipPositionClass(idx);
|
||||||
|
const vertClass = getTooltipVerticalClass(idx);
|
||||||
|
const timeRange = `${formatDateTime(detail.startTime)} – ${formatDateTime(detail.endTime)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
|
||||||
|
<span className={styles.healthTooltipTime}>{timeRange}</span>
|
||||||
|
{total > 0 ? (
|
||||||
|
<span className={styles.healthTooltipStats}>
|
||||||
|
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
|
||||||
|
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
|
||||||
|
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={styles.healthTooltipStats}>{t('status_bar.no_requests')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateClass = !hasData
|
||||||
|
? ''
|
||||||
|
: healthData.successRate >= 90
|
||||||
|
? styles.healthRateHigh
|
||||||
|
: healthData.successRate >= 50
|
||||||
|
? styles.healthRateMedium
|
||||||
|
: styles.healthRateLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.healthCard}>
|
||||||
|
<div className={styles.healthHeader}>
|
||||||
|
<h3 className={styles.healthTitle}>{t('service_health.title')}</h3>
|
||||||
|
<div className={styles.healthMeta}>
|
||||||
|
<span className={styles.healthWindow}>{t('service_health.window')}</span>
|
||||||
|
<span className={`${styles.healthRate} ${rateClass}`}>
|
||||||
|
{loading ? '--' : hasData ? `${healthData.successRate.toFixed(1)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.healthGridScroller}>
|
||||||
|
<div
|
||||||
|
className={styles.healthGrid}
|
||||||
|
ref={gridRef}
|
||||||
|
>
|
||||||
|
{healthData.blockDetails.map((detail, idx) => {
|
||||||
|
const isIdle = detail.rate === -1;
|
||||||
|
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||||
|
const isActive = activeTooltip === idx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||||
|
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||||
|
style={blockStyle}
|
||||||
|
/>
|
||||||
|
{isActive && renderTooltip(detail, idx)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.healthLegend}>
|
||||||
|
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
|
||||||
|
<div className={styles.healthLegendColors}>
|
||||||
|
<div className={`${styles.healthLegendBlock} ${styles.healthBlockIdle}`} />
|
||||||
|
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#ef4444' }} />
|
||||||
|
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#facc15' }} />
|
||||||
|
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#22c55e' }} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.healthLegendLabel}>{t('service_health.newest')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||||
import {
|
import {
|
||||||
formatTokensInMillions,
|
formatCompactNumber,
|
||||||
formatPerMinuteValue,
|
formatPerMinuteValue,
|
||||||
formatUsd,
|
formatUsd,
|
||||||
calculateTokenBreakdown,
|
calculateTokenBreakdown,
|
||||||
@@ -56,9 +56,9 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
|||||||
key: 'requests',
|
key: 'requests',
|
||||||
label: t('usage_stats.total_requests'),
|
label: t('usage_stats.total_requests'),
|
||||||
icon: <IconSatellite size={16} />,
|
icon: <IconSatellite size={16} />,
|
||||||
accent: '#3b82f6',
|
accent: '#8b8680',
|
||||||
accentSoft: 'rgba(59, 130, 246, 0.18)',
|
accentSoft: 'rgba(139, 134, 128, 0.18)',
|
||||||
accentBorder: 'rgba(59, 130, 246, 0.35)',
|
accentBorder: 'rgba(139, 134, 128, 0.35)',
|
||||||
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
|
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
|
||||||
meta: (
|
meta: (
|
||||||
<>
|
<>
|
||||||
@@ -67,7 +67,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
|||||||
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.statMetaItem}>
|
<span className={styles.statMetaItem}>
|
||||||
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
|
<span className={styles.statMetaDot} style={{ backgroundColor: '#c65746' }} />
|
||||||
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -81,14 +81,14 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
|||||||
accent: '#8b5cf6',
|
accent: '#8b5cf6',
|
||||||
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
||||||
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
||||||
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
|
value: loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0),
|
||||||
meta: (
|
meta: (
|
||||||
<>
|
<>
|
||||||
<span className={styles.statMetaItem}>
|
<span className={styles.statMetaItem}>
|
||||||
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.statMetaItem}>
|
<span className={styles.statMetaItem}>
|
||||||
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -119,7 +119,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
|||||||
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||||
meta: (
|
meta: (
|
||||||
<span className={styles.statMetaItem}>
|
<span className={styles.statMetaItem}>
|
||||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
|
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
trend: sparklines.tpm
|
trend: sparklines.tpm
|
||||||
@@ -135,7 +135,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
|||||||
meta: (
|
meta: (
|
||||||
<>
|
<>
|
||||||
<span className={styles.statMetaItem}>
|
<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>
|
</span>
|
||||||
{!hasPrices && (
|
{!hasPrices && (
|
||||||
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||||
|
|||||||
145
src/components/usage/TokenBreakdownChart.tsx
Normal file
145
src/components/usage/TokenBreakdownChart.tsx
Normal 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: '#8b8680', bg: 'rgba(139, 134, 128, 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface UseChartDataOptions {
|
|||||||
chartLines: string[];
|
chartLines: string[];
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
hourWindowHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseChartDataReturn {
|
export interface UseChartDataReturn {
|
||||||
@@ -26,20 +27,21 @@ export function useChartData({
|
|||||||
usage,
|
usage,
|
||||||
chartLines,
|
chartLines,
|
||||||
isDark,
|
isDark,
|
||||||
isMobile
|
isMobile,
|
||||||
|
hourWindowHours
|
||||||
}: UseChartDataOptions): UseChartDataReturn {
|
}: UseChartDataOptions): UseChartDataReturn {
|
||||||
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
||||||
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
||||||
|
|
||||||
const requestsChartData = useMemo(() => {
|
const requestsChartData = useMemo(() => {
|
||||||
if (!usage) return { labels: [], datasets: [] };
|
if (!usage) return { labels: [], datasets: [] };
|
||||||
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
|
return buildChartData(usage, requestsPeriod, 'requests', chartLines, { hourWindowHours });
|
||||||
}, [usage, requestsPeriod, chartLines]);
|
}, [usage, requestsPeriod, chartLines, hourWindowHours]);
|
||||||
|
|
||||||
const tokensChartData = useMemo(() => {
|
const tokensChartData = useMemo(() => {
|
||||||
if (!usage) return { labels: [], datasets: [] };
|
if (!usage) return { labels: [], datasets: [] };
|
||||||
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
return buildChartData(usage, tokensPeriod, 'tokens', chartLines, { hourWindowHours });
|
||||||
}, [usage, tokensPeriod, chartLines]);
|
}, [usage, tokensPeriod, chartLines, hourWindowHours]);
|
||||||
|
|
||||||
const requestsChartOptions = useMemo(
|
const requestsChartOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSpar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const requestsSparkline = useMemo(
|
const requestsSparkline = useMemo(
|
||||||
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
|
() => buildSparkline(buildLastHourSeries('requests'), '#8b8680', 'rgba(139, 134, 128, 0.18)'),
|
||||||
[buildLastHourSeries, buildSparkline]
|
[buildLastHourSeries, buildSparkline]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface UseUsageDataReturn {
|
|||||||
usage: UsagePayload | null;
|
usage: UsagePayload | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
lastRefreshedAt: Date | null;
|
||||||
modelPrices: Record<string, ModelPrice>;
|
modelPrices: Record<string, ModelPrice>;
|
||||||
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
||||||
loadUsage: () => Promise<void>;
|
loadUsage: () => Promise<void>;
|
||||||
@@ -38,6 +39,7 @@ export function useUsageData(): UseUsageDataReturn {
|
|||||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
|
||||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const loadUsage = useCallback(async () => {
|
const loadUsage = useCallback(async () => {
|
||||||
@@ -45,8 +47,9 @@ export function useUsageData(): UseUsageDataReturn {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await usageApi.getUsage();
|
const data = await usageApi.getUsage();
|
||||||
const payload = data?.usage ?? data;
|
const payload = (data?.usage ?? data) as unknown;
|
||||||
setUsage(payload);
|
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
|
||||||
|
setLastRefreshedAt(new Date());
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||||
setError(message);
|
setError(message);
|
||||||
@@ -140,6 +143,7 @@ export function useUsageData(): UseUsageDataReturn {
|
|||||||
usage,
|
usage,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
lastRefreshedAt,
|
||||||
modelPrices,
|
modelPrices,
|
||||||
setModelPrices: handleSetModelPrices,
|
setModelPrices: handleSetModelPrices,
|
||||||
loadUsage,
|
loadUsage,
|
||||||
|
|||||||
@@ -26,3 +26,15 @@ export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
|
|||||||
|
|
||||||
export { PriceSettingsCard } from './PriceSettingsCard';
|
export { PriceSettingsCard } from './PriceSettingsCard';
|
||||||
export type { PriceSettingsCardProps } 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';
|
||||||
|
|
||||||
|
export { ServiceHealthCard } from './ServiceHealthCard';
|
||||||
|
export type { ServiceHealthCardProps } from './ServiceHealthCard';
|
||||||
|
|||||||
232
src/features/authFiles/components/AuthFileCard.tsx
Normal file
232
src/features/authFiles/components/AuthFileCard.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { IconBot, IconCheck, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
|
||||||
|
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { resolveAuthProvider } from '@/utils/quota';
|
||||||
|
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||||
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
QUOTA_PROVIDER_TYPES,
|
||||||
|
formatModified,
|
||||||
|
getTypeColor,
|
||||||
|
getTypeLabel,
|
||||||
|
isRuntimeOnlyAuthFile,
|
||||||
|
normalizeAuthIndexValue,
|
||||||
|
resolveAuthFileStats,
|
||||||
|
type QuotaProviderType,
|
||||||
|
type ResolvedTheme
|
||||||
|
} from '@/features/authFiles/constants';
|
||||||
|
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
|
||||||
|
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFileCardProps = {
|
||||||
|
file: AuthFileItem;
|
||||||
|
selected: boolean;
|
||||||
|
resolvedTheme: ResolvedTheme;
|
||||||
|
disableControls: boolean;
|
||||||
|
deleting: string | null;
|
||||||
|
statusUpdating: Record<string, boolean>;
|
||||||
|
quotaFilterType: QuotaProviderType | null;
|
||||||
|
keyStats: KeyStats;
|
||||||
|
statusBarCache: Map<string, AuthFileStatusBarData>;
|
||||||
|
onShowModels: (file: AuthFileItem) => void;
|
||||||
|
onShowDetails: (file: AuthFileItem) => void;
|
||||||
|
onDownload: (name: string) => void;
|
||||||
|
onOpenPrefixProxyEditor: (name: string) => void;
|
||||||
|
onDelete: (name: string) => void;
|
||||||
|
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
|
||||||
|
onToggleSelect: (name: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
|
||||||
|
const provider = resolveAuthProvider(file);
|
||||||
|
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
|
||||||
|
return provider as QuotaProviderType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileCard(props: AuthFileCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
file,
|
||||||
|
selected,
|
||||||
|
resolvedTheme,
|
||||||
|
disableControls,
|
||||||
|
deleting,
|
||||||
|
statusUpdating,
|
||||||
|
quotaFilterType,
|
||||||
|
keyStats,
|
||||||
|
statusBarCache,
|
||||||
|
onShowModels,
|
||||||
|
onShowDetails,
|
||||||
|
onDownload,
|
||||||
|
onOpenPrefixProxyEditor,
|
||||||
|
onDelete,
|
||||||
|
onToggleStatus,
|
||||||
|
onToggleSelect
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const fileStats = resolveAuthFileStats(file, keyStats);
|
||||||
|
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
|
||||||
|
const isAistudio = (file.type || '').toLowerCase() === 'aistudio';
|
||||||
|
const showModelsButton = !isRuntimeOnly || isAistudio;
|
||||||
|
const typeColor = getTypeColor(file.type || 'unknown', resolvedTheme);
|
||||||
|
|
||||||
|
const quotaType =
|
||||||
|
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
|
||||||
|
|
||||||
|
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
|
||||||
|
|
||||||
|
const providerCardClass =
|
||||||
|
quotaType === 'antigravity'
|
||||||
|
? styles.antigravityCard
|
||||||
|
: quotaType === 'codex'
|
||||||
|
? styles.codexCard
|
||||||
|
: quotaType === 'gemini-cli'
|
||||||
|
? styles.geminiCliCard
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
const statusData =
|
||||||
|
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.fileCard} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
|
||||||
|
>
|
||||||
|
<div className={styles.fileCardLayout}>
|
||||||
|
<div className={styles.fileCardMain}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.selectionToggle} ${selected ? styles.selectionToggleActive : ''}`}
|
||||||
|
onClick={() => onToggleSelect(file.name)}
|
||||||
|
aria-label={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
|
||||||
|
aria-pressed={selected}
|
||||||
|
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
|
||||||
|
>
|
||||||
|
{selected && <IconCheck size={12} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={styles.typeBadge}
|
||||||
|
style={{
|
||||||
|
backgroundColor: typeColor.bg,
|
||||||
|
color: typeColor.text,
|
||||||
|
...(typeColor.border ? { border: typeColor.border } : {})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getTypeLabel(t, file.type || 'unknown')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.fileName}>{file.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardMeta}>
|
||||||
|
<span>
|
||||||
|
{t('auth_files.file_size')}: {file.size ? formatFileSize(file.size) : '-'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('auth_files.file_modified')}: {formatModified(file)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {fileStats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {fileStats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProviderStatusBar statusData={statusData} styles={styles} />
|
||||||
|
|
||||||
|
{showQuotaLayout && quotaType && (
|
||||||
|
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.cardActions}>
|
||||||
|
{showModelsButton && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onShowModels(file)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.models_button', { defaultValue: '模型' })}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconBot className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onShowDetails(file)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('common.info', { defaultValue: '关于' })}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconInfo className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDownload(file.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.download_button')}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconDownload className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenPrefixProxyEditor(file.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.prefix_proxy_button')}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
<IconCode className={styles.actionIcon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(file.name)}
|
||||||
|
className={styles.iconButton}
|
||||||
|
title={t('auth_files.delete_button')}
|
||||||
|
disabled={disableControls || deleting === file.name}
|
||||||
|
>
|
||||||
|
{deleting === file.name ? (
|
||||||
|
<LoadingSpinner size={14} />
|
||||||
|
) : (
|
||||||
|
<IconTrash2 className={styles.actionIcon} size={16} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<div className={styles.statusToggle}>
|
||||||
|
<ToggleSwitch
|
||||||
|
ariaLabel={t('auth_files.status_toggle_label')}
|
||||||
|
checked={!file.disabled}
|
||||||
|
disabled={disableControls || statusUpdating[file.name] === true}
|
||||||
|
onChange={(value) => onToggleStatus(file, value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isRuntimeOnly && (
|
||||||
|
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/features/authFiles/components/AuthFileDetailModal.tsx
Normal file
47
src/features/authFiles/components/AuthFileDetailModal.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFileDetailModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
file: AuthFileItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onCopyText: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileDetailModal({ open, file, onClose, onCopyText }: AuthFileDetailModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={file?.name || t('auth_files.title_section')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!file) return;
|
||||||
|
const text = JSON.stringify(file, null, 2);
|
||||||
|
onCopyText(text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.copy')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{file && (
|
||||||
|
<div className={styles.detailContent}>
|
||||||
|
<pre className={styles.jsonContent}>{JSON.stringify(file, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
91
src/features/authFiles/components/AuthFileModelsModal.tsx
Normal file
91
src/features/authFiles/components/AuthFileModelsModal.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
import { isModelExcluded } from '@/features/authFiles/constants';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFileModelsModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
fileName: string;
|
||||||
|
fileType: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: 'unsupported' | null;
|
||||||
|
models: AuthFileModelItem[];
|
||||||
|
excluded: Record<string, string[]>;
|
||||||
|
onClose: () => void;
|
||||||
|
onCopyText: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileModelsModal(props: AuthFileModelsModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { open, fileName, fileType, loading, error, models, excluded, onClose, onCopyText } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${fileName}`}
|
||||||
|
footer={
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{t('common.close')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>
|
||||||
|
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
|
||||||
|
</div>
|
||||||
|
) : error === 'unsupported' ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
|
||||||
|
description={t('auth_files.models_unsupported_desc', {
|
||||||
|
defaultValue: '请更新 CLI Proxy API 到最新版本后重试'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
|
||||||
|
description={t('auth_files.models_empty_desc', {
|
||||||
|
defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型'
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.modelsList}>
|
||||||
|
{models.map((model) => {
|
||||||
|
const excludedModel = isModelExcluded(model.id, fileType, excluded);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className={`${styles.modelItem} ${excludedModel ? styles.modelItemExcluded : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
onCopyText(model.id);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
excludedModel
|
||||||
|
? t('auth_files.models_excluded_hint', {
|
||||||
|
defaultValue: '此 OAuth 模型已被禁用'
|
||||||
|
})
|
||||||
|
: t('common.copy', { defaultValue: '点击复制' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={styles.modelId}>{model.id}</span>
|
||||||
|
{model.display_name && model.display_name !== model.id && (
|
||||||
|
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||||
|
)}
|
||||||
|
{model.type && <span className={styles.modelType}>{model.type}</span>}
|
||||||
|
{excludedModel && (
|
||||||
|
<span className={styles.modelExcludedBadge}>
|
||||||
|
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
124
src/features/authFiles/components/AuthFileQuotaSection.tsx
Normal file
124
src/features/authFiles/components/AuthFileQuotaSection.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
|
||||||
|
import { useNotificationStore, useQuotaStore } from '@/stores';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { getStatusFromError } from '@/utils/quota';
|
||||||
|
import {
|
||||||
|
isRuntimeOnlyAuthFile,
|
||||||
|
resolveQuotaErrorMessage,
|
||||||
|
type QuotaProviderType
|
||||||
|
} from '@/features/authFiles/constants';
|
||||||
|
import { QuotaProgressBar } from '@/features/authFiles/components/QuotaProgressBar';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaState = { status?: string; error?: string; errorStatus?: number } | undefined;
|
||||||
|
|
||||||
|
const getQuotaConfig = (type: QuotaProviderType) => {
|
||||||
|
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
|
||||||
|
if (type === 'codex') return CODEX_CONFIG;
|
||||||
|
return GEMINI_CLI_CONFIG;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthFileQuotaSectionProps = {
|
||||||
|
file: AuthFileItem;
|
||||||
|
quotaType: QuotaProviderType;
|
||||||
|
disableControls: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
|
||||||
|
const { file, quotaType, disableControls } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||||
|
|
||||||
|
const quota = useQuotaStore((state) => {
|
||||||
|
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
|
||||||
|
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
|
||||||
|
return state.geminiCliQuota[file.name] as QuotaState;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateQuotaState = useQuotaStore((state) => {
|
||||||
|
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
|
||||||
|
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
|
||||||
|
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshQuotaForFile = useCallback(async () => {
|
||||||
|
if (disableControls) return;
|
||||||
|
if (isRuntimeOnlyAuthFile(file)) return;
|
||||||
|
if (file.disabled) return;
|
||||||
|
if (quota?.status === 'loading') return;
|
||||||
|
|
||||||
|
const config = getQuotaConfig(quotaType) as unknown as {
|
||||||
|
i18nPrefix: string;
|
||||||
|
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
|
||||||
|
buildLoadingState: () => unknown;
|
||||||
|
buildSuccessState: (data: unknown) => unknown;
|
||||||
|
buildErrorState: (message: string, status?: number) => unknown;
|
||||||
|
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateQuotaState((prev: Record<string, unknown>) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildLoadingState()
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await config.fetchQuota(file, t);
|
||||||
|
updateQuotaState((prev: Record<string, unknown>) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildSuccessState(data)
|
||||||
|
}));
|
||||||
|
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const status = getStatusFromError(err);
|
||||||
|
updateQuotaState((prev: Record<string, unknown>) => ({
|
||||||
|
...prev,
|
||||||
|
[file.name]: config.buildErrorState(message, status)
|
||||||
|
}));
|
||||||
|
showNotification(t('auth_files.quota_refresh_failed', { name: file.name, message }), 'error');
|
||||||
|
}
|
||||||
|
}, [disableControls, file, quota?.status, quotaType, showNotification, t, updateQuotaState]);
|
||||||
|
|
||||||
|
const config = getQuotaConfig(quotaType) as unknown as {
|
||||||
|
i18nPrefix: string;
|
||||||
|
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quotaStatus = quota?.status ?? 'idle';
|
||||||
|
const canRefreshQuota = !disableControls && !file.disabled;
|
||||||
|
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||||
|
t,
|
||||||
|
quota?.errorStatus,
|
||||||
|
quota?.error || t('common.unknown_error')
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.quotaSection}>
|
||||||
|
{quotaStatus === 'loading' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
|
||||||
|
) : quotaStatus === 'idle' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
|
||||||
|
onClick={() => void refreshQuotaForFile()}
|
||||||
|
disabled={!canRefreshQuota}
|
||||||
|
>
|
||||||
|
{t(`${config.i18nPrefix}.idle`)}
|
||||||
|
</button>
|
||||||
|
) : quotaStatus === 'error' ? (
|
||||||
|
<div className={styles.quotaError}>
|
||||||
|
{t(`${config.i18nPrefix}.load_failed`, {
|
||||||
|
message: quotaErrorMessage
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : quota ? (
|
||||||
|
(config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode)
|
||||||
|
) : (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import type {
|
||||||
|
PrefixProxyEditorField,
|
||||||
|
PrefixProxyEditorState
|
||||||
|
} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type AuthFilesPrefixProxyEditorModalProps = {
|
||||||
|
disableControls: boolean;
|
||||||
|
editor: PrefixProxyEditorState | null;
|
||||||
|
updatedText: string;
|
||||||
|
dirty: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onChange: (field: PrefixProxyEditorField, value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { disableControls, editor, updatedText, dirty, onClose, onSave, onChange } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={Boolean(editor)}
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={editor?.saving === true}
|
||||||
|
width={720}
|
||||||
|
title={
|
||||||
|
editor?.fileName
|
||||||
|
? t('auth_files.auth_field_editor_title', { name: editor.fileName })
|
||||||
|
: t('auth_files.prefix_proxy_button')
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={editor?.saving === true}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
loading={editor?.saving === true}
|
||||||
|
disabled={
|
||||||
|
disableControls || editor?.saving === true || !dirty || !editor?.json
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editor && (
|
||||||
|
<div className={styles.prefixProxyEditor}>
|
||||||
|
{editor.loading ? (
|
||||||
|
<div className={styles.prefixProxyLoading}>
|
||||||
|
<LoadingSpinner size={14} />
|
||||||
|
<span>{t('auth_files.prefix_proxy_loading')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{editor.error && <div className={styles.prefixProxyError}>{editor.error}</div>}
|
||||||
|
<div className={styles.prefixProxyJsonWrapper}>
|
||||||
|
<label className={styles.prefixProxyLabel}>
|
||||||
|
{t('auth_files.prefix_proxy_source_label')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className={styles.prefixProxyTextarea}
|
||||||
|
rows={10}
|
||||||
|
readOnly
|
||||||
|
value={updatedText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.prefixProxyFields}>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.prefix_label')}
|
||||||
|
value={editor.prefix}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('prefix', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.proxy_url_label')}
|
||||||
|
value={editor.proxyUrl}
|
||||||
|
placeholder={t('auth_files.proxy_url_placeholder')}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('proxyUrl', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.priority_label')}
|
||||||
|
value={editor.priority}
|
||||||
|
placeholder={t('auth_files.priority_placeholder')}
|
||||||
|
hint={t('auth_files.priority_hint')}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('priority', e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('auth_files.excluded_models_label')}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
value={editor.excludedModelsText}
|
||||||
|
placeholder={t('auth_files.excluded_models_placeholder')}
|
||||||
|
rows={4}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('excludedModelsText', e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label={t('auth_files.disable_cooling_label')}
|
||||||
|
value={editor.disableCooling}
|
||||||
|
placeholder={t('auth_files.disable_cooling_placeholder')}
|
||||||
|
hint={t('auth_files.disable_cooling_hint')}
|
||||||
|
disabled={disableControls || editor.saving || !editor.json}
|
||||||
|
onChange={(e) => onChange('disableCooling', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
65
src/features/authFiles/components/OAuthExcludedCard.tsx
Normal file
65
src/features/authFiles/components/OAuthExcludedCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
type UnsupportedError = 'unsupported' | null;
|
||||||
|
|
||||||
|
export type OAuthExcludedCardProps = {
|
||||||
|
disableControls: boolean;
|
||||||
|
excludedError: UnsupportedError;
|
||||||
|
excluded: Record<string, string[]>;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (provider: string) => void;
|
||||||
|
onDelete: (provider: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OAuthExcludedCard(props: OAuthExcludedCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { disableControls, excludedError, excluded, onAdd, onEdit, onDelete } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('oauth_excluded.title')}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={disableControls || excludedError === 'unsupported'}>
|
||||||
|
{t('oauth_excluded.add')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{excludedError === 'unsupported' ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('oauth_excluded.upgrade_required_title')}
|
||||||
|
description={t('oauth_excluded.upgrade_required_desc')}
|
||||||
|
/>
|
||||||
|
) : Object.keys(excluded).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_excluded.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.excludedList}>
|
||||||
|
{Object.entries(excluded).map(([provider, models]) => (
|
||||||
|
<div key={provider} className={styles.excludedItem}>
|
||||||
|
<div className={styles.excludedInfo}>
|
||||||
|
<div className={styles.excludedProvider}>{provider}</div>
|
||||||
|
<div className={styles.excludedModels}>
|
||||||
|
{models?.length
|
||||||
|
? t('oauth_excluded.model_count', { count: models.length })
|
||||||
|
: t('oauth_excluded.no_models')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.excludedActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onEdit(provider)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => onDelete(provider)}>
|
||||||
|
{t('oauth_excluded.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
152
src/features/authFiles/components/OAuthModelAliasCard.tsx
Normal file
152
src/features/authFiles/components/OAuthModelAliasCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/modelAlias';
|
||||||
|
import { IconChevronUp } from '@/components/ui/icons';
|
||||||
|
import type { OAuthModelAliasEntry } from '@/types';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
type UnsupportedError = 'unsupported' | null;
|
||||||
|
type ViewMode = 'diagram' | 'list';
|
||||||
|
|
||||||
|
export type OAuthModelAliasCardProps = {
|
||||||
|
disableControls: boolean;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEditProvider: (provider?: string) => void;
|
||||||
|
onDeleteProvider: (provider: string) => void;
|
||||||
|
modelAliasError: UnsupportedError;
|
||||||
|
modelAlias: Record<string, OAuthModelAliasEntry[]>;
|
||||||
|
allProviderModels: Record<string, AuthFileModelItem[]>;
|
||||||
|
onUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
|
||||||
|
onDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
|
||||||
|
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => Promise<void>;
|
||||||
|
onRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
|
||||||
|
onDeleteAlias: (aliasName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const diagramRef = useRef<ModelMappingDiagramRef | null>(null);
|
||||||
|
const {
|
||||||
|
disableControls,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
onAdd,
|
||||||
|
onEditProvider,
|
||||||
|
onDeleteProvider,
|
||||||
|
modelAliasError,
|
||||||
|
modelAlias,
|
||||||
|
allProviderModels,
|
||||||
|
onUpdate,
|
||||||
|
onDeleteLink,
|
||||||
|
onToggleFork,
|
||||||
|
onRenameAlias,
|
||||||
|
onDeleteAlias
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('oauth_model_alias.title')}
|
||||||
|
extra={
|
||||||
|
<div className={styles.cardExtraButtons}>
|
||||||
|
<div className={styles.viewModeSwitch}>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.view_mode_list')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'diagram' ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onViewModeChange('diagram')}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.view_mode_diagram')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onAdd}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{modelAliasError === 'unsupported' ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('oauth_model_alias.upgrade_required_title')}
|
||||||
|
description={t('oauth_model_alias.upgrade_required_desc')}
|
||||||
|
/>
|
||||||
|
) : viewMode === 'diagram' ? (
|
||||||
|
Object.keys(modelAlias).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.aliasChartSection}>
|
||||||
|
<div className={styles.aliasChartHeader}>
|
||||||
|
<h4 className={styles.aliasChartTitle}>{t('oauth_model_alias.chart_title')}</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => diagramRef.current?.collapseAll()}
|
||||||
|
disabled={disableControls || modelAliasError === 'unsupported'}
|
||||||
|
title={t('oauth_model_alias.diagram_collapse')}
|
||||||
|
aria-label={t('oauth_model_alias.diagram_collapse')}
|
||||||
|
>
|
||||||
|
<IconChevronUp size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ModelMappingDiagram
|
||||||
|
ref={diagramRef}
|
||||||
|
modelAlias={modelAlias}
|
||||||
|
allProviderModels={allProviderModels}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onDeleteLink={onDeleteLink}
|
||||||
|
onToggleFork={onToggleFork}
|
||||||
|
onRenameAlias={onRenameAlias}
|
||||||
|
onDeleteAlias={onDeleteAlias}
|
||||||
|
onEditProvider={onEditProvider}
|
||||||
|
onDeleteProvider={onDeleteProvider}
|
||||||
|
className={styles.aliasChart}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : Object.keys(modelAlias).length === 0 ? (
|
||||||
|
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.excludedList}>
|
||||||
|
{Object.entries(modelAlias).map(([provider, mappings]) => (
|
||||||
|
<div key={provider} className={styles.excludedItem}>
|
||||||
|
<div className={styles.excludedInfo}>
|
||||||
|
<div className={styles.excludedProvider}>{provider}</div>
|
||||||
|
<div className={styles.excludedModels}>
|
||||||
|
{mappings?.length
|
||||||
|
? t('oauth_model_alias.model_count', { count: mappings.length })
|
||||||
|
: t('oauth_model_alias.no_models')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.excludedActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onEditProvider(provider)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => onDeleteProvider(provider)}>
|
||||||
|
{t('oauth_model_alias.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
28
src/features/authFiles/components/QuotaProgressBar.tsx
Normal file
28
src/features/authFiles/components/QuotaProgressBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import styles from '@/pages/AuthFilesPage.module.scss';
|
||||||
|
|
||||||
|
export type QuotaProgressBarProps = {
|
||||||
|
percent: number | null;
|
||||||
|
highThreshold: number;
|
||||||
|
mediumThreshold: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuotaProgressBar({ percent, highThreshold, mediumThreshold }: QuotaProgressBarProps) {
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
const normalized = percent === null ? null : clamp(percent, 0, 100);
|
||||||
|
const fillClass =
|
||||||
|
normalized === null
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: normalized >= highThreshold
|
||||||
|
? styles.quotaBarFillHigh
|
||||||
|
: normalized >= mediumThreshold
|
||||||
|
? styles.quotaBarFillMedium
|
||||||
|
: styles.quotaBarFillLow;
|
||||||
|
const widthPercent = Math.round(normalized ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.quotaBar}>
|
||||||
|
<div className={`${styles.quotaBarFill} ${fillClass}`} style={{ width: `${widthPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
235
src/features/authFiles/constants.ts
Normal file
235
src/features/authFiles/constants.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import {
|
||||||
|
normalizeUsageSourceId,
|
||||||
|
type KeyStatBucket,
|
||||||
|
type KeyStats
|
||||||
|
} from '@/utils/usage';
|
||||||
|
|
||||||
|
export type ThemeColors = { bg: string; text: string; border?: string };
|
||||||
|
export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||||
|
export type ResolvedTheme = 'light' | 'dark';
|
||||||
|
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||||||
|
|
||||||
|
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||||
|
|
||||||
|
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
|
||||||
|
|
||||||
|
export const MIN_CARD_PAGE_SIZE = 3;
|
||||||
|
export const MAX_CARD_PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
|
||||||
|
export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
|
||||||
|
export const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
|
||||||
|
|
||||||
|
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
||||||
|
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||||
|
qwen: {
|
||||||
|
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' }
|
||||||
|
},
|
||||||
|
'gemini-cli': {
|
||||||
|
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||||
|
dark: { bg: '#1c3f73', text: '#a8c7ff' }
|
||||||
|
},
|
||||||
|
aistudio: {
|
||||||
|
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||||||
|
dark: { bg: '#373c42', text: '#cfd3db' }
|
||||||
|
},
|
||||||
|
claude: {
|
||||||
|
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||||
|
dark: { bg: '#880e4f', text: '#f48fb1' }
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||||
|
dark: { bg: '#e65100', text: '#ffb74d' }
|
||||||
|
},
|
||||||
|
antigravity: {
|
||||||
|
light: { bg: '#e0f7fa', text: '#006064' },
|
||||||
|
dark: { bg: '#004d40', text: '#80deea' }
|
||||||
|
},
|
||||||
|
iflow: {
|
||||||
|
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||||
|
dark: { bg: '#4a148c', text: '#ce93d8' }
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
light: { bg: '#f5f5f5', text: '#616161' },
|
||||||
|
dark: { bg: '#424242', text: '#bdbdbd' }
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
||||||
|
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clampCardPageSize = (value: number) =>
|
||||||
|
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
|
||||||
|
|
||||||
|
export const resolveQuotaErrorMessage = (
|
||||||
|
t: TFunction,
|
||||||
|
status: number | undefined,
|
||||||
|
fallback: string
|
||||||
|
): string => {
|
||||||
|
if (status === 404) return t('common.quota_update_required');
|
||||||
|
if (status === 403) return t('common.quota_check_credential');
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
export const getTypeLabel = (t: TFunction, type: string): string => {
|
||||||
|
const key = `auth_files.filter_${type}`;
|
||||||
|
const translated = t(key);
|
||||||
|
if (translated !== key) return translated;
|
||||||
|
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTypeColor = (type: string, resolvedTheme: ResolvedTheme): ThemeColors => {
|
||||||
|
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
||||||
|
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseExcludedModelsText = (value: string): string[] =>
|
||||||
|
normalizeExcludedModels(value.split(/[\n,]+/));
|
||||||
|
|
||||||
|
export 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 保持一致)
|
||||||
|
export 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 ? trimmed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
||||||
|
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
||||||
|
if (typeof raw === 'boolean') return raw;
|
||||||
|
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucket {
|
||||||
|
const defaultStats: KeyStatBucket = { success: 0, failure: 0 };
|
||||||
|
const rawFileName = file?.name || '';
|
||||||
|
|
||||||
|
// 兼容 auth_index 和 authIndex 两种字段名(API 返回的是 auth_index)
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
// 尝试根据 authIndex 匹配
|
||||||
|
if (authIndexKey && stats.byAuthIndex?.[authIndexKey]) {
|
||||||
|
return stats.byAuthIndex[authIndexKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试根据 source (文件名) 匹配
|
||||||
|
const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : '';
|
||||||
|
if (fileNameId && stats.bySource?.[fileNameId]) {
|
||||||
|
const fromName = stats.bySource[fileNameId];
|
||||||
|
if (fromName.success > 0 || fromName.failure > 0) {
|
||||||
|
return fromName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试去掉扩展名后匹配
|
||||||
|
if (rawFileName) {
|
||||||
|
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
|
||||||
|
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
|
||||||
|
const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt);
|
||||||
|
const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined;
|
||||||
|
if (
|
||||||
|
fromNameWithoutExt &&
|
||||||
|
(fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)
|
||||||
|
) {
|
||||||
|
return fromNameWithoutExt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatModified = (item: AuthFileItem): string => {
|
||||||
|
const raw = item['modtime'] ?? item.modified;
|
||||||
|
if (!raw) return '-';
|
||||||
|
const asNumber = Number(raw);
|
||||||
|
const date =
|
||||||
|
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
|
||||||
|
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
|
||||||
|
: new Date(String(raw));
|
||||||
|
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查模型是否被 OAuth 排除
|
||||||
|
export const isModelExcluded = (
|
||||||
|
modelId: string,
|
||||||
|
providerType: string,
|
||||||
|
excluded: Record<string, string[]>
|
||||||
|
): boolean => {
|
||||||
|
const providerKey = normalizeProviderKey(providerType);
|
||||||
|
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
|
||||||
|
return excludedModels.some((pattern) => {
|
||||||
|
if (pattern.includes('*')) {
|
||||||
|
// 支持通配符匹配:先转义正则特殊字符,再将 * 视为通配符
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
};
|
||||||
519
src/features/authFiles/hooks/useAuthFilesData.ts
Normal file
519
src/features/authFiles/hooks/useAuthFilesData.ts
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { apiClient } from '@/services/api/client';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
|
||||||
|
import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
type DeleteAllOptions = {
|
||||||
|
filter: string;
|
||||||
|
onResetFilterToAll: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesDataResult = {
|
||||||
|
files: AuthFileItem[];
|
||||||
|
selectedFiles: Set<string>;
|
||||||
|
selectionCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
uploading: boolean;
|
||||||
|
deleting: string | null;
|
||||||
|
deletingAll: boolean;
|
||||||
|
statusUpdating: Record<string, boolean>;
|
||||||
|
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
loadFiles: () => Promise<void>;
|
||||||
|
handleUploadClick: () => void;
|
||||||
|
handleFileChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||||
|
handleDelete: (name: string) => void;
|
||||||
|
handleDeleteAll: (options: DeleteAllOptions) => void;
|
||||||
|
handleDownload: (name: string) => Promise<void>;
|
||||||
|
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
|
||||||
|
toggleSelect: (name: string) => void;
|
||||||
|
selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
|
||||||
|
deselectAll: () => void;
|
||||||
|
batchSetStatus: (names: string[], enabled: boolean) => Promise<void>;
|
||||||
|
batchDelete: (names: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesDataOptions = {
|
||||||
|
refreshKeyStats: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFilesDataResult {
|
||||||
|
const { refreshKeyStats } = options;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
|
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const selectionCount = selectedFiles.size;
|
||||||
|
|
||||||
|
const toggleSelect = useCallback((name: string) => {
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) {
|
||||||
|
next.delete(name);
|
||||||
|
} else {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectAllVisible = useCallback((visibleFiles: AuthFileItem[]) => {
|
||||||
|
const nextSelected = visibleFiles
|
||||||
|
.filter((file) => !isRuntimeOnlyAuthFile(file))
|
||||||
|
.map((file) => file.name);
|
||||||
|
setSelectedFiles(new Set(nextSelected));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deselectAll = useCallback(() => {
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFiles.size === 0) return;
|
||||||
|
const existingNames = new Set(files.map((file) => file.name));
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set<string>();
|
||||||
|
prev.forEach((name) => {
|
||||||
|
if (existingNames.has(name)) {
|
||||||
|
next.add(name);
|
||||||
|
} else {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [files, selectedFiles.size]);
|
||||||
|
|
||||||
|
const loadFiles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await authFilesApi.list();
|
||||||
|
setFiles(data?.files || []);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleUploadClick = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const fileList = event.target.files;
|
||||||
|
if (!fileList || fileList.length === 0) return;
|
||||||
|
|
||||||
|
const filesToUpload = Array.from(fileList);
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
const invalidFiles: string[] = [];
|
||||||
|
const oversizedFiles: string[] = [];
|
||||||
|
|
||||||
|
filesToUpload.forEach((file) => {
|
||||||
|
if (!file.name.endsWith('.json')) {
|
||||||
|
invalidFiles.push(file.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AUTH_FILE_SIZE) {
|
||||||
|
oversizedFiles.push(file.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validFiles.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
showNotification(t('auth_files.upload_error_json'), 'error');
|
||||||
|
}
|
||||||
|
if (oversizedFiles.length > 0) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
let successCount = 0;
|
||||||
|
const failed: { name: string; message: string }[] = [];
|
||||||
|
|
||||||
|
for (const file of validFiles) {
|
||||||
|
try {
|
||||||
|
await authFilesApi.upload(file);
|
||||||
|
successCount++;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
failed.push({ name: file.name, message: errorMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('auth_files.upload_success')}${suffix}`,
|
||||||
|
failed.length ? 'warning' : 'success'
|
||||||
|
);
|
||||||
|
await loadFiles();
|
||||||
|
await refreshKeyStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
|
||||||
|
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(false);
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
[loadFiles, refreshKeyStats, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
showConfirmation({
|
||||||
|
title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
|
||||||
|
message: `${t('auth_files.delete_confirm')} "${name}" ?`,
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
setDeleting(name);
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteFile(name);
|
||||||
|
showNotification(t('auth_files.delete_success'), 'success');
|
||||||
|
setFiles((prev) => prev.filter((item) => item.name !== name));
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
if (!prev.has(name)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteAll = useCallback(
|
||||||
|
(deleteAllOptions: DeleteAllOptions) => {
|
||||||
|
const { filter, onResetFilterToAll } = deleteAllOptions;
|
||||||
|
const isFiltered = filter !== 'all';
|
||||||
|
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
|
||||||
|
const confirmMessage = isFiltered
|
||||||
|
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
|
||||||
|
: t('auth_files.delete_all_confirm');
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
|
||||||
|
message: confirmMessage,
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
setDeletingAll(true);
|
||||||
|
try {
|
||||||
|
if (!isFiltered) {
|
||||||
|
await authFilesApi.deleteAll();
|
||||||
|
showNotification(t('auth_files.delete_all_success'), 'success');
|
||||||
|
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
|
||||||
|
deselectAll();
|
||||||
|
} else {
|
||||||
|
const filesToDelete = files.filter(
|
||||||
|
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filesToDelete.length === 0) {
|
||||||
|
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
|
||||||
|
setDeletingAll(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const deletedNames: string[] = [];
|
||||||
|
|
||||||
|
for (const file of filesToDelete) {
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteFile(file.name);
|
||||||
|
success++;
|
||||||
|
deletedNames.push(file.name);
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
if (prev.size === 0) return prev;
|
||||||
|
const deletedSet = new Set(deletedNames);
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set<string>();
|
||||||
|
prev.forEach((name) => {
|
||||||
|
if (deletedSet.has(name)) {
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failed === 0) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onResetFilterToAll();
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setDeletingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[deselectAll, files, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getRaw(
|
||||||
|
`/auth-files/download?name=${encodeURIComponent(name)}`,
|
||||||
|
{ responseType: 'blob' }
|
||||||
|
);
|
||||||
|
const blob = new Blob([response.data]);
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
showNotification(t('auth_files.download_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStatusToggle = useCallback(
|
||||||
|
async (item: AuthFileItem, enabled: boolean) => {
|
||||||
|
const name = item.name;
|
||||||
|
const nextDisabled = !enabled;
|
||||||
|
const previousDisabled = item.disabled === true;
|
||||||
|
|
||||||
|
setStatusUpdating((prev) => ({ ...prev, [name]: true }));
|
||||||
|
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.setStatus(name, nextDisabled);
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
|
||||||
|
);
|
||||||
|
showNotification(
|
||||||
|
enabled
|
||||||
|
? t('auth_files.status_enabled_success', { name })
|
||||||
|
: t('auth_files.status_disabled_success', { name }),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
|
||||||
|
);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setStatusUpdating((prev) => {
|
||||||
|
if (!prev[name]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[name];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchSetStatus = useCallback(
|
||||||
|
async (names: string[], enabled: boolean) => {
|
||||||
|
const uniqueNames = Array.from(new Set(names));
|
||||||
|
if (uniqueNames.length === 0) return;
|
||||||
|
|
||||||
|
const targetNames = new Set(uniqueNames);
|
||||||
|
const nextDisabled = !enabled;
|
||||||
|
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((file) =>
|
||||||
|
targetNames.has(file.name) ? { ...file, disabled: nextDisabled } : file
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
uniqueNames.map((name) => authFilesApi.setStatus(name, nextDisabled))
|
||||||
|
);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const failedNames = new Set<string>();
|
||||||
|
const confirmedDisabled = new Map<string, boolean>();
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const name = uniqueNames[index];
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
successCount++;
|
||||||
|
confirmedDisabled.set(name, result.value.disabled);
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
failedNames.add(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((file) => {
|
||||||
|
if (failedNames.has(file.name)) {
|
||||||
|
return { ...file, disabled: !nextDisabled };
|
||||||
|
}
|
||||||
|
if (confirmedDisabled.has(file.name)) {
|
||||||
|
return { ...file, disabled: confirmedDisabled.get(file.name) };
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
|
||||||
|
} else {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.batch_status_partial', { success: successCount, failed: failCount }),
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectAll();
|
||||||
|
},
|
||||||
|
[deselectAll, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchDelete = useCallback(
|
||||||
|
(names: string[]) => {
|
||||||
|
const uniqueNames = Array.from(new Set(names));
|
||||||
|
if (uniqueNames.length === 0) return;
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('auth_files.batch_delete_title'),
|
||||||
|
message: t('auth_files.batch_delete_confirm', { count: uniqueNames.length }),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
uniqueNames.map((name) => authFilesApi.deleteFile(name))
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleted: string[] = [];
|
||||||
|
let failCount = 0;
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
deleted.push(uniqueNames[index]);
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleted.length > 0) {
|
||||||
|
const deletedSet = new Set(deleted);
|
||||||
|
setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
if (prev.size === 0) return prev;
|
||||||
|
const deletedSet = new Set(deleted);
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set<string>();
|
||||||
|
prev.forEach((name) => {
|
||||||
|
if (deletedSet.has(name)) {
|
||||||
|
changed = true;
|
||||||
|
} else {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failCount === 0) {
|
||||||
|
showNotification(`${t('auth_files.delete_all_success')} (${deleted.length})`, 'success');
|
||||||
|
} else {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.delete_filtered_partial', {
|
||||||
|
success: deleted.length,
|
||||||
|
failed: failCount,
|
||||||
|
type: t('auth_files.filter_all')
|
||||||
|
}),
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
selectedFiles,
|
||||||
|
selectionCount,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
uploading,
|
||||||
|
deleting,
|
||||||
|
deletingAll,
|
||||||
|
statusUpdating,
|
||||||
|
fileInputRef,
|
||||||
|
loadFiles,
|
||||||
|
handleUploadClick,
|
||||||
|
handleFileChange,
|
||||||
|
handleDelete,
|
||||||
|
handleDeleteAll,
|
||||||
|
handleDownload,
|
||||||
|
handleStatusToggle,
|
||||||
|
toggleSelect,
|
||||||
|
selectAllVisible,
|
||||||
|
deselectAll,
|
||||||
|
batchSetStatus,
|
||||||
|
batchDelete
|
||||||
|
};
|
||||||
|
}
|
||||||
86
src/features/authFiles/hooks/useAuthFilesModels.ts
Normal file
86
src/features/authFiles/hooks/useAuthFilesModels.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
type ModelsError = 'unsupported' | null;
|
||||||
|
|
||||||
|
export type UseAuthFilesModelsResult = {
|
||||||
|
modelsModalOpen: boolean;
|
||||||
|
modelsLoading: boolean;
|
||||||
|
modelsList: AuthFileModelItem[];
|
||||||
|
modelsFileName: string;
|
||||||
|
modelsFileType: string;
|
||||||
|
modelsError: ModelsError;
|
||||||
|
showModels: (item: AuthFileItem) => Promise<void>;
|
||||||
|
closeModelsModal: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesModels(): UseAuthFilesModelsResult {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||||
|
|
||||||
|
const [modelsModalOpen, setModelsModalOpen] = useState(false);
|
||||||
|
const [modelsLoading, setModelsLoading] = useState(false);
|
||||||
|
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
|
||||||
|
const [modelsFileName, setModelsFileName] = useState('');
|
||||||
|
const [modelsFileType, setModelsFileType] = useState('');
|
||||||
|
const [modelsError, setModelsError] = useState<ModelsError>(null);
|
||||||
|
const modelsCacheRef = useRef<Map<string, AuthFileModelItem[]>>(new Map());
|
||||||
|
|
||||||
|
const closeModelsModal = useCallback(() => {
|
||||||
|
setModelsModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showModels = useCallback(
|
||||||
|
async (item: AuthFileItem) => {
|
||||||
|
setModelsFileName(item.name);
|
||||||
|
setModelsFileType(item.type || '');
|
||||||
|
setModelsList([]);
|
||||||
|
setModelsError(null);
|
||||||
|
setModelsModalOpen(true);
|
||||||
|
|
||||||
|
const cached = modelsCacheRef.current.get(item.name);
|
||||||
|
if (cached) {
|
||||||
|
setModelsList(cached);
|
||||||
|
setModelsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModelsLoading(true);
|
||||||
|
try {
|
||||||
|
const models = await authFilesApi.getModelsForAuthFile(item.name);
|
||||||
|
modelsCacheRef.current.set(item.name, models);
|
||||||
|
setModelsList(models);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
if (
|
||||||
|
errorMessage.includes('404') ||
|
||||||
|
errorMessage.includes('not found') ||
|
||||||
|
errorMessage.includes('Not Found')
|
||||||
|
) {
|
||||||
|
setModelsError('unsupported');
|
||||||
|
} else {
|
||||||
|
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setModelsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelsModalOpen,
|
||||||
|
modelsLoading,
|
||||||
|
modelsList,
|
||||||
|
modelsFileName,
|
||||||
|
modelsFileType,
|
||||||
|
modelsError,
|
||||||
|
showModels,
|
||||||
|
closeModelsModal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
504
src/features/authFiles/hooks/useAuthFilesOauth.tsx
Normal file
504
src/features/authFiles/hooks/useAuthFilesOauth.tsx
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
||||||
|
import type { AuthFileModelItem } from '@/features/authFiles/constants';
|
||||||
|
import { normalizeProviderKey } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
type UnsupportedError = 'unsupported' | null;
|
||||||
|
type ViewMode = 'diagram' | 'list';
|
||||||
|
|
||||||
|
export type UseAuthFilesOauthResult = {
|
||||||
|
excluded: Record<string, string[]>;
|
||||||
|
excludedError: UnsupportedError;
|
||||||
|
modelAlias: Record<string, OAuthModelAliasEntry[]>;
|
||||||
|
modelAliasError: UnsupportedError;
|
||||||
|
allProviderModels: Record<string, AuthFileModelItem[]>;
|
||||||
|
providerList: string[];
|
||||||
|
loadExcluded: () => Promise<void>;
|
||||||
|
loadModelAlias: () => Promise<void>;
|
||||||
|
deleteExcluded: (provider: string) => void;
|
||||||
|
deleteModelAlias: (provider: string) => void;
|
||||||
|
handleMappingUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
|
||||||
|
handleDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
|
||||||
|
handleToggleFork: (
|
||||||
|
provider: string,
|
||||||
|
sourceModel: string,
|
||||||
|
alias: string,
|
||||||
|
fork: boolean
|
||||||
|
) => Promise<void>;
|
||||||
|
handleRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
|
||||||
|
handleDeleteAlias: (aliasName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesOauthOptions = {
|
||||||
|
viewMode: ViewMode;
|
||||||
|
files: AuthFileItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesOauth(options: UseAuthFilesOauthOptions): UseAuthFilesOauthResult {
|
||||||
|
const { viewMode, files } = options;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
|
|
||||||
|
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||||
|
const [excludedError, setExcludedError] = useState<UnsupportedError>(null);
|
||||||
|
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
|
||||||
|
const [modelAliasError, setModelAliasError] = useState<UnsupportedError>(null);
|
||||||
|
const [allProviderModels, setAllProviderModels] = useState<Record<string, AuthFileModelItem[]>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const excludedUnsupportedRef = useRef(false);
|
||||||
|
const mappingsUnsupportedRef = useRef(false);
|
||||||
|
|
||||||
|
const providerList = useMemo(() => {
|
||||||
|
const providers = new Set<string>();
|
||||||
|
|
||||||
|
Object.keys(modelAlias).forEach((provider) => {
|
||||||
|
const key = provider.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (typeof file.type === 'string') {
|
||||||
|
const key = file.type.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
}
|
||||||
|
if (typeof file.provider === 'string') {
|
||||||
|
const key = file.provider.trim().toLowerCase();
|
||||||
|
if (key) providers.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(providers);
|
||||||
|
}, [files, modelAlias]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode !== 'diagram') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadAllModels = async () => {
|
||||||
|
if (providerList.length === 0) {
|
||||||
|
if (!cancelled) setAllProviderModels({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
providerList.map(async (provider) => {
|
||||||
|
try {
|
||||||
|
const models = await authFilesApi.getModelDefinitions(provider);
|
||||||
|
return { provider, models };
|
||||||
|
} catch {
|
||||||
|
return { provider, models: [] as AuthFileModelItem[] };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const nextModels: Record<string, AuthFileModelItem[]> = {};
|
||||||
|
results.forEach(({ provider, models }) => {
|
||||||
|
if (models.length > 0) {
|
||||||
|
nextModels[provider] = models;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAllProviderModels(nextModels);
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadAllModels();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [providerList, viewMode]);
|
||||||
|
|
||||||
|
const loadExcluded = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.getOauthExcludedModels();
|
||||||
|
excludedUnsupportedRef.current = false;
|
||||||
|
setExcluded(res || {});
|
||||||
|
setExcludedError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setExcluded({});
|
||||||
|
setExcludedError('unsupported');
|
||||||
|
if (!excludedUnsupportedRef.current) {
|
||||||
|
excludedUnsupportedRef.current = true;
|
||||||
|
showNotification(t('oauth_excluded.upgrade_required'), 'warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
}, [showNotification, t]);
|
||||||
|
|
||||||
|
const loadModelAlias = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authFilesApi.getOauthModelAlias();
|
||||||
|
mappingsUnsupportedRef.current = false;
|
||||||
|
setModelAlias(res || {});
|
||||||
|
setModelAliasError(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setModelAlias({});
|
||||||
|
setModelAliasError('unsupported');
|
||||||
|
if (!mappingsUnsupportedRef.current) {
|
||||||
|
mappingsUnsupportedRef.current = true;
|
||||||
|
showNotification(t('oauth_model_alias.upgrade_required'), 'warning');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
}, [showNotification, t]);
|
||||||
|
|
||||||
|
const deleteExcluded = useCallback(
|
||||||
|
(provider: string) => {
|
||||||
|
const providerLabel = provider.trim() || provider;
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_excluded.delete_title', { defaultValue: 'Delete Exclusion' }),
|
||||||
|
message: t('oauth_excluded.delete_confirm', { provider: providerLabel }),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const providerKey = normalizeProviderKey(provider);
|
||||||
|
if (!providerKey) {
|
||||||
|
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteOauthExcludedEntry(providerKey);
|
||||||
|
await loadExcluded();
|
||||||
|
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
try {
|
||||||
|
const current = await authFilesApi.getOauthExcludedModels();
|
||||||
|
const next: Record<string, string[]> = {};
|
||||||
|
Object.entries(current).forEach(([key, models]) => {
|
||||||
|
if (normalizeProviderKey(key) === providerKey) return;
|
||||||
|
next[key] = models;
|
||||||
|
});
|
||||||
|
await authFilesApi.replaceOauthExcludedModels(next);
|
||||||
|
await loadExcluded();
|
||||||
|
showNotification(t('oauth_excluded.delete_success'), 'success');
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
const errorMessage =
|
||||||
|
fallbackErr instanceof Error
|
||||||
|
? fallbackErr.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: '';
|
||||||
|
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadExcluded, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteModelAlias = useCallback(
|
||||||
|
(provider: string) => {
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_title', { defaultValue: 'Delete Mappings' }),
|
||||||
|
message: t('oauth_model_alias.delete_confirm', { provider }),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await authFilesApi.deleteOauthModelAlias(provider);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.delete_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadModelAlias, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMappingUpdate = useCallback(
|
||||||
|
async (provider: string, sourceModel: string, newAlias: string) => {
|
||||||
|
if (!provider || !sourceModel || !newAlias) return;
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
if (!normalizedProvider) return;
|
||||||
|
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
|
||||||
|
const nameTrim = sourceModel.trim();
|
||||||
|
const aliasTrim = newAlias.trim();
|
||||||
|
const nameKey = nameTrim.toLowerCase();
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentMappings.some(
|
||||||
|
(m) =>
|
||||||
|
(m.name ?? '').trim().toLowerCase() === nameKey &&
|
||||||
|
(m.alias ?? '').trim().toLowerCase() === aliasKey
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMappings: OAuthModelAliasEntry[] = [
|
||||||
|
...currentMappings,
|
||||||
|
{ name: nameTrim, alias: aliasTrim, fork: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteLink = useCallback(
|
||||||
|
(provider: string, sourceModel: string, alias: string) => {
|
||||||
|
const nameTrim = sourceModel.trim();
|
||||||
|
const aliasTrim = alias.trim();
|
||||||
|
if (!provider || !nameTrim || !aliasTrim) return;
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_link_title', { defaultValue: 'Unlink mapping' }),
|
||||||
|
message: (
|
||||||
|
<Trans
|
||||||
|
i18nKey="oauth_model_alias.delete_link_confirm"
|
||||||
|
values={{ provider, sourceModel: nameTrim, alias: aliasTrim }}
|
||||||
|
components={{ code: <code /> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
const nameKey = nameTrim.toLowerCase();
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
const nextMappings = currentMappings.filter(
|
||||||
|
(m) =>
|
||||||
|
(m.name ?? '').trim().toLowerCase() !== nameKey ||
|
||||||
|
(m.alias ?? '').trim().toLowerCase() !== aliasKey
|
||||||
|
);
|
||||||
|
if (nextMappings.length === currentMappings.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (nextMappings.length === 0) {
|
||||||
|
await authFilesApi.deleteOauthModelAlias(normalizedProvider);
|
||||||
|
} else {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
}
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleFork = useCallback(
|
||||||
|
async (provider: string, sourceModel: string, alias: string, fork: boolean) => {
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
if (!normalizedProvider) return;
|
||||||
|
|
||||||
|
const providerKey = Object.keys(modelAlias).find(
|
||||||
|
(key) => normalizeProviderKey(key) === normalizedProvider
|
||||||
|
);
|
||||||
|
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
|
||||||
|
const nameKey = sourceModel.trim().toLowerCase();
|
||||||
|
const aliasKey = alias.trim().toLowerCase();
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const nextMappings = currentMappings.map((m) => {
|
||||||
|
const mName = (m.name ?? '').trim().toLowerCase();
|
||||||
|
const mAlias = (m.alias ?? '').trim().toLowerCase();
|
||||||
|
if (mName === nameKey && mAlias === aliasKey) {
|
||||||
|
changed = true;
|
||||||
|
return fork ? { ...m, fork: true } : { name: m.name, alias: m.alias };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
|
||||||
|
await loadModelAlias();
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRenameAlias = useCallback(
|
||||||
|
async (oldAlias: string, newAlias: string) => {
|
||||||
|
const oldTrim = oldAlias.trim();
|
||||||
|
const newTrim = newAlias.trim();
|
||||||
|
if (!oldTrim || !newTrim || oldTrim === newTrim) return;
|
||||||
|
|
||||||
|
const oldKey = oldTrim.toLowerCase();
|
||||||
|
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
|
||||||
|
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === oldKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providersToUpdate.length === 0) return;
|
||||||
|
|
||||||
|
let hadFailure = false;
|
||||||
|
let failureMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
providersToUpdate.map(([provider, mappings]) => {
|
||||||
|
const nextMappings = mappings.map((m) =>
|
||||||
|
(m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m
|
||||||
|
);
|
||||||
|
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = results.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
hadFailure = true;
|
||||||
|
const reason = failures[0].reason;
|
||||||
|
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await loadModelAlias();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadFailure) {
|
||||||
|
showNotification(
|
||||||
|
failureMessage
|
||||||
|
? `${t('oauth_model_alias.save_failed')}: ${failureMessage}`
|
||||||
|
: t('oauth_model_alias.save_failed'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteAlias = useCallback(
|
||||||
|
(aliasName: string) => {
|
||||||
|
const aliasTrim = aliasName.trim();
|
||||||
|
if (!aliasTrim) return;
|
||||||
|
const aliasKey = aliasTrim.toLowerCase();
|
||||||
|
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
|
||||||
|
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === aliasKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providersToUpdate.length === 0) return;
|
||||||
|
|
||||||
|
showConfirmation({
|
||||||
|
title: t('oauth_model_alias.delete_alias_title', { defaultValue: 'Delete Alias' }),
|
||||||
|
message: (
|
||||||
|
<Trans
|
||||||
|
i18nKey="oauth_model_alias.delete_alias_confirm"
|
||||||
|
values={{ alias: aliasTrim }}
|
||||||
|
components={{ code: <code /> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
let hadFailure = false;
|
||||||
|
let failureMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
providersToUpdate.map(([provider, mappings]) => {
|
||||||
|
const nextMappings = mappings.filter(
|
||||||
|
(m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey
|
||||||
|
);
|
||||||
|
if (nextMappings.length === 0) {
|
||||||
|
return authFilesApi.deleteOauthModelAlias(provider);
|
||||||
|
}
|
||||||
|
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = results.filter(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
hadFailure = true;
|
||||||
|
const reason = failures[0].reason;
|
||||||
|
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await loadModelAlias();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadFailure) {
|
||||||
|
showNotification(
|
||||||
|
failureMessage
|
||||||
|
? `${t('oauth_model_alias.delete_failed')}: ${failureMessage}`
|
||||||
|
: t('oauth_model_alias.delete_failed'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showNotification(t('oauth_model_alias.delete_success'), 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
excluded,
|
||||||
|
excludedError,
|
||||||
|
modelAlias,
|
||||||
|
modelAliasError,
|
||||||
|
allProviderModels,
|
||||||
|
providerList,
|
||||||
|
loadExcluded,
|
||||||
|
loadModelAlias,
|
||||||
|
deleteExcluded,
|
||||||
|
deleteModelAlias,
|
||||||
|
handleMappingUpdate,
|
||||||
|
handleDeleteLink,
|
||||||
|
handleToggleFork,
|
||||||
|
handleRenameAlias,
|
||||||
|
handleDeleteAlias
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
254
src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts
Normal file
254
src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import { formatFileSize } from '@/utils/format';
|
||||||
|
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
|
||||||
|
import {
|
||||||
|
normalizeExcludedModels,
|
||||||
|
parseDisableCoolingValue,
|
||||||
|
parseExcludedModelsText,
|
||||||
|
parsePriorityValue
|
||||||
|
} from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
export type PrefixProxyEditorField =
|
||||||
|
| 'prefix'
|
||||||
|
| 'proxyUrl'
|
||||||
|
| 'priority'
|
||||||
|
| 'excludedModelsText'
|
||||||
|
| 'disableCooling';
|
||||||
|
|
||||||
|
export type PrefixProxyEditorState = {
|
||||||
|
fileName: string;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
originalText: string;
|
||||||
|
rawText: string;
|
||||||
|
json: Record<string, unknown> | null;
|
||||||
|
prefix: string;
|
||||||
|
proxyUrl: string;
|
||||||
|
priority: string;
|
||||||
|
excludedModelsText: string;
|
||||||
|
disableCooling: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesPrefixProxyEditorOptions = {
|
||||||
|
disableControls: boolean;
|
||||||
|
loadFiles: () => Promise<void>;
|
||||||
|
loadKeyStats: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseAuthFilesPrefixProxyEditorResult = {
|
||||||
|
prefixProxyEditor: PrefixProxyEditorState | null;
|
||||||
|
prefixProxyUpdatedText: string;
|
||||||
|
prefixProxyDirty: boolean;
|
||||||
|
openPrefixProxyEditor: (name: string) => Promise<void>;
|
||||||
|
closePrefixProxyEditor: () => void;
|
||||||
|
handlePrefixProxyChange: (field: PrefixProxyEditorField, value: string) => void;
|
||||||
|
handlePrefixProxySave: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
|
||||||
|
if (!editor?.json) return editor?.rawText ?? '';
|
||||||
|
const next: Record<string, unknown> = { ...editor.json };
|
||||||
|
if ('prefix' in next || editor.prefix.trim()) {
|
||||||
|
next.prefix = editor.prefix;
|
||||||
|
}
|
||||||
|
if ('proxy_url' in next || editor.proxyUrl.trim()) {
|
||||||
|
next.proxy_url = editor.proxyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPriority = parsePriorityValue(editor.priority);
|
||||||
|
if (parsedPriority !== undefined) {
|
||||||
|
next.priority = parsedPriority;
|
||||||
|
} else if ('priority' in next) {
|
||||||
|
delete next.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludedModels = parseExcludedModelsText(editor.excludedModelsText);
|
||||||
|
if (excludedModels.length > 0) {
|
||||||
|
next.excluded_models = excludedModels;
|
||||||
|
} else if ('excluded_models' in next) {
|
||||||
|
delete next.excluded_models;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDisableCooling = parseDisableCoolingValue(editor.disableCooling);
|
||||||
|
if (parsedDisableCooling !== undefined) {
|
||||||
|
next.disable_cooling = parsedDisableCooling;
|
||||||
|
} else if ('disable_cooling' in next) {
|
||||||
|
delete next.disable_cooling;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesPrefixProxyEditor(
|
||||||
|
options: UseAuthFilesPrefixProxyEditorOptions
|
||||||
|
): UseAuthFilesPrefixProxyEditorResult {
|
||||||
|
const { disableControls, loadFiles, loadKeyStats } = options;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showNotification = useNotificationStore((state) => state.showNotification);
|
||||||
|
|
||||||
|
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
|
||||||
|
|
||||||
|
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
|
||||||
|
const prefixProxyDirty =
|
||||||
|
Boolean(prefixProxyEditor?.json) &&
|
||||||
|
Boolean(prefixProxyEditor?.originalText) &&
|
||||||
|
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
|
||||||
|
|
||||||
|
const closePrefixProxyEditor = () => {
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPrefixProxyEditor = async (name: string) => {
|
||||||
|
if (disableControls) return;
|
||||||
|
if (prefixProxyEditor?.fileName === name) {
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrefixProxyEditor({
|
||||||
|
fileName: name,
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
error: null,
|
||||||
|
originalText: '',
|
||||||
|
rawText: '',
|
||||||
|
json: null,
|
||||||
|
prefix: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
priority: '',
|
||||||
|
excludedModelsText: '',
|
||||||
|
disableCooling: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawText = await authFilesApi.downloadText(name);
|
||||||
|
const trimmed = rawText.trim();
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: t('auth_files.prefix_proxy_invalid_json'),
|
||||||
|
rawText: trimmed,
|
||||||
|
originalText: trimmed
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: t('auth_files.prefix_proxy_invalid_json'),
|
||||||
|
rawText: trimmed,
|
||||||
|
originalText: trimmed
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = parsed as Record<string, unknown>;
|
||||||
|
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 disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
|
||||||
|
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
originalText,
|
||||||
|
rawText: originalText,
|
||||||
|
json,
|
||||||
|
prefix,
|
||||||
|
proxyUrl,
|
||||||
|
priority: priority !== undefined ? String(priority) : '',
|
||||||
|
excludedModelsText: excludedModels.join('\n'),
|
||||||
|
disableCooling:
|
||||||
|
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : t('notification.download_failed');
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, loading: false, error: errorMessage, rawText: '' };
|
||||||
|
});
|
||||||
|
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrefixProxyChange = (field: PrefixProxyEditorField, value: string) => {
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
if (field === 'prefix') return { ...prev, prefix: 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 };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrefixProxySave = async () => {
|
||||||
|
if (!prefixProxyEditor?.json) return;
|
||||||
|
if (!prefixProxyDirty) return;
|
||||||
|
|
||||||
|
const name = prefixProxyEditor.fileName;
|
||||||
|
const payload = prefixProxyUpdatedText;
|
||||||
|
const fileSize = new Blob([payload]).size;
|
||||||
|
if (fileSize > MAX_AUTH_FILE_SIZE) {
|
||||||
|
showNotification(
|
||||||
|
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, saving: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = new File([payload], name, { type: 'application/json' });
|
||||||
|
await authFilesApi.upload(file);
|
||||||
|
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
|
||||||
|
await loadFiles();
|
||||||
|
await loadKeyStats();
|
||||||
|
setPrefixProxyEditor(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
|
||||||
|
setPrefixProxyEditor((prev) => {
|
||||||
|
if (!prev || prev.fileName !== name) return prev;
|
||||||
|
return { ...prev, saving: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefixProxyEditor,
|
||||||
|
prefixProxyUpdatedText,
|
||||||
|
prefixProxyDirty,
|
||||||
|
openPrefixProxyEditor,
|
||||||
|
closePrefixProxyEditor,
|
||||||
|
handlePrefixProxyChange,
|
||||||
|
handlePrefixProxySave
|
||||||
|
};
|
||||||
|
}
|
||||||
35
src/features/authFiles/hooks/useAuthFilesStats.ts
Normal file
35
src/features/authFiles/hooks/useAuthFilesStats.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { usageApi } from '@/services/api';
|
||||||
|
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||||
|
|
||||||
|
export type UseAuthFilesStatsResult = {
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loadKeyStats: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthFilesStats(): UseAuthFilesStatsResult {
|
||||||
|
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||||
|
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||||
|
const loadingKeyStatsRef = useRef(false);
|
||||||
|
|
||||||
|
const loadKeyStats = useCallback(async () => {
|
||||||
|
if (loadingKeyStatsRef.current) return;
|
||||||
|
loadingKeyStatsRef.current = true;
|
||||||
|
try {
|
||||||
|
const usageResponse = await usageApi.getUsage();
|
||||||
|
const usageData = usageResponse?.usage ?? usageResponse;
|
||||||
|
const stats = await usageApi.getKeyStats(usageData);
|
||||||
|
setKeyStats(stats);
|
||||||
|
const details = collectUsageDetails(usageData);
|
||||||
|
setUsageDetails(details);
|
||||||
|
} catch {
|
||||||
|
// 静默失败
|
||||||
|
} finally {
|
||||||
|
loadingKeyStatsRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { keyStats, usageDetails, loadKeyStats };
|
||||||
|
}
|
||||||
|
|
||||||
28
src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts
Normal file
28
src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { calculateStatusBarData, type UsageDetail } from '@/utils/usage';
|
||||||
|
import { normalizeAuthIndexValue } from '@/features/authFiles/constants';
|
||||||
|
|
||||||
|
export type AuthFileStatusBarData = ReturnType<typeof calculateStatusBarData>;
|
||||||
|
|
||||||
|
export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails: UsageDetail[]) {
|
||||||
|
return useMemo(() => {
|
||||||
|
const cache = new Map<string, AuthFileStatusBarData>();
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
if (authIndexKey) {
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => {
|
||||||
|
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
|
||||||
|
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
|
||||||
|
});
|
||||||
|
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [files, usageDetails]);
|
||||||
|
}
|
||||||
|
|
||||||
30
src/features/authFiles/uiState.ts
Normal file
30
src/features/authFiles/uiState.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export type AuthFilesUiState = {
|
||||||
|
filter?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
|
||||||
|
|
||||||
|
export const readAuthFilesUiState = (): AuthFilesUiState | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(AUTH_FILES_UI_STATE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as AuthFilesUiState;
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ interface UseApiOptions<T> {
|
|||||||
successMessage?: string;
|
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>,
|
apiFunction: (...args: Args) => Promise<T>,
|
||||||
options: UseApiOptions<T> = {}
|
options: UseApiOptions<T> = {}
|
||||||
) {
|
) {
|
||||||
@@ -38,8 +38,9 @@ export function useApi<T = any, Args extends any[] = any[]>(
|
|||||||
|
|
||||||
options.onSuccess?.(result);
|
options.onSuccess?.(result);
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
const errorObj = err as Error;
|
const errorObj =
|
||||||
|
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error');
|
||||||
setError(errorObj);
|
setError(errorObj);
|
||||||
|
|
||||||
if (options.showErrorNotification !== false) {
|
if (options.showErrorNotification !== false) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
|
||||||
import type {
|
import type {
|
||||||
PayloadFilterRule,
|
PayloadFilterRule,
|
||||||
PayloadParamValueType,
|
PayloadParamValueType,
|
||||||
@@ -8,10 +8,6 @@ import type {
|
|||||||
} from '@/types/visualConfig';
|
} from '@/types/visualConfig';
|
||||||
import { DEFAULT_VISUAL_VALUES } 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 {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
@@ -48,53 +44,58 @@ function parseApiKeysText(raw: unknown): string {
|
|||||||
return keys.join('\n');
|
return keys.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
|
type YamlDocument = ReturnType<typeof parseDocument>;
|
||||||
const existing = asRecord(parent[key]);
|
type YamlPath = string[];
|
||||||
if (existing) return existing;
|
|
||||||
const next: Record<string, unknown> = {};
|
function docHas(doc: YamlDocument, path: YamlPath): boolean {
|
||||||
parent[key] = next;
|
return doc.hasIn(path);
|
||||||
return next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void {
|
function ensureMapInDoc(doc: YamlDocument, path: YamlPath): void {
|
||||||
const value = asRecord(parent[key]);
|
const existing = doc.getIn(path, true);
|
||||||
if (!value) return;
|
if (isMap(existing)) return;
|
||||||
if (Object.keys(value).length === 0) delete parent[key];
|
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) {
|
if (value) {
|
||||||
obj[key] = true;
|
doc.setIn(path, true);
|
||||||
return;
|
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 safe = typeof value === 'string' ? value : '';
|
||||||
const trimmed = safe.trim();
|
const trimmed = safe.trim();
|
||||||
if (trimmed !== '') {
|
if (trimmed !== '') {
|
||||||
obj[key] = safe;
|
doc.setIn(path, safe);
|
||||||
return;
|
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 safe = typeof value === 'string' ? value : '';
|
||||||
const trimmed = safe.trim();
|
const trimmed = safe.trim();
|
||||||
if (trimmed === '') {
|
if (trimmed === '') {
|
||||||
if (hasOwn(obj, key)) delete obj[key];
|
if (docHas(doc, path)) doc.deleteIn(path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = Number.parseInt(trimmed, 10);
|
const parsed = Number.parseInt(trimmed, 10);
|
||||||
if (Number.isFinite(parsed)) {
|
if (Number.isFinite(parsed)) {
|
||||||
obj[key] = parsed;
|
doc.setIn(path, parsed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasOwn(obj, key)) delete obj[key];
|
if (docHas(doc, path)) doc.deleteIn(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deepClone<T>(value: T): T {
|
function deepClone<T>(value: T): T {
|
||||||
@@ -123,20 +124,47 @@ function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueTyp
|
|||||||
return { valueType: 'string', value: String(raw ?? '') };
|
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[] {
|
function parsePayloadRules(rules: unknown): PayloadRule[] {
|
||||||
if (!Array.isArray(rules)) return [];
|
if (!Array.isArray(rules)) return [];
|
||||||
|
|
||||||
return rules.map((rule, index) => ({
|
return rules.map((rule, index) => {
|
||||||
id: `payload-rule-${index}`,
|
const record = asRecord(rule) ?? {};
|
||||||
models: Array.isArray((rule as any)?.models)
|
|
||||||
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
|
const modelsRaw = record.models;
|
||||||
id: `model-${index}-${modelIndex}`,
|
const models = Array.isArray(modelsRaw)
|
||||||
name: typeof model === 'string' ? model : model?.name || '',
|
? modelsRaw.map((model, modelIndex) => {
|
||||||
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
|
const modelRecord = asRecord(model);
|
||||||
}))
|
const nameRaw =
|
||||||
: [],
|
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
|
||||||
params: (rule as any)?.params
|
const name = typeof nameRaw === 'string' ? nameRaw : String(nameRaw ?? '');
|
||||||
? Object.entries((rule as any).params as Record<string, unknown>).map(([path, value], pIndex) => {
|
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);
|
const parsedValue = parsePayloadParamValue(value);
|
||||||
return {
|
return {
|
||||||
id: `param-${index}-${pIndex}`,
|
id: `param-${index}-${pIndex}`,
|
||||||
@@ -145,41 +173,55 @@ function parsePayloadRules(rules: unknown): PayloadRule[] {
|
|||||||
value: parsedValue.value,
|
value: parsedValue.value,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [],
|
: [];
|
||||||
}));
|
|
||||||
|
return { id: `payload-rule-${index}`, models, params };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
|
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
|
||||||
if (!Array.isArray(rules)) return [];
|
if (!Array.isArray(rules)) return [];
|
||||||
|
|
||||||
return rules.map((rule, index) => ({
|
return rules.map((rule, index) => {
|
||||||
id: `payload-filter-rule-${index}`,
|
const record = asRecord(rule) ?? {};
|
||||||
models: Array.isArray((rule as any)?.models)
|
|
||||||
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
|
const modelsRaw = record.models;
|
||||||
id: `filter-model-${index}-${modelIndex}`,
|
const models = Array.isArray(modelsRaw)
|
||||||
name: typeof model === 'string' ? model : model?.name || '',
|
? modelsRaw.map((model, modelIndex) => {
|
||||||
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
|
const modelRecord = asRecord(model);
|
||||||
}))
|
const nameRaw =
|
||||||
: [],
|
typeof model === 'string' ? model : (modelRecord?.name ?? modelRecord?.id ?? '');
|
||||||
params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [],
|
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
|
return rules
|
||||||
.map((rule) => {
|
.map((rule) => {
|
||||||
const models = (rule.models || [])
|
const models = (rule.models || [])
|
||||||
.filter((m) => m.name?.trim())
|
.filter((m) => m.name?.trim())
|
||||||
.map((m) => {
|
.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;
|
if (m.protocol) obj.protocol = m.protocol;
|
||||||
return obj;
|
return obj;
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, unknown> = {};
|
||||||
for (const param of rule.params || []) {
|
for (const param of rule.params || []) {
|
||||||
if (!param.path?.trim()) continue;
|
if (!param.path?.trim()) continue;
|
||||||
let value: any = param.value;
|
let value: unknown = param.value;
|
||||||
if (param.valueType === 'number') {
|
if (param.valueType === 'number') {
|
||||||
const num = Number(param.value);
|
const num = Number(param.value);
|
||||||
value = Number.isFinite(num) ? num : param.value;
|
value = Number.isFinite(num) ? num : param.value;
|
||||||
@@ -200,13 +242,15 @@ function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
|
|||||||
.filter((rule) => rule.models.length > 0);
|
.filter((rule) => rule.models.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] {
|
function serializePayloadFilterRulesForYaml(
|
||||||
|
rules: PayloadFilterRule[]
|
||||||
|
): Array<Record<string, unknown>> {
|
||||||
return rules
|
return rules
|
||||||
.map((rule) => {
|
.map((rule) => {
|
||||||
const models = (rule.models || [])
|
const models = (rule.models || [])
|
||||||
.filter((m) => m.name?.trim())
|
.filter((m) => m.name?.trim())
|
||||||
.map((m) => {
|
.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;
|
if (m.protocol) obj.protocol = m.protocol;
|
||||||
return obj;
|
return obj;
|
||||||
});
|
});
|
||||||
@@ -225,33 +269,45 @@ export function useVisualConfig() {
|
|||||||
...DEFAULT_VISUAL_VALUES,
|
...DEFAULT_VISUAL_VALUES,
|
||||||
});
|
});
|
||||||
|
|
||||||
const baselineValues = useRef<VisualConfigValues>({ ...DEFAULT_VISUAL_VALUES });
|
const [baselineValues, setBaselineValues] = useState<VisualConfigValues>({
|
||||||
|
...DEFAULT_VISUAL_VALUES,
|
||||||
|
});
|
||||||
|
|
||||||
const visualDirty = useMemo(() => {
|
const visualDirty = useMemo(() => {
|
||||||
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current);
|
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues);
|
||||||
}, [visualValues]);
|
}, [baselineValues, visualValues]);
|
||||||
|
|
||||||
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
|
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
|
||||||
try {
|
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 = {
|
const newValues: VisualConfigValues = {
|
||||||
host: parsed.host || '',
|
host: typeof parsed.host === 'string' ? parsed.host : '',
|
||||||
port: String(parsed.port ?? ''),
|
port: String(parsed.port ?? ''),
|
||||||
|
|
||||||
tlsEnable: Boolean(parsed.tls?.enable),
|
tlsEnable: Boolean(tls?.enable),
|
||||||
tlsCert: parsed.tls?.cert || '',
|
tlsCert: typeof tls?.cert === 'string' ? tls.cert : '',
|
||||||
tlsKey: parsed.tls?.key || '',
|
tlsKey: typeof tls?.key === 'string' ? tls.key : '',
|
||||||
|
|
||||||
rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']),
|
rmAllowRemote: Boolean(remoteManagement?.['allow-remote']),
|
||||||
rmSecretKey: parsed['remote-management']?.['secret-key'] || '',
|
rmSecretKey:
|
||||||
rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']),
|
typeof remoteManagement?.['secret-key'] === 'string' ? remoteManagement['secret-key'] : '',
|
||||||
|
rmDisableControlPanel: Boolean(remoteManagement?.['disable-control-panel']),
|
||||||
rmPanelRepo:
|
rmPanelRepo:
|
||||||
parsed['remote-management']?.['panel-github-repository'] ??
|
typeof remoteManagement?.['panel-github-repository'] === 'string'
|
||||||
parsed['remote-management']?.['panel-repo'] ??
|
? 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']),
|
apiKeysText: parseApiKeysText(parsed['api-keys']),
|
||||||
|
|
||||||
debug: Boolean(parsed.debug),
|
debug: Boolean(parsed.debug),
|
||||||
@@ -260,113 +316,131 @@ export function useVisualConfig() {
|
|||||||
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
|
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
|
||||||
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
|
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']),
|
forceModelPrefix: Boolean(parsed['force-model-prefix']),
|
||||||
requestRetry: String(parsed['request-retry'] ?? ''),
|
requestRetry: String(parsed['request-retry'] ?? ''),
|
||||||
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
|
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
|
||||||
wsAuth: Boolean(parsed['ws-auth']),
|
wsAuth: Boolean(parsed['ws-auth']),
|
||||||
|
|
||||||
quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true),
|
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
|
||||||
quotaSwitchPreviewModel: Boolean(
|
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),
|
payloadDefaultRules: parsePayloadRules(payload?.default),
|
||||||
payloadOverrideRules: parsePayloadRules(parsed.payload?.override),
|
payloadOverrideRules: parsePayloadRules(payload?.override),
|
||||||
payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter),
|
payloadFilterRules: parsePayloadFilterRules(payload?.filter),
|
||||||
|
|
||||||
streaming: {
|
streaming: {
|
||||||
keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''),
|
keepaliveSeconds: String(streaming?.['keepalive-seconds'] ?? ''),
|
||||||
bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''),
|
bootstrapRetries: String(streaming?.['bootstrap-retries'] ?? ''),
|
||||||
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
|
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
setVisualValuesState(newValues);
|
setVisualValuesState(newValues);
|
||||||
baselineValues.current = deepClone(newValues);
|
setBaselineValues(deepClone(newValues));
|
||||||
} catch {
|
} catch {
|
||||||
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
|
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
|
||||||
baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES);
|
setBaselineValues(deepClone(DEFAULT_VISUAL_VALUES));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const applyVisualChangesToYaml = useCallback(
|
const applyVisualChangesToYaml = useCallback(
|
||||||
(currentYaml: string): string => {
|
(currentYaml: string): string => {
|
||||||
try {
|
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;
|
const values = visualValues;
|
||||||
|
|
||||||
setString(parsed, 'host', values.host);
|
setStringInDoc(doc, ['host'], values.host);
|
||||||
setIntFromString(parsed, 'port', values.port);
|
setIntFromStringInDoc(doc, ['port'], values.port);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasOwn(parsed, 'tls') ||
|
docHas(doc, ['tls']) ||
|
||||||
values.tlsEnable ||
|
values.tlsEnable ||
|
||||||
values.tlsCert.trim() ||
|
values.tlsCert.trim() ||
|
||||||
values.tlsKey.trim()
|
values.tlsKey.trim()
|
||||||
) {
|
) {
|
||||||
const tls = ensureRecord(parsed, 'tls');
|
ensureMapInDoc(doc, ['tls']);
|
||||||
setBoolean(tls, 'enable', values.tlsEnable);
|
setBooleanInDoc(doc, ['tls', 'enable'], values.tlsEnable);
|
||||||
setString(tls, 'cert', values.tlsCert);
|
setStringInDoc(doc, ['tls', 'cert'], values.tlsCert);
|
||||||
setString(tls, 'key', values.tlsKey);
|
setStringInDoc(doc, ['tls', 'key'], values.tlsKey);
|
||||||
deleteIfEmpty(parsed, 'tls');
|
deleteIfMapEmpty(doc, ['tls']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasOwn(parsed, 'remote-management') ||
|
docHas(doc, ['remote-management']) ||
|
||||||
values.rmAllowRemote ||
|
values.rmAllowRemote ||
|
||||||
values.rmSecretKey.trim() ||
|
values.rmSecretKey.trim() ||
|
||||||
values.rmDisableControlPanel ||
|
values.rmDisableControlPanel ||
|
||||||
values.rmPanelRepo.trim()
|
values.rmPanelRepo.trim()
|
||||||
) {
|
) {
|
||||||
const rm = ensureRecord(parsed, 'remote-management');
|
ensureMapInDoc(doc, ['remote-management']);
|
||||||
setBoolean(rm, 'allow-remote', values.rmAllowRemote);
|
setBooleanInDoc(doc, ['remote-management', 'allow-remote'], values.rmAllowRemote);
|
||||||
setString(rm, 'secret-key', values.rmSecretKey);
|
setStringInDoc(doc, ['remote-management', 'secret-key'], values.rmSecretKey);
|
||||||
setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel);
|
setBooleanInDoc(
|
||||||
setString(rm, 'panel-github-repository', values.rmPanelRepo);
|
doc,
|
||||||
if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo'];
|
['remote-management', 'disable-control-panel'],
|
||||||
deleteIfEmpty(parsed, 'remote-management');
|
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);
|
setStringInDoc(doc, ['auth-dir'], values.authDir);
|
||||||
if (values.apiKeysText !== baselineValues.current.apiKeysText) {
|
if (values.apiKeysText !== baselineValues.apiKeysText) {
|
||||||
const apiKeys = values.apiKeysText
|
const apiKeys = values.apiKeysText
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((key) => key.trim())
|
.map((key) => key.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (apiKeys.length > 0) {
|
if (apiKeys.length > 0) {
|
||||||
parsed['api-keys'] = apiKeys;
|
doc.setIn(['api-keys'], apiKeys);
|
||||||
} else if (hasOwn(parsed, 'api-keys')) {
|
} else if (docHas(doc, ['api-keys'])) {
|
||||||
delete parsed['api-keys'];
|
doc.deleteIn(['api-keys']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setBoolean(parsed, 'debug', values.debug);
|
setBooleanInDoc(doc, ['debug'], values.debug);
|
||||||
|
|
||||||
setBoolean(parsed, 'commercial-mode', values.commercialMode);
|
setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
|
||||||
setBoolean(parsed, 'logging-to-file', values.loggingToFile);
|
setBooleanInDoc(doc, ['logging-to-file'], values.loggingToFile);
|
||||||
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb);
|
setIntFromStringInDoc(doc, ['logs-max-total-size-mb'], values.logsMaxTotalSizeMb);
|
||||||
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled);
|
setBooleanInDoc(doc, ['usage-statistics-enabled'], values.usageStatisticsEnabled);
|
||||||
|
|
||||||
setString(parsed, 'proxy-url', values.proxyUrl);
|
setStringInDoc(doc, ['proxy-url'], values.proxyUrl);
|
||||||
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix);
|
setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix);
|
||||||
setIntFromString(parsed, 'request-retry', values.requestRetry);
|
setIntFromStringInDoc(doc, ['request-retry'], values.requestRetry);
|
||||||
setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval);
|
setIntFromStringInDoc(doc, ['max-retry-interval'], values.maxRetryInterval);
|
||||||
setBoolean(parsed, 'ws-auth', values.wsAuth);
|
setBooleanInDoc(doc, ['ws-auth'], values.wsAuth);
|
||||||
|
|
||||||
if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) {
|
if (
|
||||||
const quota = ensureRecord(parsed, 'quota-exceeded');
|
docHas(doc, ['quota-exceeded']) ||
|
||||||
quota['switch-project'] = values.quotaSwitchProject;
|
!values.quotaSwitchProject ||
|
||||||
quota['switch-preview-model'] = values.quotaSwitchPreviewModel;
|
!values.quotaSwitchPreviewModel
|
||||||
deleteIfEmpty(parsed, 'quota-exceeded');
|
) {
|
||||||
|
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') {
|
if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
|
||||||
const routing = ensureRecord(parsed, 'routing');
|
ensureMapInDoc(doc, ['routing']);
|
||||||
routing.strategy = values.routingStrategy;
|
doc.setIn(['routing', 'strategy'], values.routingStrategy);
|
||||||
deleteIfEmpty(parsed, 'routing');
|
deleteIfMapEmpty(doc, ['routing']);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keepaliveSeconds =
|
const keepaliveSeconds =
|
||||||
@@ -379,47 +453,60 @@ export function useVisualConfig() {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
const streamingDefined =
|
const streamingDefined =
|
||||||
hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim();
|
docHas(doc, ['streaming']) || keepaliveSeconds.trim() || bootstrapRetries.trim();
|
||||||
if (streamingDefined) {
|
if (streamingDefined) {
|
||||||
const streaming = ensureRecord(parsed, 'streaming');
|
ensureMapInDoc(doc, ['streaming']);
|
||||||
setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds);
|
setIntFromStringInDoc(doc, ['streaming', 'keepalive-seconds'], keepaliveSeconds);
|
||||||
setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries);
|
setIntFromStringInDoc(doc, ['streaming', 'bootstrap-retries'], bootstrapRetries);
|
||||||
deleteIfEmpty(parsed, 'streaming');
|
deleteIfMapEmpty(doc, ['streaming']);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval);
|
setIntFromStringInDoc(
|
||||||
|
doc,
|
||||||
|
['nonstream-keepalive-interval'],
|
||||||
|
nonstreamKeepaliveInterval
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasOwn(parsed, 'payload') ||
|
docHas(doc, ['payload']) ||
|
||||||
values.payloadDefaultRules.length > 0 ||
|
values.payloadDefaultRules.length > 0 ||
|
||||||
values.payloadOverrideRules.length > 0 ||
|
values.payloadOverrideRules.length > 0 ||
|
||||||
values.payloadFilterRules.length > 0
|
values.payloadFilterRules.length > 0
|
||||||
) {
|
) {
|
||||||
const payload = ensureRecord(parsed, 'payload');
|
ensureMapInDoc(doc, ['payload']);
|
||||||
if (values.payloadDefaultRules.length > 0) {
|
if (values.payloadDefaultRules.length > 0) {
|
||||||
payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules);
|
doc.setIn(
|
||||||
} else if (hasOwn(payload, 'default')) {
|
['payload', 'default'],
|
||||||
delete payload.default;
|
serializePayloadRulesForYaml(values.payloadDefaultRules)
|
||||||
|
);
|
||||||
|
} else if (docHas(doc, ['payload', 'default'])) {
|
||||||
|
doc.deleteIn(['payload', 'default']);
|
||||||
}
|
}
|
||||||
if (values.payloadOverrideRules.length > 0) {
|
if (values.payloadOverrideRules.length > 0) {
|
||||||
payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules);
|
doc.setIn(
|
||||||
} else if (hasOwn(payload, 'override')) {
|
['payload', 'override'],
|
||||||
delete payload.override;
|
serializePayloadRulesForYaml(values.payloadOverrideRules)
|
||||||
|
);
|
||||||
|
} else if (docHas(doc, ['payload', 'override'])) {
|
||||||
|
doc.deleteIn(['payload', 'override']);
|
||||||
}
|
}
|
||||||
if (values.payloadFilterRules.length > 0) {
|
if (values.payloadFilterRules.length > 0) {
|
||||||
payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules);
|
doc.setIn(
|
||||||
} else if (hasOwn(payload, 'filter')) {
|
['payload', 'filter'],
|
||||||
delete 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 {
|
} catch {
|
||||||
return currentYaml;
|
return currentYaml;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[visualValues]
|
[baselineValues, visualValues]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
|
||||||
@@ -442,17 +529,66 @@ export function useVisualConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
|
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
|
||||||
{ value: '', label: '默认' },
|
{
|
||||||
{ value: 'openai', label: 'OpenAI' },
|
value: '',
|
||||||
{ value: 'gemini', label: 'Gemini' },
|
labelKey: 'config_management.visual.payload_rules.provider_default',
|
||||||
{ value: 'claude', label: 'Claude' },
|
defaultLabel: 'Default',
|
||||||
{ value: 'codex', label: 'Codex' },
|
},
|
||||||
{ value: 'antigravity', label: 'Antigravity' },
|
{
|
||||||
|
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;
|
] as const;
|
||||||
|
|
||||||
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
|
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
|
||||||
{ value: 'string', label: '字符串' },
|
{
|
||||||
{ value: 'number', label: '数字' },
|
value: 'string',
|
||||||
{ value: 'boolean', label: '布尔' },
|
labelKey: 'config_management.visual.payload_rules.value_type_string',
|
||||||
{ value: 'json', label: 'JSON' },
|
defaultLabel: 'String',
|
||||||
] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: 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;
|
||||||
|
}>;
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import i18n from 'i18next';
|
|||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
import zhCN from './locales/zh-CN.json';
|
import zhCN from './locales/zh-CN.json';
|
||||||
import en from './locales/en.json';
|
import en from './locales/en.json';
|
||||||
|
import ru from './locales/ru.json';
|
||||||
import { getInitialLanguage } from '@/utils/language';
|
import { getInitialLanguage } from '@/utils/language';
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources: {
|
resources: {
|
||||||
'zh-CN': { translation: zhCN },
|
'zh-CN': { translation: zhCN },
|
||||||
en: { translation: en }
|
en: { translation: en },
|
||||||
|
ru: { translation: ru }
|
||||||
},
|
},
|
||||||
lng: getInitialLanguage(),
|
lng: getInitialLanguage(),
|
||||||
fallbackLng: 'zh-CN',
|
fallbackLng: 'zh-CN',
|
||||||
|
|||||||
@@ -38,19 +38,26 @@
|
|||||||
"quota_update_required": "Please update the CPA version or check for updates",
|
"quota_update_required": "Please update the CPA version or check for updates",
|
||||||
"quota_check_credential": "Please check the credential status",
|
"quota_check_credential": "Please check the credential status",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
|
"status": "Status",
|
||||||
|
"action": "Action",
|
||||||
"custom_headers_label": "Custom Headers",
|
"custom_headers_label": "Custom Headers",
|
||||||
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
|
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
|
||||||
"custom_headers_add": "Add Header",
|
"custom_headers_add": "Add Header",
|
||||||
"custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header",
|
"custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header",
|
||||||
"custom_headers_value_placeholder": "Header value",
|
"custom_headers_value_placeholder": "Header value",
|
||||||
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
|
"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": {
|
"title": {
|
||||||
"main": "CLI Proxy API Management Center",
|
"main": "CLI Proxy API Management Center",
|
||||||
"login": "CLI Proxy API Management Center",
|
"login": "CLI Proxy API Management Center",
|
||||||
"abbr": "CPAMC"
|
"abbr": "CPAMC"
|
||||||
},
|
},
|
||||||
|
"splash": {
|
||||||
|
"title": "CLI Proxy API",
|
||||||
|
"subtitle": "Management Center"
|
||||||
|
},
|
||||||
"auto_login": {
|
"auto_login": {
|
||||||
"title": "Auto Login in Progress...",
|
"title": "Auto Login in Progress...",
|
||||||
"message": "Attempting to connect to server using locally saved connection information"
|
"message": "Attempting to connect to server using locally saved connection information"
|
||||||
@@ -191,6 +198,8 @@
|
|||||||
"gemini_keys_add_btn": "Add Key",
|
"gemini_keys_add_btn": "Add Key",
|
||||||
"gemini_base_url_label": "Base URL (Optional):",
|
"gemini_base_url_label": "Base URL (Optional):",
|
||||||
"gemini_base_url_placeholder": "e.g.: https://generativelanguage.googleapis.com",
|
"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_title": "Edit Gemini API Key",
|
||||||
"gemini_edit_modal_key_label": "API Key:",
|
"gemini_edit_modal_key_label": "API Key:",
|
||||||
"gemini_delete_confirm": "Are you sure you want to delete this Gemini key?",
|
"gemini_delete_confirm": "Are you sure you want to delete this Gemini key?",
|
||||||
@@ -241,6 +250,31 @@
|
|||||||
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
|
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
|
||||||
"claude_models_add_btn": "Add Model",
|
"claude_models_add_btn": "Add Model",
|
||||||
"claude_models_count": "Models Count",
|
"claude_models_count": "Models Count",
|
||||||
|
"claude_models_fetch_button": "Fetch via /v1/models",
|
||||||
|
"claude_models_fetch_title": "Pick Models from Claude /v1/models",
|
||||||
|
"claude_models_fetch_hint": "Call GET /v1/models with Anthropic headers. By default, this sends x-api-key and anthropic-version: 2023-06-01, merged with your custom headers.",
|
||||||
|
"claude_models_fetch_url_label": "Request URL",
|
||||||
|
"claude_models_fetch_refresh": "Refresh",
|
||||||
|
"claude_models_fetch_loading": "Fetching models from Claude /v1/models...",
|
||||||
|
"claude_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
|
||||||
|
"claude_models_fetch_error": "Failed to fetch Claude models",
|
||||||
|
"claude_models_fetch_apply": "Add selected models",
|
||||||
|
"claude_models_search_label": "Search models",
|
||||||
|
"claude_models_search_placeholder": "Filter by name, alias, or description",
|
||||||
|
"claude_models_search_empty": "No models match your search. Try a different keyword.",
|
||||||
|
"claude_models_fetch_added": "{{count}} new models added",
|
||||||
|
"claude_test_title": "Connection Test",
|
||||||
|
"claude_test_hint": "Send a test request to /v1/messages using Anthropic headers to verify this configuration.",
|
||||||
|
"claude_test_select_placeholder": "Choose from current models",
|
||||||
|
"claude_test_select_empty": "No models configured. Add models first",
|
||||||
|
"claude_test_action": "Test",
|
||||||
|
"claude_test_running": "Sending Claude test request...",
|
||||||
|
"claude_test_timeout": "Test request timed out after {{seconds}} seconds.",
|
||||||
|
"claude_test_success": "Test succeeded. Claude model responded.",
|
||||||
|
"claude_test_failed": "Test failed",
|
||||||
|
"claude_test_key_required": "Please provide a Claude API key or set x-api-key in custom headers",
|
||||||
|
"claude_test_model_required": "Please select a model to test",
|
||||||
|
"claude_test_endpoint_invalid": "Unable to build a valid Claude /v1/messages endpoint",
|
||||||
"vertex_title": "Vertex API Configuration",
|
"vertex_title": "Vertex API Configuration",
|
||||||
"vertex_add_button": "Add Configuration",
|
"vertex_add_button": "Add Configuration",
|
||||||
"vertex_empty_title": "No Vertex Configuration",
|
"vertex_empty_title": "No Vertex Configuration",
|
||||||
@@ -333,7 +367,13 @@
|
|||||||
"openai_test_success": "Test succeeded. The model responded.",
|
"openai_test_success": "Test succeeded. The model responded.",
|
||||||
"openai_test_failed": "Test failed",
|
"openai_test_failed": "Test failed",
|
||||||
"openai_test_select_placeholder": "Choose from current models",
|
"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": {
|
"auth_files": {
|
||||||
"title": "Auth Files Management",
|
"title": "Auth Files Management",
|
||||||
@@ -376,6 +416,7 @@
|
|||||||
"filter_qwen": "Qwen",
|
"filter_qwen": "Qwen",
|
||||||
"filter_gemini": "Gemini",
|
"filter_gemini": "Gemini",
|
||||||
"filter_gemini-cli": "GeminiCLI",
|
"filter_gemini-cli": "GeminiCLI",
|
||||||
|
"filter_kimi": "Kimi",
|
||||||
"filter_aistudio": "AIStudio",
|
"filter_aistudio": "AIStudio",
|
||||||
"filter_claude": "Claude",
|
"filter_claude": "Claude",
|
||||||
"filter_codex": "Codex",
|
"filter_codex": "Codex",
|
||||||
@@ -387,6 +428,7 @@
|
|||||||
"type_qwen": "Qwen",
|
"type_qwen": "Qwen",
|
||||||
"type_gemini": "Gemini",
|
"type_gemini": "Gemini",
|
||||||
"type_gemini-cli": "GeminiCLI",
|
"type_gemini-cli": "GeminiCLI",
|
||||||
|
"type_kimi": "Kimi",
|
||||||
"type_aistudio": "AIStudio",
|
"type_aistudio": "AIStudio",
|
||||||
"type_claude": "Claude",
|
"type_claude": "Claude",
|
||||||
"type_codex": "Codex",
|
"type_codex": "Codex",
|
||||||
@@ -403,23 +445,39 @@
|
|||||||
"models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.",
|
"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": "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_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
|
||||||
"models_excluded_badge": "Excluded",
|
"models_excluded_badge": "Disabled",
|
||||||
"models_excluded_hint": "This model is excluded by OAuth",
|
"models_excluded_hint": "This OAuth model is disabled",
|
||||||
"status_toggle_label": "Enabled",
|
"status_toggle_label": "Enabled",
|
||||||
"status_enabled_success": "\"{{name}}\" enabled",
|
"status_enabled_success": "\"{{name}}\" enabled",
|
||||||
"status_disabled_success": "\"{{name}}\" disabled",
|
"status_disabled_success": "\"{{name}}\" disabled",
|
||||||
"prefix_proxy_button": "Edit prefix/proxy_url",
|
"batch_status_success": "{{count}} files updated successfully",
|
||||||
"prefix_proxy_loading": "Loading credential...",
|
"batch_status_partial": "{{success}} updated, {{failed}} failed",
|
||||||
"prefix_proxy_source_label": "Credential JSON",
|
"batch_delete_title": "Delete Selected Files",
|
||||||
"prefix_label": "prefix",
|
"batch_delete_confirm": "Are you sure you want to delete {{count}} files?",
|
||||||
"proxy_url_label": "proxy_url",
|
"batch_selected": "{{count}} selected",
|
||||||
|
"batch_select_all": "Select All",
|
||||||
|
"batch_deselect": "Deselect",
|
||||||
|
"batch_enable": "Enable",
|
||||||
|
"batch_disable": "Disable",
|
||||||
|
"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": "",
|
"prefix_placeholder": "",
|
||||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||||
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
"priority_label": "Priority (priority)",
|
||||||
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
|
"priority_placeholder": "e.g. 10 or -1",
|
||||||
"card_tools_title": "Tools",
|
"priority_hint": "Integers only. Invalid values are ignored. Larger value means higher priority.",
|
||||||
"quota_refresh_single": "Refresh quota",
|
"excluded_models_label": "Excluded models (excluded_models)",
|
||||||
"quota_refresh_hint": "Refresh quota for this credential only",
|
"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_success": "Quota refreshed for \"{{name}}\"",
|
||||||
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
|
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
|
||||||
},
|
},
|
||||||
@@ -427,7 +485,7 @@
|
|||||||
"title": "Antigravity Quota",
|
"title": "Antigravity Quota",
|
||||||
"empty_title": "No Antigravity Auth Files",
|
"empty_title": "No Antigravity Auth Files",
|
||||||
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
|
"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...",
|
"loading": "Loading quota...",
|
||||||
"load_failed": "Failed to load quota: {{message}}",
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
"missing_auth_index": "Auth file missing auth_index",
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
@@ -435,11 +493,31 @@
|
|||||||
"refresh_button": "Refresh Quota",
|
"refresh_button": "Refresh Quota",
|
||||||
"fetch_all": "Fetch All"
|
"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": {
|
"codex_quota": {
|
||||||
"title": "Codex Quota",
|
"title": "Codex Quota",
|
||||||
"empty_title": "No Codex Auth Files",
|
"empty_title": "No Codex Auth Files",
|
||||||
"empty_desc": "Upload a Codex credential to view quota.",
|
"empty_desc": "Upload a Codex credential to view quota.",
|
||||||
"idle": "Not loaded. Click Refresh Button.",
|
"idle": "Click here to refresh quota",
|
||||||
"loading": "Loading quota...",
|
"loading": "Loading quota...",
|
||||||
"load_failed": "Failed to load quota: {{message}}",
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
"missing_auth_index": "Auth file missing auth_index",
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
@@ -452,6 +530,8 @@
|
|||||||
"secondary_window": "Weekly limit",
|
"secondary_window": "Weekly limit",
|
||||||
"code_review_primary_window": "Code review 5-hour limit",
|
"code_review_primary_window": "Code review 5-hour limit",
|
||||||
"code_review_secondary_window": "Code review weekly 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_label": "Plan",
|
||||||
"plan_plus": "Plus",
|
"plan_plus": "Plus",
|
||||||
"plan_team": "Team",
|
"plan_team": "Team",
|
||||||
@@ -461,7 +541,7 @@
|
|||||||
"title": "Gemini CLI Quota",
|
"title": "Gemini CLI Quota",
|
||||||
"empty_title": "No Gemini CLI Auth Files",
|
"empty_title": "No Gemini CLI Auth Files",
|
||||||
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
|
"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...",
|
"loading": "Loading quota...",
|
||||||
"load_failed": "Failed to load quota: {{message}}",
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
"missing_auth_index": "Auth file missing auth_index",
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
@@ -491,43 +571,43 @@
|
|||||||
"result_file": "Persisted file"
|
"result_file": "Persisted file"
|
||||||
},
|
},
|
||||||
"oauth_excluded": {
|
"oauth_excluded": {
|
||||||
"title": "OAuth Excluded Models",
|
"title": "OAuth Model Disablement",
|
||||||
"description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.",
|
"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 Exclusion",
|
"add": "Add Disablement",
|
||||||
"add_title": "Add provider exclusion",
|
"add_title": "Add provider model disablement",
|
||||||
"edit_title": "Edit exclusions for {{provider}}",
|
"edit_title": "Edit model disablement for {{provider}}",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"refreshing": "Refreshing...",
|
"refreshing": "Refreshing...",
|
||||||
"provider_label": "Provider",
|
"provider_label": "Provider",
|
||||||
"provider_auto": "Follow current filter",
|
"provider_auto": "Follow current filter",
|
||||||
"provider_placeholder": "e.g. gemini-cli",
|
"provider_placeholder": "e.g. gemini-cli",
|
||||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
"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_loading": "Loading models...",
|
||||||
"models_unsupported": "Current CPA version does not support fetching model lists.",
|
"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.",
|
"no_models_available": "No models available for this provider.",
|
||||||
"save": "Save/Update",
|
"save": "Save/Update",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"save_success": "Excluded models updated",
|
"save_success": "Model disablement updated",
|
||||||
"save_failed": "Failed to update excluded models",
|
"save_failed": "Failed to update model disablement",
|
||||||
"delete": "Delete Provider",
|
"delete": "Delete Provider",
|
||||||
"delete_confirm": "Delete the exclusion list for {{provider}}?",
|
"delete_confirm": "Delete model disablement for {{provider}}?",
|
||||||
"delete_success": "Exclusion list removed",
|
"delete_success": "Provider model disablement removed",
|
||||||
"delete_failed": "Failed to delete exclusion list",
|
"delete_failed": "Failed to delete model disablement",
|
||||||
"deleting": "Deleting...",
|
"deleting": "Deleting...",
|
||||||
"no_models": "No excluded models",
|
"no_models": "No disabled models configured",
|
||||||
"model_count": "{{count}} models excluded",
|
"model_count": "{{count}} models disabled",
|
||||||
"list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.",
|
"list_empty_all": "No provider model disablement yet; click “Add Disablement” to create one.",
|
||||||
"list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.",
|
"list_empty_filtered": "No disabled items in this scope; click “Add Disablement” to add.",
|
||||||
"disconnected": "Connect to the server to view exclusions",
|
"disconnected": "Connect to the server to view model disablement",
|
||||||
"load_failed": "Failed to load exclusion list",
|
"load_failed": "Failed to load model disablement",
|
||||||
"provider_required": "Please enter a provider first",
|
"provider_required": "Please enter a provider first",
|
||||||
"scope_all": "Scope: All providers",
|
"scope_all": "Scope: All providers",
|
||||||
"scope_provider": "Scope: {{provider}}",
|
"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_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": {
|
"oauth_model_alias": {
|
||||||
"title": "OAuth Model Aliases",
|
"title": "OAuth Model Aliases",
|
||||||
@@ -640,6 +720,17 @@
|
|||||||
"gemini_cli_oauth_status_error": "Authentication failed:",
|
"gemini_cli_oauth_status_error": "Authentication failed:",
|
||||||
"gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:",
|
"gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:",
|
||||||
"gemini_cli_oauth_polling_error": "Failed to check authentication status:",
|
"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_title": "Qwen OAuth",
|
||||||
"qwen_oauth_button": "Start Qwen Login",
|
"qwen_oauth_button": "Start Qwen Login",
|
||||||
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
|
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
|
||||||
@@ -711,6 +802,11 @@
|
|||||||
"api_details": "API Details",
|
"api_details": "API Details",
|
||||||
"by_hour": "By Hour",
|
"by_hour": "By Hour",
|
||||||
"by_day": "By Day",
|
"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",
|
"refresh": "Refresh",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
@@ -758,12 +854,29 @@
|
|||||||
"cost_axis_label": "Cost ($)",
|
"cost_axis_label": "Cost ($)",
|
||||||
"cost_need_price": "Set a model price to view cost stats",
|
"cost_need_price": "Set a model price to view cost stats",
|
||||||
"cost_need_usage": "No usage data available to calculate cost",
|
"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": {
|
"stats": {
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"failure": "Failure"
|
"failure": "Failure"
|
||||||
},
|
},
|
||||||
|
"status_bar": {
|
||||||
|
"success_short": "✓",
|
||||||
|
"failure_short": "✗",
|
||||||
|
"no_requests": "No requests"
|
||||||
|
},
|
||||||
|
"service_health": {
|
||||||
|
"title": "Service Health",
|
||||||
|
"window": "Last 7 days",
|
||||||
|
"oldest": "Oldest",
|
||||||
|
"newest": "Latest"
|
||||||
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Logs Viewer",
|
"title": "Logs Viewer",
|
||||||
"refresh_button": "Refresh Logs",
|
"refresh_button": "Refresh Logs",
|
||||||
@@ -834,6 +947,13 @@
|
|||||||
"search_no_results": "No results",
|
"search_no_results": "No results",
|
||||||
"search_prev": "Previous",
|
"search_prev": "Previous",
|
||||||
"search_next": "Next",
|
"search_next": "Next",
|
||||||
|
"diff": {
|
||||||
|
"title": "Review Changes",
|
||||||
|
"current": "Current",
|
||||||
|
"modified": "Modified",
|
||||||
|
"confirm": "Confirm Save",
|
||||||
|
"no_changes": "No changes detected"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"visual": "Visual Editor",
|
"visual": "Visual Editor",
|
||||||
"source": "Source File Editor"
|
"source": "Source File Editor"
|
||||||
@@ -877,9 +997,9 @@
|
|||||||
"debug": "Debug Mode",
|
"debug": "Debug Mode",
|
||||||
"debug_desc": "Enable verbose debug logging",
|
"debug_desc": "Enable verbose debug logging",
|
||||||
"commercial_mode": "Commercial Mode",
|
"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": "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": "Usage Statistics",
|
||||||
"usage_statistics_desc": "Collect usage statistics",
|
"usage_statistics_desc": "Collect usage statistics",
|
||||||
"logs_max_size": "Log File Size Limit (MB)"
|
"logs_max_size": "Log File Size Limit (MB)"
|
||||||
@@ -932,6 +1052,7 @@
|
|||||||
"api_keys": {
|
"api_keys": {
|
||||||
"label": "API Keys List (api-keys)",
|
"label": "API Keys List (api-keys)",
|
||||||
"add": "Add API Key",
|
"add": "Add API Key",
|
||||||
|
"generate": "Generate",
|
||||||
"empty": "No API keys",
|
"empty": "No API keys",
|
||||||
"hint": "Each entry represents an API key (consistent with 'API Key Management' page style)",
|
"hint": "Each entry represents an API key (consistent with 'API Key Management' page style)",
|
||||||
"edit_title": "Edit API Key",
|
"edit_title": "Edit API Key",
|
||||||
@@ -956,6 +1077,17 @@
|
|||||||
"add_param": "Add Parameter",
|
"add_param": "Add Parameter",
|
||||||
"no_rules": "No rules",
|
"no_rules": "No rules",
|
||||||
"add_rule": "Add Rule",
|
"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_string": "String value",
|
||||||
"value_number": "Number value (e.g., 0.7)",
|
"value_number": "Number value (e.g., 0.7)",
|
||||||
"value_boolean": "true or false",
|
"value_boolean": "true or false",
|
||||||
@@ -979,6 +1111,7 @@
|
|||||||
},
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "Management Center Info",
|
"title": "Management Center Info",
|
||||||
|
"about_title": "CLI Proxy API Management Center",
|
||||||
"connection_status_title": "Connection Status",
|
"connection_status_title": "Connection Status",
|
||||||
"api_status_label": "API Status:",
|
"api_status_label": "API Status:",
|
||||||
"config_status_label": "Config Status:",
|
"config_status_label": "Config Status:",
|
||||||
@@ -1083,12 +1216,15 @@
|
|||||||
"gemini_api_key": "Gemini API key",
|
"gemini_api_key": "Gemini API key",
|
||||||
"codex_api_key": "Codex API key",
|
"codex_api_key": "Codex API key",
|
||||||
"claude_api_key": "Claude 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"
|
"link_copied": "Link copied to clipboard"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"switch": "Language",
|
"switch": "Language",
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
"english": "English"
|
"english": "English",
|
||||||
|
"russian": "Русский"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"switch": "Theme",
|
"switch": "Theme",
|
||||||
|
|||||||
1252
src/i18n/locales/ru.json
Normal file
1252
src/i18n/locales/ru.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -38,19 +38,26 @@
|
|||||||
"quota_update_required": "请更新 CPA 版本或检查更新",
|
"quota_update_required": "请更新 CPA 版本或检查更新",
|
||||||
"quota_check_credential": "请检查凭证状态",
|
"quota_check_credential": "请检查凭证状态",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
|
"status": "状态",
|
||||||
|
"action": "操作",
|
||||||
"custom_headers_label": "自定义请求头",
|
"custom_headers_label": "自定义请求头",
|
||||||
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
|
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
|
||||||
"custom_headers_add": "添加请求头",
|
"custom_headers_add": "添加请求头",
|
||||||
"custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header",
|
"custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header",
|
||||||
"custom_headers_value_placeholder": "Header 值",
|
"custom_headers_value_placeholder": "Header 值",
|
||||||
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
|
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
|
||||||
"model_alias_placeholder": "模型别名 (可选)"
|
"model_alias_placeholder": "模型别名 (可选)",
|
||||||
|
"invalid_provider_index": "无效的提供商索引。"
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"main": "CLI Proxy API Management Center",
|
"main": "CLI Proxy API Management Center",
|
||||||
"login": "CLI Proxy API Management Center",
|
"login": "CLI Proxy API Management Center",
|
||||||
"abbr": "CPAMC"
|
"abbr": "CPAMC"
|
||||||
},
|
},
|
||||||
|
"splash": {
|
||||||
|
"title": "CLI Proxy API",
|
||||||
|
"subtitle": "管理中心"
|
||||||
|
},
|
||||||
"auto_login": {
|
"auto_login": {
|
||||||
"title": "正在自动登录...",
|
"title": "正在自动登录...",
|
||||||
"message": "正在使用本地保存的连接信息尝试连接服务器"
|
"message": "正在使用本地保存的连接信息尝试连接服务器"
|
||||||
@@ -191,6 +198,8 @@
|
|||||||
"gemini_keys_add_btn": "添加密钥",
|
"gemini_keys_add_btn": "添加密钥",
|
||||||
"gemini_base_url_label": "Base URL (可选)",
|
"gemini_base_url_label": "Base URL (可选)",
|
||||||
"gemini_base_url_placeholder": "例如: https://generativelanguage.googleapis.com",
|
"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_title": "编辑Gemini API密钥",
|
||||||
"gemini_edit_modal_key_label": "API密钥:",
|
"gemini_edit_modal_key_label": "API密钥:",
|
||||||
"gemini_delete_confirm": "确定要删除这个Gemini密钥吗?",
|
"gemini_delete_confirm": "确定要删除这个Gemini密钥吗?",
|
||||||
@@ -241,6 +250,31 @@
|
|||||||
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
|
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
|
||||||
"claude_models_add_btn": "添加模型",
|
"claude_models_add_btn": "添加模型",
|
||||||
"claude_models_count": "模型数量",
|
"claude_models_count": "模型数量",
|
||||||
|
"claude_models_fetch_button": "从 /v1/models 获取",
|
||||||
|
"claude_models_fetch_title": "从 Claude /v1/models 选择模型",
|
||||||
|
"claude_models_fetch_hint": "按 Anthropic 规范请求 GET /v1/models,默认附带 x-api-key 与 anthropic-version: 2023-06-01;也会合并你配置的自定义请求头。",
|
||||||
|
"claude_models_fetch_url_label": "请求地址",
|
||||||
|
"claude_models_fetch_refresh": "重新获取",
|
||||||
|
"claude_models_fetch_loading": "正在从 Claude /v1/models 获取模型列表...",
|
||||||
|
"claude_models_fetch_empty": "未获取到模型,请检查 Base URL、API Key 或请求头。",
|
||||||
|
"claude_models_fetch_error": "获取 Claude 模型失败",
|
||||||
|
"claude_models_fetch_apply": "添加所选模型",
|
||||||
|
"claude_models_search_label": "搜索模型",
|
||||||
|
"claude_models_search_placeholder": "按名称、别名或描述筛选",
|
||||||
|
"claude_models_search_empty": "没有匹配的模型,请更换关键字试试。",
|
||||||
|
"claude_models_fetch_added": "已添加 {{count}} 个新模型",
|
||||||
|
"claude_test_title": "连通性测试",
|
||||||
|
"claude_test_hint": "按 Anthropic 规范向 /v1/messages 发送测试请求,验证当前配置是否可用。",
|
||||||
|
"claude_test_select_placeholder": "从当前模型列表选择",
|
||||||
|
"claude_test_select_empty": "当前未配置模型,请先添加模型",
|
||||||
|
"claude_test_action": "测试",
|
||||||
|
"claude_test_running": "正在发送 Claude 测试请求...",
|
||||||
|
"claude_test_timeout": "测试请求超时({{seconds}}秒)。",
|
||||||
|
"claude_test_success": "测试成功,Claude 模型可用。",
|
||||||
|
"claude_test_failed": "测试失败",
|
||||||
|
"claude_test_key_required": "请先填写 Claude API Key 或在自定义请求头中设置 x-api-key",
|
||||||
|
"claude_test_model_required": "请选择要测试的模型",
|
||||||
|
"claude_test_endpoint_invalid": "无法构造有效的 Claude /v1/messages 请求地址",
|
||||||
"vertex_title": "Vertex API 配置",
|
"vertex_title": "Vertex API 配置",
|
||||||
"vertex_add_button": "添加配置",
|
"vertex_add_button": "添加配置",
|
||||||
"vertex_empty_title": "暂无Vertex配置",
|
"vertex_empty_title": "暂无Vertex配置",
|
||||||
@@ -333,7 +367,13 @@
|
|||||||
"openai_test_success": "测试成功,模型可用。",
|
"openai_test_success": "测试成功,模型可用。",
|
||||||
"openai_test_failed": "测试失败",
|
"openai_test_failed": "测试失败",
|
||||||
"openai_test_select_placeholder": "从当前模型列表选择",
|
"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": {
|
"auth_files": {
|
||||||
"title": "认证文件管理",
|
"title": "认证文件管理",
|
||||||
@@ -376,6 +416,7 @@
|
|||||||
"filter_qwen": "Qwen",
|
"filter_qwen": "Qwen",
|
||||||
"filter_gemini": "Gemini",
|
"filter_gemini": "Gemini",
|
||||||
"filter_gemini-cli": "GeminiCLI",
|
"filter_gemini-cli": "GeminiCLI",
|
||||||
|
"filter_kimi": "Kimi",
|
||||||
"filter_aistudio": "AIStudio",
|
"filter_aistudio": "AIStudio",
|
||||||
"filter_claude": "Claude",
|
"filter_claude": "Claude",
|
||||||
"filter_codex": "Codex",
|
"filter_codex": "Codex",
|
||||||
@@ -387,6 +428,7 @@
|
|||||||
"type_qwen": "Qwen",
|
"type_qwen": "Qwen",
|
||||||
"type_gemini": "Gemini",
|
"type_gemini": "Gemini",
|
||||||
"type_gemini-cli": "GeminiCLI",
|
"type_gemini-cli": "GeminiCLI",
|
||||||
|
"type_kimi": "Kimi",
|
||||||
"type_aistudio": "AIStudio",
|
"type_aistudio": "AIStudio",
|
||||||
"type_claude": "Claude",
|
"type_claude": "Claude",
|
||||||
"type_codex": "Codex",
|
"type_codex": "Codex",
|
||||||
@@ -403,23 +445,39 @@
|
|||||||
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
|
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
|
||||||
"models_unsupported": "当前版本不支持此功能",
|
"models_unsupported": "当前版本不支持此功能",
|
||||||
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
||||||
"models_excluded_badge": "已排除",
|
"models_excluded_badge": "已禁用",
|
||||||
"models_excluded_hint": "此模型已被 OAuth 排除",
|
"models_excluded_hint": "此 OAuth 模型已被禁用",
|
||||||
"status_toggle_label": "启用",
|
"status_toggle_label": "启用",
|
||||||
"status_enabled_success": "已启用 \"{{name}}\"",
|
"status_enabled_success": "已启用 \"{{name}}\"",
|
||||||
"status_disabled_success": "已停用 \"{{name}}\"",
|
"status_disabled_success": "已停用 \"{{name}}\"",
|
||||||
"prefix_proxy_button": "配置 prefix/proxy_url",
|
"batch_status_success": "已成功更新 {{count}} 个文件",
|
||||||
"prefix_proxy_loading": "正在加载凭证文件...",
|
"batch_status_partial": "成功 {{success}} 个,失败 {{failed}} 个",
|
||||||
"prefix_proxy_source_label": "凭证 JSON",
|
"batch_delete_title": "删除选中文件",
|
||||||
"prefix_label": "prefix",
|
"batch_delete_confirm": "确定要删除 {{count}} 个文件吗?",
|
||||||
"proxy_url_label": "proxy_url",
|
"batch_selected": "已选 {{count}} 项",
|
||||||
|
"batch_select_all": "全选",
|
||||||
|
"batch_deselect": "取消选择",
|
||||||
|
"batch_enable": "启用",
|
||||||
|
"batch_disable": "禁用",
|
||||||
|
"prefix_proxy_button": "编辑认证文件字段",
|
||||||
|
"auth_field_editor_title": "编辑认证文件字段 - {{name}}",
|
||||||
|
"prefix_proxy_loading": "正在加载认证文件...",
|
||||||
|
"prefix_proxy_source_label": "认证文件 JSON(预览)",
|
||||||
|
"prefix_label": "前缀(prefix)",
|
||||||
|
"proxy_url_label": "代理 URL(proxy_url)",
|
||||||
"prefix_placeholder": "",
|
"prefix_placeholder": "",
|
||||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
"priority_label": "优先级(priority)",
|
||||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
|
"priority_placeholder": "例如: 10 或 -1",
|
||||||
"card_tools_title": "配置面板",
|
"priority_hint": "仅支持整数;非法值会被忽略。数值越大优先级越高。",
|
||||||
"quota_refresh_single": "刷新额度",
|
"excluded_models_label": "排除模型(excluded_models)",
|
||||||
"quota_refresh_hint": "仅刷新当前凭证的额度数据",
|
"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_success": "已刷新 \"{{name}}\" 的额度",
|
||||||
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
|
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
|
||||||
},
|
},
|
||||||
@@ -427,7 +485,7 @@
|
|||||||
"title": "Antigravity 额度",
|
"title": "Antigravity 额度",
|
||||||
"empty_title": "暂无 Antigravity 认证",
|
"empty_title": "暂无 Antigravity 认证",
|
||||||
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
||||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
"idle": "点击此处刷新额度",
|
||||||
"loading": "正在加载额度...",
|
"loading": "正在加载额度...",
|
||||||
"load_failed": "额度获取失败:{{message}}",
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
"missing_auth_index": "认证文件缺少 auth_index",
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
@@ -435,11 +493,31 @@
|
|||||||
"refresh_button": "刷新额度",
|
"refresh_button": "刷新额度",
|
||||||
"fetch_all": "获取全部"
|
"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": {
|
"codex_quota": {
|
||||||
"title": "Codex 额度",
|
"title": "Codex 额度",
|
||||||
"empty_title": "暂无 Codex 认证",
|
"empty_title": "暂无 Codex 认证",
|
||||||
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
|
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
|
||||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
"idle": "点击此处刷新额度",
|
||||||
"loading": "正在加载额度...",
|
"loading": "正在加载额度...",
|
||||||
"load_failed": "额度获取失败:{{message}}",
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
"missing_auth_index": "认证文件缺少 auth_index",
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
@@ -452,6 +530,8 @@
|
|||||||
"secondary_window": "周限额",
|
"secondary_window": "周限额",
|
||||||
"code_review_primary_window": "代码审查 5 小时限额",
|
"code_review_primary_window": "代码审查 5 小时限额",
|
||||||
"code_review_secondary_window": "代码审查周限额",
|
"code_review_secondary_window": "代码审查周限额",
|
||||||
|
"additional_primary_window": "{{name}} 5 小时限额",
|
||||||
|
"additional_secondary_window": "{{name}} 周限额",
|
||||||
"plan_label": "套餐",
|
"plan_label": "套餐",
|
||||||
"plan_plus": "Plus",
|
"plan_plus": "Plus",
|
||||||
"plan_team": "Team",
|
"plan_team": "Team",
|
||||||
@@ -461,7 +541,7 @@
|
|||||||
"title": "Gemini CLI 额度",
|
"title": "Gemini CLI 额度",
|
||||||
"empty_title": "暂无 Gemini CLI 认证",
|
"empty_title": "暂无 Gemini CLI 认证",
|
||||||
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
|
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
|
||||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
"idle": "点击此处刷新额度",
|
||||||
"loading": "正在加载额度...",
|
"loading": "正在加载额度...",
|
||||||
"load_failed": "额度获取失败:{{message}}",
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
"missing_auth_index": "认证文件缺少 auth_index",
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
@@ -491,43 +571,43 @@
|
|||||||
"result_file": "存储文件"
|
"result_file": "存储文件"
|
||||||
},
|
},
|
||||||
"oauth_excluded": {
|
"oauth_excluded": {
|
||||||
"title": "OAuth 排除列表",
|
"title": "OAuth 模型禁用",
|
||||||
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
|
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
|
||||||
"add": "新增排除",
|
"add": "新增禁用",
|
||||||
"add_title": "新增提供商排除列表",
|
"add_title": "新增提供商模型禁用",
|
||||||
"edit_title": "编辑 {{provider}} 的排除列表",
|
"edit_title": "编辑 {{provider}} 的模型禁用",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"refreshing": "刷新中...",
|
"refreshing": "刷新中...",
|
||||||
"provider_label": "提供商",
|
"provider_label": "提供商",
|
||||||
"provider_auto": "跟随当前过滤",
|
"provider_auto": "跟随当前过滤",
|
||||||
"provider_placeholder": "例如 gemini-cli / openai",
|
"provider_placeholder": "例如 gemini-cli / openai",
|
||||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||||
"models_label": "排除的模型",
|
"models_label": "禁用的模型",
|
||||||
"models_loading": "正在加载模型列表...",
|
"models_loading": "正在加载模型列表...",
|
||||||
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
|
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
|
||||||
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
|
"models_loaded": "已加载 {{count}} 个模型,勾选要禁用的模型。",
|
||||||
"no_models_available": "该提供商暂无可用模型列表。",
|
"no_models_available": "该提供商暂无可用模型列表。",
|
||||||
"save": "保存/更新",
|
"save": "保存/更新",
|
||||||
"saving": "正在保存...",
|
"saving": "正在保存...",
|
||||||
"save_success": "排除列表已更新",
|
"save_success": "模型禁用已更新",
|
||||||
"save_failed": "更新排除列表失败",
|
"save_failed": "更新模型禁用失败",
|
||||||
"delete": "删除提供商",
|
"delete": "删除提供商",
|
||||||
"delete_confirm": "确定要删除 {{provider}} 的排除列表吗?",
|
"delete_confirm": "确定要删除 {{provider}} 的模型禁用吗?",
|
||||||
"delete_success": "已删除该提供商的排除列表",
|
"delete_success": "已删除该提供商的模型禁用",
|
||||||
"delete_failed": "删除排除列表失败",
|
"delete_failed": "删除模型禁用失败",
|
||||||
"deleting": "正在删除...",
|
"deleting": "正在删除...",
|
||||||
"no_models": "未配置排除模型",
|
"no_models": "未配置禁用模型",
|
||||||
"model_count": "排除 {{count}} 个模型",
|
"model_count": "禁用 {{count}} 个模型",
|
||||||
"list_empty_all": "暂无任何提供商的排除列表,点击“新增排除”创建。",
|
"list_empty_all": "暂无任何提供商的模型禁用,点击“新增禁用”创建。",
|
||||||
"list_empty_filtered": "当前筛选下没有排除项,点击“新增排除”添加。",
|
"list_empty_filtered": "当前筛选下没有禁用项,点击“新增禁用”添加。",
|
||||||
"disconnected": "请先连接服务器以查看排除列表",
|
"disconnected": "请先连接服务器以查看模型禁用",
|
||||||
"load_failed": "加载排除列表失败",
|
"load_failed": "加载模型禁用失败",
|
||||||
"provider_required": "请先填写提供商名称",
|
"provider_required": "请先填写提供商名称",
|
||||||
"scope_all": "当前范围:全局(显示所有提供商)",
|
"scope_all": "当前范围:全局(显示所有提供商)",
|
||||||
"scope_provider": "当前范围:{{provider}}",
|
"scope_provider": "当前范围:{{provider}}",
|
||||||
"upgrade_required": "当前 CPA 版本不支持模型排除列表,请升级 CPA 版本",
|
"upgrade_required": "当前 CPA 版本不支持 OAuth 模型禁用,请升级 CPA 版本",
|
||||||
"upgrade_required_title": "需要升级 CPA 版本",
|
"upgrade_required_title": "需要升级 CPA 版本",
|
||||||
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
"upgrade_required_desc": "当前服务器版本不支持获取 OAuth 模型禁用功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||||
},
|
},
|
||||||
"oauth_model_alias": {
|
"oauth_model_alias": {
|
||||||
"title": "OAuth 模型别名",
|
"title": "OAuth 模型别名",
|
||||||
@@ -640,6 +720,17 @@
|
|||||||
"gemini_cli_oauth_status_error": "认证失败:",
|
"gemini_cli_oauth_status_error": "认证失败:",
|
||||||
"gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:",
|
"gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:",
|
||||||
"gemini_cli_oauth_polling_error": "检查认证状态失败:",
|
"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_title": "Qwen OAuth",
|
||||||
"qwen_oauth_button": "开始 Qwen 登录",
|
"qwen_oauth_button": "开始 Qwen 登录",
|
||||||
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
|
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
|
||||||
@@ -711,6 +802,11 @@
|
|||||||
"api_details": "API 详细统计",
|
"api_details": "API 详细统计",
|
||||||
"by_hour": "按小时",
|
"by_hour": "按小时",
|
||||||
"by_day": "按天",
|
"by_day": "按天",
|
||||||
|
"range_filter": "时间范围",
|
||||||
|
"range_all": "全部时间",
|
||||||
|
"range_7h": "最近7小时",
|
||||||
|
"range_24h": "最近24小时",
|
||||||
|
"range_7d": "最近7天",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"export": "导出数据",
|
"export": "导出数据",
|
||||||
"import": "导入数据",
|
"import": "导入数据",
|
||||||
@@ -758,12 +854,29 @@
|
|||||||
"cost_axis_label": "花费 ($)",
|
"cost_axis_label": "花费 ($)",
|
||||||
"cost_need_price": "请先设置模型价格",
|
"cost_need_price": "请先设置模型价格",
|
||||||
"cost_need_usage": "暂无使用数据,无法计算花费",
|
"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": {
|
"stats": {
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"failure": "失败"
|
"failure": "失败"
|
||||||
},
|
},
|
||||||
|
"status_bar": {
|
||||||
|
"success_short": "✓",
|
||||||
|
"failure_short": "✗",
|
||||||
|
"no_requests": "无请求"
|
||||||
|
},
|
||||||
|
"service_health": {
|
||||||
|
"title": "服务健康监测",
|
||||||
|
"window": "最近 7 天",
|
||||||
|
"oldest": "最早",
|
||||||
|
"newest": "最新"
|
||||||
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "日志查看",
|
"title": "日志查看",
|
||||||
"refresh_button": "刷新日志",
|
"refresh_button": "刷新日志",
|
||||||
@@ -834,6 +947,13 @@
|
|||||||
"search_no_results": "无结果",
|
"search_no_results": "无结果",
|
||||||
"search_prev": "上一个",
|
"search_prev": "上一个",
|
||||||
"search_next": "下一个",
|
"search_next": "下一个",
|
||||||
|
"diff": {
|
||||||
|
"title": "确认变更",
|
||||||
|
"current": "当前配置",
|
||||||
|
"modified": "修改后",
|
||||||
|
"confirm": "确认保存",
|
||||||
|
"no_changes": "未检测到变更"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"visual": "可视化编辑",
|
"visual": "可视化编辑",
|
||||||
"source": "源文件编辑"
|
"source": "源文件编辑"
|
||||||
@@ -877,9 +997,9 @@
|
|||||||
"debug": "调试模式",
|
"debug": "调试模式",
|
||||||
"debug_desc": "启用详细的调试日志",
|
"debug_desc": "启用详细的调试日志",
|
||||||
"commercial_mode": "商业模式",
|
"commercial_mode": "商业模式",
|
||||||
"commercial_mode_desc": "禁用高开销中间件以减少高并发内存",
|
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
|
||||||
"logging_to_file": "写入日志文件",
|
"logging_to_file": "写入日志文件",
|
||||||
"logging_to_file_desc": "将日志保存到滚动文件",
|
"logging_to_file_desc": "将日志保存到文件",
|
||||||
"usage_statistics": "使用统计",
|
"usage_statistics": "使用统计",
|
||||||
"usage_statistics_desc": "收集使用统计信息",
|
"usage_statistics_desc": "收集使用统计信息",
|
||||||
"logs_max_size": "日志文件大小限制 (MB)"
|
"logs_max_size": "日志文件大小限制 (MB)"
|
||||||
@@ -932,6 +1052,7 @@
|
|||||||
"api_keys": {
|
"api_keys": {
|
||||||
"label": "API 密钥列表 (api-keys)",
|
"label": "API 密钥列表 (api-keys)",
|
||||||
"add": "添加 API 密钥",
|
"add": "添加 API 密钥",
|
||||||
|
"generate": "生成",
|
||||||
"empty": "暂无 API 密钥",
|
"empty": "暂无 API 密钥",
|
||||||
"hint": "每个条目代表一个 API 密钥(与 「API 密钥管理」 页面样式一致)",
|
"hint": "每个条目代表一个 API 密钥(与 「API 密钥管理」 页面样式一致)",
|
||||||
"edit_title": "编辑 API 密钥",
|
"edit_title": "编辑 API 密钥",
|
||||||
@@ -956,6 +1077,17 @@
|
|||||||
"add_param": "添加参数",
|
"add_param": "添加参数",
|
||||||
"no_rules": "暂无规则",
|
"no_rules": "暂无规则",
|
||||||
"add_rule": "添加规则",
|
"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_string": "字符串值",
|
||||||
"value_number": "数字值 (如 0.7)",
|
"value_number": "数字值 (如 0.7)",
|
||||||
"value_boolean": "true 或 false",
|
"value_boolean": "true 或 false",
|
||||||
@@ -979,6 +1111,7 @@
|
|||||||
},
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "管理中心信息",
|
"title": "管理中心信息",
|
||||||
|
"about_title": "CLI Proxy API Management Center",
|
||||||
"connection_status_title": "连接状态",
|
"connection_status_title": "连接状态",
|
||||||
"api_status_label": "API 状态:",
|
"api_status_label": "API 状态:",
|
||||||
"config_status_label": "配置状态:",
|
"config_status_label": "配置状态:",
|
||||||
@@ -1083,12 +1216,15 @@
|
|||||||
"gemini_api_key": "Gemini API密钥",
|
"gemini_api_key": "Gemini API密钥",
|
||||||
"codex_api_key": "Codex API密钥",
|
"codex_api_key": "Codex API密钥",
|
||||||
"claude_api_key": "Claude API密钥",
|
"claude_api_key": "Claude API密钥",
|
||||||
|
"commercial_mode_restart_required": "商业模式开关已变更,请重启服务后生效",
|
||||||
|
"copy_failed": "复制失败",
|
||||||
"link_copied": "已复制"
|
"link_copied": "已复制"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"switch": "语言",
|
"switch": "语言",
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
"english": "English"
|
"english": "English",
|
||||||
|
"russian": "Русский"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"switch": "主题",
|
"switch": "主题",
|
||||||
|
|||||||
@@ -254,17 +254,8 @@ export function AiProvidersAmpcodeEditPage() {
|
|||||||
disabled={loading || saving || disableControls}
|
disabled={loading || saving || disableControls}
|
||||||
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
||||||
/>
|
/>
|
||||||
<div
|
<div className={layoutStyles.upstreamApiKeyRow}>
|
||||||
style={{
|
<div className={layoutStyles.upstreamApiKeyHint}>
|
||||||
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', {
|
{t('ai_providers.ampcode_upstream_api_key_current', {
|
||||||
key: config?.ampcode?.upstreamApiKey
|
key: config?.ampcode?.upstreamApiKey
|
||||||
? maskApiKey(config.ampcode.upstreamApiKey)
|
? maskApiKey(config.ampcode.upstreamApiKey)
|
||||||
@@ -302,6 +293,8 @@ export function AiProvidersAmpcodeEditPage() {
|
|||||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||||
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
||||||
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={loading || saving || disableControls}
|
disabled={loading || saving || disableControls}
|
||||||
/>
|
/>
|
||||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
||||||
|
|||||||
357
src/pages/AiProvidersClaudeEditLayout.tsx
Normal file
357
src/pages/AiProvidersClaudeEditLayout.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { providersApi } from '@/services/api';
|
||||||
|
import { useAuthStore, useClaudeEditDraftStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import type { ModelInfo } from '@/utils/models';
|
||||||
|
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
|
||||||
|
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||||
|
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||||
|
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||||
|
|
||||||
|
type LocationState = { fromAiProviders?: boolean } | null;
|
||||||
|
|
||||||
|
type TestStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export type ClaudeEditOutletContext = {
|
||||||
|
hasIndexParam: boolean;
|
||||||
|
editIndex: number | null;
|
||||||
|
invalidIndexParam: boolean;
|
||||||
|
invalidIndex: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
form: ProviderFormState;
|
||||||
|
setForm: Dispatch<SetStateAction<ProviderFormState>>;
|
||||||
|
testModel: string;
|
||||||
|
setTestModel: Dispatch<SetStateAction<string>>;
|
||||||
|
testStatus: TestStatus;
|
||||||
|
setTestStatus: Dispatch<SetStateAction<TestStatus>>;
|
||||||
|
testMessage: string;
|
||||||
|
setTestMessage: Dispatch<SetStateAction<string>>;
|
||||||
|
availableModels: string[];
|
||||||
|
handleBack: () => void;
|
||||||
|
handleSave: () => Promise<void>;
|
||||||
|
mergeDiscoveredModels: (selectedModels: ModelInfo[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildEmptyForm = (): ProviderFormState => ({
|
||||||
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
|
baseUrl: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
headers: [],
|
||||||
|
models: [],
|
||||||
|
excludedModels: [],
|
||||||
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
|
excludedText: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseIndexParam = (value: string | undefined) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorMessage = (err: unknown) => {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersClaudeEditLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
|
||||||
|
const params = useParams<{ index?: string }>();
|
||||||
|
const hasIndexParam = typeof params.index === 'string';
|
||||||
|
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||||
|
const invalidIndexParam = hasIndexParam && editIndex === null;
|
||||||
|
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const config = useConfigStore((state) => state.config);
|
||||||
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
|
const isCacheValid = useConfigStore((state) => state.isCacheValid);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
|
||||||
|
const [configs, setConfigs] = useState<ProviderKeyConfig[]>(() => config?.claudeApiKeys ?? []);
|
||||||
|
const [loading, setLoading] = useState(() => !isCacheValid('claude-api-key'));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const draftKey = useMemo(() => {
|
||||||
|
if (invalidIndexParam) return `claude:invalid:${params.index ?? 'unknown'}`;
|
||||||
|
if (editIndex === null) return 'claude:new';
|
||||||
|
return `claude:${editIndex}`;
|
||||||
|
}, [editIndex, invalidIndexParam, params.index]);
|
||||||
|
|
||||||
|
const draft = useClaudeEditDraftStore((state) => state.drafts[draftKey]);
|
||||||
|
const ensureDraft = useClaudeEditDraftStore((state) => state.ensureDraft);
|
||||||
|
const initDraft = useClaudeEditDraftStore((state) => state.initDraft);
|
||||||
|
const clearDraft = useClaudeEditDraftStore((state) => state.clearDraft);
|
||||||
|
const setDraftForm = useClaudeEditDraftStore((state) => state.setDraftForm);
|
||||||
|
const setDraftTestModel = useClaudeEditDraftStore((state) => state.setDraftTestModel);
|
||||||
|
const setDraftTestStatus = useClaudeEditDraftStore((state) => state.setDraftTestStatus);
|
||||||
|
const setDraftTestMessage = useClaudeEditDraftStore((state) => state.setDraftTestMessage);
|
||||||
|
|
||||||
|
const form = draft?.form ?? buildEmptyForm();
|
||||||
|
const testModel = draft?.testModel ?? '';
|
||||||
|
const testStatus = draft?.testStatus ?? 'idle';
|
||||||
|
const testMessage = draft?.testMessage ?? '';
|
||||||
|
|
||||||
|
const setForm: Dispatch<SetStateAction<ProviderFormState>> = useCallback(
|
||||||
|
(action) => {
|
||||||
|
setDraftForm(draftKey, action);
|
||||||
|
},
|
||||||
|
[draftKey, setDraftForm]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTestModel: Dispatch<SetStateAction<string>> = useCallback(
|
||||||
|
(action) => {
|
||||||
|
setDraftTestModel(draftKey, action);
|
||||||
|
},
|
||||||
|
[draftKey, setDraftTestModel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTestStatus: Dispatch<SetStateAction<TestStatus>> = useCallback(
|
||||||
|
(action) => {
|
||||||
|
setDraftTestStatus(draftKey, action);
|
||||||
|
},
|
||||||
|
[draftKey, setDraftTestStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTestMessage: Dispatch<SetStateAction<string>> = useCallback(
|
||||||
|
(action) => {
|
||||||
|
setDraftTestMessage(draftKey, action);
|
||||||
|
},
|
||||||
|
[draftKey, setDraftTestMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialData = useMemo(() => {
|
||||||
|
if (editIndex === null) return undefined;
|
||||||
|
return configs[editIndex];
|
||||||
|
}, [configs, editIndex]);
|
||||||
|
|
||||||
|
const invalidIndex = editIndex !== null && !initialData;
|
||||||
|
|
||||||
|
const availableModels = useMemo(
|
||||||
|
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
|
||||||
|
[form.modelEntries]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureDraft(draftKey);
|
||||||
|
}, [draftKey, ensureDraft]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
clearDraft(draftKey);
|
||||||
|
const state = location.state as LocationState;
|
||||||
|
if (state?.fromAiProviders) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/ai-providers', { replace: true });
|
||||||
|
}, [clearDraft, draftKey, location.state, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const hasValidCache = isCacheValid('claude-api-key');
|
||||||
|
if (!hasValidCache) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchConfig('claude-api-key')
|
||||||
|
.then((value) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setConfigs(Array.isArray(value) ? (value as ProviderKeyConfig[]) : []);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message = getErrorMessage(err) || t('notification.refresh_failed');
|
||||||
|
showNotification(`${t('notification.load_failed')}: ${message}`, 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fetchConfig, isCacheValid, showNotification, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
if (draft?.initialized) return;
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
const seededForm: ProviderFormState = {
|
||||||
|
...initialData,
|
||||||
|
headers: headersToEntries(initialData.headers),
|
||||||
|
modelEntries: modelsToEntries(initialData.models),
|
||||||
|
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||||
|
};
|
||||||
|
const available = seededForm.modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
||||||
|
initDraft(draftKey, {
|
||||||
|
form: seededForm,
|
||||||
|
testModel: available[0] || '',
|
||||||
|
testStatus: 'idle',
|
||||||
|
testMessage: '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initDraft(draftKey, {
|
||||||
|
form: buildEmptyForm(),
|
||||||
|
testModel: '',
|
||||||
|
testStatus: 'idle',
|
||||||
|
testMessage: '',
|
||||||
|
});
|
||||||
|
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
||||||
|
|
||||||
|
const resolvedLoading = !draft?.initialized;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resolvedLoading) return;
|
||||||
|
|
||||||
|
if (availableModels.length === 0) {
|
||||||
|
if (testModel) {
|
||||||
|
setTestModel('');
|
||||||
|
setTestStatus('idle');
|
||||||
|
setTestMessage('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!testModel || !availableModels.includes(testModel)) {
|
||||||
|
setTestModel(availableModels[0]);
|
||||||
|
setTestStatus('idle');
|
||||||
|
setTestMessage('');
|
||||||
|
}
|
||||||
|
}, [availableModels, resolvedLoading, setTestMessage, setTestModel, setTestStatus, testModel]);
|
||||||
|
|
||||||
|
const mergeDiscoveredModels = useCallback(
|
||||||
|
(selectedModels: ModelInfo[]) => {
|
||||||
|
if (!selectedModels.length) return;
|
||||||
|
|
||||||
|
let addedCount = 0;
|
||||||
|
setForm((prev) => {
|
||||||
|
const mergedMap = new Map<string, ModelEntry>();
|
||||||
|
prev.modelEntries.forEach((entry) => {
|
||||||
|
const name = entry.name.trim();
|
||||||
|
if (!name) return;
|
||||||
|
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
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());
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (addedCount > 0) {
|
||||||
|
showNotification(t('ai_providers.claude_models_fetch_added', { count: addedCount }), 'success');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setForm, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
const canSave =
|
||||||
|
!disableControls && !saving && !resolvedLoading && !invalidIndexParam && !invalidIndex;
|
||||||
|
if (!canSave) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: ProviderKeyConfig = {
|
||||||
|
apiKey: form.apiKey.trim(),
|
||||||
|
prefix: form.prefix?.trim() || undefined,
|
||||||
|
baseUrl: (form.baseUrl ?? '').trim() || undefined,
|
||||||
|
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||||
|
headers: buildHeaderObject(form.headers),
|
||||||
|
models: form.modelEntries
|
||||||
|
.map((entry) => {
|
||||||
|
const name = entry.name.trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const alias = entry.alias.trim();
|
||||||
|
return { name, alias: alias || name };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as ProviderKeyConfig['models'],
|
||||||
|
excludedModels: parseExcludedModels(form.excludedText),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextList =
|
||||||
|
editIndex !== null
|
||||||
|
? configs.map((item, idx) => (idx === editIndex ? payload : item))
|
||||||
|
: [...configs, payload];
|
||||||
|
|
||||||
|
await providersApi.saveClaudeConfigs(nextList);
|
||||||
|
setConfigs(nextList);
|
||||||
|
updateConfigValue('claude-api-key', nextList);
|
||||||
|
clearCache('claude-api-key');
|
||||||
|
showNotification(
|
||||||
|
editIndex !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
handleBack();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clearCache,
|
||||||
|
configs,
|
||||||
|
disableControls,
|
||||||
|
editIndex,
|
||||||
|
form,
|
||||||
|
handleBack,
|
||||||
|
invalidIndex,
|
||||||
|
invalidIndexParam,
|
||||||
|
resolvedLoading,
|
||||||
|
saving,
|
||||||
|
showNotification,
|
||||||
|
t,
|
||||||
|
updateConfigValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Outlet
|
||||||
|
context={{
|
||||||
|
hasIndexParam,
|
||||||
|
editIndex,
|
||||||
|
invalidIndexParam,
|
||||||
|
invalidIndex,
|
||||||
|
disableControls,
|
||||||
|
loading: resolvedLoading,
|
||||||
|
saving,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
testModel,
|
||||||
|
setTestModel,
|
||||||
|
testStatus,
|
||||||
|
setTestStatus,
|
||||||
|
testMessage,
|
||||||
|
setTestMessage,
|
||||||
|
availableModels,
|
||||||
|
handleBack,
|
||||||
|
handleSave,
|
||||||
|
mergeDiscoveredModels,
|
||||||
|
} satisfies ClaudeEditOutletContext}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,88 +1,75 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
import { providersApi } from '@/services/api';
|
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
import { useNotificationStore } from '@/stores';
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
import { buildHeaderObject } from '@/utils/headers';
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
import { buildClaudeMessagesEndpoint } from '@/components/providers/utils';
|
||||||
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout';
|
||||||
import type { ProviderFormState } from '@/components/providers';
|
import styles from './AiProvidersPage.module.scss';
|
||||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
type LocationState = { fromAiProviders?: boolean } | null;
|
const CLAUDE_TEST_TIMEOUT_MS = 30_000;
|
||||||
|
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
|
||||||
|
|
||||||
const buildEmptyForm = (): ProviderFormState => ({
|
const getErrorMessage = (err: unknown) => {
|
||||||
apiKey: '',
|
if (err instanceof Error) return err.message;
|
||||||
prefix: '',
|
if (typeof err === 'string') return err;
|
||||||
baseUrl: '',
|
return '';
|
||||||
proxyUrl: '',
|
};
|
||||||
headers: [],
|
|
||||||
models: [],
|
|
||||||
excludedModels: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const parseIndexParam = (value: string | undefined) => {
|
const hasHeader = (headers: Record<string, string>, name: string) => {
|
||||||
if (!value) return null;
|
const target = name.toLowerCase();
|
||||||
const parsed = Number.parseInt(value, 10);
|
return Object.keys(headers).some((key) => key.toLowerCase() === target);
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
};
|
||||||
|
|
||||||
|
const resolveBearerTokenFromAuthorization = (headers: Record<string, string>): string => {
|
||||||
|
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === 'authorization');
|
||||||
|
if (!entry) return '';
|
||||||
|
const value = String(entry[1] ?? '').trim();
|
||||||
|
if (!value) return '';
|
||||||
|
const match = value.match(/^Bearer\s+(.+)$/i);
|
||||||
|
return match?.[1]?.trim() || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AiProvidersClaudeEditPage() {
|
export function AiProvidersClaudeEditPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const params = useParams<{ index?: string }>();
|
|
||||||
|
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const {
|
||||||
const disableControls = connectionStatus !== 'connected';
|
hasIndexParam,
|
||||||
|
invalidIndexParam,
|
||||||
|
invalidIndex,
|
||||||
|
disableControls,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
testModel,
|
||||||
|
setTestModel,
|
||||||
|
testStatus,
|
||||||
|
setTestStatus,
|
||||||
|
testMessage,
|
||||||
|
setTestMessage,
|
||||||
|
availableModels,
|
||||||
|
handleBack,
|
||||||
|
handleSave,
|
||||||
|
} = useOutletContext<ClaudeEditOutletContext>();
|
||||||
|
|
||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
const title = hasIndexParam
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
? t('ai_providers.claude_edit_modal_title')
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
: t('ai_providers.claude_add_modal_title');
|
||||||
|
|
||||||
const [configs, setConfigs] = useState<ProviderKeyConfig[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
|
||||||
|
|
||||||
const hasIndexParam = typeof params.index === 'string';
|
|
||||||
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
|
||||||
const invalidIndexParam = hasIndexParam && editIndex === null;
|
|
||||||
|
|
||||||
const initialData = useMemo(() => {
|
|
||||||
if (editIndex === null) return undefined;
|
|
||||||
return configs[editIndex];
|
|
||||||
}, [configs, editIndex]);
|
|
||||||
|
|
||||||
const invalidIndex = editIndex !== null && !initialData;
|
|
||||||
|
|
||||||
const title =
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.claude_edit_modal_title')
|
|
||||||
: t('ai_providers.claude_add_modal_title');
|
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
|
||||||
const state = location.state as LocationState;
|
|
||||||
if (state?.fromAiProviders) {
|
|
||||||
navigate(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate('/ai-providers', { replace: true });
|
|
||||||
}, [location.state, navigate]);
|
|
||||||
|
|
||||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -94,101 +81,163 @@ export function AiProvidersClaudeEditPage() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [handleBack]);
|
}, [handleBack]);
|
||||||
|
|
||||||
useEffect(() => {
|
const canSave =
|
||||||
let cancelled = false;
|
!disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTesting;
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
fetchConfig('claude-api-key')
|
const modelSelectOptions = useMemo(() => {
|
||||||
.then((value) => {
|
const seen = new Set<string>();
|
||||||
if (cancelled) return;
|
return form.modelEntries.reduce<Array<{ value: string; label: string }>>((acc, entry) => {
|
||||||
setConfigs(Array.isArray(value) ? (value as ProviderKeyConfig[]) : []);
|
const name = entry.name.trim();
|
||||||
})
|
if (!name || seen.has(name)) return acc;
|
||||||
.catch((err: unknown) => {
|
seen.add(name);
|
||||||
if (cancelled) return;
|
const alias = entry.alias.trim();
|
||||||
const message = err instanceof Error ? err.message : '';
|
acc.push({
|
||||||
setError(message || t('notification.refresh_failed'));
|
value: name,
|
||||||
})
|
label: alias && alias !== name ? `${name} (${alias})` : name,
|
||||||
.finally(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setLoading(false);
|
|
||||||
});
|
});
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}, [form.modelEntries]);
|
||||||
|
|
||||||
return () => {
|
const connectivityConfigSignature = useMemo(() => {
|
||||||
cancelled = true;
|
const headersSignature = form.headers
|
||||||
};
|
.map((entry) => `${entry.key.trim()}:${entry.value.trim()}`)
|
||||||
}, [fetchConfig, t]);
|
.join('|');
|
||||||
|
const modelsSignature = form.modelEntries
|
||||||
|
.map((entry) => `${entry.name.trim()}:${entry.alias.trim()}`)
|
||||||
|
.join('|');
|
||||||
|
return [
|
||||||
|
form.apiKey.trim(),
|
||||||
|
form.baseUrl?.trim() ?? '',
|
||||||
|
testModel.trim(),
|
||||||
|
headersSignature,
|
||||||
|
modelsSignature,
|
||||||
|
].join('||');
|
||||||
|
}, [form.apiKey, form.baseUrl, form.headers, form.modelEntries, testModel]);
|
||||||
|
|
||||||
|
const previousConnectivityConfigRef = useRef(connectivityConfigSignature);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (previousConnectivityConfigRef.current === connectivityConfigSignature) {
|
||||||
|
|
||||||
if (initialData) {
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setForm(buildEmptyForm());
|
previousConnectivityConfigRef.current = connectivityConfigSignature;
|
||||||
}, [initialData, loading]);
|
setTestStatus('idle');
|
||||||
|
setTestMessage('');
|
||||||
|
}, [connectivityConfigSignature, setTestMessage, setTestStatus]);
|
||||||
|
|
||||||
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
const openClaudeModelDiscovery = () => {
|
||||||
|
navigate('models');
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const runClaudeConnectivityTest = useCallback(async () => {
|
||||||
if (!canSave) return;
|
if (isTesting) return;
|
||||||
|
|
||||||
|
const modelName = testModel.trim() || availableModels[0] || '';
|
||||||
|
if (!modelName) {
|
||||||
|
const message = t('ai_providers.claude_test_model_required');
|
||||||
|
setTestStatus('error');
|
||||||
|
setTestMessage(message);
|
||||||
|
showNotification(message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customHeaders = buildHeaderObject(form.headers);
|
||||||
|
const apiKey = form.apiKey.trim();
|
||||||
|
const hasApiKeyHeader = hasHeader(customHeaders, 'x-api-key');
|
||||||
|
const apiKeyFromAuthorization = resolveBearerTokenFromAuthorization(customHeaders);
|
||||||
|
const resolvedApiKey = apiKey || apiKeyFromAuthorization;
|
||||||
|
|
||||||
|
if (!resolvedApiKey && !hasApiKeyHeader) {
|
||||||
|
const message = t('ai_providers.claude_test_key_required');
|
||||||
|
setTestStatus('error');
|
||||||
|
setTestMessage(message);
|
||||||
|
showNotification(message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = buildClaudeMessagesEndpoint(form.baseUrl ?? '');
|
||||||
|
if (!endpoint) {
|
||||||
|
const message = t('ai_providers.claude_test_endpoint_invalid');
|
||||||
|
setTestStatus('error');
|
||||||
|
setTestMessage(message);
|
||||||
|
showNotification(message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...customHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!hasHeader(headers, 'anthropic-version')) {
|
||||||
|
headers['anthropic-version'] = DEFAULT_ANTHROPIC_VERSION;
|
||||||
|
}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(headers, 'Anthropic-Version')) {
|
||||||
|
headers['Anthropic-Version'] = headers['anthropic-version'] ?? DEFAULT_ANTHROPIC_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasApiKeyHeader && resolvedApiKey) {
|
||||||
|
headers['x-api-key'] = resolvedApiKey;
|
||||||
|
}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(headers, 'X-Api-Key') && resolvedApiKey) {
|
||||||
|
headers['X-Api-Key'] = resolvedApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTesting(true);
|
||||||
|
setTestStatus('loading');
|
||||||
|
setTestMessage(t('ai_providers.claude_test_running'));
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
try {
|
||||||
const payload: ProviderKeyConfig = {
|
const result = await apiCallApi.request(
|
||||||
apiKey: form.apiKey.trim(),
|
{
|
||||||
prefix: form.prefix?.trim() || undefined,
|
method: 'POST',
|
||||||
baseUrl: (form.baseUrl ?? '').trim() || undefined,
|
url: endpoint,
|
||||||
proxyUrl: form.proxyUrl?.trim() || undefined,
|
header: headers,
|
||||||
headers: buildHeaderObject(form.headers),
|
data: JSON.stringify({
|
||||||
models: form.modelEntries
|
model: modelName,
|
||||||
.map((entry) => {
|
max_tokens: 8,
|
||||||
const name = entry.name.trim();
|
messages: [{ role: 'user', content: 'Hi' }],
|
||||||
if (!name) return null;
|
}),
|
||||||
const alias = entry.alias.trim();
|
},
|
||||||
return { name, alias: alias || name };
|
{ timeout: CLAUDE_TEST_TIMEOUT_MS }
|
||||||
})
|
|
||||||
.filter(Boolean) as ProviderKeyConfig['models'],
|
|
||||||
excludedModels: parseExcludedModels(form.excludedText),
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextList =
|
|
||||||
editIndex !== null
|
|
||||||
? configs.map((item, idx) => (idx === editIndex ? payload : item))
|
|
||||||
: [...configs, payload];
|
|
||||||
|
|
||||||
await providersApi.saveClaudeConfigs(nextList);
|
|
||||||
updateConfigValue('claude-api-key', nextList);
|
|
||||||
clearCache('claude-api-key');
|
|
||||||
showNotification(
|
|
||||||
editIndex !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added'),
|
|
||||||
'success'
|
|
||||||
);
|
);
|
||||||
handleBack();
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw new Error(getApiCallErrorMessage(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = t('ai_providers.claude_test_success');
|
||||||
|
setTestStatus('success');
|
||||||
|
setTestMessage(message);
|
||||||
|
showNotification(message, 'success');
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '';
|
const message = getErrorMessage(err);
|
||||||
setError(message);
|
const errorCode =
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
typeof err === 'object' && err !== null && 'code' in err
|
||||||
|
? String((err as { code?: string }).code)
|
||||||
|
: '';
|
||||||
|
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
||||||
|
const resolvedMessage = isTimeout
|
||||||
|
? t('ai_providers.claude_test_timeout', { seconds: CLAUDE_TEST_TIMEOUT_MS / 1000 })
|
||||||
|
: `${t('ai_providers.claude_test_failed')}: ${message || t('common.unknown_error')}`;
|
||||||
|
setTestStatus('error');
|
||||||
|
setTestMessage(resolvedMessage);
|
||||||
|
showNotification(resolvedMessage, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setIsTesting(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
canSave,
|
availableModels,
|
||||||
clearCache,
|
form.apiKey,
|
||||||
configs,
|
form.baseUrl,
|
||||||
editIndex,
|
form.headers,
|
||||||
form,
|
isTesting,
|
||||||
handleBack,
|
setTestMessage,
|
||||||
|
setTestStatus,
|
||||||
showNotification,
|
showNotification,
|
||||||
t,
|
t,
|
||||||
updateConfigValue,
|
testModel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -200,7 +249,7 @@ export function AiProvidersClaudeEditPage() {
|
|||||||
backLabel={t('common.back')}
|
backLabel={t('common.back')}
|
||||||
backAriaLabel={t('common.back')}
|
backAriaLabel={t('common.back')}
|
||||||
rightAction={
|
rightAction={
|
||||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
<Button size="sm" onClick={() => void handleSave()} loading={saving} disabled={!canSave}>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -208,16 +257,15 @@ export function AiProvidersClaudeEditPage() {
|
|||||||
loadingLabel={t('common.loading')}
|
loadingLabel={t('common.loading')}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
{invalidIndexParam || invalidIndex ? (
|
{invalidIndexParam || invalidIndex ? (
|
||||||
<div className="hint">Invalid provider index.</div>
|
<div className={styles.sectionHint}>{t('common.invalid_provider_index')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className={styles.openaiEditForm}>
|
||||||
<Input
|
<Input
|
||||||
label={t('ai_providers.claude_add_modal_key_label')}
|
label={t('ai_providers.claude_add_modal_key_label')}
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
disabled={disableControls || saving}
|
disabled={saving || disableControls || isTesting}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('ai_providers.prefix_label')}
|
label={t('ai_providers.prefix_label')}
|
||||||
@@ -225,19 +273,19 @@ export function AiProvidersClaudeEditPage() {
|
|||||||
value={form.prefix ?? ''}
|
value={form.prefix ?? ''}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||||
hint={t('ai_providers.prefix_hint')}
|
hint={t('ai_providers.prefix_hint')}
|
||||||
disabled={disableControls || saving}
|
disabled={saving || disableControls || isTesting}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('ai_providers.claude_add_modal_url_label')}
|
label={t('ai_providers.claude_add_modal_url_label')}
|
||||||
value={form.baseUrl ?? ''}
|
value={form.baseUrl ?? ''}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
disabled={disableControls || saving}
|
disabled={saving || disableControls || isTesting}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('ai_providers.claude_add_modal_proxy_label')}
|
label={t('ai_providers.claude_add_modal_proxy_label')}
|
||||||
value={form.proxyUrl ?? ''}
|
value={form.proxyUrl ?? ''}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
disabled={disableControls || saving}
|
disabled={saving || disableControls || isTesting}
|
||||||
/>
|
/>
|
||||||
<HeaderInputList
|
<HeaderInputList
|
||||||
entries={form.headers}
|
entries={form.headers}
|
||||||
@@ -245,19 +293,119 @@ export function AiProvidersClaudeEditPage() {
|
|||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
disabled={disableControls || saving}
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
|
disabled={saving || disableControls || isTesting}
|
||||||
/>
|
/>
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.claude_models_label')}</label>
|
<div className={styles.modelConfigSection}>
|
||||||
|
<div className={styles.modelConfigHeader}>
|
||||||
|
<label className={styles.modelConfigTitle}>{t('ai_providers.claude_models_label')}</label>
|
||||||
|
<div className={styles.modelConfigToolbar}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modelEntries: [...prev.modelEntries, { name: '', alias: '' }],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={saving || disableControls || isTesting}
|
||||||
|
>
|
||||||
|
{t('ai_providers.claude_models_add_btn')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={openClaudeModelDiscovery}
|
||||||
|
disabled={saving || disableControls || isTesting}
|
||||||
|
>
|
||||||
|
{t('ai_providers.claude_models_fetch_button')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.sectionHint}>{t('ai_providers.claude_models_hint')}</div>
|
||||||
|
|
||||||
<ModelInputList
|
<ModelInputList
|
||||||
entries={form.modelEntries}
|
entries={form.modelEntries}
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
namePlaceholder={t('common.model_name_placeholder')}
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||||
disabled={disableControls || saving}
|
disabled={saving || disableControls || isTesting}
|
||||||
|
hideAddButton
|
||||||
|
className={styles.modelInputList}
|
||||||
|
rowClassName={styles.modelInputRow}
|
||||||
|
inputClassName={styles.modelInputField}
|
||||||
|
removeButtonClassName={styles.modelRowRemoveButton}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className={styles.modelTestPanel}>
|
||||||
|
<div className={styles.modelTestMeta}>
|
||||||
|
<label className={styles.modelTestLabel}>{t('ai_providers.claude_test_title')}</label>
|
||||||
|
<span className={styles.modelTestHint}>{t('ai_providers.claude_test_hint')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelTestControls}>
|
||||||
|
<Select
|
||||||
|
value={testModel}
|
||||||
|
options={modelSelectOptions}
|
||||||
|
onChange={(value) => {
|
||||||
|
setTestModel(value);
|
||||||
|
setTestStatus('idle');
|
||||||
|
setTestMessage('');
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
availableModels.length
|
||||||
|
? t('ai_providers.claude_test_select_placeholder')
|
||||||
|
: t('ai_providers.claude_test_select_empty')
|
||||||
|
}
|
||||||
|
className={styles.openaiTestSelect}
|
||||||
|
ariaLabel={t('ai_providers.claude_test_title')}
|
||||||
|
disabled={
|
||||||
|
saving ||
|
||||||
|
disableControls ||
|
||||||
|
isTesting ||
|
||||||
|
testStatus === 'loading' ||
|
||||||
|
availableModels.length === 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void runClaudeConnectivityTest()}
|
||||||
|
loading={testStatus === 'loading'}
|
||||||
|
disabled={
|
||||||
|
saving ||
|
||||||
|
disableControls ||
|
||||||
|
isTesting ||
|
||||||
|
testStatus === 'loading' ||
|
||||||
|
availableModels.length === 0
|
||||||
|
}
|
||||||
|
className={styles.modelTestAllButton}
|
||||||
|
>
|
||||||
|
{t('ai_providers.claude_test_action')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testMessage && (
|
||||||
|
<div
|
||||||
|
className={`status-badge ${
|
||||||
|
testStatus === 'error'
|
||||||
|
? 'error'
|
||||||
|
: testStatus === 'success'
|
||||||
|
? 'success'
|
||||||
|
: 'muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -266,11 +414,11 @@ export function AiProvidersClaudeEditPage() {
|
|||||||
value={form.excludedText}
|
value={form.excludedText}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||||
rows={4}
|
rows={4}
|
||||||
disabled={disableControls || saving}
|
disabled={saving || disableControls || isTesting}
|
||||||
/>
|
/>
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</SecondaryScreenShell>
|
</SecondaryScreenShell>
|
||||||
|
|||||||
248
src/pages/AiProvidersClaudeModelsPage.tsx
Normal file
248
src/pages/AiProvidersClaudeModelsPage.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
|
import { modelsApi } from '@/services/api';
|
||||||
|
import type { ModelInfo } from '@/utils/models';
|
||||||
|
import { buildHeaderObject } from '@/utils/headers';
|
||||||
|
import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout';
|
||||||
|
import styles from './AiProvidersPage.module.scss';
|
||||||
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
|
const getErrorMessage = (err: unknown) => {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersClaudeModelsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
disableControls,
|
||||||
|
loading: initialLoading,
|
||||||
|
saving,
|
||||||
|
form,
|
||||||
|
mergeDiscoveredModels,
|
||||||
|
} = useOutletContext<ClaudeEditOutletContext>();
|
||||||
|
|
||||||
|
const [endpoint, setEndpoint] = useState('');
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const autoFetchSignatureRef = useRef<string>('');
|
||||||
|
|
||||||
|
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 fetchClaudeModelDiscovery = useCallback(async () => {
|
||||||
|
setFetching(true);
|
||||||
|
setError('');
|
||||||
|
const headerObject = buildHeaderObject(form.headers);
|
||||||
|
try {
|
||||||
|
const list = await modelsApi.fetchClaudeModelsViaApiCall(
|
||||||
|
form.baseUrl ?? '',
|
||||||
|
form.apiKey.trim() || undefined,
|
||||||
|
headerObject
|
||||||
|
);
|
||||||
|
setModels(list);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setModels([]);
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
const hasCustomXApiKey = Object.keys(headerObject).some(
|
||||||
|
(key) => key.toLowerCase() === 'x-api-key'
|
||||||
|
);
|
||||||
|
const hasAuthorization = Object.keys(headerObject).some(
|
||||||
|
(key) => key.toLowerCase() === 'authorization'
|
||||||
|
);
|
||||||
|
const shouldAttachDiag =
|
||||||
|
message.toLowerCase().includes('x-api-key') || message.includes('401');
|
||||||
|
const diag = shouldAttachDiag
|
||||||
|
? ` [diag: apiKeyField=${form.apiKey.trim() ? 'yes' : 'no'}, customXApiKey=${
|
||||||
|
hasCustomXApiKey ? 'yes' : 'no'
|
||||||
|
}, customAuthorization=${hasAuthorization ? 'yes' : 'no'}]`
|
||||||
|
: '';
|
||||||
|
setError(`${t('ai_providers.claude_models_fetch_error')}: ${message}${diag}`);
|
||||||
|
} finally {
|
||||||
|
setFetching(false);
|
||||||
|
}
|
||||||
|
}, [form.apiKey, form.baseUrl, form.headers, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialLoading) return;
|
||||||
|
|
||||||
|
const nextEndpoint = modelsApi.buildClaudeModelsEndpoint(form.baseUrl ?? '');
|
||||||
|
setEndpoint(nextEndpoint);
|
||||||
|
setModels([]);
|
||||||
|
setSearch('');
|
||||||
|
setSelected(new Set());
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const headerObject = buildHeaderObject(form.headers);
|
||||||
|
const hasCustomXApiKey = Object.keys(headerObject).some(
|
||||||
|
(key) => key.toLowerCase() === 'x-api-key'
|
||||||
|
);
|
||||||
|
const hasAuthorization = Object.keys(headerObject).some(
|
||||||
|
(key) => key.toLowerCase() === 'authorization'
|
||||||
|
);
|
||||||
|
const hasApiKeyField = Boolean(form.apiKey.trim());
|
||||||
|
const canAutoFetch = hasApiKeyField || hasCustomXApiKey || hasAuthorization;
|
||||||
|
|
||||||
|
// Avoid firing a guaranteed 401 on initial render (common while the parent form is still
|
||||||
|
// initializing), and avoid duplicate auto-fetches (e.g. React StrictMode in dev).
|
||||||
|
if (!canAutoFetch) return;
|
||||||
|
|
||||||
|
const headerSignature = Object.entries(headerObject)
|
||||||
|
.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
||||||
|
.map(([key, value]) => `${key}:${value}`)
|
||||||
|
.join('|');
|
||||||
|
const signature = `${nextEndpoint}||${form.apiKey.trim()}||${headerSignature}`;
|
||||||
|
if (autoFetchSignatureRef.current === signature) return;
|
||||||
|
autoFetchSignatureRef.current = signature;
|
||||||
|
|
||||||
|
void fetchClaudeModelDiscovery();
|
||||||
|
}, [fetchClaudeModelDiscovery, form.apiKey, form.baseUrl, form.headers, initialLoading]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
navigate(-1);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
|
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));
|
||||||
|
if (selectedModels.length) {
|
||||||
|
mergeDiscoveredModels(selectedModels);
|
||||||
|
}
|
||||||
|
handleBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canApply = !disableControls && !saving && !fetching;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryScreenShell
|
||||||
|
ref={swipeRef}
|
||||||
|
contentClassName={layoutStyles.content}
|
||||||
|
title={t('ai_providers.claude_models_fetch_title')}
|
||||||
|
onBack={handleBack}
|
||||||
|
backLabel={t('common.back')}
|
||||||
|
backAriaLabel={t('common.back')}
|
||||||
|
rightAction={
|
||||||
|
<Button size="sm" onClick={handleApply} disabled={!canApply}>
|
||||||
|
{t('ai_providers.claude_models_fetch_apply')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
isLoading={initialLoading}
|
||||||
|
loadingLabel={t('common.loading')}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<div className={styles.openaiModelsContent}>
|
||||||
|
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_hint')}</div>
|
||||||
|
<div className={styles.openaiModelsEndpointSection}>
|
||||||
|
<label className={styles.openaiModelsEndpointLabel}>
|
||||||
|
{t('ai_providers.claude_models_fetch_url_label')}
|
||||||
|
</label>
|
||||||
|
<div className={styles.openaiModelsEndpointControls}>
|
||||||
|
<input
|
||||||
|
className={`input ${styles.openaiModelsEndpointInput}`}
|
||||||
|
readOnly
|
||||||
|
value={endpoint}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void fetchClaudeModelDiscovery()}
|
||||||
|
loading={fetching}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
>
|
||||||
|
{t('ai_providers.claude_models_fetch_refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.claude_models_search_label')}
|
||||||
|
placeholder={t('ai_providers.claude_models_search_placeholder')}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
disabled={fetching}
|
||||||
|
/>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
{fetching ? (
|
||||||
|
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_loading')}</div>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<div className={styles.sectionHint}>{t('ai_providers.claude_models_fetch_empty')}</div>
|
||||||
|
) : filteredModels.length === 0 ? (
|
||||||
|
<div className={styles.sectionHint}>{t('ai_providers.claude_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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</SecondaryScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -210,7 +210,7 @@ export function AiProvidersCodexEditPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
{invalidIndexParam || invalidIndex ? (
|
{invalidIndexParam || invalidIndex ? (
|
||||||
<div className="hint">Invalid provider index.</div>
|
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -245,6 +245,8 @@ export function AiProvidersCodexEditPage() {
|
|||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={disableControls || saving}
|
disabled={disableControls || saving}
|
||||||
/>
|
/>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -3,3 +3,17 @@
|
|||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upstreamApiKeyRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upstreamApiKeyHint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const buildEmptyForm = (): GeminiFormState => ({
|
|||||||
apiKey: '',
|
apiKey: '',
|
||||||
prefix: '',
|
prefix: '',
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
|
proxyUrl: '',
|
||||||
headers: [],
|
headers: [],
|
||||||
excludedModels: [],
|
excludedModels: [],
|
||||||
excludedText: '',
|
excludedText: '',
|
||||||
@@ -138,6 +139,7 @@ export function AiProvidersGeminiEditPage() {
|
|||||||
apiKey: form.apiKey.trim(),
|
apiKey: form.apiKey.trim(),
|
||||||
prefix: form.prefix?.trim() || undefined,
|
prefix: form.prefix?.trim() || undefined,
|
||||||
baseUrl: form.baseUrl?.trim() || undefined,
|
baseUrl: form.baseUrl?.trim() || undefined,
|
||||||
|
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||||
headers: buildHeaderObject(form.headers),
|
headers: buildHeaderObject(form.headers),
|
||||||
excludedModels: parseExcludedModels(form.excludedText),
|
excludedModels: parseExcludedModels(form.excludedText),
|
||||||
};
|
};
|
||||||
@@ -193,7 +195,7 @@ export function AiProvidersGeminiEditPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
{invalidIndexParam || invalidIndex ? (
|
{invalidIndexParam || invalidIndex ? (
|
||||||
<div className="hint">Invalid provider index.</div>
|
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -218,12 +220,21 @@ export function AiProvidersGeminiEditPage() {
|
|||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
disabled={disableControls || saving}
|
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
|
<HeaderInputList
|
||||||
entries={form.headers}
|
entries={form.headers}
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={disableControls || saving}
|
disabled={disableControls || saving}
|
||||||
/>
|
/>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { ModelInfo } from '@/utils/models';
|
|||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||||
import { buildApiKeyEntry } from '@/components/providers/utils';
|
import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||||
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
||||||
|
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||||
|
|
||||||
type LocationState = { fromAiProviders?: boolean } | null;
|
type LocationState = { fromAiProviders?: boolean } | null;
|
||||||
|
|
||||||
@@ -29,6 +30,9 @@ export type OpenAIEditOutletContext = {
|
|||||||
setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>;
|
setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>;
|
||||||
testMessage: string;
|
testMessage: string;
|
||||||
setTestMessage: Dispatch<SetStateAction<string>>;
|
setTestMessage: Dispatch<SetStateAction<string>>;
|
||||||
|
keyTestStatuses: KeyTestStatus[];
|
||||||
|
setDraftKeyTestStatus: (keyIndex: number, status: KeyTestStatus) => void;
|
||||||
|
resetDraftKeyTestStatuses: (count: number) => void;
|
||||||
availableModels: string[];
|
availableModels: string[];
|
||||||
handleBack: () => void;
|
handleBack: () => void;
|
||||||
handleSave: () => Promise<void>;
|
handleSave: () => Promise<void>;
|
||||||
@@ -73,8 +77,6 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
|
|
||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
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 isCacheValid = useConfigStore((state) => state.isCacheValid);
|
||||||
|
|
||||||
const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
|
const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
|
||||||
@@ -99,11 +101,14 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
|
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
|
||||||
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
|
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
|
||||||
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
|
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
|
||||||
|
const setDraftKeyTestStatus = useOpenAIEditDraftStore((state) => state.setDraftKeyTestStatus);
|
||||||
|
const resetDraftKeyTestStatuses = useOpenAIEditDraftStore((state) => state.resetDraftKeyTestStatuses);
|
||||||
|
|
||||||
const form = draft?.form ?? buildEmptyForm();
|
const form = draft?.form ?? buildEmptyForm();
|
||||||
const testModel = draft?.testModel ?? '';
|
const testModel = draft?.testModel ?? '';
|
||||||
const testStatus = draft?.testStatus ?? 'idle';
|
const testStatus = draft?.testStatus ?? 'idle';
|
||||||
const testMessage = draft?.testMessage ?? '';
|
const testMessage = draft?.testMessage ?? '';
|
||||||
|
const keyTestStatuses = draft?.keyTestStatuses ?? [];
|
||||||
|
|
||||||
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
|
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
|
||||||
(action) => {
|
(action) => {
|
||||||
@@ -134,6 +139,20 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
[draftKey, setDraftTestMessage]
|
[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(() => {
|
const initialData = useMemo(() => {
|
||||||
if (editIndex === null) return undefined;
|
if (editIndex === null) return undefined;
|
||||||
return providers[editIndex];
|
return providers[editIndex];
|
||||||
@@ -215,6 +234,7 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
testModel: initialTestModel,
|
testModel: initialTestModel,
|
||||||
testStatus: 'idle',
|
testStatus: 'idle',
|
||||||
testMessage: '',
|
testMessage: '',
|
||||||
|
keyTestStatuses: [],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
initDraft(draftKey, {
|
initDraft(draftKey, {
|
||||||
@@ -222,6 +242,7 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
testModel: '',
|
testModel: '',
|
||||||
testStatus: 'idle',
|
testStatus: 'idle',
|
||||||
testMessage: '',
|
testMessage: '',
|
||||||
|
keyTestStatuses: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
|
||||||
@@ -243,7 +264,7 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
setTestStatus('idle');
|
setTestStatus('idle');
|
||||||
setTestMessage('');
|
setTestMessage('');
|
||||||
}
|
}
|
||||||
}, [availableModels, loading, testModel]);
|
}, [availableModels, loading, setTestMessage, setTestModel, setTestStatus, testModel]);
|
||||||
|
|
||||||
const mergeDiscoveredModels = useCallback(
|
const mergeDiscoveredModels = useCallback(
|
||||||
(selectedModels: ModelInfo[]) => {
|
(selectedModels: ModelInfo[]) => {
|
||||||
@@ -280,12 +301,20 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload: OpenAIProviderConfig = {
|
const payload: OpenAIProviderConfig = {
|
||||||
name: form.name.trim(),
|
name,
|
||||||
prefix: form.prefix?.trim() || undefined,
|
prefix: form.prefix?.trim() || undefined,
|
||||||
baseUrl: form.baseUrl.trim(),
|
baseUrl,
|
||||||
headers: buildHeaderObject(form.headers),
|
headers: buildHeaderObject(form.headers),
|
||||||
apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({
|
apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({
|
||||||
apiKey: entry.apiKey.trim(),
|
apiKey: entry.apiKey.trim(),
|
||||||
@@ -304,9 +333,18 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
: [...providers, payload];
|
: [...providers, payload];
|
||||||
|
|
||||||
await providersApi.saveOpenAIProviders(nextList);
|
await providersApi.saveOpenAIProviders(nextList);
|
||||||
setProviders(nextList);
|
|
||||||
updateConfigValue('openai-compatibility', nextList);
|
let syncedProviders = nextList;
|
||||||
clearCache('openai-compatibility');
|
try {
|
||||||
|
const latest = await fetchConfig('openai-compatibility', true);
|
||||||
|
if (Array.isArray(latest)) {
|
||||||
|
syncedProviders = latest as OpenAIProviderConfig[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 保存成功后刷新失败时,回退到本地计算结果,避免页面数据为空或回退
|
||||||
|
}
|
||||||
|
|
||||||
|
setProviders(syncedProviders);
|
||||||
showNotification(
|
showNotification(
|
||||||
editIndex !== null
|
editIndex !== null
|
||||||
? t('notification.openai_provider_updated')
|
? t('notification.openai_provider_updated')
|
||||||
@@ -320,15 +358,14 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
clearCache,
|
|
||||||
editIndex,
|
editIndex,
|
||||||
|
fetchConfig,
|
||||||
form,
|
form,
|
||||||
handleBack,
|
handleBack,
|
||||||
providers,
|
providers,
|
||||||
testModel,
|
testModel,
|
||||||
showNotification,
|
showNotification,
|
||||||
t,
|
t,
|
||||||
updateConfigValue,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolvedLoading = !draft?.initialized;
|
const resolvedLoading = !draft?.initialized;
|
||||||
@@ -351,6 +388,9 @@ export function AiProvidersOpenAIEditLayout() {
|
|||||||
setTestStatus,
|
setTestStatus,
|
||||||
testMessage,
|
testMessage,
|
||||||
setTestMessage,
|
setTestMessage,
|
||||||
|
keyTestStatuses,
|
||||||
|
setDraftKeyTestStatus: handleSetDraftKeyTestStatus,
|
||||||
|
resetDraftKeyTestStatuses: handleResetDraftKeyTestStatuses,
|
||||||
availableModels,
|
availableModels,
|
||||||
handleBack,
|
handleBack,
|
||||||
handleSave,
|
handleSave,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
@@ -6,6 +6,7 @@ import { Card } from '@/components/ui/Card';
|
|||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
import { useNotificationStore } from '@/stores';
|
import { useNotificationStore } from '@/stores';
|
||||||
@@ -14,6 +15,7 @@ import type { ApiKeyEntry } from '@/types';
|
|||||||
import { buildHeaderObject } from '@/utils/headers';
|
import { buildHeaderObject } from '@/utils/headers';
|
||||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
|
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
|
||||||
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||||
|
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
|
||||||
import styles from './AiProvidersPage.module.scss';
|
import styles from './AiProvidersPage.module.scss';
|
||||||
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
@@ -25,6 +27,72 @@ const getErrorMessage = (err: unknown) => {
|
|||||||
return '';
|
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, #c65746)" />
|
||||||
|
<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() {
|
export function AiProvidersOpenAIEditPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -44,6 +112,9 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
setTestStatus,
|
setTestStatus,
|
||||||
testMessage,
|
testMessage,
|
||||||
setTestMessage,
|
setTestMessage,
|
||||||
|
keyTestStatuses,
|
||||||
|
setDraftKeyTestStatus,
|
||||||
|
resetDraftKeyTestStatuses,
|
||||||
availableModels,
|
availableModels,
|
||||||
handleBack,
|
handleBack,
|
||||||
handleSave,
|
handleSave,
|
||||||
@@ -54,6 +125,7 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
: t('ai_providers.openai_add_modal_title');
|
: t('ai_providers.openai_add_modal_title');
|
||||||
|
|
||||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
const [isTestingKeys, setIsTestingKeys] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -65,80 +137,145 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [handleBack]);
|
}, [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 modelSelectOptions = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return form.modelEntries.reduce<Array<{ value: string; label: string }>>((acc, entry) => {
|
||||||
|
const name = entry.name.trim();
|
||||||
|
if (!name || seen.has(name)) return acc;
|
||||||
|
seen.add(name);
|
||||||
|
const alias = entry.alias.trim();
|
||||||
|
acc.push({
|
||||||
|
value: name,
|
||||||
|
label: alias && alias !== name ? `${name} (${alias})` : name,
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}, [form.modelEntries]);
|
||||||
|
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[]) => {
|
useEffect(() => {
|
||||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
if (previousConnectivityConfigRef.current === connectivityConfigSignature) {
|
||||||
|
|
||||||
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');
|
|
||||||
return;
|
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();
|
const baseUrl = form.baseUrl.trim();
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
const message = t('notification.openai_test_url_required');
|
const message = t('notification.openai_test_url_required');
|
||||||
@@ -157,15 +294,6 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
return;
|
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] || '';
|
const modelName = testModel.trim() || availableModels[0] || '';
|
||||||
if (!modelName) {
|
if (!modelName) {
|
||||||
const message = t('notification.openai_test_model_required');
|
const message = t('notification.openai_test_model_required');
|
||||||
@@ -175,56 +303,194 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const customHeaders = buildHeaderObject(form.headers);
|
const validKeyIndexes = form.apiKeyEntries
|
||||||
const headers: Record<string, string> = {
|
.map((entry, index) => (entry.apiKey?.trim() ? index : -1))
|
||||||
'Content-Type': 'application/json',
|
.filter((index) => index >= 0);
|
||||||
...customHeaders,
|
if (validKeyIndexes.length === 0) {
|
||||||
};
|
const message = t('notification.openai_test_key_required');
|
||||||
if (!headers.Authorization && !headers['authorization']) {
|
setTestStatus('error');
|
||||||
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
|
setTestMessage(message);
|
||||||
|
showNotification(message, 'error');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsTestingKeys(true);
|
||||||
setTestStatus('loading');
|
setTestStatus('loading');
|
||||||
setTestMessage(t('ai_providers.openai_test_running'));
|
setTestMessage(t('ai_providers.openai_test_running'));
|
||||||
|
resetDraftKeyTestStatuses(form.apiKeyEntries.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiCallApi.request(
|
const results = await Promise.all(validKeyIndexes.map((index) => runSingleKeyTest(index)));
|
||||||
{
|
|
||||||
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) {
|
const successCount = results.filter(Boolean).length;
|
||||||
throw new Error(getApiCallErrorMessage(result));
|
const failCount = validKeyIndexes.length - successCount;
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('success');
|
if (failCount === 0) {
|
||||||
setTestMessage(t('ai_providers.openai_test_success'));
|
const message = t('ai_providers.openai_test_all_success', { count: successCount });
|
||||||
} catch (err: unknown) {
|
setTestStatus('success');
|
||||||
setTestStatus('error');
|
setTestMessage(message);
|
||||||
const message = getErrorMessage(err);
|
showNotification(message, 'success');
|
||||||
const errorCode =
|
} else if (successCount === 0) {
|
||||||
typeof err === 'object' && err !== null && 'code' in err
|
const message = t('ai_providers.openai_test_all_failed', { count: failCount });
|
||||||
? String((err as { code?: string }).code)
|
setTestStatus('error');
|
||||||
: '';
|
setTestMessage(message);
|
||||||
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
showNotification(message, 'error');
|
||||||
if (isTimeout) {
|
|
||||||
setTestMessage(
|
|
||||||
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
|
|
||||||
);
|
|
||||||
} else {
|
} 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 (
|
return (
|
||||||
@@ -245,14 +511,14 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
{invalidIndexParam || invalidIndex ? (
|
{invalidIndexParam || invalidIndex ? (
|
||||||
<div className="hint">Invalid provider index.</div>
|
<div className={styles.sectionHint}>{t('common.invalid_provider_index')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className={styles.openaiEditForm}>
|
||||||
<Input
|
<Input
|
||||||
label={t('ai_providers.openai_add_modal_name_label')}
|
label={t('ai_providers.openai_add_modal_name_label')}
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
disabled={saving || disableControls}
|
disabled={saving || disableControls || isTestingKeys}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('ai_providers.prefix_label')}
|
label={t('ai_providers.prefix_label')}
|
||||||
@@ -260,13 +526,13 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
value={form.prefix ?? ''}
|
value={form.prefix ?? ''}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
||||||
hint={t('ai_providers.prefix_hint')}
|
hint={t('ai_providers.prefix_hint')}
|
||||||
disabled={saving || disableControls}
|
disabled={saving || disableControls || isTestingKeys}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('ai_providers.openai_add_modal_url_label')}
|
label={t('ai_providers.openai_add_modal_url_label')}
|
||||||
value={form.baseUrl}
|
value={form.baseUrl}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
disabled={saving || disableControls}
|
disabled={saving || disableControls || isTestingKeys}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HeaderInputList
|
<HeaderInputList
|
||||||
@@ -275,77 +541,98 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_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>
|
<div className={styles.modelConfigSection}>
|
||||||
{hasIndexParam
|
{/* 标题行 */}
|
||||||
? t('ai_providers.openai_edit_modal_models_label')
|
<div className={styles.modelConfigHeader}>
|
||||||
: t('ai_providers.openai_add_modal_models_label')}
|
<label className={styles.modelConfigTitle}>
|
||||||
</label>
|
{hasIndexParam
|
||||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
? 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={styles.sectionHint}>{t('ai_providers.openai_models_hint')}</div>
|
||||||
|
|
||||||
|
{/* 模型列表 */}
|
||||||
<ModelInputList
|
<ModelInputList
|
||||||
entries={form.modelEntries}
|
entries={form.modelEntries}
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||||
addLabel={t('ai_providers.openai_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
namePlaceholder={t('common.model_name_placeholder')}
|
||||||
aliasPlaceholder={t('common.model_alias_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={styles.modelTestPanel}>
|
||||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
<div className={styles.modelTestMeta}>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<label className={styles.modelTestLabel}>{t('ai_providers.openai_test_title')}</label>
|
||||||
<select
|
<span className={styles.modelTestHint}>{t('ai_providers.openai_test_hint')}</span>
|
||||||
className={`input ${styles.openaiTestSelect}`}
|
</div>
|
||||||
value={testModel}
|
<div className={styles.modelTestControls}>
|
||||||
onChange={(e) => {
|
<Select
|
||||||
setTestModel(e.target.value);
|
value={testModel}
|
||||||
setTestStatus('idle');
|
options={modelSelectOptions}
|
||||||
setTestMessage('');
|
onChange={(value) => {
|
||||||
}}
|
setTestModel(value);
|
||||||
disabled={saving || disableControls || availableModels.length === 0}
|
setTestStatus('idle');
|
||||||
>
|
setTestMessage('');
|
||||||
<option value="">
|
}}
|
||||||
{availableModels.length
|
placeholder={
|
||||||
? t('ai_providers.openai_test_select_placeholder')
|
availableModels.length
|
||||||
: t('ai_providers.openai_test_select_empty')}
|
? t('ai_providers.openai_test_select_placeholder')
|
||||||
</option>
|
: t('ai_providers.openai_test_select_empty')
|
||||||
{form.modelEntries
|
}
|
||||||
.filter((entry) => entry.name.trim())
|
className={styles.openaiTestSelect}
|
||||||
.map((entry, idx) => {
|
ariaLabel={t('ai_providers.openai_test_title')}
|
||||||
const name = entry.name.trim();
|
disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || availableModels.length === 0}
|
||||||
const alias = entry.alias.trim();
|
/>
|
||||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
<Button
|
||||||
return (
|
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
||||||
<option key={`${name}-${idx}`} value={name}>
|
size="sm"
|
||||||
{label}
|
onClick={() => void testAllKeys()}
|
||||||
</option>
|
loading={testStatus === 'loading'}
|
||||||
);
|
disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || !hasConfiguredModels || !hasTestableKeys}
|
||||||
})}
|
title={t('ai_providers.openai_test_all_hint')}
|
||||||
</select>
|
className={styles.modelTestAllButton}
|
||||||
<Button
|
>
|
||||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
{t('ai_providers.openai_test_all_action')}
|
||||||
className={`${styles.openaiTestButton} ${
|
</Button>
|
||||||
testStatus === 'success' ? styles.openaiTestButtonSuccess : ''
|
</div>
|
||||||
}`}
|
|
||||||
onClick={() => void testOpenaiProviderConnection()}
|
|
||||||
loading={testStatus === 'loading'}
|
|
||||||
disabled={saving || disableControls || availableModels.length === 0}
|
|
||||||
>
|
|
||||||
{t('ai_providers.openai_test_action')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{testMessage && (
|
{testMessage && (
|
||||||
<div
|
<div
|
||||||
@@ -362,11 +649,14 @@ export function AiProvidersOpenAIEditPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className={styles.keyEntriesSection}>
|
||||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
<div className={styles.keyEntriesHeader}>
|
||||||
|
<label className={styles.keyEntriesTitle}>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||||
|
<span className={styles.keyEntriesHint}>{t('ai_providers.openai_keys_hint')}</span>
|
||||||
|
</div>
|
||||||
{renderKeyEntries(form.apiKeyEntries)}
|
{renderKeyEntries(form.apiKeyEntries)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</SecondaryScreenShell>
|
</SecondaryScreenShell>
|
||||||
|
|||||||
@@ -153,70 +153,76 @@ export function AiProvidersOpenAIModelsPage() {
|
|||||||
loadingLabel={t('common.loading')}
|
loadingLabel={t('common.loading')}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<div className="hint" style={{ marginBottom: 8 }}>
|
<div className={styles.openaiModelsContent}>
|
||||||
{t('ai_providers.openai_models_fetch_hint')}
|
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_hint')}</div>
|
||||||
</div>
|
<div className={styles.openaiModelsEndpointSection}>
|
||||||
<div className="form-group">
|
<label className={styles.openaiModelsEndpointLabel}>
|
||||||
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
{t('ai_providers.openai_models_fetch_url_label')}
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
</label>
|
||||||
<input className="input" readOnly value={endpoint} />
|
<div className={styles.openaiModelsEndpointControls}>
|
||||||
<Button
|
<input
|
||||||
variant="secondary"
|
className={`input ${styles.openaiModelsEndpointInput}`}
|
||||||
size="sm"
|
readOnly
|
||||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
value={endpoint}
|
||||||
loading={fetching}
|
/>
|
||||||
disabled={disableControls || saving}
|
<Button
|
||||||
>
|
variant="secondary"
|
||||||
{t('ai_providers.openai_models_fetch_refresh')}
|
size="sm"
|
||||||
</Button>
|
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
||||||
|
loading={fetching}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
>
|
||||||
|
{t('ai_providers.openai_models_fetch_refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Input
|
||||||
<Input
|
label={t('ai_providers.openai_models_search_label')}
|
||||||
label={t('ai_providers.openai_models_search_label')}
|
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
||||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
value={search}
|
||||||
value={search}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
disabled={fetching}
|
||||||
disabled={fetching}
|
/>
|
||||||
/>
|
{error && <div className="error-box">{error}</div>}
|
||||||
{error && <div className="error-box">{error}</div>}
|
{fetching ? (
|
||||||
{fetching ? (
|
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||||
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
) : models.length === 0 ? (
|
||||||
) : models.length === 0 ? (
|
<div className={styles.sectionHint}>{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||||
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
) : filteredModels.length === 0 ? (
|
||||||
) : filteredModels.length === 0 ? (
|
<div className={styles.sectionHint}>{t('ai_providers.openai_models_search_empty')}</div>
|
||||||
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
) : (
|
||||||
) : (
|
<div className={styles.modelDiscoveryList}>
|
||||||
<div className={styles.modelDiscoveryList}>
|
{filteredModels.map((model) => {
|
||||||
{filteredModels.map((model) => {
|
const checked = selected.has(model.name);
|
||||||
const checked = selected.has(model.name);
|
return (
|
||||||
return (
|
<label
|
||||||
<label
|
key={model.name}
|
||||||
key={model.name}
|
className={`${styles.modelDiscoveryRow} ${
|
||||||
className={`${styles.modelDiscoveryRow} ${
|
checked ? styles.modelDiscoveryRowSelected : ''
|
||||||
checked ? styles.modelDiscoveryRowSelected : ''
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
checked={checked}
|
||||||
checked={checked}
|
onChange={() => toggleSelection(model.name)}
|
||||||
onChange={() => toggleSelection(model.name)}
|
/>
|
||||||
/>
|
<div className={styles.modelDiscoveryMeta}>
|
||||||
<div className={styles.modelDiscoveryMeta}>
|
<div className={styles.modelDiscoveryName}>
|
||||||
<div className={styles.modelDiscoveryName}>
|
{model.name}
|
||||||
{model.name}
|
{model.alias && (
|
||||||
{model.alias && (
|
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||||
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
)}
|
||||||
|
</div>
|
||||||
|
{model.description && (
|
||||||
|
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{model.description && (
|
</label>
|
||||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
);
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
)}
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</SecondaryScreenShell>
|
</SecondaryScreenShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -93,9 +93,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statFailure {
|
.statFailure {
|
||||||
background-color: var(--failure-badge-bg, #fee2e2);
|
background-color: var(--failure-badge-bg);
|
||||||
color: var(--failure-badge-text, #991b1b);
|
color: var(--failure-badge-text);
|
||||||
border-color: var(--failure-badge-border, #fca5a5);
|
border-color: var(--failure-badge-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 字段行样式:标签 + 值
|
// 字段行样式:标签 + 值
|
||||||
@@ -311,8 +311,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.apiKeyEntryStatFailure {
|
.apiKeyEntryStatFailure {
|
||||||
background: var(--failure-badge-bg, #fee2e2);
|
background: var(--failure-badge-bg);
|
||||||
color: var(--failure-badge-text, #991b1b);
|
color: var(--failure-badge-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI 模型发现(二级界面)
|
// OpenAI 模型发现(二级界面)
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-top: 8px;
|
margin-top: 0;
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
.statusBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -415,37 +402,123 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockWrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 6px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusBlock {
|
.statusBlock {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
min-width: 6px;
|
|
||||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
.statusBlockWrapper:hover &,
|
||||||
transform: scaleY(1.5);
|
.statusBlockWrapper.statusBlockActive & {
|
||||||
opacity: 0.85;
|
transform: scaleY(1.8);
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusBlockSuccess {
|
|
||||||
background-color: var(--success-color, #22c55e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBlockFailure {
|
|
||||||
background-color: var(--danger-color, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBlockMixed {
|
|
||||||
background-color: var(--warning-color, #f59e0b);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBlockIdle {
|
.statusBlockIdle {
|
||||||
background-color: var(--border-secondary, #e5e7eb);
|
background-color: var(--border-secondary, #e5e7eb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusTooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border: 1px solid var(--border-secondary, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: $z-dropdown;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
// 小箭头
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-top-color: var(--bg-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: var(--border-secondary, #e5e7eb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止左右溢出
|
||||||
|
.statusTooltipLeft {
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(0);
|
||||||
|
|
||||||
|
&::after,
|
||||||
|
&::before {
|
||||||
|
left: 8px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusTooltipRight {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(0);
|
||||||
|
|
||||||
|
&::after,
|
||||||
|
&::before {
|
||||||
|
left: auto;
|
||||||
|
right: 8px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipTime {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipStats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipSuccess {
|
||||||
|
color: var(--success-color, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipFailure {
|
||||||
|
color: var(--danger-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipRate {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.statusRate {
|
.statusRate {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -469,15 +542,407 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statusRateLow {
|
.statusRateLow {
|
||||||
color: var(--failure-badge-text, #991b1b);
|
color: var(--failure-badge-text);
|
||||||
background: var(--failure-badge-bg, #fee2e2);
|
background: var(--failure-badge-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.statusTooltip {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlocks {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Model Config Section - Unified Layout
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
.openaiEditForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
|
||||||
|
:global(.form-group) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.status-badge) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.openaiModelsContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
|
||||||
|
:global(.form-group) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.openaiModelsEndpointSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.openaiModelsEndpointLabel {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.openaiModelsEndpointControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.openaiModelsEndpointInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelConfigSection {
|
||||||
|
margin-bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelConfigHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelConfigTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 0;
|
||||||
|
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: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelTestLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelTestHint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyEntriesHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyEntriesTitle {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyEntriesHint {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
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']) {
|
:global([data-theme='dark']) {
|
||||||
.headerBadge {
|
.headerBadge {
|
||||||
background: rgba(59, 130, 246, 0.15);
|
background: rgba($primary-color, 0.14);
|
||||||
border-color: rgba(59, 130, 246, 0.3);
|
border-color: rgba($primary-color, 0.35);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
@@ -486,22 +951,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modelTag {
|
.modelTag {
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: rgba($primary-color, 0.1);
|
||||||
border-color: var(--border-secondary);
|
border-color: var(--border-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.excludedModelTag {
|
.excludedModelTag {
|
||||||
background: rgba(251, 191, 36, 0.22);
|
background: rgba($warning-color, 0.22);
|
||||||
border-color: rgba(251, 191, 36, 0.55);
|
border-color: rgba($warning-color, 0.55);
|
||||||
color: #fde68a;
|
color: var(--warning-color);
|
||||||
|
|
||||||
.modelName {
|
.modelName {
|
||||||
color: #fde68a;
|
color: var(--warning-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.excludedModelsLabel {
|
.excludedModelsLabel {
|
||||||
color: #fde68a;
|
color: var(--warning-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.apiKeyEntryCard {
|
.apiKeyEntryCard {
|
||||||
@@ -517,6 +982,20 @@
|
|||||||
background-color: var(--border-primary, #374151);
|
background-color: var(--border-primary, #374151);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusTooltip {
|
||||||
|
background: var(--bg-secondary, #1f2937);
|
||||||
|
border-color: var(--border-primary, #374151);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border-top-color: var(--bg-secondary, #1f2937);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
border-top-color: var(--border-primary, #374151);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.statusRateHigh {
|
.statusRateHigh {
|
||||||
background: rgba(34, 197, 94, 0.2);
|
background: rgba(34, 197, 94, 0.2);
|
||||||
color: #86efac;
|
color: #86efac;
|
||||||
@@ -528,7 +1007,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statusRateLow {
|
.statusRateLow {
|
||||||
background: rgba(239, 68, 68, 0.2);
|
background: rgba($error-color, 0.24);
|
||||||
color: #fca5a5;
|
color: #f1b0a6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export function AiProvidersVertexEditPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
{invalidIndexParam || invalidIndex ? (
|
{invalidIndexParam || invalidIndex ? (
|
||||||
<div className="hint">Invalid provider index.</div>
|
<div className="hint">{t('common.invalid_provider_index')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -256,6 +256,8 @@ export function AiProvidersVertexEditPage() {
|
|||||||
addLabel={t('common.custom_headers_add')}
|
addLabel={t('common.custom_headers_add')}
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={disableControls || saving}
|
disabled={disableControls || saving}
|
||||||
/>
|
/>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -266,6 +268,8 @@ export function AiProvidersVertexEditPage() {
|
|||||||
addLabel={t('ai_providers.vertex_models_add_btn')}
|
addLabel={t('ai_providers.vertex_models_add_btn')}
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
namePlaceholder={t('common.model_name_placeholder')}
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||||
|
removeButtonTitle={t('common.delete')}
|
||||||
|
removeButtonAriaLabel={t('common.delete')}
|
||||||
disabled={disableControls || saving}
|
disabled={disableControls || saving}
|
||||||
/>
|
/>
|
||||||
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const OAUTH_PROVIDER_PRESETS = [
|
|||||||
'claude',
|
'claude',
|
||||||
'codex',
|
'codex',
|
||||||
'qwen',
|
'qwen',
|
||||||
|
'kimi',
|
||||||
'iflow',
|
'iflow',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const OAUTH_PROVIDER_PRESETS = [
|
|||||||
'claude',
|
'claude',
|
||||||
'codex',
|
'codex',
|
||||||
'qwen',
|
'qwen',
|
||||||
|
'kimi',
|
||||||
'iflow',
|
'iflow',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
|
padding-bottom: calc(var(--auth-files-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageHeader {
|
.pageHeader {
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
|
|
||||||
.errorBox {
|
.errorBox {
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
background-color: rgba(239, 68, 68, 0.1);
|
background-color: rgba($error-color, 0.1);
|
||||||
border: 1px solid var(--danger-color);
|
border: 1px solid var(--danger-color);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
@@ -80,12 +81,13 @@
|
|||||||
|
|
||||||
.filterTag {
|
.filterTag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all $transition-fast;
|
transition: all $transition-fast;
|
||||||
@@ -101,12 +103,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filterTagLabel {
|
.filterTagLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterTagCount {
|
.filterTagCount {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 2ch;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,10 +194,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fileGridQuotaManaged {
|
.fileGridQuotaManaged {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(520px, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
|
||||||
@include tablet {
|
@include tablet {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
@@ -374,11 +383,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quotaBarFillMedium {
|
.quotaBarFillMedium {
|
||||||
background-color: var(--warning-color, #f59e0b);
|
background-color: var(--warning-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quotaBarFillLow {
|
.quotaBarFillLow {
|
||||||
background-color: var(--danger-color, #ef4444);
|
background-color: var(--danger-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quotaMeta {
|
.quotaMeta {
|
||||||
@@ -414,10 +423,28 @@
|
|||||||
padding: $spacing-sm 0;
|
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 {
|
.quotaError {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
background-color: rgba(239, 68, 68, 0.08);
|
background-color: rgba($error-color, 0.08);
|
||||||
border: 1px solid var(--danger-color);
|
border: 1px solid var(--danger-color);
|
||||||
border-radius: $radius-sm;
|
border-radius: $radius-sm;
|
||||||
padding: $spacing-xs $spacing-sm;
|
padding: $spacing-xs $spacing-sm;
|
||||||
@@ -425,9 +452,9 @@
|
|||||||
|
|
||||||
.quotaWarning {
|
.quotaWarning {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--warning-color, #f59e0b);
|
color: var(--warning-text);
|
||||||
background-color: rgba(245, 158, 11, 0.12);
|
background-color: var(--warning-bg);
|
||||||
border: 1px solid var(--warning-color, #f59e0b);
|
border: 1px solid var(--warning-border);
|
||||||
border-radius: $radius-sm;
|
border-radius: $radius-sm;
|
||||||
padding: $spacing-xs $spacing-sm;
|
padding: $spacing-xs $spacing-sm;
|
||||||
}
|
}
|
||||||
@@ -471,6 +498,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fileCardSelected {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary-color) 70%, transparent);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fileCardDisabled {
|
.fileCardDisabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|
||||||
@@ -487,17 +523,6 @@
|
|||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fileCardLayoutQuota {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 156px;
|
|
||||||
gap: $spacing-md;
|
|
||||||
align-items: stretch;
|
|
||||||
|
|
||||||
@include mobile {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileCardMain {
|
.fileCardMain {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -506,41 +531,6 @@
|
|||||||
min-width: 0;
|
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 {
|
.cardHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -548,6 +538,45 @@
|
|||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selectionToggle {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
|
||||||
|
color: var(--primary-contrast, #fff);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color $transition-fast,
|
||||||
|
background-color $transition-fast,
|
||||||
|
box-shadow $transition-fast,
|
||||||
|
transform $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectionToggleActive {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectionToggleActive svg {
|
||||||
|
display: block;
|
||||||
|
stroke-width: 2.4;
|
||||||
|
}
|
||||||
|
|
||||||
.typeBadge {
|
.typeBadge {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -606,9 +635,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statFailure {
|
.statFailure {
|
||||||
background-color: var(--failure-badge-bg, #fee2e2);
|
background-color: var(--failure-badge-bg);
|
||||||
color: var(--failure-badge-text, #991b1b);
|
color: var(--failure-badge-text);
|
||||||
border-color: var(--failure-badge-border, #fca5a5);
|
border-color: var(--failure-badge-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 状态监测栏
|
// 状态监测栏
|
||||||
@@ -625,39 +654,121 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockWrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 6px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusBlock {
|
.statusBlock {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
min-width: 6px;
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
transition:
|
|
||||||
transform 0.15s ease,
|
|
||||||
opacity 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
.statusBlockWrapper:hover &,
|
||||||
transform: scaleY(1.5);
|
.statusBlockWrapper.statusBlockActive & {
|
||||||
opacity: 0.85;
|
transform: scaleY(1.8);
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusBlockSuccess {
|
|
||||||
background-color: var(--success-color, #22c55e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBlockFailure {
|
|
||||||
background-color: var(--danger-color, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBlockMixed {
|
|
||||||
background-color: var(--warning-color, #f59e0b);
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBlockIdle {
|
.statusBlockIdle {
|
||||||
background-color: var(--border-secondary, #e5e7eb);
|
background-color: var(--border-secondary, #e5e7eb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusTooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border: 1px solid var(--border-secondary, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: $z-dropdown;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-top-color: var(--bg-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: var(--border-secondary, #e5e7eb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusTooltipLeft {
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(0);
|
||||||
|
|
||||||
|
&::after,
|
||||||
|
&::before {
|
||||||
|
left: 8px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusTooltipRight {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(0);
|
||||||
|
|
||||||
|
&::after,
|
||||||
|
&::before {
|
||||||
|
left: auto;
|
||||||
|
right: 8px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipTime {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipStats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipSuccess {
|
||||||
|
color: var(--success-color, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipFailure {
|
||||||
|
color: var(--danger-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipRate {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.statusRate {
|
.statusRate {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -681,8 +792,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.statusRateLow {
|
.statusRateLow {
|
||||||
color: var(--failure-badge-text, #991b1b);
|
color: var(--failure-badge-text);
|
||||||
background: var(--failure-badge-bg, #fee2e2);
|
background: var(--failure-badge-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.statusTooltip {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlocks {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.prefixProxyEditor {
|
.prefixProxyEditor {
|
||||||
@@ -707,7 +829,7 @@
|
|||||||
padding: $spacing-sm $spacing-md;
|
padding: $spacing-sm $spacing-md;
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
border: 1px solid var(--danger-color);
|
border: 1px solid var(--danger-color);
|
||||||
background-color: rgba(239, 68, 68, 0.1);
|
background-color: rgba($error-color, 0.1);
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -805,6 +927,66 @@
|
|||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batchActionContainer {
|
||||||
|
position: fixed;
|
||||||
|
left: var(--content-center-x, 50%);
|
||||||
|
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 50;
|
||||||
|
width: min(960px, calc(100vw - 24px));
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batchActionBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 84%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batchActionLeft,
|
||||||
|
.batchActionRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batchActionRight {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batchSelectionText {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.batchActionContainer {
|
||||||
|
width: calc(100vw - 16px);
|
||||||
|
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.batchActionBar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batchActionLeft,
|
||||||
|
.batchActionRight {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pageInfo {
|
.pageInfo {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -813,7 +995,7 @@
|
|||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth 排除列表
|
// OAuth 模型禁用
|
||||||
.excludedList {
|
.excludedList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -861,7 +1043,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth 排除列表表单:提供商快捷标签
|
// OAuth 模型禁用表单:提供商快捷标签
|
||||||
.providerField {
|
.providerField {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1117,7 +1299,7 @@
|
|||||||
.modelExcludedBadge {
|
.modelExcludedBadge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
background-color: rgba(239, 68, 68, 0.1);
|
background-color: rgba($error-color, 0.1);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--danger-color);
|
border: 1px solid var(--danger-color);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -192,15 +192,15 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
||||||
&.modified {
|
&.modified {
|
||||||
color: #f59e0b;
|
color: var(--warning-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.saved {
|
&.saved {
|
||||||
color: #16a34a;
|
color: var(--success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.error {
|
||||||
color: #dc2626;
|
color: var(--danger-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,12 +331,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-lg);
|
||||||
max-width: inherit;
|
max-width: inherit;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -351,7 +351,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: min(280px, 46vw);
|
max-width: min(280px, 46vw);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -373,7 +373,7 @@
|
|||||||
transition: background-color 0.2s ease, transform 0.15s ease;
|
transition: background-color 0.2s ease, transform 0.15s ease;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: rgba(0, 0, 0, 0.06);
|
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,26 +395,8 @@
|
|||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #f59e0b;
|
background: var(--warning-color);
|
||||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25);
|
box-shadow: 0 0 0 2px rgba($warning-color, 0.25);
|
||||||
}
|
|
||||||
|
|
||||||
:global([data-theme='dark']) {
|
|
||||||
.floatingActionList {
|
|
||||||
background: rgba(30, 30, 30, 0.7);
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floatingStatus {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floatingActionButton {
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
|||||||
import { yaml } from '@codemirror/lang-yaml';
|
import { yaml } from '@codemirror/lang-yaml';
|
||||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||||
import { keymap } from '@codemirror/view';
|
import { keymap } from '@codemirror/view';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons';
|
import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons';
|
||||||
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
|
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
|
||||||
|
import { DiffModal } from '@/components/config/DiffModal';
|
||||||
import { useVisualConfig } from '@/hooks/useVisualConfig';
|
import { useVisualConfig } from '@/hooks/useVisualConfig';
|
||||||
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
|
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
|
||||||
import { configFileApi } from '@/services/api/configFile';
|
import { configFileApi } from '@/services/api/configFile';
|
||||||
@@ -17,6 +19,16 @@ import styles from './ConfigPage.module.scss';
|
|||||||
|
|
||||||
type ConfigEditorTab = 'visual' | 'source';
|
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() {
|
export function ConfigPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
@@ -42,6 +54,9 @@ export function ConfigPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [diffModalOpen, setDiffModalOpen] = useState(false);
|
||||||
|
const [serverYaml, setServerYaml] = useState('');
|
||||||
|
const [mergedYaml, setMergedYaml] = useState('');
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -62,6 +77,9 @@ export function ConfigPage() {
|
|||||||
const data = await configFileApi.fetchConfigYaml();
|
const data = await configFileApi.fetchConfigYaml();
|
||||||
setContent(data);
|
setContent(data);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
setDiffModalOpen(false);
|
||||||
|
setServerYaml(data);
|
||||||
|
setMergedYaml(data);
|
||||||
loadVisualValuesFromYaml(data);
|
loadVisualValuesFromYaml(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
|
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||||
@@ -75,16 +93,52 @@ export function ConfigPage() {
|
|||||||
loadConfig();
|
loadConfig();
|
||||||
}, [loadConfig]);
|
}, [loadConfig]);
|
||||||
|
|
||||||
|
const handleConfirmSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const previousCommercialMode = readCommercialModeFromYaml(serverYaml);
|
||||||
|
const nextCommercialMode = readCommercialModeFromYaml(mergedYaml);
|
||||||
|
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
|
||||||
|
|
||||||
|
await configFileApi.saveConfigYaml(mergedYaml);
|
||||||
|
const latestContent = await configFileApi.fetchConfigYaml();
|
||||||
|
setDirty(false);
|
||||||
|
setDiffModalOpen(false);
|
||||||
|
setContent(latestContent);
|
||||||
|
setServerYaml(latestContent);
|
||||||
|
setMergedYaml(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');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
|
const nextMergedYaml = applyVisualChangesToYaml(content);
|
||||||
await configFileApi.saveConfigYaml(nextContent);
|
const latestServerYaml = await configFileApi.fetchConfigYaml();
|
||||||
const latestContent = await configFileApi.fetchConfigYaml();
|
|
||||||
setDirty(false);
|
if (latestServerYaml === nextMergedYaml) {
|
||||||
setContent(latestContent);
|
setDirty(false);
|
||||||
loadVisualValuesFromYaml(latestContent);
|
setContent(latestServerYaml);
|
||||||
showNotification(t('config_management.save_success'), 'success');
|
setServerYaml(latestServerYaml);
|
||||||
|
setMergedYaml(nextMergedYaml);
|
||||||
|
loadVisualValuesFromYaml(latestServerYaml);
|
||||||
|
showNotification(t('config_management.diff.no_changes'), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setServerYaml(latestServerYaml);
|
||||||
|
setMergedYaml(nextMergedYaml);
|
||||||
|
setDiffModalOpen(true);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '';
|
const message = err instanceof Error ? err.message : '';
|
||||||
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
|
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
|
||||||
@@ -309,7 +363,7 @@ export function ConfigPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.floatingActionButton}
|
className={styles.floatingActionButton}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={disableControls || loading || saving || !isDirty}
|
disabled={disableControls || loading || saving || !isDirty || diffModalOpen}
|
||||||
title={t('config_management.save')}
|
title={t('config_management.save')}
|
||||||
aria-label={t('config_management.save')}
|
aria-label={t('config_management.save')}
|
||||||
>
|
>
|
||||||
@@ -457,6 +511,14 @@ export function ConfigPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
|
||||||
|
<DiffModal
|
||||||
|
open={diffModalOpen}
|
||||||
|
original={serverYaml}
|
||||||
|
modified={mergedYaml}
|
||||||
|
onConfirm={handleConfirmSave}
|
||||||
|
onCancel={() => setDiffModalOpen(false)}
|
||||||
|
loading={saving}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,14 +62,23 @@ export function DashboardPage() {
|
|||||||
apiKeysCache.current = [];
|
apiKeysCache.current = [];
|
||||||
}, [apiBase, config?.apiKeys]);
|
}, [apiBase, config?.apiKeys]);
|
||||||
|
|
||||||
const normalizeApiKeyList = (input: any): string[] => {
|
const normalizeApiKeyList = (input: unknown): string[] => {
|
||||||
if (!Array.isArray(input)) return [];
|
if (!Array.isArray(input)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
|
|
||||||
input.forEach((item) => {
|
input.forEach((item) => {
|
||||||
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
|
const record =
|
||||||
const trimmed = String(value || '').trim();
|
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;
|
if (!trimmed || seen.has(trimmed)) return;
|
||||||
seen.add(trimmed);
|
seen.add(trimmed);
|
||||||
keys.push(trimmed);
|
keys.push(trimmed);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
.container {
|
.container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 左侧品牌展示区
|
// 左侧品牌展示区
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: $spacing-2xl;
|
padding: $spacing-2xl;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-secondary);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@media (max-width: $breakpoint-mobile) {
|
@media (max-width: $breakpoint-mobile) {
|
||||||
@@ -167,9 +167,24 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语言切换按钮
|
// 语言下拉选择
|
||||||
.languageBtn {
|
.languageSelect {
|
||||||
white-space: nowrap;
|
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($primary-color, 0.18);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 连接信息框
|
// 连接信息框
|
||||||
@@ -220,8 +235,8 @@
|
|||||||
|
|
||||||
// 错误提示框
|
// 错误提示框
|
||||||
.errorBox {
|
.errorBox {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba($error-color, 0.1);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
border: 1px solid rgba($error-color, 0.4);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
padding: $spacing-sm $spacing-md;
|
padding: $spacing-sm $spacing-md;
|
||||||
color: $error-color;
|
color: $error-color;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/Input';
|
|||||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||||
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
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 { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
import type { ApiError } from '@/types';
|
import type { ApiError } from '@/types';
|
||||||
import styles from './LoginPage.module.scss';
|
import styles from './LoginPage.module.scss';
|
||||||
@@ -13,11 +15,20 @@ import styles from './LoginPage.module.scss';
|
|||||||
/**
|
/**
|
||||||
* 将 API 错误转换为本地化的用户友好消息
|
* 将 API 错误转换为本地化的用户友好消息
|
||||||
*/
|
*/
|
||||||
function getLocalizedErrorMessage(error: any, t: (key: string) => string): string {
|
type RedirectState = { from?: { pathname?: string } };
|
||||||
const apiError = error as ApiError;
|
|
||||||
const status = apiError?.status;
|
function getLocalizedErrorMessage(error: unknown, t: (key: string) => string): string {
|
||||||
const code = apiError?.code;
|
const apiError = error as Partial<ApiError>;
|
||||||
const message = apiError?.message || '';
|
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 状态码判断
|
// 根据 HTTP 状态码判断
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
@@ -59,7 +70,7 @@ export function LoginPage() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const language = useLanguageStore((state) => state.language);
|
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 isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||||
@@ -78,7 +89,16 @@ export function LoginPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
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(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -88,7 +108,7 @@ export function LoginPage() {
|
|||||||
setAutoLoginSuccess(true);
|
setAutoLoginSuccess(true);
|
||||||
// 延迟跳转,让用户看到成功动画
|
// 延迟跳转,让用户看到成功动画
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
const redirect = (location.state as RedirectState | null)?.from?.pathname || '/';
|
||||||
navigate(redirect, { replace: true });
|
navigate(redirect, { replace: true });
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} else {
|
} else {
|
||||||
@@ -124,7 +144,7 @@ export function LoginPage() {
|
|||||||
});
|
});
|
||||||
showNotification(t('common.connected_status'), 'success');
|
showNotification(t('common.connected_status'), 'success');
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
const message = getLocalizedErrorMessage(err, t);
|
const message = getLocalizedErrorMessage(err, t);
|
||||||
setError(message);
|
setError(message);
|
||||||
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
|
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
|
||||||
@@ -144,7 +164,7 @@ export function LoginPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
|
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 />;
|
return <Navigate to={redirect} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +188,8 @@ export function LoginPage() {
|
|||||||
/* 启动动画 */
|
/* 启动动画 */
|
||||||
<div className={styles.splashContent}>
|
<div className={styles.splashContent}>
|
||||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.splashLogo} />
|
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.splashLogo} />
|
||||||
<h1 className={styles.splashTitle}>CLI Proxy API</h1>
|
<h1 className={styles.splashTitle}>{t('splash.title')}</h1>
|
||||||
<p className={styles.splashSubtitle}>Management Center</p>
|
<p className={styles.splashSubtitle}>{t('splash.subtitle')}</p>
|
||||||
<div className={styles.splashLoader}>
|
<div className={styles.splashLoader}>
|
||||||
<div className={styles.splashLoaderBar} />
|
<div className={styles.splashLoaderBar} />
|
||||||
</div>
|
</div>
|
||||||
@@ -185,17 +205,19 @@ export function LoginPage() {
|
|||||||
<div className={styles.loginHeader}>
|
<div className={styles.loginHeader}>
|
||||||
<div className={styles.titleRow}>
|
<div className={styles.titleRow}>
|
||||||
<div className={styles.title}>{t('title.login')}</div>
|
<div className={styles.title}>{t('title.login')}</div>
|
||||||
<Button
|
<select
|
||||||
type="button"
|
className={styles.languageSelect}
|
||||||
variant="ghost"
|
value={language}
|
||||||
size="sm"
|
onChange={handleLanguageChange}
|
||||||
className={styles.languageBtn}
|
|
||||||
onClick={toggleLanguage}
|
|
||||||
title={t('language.switch')}
|
title={t('language.switch')}
|
||||||
aria-label={t('language.switch')}
|
aria-label={t('language.switch')}
|
||||||
>
|
>
|
||||||
{nextLanguageLabel}
|
{LANGUAGE_ORDER.map((lang) => (
|
||||||
</Button>
|
<option key={lang} value={lang}>
|
||||||
|
{t(LANGUAGE_LABEL_KEYS[lang])}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.subtitle}>{t('login.subtitle')}</div>
|
<div className={styles.subtitle}>{t('login.subtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -308,7 +308,7 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(59, 130, 246, 0.06);
|
background: rgba($primary-color, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include tablet {
|
@include tablet {
|
||||||
@@ -409,14 +409,14 @@
|
|||||||
|
|
||||||
.statusInfo {
|
.statusInfo {
|
||||||
color: var(--info-color);
|
color: var(--info-color);
|
||||||
background: rgba(59, 130, 246, 0.12);
|
background: rgba($primary-color, 0.12);
|
||||||
border-color: rgba(59, 130, 246, 0.25);
|
border-color: rgba($primary-color, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusWarn {
|
.statusWarn {
|
||||||
color: var(--warning-color);
|
color: var(--warning-text);
|
||||||
background: rgba(245, 158, 11, 0.14);
|
background: var(--warning-bg);
|
||||||
border-color: rgba(245, 158, 11, 0.25);
|
border-color: var(--warning-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusError {
|
.statusError {
|
||||||
@@ -427,20 +427,20 @@
|
|||||||
|
|
||||||
.levelInfo {
|
.levelInfo {
|
||||||
color: var(--info-color);
|
color: var(--info-color);
|
||||||
background: rgba(59, 130, 246, 0.12);
|
background: rgba($primary-color, 0.12);
|
||||||
border-color: rgba(59, 130, 246, 0.25);
|
border-color: rgba($primary-color, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.levelWarn {
|
.levelWarn {
|
||||||
color: var(--warning-color);
|
color: var(--warning-text);
|
||||||
background: rgba(245, 158, 11, 0.14);
|
background: var(--warning-bg);
|
||||||
border-color: rgba(245, 158, 11, 0.25);
|
border-color: var(--warning-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.levelError {
|
.levelError {
|
||||||
color: var(--error-color);
|
color: var(--error-color);
|
||||||
background: rgba(239, 68, 68, 0.12);
|
background: rgba($error-color, 0.12);
|
||||||
border-color: rgba(239, 68, 68, 0.25);
|
border-color: rgba($error-color, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.levelDebug,
|
.levelDebug,
|
||||||
@@ -452,8 +452,8 @@
|
|||||||
|
|
||||||
.methodBadge {
|
.methodBadge {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: rgba(59, 130, 246, 0.08);
|
background: rgba($primary-color, 0.08);
|
||||||
border-color: rgba(59, 130, 246, 0.22);
|
border-color: rgba($primary-color, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.path {
|
.path {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
import { logsApi } from '@/services/api/logs';
|
import { logsApi } from '@/services/api/logs';
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
|
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
|
||||||
import { formatUnixTimestamp } from '@/utils/format';
|
import { formatUnixTimestamp } from '@/utils/format';
|
||||||
import styles from './LogsPage.module.scss';
|
import styles from './LogsPage.module.scss';
|
||||||
@@ -344,30 +345,6 @@ const getErrorMessage = (err: unknown): string => {
|
|||||||
return typeof message === 'string' ? message : '';
|
return typeof message === 'string' ? message : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
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 ok = document.execCommand('copy');
|
|
||||||
document.body.removeChild(textarea);
|
|
||||||
return ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type TabType = 'logs' | 'errors';
|
type TabType = 'logs' | 'errors';
|
||||||
|
|
||||||
export function LogsPage() {
|
export function LogsPage() {
|
||||||
@@ -400,6 +377,8 @@ export function LogsPage() {
|
|||||||
startY: number;
|
startY: number;
|
||||||
fired: boolean;
|
fired: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const logRequestInFlightRef = useRef(false);
|
||||||
|
const pendingFullReloadRef = useRef(false);
|
||||||
|
|
||||||
// 保存最新时间戳用于增量获取
|
// 保存最新时间戳用于增量获取
|
||||||
const latestTimestampRef = useRef<number>(0);
|
const latestTimestampRef = useRef<number>(0);
|
||||||
@@ -424,6 +403,15 @@ export function LogsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (logRequestInFlightRef.current) {
|
||||||
|
if (!incremental) {
|
||||||
|
pendingFullReloadRef.current = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logRequestInFlightRef.current = true;
|
||||||
|
|
||||||
if (!incremental) {
|
if (!incremental) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
@@ -474,6 +462,11 @@ export function LogsPage() {
|
|||||||
if (!incremental) {
|
if (!incremental) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
logRequestInFlightRef.current = false;
|
||||||
|
if (pendingFullReloadRef.current) {
|
||||||
|
pendingFullReloadRef.current = false;
|
||||||
|
void loadLogs(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,35 @@
|
|||||||
gap: $spacing-xl;
|
gap: $spacing-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cardContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
|
||||||
|
:global(.form-group) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.status-badge) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHintSecondary {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.oauthSection {
|
.oauthSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -61,21 +90,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.error {
|
||||||
background-color: rgba(239, 68, 68, 0.1);
|
background-color: rgba($error-color, 0.12);
|
||||||
color: #dc2626;
|
color: $error-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.waiting {
|
&.waiting {
|
||||||
background-color: rgba(59, 130, 246, 0.1);
|
background-color: rgba($primary-color, 0.12);
|
||||||
color: #3b82f6;
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.callbackSection {
|
.callbackSection {
|
||||||
margin-top: $spacing-md;
|
margin-top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.callbackActions {
|
.callbackActions {
|
||||||
@@ -117,11 +146,24 @@
|
|||||||
|
|
||||||
.geminiProjectField {
|
.geminiProjectField {
|
||||||
:global(.form-group) {
|
:global(.form-group) {
|
||||||
margin-top: $spacing-sm;
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.formItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formItemLabel {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.filePicker {
|
.filePicker {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -143,3 +185,49 @@
|
|||||||
.fileNamePlaceholder {
|
.fileNamePlaceholder {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connectionBox {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-md;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connectionLabel {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyValueList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyValueItem {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyValueKey {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyValueValue {
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user