Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1007dbbf44 | ||
|
|
3046375b3c | ||
|
|
48956aa0a7 | ||
|
|
d3db2680cf |
@@ -1,20 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: { browser: true, es2020: true },
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
],
|
|
||||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
plugins: ['react-refresh'],
|
|
||||||
rules: {
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
|
||||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
35
.github/workflows/release.yml
vendored
@@ -15,9 +15,6 @@ 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
|
||||||
@@ -39,25 +36,27 @@ 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_path: release-notes.md
|
body: |
|
||||||
|
## 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:
|
||||||
|
|||||||
54
.gitignore
vendored
@@ -1,32 +1,28 @@
|
|||||||
# Logs
|
# Node modules
|
||||||
logs
|
node_modules/
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
api.md
|
|
||||||
usage.json
|
|
||||||
CLAUDE.md
|
|
||||||
AGENTS.md
|
|
||||||
antigravity_usage.json
|
|
||||||
codex_usage.json
|
|
||||||
style.md
|
|
||||||
|
|
||||||
node_modules
|
# Build output
|
||||||
dist
|
dist/
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
# Temporary build files
|
||||||
settings.local.json
|
index.build.html
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
# npm lock files
|
||||||
.idea
|
package-lock.json
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
Thumbs.db
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
CLAUDE.md
|
||||||
*.sln
|
.claude
|
||||||
*.sw?
|
AGENTS.md
|
||||||
|
.codex
|
||||||
|
.serena
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"singleQuote": true,
|
|
||||||
"printWidth": 100,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"arrowParens": "always"
|
|
||||||
}
|
|
||||||
69
BUILD_RELEASE.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 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
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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,130 +1,107 @@
|
|||||||
# 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 version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
|
Since 6.0.19 the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
|
||||||
|
|
||||||
## What this is (and isn’t)
|
## Features
|
||||||
|
|
||||||
- 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.
|
### Capabilities
|
||||||
- It is **not** a proxy and does not forward traffic.
|
- **Login & UX**: Auto-detects the current address (manual override/reset supported), encrypted auto-login, language/theme toggles, responsive layout with mobile sidebar.
|
||||||
|
- **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.
|
||||||
|
|
||||||
## Quick start
|
## How to Use
|
||||||
|
|
||||||
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
|
1) **After CLI Proxy API is running (recommended)**
|
||||||
|
Visit `http://your-server:8317/management.html`.
|
||||||
|
|
||||||
1. Start your CLI Proxy API service.
|
2) **Direct static use after build**
|
||||||
2. Open: `http://<host>:<api_port>/management.html`
|
The single file `dist/index.html` generated by `npm run build`
|
||||||
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 run dev
|
npm start # http://localhost:3000
|
||||||
|
npm run dev # optional dev port: 3090
|
||||||
|
# or
|
||||||
|
python -m http.server 8000
|
||||||
```
|
```
|
||||||
|
Then open the corresponding localhost URL.
|
||||||
|
|
||||||
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
|
4) **Configure connection**
|
||||||
|
The login page shows the detected address; you can override it, enter the management key, and click Connect. Saved credentials use encrypted local storage for auto-login.
|
||||||
|
|
||||||
### Option C: Build a single HTML file
|
Tip: The Logs navigation item appears after enabling "Logging to file" in Basic Settings.
|
||||||
|
|
||||||
```bash
|
## Tech Stack
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
- Output: `dist/index.html` (all assets are inlined).
|
- **Frontend**: Plain HTML, CSS, JavaScript (ES6+)
|
||||||
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
|
- **Styling**: CSS3 + Flexbox/Grid with CSS Variables
|
||||||
- To preview locally: `npm run preview`
|
- **Icons**: Font Awesome 6.4.0
|
||||||
|
- **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
|
||||||
|
|
||||||
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
|
## Build & Development
|
||||||
|
|
||||||
## Connecting to the server
|
- `npm run build` bundles everything into `dist/index.html` via webpack (`build.cjs`, `bundle-entry.js`, `build-scripts/prepare-html.js`).
|
||||||
|
- External CDNs remain for Font Awesome, Chart.js, and CodeMirror to keep the bundle lean.
|
||||||
### API address
|
- Development servers: `npm start` (3000) or `npm run dev` (3090); Python `http.server` also works for static hosting.
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
- **Can’t connect / 401**: confirm the API address and management key; remote access may require enabling remote management in the server config.
|
### Connection Issues
|
||||||
- **Repeated auth failures**: the server may temporarily block remote IPs.
|
1. Confirm that the CLI Proxy API service is running.
|
||||||
- **Logs page missing**: enable “Logging to file” in Basic Settings; the navigation item is shown only when file logging is enabled.
|
2. Check if the API address is correct.
|
||||||
- **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).
|
3. Verify that the management key is valid.
|
||||||
- **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.
|
4. Ensure your firewall settings allow the connection.
|
||||||
|
|
||||||
## Development
|
### Data Not Updating
|
||||||
|
1. Click the "Refresh All" button.
|
||||||
|
2. Check your network connection.
|
||||||
|
3. Check the browser's console for any error messages.
|
||||||
|
|
||||||
```bash
|
### Logs & Config Editor
|
||||||
npm run dev # Vite dev server
|
- Logs: Requires server-side logging-to-file; 404 indicates the server build is too old or logging is disabled.
|
||||||
npm run build # tsc + Vite build
|
- Config editor: Requires `/config.yaml` endpoint; keep YAML valid before saving.
|
||||||
npm run preview # serve dist locally
|
|
||||||
npm run lint # ESLint (fails on warnings)
|
### Usage Stats
|
||||||
npm run format # Prettier
|
- Enable "Usage statistics" if charts stay empty; data resets on server restart.
|
||||||
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!
|
||||||
|
|
||||||
Issues and PRs are welcome. Please include:
|
This project is licensed under the MIT License.
|
||||||
|
|
||||||
- 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,130 +1,106 @@
|
|||||||
# CLI Proxy API 管理中心
|
# Cli-Proxy-API-Management-Center
|
||||||
|
这是一个用于管理 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)
|
||||||
|
|
||||||
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
|
自 6.0.19 起 WebUI 已集成到主程序中,启动后可通过 `/management.html` 访问。
|
||||||
|
|
||||||
## 这是什么(以及不是什么)
|
## 功能特点
|
||||||
|
|
||||||
- 本仓库只包含 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 版本。
|
||||||
|
- **安全与偏好**: 密钥遮蔽、加密本地存储,主题/语言/侧边栏状态持久化,实时状态反馈。
|
||||||
|
|
||||||
## 快速开始
|
## 使用方法
|
||||||
|
|
||||||
### 方式 A:使用 CLIProxyAPI 自带的 WebUI(推荐)
|
1) **主程序启动后使用(推荐)**
|
||||||
|
访问 `http://您的服务器:8317/management.html`。
|
||||||
|
|
||||||
1. 启动 CLI Proxy API 服务。
|
2) **构建后直接静态打开**
|
||||||
2. 打开:`http://<host>:<api_port>/management.html`
|
`npm run build` 生成的 `dist/index.html` 单文件
|
||||||
3. 输入 **管理密钥** 并连接。
|
|
||||||
|
|
||||||
页面会根据当前地址自动推断 API 地址,也支持手动修改。
|
|
||||||
|
|
||||||
### 方式 B:开发调试
|
|
||||||
|
|
||||||
|
3) **本地服务器**
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm start # 默认 http://localhost:3000
|
||||||
|
npm run dev # 可选开发端口 3090
|
||||||
|
# 或
|
||||||
|
python -m http.server 8000
|
||||||
```
|
```
|
||||||
|
然后在浏览器打开对应的 localhost 地址。
|
||||||
|
|
||||||
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
|
4) **配置连接**
|
||||||
|
登录页会显示自动检测的地址,可自行修改,填入管理密钥后点击连接。凭据将加密保存以便下次自动登录。
|
||||||
|
|
||||||
### 方式 C:构建单文件 HTML
|
提示: 开启“写入日志文件”后才会显示“日志查看”导航。
|
||||||
|
|
||||||
```bash
|
## 技术栈
|
||||||
npm install
|
|
||||||
npm run build
|
- **前端**: 纯 HTML、CSS、JavaScript (ES6+)
|
||||||
|
- **样式**: 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
|
||||||
- 构建产物:`dist/index.html`(资源已全部内联)。
|
├── styles.css
|
||||||
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`。
|
├── app.js
|
||||||
- 本地预览:`npm run preview`
|
├── i18n.js
|
||||||
|
├── src/ # 核心/模块/工具源码
|
||||||
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
|
├── build.cjs # Webpack 构建脚本
|
||||||
|
├── bundle-entry.js # 打包入口
|
||||||
## 连接说明
|
├── build-scripts/ # 构建工具
|
||||||
|
│ └── prepare-html.js
|
||||||
### API 地址怎么填
|
├── dist/ # 打包输出单文件
|
||||||
|
├── BUILD_RELEASE.md
|
||||||
以下格式均可,WebUI 会自动归一化:
|
├── LICENSE
|
||||||
|
├── README.md
|
||||||
- `localhost:8317`
|
└── README_CN.md
|
||||||
- `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 进行更新!
|
||||||
|
|
||||||
欢迎提 Issue 与 PR。建议附上:
|
本项目采用 MIT 许可。
|
||||||
|
|
||||||
- 复现步骤(服务端版本 + UI 版本)
|
|
||||||
- UI 改动截图
|
|
||||||
- 验证记录(`npm run lint`、`npm run type-check`)
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|||||||
30
build-scripts/prepare-html.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
'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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
25
bundle-entry.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// 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';
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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: '^_' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
1405
index.html
5373
package-lock.json
generated
77
package.json
@@ -1,46 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "cli-proxy-webui-react",
|
"name": "cli-proxy-api-webui",
|
||||||
"private": true,
|
"version": "1.0.0",
|
||||||
"version": "0.0.0",
|
"description": "CLI Proxy API 管理界面",
|
||||||
"type": "module",
|
"main": "index.html",
|
||||||
"scripts": {
|
"type": "module",
|
||||||
"dev": "vite",
|
"scripts": {
|
||||||
"build": "tsc && vite build",
|
"start": "npx serve .",
|
||||||
"preview": "vite preview",
|
"dev": "npx serve . -l 3090",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
|
"build": "node build.cjs",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
|
"lint": "echo '使用浏览器开发者工具检查代码'"
|
||||||
"type-check": "tsc --noEmit"
|
},
|
||||||
},
|
"keywords": [
|
||||||
"dependencies": {
|
"cli-proxy-api",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"webui",
|
||||||
"@uiw/react-codemirror": "^4.25.3",
|
"management",
|
||||||
"axios": "^1.13.2",
|
"api"
|
||||||
"chart.js": "^4.5.1",
|
],
|
||||||
"gsap": "^3.14.2",
|
"author": "CLI Proxy API WebUI",
|
||||||
"i18next": "^25.7.1",
|
"license": "MIT",
|
||||||
"react": "^19.2.1",
|
"devDependencies": {
|
||||||
"react-chartjs-2": "^5.3.1",
|
"serve": "^14.2.1"
|
||||||
"react-dom": "^19.2.1",
|
},
|
||||||
"react-i18next": "^16.4.0",
|
"engines": {
|
||||||
"react-router-dom": "^7.10.1",
|
"node": ">=14.0.0"
|
||||||
"zustand": "^5.0.9"
|
},
|
||||||
},
|
"repository": {
|
||||||
"devDependencies": {
|
"type": "git",
|
||||||
"@eslint/js": "^9.39.1",
|
"url": "local"
|
||||||
"@types/react": "^19.2.7",
|
}
|
||||||
"@types/react-dom": "^19.2.3",
|
}
|
||||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
|
||||||
"@typescript-eslint/parser": "^8.48.1",
|
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
|
||||||
"eslint": "^9.39.1",
|
|
||||||
"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
@@ -1,42 +0,0 @@
|
|||||||
#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
@@ -1,48 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 632 B |
@@ -1,28 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,25 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,25 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 3.5 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 756 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 706 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 711 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,61 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
@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;
|
|
||||||
|
|
||||||
// Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
|
|
||||||
// Older stacked layers remain `display: none` for performance.
|
|
||||||
&.page-transition__layer--stacked-keep {
|
|
||||||
display: flex;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
will-change: transform, opacity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--animating &__layer {
|
|
||||||
will-change: transform, opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
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 nextVariant: TransitionVariant = getTransitionVariant
|
|
||||||
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
|
||||||
: 'vertical';
|
|
||||||
|
|
||||||
let nextDirection: TransitionDirection =
|
|
||||||
fromIndex === null || toIndex === null || fromIndex === toIndex
|
|
||||||
? 'forward'
|
|
||||||
: toIndex > fromIndex
|
|
||||||
? 'forward'
|
|
||||||
: 'backward';
|
|
||||||
|
|
||||||
// When using iOS-style stacking, history POP within the same "section" can have equal route order.
|
|
||||||
// In that case, prefer treating navigation to an existing layer as a backward (pop) transition.
|
|
||||||
if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) {
|
|
||||||
nextDirection = 'backward';
|
|
||||||
}
|
|
||||||
|
|
||||||
transitionDirectionRef.current = nextDirection;
|
|
||||||
transitionVariantRef.current = nextVariant;
|
|
||||||
|
|
||||||
const shouldSkipExitLayer = (() => {
|
|
||||||
if (nextVariant !== '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,
|
|
||||||
layers,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 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' : ''}`}>
|
|
||||||
{(() => {
|
|
||||||
const currentIndex = layers.findIndex((layer) => layer.status === 'current');
|
|
||||||
const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex;
|
|
||||||
const keepStackedIndex = layers
|
|
||||||
.slice(0, resolvedCurrentIndex)
|
|
||||||
.map((layer, index) => ({ layer, index }))
|
|
||||||
.reverse()
|
|
||||||
.find(({ layer }) => layer.status === 'stacked')?.index;
|
|
||||||
|
|
||||||
return layers.map((layer, index) => {
|
|
||||||
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={layer.key}
|
|
||||||
className={[
|
|
||||||
'page-transition__layer',
|
|
||||||
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
|
||||||
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
|
||||||
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
aria-hidden={layer.status !== 'current'}
|
|
||||||
inert={layer.status !== 'current'}
|
|
||||||
ref={
|
|
||||||
layer.status === 'exiting'
|
|
||||||
? exitingLayerRef
|
|
||||||
: layer.status === 'current'
|
|
||||||
? currentLayerRef
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{render(layer.location)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,643 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
|
||||||
import { useConfigStore, useNotificationStore } from '@/stores';
|
|
||||||
import { ampcodeApi } from '@/services/api';
|
|
||||||
import type { AmpcodeConfig } from '@/types';
|
|
||||||
import { maskApiKey } from '@/utils/format';
|
|
||||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
|
|
||||||
import type { AmpcodeFormState } from '../types';
|
|
||||||
|
|
||||||
interface AmpcodeModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
disableControls: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onBusyChange?: (busy: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showNotification, showConfirmation } = useNotificationStore();
|
|
||||||
const config = useConfigStore((state) => state.config);
|
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
|
||||||
|
|
||||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const initializedRef = useRef(false);
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onBusyChange?.(loading || saving);
|
|
||||||
}, [loading, saving, onBusyChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
initializedRef.current = false;
|
|
||||||
setLoading(false);
|
|
||||||
setSaving(false);
|
|
||||||
setError('');
|
|
||||||
setLoaded(false);
|
|
||||||
setMappingsDirty(false);
|
|
||||||
setForm(buildAmpcodeFormState(null));
|
|
||||||
onBusyChange?.(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (initializedRef.current) return;
|
|
||||||
initializedRef.current = true;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setLoaded(false);
|
|
||||||
setMappingsDirty(false);
|
|
||||||
setError('');
|
|
||||||
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const ampcode = await ampcodeApi.getAmpcode();
|
|
||||||
setLoaded(true);
|
|
||||||
updateConfigValue('ampcode', ampcode);
|
|
||||||
clearCache('ampcode');
|
|
||||||
setForm(buildAmpcodeFormState(ampcode));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
|
||||||
|
|
||||||
const clearAmpcodeUpstreamApiKey = async () => {
|
|
||||||
showConfirmation({
|
|
||||||
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
|
|
||||||
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
|
|
||||||
variant: 'danger',
|
|
||||||
confirmText: t('common.confirm'),
|
|
||||||
onConfirm: async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await ampcodeApi.clearUpstreamApiKey();
|
|
||||||
const previous = config?.ampcode ?? {};
|
|
||||||
const next: AmpcodeConfig = { ...previous };
|
|
||||||
delete next.upstreamApiKey;
|
|
||||||
updateConfigValue('ampcode', next);
|
|
||||||
clearCache('ampcode');
|
|
||||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
setError(message);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const performSaveAmpcode = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const upstreamUrl = form.upstreamUrl.trim();
|
|
||||||
const overrideKey = form.upstreamApiKey.trim();
|
|
||||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
|
||||||
|
|
||||||
if (upstreamUrl) {
|
|
||||||
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
|
||||||
} else {
|
|
||||||
await ampcodeApi.clearUpstreamUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
|
||||||
|
|
||||||
if (loaded || mappingsDirty) {
|
|
||||||
if (modelMappings.length) {
|
|
||||||
await ampcodeApi.saveModelMappings(modelMappings);
|
|
||||||
} else {
|
|
||||||
await ampcodeApi.clearModelMappings();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrideKey) {
|
|
||||||
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = config?.ampcode ?? {};
|
|
||||||
const next: AmpcodeConfig = {
|
|
||||||
upstreamUrl: upstreamUrl || undefined,
|
|
||||||
forceModelMappings: form.forceModelMappings,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (previous.upstreamApiKey) {
|
|
||||||
next.upstreamApiKey = previous.upstreamApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(previous.modelMappings)) {
|
|
||||||
next.modelMappings = previous.modelMappings;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overrideKey) {
|
|
||||||
next.upstreamApiKey = overrideKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loaded || mappingsDirty) {
|
|
||||||
if (modelMappings.length) {
|
|
||||||
next.modelMappings = modelMappings;
|
|
||||||
} else {
|
|
||||||
delete next.modelMappings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConfigValue('ampcode', next);
|
|
||||||
clearCache('ampcode');
|
|
||||||
showNotification(t('notification.ampcode_updated'), 'success');
|
|
||||||
onClose();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
setError(message);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveAmpcode = async () => {
|
|
||||||
if (!loaded && mappingsDirty) {
|
|
||||||
showConfirmation({
|
|
||||||
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
|
||||||
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
|
||||||
variant: 'secondary', // Not dangerous, just a warning
|
|
||||||
confirmText: t('common.confirm'),
|
|
||||||
onConfirm: performSaveAmpcode,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await performSaveAmpcode();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('ai_providers.ampcode_modal_title')}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={saving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.ampcode_upstream_url_label')}
|
|
||||||
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
|
||||||
value={form.upstreamUrl}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
hint={t('ai_providers.ampcode_upstream_url_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.ampcode_upstream_api_key_label')}
|
|
||||||
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
|
|
||||||
type="password"
|
|
||||||
value={form.upstreamApiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: -8,
|
|
||||||
marginBottom: 12,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="hint" style={{ margin: 0 }}>
|
|
||||||
{t('ai_providers.ampcode_upstream_api_key_current', {
|
|
||||||
key: config?.ampcode?.upstreamApiKey
|
|
||||||
? maskApiKey(config.ampcode.upstreamApiKey)
|
|
||||||
: t('common.not_set'),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearAmpcodeUpstreamApiKey}
|
|
||||||
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
|
|
||||||
>
|
|
||||||
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<ToggleSwitch
|
|
||||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
|
||||||
checked={form.forceModelMappings}
|
|
||||||
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.mappingEntries}
|
|
||||||
onChange={(entries) => {
|
|
||||||
setMappingsDirty(true);
|
|
||||||
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
|
||||||
}}
|
|
||||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
|
||||||
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
|
||||||
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
|
||||||
disabled={loading || saving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export { AmpcodeSection } from './AmpcodeSection';
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): ProviderFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: [],
|
|
||||||
models: [],
|
|
||||||
excludedModels: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ClaudeModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: ClaudeModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.claude_edit_modal_title')
|
|
||||||
: t('ai_providers.claude_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_key_label')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_url_label')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.claude_add_modal_proxy_label')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.claude_models_label')}</label>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export { ClaudeSection } from './ClaudeSection';
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): ProviderFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: [],
|
|
||||||
models: [],
|
|
||||||
excludedModels: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function CodexModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: CodexModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.codex_edit_modal_title')
|
|
||||||
: t('ai_providers.codex_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_key_label')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_url_label')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.codex_add_modal_proxy_label')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export { CodexSection } from './CodexSection';
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import type { GeminiKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import { excludedModelsToText } from '../utils';
|
|
||||||
import type { GeminiFormState, ProviderModalProps } from '../types';
|
|
||||||
|
|
||||||
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): GeminiFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
headers: [],
|
|
||||||
excludedModels: [],
|
|
||||||
excludedText: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function GeminiModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: GeminiModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
void onSave(form, editIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.gemini_edit_modal_title')
|
|
||||||
: t('ai_providers.gemini_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.gemini_add_modal_key_label')}
|
|
||||||
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.gemini_base_url_label')}
|
|
||||||
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
|
||||||
<textarea
|
|
||||||
className="input"
|
|
||||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
|
||||||
value={form.excludedText}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export { GeminiSection } from './GeminiSection';
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { modelsApi } from '@/services/api';
|
|
||||||
import type { ApiKeyEntry } from '@/types';
|
|
||||||
import type { ModelInfo } from '@/utils/models';
|
|
||||||
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
|
||||||
import { buildOpenAIModelsEndpoint } from '../utils';
|
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
|
||||||
|
|
||||||
interface OpenAIDiscoveryModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
baseUrl: string;
|
|
||||||
headers: HeaderEntry[];
|
|
||||||
apiKeyEntries: ApiKeyEntry[];
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (selected: ModelInfo[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenAIDiscoveryModal({
|
|
||||||
isOpen,
|
|
||||||
baseUrl,
|
|
||||||
headers,
|
|
||||||
apiKeyEntries,
|
|
||||||
onClose,
|
|
||||||
onApply,
|
|
||||||
}: OpenAIDiscoveryModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [endpoint, setEndpoint] = useState('');
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredModels = useMemo(() => {
|
|
||||||
const filter = search.trim().toLowerCase();
|
|
||||||
if (!filter) return models;
|
|
||||||
return models.filter((model) => {
|
|
||||||
const name = (model.name || '').toLowerCase();
|
|
||||||
const alias = (model.alias || '').toLowerCase();
|
|
||||||
const desc = (model.description || '').toLowerCase();
|
|
||||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
|
||||||
});
|
|
||||||
}, [models, search]);
|
|
||||||
|
|
||||||
const fetchOpenaiModelDiscovery = useCallback(
|
|
||||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
|
||||||
const trimmedBaseUrl = baseUrl.trim();
|
|
||||||
if (!trimmedBaseUrl) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const headerObject = buildHeaderObject(headers);
|
|
||||||
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
|
||||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
|
||||||
const list = await modelsApi.fetchModelsViaApiCall(
|
|
||||||
trimmedBaseUrl,
|
|
||||||
hasAuthHeader ? undefined : firstKey,
|
|
||||||
headerObject
|
|
||||||
);
|
|
||||||
setModels(list);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (allowFallback) {
|
|
||||||
try {
|
|
||||||
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
|
|
||||||
setModels(list);
|
|
||||||
return;
|
|
||||||
} catch (fallbackErr: unknown) {
|
|
||||||
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
|
|
||||||
setModels([]);
|
|
||||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setModels([]);
|
|
||||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[apiKeyEntries, baseUrl, headers, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
|
|
||||||
setModels([]);
|
|
||||||
setSearch('');
|
|
||||||
setSelected(new Set());
|
|
||||||
setError('');
|
|
||||||
void fetchOpenaiModelDiscovery();
|
|
||||||
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
|
|
||||||
|
|
||||||
const toggleSelection = (name: string) => {
|
|
||||||
setSelected((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(name)) {
|
|
||||||
next.delete(name);
|
|
||||||
} else {
|
|
||||||
next.add(name);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApply = () => {
|
|
||||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
|
||||||
onApply(selectedModels);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('ai_providers.openai_models_fetch_title')}
|
|
||||||
width={720}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
|
||||||
{t('ai_providers.openai_models_fetch_back')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleApply} disabled={loading}>
|
|
||||||
{t('ai_providers.openai_models_fetch_apply')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="hint" style={{ marginBottom: 8 }}>
|
|
||||||
{t('ai_providers.openai_models_fetch_hint')}
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<input className="input" readOnly value={endpoint} />
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{t('ai_providers.openai_models_fetch_refresh')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_models_search_label')}
|
|
||||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
{loading ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
|
||||||
) : models.length === 0 ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
|
||||||
) : filteredModels.length === 0 ? (
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.modelDiscoveryList}>
|
|
||||||
{filteredModels.map((model) => {
|
|
||||||
const checked = selected.has(model.name);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={model.name}
|
|
||||||
className={`${styles.modelDiscoveryRow} ${checked ? styles.modelDiscoveryRowSelected : ''}`}
|
|
||||||
>
|
|
||||||
<input type="checkbox" checked={checked} onChange={() => toggleSelection(model.name)} />
|
|
||||||
<div className={styles.modelDiscoveryMeta}>
|
|
||||||
<div className={styles.modelDiscoveryName}>
|
|
||||||
{model.name}
|
|
||||||
{model.alias && <span className={styles.modelDiscoveryAlias}>{model.alias}</span>}
|
|
||||||
</div>
|
|
||||||
{model.description && (
|
|
||||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import { useNotificationStore } from '@/stores';
|
|
||||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
|
||||||
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
|
|
||||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
|
||||||
import type { ModelInfo } from '@/utils/models';
|
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
|
||||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
|
|
||||||
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
|
|
||||||
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
|
|
||||||
|
|
||||||
const OPENAI_TEST_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): OpenAIFormState => ({
|
|
||||||
name: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
headers: [],
|
|
||||||
apiKeyEntries: [buildApiKeyEntry()],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
testModel: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function OpenAIModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: OpenAIModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showNotification } = useNotificationStore();
|
|
||||||
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
|
|
||||||
const [discoveryOpen, setDiscoveryOpen] = useState(false);
|
|
||||||
const [testModel, setTestModel] = useState('');
|
|
||||||
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
|
||||||
const [testMessage, setTestMessage] = useState('');
|
|
||||||
|
|
||||||
const getErrorMessage = (err: unknown) => {
|
|
||||||
if (err instanceof Error) return err.message;
|
|
||||||
if (typeof err === 'string') return err;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableModels = useMemo(
|
|
||||||
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
|
|
||||||
[form.modelEntries]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialData) {
|
|
||||||
const modelEntries = modelsToEntries(initialData.models);
|
|
||||||
setForm({
|
|
||||||
name: initialData.name,
|
|
||||||
prefix: initialData.prefix ?? '',
|
|
||||||
baseUrl: initialData.baseUrl,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
testModel: initialData.testModel,
|
|
||||||
modelEntries,
|
|
||||||
apiKeyEntries: initialData.apiKeyEntries?.length
|
|
||||||
? initialData.apiKeyEntries
|
|
||||||
: [buildApiKeyEntry()],
|
|
||||||
});
|
|
||||||
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
|
||||||
const initialModel =
|
|
||||||
initialData.testModel && available.includes(initialData.testModel)
|
|
||||||
? initialData.testModel
|
|
||||||
: available[0] || '';
|
|
||||||
setTestModel(initialModel);
|
|
||||||
} else {
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
setTestModel('');
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (availableModels.length === 0) {
|
|
||||||
if (testModel) {
|
|
||||||
setTestModel('');
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!testModel || !availableModels.includes(testModel)) {
|
|
||||||
setTestModel(availableModels[0]);
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}
|
|
||||||
}, [availableModels, isOpen, testModel]);
|
|
||||||
|
|
||||||
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
|
||||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
|
||||||
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
|
||||||
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
|
||||||
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeEntry = (idx: number) => {
|
|
||||||
const next = list.filter((_, i) => i !== idx);
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addEntry = () => {
|
|
||||||
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="stack">
|
|
||||||
{list.map((entry, index) => (
|
|
||||||
<div key={index} className="item-row">
|
|
||||||
<div className="item-meta">
|
|
||||||
<Input
|
|
||||||
label={`${t('common.api_key')} #${index + 1}`}
|
|
||||||
value={entry.apiKey}
|
|
||||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('common.proxy_url')}
|
|
||||||
value={entry.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="item-actions">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeEntry(index)}
|
|
||||||
disabled={list.length <= 1 || isSaving}
|
|
||||||
>
|
|
||||||
{t('common.delete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
|
|
||||||
{t('ai_providers.openai_keys_add_btn')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openOpenaiModelDiscovery = () => {
|
|
||||||
const baseUrl = form.baseUrl.trim();
|
|
||||||
if (!baseUrl) {
|
|
||||||
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDiscoveryOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
|
|
||||||
if (!selectedModels.length) {
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedMap = new Map<string, ModelEntry>();
|
|
||||||
form.modelEntries.forEach((entry) => {
|
|
||||||
const name = entry.name.trim();
|
|
||||||
if (!name) return;
|
|
||||||
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
|
||||||
});
|
|
||||||
|
|
||||||
let addedCount = 0;
|
|
||||||
selectedModels.forEach((model) => {
|
|
||||||
const name = model.name.trim();
|
|
||||||
if (!name || mergedMap.has(name)) return;
|
|
||||||
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
|
||||||
addedCount += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const mergedEntries = Array.from(mergedMap.values());
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
setDiscoveryOpen(false);
|
|
||||||
if (addedCount > 0) {
|
|
||||||
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testOpenaiProviderConnection = async () => {
|
|
||||||
const baseUrl = form.baseUrl.trim();
|
|
||||||
if (!baseUrl) {
|
|
||||||
const message = t('notification.openai_test_url_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
|
|
||||||
if (!endpoint) {
|
|
||||||
const message = t('notification.openai_test_url_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
|
|
||||||
if (!firstKeyEntry) {
|
|
||||||
const message = t('notification.openai_test_key_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelName = testModel.trim() || availableModels[0] || '';
|
|
||||||
if (!modelName) {
|
|
||||||
const message = t('notification.openai_test_model_required');
|
|
||||||
setTestStatus('error');
|
|
||||||
setTestMessage(message);
|
|
||||||
showNotification(message, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const customHeaders = buildHeaderObject(form.headers);
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...customHeaders,
|
|
||||||
};
|
|
||||||
if (!headers.Authorization && !headers['authorization']) {
|
|
||||||
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('loading');
|
|
||||||
setTestMessage(t('ai_providers.openai_test_running'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiCallApi.request(
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
url: endpoint,
|
|
||||||
header: Object.keys(headers).length ? headers : undefined,
|
|
||||||
data: JSON.stringify({
|
|
||||||
model: modelName,
|
|
||||||
messages: [{ role: 'user', content: 'Hi' }],
|
|
||||||
stream: false,
|
|
||||||
max_tokens: 5,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
||||||
throw new Error(getApiCallErrorMessage(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestStatus('success');
|
|
||||||
setTestMessage(t('ai_providers.openai_test_success'));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setTestStatus('error');
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
const errorCode =
|
|
||||||
typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: string }).code) : '';
|
|
||||||
const isTimeout =
|
|
||||||
errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
|
||||||
if (isTimeout) {
|
|
||||||
setTestMessage(t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }));
|
|
||||||
} else {
|
|
||||||
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.openai_edit_modal_title')
|
|
||||||
: t('ai_providers.openai_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_add_modal_name_label')}
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.openai_add_modal_url_label')}
|
|
||||||
value={form.baseUrl}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>
|
|
||||||
{editIndex !== null
|
|
||||||
? t('ai_providers.openai_edit_modal_models_label')
|
|
||||||
: t('ai_providers.openai_add_modal_models_label')}
|
|
||||||
</label>
|
|
||||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.openai_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
|
|
||||||
{t('ai_providers.openai_models_fetch_button')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_test_title')}</label>
|
|
||||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
||||||
<select
|
|
||||||
className={`input ${styles.openaiTestSelect}`}
|
|
||||||
value={testModel}
|
|
||||||
onChange={(e) => {
|
|
||||||
setTestModel(e.target.value);
|
|
||||||
setTestStatus('idle');
|
|
||||||
setTestMessage('');
|
|
||||||
}}
|
|
||||||
disabled={isSaving || availableModels.length === 0}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{availableModels.length
|
|
||||||
? t('ai_providers.openai_test_select_placeholder')
|
|
||||||
: t('ai_providers.openai_test_select_empty')}
|
|
||||||
</option>
|
|
||||||
{form.modelEntries
|
|
||||||
.filter((entry) => entry.name.trim())
|
|
||||||
.map((entry, idx) => {
|
|
||||||
const name = entry.name.trim();
|
|
||||||
const alias = entry.alias.trim();
|
|
||||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
|
||||||
return (
|
|
||||||
<option key={`${name}-${idx}`} value={name}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
|
||||||
className={`${styles.openaiTestButton} ${testStatus === 'success' ? styles.openaiTestButtonSuccess : ''}`}
|
|
||||||
onClick={testOpenaiProviderConnection}
|
|
||||||
loading={testStatus === 'loading'}
|
|
||||||
disabled={isSaving || availableModels.length === 0}
|
|
||||||
>
|
|
||||||
{t('ai_providers.openai_test_action')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{testMessage && (
|
|
||||||
<div
|
|
||||||
className={`status-badge ${
|
|
||||||
testStatus === 'error' ? 'error' : testStatus === 'success' ? 'success' : 'muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{testMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
|
||||||
{renderKeyEntries(form.apiKeyEntries)}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<OpenAIDiscoveryModal
|
|
||||||
isOpen={discoveryOpen}
|
|
||||||
baseUrl={form.baseUrl}
|
|
||||||
headers={form.headers}
|
|
||||||
apiKeyEntries={form.apiKeyEntries}
|
|
||||||
onClose={() => setDiscoveryOpen(false)}
|
|
||||||
onApply={applyOpenaiModelDiscoverySelection}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export { OpenAISection } from './OpenAISection';
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
@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 {
|
|
||||||
top: auto;
|
|
||||||
right: auto;
|
|
||||||
left: 50%;
|
|
||||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: min(520px, calc(100vw - 24px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.navList {
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.navItem {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 999px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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;
|
|
||||||
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
|
|
||||||
|
|
||||||
export function ProviderNav() {
|
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
|
||||||
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
|
||||||
const contentScrollerRef = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const getHeaderHeight = useCallback(() => {
|
|
||||||
const header = document.querySelector('.main-header') as HTMLElement | null;
|
|
||||||
if (header) return header.getBoundingClientRect().height;
|
|
||||||
|
|
||||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height');
|
|
||||||
const value = Number.parseFloat(raw);
|
|
||||||
return Number.isFinite(value) ? value : 0;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getContentScroller = useCallback(() => {
|
|
||||||
if (contentScrollerRef.current && document.contains(contentScrollerRef.current)) {
|
|
||||||
return contentScrollerRef.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.querySelector('.content') as HTMLElement | null;
|
|
||||||
contentScrollerRef.current = container;
|
|
||||||
return container;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getScrollContainer = useCallback((): ScrollContainer => {
|
|
||||||
// Mobile layout uses document scroll (layout switches at 768px); desktop uses the `.content` scroller.
|
|
||||||
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
|
||||||
if (isMobile) return window;
|
|
||||||
return getContentScroller() ?? window;
|
|
||||||
}, [getContentScroller]);
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
const container = getScrollContainer();
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const isElementScroller = container instanceof HTMLElement;
|
|
||||||
const headerHeight = isElementScroller ? 0 : getHeaderHeight();
|
|
||||||
const containerTop = isElementScroller ? container.getBoundingClientRect().top : 0;
|
|
||||||
const activationLine = containerTop + headerHeight + 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);
|
|
||||||
}, [getHeaderHeight, getScrollContainer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const contentScroller = getContentScroller();
|
|
||||||
|
|
||||||
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
||||||
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
|
||||||
window.addEventListener('resize', handleScroll);
|
|
||||||
handleScroll();
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', handleScroll);
|
|
||||||
window.removeEventListener('resize', handleScroll);
|
|
||||||
contentScroller?.removeEventListener('scroll', handleScroll);
|
|
||||||
};
|
|
||||||
}, [getContentScroller, handleScroll]);
|
|
||||||
|
|
||||||
const scrollToProvider = (providerId: ProviderId) => {
|
|
||||||
const container = getScrollContainer();
|
|
||||||
const element = document.getElementById(`provider-${providerId}`);
|
|
||||||
if (!element || !container) return;
|
|
||||||
|
|
||||||
setActiveProvider(providerId);
|
|
||||||
|
|
||||||
// Mobile: scroll the document (header is fixed, so offset by header height).
|
|
||||||
if (!(container instanceof HTMLElement)) {
|
|
||||||
const headerHeight = getHeaderHeight();
|
|
||||||
const elementTop = element.getBoundingClientRect().top + window.scrollY;
|
|
||||||
const target = Math.max(0, elementTop - headerHeight - HEADER_OFFSET);
|
|
||||||
window.scrollTo({ top: target, behavior: 'smooth' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
const elementRect = element.getBoundingClientRect();
|
|
||||||
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { ProviderNav } from './ProviderNav';
|
|
||||||
export type { ProviderId } from './ProviderNav';
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
|
||||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
|
||||||
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
|
||||||
import { headersToEntries } from '@/utils/headers';
|
|
||||||
import type { ProviderModalProps, VertexFormState } from '../types';
|
|
||||||
|
|
||||||
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildEmptyForm = (): VertexFormState => ({
|
|
||||||
apiKey: '',
|
|
||||||
prefix: '',
|
|
||||||
baseUrl: '',
|
|
||||||
proxyUrl: '',
|
|
||||||
headers: [],
|
|
||||||
models: [],
|
|
||||||
modelEntries: [{ name: '', alias: '' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
export function VertexModal({
|
|
||||||
isOpen,
|
|
||||||
editIndex,
|
|
||||||
initialData,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: VertexModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
if (initialData) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setForm({
|
|
||||||
...initialData,
|
|
||||||
headers: headersToEntries(initialData.headers),
|
|
||||||
modelEntries: modelsToEntries(initialData.models),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setForm(buildEmptyForm());
|
|
||||||
}, [initialData, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={
|
|
||||||
editIndex !== null
|
|
||||||
? t('ai_providers.vertex_edit_modal_title')
|
|
||||||
: t('ai_providers.vertex_add_modal_title')
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.vertex_add_modal_key_label')}
|
|
||||||
placeholder={t('ai_providers.vertex_add_modal_key_placeholder')}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.prefix_label')}
|
|
||||||
placeholder={t('ai_providers.prefix_placeholder')}
|
|
||||||
value={form.prefix ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
|
|
||||||
hint={t('ai_providers.prefix_hint')}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.vertex_add_modal_url_label')}
|
|
||||||
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
|
|
||||||
value={form.baseUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={t('ai_providers.vertex_add_modal_proxy_label')}
|
|
||||||
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
|
|
||||||
value={form.proxyUrl ?? ''}
|
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<HeaderInputList
|
|
||||||
entries={form.headers}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
|
|
||||||
addLabel={t('common.custom_headers_add')}
|
|
||||||
keyPlaceholder={t('common.custom_headers_key_placeholder')}
|
|
||||||
valuePlaceholder={t('common.custom_headers_value_placeholder')}
|
|
||||||
/>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{t('ai_providers.vertex_models_label')}</label>
|
|
||||||
<ModelInputList
|
|
||||||
entries={form.modelEntries}
|
|
||||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
|
||||||
addLabel={t('ai_providers.vertex_models_add_btn')}
|
|
||||||
namePlaceholder={t('common.model_name_placeholder')}
|
|
||||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
|
||||||
disabled={isSaving}
|
|
||||||
/>
|
|
||||||
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export { VertexSection } from './VertexSection';
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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 };
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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';
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
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),
|
|
||||||
});
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
};
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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';
|
|
||||||
@@ -1,592 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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];
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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';
|
|
||||||