Compare commits
5 Commits
8b3c4189f1
...
v0.5.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05145bf48 | ||
|
|
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;
|
|
||||||
}
|
|
||||||
81
src/App.tsx
@@ -1,81 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } 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 { SplashScreen } from '@/components/common/SplashScreen';
|
|
||||||
import { MainLayout } from '@/components/layout/MainLayout';
|
|
||||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
|
||||||
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
|
|
||||||
|
|
||||||
const SPLASH_DURATION = 1500;
|
|
||||||
const SPLASH_FADE_DURATION = 400;
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
|
||||||
const language = useLanguageStore((state) => state.language);
|
|
||||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
|
||||||
|
|
||||||
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
|
|
||||||
const [showSplash, setShowSplash] = useState(true);
|
|
||||||
const [authReady, setAuthReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanupTheme = initializeTheme();
|
|
||||||
void restoreSession().finally(() => {
|
|
||||||
setAuthReady(true);
|
|
||||||
});
|
|
||||||
return cleanupTheme;
|
|
||||||
}, [initializeTheme, restoreSession]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLanguage(language);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []); // 仅用于首屏同步 i18n 语言
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.lang = language;
|
|
||||||
}, [language]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setSplashReadyToFade(true);
|
|
||||||
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSplashFinish = useCallback(() => {
|
|
||||||
setShowSplash(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (showSplash) {
|
|
||||||
return (
|
|
||||||
<SplashScreen
|
|
||||||
fadeOut={splashReadyToFade && authReady}
|
|
||||||
onFinish={handleSplashFinish}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 +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 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>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,39 +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;
|
|
||||||
backface-visibility: hidden;
|
|
||||||
transform: translateZ(0);
|
|
||||||
|
|
||||||
// During animation, exit layer uses absolute positioning
|
|
||||||
&--exit {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
will-change: transform, opacity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--animating &__layer {
|
|
||||||
will-change: transform, opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--animating &__layer:not(.page-transition__layer--exit) {
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import { ReactNode, useCallback, useEffect, 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;
|
|
||||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRANSITION_DURATION = 0.35;
|
|
||||||
const TRAVEL_DISTANCE = 60;
|
|
||||||
|
|
||||||
type LayerStatus = 'current' | 'exiting';
|
|
||||||
|
|
||||||
type Layer = {
|
|
||||||
key: string;
|
|
||||||
location: Location;
|
|
||||||
status: LayerStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TransitionDirection = 'forward' | 'backward';
|
|
||||||
|
|
||||||
export function PageTransition({ render, getRouteOrder, scrollContainerRef }: PageTransitionProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
const currentLayerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const transitionDirectionRef = useRef<TransitionDirection>('forward');
|
|
||||||
const exitScrollOffsetRef = useRef(0);
|
|
||||||
|
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
|
||||||
const [layers, setLayers] = useState<Layer[]>(() => [
|
|
||||||
{
|
|
||||||
key: location.key,
|
|
||||||
location,
|
|
||||||
status: 'current',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
|
|
||||||
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
|
|
||||||
|
|
||||||
const resolveScrollContainer = useCallback(() => {
|
|
||||||
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
|
||||||
if (typeof document === 'undefined') return null;
|
|
||||||
return document.scrollingElement as HTMLElement | null;
|
|
||||||
}, [scrollContainerRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAnimating) return;
|
|
||||||
if (location.key === currentLayerKey) return;
|
|
||||||
if (currentLayerPathname === location.pathname) return;
|
|
||||||
const scrollContainer = resolveScrollContainer();
|
|
||||||
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
|
|
||||||
const resolveOrderIndex = (pathname?: string) => {
|
|
||||||
if (!getRouteOrder || !pathname) return null;
|
|
||||||
const index = getRouteOrder(pathname);
|
|
||||||
return typeof index === 'number' && index >= 0 ? index : null;
|
|
||||||
};
|
|
||||||
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
|
||||||
const toIndex = resolveOrderIndex(location.pathname);
|
|
||||||
const nextDirection: TransitionDirection =
|
|
||||||
fromIndex === null || toIndex === null || fromIndex === toIndex
|
|
||||||
? 'forward'
|
|
||||||
: toIndex > fromIndex
|
|
||||||
? 'forward'
|
|
||||||
: 'backward';
|
|
||||||
|
|
||||||
transitionDirectionRef.current = nextDirection;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setLayers((prev) => {
|
|
||||||
const prevCurrent = prev[prev.length - 1];
|
|
||||||
return [
|
|
||||||
prevCurrent
|
|
||||||
? { ...prevCurrent, status: 'exiting' }
|
|
||||||
: { key: location.key, location, status: 'exiting' },
|
|
||||||
{ key: location.key, location, status: 'current' },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
setIsAnimating(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
isAnimating,
|
|
||||||
location,
|
|
||||||
currentLayerKey,
|
|
||||||
currentLayerPathname,
|
|
||||||
getRouteOrder,
|
|
||||||
resolveScrollContainer,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Run GSAP animation when animating starts
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!isAnimating) return;
|
|
||||||
|
|
||||||
if (!currentLayerRef.current) return;
|
|
||||||
|
|
||||||
const currentLayerEl = currentLayerRef.current;
|
|
||||||
const exitingLayerEl = exitingLayerRef.current;
|
|
||||||
|
|
||||||
const scrollContainer = resolveScrollContainer();
|
|
||||||
const scrollOffset = exitScrollOffsetRef.current;
|
|
||||||
if (scrollContainer && scrollOffset > 0) {
|
|
||||||
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const transitionDirection = transitionDirectionRef.current;
|
|
||||||
const enterFromY = transitionDirection === 'forward' ? TRAVEL_DISTANCE : -TRAVEL_DISTANCE;
|
|
||||||
const exitToY = transitionDirection === 'forward' ? -TRAVEL_DISTANCE : TRAVEL_DISTANCE;
|
|
||||||
const exitBaseY = scrollOffset ? -scrollOffset : 0;
|
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
|
||||||
onComplete: () => {
|
|
||||||
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
|
|
||||||
setIsAnimating(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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: 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: TRANSITION_DURATION,
|
|
||||||
ease: 'circ.out',
|
|
||||||
force3D: true,
|
|
||||||
onComplete: () => {
|
|
||||||
if (currentLayerEl) {
|
|
||||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
tl.kill();
|
|
||||||
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
|
|
||||||
};
|
|
||||||
}, [isAnimating, resolveScrollContainer]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
|
||||||
{layers.map((layer) => (
|
|
||||||
<div
|
|
||||||
key={layer.key}
|
|
||||||
className={`page-transition__layer${
|
|
||||||
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
|
|
||||||
}`}
|
|
||||||
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
|
|
||||||
>
|
|
||||||
{render(layer.location)}
|
|
||||||
</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,599 +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 exactIndex = navOrder.indexOf(normalizedPath);
|
|
||||||
if (exactIndex !== -1) return exactIndex;
|
|
||||||
const nestedIndex = navOrder.findIndex(
|
|
||||||
(path) => path !== '/' && normalizedPath.startsWith(`${path}/`)
|
|
||||||
);
|
|
||||||
return nestedIndex === -1 ? null : nestedIndex;
|
|
||||||
};
|
|
||||||
|
|
||||||
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}
|
|
||||||
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,111 +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';
|
|
||||||
import { AmpcodeModal } from './AmpcodeModal';
|
|
||||||
|
|
||||||
interface AmpcodeSectionProps {
|
|
||||||
config: AmpcodeConfig | null | undefined;
|
|
||||||
loading: boolean;
|
|
||||||
disableControls: boolean;
|
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
|
||||||
isBusy: boolean;
|
|
||||||
isModalOpen: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
onCloseModal: () => void;
|
|
||||||
onBusyChange: (busy: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AmpcodeSection({
|
|
||||||
config,
|
|
||||||
loading,
|
|
||||||
disableControls,
|
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
|
||||||
isBusy,
|
|
||||||
isModalOpen,
|
|
||||||
onOpen,
|
|
||||||
onCloseModal,
|
|
||||||
onBusyChange,
|
|
||||||
}: AmpcodeSectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
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={onOpen}
|
|
||||||
disabled={disableControls || isSaving || isBusy || isSwitching}
|
|
||||||
>
|
|
||||||
{t('common.edit')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<AmpcodeModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
disableControls={disableControls}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onBusyChange={onBusyChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { AmpcodeSection } from './AmpcodeSection';
|
|
||||||
@@ -1,128 +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, modelsToEntries } from '@/components/ui/ModelInputList';
|
|
||||||
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,214 +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';
|
|
||||||
import type { ProviderFormState } from '../types';
|
|
||||||
import { ClaudeModal } from './ClaudeModal';
|
|
||||||
|
|
||||||
interface ClaudeSectionProps {
|
|
||||||
configs: ProviderKeyConfig[];
|
|
||||||
keyStats: KeyStats;
|
|
||||||
usageDetails: UsageDetail[];
|
|
||||||
loading: boolean;
|
|
||||||
disableControls: boolean;
|
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
|
||||||
onEdit: (index: number) => void;
|
|
||||||
onDelete: (index: number) => void;
|
|
||||||
onToggle: (index: number, enabled: boolean) => void;
|
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClaudeSection({
|
|
||||||
configs,
|
|
||||||
keyStats,
|
|
||||||
usageDetails,
|
|
||||||
loading,
|
|
||||||
disableControls,
|
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onToggle,
|
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: ClaudeSectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
|
||||||
const toggleDisabled = disableControls || loading || isSaving || 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]);
|
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<ClaudeModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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/ModelInputList';
|
|
||||||
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,206 +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 iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
|
||||||
import iconOpenaiDark from '@/assets/icons/openai-dark.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';
|
|
||||||
import type { ProviderFormState } from '../types';
|
|
||||||
import { CodexModal } from './CodexModal';
|
|
||||||
|
|
||||||
interface CodexSectionProps {
|
|
||||||
configs: ProviderKeyConfig[];
|
|
||||||
keyStats: KeyStats;
|
|
||||||
usageDetails: UsageDetail[];
|
|
||||||
loading: boolean;
|
|
||||||
disableControls: boolean;
|
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
|
||||||
resolvedTheme: string;
|
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
|
||||||
onEdit: (index: number) => void;
|
|
||||||
onDelete: (index: number) => void;
|
|
||||||
onToggle: (index: number, enabled: boolean) => void;
|
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodexSection({
|
|
||||||
configs,
|
|
||||||
keyStats,
|
|
||||||
usageDetails,
|
|
||||||
loading,
|
|
||||||
disableControls,
|
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
|
||||||
resolvedTheme,
|
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onToggle,
|
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: CodexSectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
|
||||||
const toggleDisabled = disableControls || loading || isSaving || 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]);
|
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card
|
|
||||||
title={
|
|
||||||
<span className={styles.cardTitle}>
|
|
||||||
<img
|
|
||||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<CodexModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,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 { 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 type { GeminiFormState } from '../types';
|
|
||||||
import { ProviderList } from '../ProviderList';
|
|
||||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
|
||||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
|
||||||
import { GeminiModal } from './GeminiModal';
|
|
||||||
|
|
||||||
interface GeminiSectionProps {
|
|
||||||
configs: GeminiKeyConfig[];
|
|
||||||
keyStats: KeyStats;
|
|
||||||
usageDetails: UsageDetail[];
|
|
||||||
loading: boolean;
|
|
||||||
disableControls: boolean;
|
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
|
||||||
onEdit: (index: number) => void;
|
|
||||||
onDelete: (index: number) => void;
|
|
||||||
onToggle: (index: number, enabled: boolean) => void;
|
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GeminiSection({
|
|
||||||
configs,
|
|
||||||
keyStats,
|
|
||||||
usageDetails,
|
|
||||||
loading,
|
|
||||||
disableControls,
|
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onToggle,
|
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: GeminiSectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
|
||||||
const toggleDisabled = disableControls || loading || isSaving || 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]);
|
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<GeminiModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,432 +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, modelsToEntries } from '@/components/ui/ModelInputList';
|
|
||||||
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,218 +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';
|
|
||||||
import type { OpenAIFormState } from '../types';
|
|
||||||
import { OpenAIModal } from './OpenAIModal';
|
|
||||||
|
|
||||||
interface OpenAISectionProps {
|
|
||||||
configs: OpenAIProviderConfig[];
|
|
||||||
keyStats: KeyStats;
|
|
||||||
usageDetails: UsageDetail[];
|
|
||||||
loading: boolean;
|
|
||||||
disableControls: boolean;
|
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
|
||||||
resolvedTheme: string;
|
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
|
||||||
onEdit: (index: number) => void;
|
|
||||||
onDelete: (index: number) => void;
|
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OpenAISection({
|
|
||||||
configs,
|
|
||||||
keyStats,
|
|
||||||
usageDetails,
|
|
||||||
loading,
|
|
||||||
disableControls,
|
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
|
||||||
resolvedTheme,
|
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: OpenAISectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const actionsDisabled = disableControls || isSaving || 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]);
|
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<OpenAIModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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) {
|
|
||||||
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,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,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 { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
|
||||||
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,182 +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';
|
|
||||||
import type { VertexFormState } from '../types';
|
|
||||||
import { VertexModal } from './VertexModal';
|
|
||||||
|
|
||||||
interface VertexSectionProps {
|
|
||||||
configs: ProviderKeyConfig[];
|
|
||||||
keyStats: KeyStats;
|
|
||||||
usageDetails: UsageDetail[];
|
|
||||||
loading: boolean;
|
|
||||||
disableControls: boolean;
|
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
|
||||||
onEdit: (index: number) => void;
|
|
||||||
onDelete: (index: number) => void;
|
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: VertexFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VertexSection({
|
|
||||||
configs,
|
|
||||||
keyStats,
|
|
||||||
usageDetails,
|
|
||||||
loading,
|
|
||||||
disableControls,
|
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: VertexSectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const actionsDisabled = disableControls || isSaving || 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]);
|
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<VertexModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,11 +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 * 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,152 +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;
|
|
||||||
}
|
|
||||||
if (trimmed.endsWith('/v1')) {
|
|
||||||
return `${trimmed.slice(0, -3)}/chat/completions`;
|
|
||||||
}
|
|
||||||
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,604 +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,
|
|
||||||
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 isAntigravityUnknownFieldError = (message: string): boolean => {
|
|
||||||
const normalized = message.toLowerCase();
|
|
||||||
return normalized.includes('unknown name') && normalized.includes('cannot find field');
|
|
||||||
};
|
|
||||||
|
|
||||||
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 requestBodies = [JSON.stringify({ projectId }), JSON.stringify({ project: projectId })];
|
|
||||||
|
|
||||||
let lastError = '';
|
|
||||||
let lastStatus: number | undefined;
|
|
||||||
let priorityStatus: number | undefined;
|
|
||||||
let hadSuccess = false;
|
|
||||||
|
|
||||||
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
|
||||||
for (let attempt = 0; attempt < requestBodies.length; attempt++) {
|
|
||||||
try {
|
|
||||||
const result = await apiCallApi.request({
|
|
||||||
authIndex,
|
|
||||||
method: 'POST',
|
|
||||||
url,
|
|
||||||
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
|
||||||
data: requestBodies[attempt]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
||||||
lastError = getApiCallErrorMessage(result);
|
|
||||||
lastStatus = result.statusCode;
|
|
||||||
if (result.statusCode === 403 || result.statusCode === 404) {
|
|
||||||
priorityStatus ??= result.statusCode;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
result.statusCode === 400 &&
|
|
||||||
isAntigravityUnknownFieldError(lastError) &&
|
|
||||||
attempt < requestBodies.length - 1
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
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),
|
|
||||||
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),
|
|
||||||
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,105 +0,0 @@
|
|||||||
import { Fragment } from 'react';
|
|
||||||
import { Button } from './Button';
|
|
||||||
import { IconX } from './icons';
|
|
||||||
import type { ModelAlias } from '@/types';
|
|
||||||
|
|
||||||
interface ModelEntry {
|
|
||||||
name: string;
|
|
||||||
alias: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelInputListProps {
|
|
||||||
entries: ModelEntry[];
|
|
||||||
onChange: (entries: ModelEntry[]) => void;
|
|
||||||
addLabel: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
namePlaceholder?: string;
|
|
||||||
aliasPlaceholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
|
|
||||||
if (!Array.isArray(models) || models.length === 0) {
|
|
||||||
return [{ name: '', alias: '' }];
|
|
||||||
}
|
|
||||||
return models.map((m) => ({
|
|
||||||
name: m.name || '',
|
|
||||||
alias: m.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;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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,316 +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 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,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';
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react';
|
|
||||||
import type { ChartOptions } from 'chart.js';
|
|
||||||
import { buildChartData, type ChartData } from '@/utils/usage';
|
|
||||||
import { buildChartOptions } from '@/utils/usage/chartConfig';
|
|
||||||
import type { UsagePayload } from './useUsageData';
|
|
||||||
|
|
||||||
export interface UseChartDataOptions {
|
|
||||||
usage: UsagePayload | null;
|
|
||||||
chartLines: string[];
|
|
||||||
isDark: boolean;
|
|
||||||
isMobile: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseChartDataReturn {
|
|
||||||
requestsPeriod: 'hour' | 'day';
|
|
||||||
setRequestsPeriod: (period: 'hour' | 'day') => void;
|
|
||||||
tokensPeriod: 'hour' | 'day';
|
|
||||||
setTokensPeriod: (period: 'hour' | 'day') => void;
|
|
||||||
requestsChartData: ChartData;
|
|
||||||
tokensChartData: ChartData;
|
|
||||||
requestsChartOptions: ChartOptions<'line'>;
|
|
||||||
tokensChartOptions: ChartOptions<'line'>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChartData({
|
|
||||||
usage,
|
|
||||||
chartLines,
|
|
||||||
isDark,
|
|
||||||
isMobile
|
|
||||||
}: UseChartDataOptions): UseChartDataReturn {
|
|
||||||
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
|
||||||
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
|
||||||
|
|
||||||
const requestsChartData = useMemo(() => {
|
|
||||||
if (!usage) return { labels: [], datasets: [] };
|
|
||||||
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
|
|
||||||
}, [usage, requestsPeriod, chartLines]);
|
|
||||||
|
|
||||||
const tokensChartData = useMemo(() => {
|
|
||||||
if (!usage) return { labels: [], datasets: [] };
|
|
||||||
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
|
||||||
}, [usage, tokensPeriod, chartLines]);
|
|
||||||
|
|
||||||
const requestsChartOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
buildChartOptions({
|
|
||||||
period: requestsPeriod,
|
|
||||||
labels: requestsChartData.labels,
|
|
||||||
isDark,
|
|
||||||
isMobile
|
|
||||||
}),
|
|
||||||
[requestsPeriod, requestsChartData.labels, isDark, isMobile]
|
|
||||||
);
|
|
||||||
|
|
||||||
const tokensChartOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
buildChartOptions({
|
|
||||||
period: tokensPeriod,
|
|
||||||
labels: tokensChartData.labels,
|
|
||||||
isDark,
|
|
||||||
isMobile
|
|
||||||
}),
|
|
||||||
[tokensPeriod, tokensChartData.labels, isDark, isMobile]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestsPeriod,
|
|
||||||
setRequestsPeriod,
|
|
||||||
tokensPeriod,
|
|
||||||
setTokensPeriod,
|
|
||||||
requestsChartData,
|
|
||||||
tokensChartData,
|
|
||||||
requestsChartOptions,
|
|
||||||
tokensChartOptions
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { collectUsageDetails, extractTotalTokens } from '@/utils/usage';
|
|
||||||
import type { UsagePayload } from './useUsageData';
|
|
||||||
|
|
||||||
export interface SparklineData {
|
|
||||||
labels: string[];
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: number[];
|
|
||||||
borderColor: string;
|
|
||||||
backgroundColor: string;
|
|
||||||
fill: boolean;
|
|
||||||
tension: number;
|
|
||||||
pointRadius: number;
|
|
||||||
borderWidth: number;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SparklineBundle {
|
|
||||||
data: SparklineData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseSparklinesOptions {
|
|
||||||
usage: UsagePayload | null;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseSparklinesReturn {
|
|
||||||
requestsSparkline: SparklineBundle | null;
|
|
||||||
tokensSparkline: SparklineBundle | null;
|
|
||||||
rpmSparkline: SparklineBundle | null;
|
|
||||||
tpmSparkline: SparklineBundle | null;
|
|
||||||
costSparkline: SparklineBundle | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSparklinesReturn {
|
|
||||||
const buildLastHourSeries = useCallback(
|
|
||||||
(metric: 'requests' | 'tokens'): { labels: string[]; data: number[] } => {
|
|
||||||
if (!usage) return { labels: [], data: [] };
|
|
||||||
const details = collectUsageDetails(usage);
|
|
||||||
if (!details.length) return { labels: [], data: [] };
|
|
||||||
|
|
||||||
const windowMinutes = 60;
|
|
||||||
const now = Date.now();
|
|
||||||
const windowStart = now - windowMinutes * 60 * 1000;
|
|
||||||
const buckets = new Array(windowMinutes).fill(0);
|
|
||||||
|
|
||||||
details.forEach((detail) => {
|
|
||||||
const timestamp = Date.parse(detail.timestamp);
|
|
||||||
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const minuteIndex = Math.min(
|
|
||||||
windowMinutes - 1,
|
|
||||||
Math.floor((timestamp - windowStart) / 60000)
|
|
||||||
);
|
|
||||||
const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1;
|
|
||||||
buckets[minuteIndex] += increment;
|
|
||||||
});
|
|
||||||
|
|
||||||
const labels = buckets.map((_, idx) => {
|
|
||||||
const date = new Date(windowStart + (idx + 1) * 60000);
|
|
||||||
const h = date.getHours().toString().padStart(2, '0');
|
|
||||||
const m = date.getMinutes().toString().padStart(2, '0');
|
|
||||||
return `${h}:${m}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { labels, data: buckets };
|
|
||||||
},
|
|
||||||
[usage]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildSparkline = useCallback(
|
|
||||||
(
|
|
||||||
series: { labels: string[]; data: number[] },
|
|
||||||
color: string,
|
|
||||||
backgroundColor: string
|
|
||||||
): SparklineBundle | null => {
|
|
||||||
if (loading || !series?.data?.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const sliceStart = Math.max(series.data.length - 60, 0);
|
|
||||||
const labels = series.labels.slice(sliceStart);
|
|
||||||
const points = series.data.slice(sliceStart);
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
labels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: points,
|
|
||||||
borderColor: color,
|
|
||||||
backgroundColor,
|
|
||||||
fill: true,
|
|
||||||
tension: 0.45,
|
|
||||||
pointRadius: 0,
|
|
||||||
borderWidth: 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[loading]
|
|
||||||
);
|
|
||||||
|
|
||||||
const requestsSparkline = useMemo(
|
|
||||||
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
|
|
||||||
[buildLastHourSeries, buildSparkline]
|
|
||||||
);
|
|
||||||
|
|
||||||
const tokensSparkline = useMemo(
|
|
||||||
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.18)'),
|
|
||||||
[buildLastHourSeries, buildSparkline]
|
|
||||||
);
|
|
||||||
|
|
||||||
const rpmSparkline = useMemo(
|
|
||||||
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.18)'),
|
|
||||||
[buildLastHourSeries, buildSparkline]
|
|
||||||
);
|
|
||||||
|
|
||||||
const tpmSparkline = useMemo(
|
|
||||||
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.18)'),
|
|
||||||
[buildLastHourSeries, buildSparkline]
|
|
||||||
);
|
|
||||||
|
|
||||||
const costSparkline = useMemo(
|
|
||||||
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.18)'),
|
|
||||||
[buildLastHourSeries, buildSparkline]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestsSparkline,
|
|
||||||
tokensSparkline,
|
|
||||||
rpmSparkline,
|
|
||||||
tpmSparkline,
|
|
||||||
costSparkline
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useNotificationStore } from '@/stores';
|
|
||||||
import { usageApi } from '@/services/api/usage';
|
|
||||||
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
|
|
||||||
|
|
||||||
export interface UsagePayload {
|
|
||||||
total_requests?: number;
|
|
||||||
success_count?: number;
|
|
||||||
failure_count?: number;
|
|
||||||
total_tokens?: number;
|
|
||||||
apis?: Record<string, unknown>;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseUsageDataReturn {
|
|
||||||
usage: UsagePayload | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: string;
|
|
||||||
modelPrices: Record<string, ModelPrice>;
|
|
||||||
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
|
||||||
loadUsage: () => Promise<void>;
|
|
||||||
handleExport: () => Promise<void>;
|
|
||||||
handleImport: () => void;
|
|
||||||
handleImportChange: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
|
||||||
importInputRef: React.RefObject<HTMLInputElement | null>;
|
|
||||||
exporting: boolean;
|
|
||||||
importing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUsageData(): UseUsageDataReturn {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { showNotification } = useNotificationStore();
|
|
||||||
|
|
||||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
const [importing, setImporting] = useState(false);
|
|
||||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const loadUsage = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const data = await usageApi.getUsage();
|
|
||||||
const payload = data?.usage ?? data;
|
|
||||||
setUsage(payload);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadUsage();
|
|
||||||
setModelPrices(loadModelPrices());
|
|
||||||
}, [loadUsage]);
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
const data = await usageApi.exportUsage();
|
|
||||||
const exportedAt =
|
|
||||||
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
|
|
||||||
const safeTimestamp = Number.isNaN(exportedAt.getTime())
|
|
||||||
? new Date().toISOString()
|
|
||||||
: exportedAt.toISOString();
|
|
||||||
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
|
|
||||||
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
link.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
showNotification(t('usage_stats.export_success'), 'success');
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : '';
|
|
||||||
showNotification(
|
|
||||||
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImport = () => {
|
|
||||||
importInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
event.target.value = '';
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setImporting(true);
|
|
||||||
try {
|
|
||||||
const text = await file.text();
|
|
||||||
let payload: unknown;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
showNotification(t('usage_stats.import_invalid'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await usageApi.importUsage(payload);
|
|
||||||
showNotification(
|
|
||||||
t('usage_stats.import_success', {
|
|
||||||
added: result?.added ?? 0,
|
|
||||||
skipped: result?.skipped ?? 0,
|
|
||||||
total: result?.total_requests ?? 0,
|
|
||||||
failed: result?.failed_requests ?? 0
|
|
||||||
}),
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
await loadUsage();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : '';
|
|
||||||
showNotification(
|
|
||||||
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setImporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSetModelPrices = useCallback((prices: Record<string, ModelPrice>) => {
|
|
||||||
setModelPrices(prices);
|
|
||||||
saveModelPrices(prices);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
usage,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
modelPrices,
|
|
||||||
setModelPrices: handleSetModelPrices,
|
|
||||||
loadUsage,
|
|
||||||
handleExport,
|
|
||||||
handleImport,
|
|
||||||
handleImportChange,
|
|
||||||
importInputRef,
|
|
||||||
exporting,
|
|
||||||
importing
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// Hooks
|
|
||||||
export { useUsageData } from './hooks/useUsageData';
|
|
||||||
export type { UsagePayload, UseUsageDataReturn } from './hooks/useUsageData';
|
|
||||||
|
|
||||||
export { useSparklines } from './hooks/useSparklines';
|
|
||||||
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './hooks/useSparklines';
|
|
||||||
|
|
||||||
export { useChartData } from './hooks/useChartData';
|
|
||||||
export type { UseChartDataOptions, UseChartDataReturn } from './hooks/useChartData';
|
|
||||||
|
|
||||||
// Components
|
|
||||||
export { StatCards } from './StatCards';
|
|
||||||
export type { StatCardsProps } from './StatCards';
|
|
||||||
|
|
||||||
export { UsageChart } from './UsageChart';
|
|
||||||
export type { UsageChartProps } from './UsageChart';
|
|
||||||
|
|
||||||
export { ChartLineSelector } from './ChartLineSelector';
|
|
||||||
export type { ChartLineSelectorProps } from './ChartLineSelector';
|
|
||||||
|
|
||||||
export { ApiDetailsCard } from './ApiDetailsCard';
|
|
||||||
export type { ApiDetailsCardProps } from './ApiDetailsCard';
|
|
||||||
|
|
||||||
export { ModelStatsCard } from './ModelStatsCard';
|
|
||||||
export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
|
|
||||||
|
|
||||||
export { PriceSettingsCard } from './PriceSettingsCard';
|
|
||||||
export type { PriceSettingsCardProps } from './PriceSettingsCard';
|
|
||||||
87
src/core/api-client.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// API 客户端:负责规范化基础地址、构造完整 URL、发送请求并回传版本信息
|
||||||
|
export class ApiClient {
|
||||||
|
constructor({ apiBase = '', managementKey = '', onVersionUpdate = null } = {}) {
|
||||||
|
this.apiBase = '';
|
||||||
|
this.apiUrl = '';
|
||||||
|
this.managementKey = managementKey || '';
|
||||||
|
this.onVersionUpdate = onVersionUpdate;
|
||||||
|
this.setApiBase(apiBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildHeaders(options = {}) {
|
||||||
|
const customHeaders = options.headers || {};
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${this.managementKey}`,
|
||||||
|
...customHeaders
|
||||||
|
};
|
||||||
|
const hasContentType = Object.keys(headers).some(key => key.toLowerCase() === 'content-type');
|
||||||
|
const body = options.body;
|
||||||
|
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||||
|
if (!hasContentType && !isFormData) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeBase(input) {
|
||||||
|
let base = (input || '').trim();
|
||||||
|
if (!base) return '';
|
||||||
|
base = base.replace(/\/?v0\/management\/?$/i, '');
|
||||||
|
base = base.replace(/\/+$/i, '');
|
||||||
|
if (!/^https?:\/\//i.test(base)) {
|
||||||
|
base = 'http://' + base;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeApiUrl(base) {
|
||||||
|
const normalized = this.normalizeBase(base);
|
||||||
|
if (!normalized) return '';
|
||||||
|
return normalized.replace(/\/$/, '') + '/v0/management';
|
||||||
|
}
|
||||||
|
|
||||||
|
setApiBase(newBase) {
|
||||||
|
this.apiBase = this.normalizeBase(newBase);
|
||||||
|
this.apiUrl = this.computeApiUrl(this.apiBase);
|
||||||
|
return this.apiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
setManagementKey(key) {
|
||||||
|
this.managementKey = key || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(endpoint, options = {}) {
|
||||||
|
const url = `${this.apiUrl}${endpoint}`;
|
||||||
|
const headers = this.buildHeaders(options);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof this.onVersionUpdate === 'function') {
|
||||||
|
this.onVersionUpdate(response.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回原始 Response,供下载/自定义解析使用
|
||||||
|
async requestRaw(endpoint, options = {}) {
|
||||||
|
const url = `${this.apiUrl}${endpoint}`;
|
||||||
|
const headers = this.buildHeaders(options);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
if (typeof this.onVersionUpdate === 'function') {
|
||||||
|
this.onVersionUpdate(response.headers);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/core/config-service.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// 配置缓存服务:负责分段/全量读取配置与缓存控制,不涉及任何 DOM
|
||||||
|
export class ConfigService {
|
||||||
|
constructor({ apiClient, cacheExpiry }) {
|
||||||
|
this.apiClient = apiClient;
|
||||||
|
this.cacheExpiry = cacheExpiry;
|
||||||
|
this.cache = {};
|
||||||
|
this.cacheTimestamps = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
isCacheValid(section = null) {
|
||||||
|
if (section) {
|
||||||
|
if (!(section in this.cache) || !(section in this.cacheTimestamps)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (Date.now() - this.cacheTimestamps[section]) < this.cacheExpiry;
|
||||||
|
}
|
||||||
|
if (!this.cache['__full__'] || !this.cacheTimestamps['__full__']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (Date.now() - this.cacheTimestamps['__full__']) < this.cacheExpiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(section = null) {
|
||||||
|
if (section) {
|
||||||
|
delete this.cache[section];
|
||||||
|
delete this.cacheTimestamps[section];
|
||||||
|
if (this.cache['__full__']) {
|
||||||
|
delete this.cache['__full__'][section];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.keys(this.cache).forEach(key => delete this.cache[key]);
|
||||||
|
Object.keys(this.cacheTimestamps).forEach(key => delete this.cacheTimestamps[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfig(section = null, forceRefresh = false) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (section && !forceRefresh && this.isCacheValid(section)) {
|
||||||
|
return this.cache[section];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!section && !forceRefresh && this.isCacheValid()) {
|
||||||
|
return this.cache['__full__'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await this.apiClient.request('/config');
|
||||||
|
|
||||||
|
if (section) {
|
||||||
|
this.cache[section] = config[section];
|
||||||
|
this.cacheTimestamps[section] = now;
|
||||||
|
if (this.cache['__full__']) {
|
||||||
|
this.cache['__full__'][section] = config[section];
|
||||||
|
} else {
|
||||||
|
this.cache['__full__'] = config;
|
||||||
|
this.cacheTimestamps['__full__'] = now;
|
||||||
|
}
|
||||||
|
return config[section];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache['__full__'] = config;
|
||||||
|
this.cacheTimestamps['__full__'] = now;
|
||||||
|
Object.keys(config).forEach(key => {
|
||||||
|
this.cache[key] = config[key];
|
||||||
|
this.cacheTimestamps[key] = now;
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
746
src/core/connection.js
Normal file
@@ -0,0 +1,746 @@
|
|||||||
|
// 连接与配置缓存核心模块
|
||||||
|
// 提供 API 基础地址规范化、请求封装、配置缓存以及统一数据加载能力
|
||||||
|
|
||||||
|
import { STATUS_UPDATE_INTERVAL_MS, DEFAULT_API_PORT } from '../utils/constants.js';
|
||||||
|
import { secureStorage } from '../utils/secure-storage.js';
|
||||||
|
import { normalizeModelList, classifyModels } from '../utils/models.js';
|
||||||
|
|
||||||
|
const buildModelsEndpoint = (baseUrl) => {
|
||||||
|
if (!baseUrl) return '';
|
||||||
|
const trimmed = String(baseUrl).trim().replace(/\/+$/g, '');
|
||||||
|
if (!trimmed) return '';
|
||||||
|
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeApiKeyList = (input) => {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const seen = new Set();
|
||||||
|
const keys = [];
|
||||||
|
|
||||||
|
input.forEach(item => {
|
||||||
|
const value = typeof item === 'string'
|
||||||
|
? item
|
||||||
|
: (item && item['api-key'] ? item['api-key'] : '');
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (!trimmed || seen.has(trimmed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(trimmed);
|
||||||
|
keys.push(trimmed);
|
||||||
|
});
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const connectionModule = {
|
||||||
|
// 规范化基础地址,移除尾部斜杠与 /v0/management
|
||||||
|
normalizeBase(input) {
|
||||||
|
return this.apiClient.normalizeBase(input);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 由基础地址生成完整管理 API 地址
|
||||||
|
computeApiUrl(base) {
|
||||||
|
return this.apiClient.computeApiUrl(base);
|
||||||
|
},
|
||||||
|
|
||||||
|
setApiBase(newBase) {
|
||||||
|
this.apiClient.setApiBase(newBase);
|
||||||
|
this.apiBase = this.apiClient.apiBase;
|
||||||
|
this.apiUrl = this.apiClient.apiUrl;
|
||||||
|
secureStorage.setItem('apiBase', this.apiBase);
|
||||||
|
secureStorage.setItem('apiUrl', this.apiUrl); // 兼容旧字段
|
||||||
|
this.updateLoginConnectionInfo();
|
||||||
|
},
|
||||||
|
|
||||||
|
setManagementKey(key, { persist = true } = {}) {
|
||||||
|
this.managementKey = key || '';
|
||||||
|
this.apiClient.setManagementKey(this.managementKey);
|
||||||
|
if (persist) {
|
||||||
|
secureStorage.setItem('managementKey', this.managementKey);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 加载设置(简化版,仅加载内部状态)
|
||||||
|
loadSettings() {
|
||||||
|
secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
|
||||||
|
|
||||||
|
const savedBase = secureStorage.getItem('apiBase');
|
||||||
|
const savedUrl = secureStorage.getItem('apiUrl');
|
||||||
|
const savedKey = secureStorage.getItem('managementKey');
|
||||||
|
|
||||||
|
if (savedBase) {
|
||||||
|
this.setApiBase(savedBase);
|
||||||
|
} else if (savedUrl) {
|
||||||
|
const base = (savedUrl || '').replace(/\/?v0\/management\/?$/i, '');
|
||||||
|
this.setApiBase(base);
|
||||||
|
} else {
|
||||||
|
this.setApiBase(this.detectApiBaseFromLocation());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setManagementKey(savedKey || '', { persist: false });
|
||||||
|
|
||||||
|
this.updateLoginConnectionInfo();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 读取并填充管理中心版本号(可能来自构建时注入或占位符)
|
||||||
|
initUiVersion() {
|
||||||
|
const uiVersion = this.readUiVersionFromDom();
|
||||||
|
this.uiVersion = uiVersion;
|
||||||
|
this.renderVersionInfo();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 从 DOM 获取版本占位符,并处理空值、引号或未替换的占位符
|
||||||
|
readUiVersionFromDom() {
|
||||||
|
const el = document.getElementById('ui-version');
|
||||||
|
if (!el) return null;
|
||||||
|
|
||||||
|
const raw = (el.dataset && el.dataset.uiVersion) ? el.dataset.uiVersion : el.textContent;
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
|
||||||
|
const cleaned = raw.replace(/^"+|"+$/g, '').trim();
|
||||||
|
if (!cleaned || cleaned === '__VERSION__') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 根据响应头更新版本与构建时间
|
||||||
|
updateVersionFromHeaders(headers) {
|
||||||
|
if (!headers || typeof headers.get !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = headers.get('X-CPA-VERSION');
|
||||||
|
const buildDate = headers.get('X-CPA-BUILD-DATE');
|
||||||
|
let updated = false;
|
||||||
|
|
||||||
|
if (version && version !== this.serverVersion) {
|
||||||
|
this.serverVersion = version;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildDate && buildDate !== this.serverBuildDate) {
|
||||||
|
this.serverBuildDate = buildDate;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
this.renderVersionInfo();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderVersionCheckStatus({
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
message,
|
||||||
|
status
|
||||||
|
} = {}) {
|
||||||
|
const resolvedCurrent = (typeof currentVersion === 'undefined' || currentVersion === null)
|
||||||
|
? this.serverVersion
|
||||||
|
: currentVersion;
|
||||||
|
const resolvedLatest = (typeof latestVersion === 'undefined' || latestVersion === null)
|
||||||
|
? this.latestVersion
|
||||||
|
: latestVersion;
|
||||||
|
const resolvedMessage = (typeof message === 'undefined' || message === null)
|
||||||
|
? (this.versionCheckMessage || i18n.t('system_info.version_check_idle'))
|
||||||
|
: message;
|
||||||
|
const resolvedStatus = status || this.versionCheckStatus || 'muted';
|
||||||
|
|
||||||
|
this.latestVersion = resolvedLatest || null;
|
||||||
|
this.versionCheckMessage = resolvedMessage;
|
||||||
|
this.versionCheckStatus = resolvedStatus;
|
||||||
|
|
||||||
|
const currentEl = document.getElementById('version-check-current');
|
||||||
|
if (currentEl) {
|
||||||
|
currentEl.textContent = resolvedCurrent || i18n.t('system_info.version_unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestEl = document.getElementById('version-check-latest');
|
||||||
|
if (latestEl) {
|
||||||
|
latestEl.textContent = resolvedLatest || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultEl = document.getElementById('version-check-result');
|
||||||
|
if (resultEl) {
|
||||||
|
resultEl.textContent = resolvedMessage;
|
||||||
|
resultEl.className = `version-check-result ${resolvedStatus}`.trim();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetVersionCheckStatus() {
|
||||||
|
this.latestVersion = null;
|
||||||
|
this.versionCheckMessage = i18n.t('system_info.version_check_idle');
|
||||||
|
this.versionCheckStatus = 'muted';
|
||||||
|
this.renderVersionCheckStatus({
|
||||||
|
currentVersion: this.serverVersion,
|
||||||
|
latestVersion: this.latestVersion,
|
||||||
|
message: this.versionCheckMessage,
|
||||||
|
status: this.versionCheckStatus
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 渲染底栏的版本与构建时间
|
||||||
|
renderVersionInfo() {
|
||||||
|
const versionEl = document.getElementById('api-version');
|
||||||
|
const buildDateEl = document.getElementById('api-build-date');
|
||||||
|
const uiVersionEl = document.getElementById('ui-version');
|
||||||
|
|
||||||
|
if (versionEl) {
|
||||||
|
versionEl.textContent = this.serverVersion || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buildDateEl) {
|
||||||
|
buildDateEl.textContent = this.serverBuildDate
|
||||||
|
? this.formatBuildDate(this.serverBuildDate)
|
||||||
|
: '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiVersionEl) {
|
||||||
|
const domVersion = this.readUiVersionFromDom();
|
||||||
|
uiVersionEl.textContent = this.uiVersion || domVersion || 'v0.0.0-dev';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderVersionCheckStatus({
|
||||||
|
currentVersion: this.serverVersion,
|
||||||
|
latestVersion: this.latestVersion,
|
||||||
|
message: this.versionCheckMessage,
|
||||||
|
status: this.versionCheckStatus
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清空版本信息(例如登出时)
|
||||||
|
resetVersionInfo() {
|
||||||
|
this.serverVersion = null;
|
||||||
|
this.serverBuildDate = null;
|
||||||
|
this.resetVersionCheckStatus();
|
||||||
|
this.renderVersionInfo();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 格式化构建时间,优先使用界面语言对应的本地格式
|
||||||
|
formatBuildDate(buildDate) {
|
||||||
|
if (!buildDate) return '-';
|
||||||
|
|
||||||
|
const parsed = Date.parse(buildDate);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
const locale = i18n?.currentLanguage || undefined;
|
||||||
|
return new Date(parsed).toLocaleString(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDate;
|
||||||
|
},
|
||||||
|
|
||||||
|
parseVersionSegments(version) {
|
||||||
|
if (!version || typeof version !== 'string') return null;
|
||||||
|
const cleaned = version.trim().replace(/^v/i, '');
|
||||||
|
if (!cleaned) return null;
|
||||||
|
const parts = cleaned.split(/[^0-9]+/).filter(Boolean).map(segment => {
|
||||||
|
const parsed = parseInt(segment, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
});
|
||||||
|
return parts.length ? parts : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
compareVersions(latestVersion, currentVersion) {
|
||||||
|
const latestParts = this.parseVersionSegments(latestVersion);
|
||||||
|
const currentParts = this.parseVersionSegments(currentVersion);
|
||||||
|
if (!latestParts || !currentParts) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = Math.max(latestParts.length, currentParts.length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const latest = latestParts[i] || 0;
|
||||||
|
const current = currentParts[i] || 0;
|
||||||
|
if (latest > current) return 1;
|
||||||
|
if (latest < current) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkLatestVersion() {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
const message = i18n.t('notification.connection_required');
|
||||||
|
this.renderVersionCheckStatus({
|
||||||
|
currentVersion: this.serverVersion,
|
||||||
|
latestVersion: this.latestVersion,
|
||||||
|
message,
|
||||||
|
status: 'warning'
|
||||||
|
});
|
||||||
|
this.showNotification(message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = document.getElementById('version-check-btn');
|
||||||
|
const originalLabel = button ? button.innerHTML : '';
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = `<div class="loading"></div> ${i18n.t('system_info.version_checking')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderVersionCheckStatus({
|
||||||
|
currentVersion: this.serverVersion,
|
||||||
|
latestVersion: this.latestVersion,
|
||||||
|
message: i18n.t('system_info.version_checking'),
|
||||||
|
status: 'info'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.makeRequest('/latest-version');
|
||||||
|
const latestVersion = data?.['latest-version'] || data?.latest_version || '';
|
||||||
|
const latestParts = this.parseVersionSegments(latestVersion);
|
||||||
|
const currentParts = this.parseVersionSegments(this.serverVersion);
|
||||||
|
const comparison = (latestParts && currentParts)
|
||||||
|
? this.compareVersions(latestVersion, this.serverVersion)
|
||||||
|
: null;
|
||||||
|
let messageKey = 'system_info.version_check_error';
|
||||||
|
let statusClass = 'error';
|
||||||
|
|
||||||
|
if (!latestParts) {
|
||||||
|
messageKey = 'system_info.version_check_error';
|
||||||
|
} else if (!currentParts) {
|
||||||
|
messageKey = 'system_info.version_current_missing';
|
||||||
|
statusClass = 'warning';
|
||||||
|
} else if (comparison > 0) {
|
||||||
|
messageKey = 'system_info.version_update_available';
|
||||||
|
statusClass = 'warning';
|
||||||
|
} else {
|
||||||
|
messageKey = 'system_info.version_is_latest';
|
||||||
|
statusClass = 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = i18n.t(messageKey, latestVersion ? { version: latestVersion } : undefined);
|
||||||
|
this.renderVersionCheckStatus({
|
||||||
|
currentVersion: this.serverVersion,
|
||||||
|
latestVersion,
|
||||||
|
message,
|
||||||
|
status: statusClass
|
||||||
|
});
|
||||||
|
|
||||||
|
if (latestVersion && comparison !== null) {
|
||||||
|
const notifyKey = comparison > 0
|
||||||
|
? 'system_info.version_update_available'
|
||||||
|
: 'system_info.version_is_latest';
|
||||||
|
const notifyType = comparison > 0 ? 'warning' : 'success';
|
||||||
|
this.showNotification(i18n.t(notifyKey, { version: latestVersion }), notifyType);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = `${i18n.t('system_info.version_check_error')}: ${error.message}`;
|
||||||
|
this.renderVersionCheckStatus({
|
||||||
|
currentVersion: this.serverVersion,
|
||||||
|
latestVersion: this.latestVersion,
|
||||||
|
message,
|
||||||
|
status: 'error'
|
||||||
|
});
|
||||||
|
this.showNotification(message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = originalLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// API 请求方法
|
||||||
|
async makeRequest(endpoint, options = {}) {
|
||||||
|
try {
|
||||||
|
return await this.apiClient.request(endpoint, options);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API请求失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
buildAvailableModelsEndpoint() {
|
||||||
|
return buildModelsEndpoint(this.apiBase || this.apiClient?.apiBase || '');
|
||||||
|
},
|
||||||
|
|
||||||
|
setAvailableModelsStatus(message = '', type = 'info') {
|
||||||
|
const statusEl = document.getElementById('available-models-status');
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.textContent = message || '';
|
||||||
|
statusEl.className = `available-models-status ${type}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderAvailableModels(models = []) {
|
||||||
|
const listEl = document.getElementById('available-models-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
if (!models.length) {
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="available-models-empty">
|
||||||
|
<i class="fas fa-inbox"></i>
|
||||||
|
<span>${i18n.t('system_info.models_empty')}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const language = (i18n?.currentLanguage || '').toLowerCase();
|
||||||
|
const otherLabel = language.startsWith('zh') ? '其他' : 'Other';
|
||||||
|
const groups = classifyModels(models, { otherLabel });
|
||||||
|
|
||||||
|
const groupHtml = groups.map(group => {
|
||||||
|
const pills = group.items.map(model => {
|
||||||
|
const name = this.escapeHtml(model.name || '');
|
||||||
|
const alias = model.alias ? `<span class="model-alias">${this.escapeHtml(model.alias)}</span>` : '';
|
||||||
|
const description = model.description ? this.escapeHtml(model.description) : '';
|
||||||
|
const titleAttr = description ? ` title="${description}"` : '';
|
||||||
|
return `
|
||||||
|
<span class="provider-model-tag available-model-tag"${titleAttr}>
|
||||||
|
<span class="model-name">${name}</span>
|
||||||
|
${alias}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const label = this.escapeHtml(group.label || group.id || '');
|
||||||
|
return `
|
||||||
|
<div class="available-model-group">
|
||||||
|
<div class="available-model-group-header">
|
||||||
|
<div class="available-model-group-title">
|
||||||
|
<span class="available-model-group-label">${label}</span>
|
||||||
|
<span class="available-model-group-count">${group.items.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="available-model-group-body">
|
||||||
|
${pills}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
listEl.innerHTML = groupHtml;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAvailableModels(messageKey = 'system_info.models_empty') {
|
||||||
|
this.availableModels = [];
|
||||||
|
this.availableModelApiKeysCache = null;
|
||||||
|
const listEl = document.getElementById('available-models-list');
|
||||||
|
if (listEl) {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
this.setAvailableModelsStatus(i18n.t(messageKey), 'warning');
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolveApiKeysForModels({ config = null, forceRefresh = false } = {}) {
|
||||||
|
if (!forceRefresh && Array.isArray(this.availableModelApiKeysCache) && this.availableModelApiKeysCache.length) {
|
||||||
|
return this.availableModelApiKeysCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configKeys = normalizeApiKeyList(config?.['api-keys'] || this.configCache?.['api-keys']);
|
||||||
|
if (configKeys.length) {
|
||||||
|
this.availableModelApiKeysCache = configKeys;
|
||||||
|
return configKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.makeRequest('/api-keys');
|
||||||
|
const keys = normalizeApiKeyList(data?.['api-keys']);
|
||||||
|
if (keys.length) {
|
||||||
|
this.availableModelApiKeysCache = keys;
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('自动获取 API Key 失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAvailableModels({ config = null, forceRefresh = false } = {}) {
|
||||||
|
const listEl = document.getElementById('available-models-list');
|
||||||
|
const statusEl = document.getElementById('available-models-status');
|
||||||
|
|
||||||
|
if (!listEl || !statusEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isConnected) {
|
||||||
|
this.setAvailableModelsStatus(i18n.t('common.disconnected'), 'warning');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = this.buildAvailableModelsEndpoint();
|
||||||
|
if (!endpoint) {
|
||||||
|
this.setAvailableModelsStatus(i18n.t('system_info.models_error'), 'error');
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="available-models-empty">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<span>${i18n.t('login.error_invalid')}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.availableModelsLoading = true;
|
||||||
|
this.setAvailableModelsStatus(i18n.t('system_info.models_loading'), 'info');
|
||||||
|
listEl.innerHTML = '<div class="available-models-placeholder"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = {};
|
||||||
|
const keys = await this.resolveApiKeysForModels({ config, forceRefresh });
|
||||||
|
if (keys.length) {
|
||||||
|
headers.Authorization = `Bearer ${keys[0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, { headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(text || err.message || 'Invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = normalizeModelList(data, { dedupe: true });
|
||||||
|
this.availableModels = models;
|
||||||
|
|
||||||
|
if (!models.length) {
|
||||||
|
this.setAvailableModelsStatus(i18n.t('system_info.models_empty'), 'warning');
|
||||||
|
this.renderAvailableModels([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setAvailableModelsStatus(i18n.t('system_info.models_count', { count: models.length }), 'success');
|
||||||
|
this.renderAvailableModels(models);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载可用模型失败:', error);
|
||||||
|
this.availableModels = [];
|
||||||
|
this.setAvailableModelsStatus(`${i18n.t('system_info.models_error')}: ${error.message}`, 'error');
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="available-models-empty">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
<span>${this.escapeHtml(error.message || '')}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} finally {
|
||||||
|
this.availableModelsLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 测试连接(简化版,用于内部调用)
|
||||||
|
async testConnection() {
|
||||||
|
try {
|
||||||
|
await this.makeRequest('/debug');
|
||||||
|
this.isConnected = true;
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
this.startStatusUpdateTimer();
|
||||||
|
await this.loadAllData();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.isConnected = false;
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
this.stopStatusUpdateTimer();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新连接状态
|
||||||
|
updateConnectionStatus() {
|
||||||
|
const statusButton = document.getElementById('connection-status');
|
||||||
|
const apiStatus = document.getElementById('api-status');
|
||||||
|
const configStatus = document.getElementById('config-status');
|
||||||
|
const lastUpdate = document.getElementById('last-update');
|
||||||
|
|
||||||
|
if (this.isConnected) {
|
||||||
|
statusButton.innerHTML = `<i class="fas fa-circle connection-indicator connected"></i> ${i18n.t('common.connected')}`;
|
||||||
|
statusButton.className = 'btn btn-success';
|
||||||
|
apiStatus.textContent = i18n.t('common.connected');
|
||||||
|
|
||||||
|
// 更新配置状态
|
||||||
|
if (this.isCacheValid()) {
|
||||||
|
const fullTimestamp = this.cacheTimestamps && this.cacheTimestamps['__full__'];
|
||||||
|
const cacheAge = fullTimestamp
|
||||||
|
? Math.floor((Date.now() - fullTimestamp) / 1000)
|
||||||
|
: 0;
|
||||||
|
configStatus.textContent = `${i18n.t('system_info.cache_data')} (${cacheAge}${i18n.t('system_info.seconds_ago')})`;
|
||||||
|
configStatus.style.color = '#f59e0b'; // 橙色表示缓存
|
||||||
|
} else if (this.configCache && this.configCache['__full__']) {
|
||||||
|
configStatus.textContent = i18n.t('system_info.real_time_data');
|
||||||
|
configStatus.style.color = '#10b981'; // 绿色表示实时
|
||||||
|
} else {
|
||||||
|
configStatus.textContent = i18n.t('system_info.not_loaded');
|
||||||
|
configStatus.style.color = '#6b7280'; // 灰色表示未加载
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statusButton.innerHTML = `<i class="fas fa-circle connection-indicator disconnected"></i> ${i18n.t('common.disconnected')}`;
|
||||||
|
statusButton.className = 'btn btn-danger';
|
||||||
|
apiStatus.textContent = i18n.t('common.disconnected');
|
||||||
|
configStatus.textContent = i18n.t('system_info.not_loaded');
|
||||||
|
configStatus.style.color = '#6b7280';
|
||||||
|
this.setAvailableModelsStatus(i18n.t('common.disconnected'), 'warning');
|
||||||
|
const modelsList = document.getElementById('available-models-list');
|
||||||
|
if (modelsList) {
|
||||||
|
modelsList.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdate.textContent = new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
if (this.lastEditorConnectionState !== this.isConnected) {
|
||||||
|
this.updateConfigEditorAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新连接信息显示
|
||||||
|
this.updateConnectionInfo();
|
||||||
|
|
||||||
|
if (this.events && typeof this.events.emit === 'function') {
|
||||||
|
const shouldEmit = this.lastConnectionStatusEmitted !== this.isConnected;
|
||||||
|
if (shouldEmit) {
|
||||||
|
this.events.emit('connection:status-changed', {
|
||||||
|
isConnected: this.isConnected,
|
||||||
|
apiBase: this.apiBase
|
||||||
|
});
|
||||||
|
this.lastConnectionStatusEmitted = this.isConnected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查连接状态
|
||||||
|
async checkConnectionStatus() {
|
||||||
|
await this.testConnection();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 刷新所有数据
|
||||||
|
async refreshAllData() {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
this.showNotification(i18n.t('notification.connection_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = document.getElementById('refresh-all');
|
||||||
|
const originalText = button.innerHTML;
|
||||||
|
|
||||||
|
button.innerHTML = `<div class="loading"></div> ${i18n.t('common.loading')}`;
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 强制刷新,清除缓存
|
||||||
|
await this.loadAllData(true);
|
||||||
|
this.showNotification(i18n.t('notification.data_refreshed'), 'success');
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification(`${i18n.t('notification.refresh_failed')}: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
button.innerHTML = originalText;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查缓存是否有效
|
||||||
|
isCacheValid(section = null) {
|
||||||
|
return this.configService.isCacheValid(section);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取配置(优先使用缓存,支持按段获取)
|
||||||
|
async getConfig(section = null, forceRefresh = false) {
|
||||||
|
try {
|
||||||
|
const config = await this.configService.getConfig(section, forceRefresh);
|
||||||
|
this.configCache = this.configService.cache;
|
||||||
|
this.cacheTimestamps = this.configService.cacheTimestamps;
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
return config;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取配置失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除缓存(支持清除特定配置段)
|
||||||
|
clearCache(section = null) {
|
||||||
|
this.configService.clearCache(section);
|
||||||
|
this.configCache = this.configService.cache;
|
||||||
|
this.cacheTimestamps = this.configService.cacheTimestamps;
|
||||||
|
if (!section || section === 'api-keys') {
|
||||||
|
this.availableModelApiKeysCache = null;
|
||||||
|
}
|
||||||
|
if (!section) {
|
||||||
|
this.configYamlCache = '';
|
||||||
|
this.availableModels = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 启动状态更新定时器
|
||||||
|
startStatusUpdateTimer() {
|
||||||
|
if (this.statusUpdateTimer) {
|
||||||
|
clearInterval(this.statusUpdateTimer);
|
||||||
|
}
|
||||||
|
this.statusUpdateTimer = setInterval(() => {
|
||||||
|
if (this.isConnected) {
|
||||||
|
this.updateConnectionStatus();
|
||||||
|
}
|
||||||
|
}, STATUS_UPDATE_INTERVAL_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 停止状态更新定时器
|
||||||
|
stopStatusUpdateTimer() {
|
||||||
|
if (this.statusUpdateTimer) {
|
||||||
|
clearInterval(this.statusUpdateTimer);
|
||||||
|
this.statusUpdateTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 加载所有数据 - 使用新的 /config 端点一次性获取所有配置
|
||||||
|
async loadAllData(forceRefresh = false) {
|
||||||
|
try {
|
||||||
|
console.log(i18n.t('system_info.real_time_data'));
|
||||||
|
// 使用新的 /config 端点一次性获取所有配置
|
||||||
|
// 注意:getConfig(section, forceRefresh),不传 section 表示获取全部
|
||||||
|
const config = await this.getConfig(null, forceRefresh);
|
||||||
|
|
||||||
|
// 获取一次usage统计数据,供渲染函数和loadUsageStats复用
|
||||||
|
let usageData = null;
|
||||||
|
let keyStats = null;
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('/usage');
|
||||||
|
usageData = response?.usage || null;
|
||||||
|
if (usageData) {
|
||||||
|
keyStats = await this.getKeyStats(usageData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('获取usage统计失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从配置中提取并设置各个设置项(现在传递keyStats)
|
||||||
|
await this.updateSettingsFromConfig(config, keyStats);
|
||||||
|
|
||||||
|
await this.loadAvailableModels({ config, forceRefresh });
|
||||||
|
|
||||||
|
if (this.events && typeof this.events.emit === 'function') {
|
||||||
|
this.events.emit('data:config-loaded', {
|
||||||
|
config,
|
||||||
|
usageData,
|
||||||
|
keyStats,
|
||||||
|
forceRefresh
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('配置加载完成,使用缓存:', !forceRefresh && this.isCacheValid());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载配置失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 从配置对象更新所有设置 —— 委派给 settings 模块,保持兼容旧调用
|
||||||
|
async updateSettingsFromConfig(config, keyStats = null) {
|
||||||
|
if (typeof this.applySettingsFromConfig === 'function') {
|
||||||
|
return this.applySettingsFromConfig(config, keyStats);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
detectApiBaseFromLocation() {
|
||||||
|
try {
|
||||||
|
const { protocol, hostname, port } = window.location;
|
||||||
|
const normalizedPort = port ? `:${port}` : '';
|
||||||
|
return this.normalizeBase(`${protocol}//${hostname}${normalizedPort}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('无法从当前地址检测 API 基础地址,使用默认设置', error);
|
||||||
|
return this.normalizeBase(this.apiBase || `http://localhost:${DEFAULT_API_PORT}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
231
src/core/error-handler.js
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* 错误处理器
|
||||||
|
* 统一管理应用中的错误处理逻辑
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ERROR_MESSAGES } from '../utils/constants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误处理器类
|
||||||
|
* 提供统一的错误处理接口,确保错误处理的一致性
|
||||||
|
*/
|
||||||
|
export class ErrorHandler {
|
||||||
|
/**
|
||||||
|
* 构造错误处理器
|
||||||
|
* @param {Object} notificationService - 通知服务对象
|
||||||
|
* @param {Function} notificationService.show - 显示通知的方法
|
||||||
|
*/
|
||||||
|
constructor(notificationService) {
|
||||||
|
this.notificationService = notificationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理更新操作失败
|
||||||
|
* 包括显示错误通知和执行UI回滚操作
|
||||||
|
*
|
||||||
|
* @param {Error} error - 错误对象
|
||||||
|
* @param {string} context - 操作上下文(如"调试模式"、"代理设置")
|
||||||
|
* @param {Function} [rollbackFn] - UI回滚函数
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* try {
|
||||||
|
* await this.makeRequest('/debug', { method: 'PATCH', body: JSON.stringify({ enabled: true }) });
|
||||||
|
* } catch (error) {
|
||||||
|
* this.errorHandler.handleUpdateError(
|
||||||
|
* error,
|
||||||
|
* '调试模式',
|
||||||
|
* () => document.getElementById('debug-toggle').checked = false
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
handleUpdateError(error, context, rollbackFn) {
|
||||||
|
console.error(`更新${context}失败:`, error);
|
||||||
|
const message = `更新${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`;
|
||||||
|
this.notificationService.show(message, 'error');
|
||||||
|
|
||||||
|
// 执行回滚操作
|
||||||
|
if (typeof rollbackFn === 'function') {
|
||||||
|
try {
|
||||||
|
rollbackFn();
|
||||||
|
} catch (rollbackError) {
|
||||||
|
console.error('UI回滚操作失败:', rollbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理加载操作失败
|
||||||
|
*
|
||||||
|
* @param {Error} error - 错误对象
|
||||||
|
* @param {string} context - 加载内容的上下文(如"API密钥"、"使用统计")
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* try {
|
||||||
|
* const data = await this.makeRequest('/api-keys');
|
||||||
|
* this.renderApiKeys(data);
|
||||||
|
* } catch (error) {
|
||||||
|
* this.errorHandler.handleLoadError(error, 'API密钥');
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
handleLoadError(error, context) {
|
||||||
|
console.error(`加载${context}失败:`, error);
|
||||||
|
const message = `加载${context}失败,请检查连接`;
|
||||||
|
this.notificationService.show(message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理删除操作失败
|
||||||
|
*
|
||||||
|
* @param {Error} error - 错误对象
|
||||||
|
* @param {string} context - 删除内容的上下文
|
||||||
|
*/
|
||||||
|
handleDeleteError(error, context) {
|
||||||
|
console.error(`删除${context}失败:`, error);
|
||||||
|
const message = `删除${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`;
|
||||||
|
this.notificationService.show(message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理添加操作失败
|
||||||
|
*
|
||||||
|
* @param {Error} error - 错误对象
|
||||||
|
* @param {string} context - 添加内容的上下文
|
||||||
|
*/
|
||||||
|
handleAddError(error, context) {
|
||||||
|
console.error(`添加${context}失败:`, error);
|
||||||
|
const message = `添加${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`;
|
||||||
|
this.notificationService.show(message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理网络错误
|
||||||
|
* 检测常见的网络问题并提供友好的错误提示
|
||||||
|
*
|
||||||
|
* @param {Error} error - 错误对象
|
||||||
|
*/
|
||||||
|
handleNetworkError(error) {
|
||||||
|
console.error('网络请求失败:', error);
|
||||||
|
|
||||||
|
let message = ERROR_MESSAGES.NETWORK_ERROR;
|
||||||
|
|
||||||
|
// 检测特定错误类型
|
||||||
|
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||||
|
message = ERROR_MESSAGES.NETWORK_ERROR;
|
||||||
|
} else if (error.message && error.message.includes('timeout')) {
|
||||||
|
message = ERROR_MESSAGES.TIMEOUT;
|
||||||
|
} else if (error.message && error.message.includes('401')) {
|
||||||
|
message = ERROR_MESSAGES.UNAUTHORIZED;
|
||||||
|
} else if (error.message && error.message.includes('404')) {
|
||||||
|
message = ERROR_MESSAGES.NOT_FOUND;
|
||||||
|
} else if (error.message && error.message.includes('500')) {
|
||||||
|
message = ERROR_MESSAGES.SERVER_ERROR;
|
||||||
|
} else if (error.message) {
|
||||||
|
message = `网络错误: ${error.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notificationService.show(message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理验证错误
|
||||||
|
*
|
||||||
|
* @param {string} fieldName - 字段名称
|
||||||
|
* @param {string} [message] - 自定义错误消息
|
||||||
|
*/
|
||||||
|
handleValidationError(fieldName, message) {
|
||||||
|
const errorMessage = message || `请输入有效的${fieldName}`;
|
||||||
|
this.notificationService.show(errorMessage, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理通用错误
|
||||||
|
* 当错误类型不明确时使用
|
||||||
|
*
|
||||||
|
* @param {Error} error - 错误对象
|
||||||
|
* @param {string} [defaultMessage] - 默认错误消息
|
||||||
|
*/
|
||||||
|
handleGenericError(error, defaultMessage) {
|
||||||
|
console.error('操作失败:', error);
|
||||||
|
const message = error.message || defaultMessage || ERROR_MESSAGES.OPERATION_FAILED;
|
||||||
|
this.notificationService.show(message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带错误处理的异步函数包装器
|
||||||
|
* 自动捕获并处理错误
|
||||||
|
*
|
||||||
|
* @param {Function} asyncFn - 异步函数
|
||||||
|
* @param {string} context - 操作上下文
|
||||||
|
* @param {Function} [rollbackFn] - 回滚函数
|
||||||
|
* @returns {Function} 包装后的函数
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const safeUpdate = this.errorHandler.withErrorHandling(
|
||||||
|
* () => this.makeRequest('/debug', { method: 'PATCH', body: '...' }),
|
||||||
|
* '调试模式',
|
||||||
|
* () => document.getElementById('debug-toggle').checked = false
|
||||||
|
* );
|
||||||
|
* await safeUpdate();
|
||||||
|
*/
|
||||||
|
withErrorHandling(asyncFn, context, rollbackFn) {
|
||||||
|
return async (...args) => {
|
||||||
|
try {
|
||||||
|
return await asyncFn(...args);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleUpdateError(error, context, rollbackFn);
|
||||||
|
throw error; // 重新抛出以便调用者处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带重试机制的错误处理包装器
|
||||||
|
*
|
||||||
|
* @param {Function} asyncFn - 异步函数
|
||||||
|
* @param {number} [maxRetries=3] - 最大重试次数
|
||||||
|
* @param {number} [retryDelay=1000] - 重试延迟(毫秒)
|
||||||
|
* @returns {Function} 包装后的函数
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const retryableFetch = this.errorHandler.withRetry(
|
||||||
|
* () => this.makeRequest('/config'),
|
||||||
|
* 3,
|
||||||
|
* 2000
|
||||||
|
* );
|
||||||
|
* const config = await retryableFetch();
|
||||||
|
*/
|
||||||
|
withRetry(asyncFn, maxRetries = 3, retryDelay = 1000) {
|
||||||
|
return async (...args) => {
|
||||||
|
let lastError;
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await asyncFn(...args);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
console.warn(`尝试 ${attempt + 1}/${maxRetries} 失败:`, error);
|
||||||
|
|
||||||
|
if (attempt < maxRetries - 1) {
|
||||||
|
// 等待后重试
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有尝试都失败
|
||||||
|
throw lastError;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建错误处理器工厂函数
|
||||||
|
* 便于在不同模块中创建错误处理器实例
|
||||||
|
*
|
||||||
|
* @param {Function} showNotification - 显示通知的函数
|
||||||
|
* @returns {ErrorHandler} 错误处理器实例
|
||||||
|
*/
|
||||||
|
export function createErrorHandler(showNotification) {
|
||||||
|
return new ErrorHandler({
|
||||||
|
show: showNotification
|
||||||
|
});
|
||||||
|
}
|
||||||
10
src/core/event-bus.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// 轻量事件总线,避免模块之间的直接耦合
|
||||||
|
export function createEventBus() {
|
||||||
|
const target = new EventTarget();
|
||||||
|
|
||||||
|
const on = (type, listener) => target.addEventListener(type, listener);
|
||||||
|
const off = (type, listener) => target.removeEventListener(type, listener);
|
||||||
|
const emit = (type, detail = {}) => target.dispatchEvent(new CustomEvent(type, { detail }));
|
||||||
|
|
||||||
|
return { on, off, emit };
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hooks 统一导出
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { useApi } from './useApi';
|
|
||||||
export { useDebounce } from './useDebounce';
|
|
||||||
export { useLocalStorage } from './useLocalStorage';
|
|
||||||
export { useInterval } from './useInterval';
|
|
||||||
export { useMediaQuery } from './useMediaQuery';
|
|
||||||
export { usePagination } from './usePagination';
|
|
||||||
export { useHeaderRefresh } from './useHeaderRefresh';
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* 通用 API 调用 Hook
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { useNotificationStore } from '@/stores';
|
|
||||||
|
|
||||||
interface UseApiOptions<T> {
|
|
||||||
onSuccess?: (data: T) => void;
|
|
||||||
onError?: (error: Error) => void;
|
|
||||||
showSuccessNotification?: boolean;
|
|
||||||
showErrorNotification?: boolean;
|
|
||||||
successMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApi<T = any, Args extends any[] = any[]>(
|
|
||||||
apiFunction: (...args: Args) => Promise<T>,
|
|
||||||
options: UseApiOptions<T> = {}
|
|
||||||
) {
|
|
||||||
const [data, setData] = useState<T | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
|
|
||||||
const { showNotification } = useNotificationStore();
|
|
||||||
|
|
||||||
const execute = useCallback(
|
|
||||||
async (...args: Args) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await apiFunction(...args);
|
|
||||||
setData(result);
|
|
||||||
|
|
||||||
if (options.showSuccessNotification && options.successMessage) {
|
|
||||||
showNotification(options.successMessage, 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
options.onSuccess?.(result);
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
const errorObj = err as Error;
|
|
||||||
setError(errorObj);
|
|
||||||
|
|
||||||
if (options.showErrorNotification !== false) {
|
|
||||||
showNotification(errorObj.message, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
options.onError?.(errorObj);
|
|
||||||
throw errorObj;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[apiFunction, options, showNotification]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setData(null);
|
|
||||||
setError(null);
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { data, loading, error, execute, reset };
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* 防抖 Hook
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function useDebounce<T>(value: T, delay: number = 500): T {
|
|
||||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => {
|
|
||||||
setDebouncedValue(value);
|
|
||||||
}, delay);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(handler);
|
|
||||||
};
|
|
||||||
}, [value, delay]);
|
|
||||||
|
|
||||||
return debouncedValue;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
export type HeaderRefreshHandler = () => void | Promise<void>;
|
|
||||||
|
|
||||||
let activeHeaderRefreshHandler: HeaderRefreshHandler | null = null;
|
|
||||||
|
|
||||||
export const triggerHeaderRefresh = async () => {
|
|
||||||
if (!activeHeaderRefreshHandler) return;
|
|
||||||
await activeHeaderRefreshHandler();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!handler) return;
|
|
||||||
|
|
||||||
activeHeaderRefreshHandler = handler;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (activeHeaderRefreshHandler === handler) {
|
|
||||||
activeHeaderRefreshHandler = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [handler]);
|
|
||||||
};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* 定时器 Hook
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
export function useInterval(callback: () => void, delay: number | null) {
|
|
||||||
const savedCallback = useRef<(() => void) | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
savedCallback.current = callback;
|
|
||||||
}, [callback]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (delay === null) return;
|
|
||||||
|
|
||||||
const tick = () => {
|
|
||||||
savedCallback.current?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const id = setInterval(tick, delay);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [delay]);
|
|
||||||
}
|
|
||||||