Compare commits
236 Commits
v0.5.10
...
c6fabcb6bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6fabcb6bc | ||
|
|
460519ed00 | ||
|
|
1053e91fe4 | ||
|
|
b4d08dd0d7 | ||
|
|
1502e14ca7 | ||
|
|
7b77520526 | ||
|
|
525541ea0d | ||
|
|
e7a33f8852 | ||
|
|
70968bbc4c | ||
|
|
c93030370e | ||
|
|
96307873c5 | ||
|
|
b4eb2d790c | ||
|
|
3d33958d9e | ||
|
|
e4c5f80b02 | ||
|
|
291f67e2b9 | ||
|
|
3cdcb7a2a3 | ||
|
|
3d83d0bfe2 | ||
|
|
129d89cf67 | ||
|
|
5c85df486e | ||
|
|
34b6d114d3 | ||
|
|
94f0038f19 | ||
|
|
aa9c7d89f9 | ||
|
|
9bbf61e1b6 | ||
|
|
73198d6929 | ||
|
|
ab86fcf674 | ||
|
|
a88078e171 | ||
|
|
8148851a06 | ||
|
|
8b3c4189f1 | ||
|
|
db5fb0d125 | ||
|
|
9515d88e3c | ||
|
|
2bf721974b | ||
|
|
0c53dcfa80 | ||
|
|
034c086e31 | ||
|
|
76e9eb4aa0 | ||
|
|
f22d392b21 | ||
|
|
2539710075 | ||
|
|
6bdc87aed6 | ||
|
|
268b92c59b | ||
|
|
c89bbd5098 | ||
|
|
2715f44a5e | ||
|
|
305ddef900 | ||
|
|
7e56d33bf0 | ||
|
|
80daf03fa6 | ||
|
|
883059b031 | ||
|
|
d077b5dd26 | ||
|
|
d79ccc480d | ||
|
|
7b0d6dc7e9 | ||
|
|
b8d7b8997c | ||
|
|
0bb34ca74b | ||
|
|
99c4fbc30d | ||
|
|
a44257edda | ||
|
|
ebb80df24a | ||
|
|
5165715d37 | ||
|
|
73ee6eb2f3 | ||
|
|
161d5d1e7f | ||
|
|
3cbd04b296 | ||
|
|
859f7f120c | ||
|
|
fea29f7318 | ||
|
|
f663b83ac8 | ||
|
|
ee99836285 | ||
|
|
2086c348a9 | ||
|
|
a8abf71bfe | ||
|
|
8dca670358 | ||
|
|
71556a51c5 | ||
|
|
2a92ea8862 | ||
|
|
681fc3cee5 | ||
|
|
916dd3ec26 | ||
|
|
692f7f3cde | ||
|
|
bf20f3d86e | ||
|
|
b7e720133d | ||
|
|
e914337e57 | ||
|
|
6364bac1f2 | ||
|
|
38a3e20427 | ||
|
|
334d75f2dd | ||
|
|
42eb783395 | ||
|
|
84b219957e | ||
|
|
f5c1ef36ce | ||
|
|
fae4fb0fed | ||
|
|
1d8729ec53 | ||
|
|
c6ef8a259f | ||
|
|
0efef5a789 | ||
|
|
db376c7504 | ||
|
|
8232812ac2 | ||
|
|
2ae06a8860 | ||
|
|
dc58a0701f | ||
|
|
3446280987 | ||
|
|
82bf1806ed | ||
|
|
47f0042bf0 | ||
|
|
58154063ed | ||
|
|
cc467889d0 | ||
|
|
469e5d2ed4 | ||
|
|
6ce301d7e0 | ||
|
|
8461de124f | ||
|
|
276f416ec9 | ||
|
|
583a844771 | ||
|
|
62fa437285 | ||
|
|
daab589c49 | ||
|
|
e18e9b25ce | ||
|
|
4cfb77dd44 | ||
|
|
7cab1e8782 | ||
|
|
079f37ec93 | ||
|
|
7ce97a616f | ||
|
|
946ed36af0 | ||
|
|
f139598526 | ||
|
|
40ddd3c066 | ||
|
|
3a66dc225d | ||
|
|
eadfd7a957 | ||
|
|
f739e0b372 | ||
|
|
23fb88e5fd | ||
|
|
49b9259452 | ||
|
|
4e26b6c92d | ||
|
|
215ce61b48 | ||
|
|
a48e06a28c | ||
|
|
8a59ab73a1 | ||
|
|
66d58288b4 | ||
|
|
be3f58f0a8 | ||
|
|
c299e403cc | ||
|
|
769c05e459 | ||
|
|
5ef3406068 | ||
|
|
95cbfb8c59 | ||
|
|
c17217875c | ||
|
|
981f7ac9b2 | ||
|
|
762db81252 | ||
|
|
79f6d87d7b | ||
|
|
c5d4356d6c | ||
|
|
c989dbf1b6 | ||
|
|
3cffa19319 | ||
|
|
2367f122a8 | ||
|
|
69a8e1657e | ||
|
|
987ce0ec4b | ||
|
|
03bf58671e | ||
|
|
cb6b810d6d | ||
|
|
408e6e5872 | ||
|
|
b3808add0f | ||
|
|
0b2e6efe28 | ||
|
|
8ca6d31a26 | ||
|
|
66c6073bbc | ||
|
|
2dd3f233d3 | ||
|
|
7a65e03ad3 | ||
|
|
589a5bad4c | ||
|
|
bcaa0c8545 | ||
|
|
312a06a8b8 | ||
|
|
24861dabd2 | ||
|
|
ea1bdc3ac1 | ||
|
|
46701b40ad | ||
|
|
c9fc22bae5 | ||
|
|
ff9bd8a33b | ||
|
|
d0c376fc31 | ||
|
|
d09db34c34 | ||
|
|
9dd37245bd | ||
|
|
834ba43231 | ||
|
|
684502c8b6 | ||
|
|
0aee78c072 | ||
|
|
8780ea7ec5 | ||
|
|
40fe33aeae | ||
|
|
2a94be08fa | ||
|
|
0758cfe08a | ||
|
|
02a01e5afc | ||
|
|
961cc802b2 | ||
|
|
5f7df33469 | ||
|
|
39847fa56d | ||
|
|
561e06503c | ||
|
|
94962158ef | ||
|
|
68974ffc68 | ||
|
|
f8ed787f92 | ||
|
|
dea106cf47 | ||
|
|
76ef1b68af | ||
|
|
39a003bdd4 | ||
|
|
b1426ccefc | ||
|
|
a9df58cba7 | ||
|
|
f6563490a6 | ||
|
|
18c1ba6c3c | ||
|
|
c2627cac3e | ||
|
|
df472119e7 | ||
|
|
10f2262753 | ||
|
|
39d86d133a | ||
|
|
ddbd7d00bd | ||
|
|
e44beb541f | ||
|
|
aecd5875d6 | ||
|
|
ec4b5ab46a | ||
|
|
cd6c142324 | ||
|
|
0ebf62b564 | ||
|
|
16f3442a11 | ||
|
|
3328e686ee | ||
|
|
f60bdb0a8e | ||
|
|
5eed3e787b | ||
|
|
5ebc845a1f | ||
|
|
03c1cd1dc8 | ||
|
|
db6d5ca4b5 | ||
|
|
8d606aa456 | ||
|
|
a993299cb5 | ||
|
|
8bcd172c5a | ||
|
|
4d898b3e20 | ||
|
|
f17329b0ff | ||
|
|
2757d82007 | ||
|
|
340c1f1ae5 | ||
|
|
09c17c03b9 | ||
|
|
9d648e3404 | ||
|
|
e615979757 | ||
|
|
ea2ce4047f | ||
|
|
2a87a4d82a | ||
|
|
abf9b5f8c9 | ||
|
|
aea1ceb6be | ||
|
|
20a69a25bc | ||
|
|
e0584af365 | ||
|
|
c4034c6467 | ||
|
|
ccc82e5802 | ||
|
|
13d1804e66 | ||
|
|
62486534e4 | ||
|
|
da9469c5aa | ||
|
|
a7b77ffa25 | ||
|
|
bcf82252ea | ||
|
|
7c0a2280a4 | ||
|
|
bae7ff8752 | ||
|
|
2a57055f81 | ||
|
|
ad92f0c2ed | ||
|
|
d425332eb0 | ||
|
|
3c1a600994 | ||
|
|
673ab15ad4 | ||
|
|
95218676db | ||
|
|
defa633f92 | ||
|
|
841dfa8a61 | ||
|
|
bf5f34be0d | ||
|
|
e8d918ba98 | ||
|
|
c71af9a8a5 | ||
|
|
d8f540cdb1 | ||
|
|
18b1adb4e2 | ||
|
|
5d5334afb1 | ||
|
|
2ca662e971 | ||
|
|
e417d3c771 | ||
|
|
b6765b074e | ||
|
|
9d7db57c6a | ||
|
|
450964fb1a | ||
|
|
8e4132200d | ||
|
|
fc10db3b0a | ||
|
|
2bcaf15fe8 |
20
.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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: '^_' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
35
.github/workflows/release.yml
vendored
@@ -15,6 +15,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -36,27 +39,25 @@ jobs:
|
|||||||
mv index.html management.html
|
mv index.html management.html
|
||||||
ls -lh management.html
|
ls -lh management.html
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
current_tag="${GITHUB_REF_NAME}"
|
||||||
|
previous_tag="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${current_tag}$" | head -n 1 || true)"
|
||||||
|
if [ -n "${previous_tag}" ]; then
|
||||||
|
range="${previous_tag}..${current_tag}"
|
||||||
|
else
|
||||||
|
range="${current_tag}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > release-notes.md
|
||||||
|
git log --pretty=format:"- %h %s" "${range}" >> release-notes.md
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
files: dist/management.html
|
files: dist/management.html
|
||||||
body: |
|
body_path: release-notes.md
|
||||||
## CLI Proxy API Management Center - ${{ github.ref_name }}
|
|
||||||
|
|
||||||
### Download and Usage
|
|
||||||
1. Download the `management.html` file
|
|
||||||
2. Open it directly in your browser
|
|
||||||
3. All assets (CSS, JavaScript, images) are bundled into this single file
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Single file, no external dependencies required
|
|
||||||
- Complete management interface for CLI Proxy API
|
|
||||||
- Support for local and remote connections
|
|
||||||
- Multi-language support (Chinese/English)
|
|
||||||
- Dark/Light theme support
|
|
||||||
|
|
||||||
---
|
|
||||||
🤖 Generated with GitHub Actions
|
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
|
|||||||
56
.gitignore
vendored
@@ -1,28 +1,32 @@
|
|||||||
# Node modules
|
# Logs
|
||||||
node_modules/
|
logs
|
||||||
|
*.log
|
||||||
# Build output
|
npm-debug.log*
|
||||||
dist/
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
# Temporary build files
|
pnpm-debug.log*
|
||||||
index.build.html
|
lerna-debug.log*
|
||||||
|
api.md
|
||||||
# npm lock files
|
usage.json
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# IDE and editor files
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.claude
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.codex
|
antigravity_usage.json
|
||||||
.serena
|
codex_usage.json
|
||||||
|
style.md
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
settings.local.json
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|||||||
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Build and Release Instructions
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This project uses webpack to bundle all HTML, CSS, JavaScript, and images into a single all-in-one HTML file. The GitHub workflow automatically builds and releases this file when you create a new tag.
|
|
||||||
|
|
||||||
## How to Create a Release
|
|
||||||
|
|
||||||
1. Make sure all your changes are committed
|
|
||||||
2. Create and push a new tag:
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
3. The GitHub workflow will automatically:
|
|
||||||
- Install dependencies
|
|
||||||
- Build the all-in-one HTML file using webpack
|
|
||||||
- Create a new release with the tag
|
|
||||||
- Upload the bundled HTML file to the release
|
|
||||||
|
|
||||||
## Manual Build
|
|
||||||
|
|
||||||
To build locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Build the all-in-one HTML file
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
The output will be in the `dist/` directory as `index.html`.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
1. **build-scripts/prepare-html.js**: Pre-build script
|
|
||||||
- Reads the original `index.html`
|
|
||||||
- Removes local CSS and JavaScript references
|
|
||||||
- Generates temporary `index.build.html` for webpack
|
|
||||||
|
|
||||||
2. **webpack.config.js**: Configures webpack to bundle all assets
|
|
||||||
- Uses `style-loader` to inline CSS
|
|
||||||
- Uses `asset/inline` to embed images as base64
|
|
||||||
- Uses `html-inline-script-webpack-plugin` to inline JavaScript
|
|
||||||
- Uses `index.build.html` as template (generated dynamically)
|
|
||||||
|
|
||||||
3. **bundle-entry.js**: Entry point that imports all resources
|
|
||||||
- Imports CSS files
|
|
||||||
- Imports JavaScript modules
|
|
||||||
- Imports and sets logo image
|
|
||||||
|
|
||||||
4. **package.json scripts**:
|
|
||||||
- `prebuild`: Automatically runs before build to generate `index.build.html`
|
|
||||||
- `build`: Runs webpack to bundle everything
|
|
||||||
- `postbuild`: Cleans up temporary `index.build.html` file
|
|
||||||
|
|
||||||
5. **.github/workflows/release.yml**: GitHub workflow
|
|
||||||
- Triggers on tag push
|
|
||||||
- Builds the project (prebuild → build → postbuild)
|
|
||||||
- Creates a release with the bundled HTML file
|
|
||||||
|
|
||||||
## External Dependencies
|
|
||||||
|
|
||||||
The bundled HTML file still relies on these CDN resources:
|
|
||||||
- Font Awesome (icons)
|
|
||||||
- Chart.js (charts and graphs)
|
|
||||||
|
|
||||||
These are loaded from CDN to keep the file size reasonable and leverage browser caching.
|
|
||||||
21
LICENSE
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 Supra4E8C
|
|
||||||
|
|
||||||
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.
|
|
||||||
181
README.md
@@ -1,107 +1,130 @@
|
|||||||
# Cli-Proxy-API-Management-Center
|
# CLI Proxy API Management Center
|
||||||
This is the modern WebUI for managing the CLI Proxy API.
|
|
||||||
|
A single-file WebUI (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.3.0 (recommended ≥ 6.5.0)
|
||||||
|
|
||||||
Since 6.0.19 the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
|
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.
|
||||||
|
|
||||||
## Features
|
## What this is (and isn’t)
|
||||||
|
|
||||||
### Capabilities
|
- 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.
|
||||||
- **Login & UX**: Auto-detects the current address (manual override/reset supported), encrypted auto-login, language/theme toggles, responsive layout with mobile sidebar.
|
- It is **not** a proxy and does not forward traffic.
|
||||||
- **Basic Settings**: Debug, proxy URL, request retries, quota fallback (auto-switch project/preview models), usage-statistics toggle, request logging & logging-to-file switches, WebSocket `/ws/*` auth switch.
|
|
||||||
- **Keys & Providers**: Manage proxy auth keys, Gemini/Codex/Claude configs, OpenAI-compatible providers (custom base URLs/headers/proxy/model aliases), Vertex AI credential import from service-account JSON with optional location.
|
|
||||||
- **Auth Files & OAuth**: Upload/download/search/paginate JSON credentials; type filters (Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty); delete-all; OAuth/Device flows for Codex, Anthropic (Claude), Antigravity (Google), Gemini CLI (optional project), Qwen; iFlow OAuth and cookie login.
|
|
||||||
- **Logs**: Live viewer with auto-refresh/incremental updates, download and clear; section appears when logging-to-file is enabled.
|
|
||||||
- **Usage Analytics**: Overview cards, hourly/daily toggles, up to three model lines per chart, per-API stats table (Chart.js).
|
|
||||||
- **Config Management**: In-browser CodeMirror YAML editor for `/config.yaml` with reload/save, syntax highlighting, and status feedback.
|
|
||||||
- **System Info & Versioning**: Connection/config cache status, last refresh time, server version/build date, and UI version in the footer.
|
|
||||||
- **Security & Preferences**: Masked secrets, secure local storage, persistent theme/language/sidebar state, real-time status feedback.
|
|
||||||
|
|
||||||
## How to Use
|
## Quick start
|
||||||
|
|
||||||
1) **After CLI Proxy API is running (recommended)**
|
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
|
||||||
Visit `http://your-server:8317/management.html`.
|
|
||||||
|
|
||||||
2) **Direct static use after build**
|
1. Start your CLI Proxy API service.
|
||||||
The single file `dist/index.html` generated by `npm run build`
|
2. Open: `http://<host>:<api_port>/management.html`
|
||||||
|
3. Enter your **management key** and connect.
|
||||||
|
|
||||||
|
The address is auto-detected from the current page URL; manual override is supported.
|
||||||
|
|
||||||
|
### Option B: Run the dev server
|
||||||
|
|
||||||
3) **Local server**
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm start # http://localhost:3000
|
npm run dev
|
||||||
npm run dev # optional dev port: 3090
|
|
||||||
# or
|
|
||||||
python -m http.server 8000
|
|
||||||
```
|
```
|
||||||
Then open the corresponding localhost URL.
|
|
||||||
|
|
||||||
4) **Configure connection**
|
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
|
||||||
The login page shows the detected address; you can override it, enter the management key, and click Connect. Saved credentials use encrypted local storage for auto-login.
|
|
||||||
|
|
||||||
Tip: The Logs navigation item appears after enabling "Logging to file" in Basic Settings.
|
### Option C: Build a single HTML file
|
||||||
|
|
||||||
## Tech Stack
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
- **Frontend**: Plain HTML, CSS, JavaScript (ES6+)
|
- Output: `dist/index.html` (all assets are inlined).
|
||||||
- **Styling**: CSS3 + Flexbox/Grid with CSS Variables
|
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
|
||||||
- **Icons**: Font Awesome 6.4.0
|
- To preview locally: `npm run preview`
|
||||||
- **Charts**: Chart.js for interactive data visualization
|
|
||||||
- **Editor/Parsing**: CodeMirror + js-yaml
|
|
||||||
- **Fonts**: Segoe UI system font
|
|
||||||
- **Internationalization**: Custom i18n (EN/CN) and theme system (light/dark)
|
|
||||||
- **API**: RESTful management endpoints with automatic authentication
|
|
||||||
- **Storage**: LocalStorage with lightweight encryption for preferences/credentials
|
|
||||||
|
|
||||||
## Build & Development
|
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
|
||||||
|
|
||||||
- `npm run build` bundles everything into `dist/index.html` via webpack (`build.cjs`, `bundle-entry.js`, `build-scripts/prepare-html.js`).
|
## Connecting to the server
|
||||||
- External CDNs remain for Font Awesome, Chart.js, and CodeMirror to keep the bundle lean.
|
|
||||||
- Development servers: `npm start` (3000) or `npm run dev` (3090); Python `http.server` also works for static hosting.
|
### API address
|
||||||
|
|
||||||
|
You can enter any of the following; the UI will normalize it:
|
||||||
|
|
||||||
|
- `localhost:8317`
|
||||||
|
- `http://192.168.1.10:8317`
|
||||||
|
- `https://example.com:8317`
|
||||||
|
- `http://example.com:8317/v0/management` (also accepted; the suffix is removed internally)
|
||||||
|
|
||||||
|
### Management key (not the same as API keys)
|
||||||
|
|
||||||
|
The management key is sent with every request as:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <MANAGEMENT_KEY>` (default)
|
||||||
|
|
||||||
|
This is different from the proxy `api-keys` you manage inside the UI (those are for client requests to the proxy endpoints).
|
||||||
|
|
||||||
|
### Remote management
|
||||||
|
|
||||||
|
If you connect from a non-localhost browser, the server must allow remote management (e.g. `allow-remote-management: true`).
|
||||||
|
See `api.md` for the full authentication rules, server-side limits, and edge cases.
|
||||||
|
|
||||||
|
## What you can manage (mapped to the UI pages)
|
||||||
|
|
||||||
|
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
|
||||||
|
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth.
|
||||||
|
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
|
||||||
|
- **AI Providers**:
|
||||||
|
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
|
||||||
|
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side “chat/completions” test).
|
||||||
|
- 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).
|
||||||
|
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
|
||||||
|
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
|
||||||
|
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
|
||||||
|
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
|
||||||
|
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
|
||||||
|
|
||||||
|
## Build & release notes
|
||||||
|
|
||||||
|
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
|
||||||
|
- Tagging `vX.Y.Z` triggers `.github/workflows/release.yml` to publish `dist/management.html`.
|
||||||
|
- The UI version shown in the footer is injected at build time (env `VERSION`, git tag, or `package.json` fallback).
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- The management key is stored in browser `localStorage` using a lightweight obfuscation format (`enc::v1::...`) to avoid plaintext storage; treat it as sensitive.
|
||||||
|
- Use a dedicated browser profile/device for management. Be cautious when enabling remote management and evaluate its exposure surface.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Connection Issues
|
- **Can’t connect / 401**: confirm the API address and management key; remote access may require enabling remote management in the server config.
|
||||||
1. Confirm that the CLI Proxy API service is running.
|
- **Repeated auth failures**: the server may temporarily block remote IPs.
|
||||||
2. Check if the API address is correct.
|
- **Logs page missing**: enable “Logging to file” in Basic Settings; the navigation item is shown only when file logging is enabled.
|
||||||
3. Verify that the management key is valid.
|
- **Some features show “unsupported”**: the backend may be too old or the endpoint is disabled/absent (common for model lists per auth file, excluded models, logs).
|
||||||
4. Ensure your firewall settings allow the connection.
|
- **OpenAI provider test fails**: the test runs in the browser and depends on network/CORS of the provider endpoint; a failure here does not always mean the server cannot reach it.
|
||||||
|
|
||||||
### Data Not Updating
|
## Development
|
||||||
1. Click the "Refresh All" button.
|
|
||||||
2. Check your network connection.
|
|
||||||
3. Check the browser's console for any error messages.
|
|
||||||
|
|
||||||
### Logs & Config Editor
|
```bash
|
||||||
- Logs: Requires server-side logging-to-file; 404 indicates the server build is too old or logging is disabled.
|
npm run dev # Vite dev server
|
||||||
- Config editor: Requires `/config.yaml` endpoint; keep YAML valid before saving.
|
npm run build # tsc + Vite build
|
||||||
|
npm run preview # serve dist locally
|
||||||
### Usage Stats
|
npm run lint # ESLint (fails on warnings)
|
||||||
- Enable "Usage statistics" if charts stay empty; data resets on server restart.
|
npm run format # Prettier
|
||||||
|
npm run type-check # tsc --noEmit
|
||||||
## Project Structure
|
|
||||||
```
|
|
||||||
├── index.html
|
|
||||||
├── styles.css
|
|
||||||
├── app.js
|
|
||||||
├── i18n.js
|
|
||||||
├── src/ # Core/modules/utils source code
|
|
||||||
├── build.cjs # Webpack build script
|
|
||||||
├── bundle-entry.js # Bundling entry
|
|
||||||
├── build-scripts/ # Build utilities
|
|
||||||
│ └── prepare-html.js
|
|
||||||
├── dist/ # Bundled single-file output
|
|
||||||
├── BUILD_RELEASE.md
|
|
||||||
├── LICENSE
|
|
||||||
├── README.md
|
|
||||||
└── README_CN.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
We welcome Issues and Pull Requests to improve this project! We encourage more developers to contribute to the enhancement of this WebUI!
|
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
Issues and PRs are welcome. Please include:
|
||||||
|
|
||||||
|
- Reproduction steps (server version + UI version)
|
||||||
|
- Screenshots for UI changes
|
||||||
|
- Verification notes (`npm run lint`, `npm run type-check`)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
194
README_CN.md
@@ -1,106 +1,130 @@
|
|||||||
# Cli-Proxy-API-Management-Center
|
# CLI Proxy API 管理中心
|
||||||
这是一个用于管理 CLI Proxy API 的现代化 Web 界面。
|
|
||||||
|
用于管理与排障 **CLI Proxy API** 的单文件 WebUI(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.3.0(推荐 ≥ 6.5.0)
|
||||||
|
|
||||||
自 6.0.19 起 WebUI 已集成到主程序中,启动后可通过 `/management.html` 访问。
|
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.
|
||||||
|
|
||||||
## 功能特点
|
## 这是什么(以及不是什么)
|
||||||
|
|
||||||
### 主要能力
|
- 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**(`/v0/management`)读取/修改配置、上传凭据、查看日志与使用统计。
|
||||||
- **登录与体验**: 自动检测当前地址(可自定义/重置),加密自动登录,语言/主题切换,响应式布局与移动端侧边栏。
|
- 它 **不是** 代理本体,不参与流量转发。
|
||||||
- **基础设置**: 调试、代理 URL、请求重试,配额溢出自动切换项目/预览模型,使用统计开关,请求日志与文件日志开关,WebSocket `/ws/*` 鉴权开关。
|
|
||||||
- **密钥与提供商**: 管理代理服务密钥,Gemini/Codex/Claude 配置,OpenAI 兼容提供商(自定义 Base URL/Headers/Proxy/模型别名),Vertex AI 服务账号导入(可选区域)。
|
|
||||||
- **认证文件与 OAuth**: 上传/下载/搜索/分页 JSON 凭据,类型筛选(Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty),一键删除全部;Codex、Anthropic(Claude)、Antigravity(Google)、Gemini CLI(可选项目)、Qwen 设备码、iFlow OAuth 与 Cookie 登录。
|
|
||||||
- **日志**: 实时查看并增量刷新,支持下载和清空;启用“写入日志文件”后出现日志栏目。
|
|
||||||
- **使用统计**: 概览卡片、小时/天切换、最多三条模型曲线、按 API 统计表(Chart.js)。
|
|
||||||
- **配置管理**: 内置 CodeMirror YAML 编辑器,在线读取/保存 `/config.yaml`,语法高亮与状态提示。
|
|
||||||
- **系统与版本**: 连接/配置缓存状态、最后刷新时间,底栏显示服务版本、构建时间与 UI 版本。
|
|
||||||
- **安全与偏好**: 密钥遮蔽、加密本地存储,主题/语言/侧边栏状态持久化,实时状态反馈。
|
|
||||||
|
|
||||||
## 使用方法
|
## 快速开始
|
||||||
|
|
||||||
1) **主程序启动后使用(推荐)**
|
### 方式 A:使用 CLIProxyAPI 自带的 WebUI(推荐)
|
||||||
访问 `http://您的服务器:8317/management.html`。
|
|
||||||
|
|
||||||
2) **构建后直接静态打开**
|
1. 启动 CLI Proxy API 服务。
|
||||||
`npm run build` 生成的 `dist/index.html` 单文件
|
2. 打开:`http://<host>:<api_port>/management.html`
|
||||||
|
3. 输入 **管理密钥** 并连接。
|
||||||
|
|
||||||
|
页面会根据当前地址自动推断 API 地址,也支持手动修改。
|
||||||
|
|
||||||
|
### 方式 B:开发调试
|
||||||
|
|
||||||
3) **本地服务器**
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm start # 默认 http://localhost:3000
|
npm run dev
|
||||||
npm run dev # 可选开发端口 3090
|
|
||||||
# 或
|
|
||||||
python -m http.server 8000
|
|
||||||
```
|
```
|
||||||
然后在浏览器打开对应的 localhost 地址。
|
|
||||||
|
|
||||||
4) **配置连接**
|
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
|
||||||
登录页会显示自动检测的地址,可自行修改,填入管理密钥后点击连接。凭据将加密保存以便下次自动登录。
|
|
||||||
|
|
||||||
提示: 开启“写入日志文件”后才会显示“日志查看”导航。
|
### 方式 C:构建单文件 HTML
|
||||||
|
|
||||||
## 技术栈
|
```bash
|
||||||
|
npm install
|
||||||
- **前端**: 纯 HTML、CSS、JavaScript (ES6+)
|
npm run build
|
||||||
- **样式**: CSS3 + Flexbox/Grid,支持 CSS 变量
|
|
||||||
- **图标**: Font Awesome 6.4.0
|
|
||||||
- **图表**: Chart.js 交互式数据可视化
|
|
||||||
- **编辑/解析**: CodeMirror + js-yaml
|
|
||||||
- **国际化**: 自定义 i18n(中/英)与主题系统(明/暗)
|
|
||||||
- **API**: RESTful 管理接口,自动附加认证
|
|
||||||
- **存储**: LocalStorage 轻量加密存储偏好与凭据
|
|
||||||
|
|
||||||
## 构建与开发
|
|
||||||
|
|
||||||
- `npm run build` 通过 webpack(`build.cjs`、`bundle-entry.js`、`build-scripts/prepare-html.js`)打包为 `dist/index.html`。
|
|
||||||
- Font Awesome、Chart.js、CodeMirror 仍走 CDN,减小打包体积。
|
|
||||||
- 开发可用 `npm start` (3000) / `npm run dev` (3090) 或 `python -m http.server` 静态托管。
|
|
||||||
|
|
||||||
## 故障排除
|
|
||||||
|
|
||||||
### 连接问题
|
|
||||||
1. 确认 CLI Proxy API 服务正在运行
|
|
||||||
2. 检查 API 地址是否正确
|
|
||||||
3. 验证管理密钥是否有效
|
|
||||||
4. 确认防火墙设置允许连接
|
|
||||||
|
|
||||||
### 数据不更新
|
|
||||||
1. 点击"刷新全部"按钮
|
|
||||||
2. 检查网络连接
|
|
||||||
3. 查看浏览器控制台错误信息
|
|
||||||
|
|
||||||
### 日志与配置编辑
|
|
||||||
- 日志: 需要服务端开启写文件日志;返回 404 说明版本过旧或未启用。
|
|
||||||
- 配置编辑: 依赖 `/config.yaml` 接口,保存前请确保 YAML 语法正确。
|
|
||||||
|
|
||||||
### 使用统计
|
|
||||||
- 若图表为空,请开启“使用统计”;数据在服务重启后会清空。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
```
|
```
|
||||||
├── index.html
|
|
||||||
├── styles.css
|
- 构建产物:`dist/index.html`(资源已全部内联)。
|
||||||
├── app.js
|
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`。
|
||||||
├── i18n.js
|
- 本地预览:`npm run preview`
|
||||||
├── src/ # 核心/模块/工具源码
|
|
||||||
├── build.cjs # Webpack 构建脚本
|
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
|
||||||
├── bundle-entry.js # 打包入口
|
|
||||||
├── build-scripts/ # 构建工具
|
## 连接说明
|
||||||
│ └── prepare-html.js
|
|
||||||
├── dist/ # 打包输出单文件
|
### API 地址怎么填
|
||||||
├── BUILD_RELEASE.md
|
|
||||||
├── LICENSE
|
以下格式均可,WebUI 会自动归一化:
|
||||||
├── README.md
|
|
||||||
└── README_CN.md
|
- `localhost:8317`
|
||||||
|
- `http://192.168.1.10:8317`
|
||||||
|
- `https://example.com:8317`
|
||||||
|
- `http://example.com:8317/v0/management`(也可填写,后缀会被自动去除)
|
||||||
|
|
||||||
|
### 管理密钥(注意:不是 API Keys)
|
||||||
|
|
||||||
|
管理密钥会以如下方式随请求发送:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
|
||||||
|
|
||||||
|
这与 WebUI 中“API Keys”页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
|
||||||
|
|
||||||
|
### 远程管理
|
||||||
|
|
||||||
|
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
|
||||||
|
完整鉴权规则、限制与边界情况请查看 `api.md`。
|
||||||
|
|
||||||
|
## 功能一览(按页面对应)
|
||||||
|
|
||||||
|
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
|
||||||
|
- **基础设置**:调试开关、代理 URL、请求重试、配额回退(切项目/切预览模型)、使用统计、请求日志、文件日志、WebSocket 鉴权。
|
||||||
|
- **API Keys**:管理代理 `api-keys`(增/改/删)。
|
||||||
|
- **AI 提供商**:
|
||||||
|
- Gemini/Codex/Claude 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
|
||||||
|
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
|
||||||
|
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
|
||||||
|
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符)。
|
||||||
|
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
|
||||||
|
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
|
||||||
|
- **配置文件**:浏览器内编辑 `/config.yaml`(YAML 高亮 + 搜索),保存/重载。
|
||||||
|
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
|
||||||
|
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
|
||||||
|
|
||||||
|
## 构建与发布说明
|
||||||
|
|
||||||
|
- 使用 Vite 输出 **单文件 HTML**(`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
|
||||||
|
- 打 `vX.Y.Z` 标签会触发 `.github/workflows/release.yml`,发布 `dist/management.html`。
|
||||||
|
- 页脚显示的 UI 版本在构建期注入(优先使用环境变量 `VERSION`,否则使用 git tag / `package.json`)。
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
- 管理密钥会存入浏览器 `localStorage`,并使用轻量混淆格式(`enc::v1::...`)避免明文;仍应视为敏感信息。
|
||||||
|
- 建议使用独立浏览器配置/设备进行管理;开启远程管理时请谨慎评估暴露面。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
- **无法连接 / 401**:确认 API 地址与管理密钥;远程访问可能需要服务端开启远程管理。
|
||||||
|
- **反复输错密钥**:服务端可能对远程 IP 进行临时封禁。
|
||||||
|
- **日志页面不显示**:需要在“基础设置”里开启“写入日志文件”,导航项才会出现。
|
||||||
|
- **功能提示不支持**:多为后端版本较旧或接口未启用/不存在(如:认证文件模型列表、排除模型、日志相关接口)。
|
||||||
|
- **OpenAI 提供商测试失败**:测试在浏览器侧执行,会受网络与 CORS 影响;这里失败不一定代表服务端不可用。
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # 启动开发服务器
|
||||||
|
npm run build # tsc + Vite 构建
|
||||||
|
npm run preview # 本地预览 dist
|
||||||
|
npm run lint # ESLint(warnings 视为失败)
|
||||||
|
npm run format # Prettier
|
||||||
|
npm run type-check # tsc --noEmit
|
||||||
```
|
```
|
||||||
|
|
||||||
## 贡献
|
## 贡献
|
||||||
欢迎提交 Issue 和 Pull Request 来改进这个项目!我们欢迎更多的大佬来对这个 WebUI 进行更新!
|
|
||||||
|
|
||||||
本项目采用 MIT 许可。
|
欢迎提 Issue 与 PR。建议附上:
|
||||||
|
|
||||||
|
- 复现步骤(服务端版本 + UI 版本)
|
||||||
|
- UI 改动截图
|
||||||
|
- 验证记录(`npm run lint`、`npm run type-check`)
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Read the original index.html
|
|
||||||
const indexPath = path.resolve(__dirname, '../index.html');
|
|
||||||
const outputPath = path.resolve(__dirname, '../index.build.html');
|
|
||||||
|
|
||||||
let htmlContent = fs.readFileSync(indexPath, 'utf8');
|
|
||||||
|
|
||||||
// Remove local CSS reference
|
|
||||||
htmlContent = htmlContent.replace(
|
|
||||||
/<link rel="stylesheet" href="styles\.css">\n?/g,
|
|
||||||
''
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove local JavaScript references
|
|
||||||
htmlContent = htmlContent.replace(
|
|
||||||
/<script src="i18n\.js"><\/script>\n?/g,
|
|
||||||
''
|
|
||||||
);
|
|
||||||
|
|
||||||
htmlContent = htmlContent.replace(
|
|
||||||
/<script src="app\.js"><\/script>\n?/g,
|
|
||||||
''
|
|
||||||
);
|
|
||||||
|
|
||||||
// Write the modified HTML to a temporary build file
|
|
||||||
fs.writeFileSync(outputPath, htmlContent, 'utf8');
|
|
||||||
|
|
||||||
console.log('✓ Generated index.build.html for webpack processing');
|
|
||||||
225
build.cjs
@@ -1,225 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const projectRoot = __dirname;
|
|
||||||
const distDir = path.join(projectRoot, 'dist');
|
|
||||||
|
|
||||||
const sourceFiles = {
|
|
||||||
html: path.join(projectRoot, 'index.html'),
|
|
||||||
css: path.join(projectRoot, 'styles.css'),
|
|
||||||
i18n: path.join(projectRoot, 'i18n.js'),
|
|
||||||
app: path.join(projectRoot, 'app.js')
|
|
||||||
};
|
|
||||||
|
|
||||||
const logoCandidates = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.svg', 'logo.webp', 'logo.gif'];
|
|
||||||
const logoMimeMap = {
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.jpeg': 'image/jpeg',
|
|
||||||
'.svg': 'image/svg+xml',
|
|
||||||
'.webp': 'image/webp',
|
|
||||||
'.gif': 'image/gif'
|
|
||||||
};
|
|
||||||
|
|
||||||
function readFile(filePath) {
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(filePath, 'utf8');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`读取文件失败: ${filePath}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readBinary(filePath) {
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(filePath);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`读取文件失败: ${filePath}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeForScript(content) {
|
|
||||||
return content.replace(/<\/(script)/gi, '<\\/$1');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeForStyle(content) {
|
|
||||||
return content.replace(/<\/(style)/gi, '<\\/$1');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVersion() {
|
|
||||||
// 1. 优先从环境变量获取(GitHub Actions 会设置)
|
|
||||||
if (process.env.VERSION) {
|
|
||||||
return process.env.VERSION;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 尝试从 git tag 获取
|
|
||||||
try {
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
const gitTag = execSync('git describe --tags --exact-match 2>/dev/null || git describe --tags 2>/dev/null || echo ""', { encoding: 'utf8' }).trim();
|
|
||||||
if (gitTag) {
|
|
||||||
return gitTag;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('无法从 git 获取版本号');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 回退到 package.json
|
|
||||||
try {
|
|
||||||
const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
|
|
||||||
return 'v' + packageJson.version;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('无法从 package.json 读取版本号');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 最后使用默认值
|
|
||||||
return 'v0.0.0-dev';
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureDistDir() {
|
|
||||||
if (fs.existsSync(distDir)) {
|
|
||||||
fs.rmSync(distDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
fs.mkdirSync(distDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 匹配各种 import 语句
|
|
||||||
const importRegex = /import\s+(?:{[^}]*}|[\w*\s,{}]+)\s+from\s+['"]([^'"]+)['"];?/gm;
|
|
||||||
// 匹配 export 关键字(包括 export const, export function, export class, export async function 等)
|
|
||||||
const exportRegex = /^export\s+(?=const|let|var|function|class|default|async)/gm;
|
|
||||||
// 匹配单独的 export {} 或 export { ... } from '...'
|
|
||||||
const exportBraceRegex = /^export\s*{[^}]*}\s*(?:from\s+['"][^'"]+['"];?)?$/gm;
|
|
||||||
|
|
||||||
function bundleApp(entryPath) {
|
|
||||||
const visited = new Set();
|
|
||||||
const modules = [];
|
|
||||||
|
|
||||||
function inlineFile(filePath) {
|
|
||||||
let content = readFile(filePath);
|
|
||||||
const dir = path.dirname(filePath);
|
|
||||||
|
|
||||||
// 收集所有 import 语句
|
|
||||||
const imports = [];
|
|
||||||
content = content.replace(importRegex, (match, specifier) => {
|
|
||||||
const targetPath = path.resolve(dir, specifier);
|
|
||||||
const normalized = path.normalize(targetPath);
|
|
||||||
if (!fs.existsSync(normalized)) {
|
|
||||||
throw new Error(`无法解析模块: ${specifier} (from ${filePath})`);
|
|
||||||
}
|
|
||||||
if (!visited.has(normalized)) {
|
|
||||||
visited.add(normalized);
|
|
||||||
imports.push(normalized);
|
|
||||||
}
|
|
||||||
return ''; // 移除 import 语句
|
|
||||||
});
|
|
||||||
|
|
||||||
// 移除 export 关键字
|
|
||||||
content = content.replace(exportRegex, '');
|
|
||||||
content = content.replace(exportBraceRegex, '');
|
|
||||||
|
|
||||||
// 处理依赖的模块
|
|
||||||
for (const importPath of imports) {
|
|
||||||
const moduleContent = inlineFile(importPath);
|
|
||||||
const relativePath = path.relative(projectRoot, importPath);
|
|
||||||
modules.push(`\n// ============ ${relativePath} ============\n${moduleContent}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainContent = inlineFile(entryPath);
|
|
||||||
|
|
||||||
// 将所有模块内容组合在一起,模块在前,主文件在后
|
|
||||||
return modules.join('\n') + '\n// ============ Main ============\n' + mainContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function loadLogoDataUrl() {
|
|
||||||
for (const candidate of logoCandidates) {
|
|
||||||
const filePath = path.join(projectRoot, candidate);
|
|
||||||
if (!fs.existsSync(filePath)) continue;
|
|
||||||
|
|
||||||
const ext = path.extname(candidate).toLowerCase();
|
|
||||||
const mime = logoMimeMap[ext];
|
|
||||||
if (!mime) {
|
|
||||||
console.warn(`未知 Logo 文件类型,跳过内联: ${candidate}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = readBinary(filePath);
|
|
||||||
const base64 = buffer.toString('base64');
|
|
||||||
return `data:${mime};base64,${base64}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function build() {
|
|
||||||
ensureDistDir();
|
|
||||||
|
|
||||||
let html = readFile(sourceFiles.html);
|
|
||||||
const css = escapeForStyle(readFile(sourceFiles.css));
|
|
||||||
const i18n = escapeForScript(readFile(sourceFiles.i18n));
|
|
||||||
const bundledApp = bundleApp(sourceFiles.app);
|
|
||||||
const app = escapeForScript(bundledApp);
|
|
||||||
|
|
||||||
// 获取版本号并替换
|
|
||||||
const version = getVersion();
|
|
||||||
console.log(`使用版本号: ${version}`);
|
|
||||||
html = html.replace(/__VERSION__/g, version);
|
|
||||||
|
|
||||||
html = html.replace(
|
|
||||||
'<link rel="stylesheet" href="styles.css">',
|
|
||||||
() => `<style>
|
|
||||||
${css}
|
|
||||||
</style>`
|
|
||||||
);
|
|
||||||
|
|
||||||
html = html.replace(
|
|
||||||
'<script src="i18n.js"></script>',
|
|
||||||
() => `<script>
|
|
||||||
${i18n}
|
|
||||||
</script>`
|
|
||||||
);
|
|
||||||
|
|
||||||
const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i;
|
|
||||||
if (scriptTagRegex.test(html)) {
|
|
||||||
html = html.replace(
|
|
||||||
scriptTagRegex,
|
|
||||||
() => `<script>
|
|
||||||
${app}
|
|
||||||
</script>`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.warn('未找到 app.js 脚本标签,未内联应用代码。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const logoDataUrl = loadLogoDataUrl();
|
|
||||||
if (logoDataUrl) {
|
|
||||||
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
|
|
||||||
const closingBodyTag = '</body>';
|
|
||||||
const closingBodyIndex = html.lastIndexOf(closingBodyTag);
|
|
||||||
if (closingBodyIndex !== -1) {
|
|
||||||
html = `${html.slice(0, closingBodyIndex)}${logoScript}\n${closingBodyTag}${html.slice(closingBodyIndex + closingBodyTag.length)}`;
|
|
||||||
} else {
|
|
||||||
html += `\n${logoScript}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputPath = path.join(distDir, 'index.html');
|
|
||||||
fs.writeFileSync(outputPath, html, 'utf8');
|
|
||||||
|
|
||||||
console.log('构建完成: dist/index.html');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
build();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('构建失败:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// Import CSS
|
|
||||||
import './styles.css';
|
|
||||||
|
|
||||||
// Import JavaScript modules
|
|
||||||
import './i18n.js';
|
|
||||||
import './app.js';
|
|
||||||
|
|
||||||
// Import logo image
|
|
||||||
import logoImg from './logo.jpg';
|
|
||||||
|
|
||||||
// Set logo after DOM is loaded
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const loginLogo = document.getElementById('login-logo');
|
|
||||||
const siteLogo = document.getElementById('site-logo');
|
|
||||||
|
|
||||||
if (loginLogo) {
|
|
||||||
loginLogo.src = logoImg;
|
|
||||||
loginLogo.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteLogo) {
|
|
||||||
siteLogo.src = logoImg;
|
|
||||||
siteLogo.style.display = 'block';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
30
eslint.config.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
1386
index.html
4675
package-lock.json
generated
63
package.json
@@ -1,31 +1,46 @@
|
|||||||
{
|
{
|
||||||
"name": "cli-proxy-api-webui",
|
"name": "cli-proxy-webui-react",
|
||||||
"version": "1.0.0",
|
"private": true,
|
||||||
"description": "CLI Proxy API 管理界面",
|
"version": "0.0.0",
|
||||||
"main": "index.html",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npx serve .",
|
"dev": "vite",
|
||||||
"dev": "npx serve . -l 3090",
|
"build": "tsc && vite build",
|
||||||
"build": "node build.cjs",
|
"preview": "vite preview",
|
||||||
"lint": "echo '使用浏览器开发者工具检查代码'"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"gsap": "^3.14.2",
|
||||||
|
"i18next": "^25.7.1",
|
||||||
|
"react": "^19.2.1",
|
||||||
|
"react-chartjs-2": "^5.3.1",
|
||||||
|
"react-dom": "^19.2.1",
|
||||||
|
"react-i18next": "^16.4.0",
|
||||||
|
"react-router-dom": "^7.10.1",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"keywords": [
|
|
||||||
"cli-proxy-api",
|
|
||||||
"webui",
|
|
||||||
"management",
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"author": "CLI Proxy API WebUI",
|
|
||||||
"license": "MIT",
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"serve": "^14.2.1"
|
"@eslint/js": "^9.39.1",
|
||||||
},
|
"@types/react": "^19.2.7",
|
||||||
"engines": {
|
"@types/react-dom": "^19.2.3",
|
||||||
"node": ">=14.0.0"
|
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||||
},
|
"@typescript-eslint/parser": "^8.48.1",
|
||||||
"repository": {
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"type": "git",
|
"eslint": "^9.39.1",
|
||||||
"url": "local"
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"sass": "^1.94.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.1",
|
||||||
|
"vite": "^7.2.6",
|
||||||
|
"vite-plugin-singlefile": "^2.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
48
src/App.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { LoginPage } from '@/pages/LoginPage';
|
||||||
|
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||||
|
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||||
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
|
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||||
|
import { useLanguageStore, useThemeStore } from '@/stores';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||||
|
const language = useLanguageStore((state) => state.language);
|
||||||
|
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanupTheme = initializeTheme();
|
||||||
|
return cleanupTheme;
|
||||||
|
}, [initializeTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLanguage(language);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // 仅用于首屏同步 i18n 语言
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HashRouter>
|
||||||
|
<NotificationContainer />
|
||||||
|
<ConfirmationModal />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</HashRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
6
src/assets/icons/amp.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="400" height="400" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="#F34E3F"/>
|
||||||
|
<path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="#F34E3F"/>
|
||||||
|
<path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="#F34E3F"/>
|
||||||
|
<path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="#F34E3F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 632 B |
28
src/assets/icons/antigravity.svg
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Generator: visioncortex VTracer 0.6.4 -->
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="59">
|
||||||
|
<path d="M0,0 L8,0 L14,4 L19,14 L27,40 L32,50 L36,54 L35,59 L30,59 L22,52 L11,35 L6,33 L-1,34 L-6,39 L-14,52 L-22,59 L-28,59 L-27,53 L-22,47 L-17,34 L-10,12 L-5,3 Z " fill="#3789F9" transform="translate(28,0)"/>
|
||||||
|
<path d="M0,0 L8,0 L14,4 L19,14 L25,35 L21,34 L16,29 L11,26 L7,20 L7,18 L2,16 L-3,15 L-8,18 L-12,19 L-9,9 L-4,2 Z " fill="#6D80D8" transform="translate(28,0)"/>
|
||||||
|
<path d="M0,0 L8,0 L14,4 L19,14 L20,19 L13,15 L10,12 L3,10 L-1,8 L-7,7 L-4,2 Z " fill="#D78240" transform="translate(28,0)"/>
|
||||||
|
<path d="M0,0 L5,1 L10,4 L12,9 L1,8 L-5,13 L-10,21 L-13,26 L-16,26 L-9,5 L-4,2 Z M6,7 Z " fill="#3294CC" transform="translate(25,14)"/>
|
||||||
|
<path d="M0,0 L5,2 L10,10 L12,18 L5,14 L1,10 L0,4 L-3,3 L0,2 Z " fill="#E45C49" transform="translate(36,1)"/>
|
||||||
|
<path d="M0,0 L9,1 L12,3 L12,5 L7,6 L4,8 L-1,11 L-5,12 L-2,2 Z " fill="#90AE64" transform="translate(21,7)"/>
|
||||||
|
<path d="M0,0 L5,1 L5,4 L-2,7 L-7,11 L-11,10 L-9,5 L-4,2 Z " fill="#53A89A" transform="translate(25,14)"/>
|
||||||
|
<path d="M0,0 L5,0 L16,9 L17,13 L12,12 L8,9 L8,7 L4,5 L0,2 Z " fill="#B5677D" transform="translate(33,11)"/>
|
||||||
|
<path d="M0,0 L6,0 L14,6 L19,11 L23,12 L22,15 L15,12 L10,8 L10,6 L4,5 Z " fill="#778998" transform="translate(27,12)"/>
|
||||||
|
<path d="M0,0 L4,2 L-11,17 L-12,14 L-5,4 Z " fill="#3390DF" transform="translate(26,21)"/>
|
||||||
|
<path d="M0,0 L2,1 L-4,5 L-9,9 L-13,13 L-14,10 L-13,7 L-6,4 L-3,1 Z " fill="#3FA1B7" transform="translate(27,18)"/>
|
||||||
|
<path d="M0,0 L4,0 L9,5 L13,6 L12,9 L5,6 L0,2 Z " fill="#8277BB" transform="translate(37,18)"/>
|
||||||
|
<path d="M0,0 L5,1 L7,6 L-2,5 Z M1,4 Z " fill="#4989CF" transform="translate(30,17)"/>
|
||||||
|
<path d="M0,0 L5,1 L2,3 L-3,6 L-7,7 L-6,3 Z " fill="#71B774" transform="translate(23,12)"/>
|
||||||
|
<path d="M0,0 L7,1 L9,7 L5,6 L0,1 Z " fill="#6687E9" transform="translate(44,28)"/>
|
||||||
|
<path d="M0,0 L7,0 L5,1 L5,3 L8,4 L4,5 L-2,4 Z " fill="#C7AF38" transform="translate(23,3)"/>
|
||||||
|
<path d="M0,0 L8,0 L8,3 L4,4 L-4,3 Z " fill="#EF842A" transform="translate(28,0)"/>
|
||||||
|
<path d="M0,0 L7,4 L7,6 L10,6 L11,10 L4,6 L0,2 Z " fill="#CD5D67" transform="translate(37,9)"/>
|
||||||
|
<path d="M0,0 L5,2 L9,8 L8,11 L2,3 L0,2 Z " fill="#F35241" transform="translate(36,1)"/>
|
||||||
|
<path d="M0,0 L8,2 L9,6 L4,5 L0,2 Z " fill="#A667A2" transform="translate(41,18)"/>
|
||||||
|
<path d="M0,0 L9,1 L8,3 L-2,3 Z " fill="#A4B34C" transform="translate(21,7)"/>
|
||||||
|
<path d="M0,0 L2,0 L7,5 L8,7 L3,6 L0,2 Z " fill="#617FCF" transform="translate(35,18)"/>
|
||||||
|
<path d="M0,0 L5,2 L8,7 L4,5 L0,2 Z " fill="#9D7784" transform="translate(33,11)"/>
|
||||||
|
<path d="M0,0 L6,2 L6,4 L0,3 Z " fill="#BC7F59" transform="translate(31,7)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
1
src/assets/icons/claude.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
25
src/assets/icons/codex_drak.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#FFFFFF" stroke="none">
|
||||||
|
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||||
|
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||||
|
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||||
|
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||||
|
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||||
|
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||||
|
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||||
|
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||||
|
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||||
|
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||||
|
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||||
|
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||||
|
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||||
|
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
25
src/assets/icons/codex_light.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||||
|
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||||
|
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||||
|
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||||
|
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||||
|
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||||
|
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||||
|
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||||
|
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||||
|
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||||
|
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||||
|
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||||
|
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||||
|
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/assets/icons/deepseek.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
1
src/assets/icons/gemini.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
1
src/assets/icons/glm.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
1
src/assets/icons/grok.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||||
|
After Width: | Height: | Size: 756 B |
1
src/assets/icons/iflow.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="32" height="32" viewBox="0 0 32 32"><defs><filter id="master_svg0_278_51503" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB" x="0" y="0" width="1" height="1"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur in="BackgroundImageFix" stdDeviation="1.3333334922790527"/><feComposite in2="SourceAlpha" operator="in" result="effect1_foregroundBlur"/><feBlend mode="normal" in="SourceGraphic" in2="effect1_foregroundBlur" result="shape"/></filter><linearGradient x1="0.07353696972131729" y1="0.12899449467658997" x2="0.9907095821060244" y2="0.9383787344260006" id="master_svg1_93_40276"><stop offset="0%" stop-color="#5C5CFF" stop-opacity="1"/><stop offset="100%" stop-color="#AE5CFF" stop-opacity="1"/></linearGradient></defs><g><g filter="url(#master_svg0_278_51503)"><rect x="0" y="0" width="32" height="32" rx="16" fill="#F0F2F5" fill-opacity="0"/></g><g><g><path d="M31.843111328125,14.751C31.315411328125,7.18121,25.497411328125,1.04691,17.966011328125,0.119698C10.434711328125,-0.807512,3.302541328125,3.73244,0.954596328125,10.9482C0.345662328125,12.8248,1.732821328125,14.751,3.705641328125,14.751C4.950051328125,14.7517,6.055631328125,13.9569,6.451401328125,12.7772C7.497331328125,9.65101,10.504411328125,3.91401,18.482011328125,3.91401Q29.445911328125,3.91401,31.843111328125,14.751ZM9.127681328125,17.3314L9.127681328125,13.0862Q9.127681328125,13.0022,9.144081328125,12.9198Q9.160481328125,12.8373,9.192641328125,12.7597Q9.224801328125,12.682,9.271501328125,12.6122Q9.318191328125,12.5423,9.377621328125,12.4828Q9.437051328125,12.4234,9.506931328125,12.3767Q9.576811328125,12.33,9.654461328125,12.2979Q9.732111328125,12.2657,9.814541328125,12.2493Q9.896971328125,12.2329,9.981021328125,12.2329L11.049211328125,12.2329Q11.133211328125,12.2329,11.215711328125,12.2493Q11.298111328125,12.2657,11.375811328125,12.2979Q11.453411328125,12.33,11.523311328125,12.3767Q11.593211328125,12.4234,11.652611328125,12.4828Q11.712011328125,12.5423,11.758711328125,12.6122Q11.805411328125,12.682,11.837611328125,12.7597Q11.869711328125,12.8373,11.886111328125,12.9198Q11.902511328125,13.0022,11.902511328125,13.0862L11.902511328125,17.3314Q11.902511328125,17.4154,11.886111328125,17.4978Q11.869711328125,17.5803,11.837611328125,17.6579Q11.805411328125,17.7356,11.758711328125,17.8055Q11.712011328125,17.8753,11.652611328125,17.9348Q11.593211328125,17.9942,11.523311328125,18.0409Q11.453411328125,18.0876,11.375811328125,18.1197Q11.298111328125,18.1519,11.215711328125,18.1683Q11.133211328125,18.1847,11.049211328125,18.1847L9.981021328125,18.1847Q9.896971328125,18.1847,9.814541328125,18.1683Q9.732111328125,18.1519,9.654461328125,18.1197Q9.576811328125,18.0876,9.506931328125,18.0409Q9.437051328125,17.9942,9.377621328125,17.9348Q9.318191328125,17.8753,9.271501328125,17.8055Q9.224801328125,17.7356,9.192641328125,17.6579Q9.160481328125,17.5803,9.144081328125,17.4978Q9.127681328125,17.4154,9.127681328125,17.3314ZM17.273611328125,17.3295C17.272611328125,17.8015,17.654911328125,18.1847,18.126911328125,18.1847L19.408411328125,18.1847C19.879011328125,18.1847,20.260711328125,17.8038,20.261811328125,17.3332L20.266411328125,15.2107L20.266411328125,15.2069L20.261811328125,13.0844C20.260711328125,12.6138,19.879011328125,12.2329,19.408411328125,12.2329L18.126911328125,12.2329C17.654911328125,12.2329,17.272611328125,12.6161,17.273611328125,13.0881L17.278211328125,15.2069L17.278211328125,15.2107L17.273611328125,17.3295ZM13.574711328125,28.0523C21.552211328125,28.0523,24.559311328125,22.3153,25.605811328125,19.1897C26.001411328125,18.0098,27.107111328125,17.215,28.351511328125,17.2158C30.323811328125,17.2158,31.711511328125,19.1416,31.102611328125,21.0181C30.552411328125,22.7189,29.716211328125,24.3134,28.629811328125,25.733L30.137611328125,30.2235L24.775211328125,29.3432C14.645911328125,36.0484,1.048779328125,29.3346,0.214111328125,17.2158Q2.611231328125,28.0523,13.574711328125,28.0523Z" fill-rule="evenodd" fill="url(#master_svg1_93_40276)" fill-opacity="1"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
1
src/assets/icons/kimi-dark.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#FFFFFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 706 B |
1
src/assets/icons/kimi-light.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 711 B |
1
src/assets/icons/minimax.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
src/assets/icons/openai-dark.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#ffffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
src/assets/icons/openai-light.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
src/assets/icons/qwen.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
1
src/assets/icons/vertex.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
1
src/assets/logoInline.ts
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
61
src/components/common/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
|
||||||
|
export function ConfirmationModal() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirmation = useNotificationStore((state) => state.confirmation);
|
||||||
|
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
|
||||||
|
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
|
||||||
|
|
||||||
|
const { isOpen, isLoading, options } = confirmation;
|
||||||
|
|
||||||
|
if (!isOpen || !options) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, message, onConfirm, onCancel, confirmText, cancelText, variant = 'primary' } = options;
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
try {
|
||||||
|
setConfirmationLoading(true);
|
||||||
|
await onConfirm();
|
||||||
|
hideConfirmation();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Confirmation action failed:', error);
|
||||||
|
// Optional: show error notification here if needed,
|
||||||
|
// but usually the calling component handles specific errors.
|
||||||
|
} finally {
|
||||||
|
setConfirmationLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
hideConfirmation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
|
||||||
|
<p style={{ margin: '1rem 0' }}>{message}</p>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
|
||||||
|
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
|
||||||
|
{cancelText || t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
{confirmText || t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/components/common/NotificationContainer.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import { IconX } from '@/components/ui/icons';
|
||||||
|
import type { Notification } from '@/types';
|
||||||
|
|
||||||
|
interface AnimatedNotification extends Notification {
|
||||||
|
isExiting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_DURATION = 300; // ms
|
||||||
|
|
||||||
|
export function NotificationContainer() {
|
||||||
|
const { notifications, removeNotification } = useNotificationStore();
|
||||||
|
const [animatedNotifications, setAnimatedNotifications] = useState<AnimatedNotification[]>([]);
|
||||||
|
const prevNotificationsRef = useRef<Notification[]>([]);
|
||||||
|
|
||||||
|
// Track notifications and manage animation states
|
||||||
|
useEffect(() => {
|
||||||
|
const prevNotifications = prevNotificationsRef.current;
|
||||||
|
const prevIds = new Set(prevNotifications.map((n) => n.id));
|
||||||
|
const currentIds = new Set(notifications.map((n) => n.id));
|
||||||
|
|
||||||
|
// Find new notifications (for enter animation)
|
||||||
|
const newNotifications = notifications.filter((n) => !prevIds.has(n.id));
|
||||||
|
|
||||||
|
// Find removed notifications (for exit animation)
|
||||||
|
const removedIds = new Set(
|
||||||
|
prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
setAnimatedNotifications((prev) => {
|
||||||
|
// Mark removed notifications as exiting
|
||||||
|
let updated = prev.map((n) =>
|
||||||
|
removedIds.has(n.id) ? { ...n, isExiting: true } : n
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add new notifications
|
||||||
|
newNotifications.forEach((n) => {
|
||||||
|
if (!updated.find((an) => an.id === n.id)) {
|
||||||
|
updated.push({ ...n, isExiting: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove notifications that are not in current and not exiting
|
||||||
|
// (they've already completed their exit animation)
|
||||||
|
updated = updated.filter(
|
||||||
|
(n) => currentIds.has(n.id) || n.isExiting
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up exited notifications after animation
|
||||||
|
if (removedIds.size > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setAnimatedNotifications((prev) =>
|
||||||
|
prev.filter((n) => !removedIds.has(n.id))
|
||||||
|
);
|
||||||
|
}, ANIMATION_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevNotificationsRef.current = notifications;
|
||||||
|
}, [notifications]);
|
||||||
|
|
||||||
|
const handleClose = (id: string) => {
|
||||||
|
// Start exit animation
|
||||||
|
setAnimatedNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actually remove after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
removeNotification(id);
|
||||||
|
}, ANIMATION_DURATION);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!animatedNotifications.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="notification-container">
|
||||||
|
{animatedNotifications.map((notification) => (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
className={`notification ${notification.type} ${notification.isExiting ? 'exiting' : 'entering'}`}
|
||||||
|
>
|
||||||
|
<div className="message">{notification.message}</div>
|
||||||
|
<button className="close-btn" onClick={() => handleClose(notification.id)} aria-label="Close">
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/common/PageTransition.scss
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
@use '@/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.page-transition {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__layer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform: translateZ(0);
|
||||||
|
|
||||||
|
// During animation, exit layer uses absolute positioning
|
||||||
|
&--exit {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--stacked {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--animating &__layer {
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--animating &__layer:not(.page-transition__layer--exit) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
348
src/components/common/PageTransition.tsx
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { useLocation, type Location } from 'react-router-dom';
|
||||||
|
import gsap from 'gsap';
|
||||||
|
import './PageTransition.scss';
|
||||||
|
|
||||||
|
interface PageTransitionProps {
|
||||||
|
render: (location: Location) => ReactNode;
|
||||||
|
getRouteOrder?: (pathname: string) => number | null;
|
||||||
|
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
|
||||||
|
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERTICAL_TRANSITION_DURATION = 0.35;
|
||||||
|
const VERTICAL_TRAVEL_DISTANCE = 60;
|
||||||
|
const IOS_TRANSITION_DURATION = 0.42;
|
||||||
|
const IOS_ENTER_FROM_X_PERCENT = 100;
|
||||||
|
const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
|
||||||
|
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
|
||||||
|
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
|
||||||
|
const IOS_EXIT_DIM_OPACITY = 0.72;
|
||||||
|
|
||||||
|
type LayerStatus = 'current' | 'exiting' | 'stacked';
|
||||||
|
|
||||||
|
type Layer = {
|
||||||
|
key: string;
|
||||||
|
location: Location;
|
||||||
|
status: LayerStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransitionDirection = 'forward' | 'backward';
|
||||||
|
|
||||||
|
type TransitionVariant = 'vertical' | 'ios';
|
||||||
|
|
||||||
|
export function PageTransition({
|
||||||
|
render,
|
||||||
|
getRouteOrder,
|
||||||
|
getTransitionVariant,
|
||||||
|
scrollContainerRef,
|
||||||
|
}: PageTransitionProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const transitionDirectionRef = useRef<TransitionDirection>('forward');
|
||||||
|
const transitionVariantRef = useRef<TransitionVariant>('vertical');
|
||||||
|
const exitScrollOffsetRef = useRef(0);
|
||||||
|
const enterScrollOffsetRef = useRef(0);
|
||||||
|
const scrollPositionsRef = useRef(new Map<string, number>());
|
||||||
|
const nextLayersRef = useRef<Layer[] | null>(null);
|
||||||
|
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
const [layers, setLayers] = useState<Layer[]>(() => [
|
||||||
|
{
|
||||||
|
key: location.key,
|
||||||
|
location,
|
||||||
|
status: 'current',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const currentLayer =
|
||||||
|
layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
|
||||||
|
const currentLayerKey = currentLayer?.key ?? location.key;
|
||||||
|
const currentLayerPathname = currentLayer?.location.pathname;
|
||||||
|
|
||||||
|
const resolveScrollContainer = useCallback(() => {
|
||||||
|
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
return document.scrollingElement as HTMLElement | null;
|
||||||
|
}, [scrollContainerRef]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (isAnimating) return;
|
||||||
|
if (location.key === currentLayerKey) return;
|
||||||
|
if (currentLayerPathname === location.pathname) return;
|
||||||
|
const scrollContainer = resolveScrollContainer();
|
||||||
|
const exitScrollOffset = scrollContainer?.scrollTop ?? 0;
|
||||||
|
exitScrollOffsetRef.current = exitScrollOffset;
|
||||||
|
scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset);
|
||||||
|
|
||||||
|
enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 0;
|
||||||
|
const resolveOrderIndex = (pathname?: string) => {
|
||||||
|
if (!getRouteOrder || !pathname) return null;
|
||||||
|
const index = getRouteOrder(pathname);
|
||||||
|
return typeof index === 'number' && index >= 0 ? index : null;
|
||||||
|
};
|
||||||
|
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
||||||
|
const toIndex = resolveOrderIndex(location.pathname);
|
||||||
|
const nextDirection: TransitionDirection =
|
||||||
|
fromIndex === null || toIndex === null || fromIndex === toIndex
|
||||||
|
? 'forward'
|
||||||
|
: toIndex > fromIndex
|
||||||
|
? 'forward'
|
||||||
|
: 'backward';
|
||||||
|
|
||||||
|
transitionDirectionRef.current = nextDirection;
|
||||||
|
transitionVariantRef.current = getTransitionVariant
|
||||||
|
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
||||||
|
: 'vertical';
|
||||||
|
|
||||||
|
const shouldSkipExitLayer = (() => {
|
||||||
|
if (transitionVariantRef.current !== 'ios' || nextDirection !== 'backward') return false;
|
||||||
|
const normalizeSegments = (pathname: string) =>
|
||||||
|
pathname
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((segment) => segment.length > 0);
|
||||||
|
const fromSegments = normalizeSegments(currentLayerPathname ?? '');
|
||||||
|
const toSegments = normalizeSegments(location.pathname);
|
||||||
|
if (!fromSegments.length || !toSegments.length) return false;
|
||||||
|
return fromSegments[0] === toSegments[0] && toSegments.length === 1;
|
||||||
|
})();
|
||||||
|
|
||||||
|
setLayers((prev) => {
|
||||||
|
const variant = transitionVariantRef.current;
|
||||||
|
const direction = transitionDirectionRef.current;
|
||||||
|
const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
|
||||||
|
const resolvedCurrentIndex =
|
||||||
|
previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
|
||||||
|
const previousCurrent = prev[resolvedCurrentIndex];
|
||||||
|
const previousStack: Layer[] = prev
|
||||||
|
.filter((_, idx) => idx !== resolvedCurrentIndex)
|
||||||
|
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
|
||||||
|
|
||||||
|
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
|
||||||
|
|
||||||
|
if (!previousCurrent) {
|
||||||
|
nextLayersRef.current = [nextCurrent];
|
||||||
|
return [nextCurrent];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'ios') {
|
||||||
|
if (direction === 'forward') {
|
||||||
|
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||||
|
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
|
||||||
|
|
||||||
|
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
|
||||||
|
return [...previousStack, exitingLayer, nextCurrent];
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
|
||||||
|
if (targetIndex !== -1) {
|
||||||
|
const targetStack: Layer[] = prev
|
||||||
|
.slice(0, targetIndex + 1)
|
||||||
|
.map((layer, idx): Layer => {
|
||||||
|
const isTarget = idx === targetIndex;
|
||||||
|
return {
|
||||||
|
...layer,
|
||||||
|
location: isTarget ? location : layer.location,
|
||||||
|
status: isTarget ? 'current' : 'stacked',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldSkipExitLayer) {
|
||||||
|
nextLayersRef.current = targetStack;
|
||||||
|
return targetStack;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||||
|
nextLayersRef.current = targetStack;
|
||||||
|
return [...targetStack, exitingLayer];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldSkipExitLayer) {
|
||||||
|
nextLayersRef.current = [nextCurrent];
|
||||||
|
return [nextCurrent];
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||||
|
|
||||||
|
nextLayersRef.current = [nextCurrent];
|
||||||
|
return [exitingLayer, nextCurrent];
|
||||||
|
});
|
||||||
|
setIsAnimating(true);
|
||||||
|
}, [
|
||||||
|
isAnimating,
|
||||||
|
location,
|
||||||
|
currentLayerKey,
|
||||||
|
currentLayerPathname,
|
||||||
|
getRouteOrder,
|
||||||
|
getTransitionVariant,
|
||||||
|
resolveScrollContainer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Run GSAP animation when animating starts
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isAnimating) return;
|
||||||
|
|
||||||
|
if (!currentLayerRef.current) return;
|
||||||
|
|
||||||
|
const currentLayerEl = currentLayerRef.current;
|
||||||
|
const exitingLayerEl = exitingLayerRef.current;
|
||||||
|
const transitionVariant = transitionVariantRef.current;
|
||||||
|
|
||||||
|
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollContainer = resolveScrollContainer();
|
||||||
|
const exitScrollOffset = exitScrollOffsetRef.current;
|
||||||
|
const enterScrollOffset = enterScrollOffsetRef.current;
|
||||||
|
if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
|
||||||
|
scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitionDirection = transitionDirectionRef.current;
|
||||||
|
const isForward = transitionDirection === 'forward';
|
||||||
|
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
|
||||||
|
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
|
||||||
|
const exitBaseY = enterScrollOffset - exitScrollOffset;
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
const nextLayers = nextLayersRef.current;
|
||||||
|
nextLayersRef.current = null;
|
||||||
|
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
|
||||||
|
setIsAnimating(false);
|
||||||
|
|
||||||
|
if (currentLayerEl) {
|
||||||
|
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
|
}
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (transitionVariant === 'ios') {
|
||||||
|
const exitToXPercent = isForward
|
||||||
|
? IOS_EXIT_TO_X_PERCENT_FORWARD
|
||||||
|
: IOS_EXIT_TO_X_PERCENT_BACKWARD;
|
||||||
|
const enterFromXPercent = isForward
|
||||||
|
? IOS_ENTER_FROM_X_PERCENT
|
||||||
|
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
|
||||||
|
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
gsap.set(exitingLayerEl, {
|
||||||
|
y: exitBaseY,
|
||||||
|
xPercent: 0,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.set(currentLayerEl, {
|
||||||
|
xPercent: enterFromXPercent,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
|
||||||
|
|
||||||
|
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
|
||||||
|
if (topLayerEl) {
|
||||||
|
gsap.set(topLayerEl, { boxShadow: shadowValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
tl.to(
|
||||||
|
exitingLayerEl,
|
||||||
|
{
|
||||||
|
xPercent: exitToXPercent,
|
||||||
|
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
|
||||||
|
duration: IOS_TRANSITION_DURATION,
|
||||||
|
ease: 'power2.out',
|
||||||
|
force3D: true,
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tl.to(
|
||||||
|
currentLayerEl,
|
||||||
|
{
|
||||||
|
xPercent: 0,
|
||||||
|
opacity: 1,
|
||||||
|
duration: IOS_TRANSITION_DURATION,
|
||||||
|
ease: 'power2.out',
|
||||||
|
force3D: true,
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Exit animation: fade out with slight movement (runs simultaneously)
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
gsap.set(exitingLayerEl, { y: exitBaseY });
|
||||||
|
tl.to(
|
||||||
|
exitingLayerEl,
|
||||||
|
{
|
||||||
|
y: exitBaseY + exitToY,
|
||||||
|
opacity: 0,
|
||||||
|
duration: VERTICAL_TRANSITION_DURATION,
|
||||||
|
ease: 'circ.out',
|
||||||
|
force3D: true,
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter animation: fade in with slight movement (runs simultaneously)
|
||||||
|
tl.fromTo(
|
||||||
|
currentLayerEl,
|
||||||
|
{ y: enterFromY, opacity: 0 },
|
||||||
|
{
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
duration: VERTICAL_TRANSITION_DURATION,
|
||||||
|
ease: 'circ.out',
|
||||||
|
force3D: true,
|
||||||
|
onComplete: () => {
|
||||||
|
if (currentLayerEl) {
|
||||||
|
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
tl.kill();
|
||||||
|
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
|
||||||
|
};
|
||||||
|
}, [isAnimating, resolveScrollContainer]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||||
|
{layers.map((layer) => (
|
||||||
|
<div
|
||||||
|
key={layer.key}
|
||||||
|
className={[
|
||||||
|
'page-transition__layer',
|
||||||
|
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
||||||
|
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
ref={
|
||||||
|
layer.status === 'exiting'
|
||||||
|
? exitingLayerRef
|
||||||
|
: layer.status === 'current'
|
||||||
|
? currentLayerRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{render(layer.location)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/common/SecondaryScreenShell.module.scss
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBarTitle {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 10px;
|
||||||
|
justify-self: start;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton > span:last-child {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backText {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightSlot {
|
||||||
|
justify-self: end;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingState {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-2xl 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
78
src/components/common/SecondaryScreenShell.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { forwardRef, type ReactNode } from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { IconChevronLeft } from '@/components/ui/icons';
|
||||||
|
import styles from './SecondaryScreenShell.module.scss';
|
||||||
|
|
||||||
|
export type SecondaryScreenShellProps = {
|
||||||
|
title: ReactNode;
|
||||||
|
onBack?: () => void;
|
||||||
|
backLabel?: string;
|
||||||
|
backAriaLabel?: string;
|
||||||
|
rightAction?: ReactNode;
|
||||||
|
isLoading?: boolean;
|
||||||
|
loadingLabel?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenShellProps>(
|
||||||
|
function SecondaryScreenShell(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
onBack,
|
||||||
|
backLabel = 'Back',
|
||||||
|
backAriaLabel,
|
||||||
|
rightAction,
|
||||||
|
isLoading = false,
|
||||||
|
loadingLabel = 'Loading...',
|
||||||
|
className = '',
|
||||||
|
contentClassName = '',
|
||||||
|
children,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const containerClassName = [styles.container, className].filter(Boolean).join(' ');
|
||||||
|
const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' ');
|
||||||
|
const titleTooltip = typeof title === 'string' ? title : undefined;
|
||||||
|
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClassName} ref={ref}>
|
||||||
|
<div className={styles.topBar}>
|
||||||
|
{onBack ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onBack}
|
||||||
|
className={styles.backButton}
|
||||||
|
aria-label={resolvedBackAriaLabel}
|
||||||
|
>
|
||||||
|
<span className={styles.backIcon}>
|
||||||
|
<IconChevronLeft size={18} />
|
||||||
|
</span>
|
||||||
|
<span className={styles.backText}>{backLabel}</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<div className={styles.topBarTitle} title={titleTooltip}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className={styles.rightSlot}>{rightAction}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className={styles.loadingState}>
|
||||||
|
<LoadingSpinner size={16} />
|
||||||
|
<span>{loadingLabel}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={contentClasses}>{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
106
src/components/common/SplashScreen.scss
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
@use 'sass:color';
|
||||||
|
@use '../../styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.splash-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.4s ease-out;
|
||||||
|
|
||||||
|
&.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
animation: splash-enter 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes splash-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-logo {
|
||||||
|
height: 80px;
|
||||||
|
width: auto;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
animation: splash-logo-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes splash-logo-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
margin-top: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-loader {
|
||||||
|
width: 120px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: $radius-full;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash-loader-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: $radius-full;
|
||||||
|
animation: splash-loading 1.2s ease-in-out infinite;
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes splash-loading {
|
||||||
|
0% {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleX(1);
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
50.01% {
|
||||||
|
transform-origin: right;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/components/common/SplashScreen.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
|
import './SplashScreen.scss';
|
||||||
|
|
||||||
|
interface SplashScreenProps {
|
||||||
|
onFinish: () => void;
|
||||||
|
fadeOut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FADE_OUT_DURATION = 400;
|
||||||
|
|
||||||
|
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fadeOut) return;
|
||||||
|
const finishTimer = setTimeout(() => {
|
||||||
|
onFinish();
|
||||||
|
}, FADE_OUT_DURATION);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(finishTimer);
|
||||||
|
};
|
||||||
|
}, [fadeOut, onFinish]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
|
||||||
|
<div className="splash-content">
|
||||||
|
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
|
||||||
|
<h1 className="splash-title">CLI Proxy API</h1>
|
||||||
|
<p className="splash-subtitle">Management Center</p>
|
||||||
|
<div className="splash-loader">
|
||||||
|
<div className="splash-loader-bar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
643
src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
import {
|
||||||
|
ReactNode,
|
||||||
|
SVGProps,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { PageTransition } from '@/components/common/PageTransition';
|
||||||
|
import { MainRoutes } from '@/router/MainRoutes';
|
||||||
|
import {
|
||||||
|
IconBot,
|
||||||
|
IconChartLine,
|
||||||
|
IconFileText,
|
||||||
|
IconInfo,
|
||||||
|
IconKey,
|
||||||
|
IconLayoutDashboard,
|
||||||
|
IconScrollText,
|
||||||
|
IconSettings,
|
||||||
|
IconShield,
|
||||||
|
IconSlidersHorizontal,
|
||||||
|
IconTimer,
|
||||||
|
} from '@/components/ui/icons';
|
||||||
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
|
import {
|
||||||
|
useAuthStore,
|
||||||
|
useConfigStore,
|
||||||
|
useLanguageStore,
|
||||||
|
useNotificationStore,
|
||||||
|
useThemeStore,
|
||||||
|
} from '@/stores';
|
||||||
|
import { configApi, versionApi } from '@/services/api';
|
||||||
|
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
|
||||||
|
const sidebarIcons: Record<string, ReactNode> = {
|
||||||
|
dashboard: <IconLayoutDashboard size={18} />,
|
||||||
|
settings: <IconSlidersHorizontal size={18} />,
|
||||||
|
apiKeys: <IconKey size={18} />,
|
||||||
|
aiProviders: <IconBot size={18} />,
|
||||||
|
authFiles: <IconFileText size={18} />,
|
||||||
|
oauth: <IconShield size={18} />,
|
||||||
|
quota: <IconTimer size={18} />,
|
||||||
|
usage: <IconChartLine size={18} />,
|
||||||
|
config: <IconSettings size={18} />,
|
||||||
|
logs: <IconScrollText size={18} />,
|
||||||
|
system: <IconInfo size={18} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Header action icons - smaller size for header buttons
|
||||||
|
const headerIconProps: SVGProps<SVGSVGElement> = {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
'aria-hidden': 'true',
|
||||||
|
focusable: 'false',
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerIcons = {
|
||||||
|
refresh: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||||
|
<path d="M21 3v5h-5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
update: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<path d="M12 19V5" />
|
||||||
|
<path d="m5 12 7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
menu: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<path d="M4 7h16" />
|
||||||
|
<path d="M4 12h16" />
|
||||||
|
<path d="M4 17h16" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
chevronLeft: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<path d="m14 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
chevronRight: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<path d="m10 6 6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
language: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M2 12h20" />
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
sun: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2" />
|
||||||
|
<path d="M12 20v2" />
|
||||||
|
<path d="m4.93 4.93 1.41 1.41" />
|
||||||
|
<path d="m17.66 17.66 1.41 1.41" />
|
||||||
|
<path d="M2 12h2" />
|
||||||
|
<path d="M20 12h2" />
|
||||||
|
<path d="m6.34 17.66-1.41 1.41" />
|
||||||
|
<path d="m19.07 4.93-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
moon: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
autoTheme: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="mainLayoutAutoThemeSunLeftHalf">
|
||||||
|
<rect x="0" y="0" width="12" height="24" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
|
||||||
|
<path d="M12 2v2" />
|
||||||
|
<path d="M12 20v2" />
|
||||||
|
<path d="M4.93 4.93l1.41 1.41" />
|
||||||
|
<path d="M17.66 17.66l1.41 1.41" />
|
||||||
|
<path d="M2 12h2" />
|
||||||
|
<path d="M20 12h2" />
|
||||||
|
<path d="M6.34 17.66l-1.41 1.41" />
|
||||||
|
<path d="M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
logout: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<path d="m16 17 5-5-5-5" />
|
||||||
|
<path d="M21 12H9" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseVersionSegments = (version?: string | null) => {
|
||||||
|
if (!version) return null;
|
||||||
|
const cleaned = version.trim().replace(/^v/i, '');
|
||||||
|
if (!cleaned) return null;
|
||||||
|
const parts = cleaned
|
||||||
|
.split(/[^0-9]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => Number.parseInt(segment, 10))
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
return parts.length ? parts : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||||
|
const latestParts = parseVersionSegments(latest);
|
||||||
|
const currentParts = parseVersionSegments(current);
|
||||||
|
if (!latestParts || !currentParts) return null;
|
||||||
|
const length = Math.max(latestParts.length, currentParts.length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const l = latestParts[i] || 0;
|
||||||
|
const c = currentParts[i] || 0;
|
||||||
|
if (l > c) return 1;
|
||||||
|
if (l < c) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MainLayout() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const apiBase = useAuthStore((state) => state.apiBase);
|
||||||
|
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||||
|
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const logout = useAuthStore((state) => state.logout);
|
||||||
|
|
||||||
|
const config = useConfigStore((state) => state.config);
|
||||||
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
|
||||||
|
const theme = useThemeStore((state) => state.theme);
|
||||||
|
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||||
|
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||||
|
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||||
|
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||||
|
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
|
||||||
|
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||||
|
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||||
|
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||||
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const headerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const versionTapCount = useRef(0);
|
||||||
|
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const fullBrandName = 'CLI Proxy API Management Center';
|
||||||
|
const abbrBrandName = t('title.abbr');
|
||||||
|
const requestLogEnabled = config?.requestLog ?? false;
|
||||||
|
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||||
|
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
||||||
|
const isLogsPage = location.pathname.startsWith('/logs');
|
||||||
|
|
||||||
|
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const updateHeaderHeight = () => {
|
||||||
|
const height = headerRef.current?.offsetHeight;
|
||||||
|
if (height) {
|
||||||
|
document.documentElement.style.setProperty('--header-height', `${height}px`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateHeaderHeight();
|
||||||
|
|
||||||
|
const resizeObserver =
|
||||||
|
typeof ResizeObserver !== 'undefined' && headerRef.current
|
||||||
|
? new ResizeObserver(updateHeaderHeight)
|
||||||
|
: null;
|
||||||
|
if (resizeObserver && headerRef.current) {
|
||||||
|
resizeObserver.observe(headerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateHeaderHeight);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', updateHeaderHeight);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 5秒后自动收起品牌名称
|
||||||
|
useEffect(() => {
|
||||||
|
brandCollapseTimer.current = setTimeout(() => {
|
||||||
|
setBrandExpanded(false);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (brandCollapseTimer.current) {
|
||||||
|
clearTimeout(brandCollapseTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestLogModalOpen && !requestLogTouched) {
|
||||||
|
setRequestLogDraft(requestLogEnabled);
|
||||||
|
}
|
||||||
|
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBrandClick = useCallback(() => {
|
||||||
|
if (!brandExpanded) {
|
||||||
|
setBrandExpanded(true);
|
||||||
|
// 点击展开后,5秒后再次收起
|
||||||
|
if (brandCollapseTimer.current) {
|
||||||
|
clearTimeout(brandCollapseTimer.current);
|
||||||
|
}
|
||||||
|
brandCollapseTimer.current = setTimeout(() => {
|
||||||
|
setBrandExpanded(false);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}, [brandExpanded]);
|
||||||
|
|
||||||
|
const openRequestLogModal = useCallback(() => {
|
||||||
|
setRequestLogTouched(false);
|
||||||
|
setRequestLogDraft(requestLogEnabled);
|
||||||
|
setRequestLogModalOpen(true);
|
||||||
|
}, [requestLogEnabled]);
|
||||||
|
|
||||||
|
const handleRequestLogClose = useCallback(() => {
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
setRequestLogTouched(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVersionTap = useCallback(() => {
|
||||||
|
versionTapCount.current += 1;
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
}
|
||||||
|
versionTapTimer.current = setTimeout(() => {
|
||||||
|
versionTapCount.current = 0;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
if (versionTapCount.current >= 7) {
|
||||||
|
versionTapCount.current = 0;
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
versionTapTimer.current = null;
|
||||||
|
}
|
||||||
|
openRequestLogModal();
|
||||||
|
}
|
||||||
|
}, [openRequestLogModal]);
|
||||||
|
|
||||||
|
const handleRequestLogSave = async () => {
|
||||||
|
if (!canEditRequestLog) return;
|
||||||
|
if (!requestLogDirty) {
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = requestLogEnabled;
|
||||||
|
setRequestLogSaving(true);
|
||||||
|
updateConfigValue('request-log', requestLogDraft);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configApi.updateRequestLog(requestLogDraft);
|
||||||
|
clearCache('request-log');
|
||||||
|
showNotification(t('notification.request_log_updated'), 'success');
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
updateConfigValue('request-log', previous);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setRequestLogSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig().catch(() => {
|
||||||
|
// ignore initial failure; login flow会提示
|
||||||
|
});
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
|
||||||
|
const statusClass =
|
||||||
|
connectionStatus === 'connected'
|
||||||
|
? 'success'
|
||||||
|
: connectionStatus === 'connecting'
|
||||||
|
? 'warning'
|
||||||
|
: connectionStatus === 'error'
|
||||||
|
? 'error'
|
||||||
|
: 'muted';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
|
||||||
|
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
|
||||||
|
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
|
||||||
|
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
||||||
|
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
|
||||||
|
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
||||||
|
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
|
||||||
|
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
||||||
|
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||||
|
...(config?.loggingToFile
|
||||||
|
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
||||||
|
: []),
|
||||||
|
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
||||||
|
];
|
||||||
|
const navOrder = navItems.map((item) => item.path);
|
||||||
|
const getRouteOrder = (pathname: string) => {
|
||||||
|
const trimmedPath =
|
||||||
|
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||||
|
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
|
||||||
|
|
||||||
|
const aiProvidersIndex = navOrder.indexOf('/ai-providers');
|
||||||
|
if (aiProvidersIndex !== -1) {
|
||||||
|
if (normalizedPath === '/ai-providers') return aiProvidersIndex;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/')) {
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6;
|
||||||
|
return aiProvidersIndex + 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authFilesIndex = navOrder.indexOf('/auth-files');
|
||||||
|
if (authFilesIndex !== -1) {
|
||||||
|
if (normalizedPath === '/auth-files') return authFilesIndex;
|
||||||
|
if (normalizedPath.startsWith('/auth-files/')) {
|
||||||
|
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
|
||||||
|
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
|
||||||
|
return authFilesIndex + 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exactIndex = navOrder.indexOf(normalizedPath);
|
||||||
|
if (exactIndex !== -1) return exactIndex;
|
||||||
|
const nestedIndex = navOrder.findIndex(
|
||||||
|
(path) => path !== '/' && normalizedPath.startsWith(`${path}/`)
|
||||||
|
);
|
||||||
|
return nestedIndex === -1 ? null : nestedIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
|
||||||
|
const normalize = (pathname: string) => {
|
||||||
|
const trimmed =
|
||||||
|
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||||
|
return trimmed === '/dashboard' ? '/' : trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const from = normalize(fromPathname);
|
||||||
|
const to = normalize(toPathname);
|
||||||
|
const isAuthFiles = (pathname: string) =>
|
||||||
|
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
|
||||||
|
const isAiProviders = (pathname: string) =>
|
||||||
|
pathname === '/ai-providers' || pathname.startsWith('/ai-providers/');
|
||||||
|
if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
|
||||||
|
if (isAiProviders(from) && isAiProviders(to)) return 'ios';
|
||||||
|
return 'vertical';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefreshAll = async () => {
|
||||||
|
clearCache();
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
fetchConfig(undefined, true),
|
||||||
|
triggerHeaderRefresh()
|
||||||
|
]);
|
||||||
|
const rejected = results.find((result) => result.status === 'rejected');
|
||||||
|
if (rejected && rejected.status === 'rejected') {
|
||||||
|
const reason = rejected.reason;
|
||||||
|
const message =
|
||||||
|
typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showNotification(t('notification.data_refreshed'), 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVersionCheck = async () => {
|
||||||
|
setCheckingVersion(true);
|
||||||
|
try {
|
||||||
|
const data = await versionApi.checkLatest();
|
||||||
|
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||||
|
const comparison = compareVersions(latest, serverVersion);
|
||||||
|
|
||||||
|
if (!latest) {
|
||||||
|
showNotification(t('system_info.version_check_error'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comparison === null) {
|
||||||
|
showNotification(t('system_info.version_current_missing'), 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comparison > 0) {
|
||||||
|
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
|
||||||
|
} else {
|
||||||
|
showNotification(t('system_info.version_is_latest'), 'success');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setCheckingVersion(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="main-header" ref={headerRef}>
|
||||||
|
<div className="left">
|
||||||
|
<button
|
||||||
|
className="sidebar-toggle-header"
|
||||||
|
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||||
|
title={
|
||||||
|
sidebarCollapsed
|
||||||
|
? t('sidebar.expand', { defaultValue: '展开' })
|
||||||
|
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
||||||
|
</button>
|
||||||
|
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="brand-logo" />
|
||||||
|
<div
|
||||||
|
className={`brand-header ${brandExpanded ? 'expanded' : 'collapsed'}`}
|
||||||
|
onClick={handleBrandClick}
|
||||||
|
title={brandExpanded ? undefined : fullBrandName}
|
||||||
|
>
|
||||||
|
<span className="brand-full">{fullBrandName}</span>
|
||||||
|
<span className="brand-abbr">{abbrBrandName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="right">
|
||||||
|
<div className="connection">
|
||||||
|
<span className={`status-badge ${statusClass}`}>
|
||||||
|
{t(
|
||||||
|
connectionStatus === 'connected'
|
||||||
|
? 'common.connected_status'
|
||||||
|
: connectionStatus === 'connecting'
|
||||||
|
? 'common.connecting_status'
|
||||||
|
: 'common.disconnected_status'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="base">{apiBase || '-'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
<Button
|
||||||
|
className="mobile-menu-btn"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSidebarOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{headerIcons.menu}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefreshAll}
|
||||||
|
title={t('header.refresh_all')}
|
||||||
|
>
|
||||||
|
{headerIcons.refresh}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleVersionCheck}
|
||||||
|
loading={checkingVersion}
|
||||||
|
title={t('system_info.version_check_button')}
|
||||||
|
>
|
||||||
|
{headerIcons.update}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
||||||
|
{headerIcons.language}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||||
|
{theme === 'auto'
|
||||||
|
? headerIcons.autoTheme
|
||||||
|
: theme === 'dark'
|
||||||
|
? headerIcons.moon
|
||||||
|
: headerIcons.sun}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||||
|
{headerIcons.logout}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="main-body">
|
||||||
|
<aside
|
||||||
|
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="nav-section">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
title={sidebarCollapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<span className="nav-icon">{item.icon}</span>
|
||||||
|
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
|
||||||
|
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
|
||||||
|
<PageTransition
|
||||||
|
render={(location) => <MainRoutes location={location} />}
|
||||||
|
getRouteOrder={getRouteOrder}
|
||||||
|
getTransitionVariant={getTransitionVariant}
|
||||||
|
scrollContainerRef={contentRef}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="footer">
|
||||||
|
<span>
|
||||||
|
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
||||||
|
</span>
|
||||||
|
<span className="footer-version" onClick={handleVersionTap}>
|
||||||
|
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('footer.build_date')}:{' '}
|
||||||
|
{serverBuildDate
|
||||||
|
? new Date(serverBuildDate).toLocaleString(i18n.language)
|
||||||
|
: t('system_info.version_unknown')}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={requestLogModalOpen}
|
||||||
|
onClose={handleRequestLogClose}
|
||||||
|
title={t('basic_settings.request_log_title')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleRequestLogSave}
|
||||||
|
loading={requestLogSaving}
|
||||||
|
disabled={!canEditRequestLog || !requestLogDirty}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="request-log-modal">
|
||||||
|
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('basic_settings.request_log_enable')}
|
||||||
|
labelPosition="left"
|
||||||
|
checked={requestLogDraft}
|
||||||
|
disabled={!canEditRequestLog || requestLogSaving}
|
||||||
|
onChange={(value) => {
|
||||||
|
setRequestLogDraft(value);
|
||||||
|
setRequestLogTouched(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
281
src/components/providers/AmpcodeSection/AmpcodeModal.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/components/providers/AmpcodeSection/AmpcodeSection.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import iconAmp from '@/assets/icons/amp.svg';
|
||||||
|
import type { AmpcodeConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface AmpcodeSectionProps {
|
||||||
|
config: AmpcodeConfig | null | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpcodeSection({
|
||||||
|
config,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSwitching,
|
||||||
|
onEdit,
|
||||||
|
}: AmpcodeSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showLoadingPlaceholder = loading && !config;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconAmp} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('ai_providers.ampcode_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onEdit}
|
||||||
|
disabled={disableControls || loading || isSwitching}
|
||||||
|
>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showLoadingPlaceholder ? (
|
||||||
|
<div className="hint">{t('common.loading')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_url_label')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{config?.upstreamUrl || t('common.not_set')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>
|
||||||
|
{t('ai_providers.ampcode_upstream_api_key_label')}:
|
||||||
|
</span>
|
||||||
|
<span className={styles.fieldValue}>
|
||||||
|
{config?.upstreamApiKey ? maskApiKey(config.upstreamApiKey) : t('common.not_set')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>
|
||||||
|
{t('ai_providers.ampcode_force_model_mappings_label')}:
|
||||||
|
</span>
|
||||||
|
<span className={styles.fieldValue}>
|
||||||
|
{(config?.forceModelMappings ?? false) ? t('common.yes') : t('common.no')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow} style={{ marginTop: 8 }}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{config?.modelMappings?.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
{config?.modelMappings?.length ? (
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{config.modelMappings.slice(0, 5).map((mapping) => (
|
||||||
|
<span key={`${mapping.from}→${mapping.to}`} className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>{mapping.from}</span>
|
||||||
|
<span className={styles.modelAlias}>{mapping.to}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{config.modelMappings.length > 5 && (
|
||||||
|
<span className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>+{config.modelMappings.length - 5}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/AmpcodeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AmpcodeSection } from './AmpcodeSection';
|
||||||
129
src/components/providers/ClaudeSection/ClaudeModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
src/components/providers/ClaudeSection/ClaudeSection.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { Fragment, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import { ProviderList } from '../ProviderList';
|
||||||
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
|
|
||||||
|
interface ClaudeSectionProps {
|
||||||
|
configs: ProviderKeyConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClaudeSection({
|
||||||
|
configs,
|
||||||
|
keyStats,
|
||||||
|
usageDetails,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSwitching,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggle,
|
||||||
|
}: ClaudeSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
|
const toggleDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
|
const statusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
configs.forEach((config) => {
|
||||||
|
if (!config.apiKey) return;
|
||||||
|
const candidates = buildCandidateUsageSourceIds({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
prefix: config.prefix,
|
||||||
|
});
|
||||||
|
if (!candidates.length) return;
|
||||||
|
const candidateSet = new Set(candidates);
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||||
|
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconClaude} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('ai_providers.claude_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||||
|
{t('ai_providers.claude_add_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProviderList<ProviderKeyConfig>
|
||||||
|
items={configs}
|
||||||
|
loading={loading}
|
||||||
|
keyField={(item) => item.apiKey}
|
||||||
|
emptyTitle={t('ai_providers.claude_empty_title')}
|
||||||
|
emptyDescription={t('ai_providers.claude_empty_desc')}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
actionsDisabled={actionsDisabled}
|
||||||
|
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
renderExtraActions={(item, index) => (
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('ai_providers.config_toggle_label')}
|
||||||
|
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
disabled={toggleDisabled}
|
||||||
|
onChange={(value) => void onToggle(index, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderContent={(item) => {
|
||||||
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
|
const excludedModels = item.excludedModels ?? [];
|
||||||
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className="item-title">{t('ai_providers.claude_item_title')}</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||||
|
</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.baseUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.proxyUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.proxyUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{headerEntries.length > 0 && (
|
||||||
|
<div className={styles.headerBadgeList}>
|
||||||
|
{headerEntries.map(([key, value]) => (
|
||||||
|
<span key={key} className={styles.headerBadge}>
|
||||||
|
<strong>{key}:</strong> {value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{configDisabled && (
|
||||||
|
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
{t('ai_providers.config_disabled_badge')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.models?.length ? (
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
<span className={styles.modelCountLabel}>
|
||||||
|
{t('ai_providers.claude_models_count')}: {item.models.length}
|
||||||
|
</span>
|
||||||
|
{item.models.map((model) => (
|
||||||
|
<span key={model.name} className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>{model.name}</span>
|
||||||
|
{model.alias && model.alias !== model.name && (
|
||||||
|
<span className={styles.modelAlias}>{model.alias}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{excludedModels.length ? (
|
||||||
|
<div className={styles.excludedModelsSection}>
|
||||||
|
<div className={styles.excludedModelsLabel}>
|
||||||
|
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{excludedModels.map((model) => (
|
||||||
|
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||||
|
<span className={styles.modelName}>{model}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {stats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {stats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProviderStatusBar statusData={statusData} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/ClaudeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ClaudeSection } from './ClaudeSection';
|
||||||
117
src/components/providers/CodexSection/CodexModal.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/components/providers/CodexSection/CodexSection.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { Fragment, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||||
|
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import { ProviderList } from '../ProviderList';
|
||||||
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
|
|
||||||
|
interface CodexSectionProps {
|
||||||
|
configs: ProviderKeyConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
resolvedTheme: string;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodexSection({
|
||||||
|
configs,
|
||||||
|
keyStats,
|
||||||
|
usageDetails,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSwitching,
|
||||||
|
resolvedTheme,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggle,
|
||||||
|
}: CodexSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
|
const toggleDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
|
const statusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
configs.forEach((config) => {
|
||||||
|
if (!config.apiKey) return;
|
||||||
|
const candidates = buildCandidateUsageSourceIds({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
prefix: config.prefix,
|
||||||
|
});
|
||||||
|
if (!candidates.length) return;
|
||||||
|
const candidateSet = new Set(candidates);
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||||
|
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img
|
||||||
|
src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
|
||||||
|
alt=""
|
||||||
|
className={styles.cardTitleIcon}
|
||||||
|
/>
|
||||||
|
{t('ai_providers.codex_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||||
|
{t('ai_providers.codex_add_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProviderList<ProviderKeyConfig>
|
||||||
|
items={configs}
|
||||||
|
loading={loading}
|
||||||
|
keyField={(item) => item.apiKey}
|
||||||
|
emptyTitle={t('ai_providers.codex_empty_title')}
|
||||||
|
emptyDescription={t('ai_providers.codex_empty_desc')}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
actionsDisabled={actionsDisabled}
|
||||||
|
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
renderExtraActions={(item, index) => (
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('ai_providers.config_toggle_label')}
|
||||||
|
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
disabled={toggleDisabled}
|
||||||
|
onChange={(value) => void onToggle(index, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderContent={(item) => {
|
||||||
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
|
const excludedModels = item.excludedModels ?? [];
|
||||||
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className="item-title">{t('ai_providers.codex_item_title')}</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||||
|
</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.baseUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.proxyUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.proxyUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{headerEntries.length > 0 && (
|
||||||
|
<div className={styles.headerBadgeList}>
|
||||||
|
{headerEntries.map(([key, value]) => (
|
||||||
|
<span key={key} className={styles.headerBadge}>
|
||||||
|
<strong>{key}:</strong> {value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{configDisabled && (
|
||||||
|
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
{t('ai_providers.config_disabled_badge')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{excludedModels.length ? (
|
||||||
|
<div className={styles.excludedModelsSection}>
|
||||||
|
<div className={styles.excludedModelsLabel}>
|
||||||
|
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{excludedModels.map((model) => (
|
||||||
|
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||||
|
<span className={styles.modelName}>{model}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {stats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {stats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProviderStatusBar statusData={statusData} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/CodexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { CodexSection } from './CodexSection';
|
||||||
113
src/components/providers/GeminiSection/GeminiModal.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/components/providers/GeminiSection/GeminiSection.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { Fragment, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
|
import type { GeminiKeyConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import { ProviderList } from '../ProviderList';
|
||||||
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
|
|
||||||
|
interface GeminiSectionProps {
|
||||||
|
configs: GeminiKeyConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeminiSection({
|
||||||
|
configs,
|
||||||
|
keyStats,
|
||||||
|
usageDetails,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSwitching,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggle,
|
||||||
|
}: GeminiSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
|
const toggleDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
|
const statusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
configs.forEach((config) => {
|
||||||
|
if (!config.apiKey) return;
|
||||||
|
const candidates = buildCandidateUsageSourceIds({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
prefix: config.prefix,
|
||||||
|
});
|
||||||
|
if (!candidates.length) return;
|
||||||
|
const candidateSet = new Set(candidates);
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||||
|
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconGemini} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('ai_providers.gemini_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||||
|
{t('ai_providers.gemini_add_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProviderList<GeminiKeyConfig>
|
||||||
|
items={configs}
|
||||||
|
loading={loading}
|
||||||
|
keyField={(item) => item.apiKey}
|
||||||
|
emptyTitle={t('ai_providers.gemini_empty_title')}
|
||||||
|
emptyDescription={t('ai_providers.gemini_empty_desc')}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
actionsDisabled={actionsDisabled}
|
||||||
|
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
renderExtraActions={(item, index) => (
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('ai_providers.config_toggle_label')}
|
||||||
|
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||||
|
disabled={toggleDisabled}
|
||||||
|
onChange={(value) => void onToggle(index, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderContent={(item, index) => {
|
||||||
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
|
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||||
|
const excludedModels = item.excludedModels ?? [];
|
||||||
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className="item-title">
|
||||||
|
{t('ai_providers.gemini_item_title')} #{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||||
|
</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.baseUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{headerEntries.length > 0 && (
|
||||||
|
<div className={styles.headerBadgeList}>
|
||||||
|
{headerEntries.map(([key, value]) => (
|
||||||
|
<span key={key} className={styles.headerBadge}>
|
||||||
|
<strong>{key}:</strong> {value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{configDisabled && (
|
||||||
|
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
{t('ai_providers.config_disabled_badge')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{excludedModels.length ? (
|
||||||
|
<div className={styles.excludedModelsSection}>
|
||||||
|
<div className={styles.excludedModelsLabel}>
|
||||||
|
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{excludedModels.map((model) => (
|
||||||
|
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||||
|
<span className={styles.modelName}>{model}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {stats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {stats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProviderStatusBar statusData={statusData} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/GeminiSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { GeminiSection } from './GeminiSection';
|
||||||
194
src/components/providers/OpenAISection/OpenAIDiscoveryModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
433
src/components/providers/OpenAISection/OpenAIModal.tsx
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
src/components/providers/OpenAISection/OpenAISection.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Fragment, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||||
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
|
import type { OpenAIProviderConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import { ProviderList } from '../ProviderList';
|
||||||
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
|
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||||
|
|
||||||
|
interface OpenAISectionProps {
|
||||||
|
configs: OpenAIProviderConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
resolvedTheme: string;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenAISection({
|
||||||
|
configs,
|
||||||
|
keyStats,
|
||||||
|
usageDetails,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSwitching,
|
||||||
|
resolvedTheme,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: OpenAISectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
|
const statusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
configs.forEach((provider) => {
|
||||||
|
const sourceIds = new Set<string>();
|
||||||
|
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
|
||||||
|
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||||
|
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredDetails = sourceIds.size
|
||||||
|
? usageDetails.filter((detail) => sourceIds.has(detail.source))
|
||||||
|
: [];
|
||||||
|
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img
|
||||||
|
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||||
|
alt=""
|
||||||
|
className={styles.cardTitleIcon}
|
||||||
|
/>
|
||||||
|
{t('ai_providers.openai_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||||
|
{t('ai_providers.openai_add_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProviderList<OpenAIProviderConfig>
|
||||||
|
items={configs}
|
||||||
|
loading={loading}
|
||||||
|
keyField={(item) => item.name}
|
||||||
|
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||||
|
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
actionsDisabled={actionsDisabled}
|
||||||
|
renderContent={(item) => {
|
||||||
|
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||||
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
|
const apiKeyEntries = item.apiKeyEntries || [];
|
||||||
|
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className="item-title">{item.name}</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
{headerEntries.length > 0 && (
|
||||||
|
<div className={styles.headerBadgeList}>
|
||||||
|
{headerEntries.map(([key, value]) => (
|
||||||
|
<span key={key} className={styles.headerBadge}>
|
||||||
|
<strong>{key}:</strong> {value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{apiKeyEntries.length > 0 && (
|
||||||
|
<div className={styles.apiKeyEntriesSection}>
|
||||||
|
<div className={styles.apiKeyEntriesLabel}>
|
||||||
|
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
|
||||||
|
</div>
|
||||||
|
<div className={styles.apiKeyEntryList}>
|
||||||
|
{apiKeyEntries.map((entry, entryIndex) => {
|
||||||
|
const entryStats = getStatsBySource(entry.apiKey, keyStats);
|
||||||
|
return (
|
||||||
|
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||||
|
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||||
|
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||||
|
{entry.proxyUrl && (
|
||||||
|
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
|
||||||
|
)}
|
||||||
|
<div className={styles.apiKeyEntryStats}>
|
||||||
|
<span
|
||||||
|
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
|
||||||
|
>
|
||||||
|
<IconCheck size={12} /> {entryStats.success}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
|
||||||
|
>
|
||||||
|
<IconX size={12} /> {entryStats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
|
||||||
|
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.models?.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
{item.models?.length ? (
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
{item.models.map((model) => (
|
||||||
|
<span key={model.name} className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>{model.name}</span>
|
||||||
|
{model.alias && model.alias !== model.name && (
|
||||||
|
<span className={styles.modelAlias}>{model.alias}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.testModel && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>Test Model:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.testModel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {stats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {stats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProviderStatusBar statusData={statusData} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/OpenAISection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { OpenAISection } from './OpenAISection';
|
||||||
80
src/components/providers/ProviderList.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
|
||||||
|
interface ProviderListProps<T> {
|
||||||
|
items: T[];
|
||||||
|
loading: boolean;
|
||||||
|
keyField: (item: T) => string;
|
||||||
|
renderContent: (item: T, index: number) => ReactNode;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyDescription: string;
|
||||||
|
deleteLabel?: string;
|
||||||
|
actionsDisabled?: boolean;
|
||||||
|
getRowDisabled?: (item: T, index: number) => boolean;
|
||||||
|
renderExtraActions?: (item: T, index: number) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderList<T>({
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
keyField,
|
||||||
|
renderContent,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
deleteLabel,
|
||||||
|
actionsDisabled = false,
|
||||||
|
getRowDisabled,
|
||||||
|
renderExtraActions,
|
||||||
|
}: ProviderListProps<T>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (loading && items.length === 0) {
|
||||||
|
return <div className="hint">{t('common.loading')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return <EmptyState title={emptyTitle} description={emptyDescription} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="item-list">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={keyField(item)}
|
||||||
|
className="item-row"
|
||||||
|
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
||||||
|
>
|
||||||
|
<div className="item-meta">{renderContent(item, index)}</div>
|
||||||
|
<div className="item-actions">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(index)}
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(index)}
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
>
|
||||||
|
{deleteLabel || t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
{renderExtraActions ? renderExtraActions(item, index) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/providers/ProviderNav/ProviderNav.module.scss
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.navContainer {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
box-shadow: inset 0 0 0 2px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暗色主题适配
|
||||||
|
:global([data-theme='dark']) {
|
||||||
|
.navList {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
background: rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏幕隐藏悬浮导航
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.navContainer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/components/providers/ProviderNav/ProviderNav.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useThemeStore } from '@/stores';
|
||||||
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
|
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||||
|
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||||
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
|
import iconVertex from '@/assets/icons/vertex.svg';
|
||||||
|
import iconAmp from '@/assets/icons/amp.svg';
|
||||||
|
import styles from './ProviderNav.module.scss';
|
||||||
|
|
||||||
|
export type ProviderId = 'gemini' | 'codex' | 'claude' | 'vertex' | 'ampcode' | 'openai';
|
||||||
|
|
||||||
|
interface ProviderNavItem {
|
||||||
|
id: ProviderId;
|
||||||
|
label: string;
|
||||||
|
getIcon: (theme: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS: ProviderNavItem[] = [
|
||||||
|
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
|
||||||
|
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
|
||||||
|
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
|
||||||
|
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
|
||||||
|
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
|
||||||
|
{ id: 'openai', label: 'OpenAI', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HEADER_OFFSET = 24;
|
||||||
|
|
||||||
|
export function ProviderNav() {
|
||||||
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const getScrollContainer = useCallback(() => {
|
||||||
|
if (scrollContainerRef.current) return scrollContainerRef.current;
|
||||||
|
const container = document.querySelector('.content') as HTMLElement | null;
|
||||||
|
scrollContainerRef.current = container;
|
||||||
|
return container;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const container = getScrollContainer();
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const activationLine = containerRect.top + HEADER_OFFSET + 1;
|
||||||
|
let currentActive: ProviderId | null = null;
|
||||||
|
|
||||||
|
for (const provider of PROVIDERS) {
|
||||||
|
const element = document.getElementById(`provider-${provider.id}`);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.top <= activationLine) {
|
||||||
|
currentActive = provider.id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentActive) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentActive) {
|
||||||
|
const firstVisible = PROVIDERS.find((provider) =>
|
||||||
|
document.getElementById(`provider-${provider.id}`)
|
||||||
|
);
|
||||||
|
currentActive = firstVisible?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveProvider(currentActive);
|
||||||
|
}, [getScrollContainer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = getScrollContainer();
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
handleScroll();
|
||||||
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [handleScroll, getScrollContainer]);
|
||||||
|
|
||||||
|
const scrollToProvider = (providerId: ProviderId) => {
|
||||||
|
const container = getScrollContainer();
|
||||||
|
const element = document.getElementById(`provider-${providerId}`);
|
||||||
|
if (!element || !container) return;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
|
||||||
|
|
||||||
|
setActiveProvider(providerId);
|
||||||
|
container.scrollTo({
|
||||||
|
top: scrollTop,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const navContent = (
|
||||||
|
<div className={styles.navContainer}>
|
||||||
|
<div className={styles.navList}>
|
||||||
|
{PROVIDERS.map((provider) => {
|
||||||
|
const isActive = activeProvider === provider.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
|
||||||
|
onClick={() => scrollToProvider(provider.id)}
|
||||||
|
title={provider.label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={provider.getIcon(resolvedTheme)}
|
||||||
|
alt={provider.label}
|
||||||
|
className={styles.icon}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
|
||||||
|
return createPortal(navContent, document.body);
|
||||||
|
}
|
||||||
2
src/components/providers/ProviderNav/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ProviderNav } from './ProviderNav';
|
||||||
|
export type { ProviderId } from './ProviderNav';
|
||||||
38
src/components/providers/ProviderStatusBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { calculateStatusBarData } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
|
||||||
|
interface ProviderStatusBarProps {
|
||||||
|
statusData: ReturnType<typeof calculateStatusBarData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
|
||||||
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
|
const rateClass = !hasData
|
||||||
|
? ''
|
||||||
|
: statusData.successRate >= 90
|
||||||
|
? styles.statusRateHigh
|
||||||
|
: statusData.successRate >= 50
|
||||||
|
? styles.statusRateMedium
|
||||||
|
: styles.statusRateLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<div className={styles.statusBlocks}>
|
||||||
|
{statusData.blocks.map((state, idx) => {
|
||||||
|
const blockClass =
|
||||||
|
state === 'success'
|
||||||
|
? styles.statusBlockSuccess
|
||||||
|
: state === 'failure'
|
||||||
|
? styles.statusBlockFailure
|
||||||
|
: state === 'mixed'
|
||||||
|
? styles.statusBlockMixed
|
||||||
|
: styles.statusBlockIdle;
|
||||||
|
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||||
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/providers/VertexSection/VertexModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/components/providers/VertexSection/VertexSection.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Fragment, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import iconVertex from '@/assets/icons/vertex.svg';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import {
|
||||||
|
buildCandidateUsageSourceIds,
|
||||||
|
calculateStatusBarData,
|
||||||
|
type KeyStats,
|
||||||
|
type UsageDetail,
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
|
import { ProviderList } from '../ProviderList';
|
||||||
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
|
import { getStatsBySource } from '../utils';
|
||||||
|
|
||||||
|
interface VertexSectionProps {
|
||||||
|
configs: ProviderKeyConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
loading: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
isSwitching: boolean;
|
||||||
|
onAdd: () => void;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VertexSection({
|
||||||
|
configs,
|
||||||
|
keyStats,
|
||||||
|
usageDetails,
|
||||||
|
loading,
|
||||||
|
disableControls,
|
||||||
|
isSwitching,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: VertexSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
|
const statusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
configs.forEach((config) => {
|
||||||
|
if (!config.apiKey) return;
|
||||||
|
const candidates = buildCandidateUsageSourceIds({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
prefix: config.prefix,
|
||||||
|
});
|
||||||
|
if (!candidates.length) return;
|
||||||
|
const candidateSet = new Set(candidates);
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||||
|
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('ai_providers.vertex_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||||
|
{t('ai_providers.vertex_add_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProviderList<ProviderKeyConfig>
|
||||||
|
items={configs}
|
||||||
|
loading={loading}
|
||||||
|
keyField={(item) => item.apiKey}
|
||||||
|
emptyTitle={t('ai_providers.vertex_empty_title')}
|
||||||
|
emptyDescription={t('ai_providers.vertex_empty_desc')}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
actionsDisabled={actionsDisabled}
|
||||||
|
renderContent={(item, index) => {
|
||||||
|
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||||
|
const headerEntries = Object.entries(item.headers || {});
|
||||||
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className="item-title">
|
||||||
|
{t('ai_providers.vertex_item_title')} #{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
|
||||||
|
</div>
|
||||||
|
{item.prefix && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.baseUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.baseUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.proxyUrl && (
|
||||||
|
<div className={styles.fieldRow}>
|
||||||
|
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
|
||||||
|
<span className={styles.fieldValue}>{item.proxyUrl}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{headerEntries.length > 0 && (
|
||||||
|
<div className={styles.headerBadgeList}>
|
||||||
|
{headerEntries.map(([key, value]) => (
|
||||||
|
<span key={key} className={styles.headerBadge}>
|
||||||
|
<strong>{key}:</strong> {value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.models?.length ? (
|
||||||
|
<div className={styles.modelTagList}>
|
||||||
|
<span className={styles.modelCountLabel}>
|
||||||
|
{t('ai_providers.vertex_models_count')}: {item.models.length}
|
||||||
|
</span>
|
||||||
|
{item.models.map((model) => (
|
||||||
|
<span key={`${model.name}-${model.alias || 'default'}`} className={styles.modelTag}>
|
||||||
|
<span className={styles.modelName}>{model.name}</span>
|
||||||
|
{model.alias && (
|
||||||
|
<span className={styles.modelAlias}>{model.alias}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span className={`${styles.statPill} ${styles.statSuccess}`}>
|
||||||
|
{t('stats.success')}: {stats.success}
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.statPill} ${styles.statFailure}`}>
|
||||||
|
{t('stats.failure')}: {stats.failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ProviderStatusBar statusData={statusData} />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/VertexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { VertexSection } from './VertexSection';
|
||||||
37
src/components/providers/hooks/useProviderStats.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useInterval } from '@/hooks/useInterval';
|
||||||
|
import { usageApi } from '@/services/api';
|
||||||
|
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||||
|
|
||||||
|
const EMPTY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
|
||||||
|
|
||||||
|
export const useProviderStats = () => {
|
||||||
|
const [keyStats, setKeyStats] = useState<KeyStats>(EMPTY_STATS);
|
||||||
|
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
|
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||||
|
const loadKeyStats = useCallback(async () => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const usageResponse = await usageApi.getUsage();
|
||||||
|
const usageData = usageResponse?.usage ?? usageResponse;
|
||||||
|
const stats = await usageApi.getKeyStats(usageData);
|
||||||
|
setKeyStats(stats);
|
||||||
|
setUsageDetails(collectUsageDetails(usageData));
|
||||||
|
} catch {
|
||||||
|
// 静默失败
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 定时刷新状态数据(每240秒)
|
||||||
|
useInterval(loadKeyStats, 240_000);
|
||||||
|
|
||||||
|
return { keyStats, usageDetails, loadKeyStats, isLoading };
|
||||||
|
};
|
||||||
12
src/components/providers/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export { AmpcodeSection } from './AmpcodeSection';
|
||||||
|
export { ClaudeSection } from './ClaudeSection';
|
||||||
|
export { CodexSection } from './CodexSection';
|
||||||
|
export { GeminiSection } from './GeminiSection';
|
||||||
|
export { OpenAISection } from './OpenAISection';
|
||||||
|
export { VertexSection } from './VertexSection';
|
||||||
|
export { ProviderList } from './ProviderList';
|
||||||
|
export { ProviderStatusBar } from './ProviderStatusBar';
|
||||||
|
export { ProviderNav } from './ProviderNav';
|
||||||
|
export * from './hooks/useProviderStats';
|
||||||
|
export * from './types';
|
||||||
|
export * from './utils';
|
||||||
69
src/components/providers/types.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
|
||||||
|
import type { HeaderEntry } from '@/utils/headers';
|
||||||
|
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||||
|
|
||||||
|
export type ProviderModal =
|
||||||
|
| { type: 'gemini'; index: number | null }
|
||||||
|
| { type: 'codex'; index: number | null }
|
||||||
|
| { type: 'claude'; index: number | null }
|
||||||
|
| { type: 'vertex'; index: number | null }
|
||||||
|
| { type: 'ampcode'; index: null }
|
||||||
|
| { type: 'openai'; index: number | null };
|
||||||
|
|
||||||
|
export interface ModelEntry {
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenAIFormState {
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
baseUrl: string;
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
testModel?: string;
|
||||||
|
modelEntries: ModelEntry[];
|
||||||
|
apiKeyEntries: ApiKeyEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmpcodeFormState {
|
||||||
|
upstreamUrl: string;
|
||||||
|
upstreamApiKey: string;
|
||||||
|
forceModelMappings: boolean;
|
||||||
|
mappingEntries: ModelEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
excludedText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
modelEntries: ModelEntry[];
|
||||||
|
excludedText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
|
||||||
|
headers: HeaderEntry[];
|
||||||
|
modelEntries: ModelEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProviderSectionProps<TConfig> {
|
||||||
|
configs: TConfig[];
|
||||||
|
keyStats: KeyStats;
|
||||||
|
usageDetails: UsageDetail[];
|
||||||
|
disabled: boolean;
|
||||||
|
onEdit: (index: number) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
onDelete: (index: number) => void;
|
||||||
|
onToggle?: (index: number, enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
|
||||||
|
isOpen: boolean;
|
||||||
|
editIndex: number | null;
|
||||||
|
initialData?: TConfig;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: TPayload, index: number | null) => Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
149
src/components/providers/utils.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||||
|
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||||
|
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||||
|
|
||||||
|
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||||
|
|
||||||
|
export const hasDisableAllModelsRule = (models?: string[]) =>
|
||||||
|
Array.isArray(models) &&
|
||||||
|
models.some((model) => String(model ?? '').trim() === DISABLE_ALL_MODELS_RULE);
|
||||||
|
|
||||||
|
export const stripDisableAllModelsRule = (models?: string[]) =>
|
||||||
|
Array.isArray(models)
|
||||||
|
? models.filter((model) => String(model ?? '').trim() !== DISABLE_ALL_MODELS_RULE)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
export const withDisableAllModelsRule = (models?: string[]) => {
|
||||||
|
const base = stripDisableAllModelsRule(models);
|
||||||
|
return [...base, DISABLE_ALL_MODELS_RULE];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withoutDisableAllModelsRule = (models?: string[]) => {
|
||||||
|
const base = stripDisableAllModelsRule(models);
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseExcludedModels = (text: string): string[] =>
|
||||||
|
text
|
||||||
|
.split(/[\n,]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
export const excludedModelsToText = (models?: string[]) =>
|
||||||
|
Array.isArray(models) ? models.join('\n') : '';
|
||||||
|
|
||||||
|
export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
||||||
|
let trimmed = String(baseUrl || '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
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 => {
|
||||||
|
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||||
|
if (!trimmed) return '';
|
||||||
|
return `${trimmed}/models`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||||
|
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (trimmed.endsWith('/chat/completions')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return `${trimmed}/chat/completions`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
||||||
|
export const getStatsBySource = (
|
||||||
|
apiKey: string,
|
||||||
|
keyStats: KeyStats,
|
||||||
|
prefix?: string
|
||||||
|
): KeyStatBucket => {
|
||||||
|
const bySource = keyStats.bySource ?? {};
|
||||||
|
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||||
|
if (!candidates.length) {
|
||||||
|
return { success: 0, failure: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failure = 0;
|
||||||
|
candidates.forEach((candidate) => {
|
||||||
|
const stats = bySource[candidate];
|
||||||
|
if (!stats) return;
|
||||||
|
success += stats.success;
|
||||||
|
failure += stats.failure;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, failure };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||||
|
export const getOpenAIProviderStats = (
|
||||||
|
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||||
|
keyStats: KeyStats,
|
||||||
|
providerPrefix?: string
|
||||||
|
): KeyStatBucket => {
|
||||||
|
const bySource = keyStats.bySource ?? {};
|
||||||
|
|
||||||
|
const sourceIds = new Set<string>();
|
||||||
|
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||||
|
(apiKeyEntries || []).forEach((entry) => {
|
||||||
|
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
let success = 0;
|
||||||
|
let failure = 0;
|
||||||
|
sourceIds.forEach((id) => {
|
||||||
|
const stats = bySource[id];
|
||||||
|
if (!stats) return;
|
||||||
|
success += stats.success;
|
||||||
|
failure += stats.failure;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success, failure };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||||
|
apiKey: input?.apiKey ?? '',
|
||||||
|
proxyUrl: input?.proxyUrl ?? '',
|
||||||
|
headers: input?.headers ?? {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ampcodeMappingsToEntries = (mappings?: AmpcodeModelMapping[]): ModelEntry[] => {
|
||||||
|
if (!Array.isArray(mappings) || mappings.length === 0) {
|
||||||
|
return [{ name: '', alias: '' }];
|
||||||
|
}
|
||||||
|
return mappings.map((mapping) => ({
|
||||||
|
name: mapping.from ?? '',
|
||||||
|
alias: mapping.to ?? '',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const mappings: AmpcodeModelMapping[] = [];
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const from = entry.name.trim();
|
||||||
|
const to = entry.alias.trim();
|
||||||
|
if (!from || !to) return;
|
||||||
|
const key = from.toLowerCase();
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
mappings.push({ from, to });
|
||||||
|
});
|
||||||
|
|
||||||
|
return mappings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
||||||
|
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
||||||
|
upstreamApiKey: '',
|
||||||
|
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
||||||
|
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
|
||||||
|
});
|
||||||
145
src/components/quota/QuotaCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Generic quota card component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
|
||||||
|
import { TYPE_COLORS } from '@/utils/quota';
|
||||||
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface QuotaStatusState {
|
||||||
|
status: QuotaStatus;
|
||||||
|
error?: string;
|
||||||
|
errorStatus?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaRenderHelpers {
|
||||||
|
styles: typeof styles;
|
||||||
|
QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuotaCardProps<TState extends QuotaStatusState> {
|
||||||
|
item: AuthFileItem;
|
||||||
|
quota?: TState;
|
||||||
|
resolvedTheme: ResolvedTheme;
|
||||||
|
i18nPrefix: string;
|
||||||
|
cardClassName: string;
|
||||||
|
defaultType: string;
|
||||||
|
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotaCard<TState extends QuotaStatusState>({
|
||||||
|
item,
|
||||||
|
quota,
|
||||||
|
resolvedTheme,
|
||||||
|
i18nPrefix,
|
||||||
|
cardClassName,
|
||||||
|
defaultType,
|
||||||
|
renderQuotaItems
|
||||||
|
}: QuotaCardProps<TState>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const displayType = item.type || item.provider || defaultType;
|
||||||
|
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
|
||||||
|
const typeColor: ThemeColors =
|
||||||
|
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
|
||||||
|
|
||||||
|
const quotaStatus = quota?.status ?? 'idle';
|
||||||
|
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||||
|
t,
|
||||||
|
quota?.errorStatus,
|
||||||
|
quota?.error || t('common.unknown_error')
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTypeLabel = (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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.fileCard} ${cardClassName}`}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<span
|
||||||
|
className={styles.typeBadge}
|
||||||
|
style={{
|
||||||
|
backgroundColor: typeColor.bg,
|
||||||
|
color: typeColor.text,
|
||||||
|
...(typeColor.border ? { border: typeColor.border } : {})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getTypeLabel(displayType)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.fileName}>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.quotaSection}>
|
||||||
|
{quotaStatus === 'loading' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
|
||||||
|
) : quotaStatus === 'idle' ? (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||||
|
) : quotaStatus === 'error' ? (
|
||||||
|
<div className={styles.quotaError}>
|
||||||
|
{t(`${i18nPrefix}.load_failed`, {
|
||||||
|
message: quotaErrorMessage
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : quota ? (
|
||||||
|
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
|
||||||
|
) : (
|
||||||
|
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
321
src/components/quota/QuotaSection.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Generic quota section component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
import { useQuotaStore, useThemeStore } from '@/stores';
|
||||||
|
import type { AuthFileItem, ResolvedTheme } from '@/types';
|
||||||
|
import { QuotaCard } from './QuotaCard';
|
||||||
|
import type { QuotaStatusState } from './QuotaCard';
|
||||||
|
import { useQuotaLoader } from './useQuotaLoader';
|
||||||
|
import type { QuotaConfig } from './quotaConfigs';
|
||||||
|
import { useGridColumns } from './useGridColumns';
|
||||||
|
import { IconRefreshCw } from '@/components/ui/icons';
|
||||||
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||||
|
|
||||||
|
type ViewMode = 'paged' | 'all';
|
||||||
|
|
||||||
|
const MAX_ITEMS_PER_PAGE = 14;
|
||||||
|
const MAX_SHOW_ALL_THRESHOLD = 30;
|
||||||
|
|
||||||
|
interface QuotaPaginationState<T> {
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
pageItems: T[];
|
||||||
|
setPageSize: (size: number) => void;
|
||||||
|
goToPrev: () => void;
|
||||||
|
goToNext: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
loadingScope: 'page' | 'all' | null;
|
||||||
|
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSizeState] = useState(defaultPageSize);
|
||||||
|
const [loading, setLoadingState] = useState(false);
|
||||||
|
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
||||||
|
|
||||||
|
const totalPages = useMemo(
|
||||||
|
() => Math.max(1, Math.ceil(items.length / pageSize)),
|
||||||
|
[items.length, pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentPage = useMemo(() => Math.min(page, totalPages), [page, totalPages]);
|
||||||
|
|
||||||
|
const pageItems = useMemo(() => {
|
||||||
|
const start = (currentPage - 1) * pageSize;
|
||||||
|
return items.slice(start, start + pageSize);
|
||||||
|
}, [items, currentPage, pageSize]);
|
||||||
|
|
||||||
|
const setPageSize = useCallback((size: number) => {
|
||||||
|
setPageSizeState(size);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToPrev = useCallback(() => {
|
||||||
|
setPage((prev) => Math.max(1, prev - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
setPage((prev) => Math.min(totalPages, prev + 1));
|
||||||
|
}, [totalPages]);
|
||||||
|
|
||||||
|
const setLoading = useCallback((isLoading: boolean, scope?: 'page' | 'all' | null) => {
|
||||||
|
setLoadingState(isLoading);
|
||||||
|
setLoadingScope(isLoading ? (scope ?? null) : null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
currentPage,
|
||||||
|
pageItems,
|
||||||
|
setPageSize,
|
||||||
|
goToPrev,
|
||||||
|
goToNext,
|
||||||
|
loading,
|
||||||
|
loadingScope,
|
||||||
|
setLoading
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
|
||||||
|
config: QuotaConfig<TState, TData>;
|
||||||
|
files: AuthFileItem[];
|
||||||
|
loading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||||
|
config,
|
||||||
|
files,
|
||||||
|
loading,
|
||||||
|
disabled
|
||||||
|
}: QuotaSectionProps<TState, TData>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||||
|
Record<string, TState>
|
||||||
|
>;
|
||||||
|
|
||||||
|
/* Removed useRef */
|
||||||
|
const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('paged');
|
||||||
|
const [showTooManyWarning, setShowTooManyWarning] = useState(false);
|
||||||
|
|
||||||
|
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
|
||||||
|
files,
|
||||||
|
config
|
||||||
|
]);
|
||||||
|
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
|
||||||
|
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
|
||||||
|
|
||||||
|
const {
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
currentPage,
|
||||||
|
pageItems,
|
||||||
|
setPageSize,
|
||||||
|
goToPrev,
|
||||||
|
goToNext,
|
||||||
|
loading: sectionLoading,
|
||||||
|
setLoading
|
||||||
|
} = useQuotaPagination(filteredFiles);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAllAllowed) return;
|
||||||
|
if (viewMode !== 'all') return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setViewMode('paged');
|
||||||
|
setShowTooManyWarning(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [showAllAllowed, viewMode]);
|
||||||
|
|
||||||
|
// Update page size based on view mode and columns
|
||||||
|
useEffect(() => {
|
||||||
|
if (effectiveViewMode === 'all') {
|
||||||
|
setPageSize(Math.max(1, filteredFiles.length));
|
||||||
|
} else {
|
||||||
|
// Paged mode: 3 rows * columns, capped to avoid oversized pages.
|
||||||
|
setPageSize(Math.min(columns * 3, MAX_ITEMS_PER_PAGE));
|
||||||
|
}
|
||||||
|
}, [effectiveViewMode, columns, filteredFiles.length, setPageSize]);
|
||||||
|
|
||||||
|
const { quota, loadQuota } = useQuotaLoader(config);
|
||||||
|
|
||||||
|
const pendingQuotaRefreshRef = useRef(false);
|
||||||
|
const prevFilesLoadingRef = useRef(loading);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
pendingQuotaRefreshRef.current = true;
|
||||||
|
void triggerHeaderRefresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasLoading = prevFilesLoadingRef.current;
|
||||||
|
prevFilesLoadingRef.current = loading;
|
||||||
|
|
||||||
|
if (!pendingQuotaRefreshRef.current) return;
|
||||||
|
if (loading) return;
|
||||||
|
if (!wasLoading) return;
|
||||||
|
|
||||||
|
pendingQuotaRefreshRef.current = false;
|
||||||
|
const scope = effectiveViewMode === 'all' ? 'all' : 'page';
|
||||||
|
const targets = effectiveViewMode === 'all' ? filteredFiles : pageItems;
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
loadQuota(targets, scope, setLoading);
|
||||||
|
}, [loading, effectiveViewMode, filteredFiles, pageItems, loadQuota, setLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
if (filteredFiles.length === 0) {
|
||||||
|
setQuota({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuota((prev) => {
|
||||||
|
const nextState: Record<string, TState> = {};
|
||||||
|
filteredFiles.forEach((file) => {
|
||||||
|
const cached = prev[file.name];
|
||||||
|
if (cached) {
|
||||||
|
nextState[file.name] = cached;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
}, [filteredFiles, loading, setQuota]);
|
||||||
|
|
||||||
|
const titleNode = (
|
||||||
|
<div className={styles.titleWrapper}>
|
||||||
|
<span>{t(`${config.i18nPrefix}.title`)}</span>
|
||||||
|
{filteredFiles.length > 0 && (
|
||||||
|
<span className={styles.countBadge}>
|
||||||
|
{filteredFiles.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRefreshing = sectionLoading || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={titleNode}
|
||||||
|
extra={
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<div className={styles.viewModeToggle}>
|
||||||
|
<Button
|
||||||
|
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode('paged')}
|
||||||
|
>
|
||||||
|
{t('auth_files.view_mode_paged')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
|
||||||
|
setShowTooManyWarning(true);
|
||||||
|
} else {
|
||||||
|
setViewMode('all');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('auth_files.view_mode_all')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={disabled || isRefreshing}
|
||||||
|
loading={isRefreshing}
|
||||||
|
title={t('quota_management.refresh_files_and_quota')}
|
||||||
|
aria-label={t('quota_management.refresh_files_and_quota')}
|
||||||
|
>
|
||||||
|
{!isRefreshing && <IconRefreshCw size={16} />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filteredFiles.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t(`${config.i18nPrefix}.empty_title`)}
|
||||||
|
description={t(`${config.i18nPrefix}.empty_desc`)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div ref={gridRef} className={config.gridClassName}>
|
||||||
|
{pageItems.map((item) => (
|
||||||
|
<QuotaCard
|
||||||
|
key={item.name}
|
||||||
|
item={item}
|
||||||
|
quota={quota[item.name]}
|
||||||
|
resolvedTheme={resolvedTheme}
|
||||||
|
i18nPrefix={config.i18nPrefix}
|
||||||
|
cardClassName={config.cardClassName}
|
||||||
|
defaultType={config.type}
|
||||||
|
renderQuotaItems={config.renderQuotaItems}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToPrev}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
>
|
||||||
|
{t('auth_files.pagination_prev')}
|
||||||
|
</Button>
|
||||||
|
<div className={styles.pageInfo}>
|
||||||
|
{t('auth_files.pagination_info', {
|
||||||
|
current: currentPage,
|
||||||
|
total: totalPages,
|
||||||
|
count: filteredFiles.length
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNext}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
>
|
||||||
|
{t('auth_files.pagination_next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showTooManyWarning && (
|
||||||
|
<div className={styles.warningOverlay} onClick={() => setShowTooManyWarning(false)}>
|
||||||
|
<div className={styles.warningModal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<p>{t('auth_files.too_many_files_warning')}</p>
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setShowTooManyWarning(false)}>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/components/quota/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Quota components barrel export.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { QuotaSection } from './QuotaSection';
|
||||||
|
export { QuotaCard } from './QuotaCard';
|
||||||
|
export { useQuotaLoader } from './useQuotaLoader';
|
||||||
|
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
||||||
|
export type { QuotaConfig } from './quotaConfigs';
|
||||||
592
src/components/quota/quotaConfigs.ts
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
/**
|
||||||
|
* Quota configuration definitions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type {
|
||||||
|
AntigravityQuotaGroup,
|
||||||
|
AntigravityModelsPayload,
|
||||||
|
AntigravityQuotaState,
|
||||||
|
AuthFileItem,
|
||||||
|
CodexQuotaState,
|
||||||
|
CodexUsageWindow,
|
||||||
|
CodexQuotaWindow,
|
||||||
|
CodexUsagePayload,
|
||||||
|
GeminiCliParsedBucket,
|
||||||
|
GeminiCliQuotaBucketState,
|
||||||
|
GeminiCliQuotaState
|
||||||
|
} from '@/types';
|
||||||
|
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
||||||
|
import {
|
||||||
|
ANTIGRAVITY_QUOTA_URLS,
|
||||||
|
ANTIGRAVITY_REQUEST_HEADERS,
|
||||||
|
CODEX_USAGE_URL,
|
||||||
|
CODEX_REQUEST_HEADERS,
|
||||||
|
GEMINI_CLI_QUOTA_URL,
|
||||||
|
GEMINI_CLI_REQUEST_HEADERS,
|
||||||
|
normalizeAuthIndexValue,
|
||||||
|
normalizeNumberValue,
|
||||||
|
normalizePlanType,
|
||||||
|
normalizeQuotaFraction,
|
||||||
|
normalizeStringValue,
|
||||||
|
parseAntigravityPayload,
|
||||||
|
parseCodexUsagePayload,
|
||||||
|
parseGeminiCliQuotaPayload,
|
||||||
|
resolveCodexChatgptAccountId,
|
||||||
|
resolveCodexPlanType,
|
||||||
|
resolveGeminiCliProjectId,
|
||||||
|
formatCodexResetLabel,
|
||||||
|
formatQuotaResetTime,
|
||||||
|
buildAntigravityQuotaGroups,
|
||||||
|
buildGeminiCliQuotaBuckets,
|
||||||
|
createStatusError,
|
||||||
|
getStatusFromError,
|
||||||
|
isAntigravityFile,
|
||||||
|
isCodexFile,
|
||||||
|
isDisabledAuthFile,
|
||||||
|
isGeminiCliFile,
|
||||||
|
isRuntimeOnlyAuthFile
|
||||||
|
} from '@/utils/quota';
|
||||||
|
import type { QuotaRenderHelpers } from './QuotaCard';
|
||||||
|
import styles from '@/pages/QuotaPage.module.scss';
|
||||||
|
|
||||||
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||||
|
|
||||||
|
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||||
|
|
||||||
|
export interface QuotaStore {
|
||||||
|
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||||
|
codexQuota: Record<string, CodexQuotaState>;
|
||||||
|
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||||
|
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||||
|
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||||
|
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||||
|
clearQuotaCache: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaConfig<TState, TData> {
|
||||||
|
type: QuotaType;
|
||||||
|
i18nPrefix: string;
|
||||||
|
filterFn: (file: AuthFileItem) => boolean;
|
||||||
|
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
|
||||||
|
storeSelector: (state: QuotaStore) => Record<string, TState>;
|
||||||
|
storeSetter: keyof QuotaStore;
|
||||||
|
buildLoadingState: () => TState;
|
||||||
|
buildSuccessState: (data: TData) => TState;
|
||||||
|
buildErrorState: (message: string, status?: number) => TState;
|
||||||
|
cardClassName: string;
|
||||||
|
controlsClassName: string;
|
||||||
|
controlClassName: string;
|
||||||
|
gridClassName: string;
|
||||||
|
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const text = await authFilesApi.downloadText(file.name);
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
|
||||||
|
if (topLevel) return topLevel;
|
||||||
|
|
||||||
|
const installed =
|
||||||
|
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
|
||||||
|
? (parsed.installed as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const installedProjectId = installed
|
||||||
|
? normalizeStringValue(installed.project_id ?? installed.projectId)
|
||||||
|
: null;
|
||||||
|
if (installedProjectId) return installedProjectId;
|
||||||
|
|
||||||
|
const web =
|
||||||
|
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
|
||||||
|
? (parsed.web as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
|
||||||
|
if (webProjectId) return webProjectId;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAntigravityQuota = async (
|
||||||
|
file: AuthFileItem,
|
||||||
|
t: TFunction
|
||||||
|
): Promise<AntigravityQuotaGroup[]> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('antigravity_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = await resolveAntigravityProjectId(file);
|
||||||
|
const requestBody = JSON.stringify({ project: projectId });
|
||||||
|
|
||||||
|
let lastError = '';
|
||||||
|
let lastStatus: number | undefined;
|
||||||
|
let priorityStatus: number | undefined;
|
||||||
|
let hadSuccess = false;
|
||||||
|
|
||||||
|
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
||||||
|
try {
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'POST',
|
||||||
|
url,
|
||||||
|
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
||||||
|
data: requestBody
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
lastError = getApiCallErrorMessage(result);
|
||||||
|
lastStatus = result.statusCode;
|
||||||
|
if (result.statusCode === 403 || result.statusCode === 404) {
|
||||||
|
priorityStatus ??= result.statusCode;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hadSuccess = true;
|
||||||
|
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
||||||
|
const models = payload?.models;
|
||||||
|
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
||||||
|
lastError = t('antigravity_quota.empty_models');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
||||||
|
if (groups.length === 0) {
|
||||||
|
lastError = t('antigravity_quota.empty_models');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const status = getStatusFromError(err);
|
||||||
|
if (status) {
|
||||||
|
lastStatus = status;
|
||||||
|
if (status === 403 || status === 404) {
|
||||||
|
priorityStatus ??= status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hadSuccess) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
|
||||||
|
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
||||||
|
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
||||||
|
const windows: CodexQuotaWindow[] = [];
|
||||||
|
|
||||||
|
const addWindow = (
|
||||||
|
id: string,
|
||||||
|
labelKey: string,
|
||||||
|
window?: CodexUsageWindow | null,
|
||||||
|
limitReached?: boolean,
|
||||||
|
allowed?: boolean
|
||||||
|
) => {
|
||||||
|
if (!window) return;
|
||||||
|
const resetLabel = formatCodexResetLabel(window);
|
||||||
|
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
|
||||||
|
const isLimitReached = Boolean(limitReached) || allowed === false;
|
||||||
|
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
||||||
|
windows.push({
|
||||||
|
id,
|
||||||
|
label: t(labelKey),
|
||||||
|
labelKey,
|
||||||
|
usedPercent,
|
||||||
|
resetLabel
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
addWindow(
|
||||||
|
'primary',
|
||||||
|
'codex_quota.primary_window',
|
||||||
|
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
||||||
|
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||||
|
rateLimit?.allowed
|
||||||
|
);
|
||||||
|
addWindow(
|
||||||
|
'secondary',
|
||||||
|
'codex_quota.secondary_window',
|
||||||
|
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
||||||
|
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||||
|
rateLimit?.allowed
|
||||||
|
);
|
||||||
|
addWindow(
|
||||||
|
'code-review',
|
||||||
|
'codex_quota.code_review_window',
|
||||||
|
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
||||||
|
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
||||||
|
codeReviewLimit?.allowed
|
||||||
|
);
|
||||||
|
|
||||||
|
return windows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCodexQuota = async (
|
||||||
|
file: AuthFileItem,
|
||||||
|
t: TFunction
|
||||||
|
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('codex_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const planTypeFromFile = resolveCodexPlanType(file);
|
||||||
|
const accountId = resolveCodexChatgptAccountId(file);
|
||||||
|
if (!accountId) {
|
||||||
|
throw new Error(t('codex_quota.missing_account_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestHeader: Record<string, string> = {
|
||||||
|
...CODEX_REQUEST_HEADERS,
|
||||||
|
'Chatgpt-Account-Id': accountId
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'GET',
|
||||||
|
url: CODEX_USAGE_URL,
|
||||||
|
header: requestHeader
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error(t('codex_quota.empty_windows'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
|
||||||
|
const windows = buildCodexQuotaWindows(payload, t);
|
||||||
|
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchGeminiCliQuota = async (
|
||||||
|
file: AuthFileItem,
|
||||||
|
t: TFunction
|
||||||
|
): Promise<GeminiCliQuotaBucketState[]> => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
if (!authIndex) {
|
||||||
|
throw new Error(t('gemini_cli_quota.missing_auth_index'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = resolveGeminiCliProjectId(file);
|
||||||
|
if (!projectId) {
|
||||||
|
throw new Error(t('gemini_cli_quota.missing_project_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
authIndex,
|
||||||
|
method: 'POST',
|
||||||
|
url: GEMINI_CLI_QUOTA_URL,
|
||||||
|
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||||
|
data: JSON.stringify({ project: projectId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
|
||||||
|
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
|
||||||
|
if (buckets.length === 0) return [];
|
||||||
|
|
||||||
|
const parsedBuckets = buckets
|
||||||
|
.map((bucket) => {
|
||||||
|
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
||||||
|
if (!modelId) return null;
|
||||||
|
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||||
|
const remainingFractionRaw = normalizeQuotaFraction(
|
||||||
|
bucket.remainingFraction ?? bucket.remaining_fraction
|
||||||
|
);
|
||||||
|
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
|
||||||
|
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
||||||
|
let fallbackFraction: number | null = null;
|
||||||
|
if (remainingAmount !== null) {
|
||||||
|
fallbackFraction = remainingAmount <= 0 ? 0 : null;
|
||||||
|
} else if (resetTime) {
|
||||||
|
fallbackFraction = 0;
|
||||||
|
}
|
||||||
|
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
|
||||||
|
return {
|
||||||
|
modelId,
|
||||||
|
tokenType,
|
||||||
|
remainingFraction,
|
||||||
|
remainingAmount,
|
||||||
|
resetTime
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||||
|
|
||||||
|
return buildGeminiCliQuotaBuckets(parsedBuckets);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAntigravityItems = (
|
||||||
|
quota: AntigravityQuotaState,
|
||||||
|
t: TFunction,
|
||||||
|
helpers: QuotaRenderHelpers
|
||||||
|
): ReactNode => {
|
||||||
|
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||||
|
const { createElement: h } = React;
|
||||||
|
const groups = quota.groups ?? [];
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.map((group) => {
|
||||||
|
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
|
||||||
|
const percent = Math.round(clamped * 100);
|
||||||
|
const resetLabel = formatQuotaResetTime(group.resetTime);
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ key: group.id, className: styleMap.quotaRow },
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaRowHeader },
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ className: styleMap.quotaModel, title: group.models.join(', ') },
|
||||||
|
group.label
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaMeta },
|
||||||
|
h('span', { className: styleMap.quotaPercent }, `${percent}%`),
|
||||||
|
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCodexItems = (
|
||||||
|
quota: CodexQuotaState,
|
||||||
|
t: TFunction,
|
||||||
|
helpers: QuotaRenderHelpers
|
||||||
|
): ReactNode => {
|
||||||
|
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||||
|
const { createElement: h, Fragment } = React;
|
||||||
|
const windows = quota.windows ?? [];
|
||||||
|
const planType = quota.planType ?? null;
|
||||||
|
|
||||||
|
const getPlanLabel = (pt?: string | null): string | null => {
|
||||||
|
const normalized = normalizePlanType(pt);
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
||||||
|
if (normalized === 'team') return t('codex_quota.plan_team');
|
||||||
|
if (normalized === 'free') return t('codex_quota.plan_free');
|
||||||
|
return pt || normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const planLabel = getPlanLabel(planType);
|
||||||
|
const isFreePlan = normalizePlanType(planType) === 'free';
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
|
||||||
|
if (planLabel) {
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ key: 'plan', className: styleMap.codexPlan },
|
||||||
|
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
|
||||||
|
h('span', { className: styleMap.codexPlanValue }, planLabel)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFreePlan) {
|
||||||
|
nodes.push(
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ key: 'warning', className: styleMap.quotaWarning },
|
||||||
|
t('codex_quota.no_access')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return h(Fragment, null, ...nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windows.length === 0) {
|
||||||
|
nodes.push(
|
||||||
|
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGeminiCliItems = (
|
||||||
|
quota: GeminiCliQuotaState,
|
||||||
|
t: TFunction,
|
||||||
|
helpers: QuotaRenderHelpers
|
||||||
|
): ReactNode => {
|
||||||
|
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||||
|
const { createElement: h } = React;
|
||||||
|
const buckets = quota.buckets ?? [];
|
||||||
|
|
||||||
|
if (buckets.length === 0) {
|
||||||
|
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets.map((bucket) => {
|
||||||
|
const fraction = bucket.remainingFraction;
|
||||||
|
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
||||||
|
const percent = clamped === null ? null : Math.round(clamped * 100);
|
||||||
|
const percentLabel = percent === null ? '--' : `${percent}%`;
|
||||||
|
const remainingAmountLabel =
|
||||||
|
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||||
|
? null
|
||||||
|
: t('gemini_cli_quota.remaining_amount', {
|
||||||
|
count: bucket.remainingAmount
|
||||||
|
});
|
||||||
|
const titleBase =
|
||||||
|
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||||
|
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
|
||||||
|
|
||||||
|
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ key: bucket.id, className: styleMap.quotaRow },
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaRowHeader },
|
||||||
|
h('span', { className: styleMap.quotaModel, title }, bucket.label),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ className: styleMap.quotaMeta },
|
||||||
|
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||||
|
remainingAmountLabel
|
||||||
|
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
|
||||||
|
: null,
|
||||||
|
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||||
|
type: 'antigravity',
|
||||||
|
i18nPrefix: 'antigravity_quota',
|
||||||
|
filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
|
||||||
|
fetchQuota: fetchAntigravityQuota,
|
||||||
|
storeSelector: (state) => state.antigravityQuota,
|
||||||
|
storeSetter: 'setAntigravityQuota',
|
||||||
|
buildLoadingState: () => ({ status: 'loading', groups: [] }),
|
||||||
|
buildSuccessState: (groups) => ({ status: 'success', groups }),
|
||||||
|
buildErrorState: (message, status) => ({
|
||||||
|
status: 'error',
|
||||||
|
groups: [],
|
||||||
|
error: message,
|
||||||
|
errorStatus: status
|
||||||
|
}),
|
||||||
|
cardClassName: styles.antigravityCard,
|
||||||
|
controlsClassName: styles.antigravityControls,
|
||||||
|
controlClassName: styles.antigravityControl,
|
||||||
|
gridClassName: styles.antigravityGrid,
|
||||||
|
renderQuotaItems: renderAntigravityItems
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CODEX_CONFIG: QuotaConfig<
|
||||||
|
CodexQuotaState,
|
||||||
|
{ planType: string | null; windows: CodexQuotaWindow[] }
|
||||||
|
> = {
|
||||||
|
type: 'codex',
|
||||||
|
i18nPrefix: 'codex_quota',
|
||||||
|
filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
|
||||||
|
fetchQuota: fetchCodexQuota,
|
||||||
|
storeSelector: (state) => state.codexQuota,
|
||||||
|
storeSetter: 'setCodexQuota',
|
||||||
|
buildLoadingState: () => ({ status: 'loading', windows: [] }),
|
||||||
|
buildSuccessState: (data) => ({
|
||||||
|
status: 'success',
|
||||||
|
windows: data.windows,
|
||||||
|
planType: data.planType
|
||||||
|
}),
|
||||||
|
buildErrorState: (message, status) => ({
|
||||||
|
status: 'error',
|
||||||
|
windows: [],
|
||||||
|
error: message,
|
||||||
|
errorStatus: status
|
||||||
|
}),
|
||||||
|
cardClassName: styles.codexCard,
|
||||||
|
controlsClassName: styles.codexControls,
|
||||||
|
controlClassName: styles.codexControl,
|
||||||
|
gridClassName: styles.codexGrid,
|
||||||
|
renderQuotaItems: renderCodexItems
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||||
|
type: 'gemini-cli',
|
||||||
|
i18nPrefix: 'gemini_cli_quota',
|
||||||
|
filterFn: (file) =>
|
||||||
|
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
|
||||||
|
fetchQuota: fetchGeminiCliQuota,
|
||||||
|
storeSelector: (state) => state.geminiCliQuota,
|
||||||
|
storeSetter: 'setGeminiCliQuota',
|
||||||
|
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
|
||||||
|
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
|
||||||
|
buildErrorState: (message, status) => ({
|
||||||
|
status: 'error',
|
||||||
|
buckets: [],
|
||||||
|
error: message,
|
||||||
|
errorStatus: status
|
||||||
|
}),
|
||||||
|
cardClassName: styles.geminiCliCard,
|
||||||
|
controlsClassName: styles.geminiCliControls,
|
||||||
|
controlClassName: styles.geminiCliControl,
|
||||||
|
gridClassName: styles.geminiCliGrid,
|
||||||
|
renderQuotaItems: renderGeminiCliItems
|
||||||
|
};
|
||||||
40
src/components/quota/useGridColumns.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to calculate the number of grid columns based on container width and item min-width.
|
||||||
|
* Returns [columns, refCallback].
|
||||||
|
*/
|
||||||
|
export function useGridColumns(
|
||||||
|
itemMinWidth: number,
|
||||||
|
gap: number = 16
|
||||||
|
): [number, (node: HTMLDivElement | null) => void] {
|
||||||
|
const [columns, setColumns] = useState(1);
|
||||||
|
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const refCallback = useCallback((node: HTMLDivElement | null) => {
|
||||||
|
setElement(node);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateColumns = () => {
|
||||||
|
const containerWidth = element.clientWidth;
|
||||||
|
const effectiveItemWidth = itemMinWidth + gap;
|
||||||
|
const count = Math.floor((containerWidth + gap) / effectiveItemWidth);
|
||||||
|
setColumns(Math.max(1, count));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateColumns();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
updateColumns();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [element, itemMinWidth, gap]);
|
||||||
|
|
||||||
|
return [columns, refCallback];
|
||||||
|
}
|
||||||
98
src/components/quota/useQuotaLoader.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Generic hook for quota data fetching and management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { AuthFileItem } from '@/types';
|
||||||
|
import { useQuotaStore } from '@/stores';
|
||||||
|
import { getStatusFromError } from '@/utils/quota';
|
||||||
|
import type { QuotaConfig } from './quotaConfigs';
|
||||||
|
|
||||||
|
type QuotaScope = 'page' | 'all';
|
||||||
|
|
||||||
|
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||||
|
|
||||||
|
interface LoadQuotaResult<TData> {
|
||||||
|
name: string;
|
||||||
|
status: 'success' | 'error';
|
||||||
|
data?: TData;
|
||||||
|
error?: string;
|
||||||
|
errorStatus?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQuotaLoader<TState, TData>(config: QuotaConfig<TState, TData>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const quota = useQuotaStore(config.storeSelector);
|
||||||
|
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||||
|
Record<string, TState>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
|
const loadQuota = useCallback(
|
||||||
|
async (
|
||||||
|
targets: AuthFileItem[],
|
||||||
|
scope: QuotaScope,
|
||||||
|
setLoading: (loading: boolean, scope?: QuotaScope | null) => void
|
||||||
|
) => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
const requestId = ++requestIdRef.current;
|
||||||
|
setLoading(true, scope);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
setQuota((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
targets.forEach((file) => {
|
||||||
|
nextState[file.name] = config.buildLoadingState();
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
targets.map(async (file): Promise<LoadQuotaResult<TData>> => {
|
||||||
|
try {
|
||||||
|
const data = await config.fetchQuota(file, t);
|
||||||
|
return { name: file.name, status: 'success', data };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
|
const errorStatus = getStatusFromError(err);
|
||||||
|
return { name: file.name, status: 'error', error: message, errorStatus };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requestId !== requestIdRef.current) return;
|
||||||
|
|
||||||
|
setQuota((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'success') {
|
||||||
|
nextState[result.name] = config.buildSuccessState(result.data as TData);
|
||||||
|
} else {
|
||||||
|
nextState[result.name] = config.buildErrorState(
|
||||||
|
result.error || t('common.unknown_error'),
|
||||||
|
result.errorStatus
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (requestId === requestIdRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
loadingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config, setQuota, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { quota, loadQuota };
|
||||||
|
}
|
||||||
175
src/components/ui/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
|
||||||
|
import { IconChevronDown } from './icons';
|
||||||
|
|
||||||
|
interface AutocompleteInputProps {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: string[] | { value: string; label?: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
error?: string;
|
||||||
|
className?: string;
|
||||||
|
wrapperClassName?: string;
|
||||||
|
wrapperStyle?: React.CSSProperties;
|
||||||
|
id?: string;
|
||||||
|
rightElement?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutocompleteInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
className = '',
|
||||||
|
wrapperClassName = '',
|
||||||
|
wrapperStyle,
|
||||||
|
id,
|
||||||
|
rightElement
|
||||||
|
}: AutocompleteInputProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const normalizedOptions = options.map(opt =>
|
||||||
|
typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value }
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredOptions = normalizedOptions.filter(opt => {
|
||||||
|
const v = value.toLowerCase();
|
||||||
|
return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v));
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
onChange(selectedValue);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHighlightedIndex(prev =>
|
||||||
|
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect(filteredOptions[highlightedIndex].value);
|
||||||
|
} else if (isOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
|
||||||
|
{label && <label htmlFor={id}>{label}</label>}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={`input ${className}`.trim()}
|
||||||
|
value={value}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ paddingRight: 32 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
pointerEvents: disabled ? 'none' : 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{rightElement}
|
||||||
|
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && filteredOptions.length > 0 && !disabled && (
|
||||||
|
<div className="autocomplete-dropdown" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'calc(100% + 4px)',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
|
||||||
|
}}>
|
||||||
|
{filteredOptions.map((opt, index) => (
|
||||||
|
<div
|
||||||
|
key={`${opt.value}-${index}`}
|
||||||
|
onClick={() => handleSelect(opt.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
fontSize: '0.9rem'
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 500 }}>{opt.value}</span>
|
||||||
|
{opt.label && opt.label !== opt.value && (
|
||||||
|
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>{opt.label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hint && <div className="hint">{hint}</div>}
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
type ButtonSize = 'md' | 'sm';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
children,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
fullWidth = false,
|
||||||
|
loading = false,
|
||||||
|
className = '',
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
}: PropsWithChildren<ButtonProps>) {
|
||||||
|
const hasChildren = children !== null && children !== undefined && children !== false;
|
||||||
|
const classes = [
|
||||||
|
'btn',
|
||||||
|
`btn-${variant}`,
|
||||||
|
size === 'sm' ? 'btn-sm' : '',
|
||||||
|
fullWidth ? 'btn-full' : '',
|
||||||
|
className
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={classes} disabled={disabled || loading} {...rest}>
|
||||||
|
{loading && <span className="loading-spinner" aria-hidden="true" />}
|
||||||
|
{hasChildren && <span>{children}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { PropsWithChildren, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
title?: ReactNode;
|
||||||
|
extra?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
|
||||||
|
return (
|
||||||
|
<div className={className ? `card ${className}` : 'card'}>
|
||||||
|
{(title || extra) && (
|
||||||
|
<div className="card-header">
|
||||||
|
<div className="title">{title}</div>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { IconInbox } from './icons';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-content">
|
||||||
|
<div className="empty-icon" aria-hidden="true">
|
||||||
|
<IconInbox size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="empty-title">{title}</div>
|
||||||
|
{description && <div className="empty-desc">{description}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{action && <div className="empty-action">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/ui/HeaderInputList.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { IconX } from './icons';
|
||||||
|
import type { HeaderEntry } from '@/utils/headers';
|
||||||
|
|
||||||
|
interface HeaderInputListProps {
|
||||||
|
entries: HeaderEntry[];
|
||||||
|
onChange: (entries: HeaderEntry[]) => void;
|
||||||
|
addLabel: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
keyPlaceholder?: string;
|
||||||
|
valuePlaceholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderInputList({
|
||||||
|
entries,
|
||||||
|
onChange,
|
||||||
|
addLabel,
|
||||||
|
disabled = false,
|
||||||
|
keyPlaceholder = 'X-Custom-Header',
|
||||||
|
valuePlaceholder = 'value'
|
||||||
|
}: HeaderInputListProps) {
|
||||||
|
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
|
||||||
|
|
||||||
|
const updateEntry = (index: number, field: 'key' | 'value', value: string) => {
|
||||||
|
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEntry = () => {
|
||||||
|
onChange([...currentEntries, { key: '', value: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEntry = (index: number) => {
|
||||||
|
const next = currentEntries.filter((_, idx) => idx !== index);
|
||||||
|
onChange(next.length ? next : [{ key: '', value: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="header-input-list">
|
||||||
|
{currentEntries.map((entry, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<div className="header-input-row">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={keyPlaceholder}
|
||||||
|
value={entry.key}
|
||||||
|
onChange={(e) => updateEntry(index, 'key', e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="header-separator">:</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={valuePlaceholder}
|
||||||
|
value={entry.value}
|
||||||
|
onChange={(e) => updateEntry(index, 'value', e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeEntry(index)}
|
||||||
|
disabled={disabled || currentEntries.length <= 1}
|
||||||
|
title="Remove"
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
hint?: string;
|
||||||
|
error?: string;
|
||||||
|
rightElement?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ label, hint, error, rightElement, className = '', ...rest }: InputProps) {
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
{label && <label>{label}</label>}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input className={`input ${className}`.trim()} {...rest} />
|
||||||
|
{rightElement && (
|
||||||
|
<div style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)' }}>
|
||||||
|
{rightElement}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hint && <div className="hint">{hint}</div>}
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export function LoadingSpinner({
|
||||||
|
size = 20,
|
||||||
|
className = ''
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`loading-spinner${className ? ` ${className}` : ''}`}
|
||||||
|
style={{ width: size, height: size, borderWidth: size / 7 }}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { IconX } from './icons';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
title?: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
footer?: ReactNode;
|
||||||
|
width?: number | string;
|
||||||
|
closeDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSE_ANIMATION_DURATION = 350;
|
||||||
|
const MODAL_LOCK_CLASS = 'modal-open';
|
||||||
|
let activeModalCount = 0;
|
||||||
|
|
||||||
|
const lockScroll = () => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
if (activeModalCount === 0) {
|
||||||
|
document.body?.classList.add(MODAL_LOCK_CLASS);
|
||||||
|
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
|
||||||
|
}
|
||||||
|
activeModalCount += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlockScroll = () => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
activeModalCount = Math.max(0, activeModalCount - 1);
|
||||||
|
if (activeModalCount === 0) {
|
||||||
|
document.body?.classList.remove(MODAL_LOCK_CLASS);
|
||||||
|
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
footer,
|
||||||
|
width = 520,
|
||||||
|
closeDisabled = false,
|
||||||
|
children
|
||||||
|
}: PropsWithChildren<ModalProps>) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const startClose = useCallback(
|
||||||
|
(notifyParent: boolean) => {
|
||||||
|
if (closeTimerRef.current !== null) return;
|
||||||
|
setIsClosing(true);
|
||||||
|
closeTimerRef.current = window.setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setIsClosing(false);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
if (notifyParent) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, CLOSE_ANIMATION_DURATION);
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
if (closeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIsVisible(true);
|
||||||
|
setIsClosing(false);
|
||||||
|
});
|
||||||
|
} else if (isVisible) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
startClose(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, isVisible, startClose]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
startClose(true);
|
||||||
|
}, [startClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (closeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(closeTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shouldLockScroll = open || isVisible;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldLockScroll) return;
|
||||||
|
lockScroll();
|
||||||
|
return () => unlockScroll();
|
||||||
|
}, [shouldLockScroll]);
|
||||||
|
|
||||||
|
if (!open && !isVisible) return null;
|
||||||
|
|
||||||
|
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||||
|
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div className={overlayClass}>
|
||||||
|
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close-floating"
|
||||||
|
onClick={closeDisabled ? undefined : handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={closeDisabled}
|
||||||
|
>
|
||||||
|
<IconX size={20} />
|
||||||
|
</button>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div className="modal-title">{title}</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">{children}</div>
|
||||||
|
{footer && <div className="modal-footer">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return modalContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
|
}
|
||||||
77
src/components/ui/ModelInputList.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { IconX } from './icons';
|
||||||
|
import type { ModelEntry } from './modelInputListUtils';
|
||||||
|
|
||||||
|
interface ModelInputListProps {
|
||||||
|
entries: ModelEntry[];
|
||||||
|
onChange: (entries: ModelEntry[]) => void;
|
||||||
|
addLabel: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
namePlaceholder?: string;
|
||||||
|
aliasPlaceholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelInputList({
|
||||||
|
entries,
|
||||||
|
onChange,
|
||||||
|
addLabel,
|
||||||
|
disabled = false,
|
||||||
|
namePlaceholder = 'model-name',
|
||||||
|
aliasPlaceholder = 'alias (optional)'
|
||||||
|
}: ModelInputListProps) {
|
||||||
|
const currentEntries = entries.length ? entries : [{ name: '', alias: '' }];
|
||||||
|
|
||||||
|
const updateEntry = (index: number, field: 'name' | 'alias', value: string) => {
|
||||||
|
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEntry = () => {
|
||||||
|
onChange([...currentEntries, { name: '', alias: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEntry = (index: number) => {
|
||||||
|
const next = currentEntries.filter((_, idx) => idx !== index);
|
||||||
|
onChange(next.length ? next : [{ name: '', alias: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="header-input-list">
|
||||||
|
{currentEntries.map((entry, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<div className="header-input-row">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={namePlaceholder}
|
||||||
|
value={entry.name}
|
||||||
|
onChange={(e) => updateEntry(index, 'name', e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="header-separator">→</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder={aliasPlaceholder}
|
||||||
|
value={entry.alias}
|
||||||
|
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeEntry(index)}
|
||||||
|
disabled={disabled || currentEntries.length <= 1}
|
||||||
|
title="Remove"
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/ui/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ChangeEvent, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ToggleSwitchProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
label?: ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
labelPosition?: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleSwitch({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
ariaLabel,
|
||||||
|
disabled = false,
|
||||||
|
labelPosition = 'right'
|
||||||
|
}: ToggleSwitchProps) {
|
||||||
|
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(event.target.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={className}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
<span className="track">
|
||||||
|
<span className="thumb" />
|
||||||
|
</span>
|
||||||
|
{label && <span className="label">{label}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
src/components/ui/icons.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import type { SVGProps } from 'react';
|
||||||
|
|
||||||
|
// Inline SVG icons (Lucide, ISC). We embed paths to keep the WebUI single-file/offline friendly.
|
||||||
|
// Source: https://github.com/lucide-icons/lucide (via lucide-static).
|
||||||
|
|
||||||
|
export interface IconProps extends SVGProps<SVGSVGElement> {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSvgProps: SVGProps<SVGSVGElement> = {
|
||||||
|
xmlns: 'http://www.w3.org/2000/svg',
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
'aria-hidden': 'true',
|
||||||
|
focusable: 'false'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconSlidersHorizontal({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<line x1="21" x2="14" y1="4" y2="4" />
|
||||||
|
<line x1="10" x2="3" y1="4" y2="4" />
|
||||||
|
<line x1="21" x2="12" y1="12" y2="12" />
|
||||||
|
<line x1="8" x2="3" y1="12" y2="12" />
|
||||||
|
<line x1="21" x2="16" y1="20" y2="20" />
|
||||||
|
<line x1="12" x2="3" y1="20" y2="20" />
|
||||||
|
<line x1="14" x2="14" y1="2" y2="6" />
|
||||||
|
<line x1="8" x2="8" y1="10" y2="14" />
|
||||||
|
<line x1="16" x2="16" y1="18" y2="22" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconKey({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" />
|
||||||
|
<path d="m21 2-9.6 9.6" />
|
||||||
|
<circle cx="7.5" cy="15.5" r="5.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconBot({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M12 8V4H8" />
|
||||||
|
<rect width="16" height="12" x="4" y="8" rx="2" />
|
||||||
|
<path d="M2 14h2" />
|
||||||
|
<path d="M20 14h2" />
|
||||||
|
<path d="M15 13v2" />
|
||||||
|
<path d="M9 13v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconFileText({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||||
|
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||||
|
<path d="M10 9H8" />
|
||||||
|
<path d="M16 13H8" />
|
||||||
|
<path d="M16 17H8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconShield({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChartLine({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
|
||||||
|
<path d="m19 9-5 5-4-4-3 3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSettings({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconScrollText({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M15 12h-5" />
|
||||||
|
<path d="M15 8h-5" />
|
||||||
|
<path d="M19 17V5a2 2 0 0 0-2-2H4" />
|
||||||
|
<path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconInfo({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 16v-4" />
|
||||||
|
<path d="M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconRefreshCw({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||||
|
<path d="M21 3v5h-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconDownload({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M12 15V3" />
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<path d="m7 10 5 5 5-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconTrash2({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
<line x1="10" x2="10" y1="11" y2="17" />
|
||||||
|
<line x1="14" x2="14" y1="11" y2="17" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChevronUp({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="m18 15-6-6-6 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChevronDown({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChevronLeft({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSearch({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="m21 21-4.34-4.34" />
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconX({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCheck({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconEye({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconEyeOff({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
|
||||||
|
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
||||||
|
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
|
||||||
|
<path d="m2 2 20 20" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconInbox({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||||
|
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSatellite({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="m13.5 6.5-3.148-3.148a1.205 1.205 0 0 0-1.704 0L6.352 5.648a1.205 1.205 0 0 0 0 1.704L9.5 10.5" />
|
||||||
|
<path d="M16.5 7.5 19 5" />
|
||||||
|
<path d="m17.5 10.5 3.148 3.148a1.205 1.205 0 0 1 0 1.704l-2.296 2.296a1.205 1.205 0 0 1-1.704 0L13.5 14.5" />
|
||||||
|
<path d="M9 21a6 6 0 0 0-6-6" />
|
||||||
|
<path d="M9.352 10.648a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l4.296-4.296a1.205 1.205 0 0 0 0-1.704l-2.296-2.296a1.205 1.205 0 0 0-1.704 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconDiamond({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41l-7.59-7.59a2.41 2.41 0 0 0-3.41 0Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconTimer({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<line x1="10" x2="14" y1="2" y2="2" />
|
||||||
|
<line x1="12" x2="15" y1="14" y2="11" />
|
||||||
|
<circle cx="12" cy="14" r="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconTrendingUp({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M16 7h6v6" />
|
||||||
|
<path d="m22 7-8.5 8.5-5-5L2 17" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconDollarSign({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<line x1="12" x2="12" y1="2" y2="22" />
|
||||||
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconGithub({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
|
||||||
|
<path d="M9 18c-4.51 2-5-2-7-2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconExternalLink({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M15 3h6v6" />
|
||||||
|
<path d="M10 14 21 3" />
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconBookOpen({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M12 7v14" />
|
||||||
|
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCode({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<polyline points="16 18 22 12 16 6" />
|
||||||
|
<polyline points="8 6 2 12 8 18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<rect width="7" height="9" x="3" y="3" rx="1" />
|
||||||
|
<rect width="7" height="5" x="14" y="3" rx="1" />
|
||||||
|
<rect width="7" height="9" x="14" y="12" rx="1" />
|
||||||
|
<rect width="7" height="5" x="3" y="16" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/ui/modelInputListUtils.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ModelAlias } from '@/types';
|
||||||
|
|
||||||
|
export interface ModelEntry {
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
|
||||||
|
if (!Array.isArray(models) || models.length === 0) {
|
||||||
|
return [{ name: '', alias: '' }];
|
||||||
|
}
|
||||||
|
return models.map((model) => ({
|
||||||
|
name: model.name || '',
|
||||||
|
alias: model.alias || ''
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.name.trim())
|
||||||
|
.map((entry) => {
|
||||||
|
const model: ModelAlias = { name: entry.name.trim() };
|
||||||
|
const alias = entry.alias.trim();
|
||||||
|
if (alias && alias !== model.name) {
|
||||||
|
model.alias = alias;
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
});
|
||||||
|
};
|
||||||
79
src/components/usage/ApiDetailsCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface ApiDetailsCardProps {
|
||||||
|
apiStats: ApiStats[];
|
||||||
|
loading: boolean;
|
||||||
|
hasPrices: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleExpand = (endpoint: string) => {
|
||||||
|
setExpandedApis((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(endpoint)) {
|
||||||
|
newSet.delete(endpoint);
|
||||||
|
} else {
|
||||||
|
newSet.add(endpoint);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('usage_stats.api_details')}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : apiStats.length > 0 ? (
|
||||||
|
<div className={styles.apiList}>
|
||||||
|
{apiStats.map((api) => (
|
||||||
|
<div key={api.endpoint} className={styles.apiItem}>
|
||||||
|
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
|
||||||
|
<div className={styles.apiInfo}>
|
||||||
|
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||||
|
<div className={styles.apiStats}>
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
{t('usage_stats.requests_count')}: {api.totalRequests}
|
||||||
|
</span>
|
||||||
|
<span className={styles.apiBadge}>
|
||||||
|
Tokens: {formatTokensInMillions(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}>
|
||||||
|
{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}>
|
||||||
|
{stats.requests} {t('usage_stats.requests_count')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/usage/ChartLineSelector.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface ChartLineSelectorProps {
|
||||||
|
chartLines: string[];
|
||||||
|
modelNames: string[];
|
||||||
|
maxLines?: number;
|
||||||
|
onChange: (lines: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartLineSelector({
|
||||||
|
chartLines,
|
||||||
|
modelNames,
|
||||||
|
maxLines = 9,
|
||||||
|
onChange
|
||||||
|
}: ChartLineSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (chartLines.length >= maxLines) return;
|
||||||
|
const unusedModel = modelNames.find((m) => !chartLines.includes(m));
|
||||||
|
if (unusedModel) {
|
||||||
|
onChange([...chartLines, unusedModel]);
|
||||||
|
} else {
|
||||||
|
onChange([...chartLines, 'all']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
if (chartLines.length <= 1) return;
|
||||||
|
const newLines = [...chartLines];
|
||||||
|
newLines.splice(index, 1);
|
||||||
|
onChange(newLines);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (index: number, value: string) => {
|
||||||
|
const newLines = [...chartLines];
|
||||||
|
newLines[index] = value;
|
||||||
|
onChange(newLines);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t('usage_stats.chart_line_actions_label')}
|
||||||
|
extra={
|
||||||
|
<div className={styles.chartLineHeader}>
|
||||||
|
<span className={styles.chartLineCount}>
|
||||||
|
{chartLines.length}/{maxLines}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={chartLines.length >= maxLines}
|
||||||
|
>
|
||||||
|
{t('usage_stats.chart_line_add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.chartLineList}>
|
||||||
|
{chartLines.map((line, index) => (
|
||||||
|
<div key={index} className={styles.chartLineItem}>
|
||||||
|
<span className={styles.chartLineLabel}>
|
||||||
|
{t(`usage_stats.chart_line_label_${index + 1}`)}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={line}
|
||||||
|
onChange={(e) => handleChange(index, e.target.value)}
|
||||||
|
className={styles.select}
|
||||||
|
>
|
||||||
|
<option value="all">{t('usage_stats.chart_line_all')}</option>
|
||||||
|
{modelNames.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{chartLines.length > 1 && (
|
||||||
|
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
|
||||||
|
{t('usage_stats.chart_line_delete')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/usage/ModelStatsCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface ModelStat {
|
||||||
|
model: string;
|
||||||
|
requests: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
tokens: number;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelStatsCardProps {
|
||||||
|
modelStats: ModelStat[];
|
||||||
|
loading: boolean;
|
||||||
|
hasPrices: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('usage_stats.models')}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.hint}>{t('common.loading')}</div>
|
||||||
|
) : modelStats.length > 0 ? (
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('usage_stats.model_name')}</th>
|
||||||
|
<th>{t('usage_stats.requests_count')}</th>
|
||||||
|
<th>{t('usage_stats.tokens_count')}</th>
|
||||||
|
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{modelStats.map((stat) => (
|
||||||
|
<tr key={stat.model}>
|
||||||
|
<td className={styles.modelCell}>{stat.model}</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.requestCountCell}>
|
||||||
|
<span>{stat.requests.toLocaleString()}</span>
|
||||||
|
<span className={styles.requestBreakdown}>
|
||||||
|
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||||
|
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||||
|
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/components/usage/PriceSettingsCard.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import type { ModelPrice } from '@/utils/usage';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface PriceSettingsCardProps {
|
||||||
|
modelNames: string[];
|
||||||
|
modelPrices: Record<string, ModelPrice>;
|
||||||
|
onPricesChange: (prices: Record<string, ModelPrice>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceSettingsCard({
|
||||||
|
modelNames,
|
||||||
|
modelPrices,
|
||||||
|
onPricesChange
|
||||||
|
}: PriceSettingsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
|
const [promptPrice, setPromptPrice] = useState('');
|
||||||
|
const [completionPrice, setCompletionPrice] = useState('');
|
||||||
|
const [cachePrice, setCachePrice] = useState('');
|
||||||
|
|
||||||
|
const handleSavePrice = () => {
|
||||||
|
if (!selectedModel) return;
|
||||||
|
const prompt = parseFloat(promptPrice) || 0;
|
||||||
|
const completion = parseFloat(completionPrice) || 0;
|
||||||
|
const cache = cachePrice.trim() === '' ? prompt : parseFloat(cachePrice) || 0;
|
||||||
|
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache } };
|
||||||
|
onPricesChange(newPrices);
|
||||||
|
setSelectedModel('');
|
||||||
|
setPromptPrice('');
|
||||||
|
setCompletionPrice('');
|
||||||
|
setCachePrice('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePrice = (model: string) => {
|
||||||
|
const newPrices = { ...modelPrices };
|
||||||
|
delete newPrices[model];
|
||||||
|
onPricesChange(newPrices);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditPrice = (model: string) => {
|
||||||
|
const price = modelPrices[model];
|
||||||
|
setSelectedModel(model);
|
||||||
|
setPromptPrice(price?.prompt?.toString() || '');
|
||||||
|
setCompletionPrice(price?.completion?.toString() || '');
|
||||||
|
setCachePrice(price?.cache?.toString() || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelSelect = (value: string) => {
|
||||||
|
setSelectedModel(value);
|
||||||
|
const price = modelPrices[value];
|
||||||
|
if (price) {
|
||||||
|
setPromptPrice(price.prompt.toString());
|
||||||
|
setCompletionPrice(price.completion.toString());
|
||||||
|
setCachePrice(price.cache.toString());
|
||||||
|
} else {
|
||||||
|
setPromptPrice('');
|
||||||
|
setCompletionPrice('');
|
||||||
|
setCachePrice('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={t('usage_stats.model_price_settings')}>
|
||||||
|
<div className={styles.pricingSection}>
|
||||||
|
{/* Price Form */}
|
||||||
|
<div className={styles.priceForm}>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_name')}</label>
|
||||||
|
<select
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => handleModelSelect(e.target.value)}
|
||||||
|
className={styles.select}
|
||||||
|
>
|
||||||
|
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
|
||||||
|
{modelNames.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formField}>
|
||||||
|
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={promptPrice}
|
||||||
|
onChange={(e) => setPromptPrice(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={completionPrice}
|
||||||
|
onChange={(e) => setCompletionPrice(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={cachePrice}
|
||||||
|
onChange={(e) => setCachePrice(e.target.value)}
|
||||||
|
placeholder="0.00"
|
||||||
|
step="0.0001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={handleSavePrice} disabled={!selectedModel}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saved Prices List */}
|
||||||
|
<div className={styles.pricesList}>
|
||||||
|
<h4 className={styles.pricesTitle}>{t('usage_stats.saved_prices')}</h4>
|
||||||
|
{Object.keys(modelPrices).length > 0 ? (
|
||||||
|
<div className={styles.pricesGrid}>
|
||||||
|
{Object.entries(modelPrices).map(([model, price]) => (
|
||||||
|
<div key={model} className={styles.priceItem}>
|
||||||
|
<div className={styles.priceInfo}>
|
||||||
|
<span className={styles.priceModel}>{model}</span>
|
||||||
|
<div className={styles.priceMeta}>
|
||||||
|
<span>
|
||||||
|
{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.priceActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
|
||||||
|
{t('common.edit')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.hint}>{t('usage_stats.model_price_empty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/components/usage/StatCards.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||||
|
import {
|
||||||
|
formatTokensInMillions,
|
||||||
|
formatPerMinuteValue,
|
||||||
|
formatUsd,
|
||||||
|
calculateTokenBreakdown,
|
||||||
|
calculateRecentPerMinuteRates,
|
||||||
|
calculateTotalCost,
|
||||||
|
type ModelPrice
|
||||||
|
} from '@/utils/usage';
|
||||||
|
import { sparklineOptions } from '@/utils/usage/chartConfig';
|
||||||
|
import type { UsagePayload } from './hooks/useUsageData';
|
||||||
|
import type { SparklineBundle } from './hooks/useSparklines';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
interface StatCardData {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
accent: string;
|
||||||
|
accentSoft: string;
|
||||||
|
accentBorder: string;
|
||||||
|
value: string;
|
||||||
|
meta?: ReactNode;
|
||||||
|
trend: SparklineBundle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatCardsProps {
|
||||||
|
usage: UsagePayload | null;
|
||||||
|
loading: boolean;
|
||||||
|
modelPrices: Record<string, ModelPrice>;
|
||||||
|
sparklines: {
|
||||||
|
requests: SparklineBundle | null;
|
||||||
|
tokens: SparklineBundle | null;
|
||||||
|
rpm: SparklineBundle | null;
|
||||||
|
tpm: SparklineBundle | null;
|
||||||
|
cost: SparklineBundle | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCards({ usage, loading, modelPrices, sparklines }: StatCardsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||||
|
const rateStats = usage
|
||||||
|
? calculateRecentPerMinuteRates(30, usage)
|
||||||
|
: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 };
|
||||||
|
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
|
||||||
|
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||||
|
|
||||||
|
const statsCards: StatCardData[] = [
|
||||||
|
{
|
||||||
|
key: 'requests',
|
||||||
|
label: t('usage_stats.total_requests'),
|
||||||
|
icon: <IconSatellite size={16} />,
|
||||||
|
accent: '#3b82f6',
|
||||||
|
accentSoft: 'rgba(59, 130, 246, 0.18)',
|
||||||
|
accentBorder: 'rgba(59, 130, 246, 0.35)',
|
||||||
|
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
|
||||||
|
meta: (
|
||||||
|
<>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
<span className={styles.statMetaDot} style={{ backgroundColor: '#10b981' }} />
|
||||||
|
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
|
||||||
|
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
trend: sparklines.requests
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tokens',
|
||||||
|
label: t('usage_stats.total_tokens'),
|
||||||
|
icon: <IconDiamond size={16} />,
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
||||||
|
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
||||||
|
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
|
||||||
|
meta: (
|
||||||
|
<>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
trend: sparklines.tokens
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rpm',
|
||||||
|
label: t('usage_stats.rpm_30m'),
|
||||||
|
icon: <IconTimer size={16} />,
|
||||||
|
accent: '#22c55e',
|
||||||
|
accentSoft: 'rgba(34, 197, 94, 0.18)',
|
||||||
|
accentBorder: 'rgba(34, 197, 94, 0.32)',
|
||||||
|
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
|
||||||
|
meta: (
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
trend: sparklines.rpm
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tpm',
|
||||||
|
label: t('usage_stats.tpm_30m'),
|
||||||
|
icon: <IconTrendingUp size={16} />,
|
||||||
|
accent: '#f97316',
|
||||||
|
accentSoft: 'rgba(249, 115, 22, 0.18)',
|
||||||
|
accentBorder: 'rgba(249, 115, 22, 0.32)',
|
||||||
|
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||||
|
meta: (
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
trend: sparklines.tpm
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cost',
|
||||||
|
label: t('usage_stats.total_cost'),
|
||||||
|
icon: <IconDollarSign size={16} />,
|
||||||
|
accent: '#f59e0b',
|
||||||
|
accentSoft: 'rgba(245, 158, 11, 0.18)',
|
||||||
|
accentBorder: 'rgba(245, 158, 11, 0.32)',
|
||||||
|
value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--',
|
||||||
|
meta: (
|
||||||
|
<>
|
||||||
|
<span className={styles.statMetaItem}>
|
||||||
|
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
|
||||||
|
</span>
|
||||||
|
{!hasPrices && (
|
||||||
|
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||||
|
{t('usage_stats.cost_need_price')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
trend: hasPrices ? sparklines.cost : null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
{statsCards.map((card) => (
|
||||||
|
<div
|
||||||
|
key={card.key}
|
||||||
|
className={styles.statCard}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--accent': card.accent,
|
||||||
|
'--accent-soft': card.accentSoft,
|
||||||
|
'--accent-border': card.accentBorder
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.statCardHeader}>
|
||||||
|
<div className={styles.statLabelGroup}>
|
||||||
|
<span className={styles.statLabel}>{card.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.statIconBadge}>{card.icon}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statValue}>{card.value}</div>
|
||||||
|
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
|
||||||
|
<div className={styles.statTrend}>
|
||||||
|
{card.trend ? (
|
||||||
|
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.statTrendPlaceholder}></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/usage/UsageChart.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ChartOptions } from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { ChartData } from '@/utils/usage';
|
||||||
|
import { getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||||
|
import styles from '@/pages/UsagePage.module.scss';
|
||||||
|
|
||||||
|
export interface UsageChartProps {
|
||||||
|
title: string;
|
||||||
|
period: 'hour' | 'day';
|
||||||
|
onPeriodChange: (period: 'hour' | 'day') => void;
|
||||||
|
chartData: ChartData;
|
||||||
|
chartOptions: ChartOptions<'line'>;
|
||||||
|
loading: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
emptyText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageChart({
|
||||||
|
title,
|
||||||
|
period,
|
||||||
|
onPeriodChange,
|
||||||
|
chartData,
|
||||||
|
chartOptions,
|
||||||
|
loading,
|
||||||
|
isMobile,
|
||||||
|
emptyText
|
||||||
|
}: UsageChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={title}
|
||||||
|
extra={
|
||||||
|
<div className={styles.periodButtons}>
|
||||||
|
<Button
|
||||||
|
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPeriodChange('hour')}
|
||||||
|
>
|
||||||
|
{t('usage_stats.by_hour')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPeriodChange('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}>{emptyText}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/components/usage/hooks/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { useUsageData } from './useUsageData';
|
||||||
|
export type { UsagePayload, UseUsageDataReturn } from './useUsageData';
|
||||||
|
|
||||||
|
export { useSparklines } from './useSparklines';
|
||||||
|
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './useSparklines';
|
||||||
|
|
||||||
|
export { useChartData } from './useChartData';
|
||||||
|
export type { UseChartDataOptions, UseChartDataReturn } from './useChartData';
|
||||||