Compare commits

...

42 Commits

Author SHA1 Message Date
LTbinglingfeng
76e9eb4aa0 feat(auth-files): add disabled state styling for file cards 2026-01-25 00:01:15 +08:00
LTbinglingfeng
f22d392b21 fix 2026-01-24 18:04:59 +08:00
LTbinglingfeng
2539710075 fix(status-bar): extend health monitor window to 200 minutes 2026-01-24 17:17:29 +08:00
LTbinglingfeng
6bdc87aed6 fix(quota): unify Gemini CLI quota groups (Flash/Pro series) 2026-01-24 16:35:59 +08:00
LTbinglingfeng
268b92c59b feat(ui): implement custom AutocompleteInput and refactor model mapping UI 2026-01-24 15:55:31 +08:00
LTbinglingfeng
c89bbd5098 feat(auth-files): add auth-file model suggestions for OAuth mappings 2026-01-24 15:30:45 +08:00
LTbinglingfeng
2715f44a5e fix(ui): use crossfade animation with subtle movement for page transitions 2026-01-24 14:16:58 +08:00
LTbinglingfeng
305ddef900 fix(ui): improve GSAP page transition smoothness 2026-01-24 14:03:15 +08:00
LTbinglingfeng
7e56d33bf0 feat(auth-files): add prefix/proxy_url modal editor 2026-01-24 01:24:05 +08:00
LTbinglingfeng
80daf03fa6 feat(auth-files): add per-file enable/disable toggle 2026-01-24 00:10:04 +08:00
LTbinglingfeng
883059b031 fix(auth-files): fix deleting OAuth model mappings providers 2026-01-19 23:29:11 +08:00
LTbinglingfeng
d077b5dd26 fix(ui): use fixed-length key masking and fingerprint usage sources 2026-01-19 00:41:11 +08:00
Supra4E8C
d79ccc480d fix: prevent focus loss in OAuth model mappings input 2026-01-17 15:41:56 +08:00
Supra4E8C
7b0d6dc7e9 fix: prevent async confirmation races in API key deletion 2026-01-17 15:31:35 +08:00
Supra4E8C
b8d7b8997c feat(ui): implement global ConfirmationModal to replace native window.confirm 2026-01-17 14:59:46 +08:00
Supra4E8C
0bb34ca74b fix(auth-files): send aliases for oauth model alias patch 2026-01-17 14:34:57 +08:00
hkfires
99c4fbc30d fix(api): use oauth model alias endpoints 2026-01-16 09:13:38 +08:00
Supra4E8C
a44257edda fix(antigravity): enhance error handling and support multiple request bodies 2026-01-14 17:13:07 +08:00
Supra4E8C
ebb80df24a fix(quota): include project_id in antigravity quota requests 2026-01-14 16:44:36 +08:00
LTbinglingfeng
5165715d37 fix: 调整登录页面的重定向逻辑和键盘事件处理顺序 2026-01-10 23:10:30 +08:00
Supra4E8C
73ee6eb2f3 fix(ai-providers): keep custom header editing stable in modals 2026-01-10 14:00:50 +08:00
Supra4E8C
161d5d1e7f Merge pull request #49 from sunday-ma/feature/fix-login-enter-key
fix: 添加登录表单 Enter 键提交功能
2026-01-08 19:16:48 +08:00
Sunny
3cbd04b296 Update src/pages/LoginPage.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-08 14:27:33 +08:00
Sunny
859f7f120c Update src/pages/LoginPage.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-08 14:27:18 +08:00
sunday-ma
fea29f7318 fix: 添加登录表单 Enter 键提交功能 2026-01-08 14:16:38 +08:00
Supra4E8C
f663b83ac8 feat(auth-files): normalize OAuth excluded models handling and update related API methods 2026-01-07 12:26:33 +08:00
LTbinglingfeng
ee99836285 Revert "feat(auth-files): add external migration modal for antigravity credentials"
This reverts commit 2086c348a9.
2026-01-07 00:02:45 +08:00
Supra4E8C
2086c348a9 feat(auth-files): add external migration modal for antigravity credentials 2026-01-06 18:21:34 +08:00
LTbinglingfeng
a8abf71bfe fix(settings): align log size and routing update controls 2026-01-06 00:30:06 +08:00
Supra4E8C
8dca670358 feat: add vertex provider, oauth model mappings, and routing/log settings 2026-01-05 19:03:05 +08:00
Supra4E8C
71556a51c5 fix(usage): prevent gaps in request trend fill by matching point colors 2026-01-05 17:32:01 +08:00
LTbinglingfeng
2a92ea8862 feat(AuthFilesPage): add title section with file count badge 2026-01-05 00:18:35 +08:00
LTbinglingfeng
681fc3cee5 fix(quota): cap per-page credentials to 14 2026-01-05 00:00:22 +08:00
Supra4E8C
916dd3ec26 Merge pull request #44 from moxi000/dev
feat: 优化配额管理页面 UI 与交互
2026-01-04 23:38:44 +08:00
moxi
692f7f3cde fix(quota): allow refresh without creds 2026-01-04 18:48:27 +08:00
Supra4E8C
bf20f3d86e fix(PageTransition): prevent unnecessary execution in useEffect when pathname matches 2026-01-04 18:25:54 +08:00
Supra4E8C
b7e720133d feat(auth-files): add file size validation for uploads 2026-01-04 18:14:18 +08:00
moxi
e914337e57 feat(button): enhance button component to conditionally render children
- Added a check to determine if children are present before rendering them in the button.
- Improved button rendering logic for better handling of empty or false children values.
2026-01-04 01:12:48 +08:00
moxi
6364bac1f2 feat(quota): improve refresh button functionality and update translations
- Added a new `isRefreshing` state to streamline loading logic for the refresh button.
- Updated the refresh button's disabled and loading states for better user experience.
- Simplified the refresh button content display.
- Revised translations for the refresh action in both English and Chinese locales.
- Enhanced styles for button alignment and SVG display.
2026-01-04 01:05:58 +08:00
moxi
38a3e20427 feat(quota): enhance QuotaSection with improved view mode handling and refresh functionality
- Introduced effective view mode logic to manage 'paged' and 'all' views based on file count.
- Added a warning for too many files when in 'all' view, prompting users to switch to 'paged'.
- Updated refresh button to handle loading states more effectively and provide clearer user feedback.
- Enhanced UI with new translations for view modes and refresh actions.
- Adjusted styles for better alignment and spacing in the view mode toggle and refresh button.
2026-01-04 00:45:34 +08:00
moxi
334d75f2dd fix: lint error 2026-01-04 00:04:36 +08:00
moxi
42eb783395 feat: 优化配额管理页面 UI 与交互
- 卡片布局改为 CSS Grid 自适应,最小宽度 380px,支持 1080p 下显示 4 列
- 分页控件重构:移除数字输入框,改为 [按页显示] / [显示全部] 切换按钮
- 动态计算每页数量:按页模式固定显示 3 行(行数 * 动态列数)
- Header 布局优化:凭证计数移至标题旁(淡蓝色气泡),刷新按钮合并为图标
- 安全限制:凭证数超过 30 个时禁用显示全部功能并弹窗提示
2026-01-03 22:43:58 +08:00
55 changed files with 4458 additions and 1425 deletions

29
package-lock.json generated
View File

@@ -71,6 +71,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -465,6 +466,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -1930,6 +1932,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2017,6 +2020,7 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1", "@typescript-eslint/types": "8.48.1",
@@ -2334,6 +2338,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2545,6 +2550,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@kurkle/color": "^0.3.0" "@kurkle/color": "^0.3.0"
}, },
@@ -2809,6 +2815,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3285,6 +3292,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4" "@babel/runtime": "^7.28.4"
}, },
@@ -3614,6 +3622,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3720,6 +3729,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3737,6 +3747,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -3780,9 +3791,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.10.1", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -3802,12 +3813,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.10.1", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.10.1" "react-router": "7.12.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -3845,6 +3856,7 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -4027,6 +4039,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4103,6 +4116,7 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -4244,6 +4258,7 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"", "format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { HashRouter, Route, Routes } from 'react-router-dom'; import { HashRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { NotificationContainer } from '@/components/common/NotificationContainer'; import { NotificationContainer } from '@/components/common/NotificationContainer';
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
import { SplashScreen } from '@/components/common/SplashScreen'; import { SplashScreen } from '@/components/common/SplashScreen';
import { MainLayout } from '@/components/layout/MainLayout'; import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute'; import { ProtectedRoute } from '@/router/ProtectedRoute';
@@ -61,6 +62,7 @@ function App() {
return ( return (
<HashRouter> <HashRouter>
<NotificationContainer /> <NotificationContainer />
<ConfirmationModal />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route

View File

@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useNotificationStore } from '@/stores';
export function ConfirmationModal() {
const { t } = useTranslation();
const confirmation = useNotificationStore((state) => state.confirmation);
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
const { isOpen, isLoading, options } = confirmation;
if (!isOpen || !options) {
return null;
}
const { title, message, onConfirm, onCancel, confirmText, cancelText, variant = 'primary' } = options;
const handleConfirm = async () => {
try {
setConfirmationLoading(true);
await onConfirm();
hideConfirmation();
} catch (error) {
console.error('Confirmation action failed:', error);
// Optional: show error notification here if needed,
// but usually the calling component handles specific errors.
} finally {
setConfirmationLoading(false);
}
};
const handleCancel = () => {
if (isLoading) {
return;
}
if (onCancel) {
onCancel();
}
hideConfirmation();
};
return (
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
<p style={{ margin: '1rem 0' }}>{message}</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
{cancelText || t('common.cancel')}
</Button>
<Button
variant={variant}
onClick={handleConfirm}
loading={isLoading}
>
{confirmText || t('common.confirm')}
</Button>
</div>
</Modal>
);
}

View File

@@ -14,6 +14,8 @@
gap: $spacing-lg; gap: $spacing-lg;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
backface-visibility: hidden;
transform: translateZ(0);
// During animation, exit layer uses absolute positioning // During animation, exit layer uses absolute positioning
&--exit { &--exit {
@@ -22,17 +24,15 @@
z-index: 1; z-index: 1;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
will-change: transform, opacity;
} }
} }
&--animating &__layer { &--animating &__layer {
will-change: transform, opacity; will-change: transform, opacity;
backface-visibility: hidden;
transform-style: preserve-3d;
} }
// When both layers exist, current layer also needs positioning &--animating &__layer:not(.page-transition__layer--exit) {
&--animating &__layer:not(&__layer--exit) {
position: relative; position: relative;
z-index: 0; z-index: 0;
} }

View File

@@ -9,9 +9,8 @@ interface PageTransitionProps {
scrollContainerRef?: React.RefObject<HTMLElement | null>; scrollContainerRef?: React.RefObject<HTMLElement | null>;
} }
const TRANSITION_DURATION = 0.5; const TRANSITION_DURATION = 0.35;
const EXIT_DURATION = 0.45; const TRAVEL_DISTANCE = 60;
const ENTER_DELAY = 0.08;
type LayerStatus = 'current' | 'exiting'; type LayerStatus = 'current' | 'exiting';
@@ -23,18 +22,14 @@ type Layer = {
type TransitionDirection = 'forward' | 'backward'; type TransitionDirection = 'forward' | 'backward';
export function PageTransition({ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: PageTransitionProps) {
render,
getRouteOrder,
scrollContainerRef,
}: PageTransitionProps) {
const location = useLocation(); const location = useLocation();
const currentLayerRef = useRef<HTMLDivElement>(null); const currentLayerRef = useRef<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null); const exitingLayerRef = useRef<HTMLDivElement>(null);
const transitionDirectionRef = useRef<TransitionDirection>('forward');
const exitScrollOffsetRef = useRef(0); const exitScrollOffsetRef = useRef(0);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
const [layers, setLayers] = useState<Layer[]>(() => [ const [layers, setLayers] = useState<Layer[]>(() => [
{ {
key: location.key, key: location.key,
@@ -54,6 +49,7 @@ export function PageTransition({
useEffect(() => { useEffect(() => {
if (isAnimating) return; if (isAnimating) return;
if (location.key === currentLayerKey) return; if (location.key === currentLayerKey) return;
if (currentLayerPathname === location.pathname) return;
const scrollContainer = resolveScrollContainer(); const scrollContainer = resolveScrollContainer();
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0; exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
const resolveOrderIndex = (pathname?: string) => { const resolveOrderIndex = (pathname?: string) => {
@@ -69,7 +65,12 @@ export function PageTransition({
: toIndex > fromIndex : toIndex > fromIndex
? 'forward' ? 'forward'
: 'backward'; : 'backward';
setTransitionDirection(nextDirection);
transitionDirectionRef.current = nextDirection;
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setLayers((prev) => { setLayers((prev) => {
const prevCurrent = prev[prev.length - 1]; const prevCurrent = prev[prev.length - 1];
return [ return [
@@ -80,6 +81,11 @@ export function PageTransition({
]; ];
}); });
setIsAnimating(true); setIsAnimating(true);
});
return () => {
cancelled = true;
};
}, [ }, [
isAnimating, isAnimating,
location, location,
@@ -95,17 +101,18 @@ export function PageTransition({
if (!currentLayerRef.current) return; if (!currentLayerRef.current) return;
const currentLayerEl = currentLayerRef.current;
const exitingLayerEl = exitingLayerRef.current;
const scrollContainer = resolveScrollContainer(); const scrollContainer = resolveScrollContainer();
const scrollOffset = exitScrollOffsetRef.current; const scrollOffset = exitScrollOffsetRef.current;
if (scrollContainer && scrollOffset > 0) { if (scrollContainer && scrollOffset > 0) {
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' }); scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
} }
const containerHeight = scrollContainer?.clientHeight ?? 0; const transitionDirection = transitionDirectionRef.current;
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight; const enterFromY = transitionDirection === 'forward' ? TRAVEL_DISTANCE : -TRAVEL_DISTANCE;
const travelDistance = Math.max(containerHeight, viewportHeight, 1); const exitToY = transitionDirection === 'forward' ? -TRAVEL_DISTANCE : TRAVEL_DISTANCE;
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
const exitBaseY = scrollOffset ? -scrollOffset : 0; const exitBaseY = scrollOffset ? -scrollOffset : 0;
const tl = gsap.timeline({ const tl = gsap.timeline({
@@ -115,43 +122,46 @@ export function PageTransition({
}, },
}); });
// Exit animation: fly out to top (slow-to-fast) // Exit animation: fade out with slight movement (runs simultaneously)
if (exitingLayerRef.current) { if (exitingLayerEl) {
gsap.set(exitingLayerRef.current, { y: exitBaseY }); gsap.set(exitingLayerEl, { y: exitBaseY });
tl.fromTo( tl.to(
exitingLayerRef.current, exitingLayerEl,
{ y: exitBaseY, opacity: 1 },
{ {
y: exitBaseY + exitToY, y: exitBaseY + exitToY,
opacity: 0, opacity: 0,
duration: EXIT_DURATION, duration: TRANSITION_DURATION,
ease: 'power2.in', // fast finish to clear screen ease: 'circ.out',
force3D: true, force3D: true,
}, },
0 0
); );
} }
// Enter animation: slide in from bottom (slow-to-fast) // Enter animation: fade in with slight movement (runs simultaneously)
tl.fromTo( tl.fromTo(
currentLayerRef.current, currentLayerEl,
{ y: enterFromY, opacity: 0 }, { y: enterFromY, opacity: 0 },
{ {
y: 0, y: 0,
opacity: 1, opacity: 1,
duration: TRANSITION_DURATION, duration: TRANSITION_DURATION,
ease: 'power2.out', // smooth settle ease: 'circ.out',
clearProps: 'transform,opacity',
force3D: true, force3D: true,
onComplete: () => {
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
}
}, },
ENTER_DELAY },
0
); );
return () => { return () => {
tl.kill(); tl.kill();
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]); gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
}; };
}, [isAnimating, transitionDirection, resolveScrollContainer]); }, [isAnimating, resolveScrollContainer]);
return ( return (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}> <div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>

View File

@@ -21,7 +21,7 @@ interface AmpcodeModalProps {
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) { export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue); const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache); const clearCache = useConfigStore((state) => state.clearCache);
@@ -81,7 +81,12 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]); }, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
const clearAmpcodeUpstreamApiKey = async () => { const clearAmpcodeUpstreamApiKey = async () => {
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return; 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); setSaving(true);
setError(''); setError('');
try { try {
@@ -99,14 +104,11 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
} finally { } finally {
setSaving(false); setSaving(false);
} }
},
});
}; };
const saveAmpcode = async () => { const performSaveAmpcode = async () => {
if (!loaded && mappingsDirty) {
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
if (!confirmed) return;
}
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
@@ -173,6 +175,21 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
} }
}; };
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 ( return (
<Modal <Modal
open={isOpen} open={isOpen}

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList'; import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import type { ProviderKeyConfig } from '@/types'; import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils'; import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types'; import type { ProviderFormState, ProviderModalProps } from '../types';
@@ -19,7 +19,7 @@ const buildEmptyForm = (): ProviderFormState => ({
prefix: '', prefix: '',
baseUrl: '', baseUrl: '',
proxyUrl: '', proxyUrl: '',
headers: {}, headers: [],
models: [], models: [],
excludedModels: [], excludedModels: [],
modelEntries: [{ name: '', alias: '' }], modelEntries: [{ name: '', alias: '' }],
@@ -43,7 +43,7 @@ export function ClaudeModal({
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setForm({ setForm({
...initialData, ...initialData,
headers: initialData.headers ?? {}, headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models), modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels), excludedText: excludedModelsToText(initialData.excludedModels),
}); });
@@ -95,8 +95,8 @@ export function ClaudeModal({
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/> />
<HeaderInputList <HeaderInputList
entries={headersToEntries(form.headers)} entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))} onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}

View File

@@ -6,7 +6,12 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconClaude from '@/assets/icons/claude.svg'; import iconClaude from '@/assets/icons/claude.svg';
import type { ProviderKeyConfig } from '@/types'; import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -55,11 +60,19 @@ export function ClaudeSection({
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey)); configs.forEach((config) => {
allApiKeys.forEach((apiKey) => { if (!config.apiKey) return;
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey)); 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; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
@@ -99,12 +112,11 @@ export function ClaudeSection({
/> />
)} )}
renderContent={(item) => { renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels); const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? []; const excludedModels = item.excludedModels ?? [];
const statusData = const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return ( return (
<Fragment> <Fragment>

View File

@@ -5,7 +5,7 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import type { ProviderKeyConfig } from '@/types'; import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { headersToEntries } from '@/utils/headers';
import { modelsToEntries } from '@/components/ui/ModelInputList'; import { modelsToEntries } from '@/components/ui/ModelInputList';
import { excludedModelsToText } from '../utils'; import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types'; import type { ProviderFormState, ProviderModalProps } from '../types';
@@ -19,7 +19,7 @@ const buildEmptyForm = (): ProviderFormState => ({
prefix: '', prefix: '',
baseUrl: '', baseUrl: '',
proxyUrl: '', proxyUrl: '',
headers: {}, headers: [],
models: [], models: [],
excludedModels: [], excludedModels: [],
modelEntries: [{ name: '', alias: '' }], modelEntries: [{ name: '', alias: '' }],
@@ -43,7 +43,7 @@ export function CodexModal({
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setForm({ setForm({
...initialData, ...initialData,
headers: initialData.headers ?? {}, headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models), modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels), excludedText: excludedModelsToText(initialData.excludedModels),
}); });
@@ -95,8 +95,8 @@ export function CodexModal({
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/> />
<HeaderInputList <HeaderInputList
entries={headersToEntries(form.headers)} entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))} onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}

View File

@@ -7,7 +7,12 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import type { ProviderKeyConfig } from '@/types'; import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -58,11 +63,19 @@ export function CodexSection({
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey)); configs.forEach((config) => {
allApiKeys.forEach((apiKey) => { if (!config.apiKey) return;
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey)); 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; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
@@ -106,12 +119,11 @@ export function CodexSection({
/> />
)} )}
renderContent={(item) => { renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels); const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? []; const excludedModels = item.excludedModels ?? [];
const statusData = const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return ( return (
<Fragment> <Fragment>

View File

@@ -5,7 +5,7 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import type { GeminiKeyConfig } from '@/types'; import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils'; import { excludedModelsToText } from '../utils';
import type { GeminiFormState, ProviderModalProps } from '../types'; import type { GeminiFormState, ProviderModalProps } from '../types';
@@ -17,7 +17,7 @@ const buildEmptyForm = (): GeminiFormState => ({
apiKey: '', apiKey: '',
prefix: '', prefix: '',
baseUrl: '', baseUrl: '',
headers: {}, headers: [],
excludedModels: [], excludedModels: [],
excludedText: '', excludedText: '',
}); });
@@ -39,7 +39,7 @@ export function GeminiModal({
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setForm({ setForm({
...initialData, ...initialData,
headers: initialData.headers ?? {}, headers: headersToEntries(initialData.headers),
excludedText: excludedModelsToText(initialData.excludedModels), excludedText: excludedModelsToText(initialData.excludedModels),
}); });
return; return;
@@ -91,8 +91,8 @@ export function GeminiModal({
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/> />
<HeaderInputList <HeaderInputList
entries={headersToEntries(form.headers)} entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))} onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}

View File

@@ -6,7 +6,12 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconGemini from '@/assets/icons/gemini.svg'; import iconGemini from '@/assets/icons/gemini.svg';
import type { GeminiKeyConfig } from '@/types'; import type { GeminiKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import type { GeminiFormState } from '../types'; import type { GeminiFormState } from '../types';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
@@ -55,11 +60,19 @@ export function GeminiSection({
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey)); configs.forEach((config) => {
allApiKeys.forEach((apiKey) => { if (!config.apiKey) return;
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey)); 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; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
@@ -99,12 +112,11 @@ export function GeminiSection({
/> />
)} )}
renderContent={(item, index) => { renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels); const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? []; const excludedModels = item.excludedModels ?? [];
const statusData = const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return ( return (
<Fragment> <Fragment>

View File

@@ -7,7 +7,12 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import type { OpenAIProviderConfig } from '@/types'; import type { OpenAIProviderConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
@@ -57,8 +62,15 @@ export function OpenAISection({
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((provider) => { configs.forEach((provider) => {
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean); const sourceIds = new Set<string>();
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source)); 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)); cache.set(provider.name, calculateStatusBarData(filteredDetails));
}); });
@@ -96,7 +108,7 @@ export function OpenAISection({
onDelete={onDelete} onDelete={onDelete}
actionsDisabled={actionsDisabled} actionsDisabled={actionsDisabled}
renderContent={(item) => { renderContent={(item) => {
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey); const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const apiKeyEntries = item.apiKeyEntries || []; const apiKeyEntries = item.apiKeyEntries || [];
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]); const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
@@ -130,7 +142,7 @@ export function OpenAISection({
</div> </div>
<div className={styles.apiKeyEntryList}> <div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => { {apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey); const entryStats = getStatsBySource(entry.apiKey, keyStats);
return ( return (
<div key={entryIndex} className={styles.apiKeyEntryCard}> <div key={entryIndex} className={styles.apiKeyEntryCard}>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span> <span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { 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>
);
}

View File

@@ -0,0 +1,182 @@
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}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { VertexSection } from './VertexSection';

View File

@@ -3,6 +3,7 @@ export { ClaudeSection } from './ClaudeSection';
export { CodexSection } from './CodexSection'; export { CodexSection } from './CodexSection';
export { GeminiSection } from './GeminiSection'; export { GeminiSection } from './GeminiSection';
export { OpenAISection } from './OpenAISection'; export { OpenAISection } from './OpenAISection';
export { VertexSection } from './VertexSection';
export { ProviderList } from './ProviderList'; export { ProviderList } from './ProviderList';
export { ProviderStatusBar } from './ProviderStatusBar'; export { ProviderStatusBar } from './ProviderStatusBar';
export * from './hooks/useProviderStats'; export * from './hooks/useProviderStats';

View File

@@ -6,6 +6,7 @@ export type ProviderModal =
| { type: 'gemini'; index: number | null } | { type: 'gemini'; index: number | null }
| { type: 'codex'; index: number | null } | { type: 'codex'; index: number | null }
| { type: 'claude'; index: number | null } | { type: 'claude'; index: number | null }
| { type: 'vertex'; index: number | null }
| { type: 'ampcode'; index: null } | { type: 'ampcode'; index: null }
| { type: 'openai'; index: number | null }; | { type: 'openai'; index: number | null };
@@ -31,13 +32,22 @@ export interface AmpcodeFormState {
mappingEntries: ModelEntry[]; mappingEntries: ModelEntry[];
} }
export type GeminiFormState = GeminiKeyConfig & { excludedText: string }; export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
headers: HeaderEntry[];
excludedText: string;
};
export type ProviderFormState = ProviderKeyConfig & { export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[]; modelEntries: ModelEntry[];
excludedText: string; excludedText: string;
}; };
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[];
};
export interface ProviderSectionProps<TConfig> { export interface ProviderSectionProps<TConfig> {
configs: TConfig[]; configs: TConfig[];
keyStats: KeyStats; keyStats: KeyStats;

View File

@@ -1,5 +1,5 @@
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types'; import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
import type { KeyStatBucket, KeyStats } from '@/utils/usage'; import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
import type { AmpcodeFormState, ModelEntry } from './types'; import type { AmpcodeFormState, ModelEntry } from './types';
export const DISABLE_ALL_MODELS_RULE = '*'; export const DISABLE_ALL_MODELS_RULE = '*';
@@ -46,7 +46,7 @@ export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => { export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeOpenAIBaseUrl(baseUrl); const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return ''; if (!trimmed) return '';
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`; return `${trimmed}/models`;
}; };
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => { export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
@@ -62,33 +62,50 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
export const getStatsBySource = ( export const getStatsBySource = (
apiKey: string, apiKey: string,
keyStats: KeyStats, keyStats: KeyStats,
maskFn: (key: string) => string prefix?: string
): KeyStatBucket => { ): KeyStatBucket => {
const bySource = keyStats.bySource ?? {}; const bySource = keyStats.bySource ?? {};
const masked = maskFn(apiKey); const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 }; 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 的统计 - 与旧版逻辑一致 // 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = ( export const getOpenAIProviderStats = (
apiKeyEntries: ApiKeyEntry[] | undefined, apiKeyEntries: ApiKeyEntry[] | undefined,
keyStats: KeyStats, keyStats: KeyStats,
maskFn: (key: string) => string providerPrefix?: string
): KeyStatBucket => { ): KeyStatBucket => {
const bySource = keyStats.bySource ?? {}; const bySource = keyStats.bySource ?? {};
let totalSuccess = 0;
let totalFailure = 0;
const sourceIds = new Set<string>();
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
(apiKeyEntries || []).forEach((entry) => { (apiKeyEntries || []).forEach((entry) => {
const key = entry?.apiKey || ''; buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
if (!key) return;
const masked = maskFn(key);
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
totalSuccess += stats.success;
totalFailure += stats.failure;
}); });
return { success: totalSuccess, failure: totalFailure }; 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 => ({ export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({

View File

@@ -2,28 +2,30 @@
* Generic quota section component. * Generic quota section component.
*/ */
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useQuotaStore, useThemeStore } from '@/stores'; import { useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types'; import type { AuthFileItem, ResolvedTheme } from '@/types';
import { QuotaCard } from './QuotaCard'; import { QuotaCard } from './QuotaCard';
import type { QuotaStatusState } from './QuotaCard'; import type { QuotaStatusState } from './QuotaCard';
import { useQuotaLoader } from './useQuotaLoader'; import { useQuotaLoader } from './useQuotaLoader';
import type { QuotaConfig } from './quotaConfigs'; import type { QuotaConfig } from './quotaConfigs';
import { useGridColumns } from './useGridColumns';
import { IconRefreshCw } from '@/components/ui/icons';
import styles from '@/pages/QuotaPage.module.scss'; import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T); type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void; type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
const MIN_CARD_PAGE_SIZE = 3; type ViewMode = 'paged' | 'all';
const MAX_CARD_PAGE_SIZE = 30;
const clampCardPageSize = (value: number) => const MAX_ITEMS_PER_PAGE = 14;
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value))); const MAX_SHOW_ALL_THRESHOLD = 30;
interface QuotaPaginationState<T> { interface QuotaPaginationState<T> {
pageSize: number; pageSize: number;
@@ -40,7 +42,7 @@ interface QuotaPaginationState<T> {
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => { const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSizeState] = useState(() => clampCardPageSize(defaultPageSize)); const [pageSize, setPageSizeState] = useState(defaultPageSize);
const [loading, setLoadingState] = useState(false); const [loading, setLoadingState] = useState(false);
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null); const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
@@ -57,7 +59,7 @@ const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginatio
}, [items, currentPage, pageSize]); }, [items, currentPage, pageSize]);
const setPageSize = useCallback((size: number) => { const setPageSize = useCallback((size: number) => {
setPageSizeState(clampCardPageSize(size)); setPageSizeState(size);
setPage(1); setPage(1);
}, []); }, []);
@@ -107,10 +109,17 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
Record<string, TState> 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)), [ const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
files, files,
config.filterFn config
]); ]);
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
const { const {
pageSize, pageSize,
@@ -121,19 +130,59 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
goToPrev, goToPrev,
goToNext, goToNext,
loading: sectionLoading, loading: sectionLoading,
loadingScope,
setLoading setLoading
} = useQuotaPagination(filteredFiles); } = 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 { quota, loadQuota } = useQuotaLoader(config);
const handleRefreshPage = useCallback(() => { const pendingQuotaRefreshRef = useRef(false);
loadQuota(pageItems, 'page', setLoading); const prevFilesLoadingRef = useRef(loading);
}, [loadQuota, pageItems, setLoading]);
const handleRefreshAll = useCallback(() => { const handleRefresh = useCallback(() => {
loadQuota(filteredFiles, 'all', setLoading); pendingQuotaRefreshRef.current = true;
}, [loadQuota, filteredFiles, setLoading]); 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(() => { useEffect(() => {
if (loading) return; if (loading) return;
@@ -153,28 +202,56 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
}); });
}, [filteredFiles, loading, setQuota]); }, [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 ( return (
<Card <Card
title={t(`${config.i18nPrefix}.title`)} title={titleNode}
extra={ extra={
<div className={styles.headerActions}> <div className={styles.headerActions}>
<div className={styles.viewModeToggle}>
<Button <Button
variant="secondary" variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
size="sm" size="sm"
onClick={handleRefreshPage} onClick={() => setViewMode('paged')}
disabled={disabled || sectionLoading || pageItems.length === 0}
loading={sectionLoading && loadingScope === 'page'}
> >
{t(`${config.i18nPrefix}.refresh_button`)} {t('auth_files.view_mode_paged')}
</Button> </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 <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={handleRefreshAll} onClick={handleRefresh}
disabled={disabled || sectionLoading || filteredFiles.length === 0} disabled={disabled || isRefreshing}
loading={sectionLoading && loadingScope === 'all'} loading={isRefreshing}
title={t('quota_management.refresh_files_and_quota')}
aria-label={t('quota_management.refresh_files_and_quota')}
> >
{t(`${config.i18nPrefix}.fetch_all`)} {!isRefreshing && <IconRefreshCw size={16} />}
</Button> </Button>
</div> </div>
} }
@@ -186,31 +263,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
/> />
) : ( ) : (
<> <>
<div className={config.controlsClassName}> <div ref={gridRef} className={config.gridClassName}>
<div className={config.controlClassName}>
<label>{t('auth_files.page_size_label')}</label>
<input
className={styles.pageSizeSelect}
type="number"
min={MIN_CARD_PAGE_SIZE}
max={MAX_CARD_PAGE_SIZE}
step={1}
value={pageSize}
onChange={(e) => {
const value = e.currentTarget.valueAsNumber;
if (!Number.isFinite(value)) return;
setPageSize(value);
}}
/>
</div>
<div className={config.controlClassName}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{filteredFiles.length} {t('auth_files.files_count')}
</div>
</div>
</div>
<div className={config.gridClassName}>
{pageItems.map((item) => ( {pageItems.map((item) => (
<QuotaCard <QuotaCard
key={item.name} key={item.name}
@@ -224,7 +277,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
/> />
))} ))}
</div> </div>
{filteredFiles.length > pageSize && ( {filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
<div className={styles.pagination}> <div className={styles.pagination}>
<Button <Button
variant="secondary" variant="secondary"
@@ -253,6 +306,16 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
)} )}
</> </>
)} )}
{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> </Card>
); );
} }

View File

@@ -18,7 +18,7 @@ import type {
GeminiCliQuotaBucketState, GeminiCliQuotaBucketState,
GeminiCliQuotaState GeminiCliQuotaState
} from '@/types'; } from '@/types';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import { import {
ANTIGRAVITY_QUOTA_URLS, ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS, ANTIGRAVITY_REQUEST_HEADERS,
@@ -55,6 +55,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli'; type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore { export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>; antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>; codexQuota: Record<string, CodexQuotaState>;
@@ -82,6 +84,43 @@ export interface QuotaConfig<TState, TData> {
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode; 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 ( const fetchAntigravityQuota = async (
file: AuthFileItem, file: AuthFileItem,
t: TFunction t: TFunction
@@ -92,19 +131,23 @@ const fetchAntigravityQuota = async (
throw new Error(t('antigravity_quota.missing_auth_index')); 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 lastError = '';
let lastStatus: number | undefined; let lastStatus: number | undefined;
let priorityStatus: number | undefined; let priorityStatus: number | undefined;
let hadSuccess = false; let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) { for (const url of ANTIGRAVITY_QUOTA_URLS) {
for (let attempt = 0; attempt < requestBodies.length; attempt++) {
try { try {
const result = await apiCallApi.request({ const result = await apiCallApi.request({
authIndex, authIndex,
method: 'POST', method: 'POST',
url, url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS }, header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: '{}' data: requestBodies[attempt]
}); });
if (result.statusCode < 200 || result.statusCode >= 300) { if (result.statusCode < 200 || result.statusCode >= 300) {
@@ -113,8 +156,15 @@ const fetchAntigravityQuota = async (
if (result.statusCode === 403 || result.statusCode === 404) { if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode; priorityStatus ??= result.statusCode;
} }
if (
result.statusCode === 400 &&
isAntigravityUnknownFieldError(lastError) &&
attempt < requestBodies.length - 1
) {
continue; continue;
} }
break;
}
hadSuccess = true; hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText); const payload = parseAntigravityPayload(result.body ?? result.bodyText);
@@ -142,6 +192,7 @@ const fetchAntigravityQuota = async (
} }
} }
} }
}
if (hadSuccess) { if (hadSuccess) {
return []; return [];

View File

@@ -0,0 +1,40 @@
import { useState, useEffect, useCallback } from 'react';
/**
* Hook to calculate the number of grid columns based on container width and item min-width.
* Returns [columns, refCallback].
*/
export function useGridColumns(
itemMinWidth: number,
gap: number = 16
): [number, (node: HTMLDivElement | null) => void] {
const [columns, setColumns] = useState(1);
const [element, setElement] = useState<HTMLDivElement | null>(null);
const refCallback = useCallback((node: HTMLDivElement | null) => {
setElement(node);
}, []);
useEffect(() => {
if (!element) return;
const updateColumns = () => {
const containerWidth = element.clientWidth;
const effectiveItemWidth = itemMinWidth + gap;
const count = Math.floor((containerWidth + gap) / effectiveItemWidth);
setColumns(Math.max(1, count));
};
updateColumns();
const observer = new ResizeObserver(() => {
updateColumns();
});
observer.observe(element);
return () => observer.disconnect();
}, [element, itemMinWidth, gap]);
return [columns, refCallback];
}

View File

@@ -0,0 +1,175 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
import { IconChevronDown } from './icons';
interface AutocompleteInputProps {
label?: string;
value: string;
onChange: (value: string) => void;
options: string[] | { value: string; label?: string }[];
placeholder?: string;
disabled?: boolean;
hint?: string;
error?: string;
className?: string;
wrapperClassName?: string;
wrapperStyle?: React.CSSProperties;
id?: string;
rightElement?: ReactNode;
}
export function AutocompleteInput({
label,
value,
onChange,
options,
placeholder,
disabled,
hint,
error,
className = '',
wrapperClassName = '',
wrapperStyle,
id,
rightElement
}: AutocompleteInputProps) {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const normalizedOptions = options.map(opt =>
typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value }
);
const filteredOptions = normalizedOptions.filter(opt => {
const v = value.toLowerCase();
return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v));
});
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
};
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setIsOpen(false);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
return;
}
setHighlightedIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
} else if (e.key === 'Enter') {
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
e.preventDefault();
handleSelect(filteredOptions[highlightedIndex].value);
} else if (isOpen) {
e.preventDefault();
setIsOpen(false);
}
} else if (e.key === 'Escape') {
setIsOpen(false);
} else if (e.key === 'Tab') {
setIsOpen(false);
}
};
return (
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
{label && <label htmlFor={id}>{label}</label>}
<div style={{ position: 'relative' }}>
<input
id={id}
className={`input ${className}`.trim()}
value={value}
onChange={handleInputChange}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
style={{ paddingRight: 32 }}
/>
<div
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
pointerEvents: disabled ? 'none' : 'auto',
cursor: 'pointer',
height: '100%'
}}
onClick={() => !disabled && setIsOpen(!isOpen)}
>
{rightElement}
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
</div>
{isOpen && filteredOptions.length > 0 && !disabled && (
<div className="autocomplete-dropdown" style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-md)',
maxHeight: 200,
overflowY: 'auto',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
}}>
{filteredOptions.map((opt, index) => (
<div
key={`${opt.value}-${index}`}
onClick={() => handleSelect(opt.value)}
style={{
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
color: 'var(--text-primary)',
display: 'flex',
flexDirection: 'column',
fontSize: '0.9rem'
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
<span style={{ fontWeight: 500 }}>{opt.value}</span>
{opt.label && opt.label !== opt.value && (
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>{opt.label}</span>
)}
</div>
))}
</div>
)}
</div>
{hint && <div className="hint">{hint}</div>}
{error && <div className="error-box">{error}</div>}
</div>
);
}

View File

@@ -20,6 +20,7 @@ export function Button({
disabled, disabled,
...rest ...rest
}: PropsWithChildren<ButtonProps>) { }: PropsWithChildren<ButtonProps>) {
const hasChildren = children !== null && children !== undefined && children !== false;
const classes = [ const classes = [
'btn', 'btn',
`btn-${variant}`, `btn-${variant}`,
@@ -33,7 +34,7 @@ export function Button({
return ( return (
<button className={classes} disabled={disabled || loading} {...rest}> <button className={classes} disabled={disabled || loading} {...rest}>
{loading && <span className="loading-spinner" aria-hidden="true" />} {loading && <span className="loading-spinner" aria-hidden="true" />}
<span>{children}</span> {hasChildren && <span>{children}</span>}
</button> </button>
); );
} }

View File

@@ -8,6 +8,7 @@ interface ModalProps {
onClose: () => void; onClose: () => void;
footer?: ReactNode; footer?: ReactNode;
width?: number | string; width?: number | string;
closeDisabled?: boolean;
} }
const CLOSE_ANIMATION_DURATION = 350; const CLOSE_ANIMATION_DURATION = 350;
@@ -32,7 +33,15 @@ const unlockScroll = () => {
} }
}; };
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) { export function Modal({
open,
title,
onClose,
footer,
width = 520,
closeDisabled = false,
children
}: PropsWithChildren<ModalProps>) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -54,19 +63,28 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
); );
useEffect(() => { useEffect(() => {
let cancelled = false;
if (open) { if (open) {
if (closeTimerRef.current !== null) { if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current); window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null; closeTimerRef.current = null;
} }
queueMicrotask(() => {
if (cancelled) return;
setIsVisible(true); setIsVisible(true);
setIsClosing(false); setIsClosing(false);
return; });
} else if (isVisible) {
queueMicrotask(() => {
if (cancelled) return;
startClose(false);
});
} }
if (isVisible) { return () => {
startClose(false); cancelled = true;
} };
}, [open, isVisible, startClose]); }, [open, isVisible, startClose]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -97,7 +115,13 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
const modalContent = ( const modalContent = (
<div className={overlayClass}> <div className={overlayClass}>
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true"> <div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<button className="modal-close-floating" onClick={handleClose} aria-label="Close"> <button
type="button"
className="modal-close-floating"
onClick={closeDisabled ? undefined : handleClose}
aria-label="Close"
disabled={closeDisabled}
>
<IconX size={20} /> <IconX size={20} />
</button> </button>
<div className="modal-header"> <div className="modal-header">

View File

@@ -4,6 +4,7 @@ interface ToggleSwitchProps {
checked: boolean; checked: boolean;
onChange: (value: boolean) => void; onChange: (value: boolean) => void;
label?: ReactNode; label?: ReactNode;
ariaLabel?: string;
disabled?: boolean; disabled?: boolean;
labelPosition?: 'left' | 'right'; labelPosition?: 'left' | 'right';
} }
@@ -12,6 +13,7 @@ export function ToggleSwitch({
checked, checked,
onChange, onChange,
label, label,
ariaLabel,
disabled = false, disabled = false,
labelPosition = 'right' labelPosition = 'right'
}: ToggleSwitchProps) { }: ToggleSwitchProps) {
@@ -25,7 +27,13 @@ export function ToggleSwitch({
return ( return (
<label className={className}> <label className={className}>
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} /> <input
type="checkbox"
checked={checked}
onChange={handleChange}
disabled={disabled}
aria-label={ariaLabel}
/>
<span className="track"> <span className="track">
<span className="thumb" /> <span className="thumb" />
</span> </span>

View File

@@ -137,11 +137,22 @@
"usage_statistics_enable": "Enable usage statistics", "usage_statistics_enable": "Enable usage statistics",
"logging_title": "Logging", "logging_title": "Logging",
"logging_to_file_enable": "Enable logging to file", "logging_to_file_enable": "Enable logging to file",
"logs_max_total_size_title": "Log Size Limit",
"logs_max_total_size_label": "Total log size cap (MB):",
"logs_max_total_size_hint": "Set to 0 to disable the limit.",
"logs_max_total_size_update": "Update",
"request_log_title": "Request Logging", "request_log_title": "Request Logging",
"request_log_enable": "Enable request logging", "request_log_enable": "Enable request logging",
"request_log_warning": "Keep this off unless you need detailed troubleshooting.", "request_log_warning": "Keep this off unless you need detailed troubleshooting.",
"force_model_prefix_enable": "Force model prefix",
"ws_auth_title": "WebSocket Authentication", "ws_auth_title": "WebSocket Authentication",
"ws_auth_enable": "Require auth for /ws/*" "ws_auth_enable": "Require auth for /ws/*",
"routing_title": "Routing Strategy",
"routing_strategy_label": "Routing strategy:",
"routing_strategy_hint": "round-robin cycles through keys; fill-first prioritizes the first available key.",
"routing_strategy_update": "Update",
"routing_strategy_round_robin": "round-robin (cycle)",
"routing_strategy_fill_first": "fill-first (prioritize)"
}, },
"api_keys": { "api_keys": {
"title": "API Keys Management", "title": "API Keys Management",
@@ -221,6 +232,27 @@
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.", "claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
"claude_models_add_btn": "Add Model", "claude_models_add_btn": "Add Model",
"claude_models_count": "Models Count", "claude_models_count": "Models Count",
"vertex_title": "Vertex API Configuration",
"vertex_add_button": "Add Configuration",
"vertex_empty_title": "No Vertex Configuration",
"vertex_empty_desc": "Click the button above to add the first configuration",
"vertex_item_title": "Vertex Configuration",
"vertex_add_modal_title": "Add Vertex API Configuration",
"vertex_add_modal_key_label": "API Key:",
"vertex_add_modal_key_placeholder": "Please enter Vertex API key",
"vertex_add_modal_url_label": "Base URL (Required):",
"vertex_add_modal_url_placeholder": "e.g.: https://example.com/api",
"vertex_add_modal_proxy_label": "Proxy URL (Optional):",
"vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "Edit Vertex API Configuration",
"vertex_edit_modal_key_label": "API Key:",
"vertex_edit_modal_url_label": "Base URL (Required):",
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
"vertex_models_label": "Model mappings (alias required):",
"vertex_models_add_btn": "Add Mapping",
"vertex_models_hint": "Each mapping needs both the original model and its alias.",
"vertex_models_count": "Mapping count",
"ampcode_title": "Amp CLI Integration (ampcode)", "ampcode_title": "Amp CLI Integration (ampcode)",
"ampcode_modal_title": "Configure Ampcode", "ampcode_modal_title": "Configure Ampcode",
"ampcode_upstream_url_label": "Upstream URL", "ampcode_upstream_url_label": "Upstream URL",
@@ -261,12 +293,12 @@
"openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free", "openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free",
"openai_model_alias_placeholder": "Model alias (optional)", "openai_model_alias_placeholder": "Model alias (optional)",
"openai_models_add_btn": "Add Model", "openai_models_add_btn": "Add Model",
"openai_models_fetch_button": "Fetch via /v1/models", "openai_models_fetch_button": "Fetch via /models",
"openai_models_fetch_title": "Pick Models from /v1/models", "openai_models_fetch_title": "Pick Models from /models",
"openai_models_fetch_hint": "Call the /v1/models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.", "openai_models_fetch_hint": "Call the /models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.",
"openai_models_fetch_url_label": "Request URL", "openai_models_fetch_url_label": "Request URL",
"openai_models_fetch_refresh": "Refresh", "openai_models_fetch_refresh": "Refresh",
"openai_models_fetch_loading": "Fetching models from /v1/models...", "openai_models_fetch_loading": "Fetching models from /models...",
"openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.", "openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.",
"openai_models_fetch_error": "Failed to fetch models", "openai_models_fetch_error": "Failed to fetch models",
"openai_models_fetch_back": "Back to edit", "openai_models_fetch_back": "Back to edit",
@@ -312,6 +344,7 @@
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!", "delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!", "delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
"upload_error_json": "Only JSON files are allowed", "upload_error_json": "Only JSON files are allowed",
"upload_error_size": "File size cannot exceed {{maxSize}}",
"upload_success": "File uploaded successfully", "upload_success": "File uploaded successfully",
"download_success": "File downloaded successfully", "download_success": "File downloaded successfully",
"delete_success": "File deleted successfully", "delete_success": "File deleted successfully",
@@ -327,6 +360,9 @@
"search_placeholder": "Filter by name, type, or provider", "search_placeholder": "Filter by name, type, or provider",
"page_size_label": "Per page", "page_size_label": "Per page",
"page_size_unit": "items", "page_size_unit": "items",
"view_mode_paged": "Paged",
"view_mode_all": "Show all",
"too_many_files_warning": "Too many credentials. Showing all may cause performance issues, please use paged view.",
"filter_all": "All", "filter_all": "All",
"filter_qwen": "Qwen", "filter_qwen": "Qwen",
"filter_gemini": "Gemini", "filter_gemini": "Gemini",
@@ -359,7 +395,19 @@
"models_unsupported": "This feature is not supported in the current version", "models_unsupported": "This feature is not supported in the current version",
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again", "models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
"models_excluded_badge": "Excluded", "models_excluded_badge": "Excluded",
"models_excluded_hint": "This model is excluded by OAuth" "models_excluded_hint": "This model is excluded by OAuth",
"status_toggle_label": "Enabled",
"status_enabled_success": "\"{{name}}\" enabled",
"status_disabled_success": "\"{{name}}\" disabled",
"prefix_proxy_button": "Edit prefix/proxy_url",
"prefix_proxy_loading": "Loading credential...",
"prefix_proxy_source_label": "Credential JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
}, },
"antigravity_quota": { "antigravity_quota": {
"title": "Antigravity Quota", "title": "Antigravity Quota",
@@ -464,6 +512,40 @@
"upgrade_required_title": "Please upgrade CLI Proxy API", "upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version." "upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
}, },
"oauth_model_mappings": {
"title": "OAuth Model Mappings",
"add": "Add Mapping",
"add_title": "Add provider model mappings",
"provider_label": "Provider",
"provider_placeholder": "e.g. gemini-cli / vertex",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"model_source_label": "Auth file model source",
"model_source_placeholder": "Select an auth file (for model suggestions)",
"model_source_hint": "Pick an auth file to enable model suggestions for “Source model name”. You can still type custom values.",
"model_source_loading": "Loading models...",
"model_source_unsupported": "The current CPA version does not support fetching model lists (manual input still works).",
"model_source_loaded": "{{count}} models loaded. Use the dropdown in “Source model name”, or type custom values.",
"mappings_label": "Model mappings",
"mapping_name_placeholder": "Source model name",
"mapping_alias_placeholder": "Alias (required)",
"mapping_fork_label": "Keep original",
"mappings_hint": "Saving an empty list removes that provider. Enable “Keep original” to keep the original name while adding the alias.",
"add_mapping": "Add mapping",
"save": "Save/Update",
"save_success": "Model mappings updated",
"save_failed": "Failed to update model mappings",
"delete": "Delete Provider",
"delete_confirm": "Delete model mappings for {{provider}}?",
"delete_success": "Model mappings removed",
"delete_failed": "Failed to delete model mappings",
"no_models": "No model mappings",
"model_count": "{{count}} mappings",
"list_empty_all": "No model mappings yet—use “Add Mapping” to create one.",
"provider_required": "Please enter a provider first",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth model mappings API. Please upgrade to the latest CLI Proxy API (CPA) version."
},
"auth_login": { "auth_login": {
"codex_oauth_title": "Codex OAuth", "codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "Start Codex Login", "codex_oauth_button": "Start Codex Login",
@@ -548,7 +630,7 @@
"iflow_oauth_polling_error": "Failed to check authentication status:", "iflow_oauth_polling_error": "Failed to check authentication status:",
"iflow_cookie_title": "iFlow Cookie Login", "iflow_cookie_title": "iFlow Cookie Login",
"iflow_cookie_label": "Cookie Value:", "iflow_cookie_label": "Cookie Value:",
"iflow_cookie_placeholder": "Paste browser cookie, e.g. sessionid=...;", "iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.", "iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
"iflow_cookie_key_hint": "Note: Create a key on the platform first.", "iflow_cookie_key_hint": "Note: Create a key on the platform first.",
"iflow_cookie_button": "Submit Cookie Login", "iflow_cookie_button": "Submit Cookie Login",
@@ -709,7 +791,8 @@
"quota_management": { "quota_management": {
"title": "Quota Management", "title": "Quota Management",
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.", "description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
"refresh_files": "Refresh auth files" "refresh_files": "Refresh auth files",
"refresh_files_and_quota": "Refresh files & quota"
}, },
"system_info": { "system_info": {
"title": "Management Center Info", "title": "Management Center Info",
@@ -722,9 +805,9 @@
"not_loaded": "Not Loaded", "not_loaded": "Not Loaded",
"seconds_ago": "seconds ago", "seconds_ago": "seconds ago",
"models_title": "Available Models", "models_title": "Available Models",
"models_desc": "Shows the /v1/models response and uses saved API keys for auth automatically.", "models_desc": "Shows the /models response and uses saved API keys for auth automatically.",
"models_loading": "Loading available models...", "models_loading": "Loading available models...",
"models_empty": "No models returned by /v1/models", "models_empty": "No models returned by /models",
"models_error": "Failed to load model list", "models_error": "Failed to load model list",
"models_count": "{{count}} available models", "models_count": "{{count}} available models",
"version_check_title": "Update Check", "version_check_title": "Update Check",
@@ -761,8 +844,11 @@
"quota_switch_preview_updated": "Preview model switch settings updated", "quota_switch_preview_updated": "Preview model switch settings updated",
"usage_statistics_updated": "Usage statistics settings updated", "usage_statistics_updated": "Usage statistics settings updated",
"logging_to_file_updated": "Logging settings updated", "logging_to_file_updated": "Logging settings updated",
"logs_max_total_size_updated": "Log size limit updated",
"request_log_updated": "Request logging setting updated", "request_log_updated": "Request logging setting updated",
"force_model_prefix_updated": "Model prefix setting updated",
"ws_auth_updated": "WebSocket authentication setting updated", "ws_auth_updated": "WebSocket authentication setting updated",
"routing_strategy_updated": "Routing strategy updated",
"login_storage_cleared": "Local login data cleared", "login_storage_cleared": "Local login data cleared",
"api_key_added": "API key added successfully", "api_key_added": "API key added successfully",
"api_key_updated": "API key updated successfully", "api_key_updated": "API key updated successfully",
@@ -781,6 +867,10 @@
"claude_config_added": "Claude configuration added successfully", "claude_config_added": "Claude configuration added successfully",
"claude_config_updated": "Claude configuration updated successfully", "claude_config_updated": "Claude configuration updated successfully",
"claude_config_deleted": "Claude configuration deleted successfully", "claude_config_deleted": "Claude configuration deleted successfully",
"vertex_config_added": "Vertex configuration added successfully",
"vertex_config_updated": "Vertex configuration updated successfully",
"vertex_config_deleted": "Vertex configuration deleted successfully",
"vertex_base_url_required": "Please enter the Vertex Base URL",
"config_enabled": "Configuration enabled", "config_enabled": "Configuration enabled",
"config_disabled": "Configuration disabled", "config_disabled": "Configuration disabled",
"field_required": "Required fields cannot be empty", "field_required": "Required fields cannot be empty",

View File

@@ -137,11 +137,22 @@
"usage_statistics_enable": "启用使用统计", "usage_statistics_enable": "启用使用统计",
"logging_title": "日志记录", "logging_title": "日志记录",
"logging_to_file_enable": "启用日志记录到文件", "logging_to_file_enable": "启用日志记录到文件",
"logs_max_total_size_title": "日志容量限制",
"logs_max_total_size_label": "日志总大小上限 (MB):",
"logs_max_total_size_hint": "设置为 0 表示不限制。",
"logs_max_total_size_update": "更新",
"request_log_title": "请求日志", "request_log_title": "请求日志",
"request_log_enable": "启用请求日志", "request_log_enable": "启用请求日志",
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。", "request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
"force_model_prefix_enable": "强制模型前缀",
"ws_auth_title": "WebSocket 鉴权", "ws_auth_title": "WebSocket 鉴权",
"ws_auth_enable": "启用 /ws/* 鉴权" "ws_auth_enable": "启用 /ws/* 鉴权",
"routing_title": "路由策略",
"routing_strategy_label": "路由策略:",
"routing_strategy_hint": "round-robin 为轮询fill-first 为优先填充。",
"routing_strategy_update": "更新",
"routing_strategy_round_robin": "round-robin (轮询)",
"routing_strategy_fill_first": "fill-first (优先填充)"
}, },
"api_keys": { "api_keys": {
"title": "API 密钥管理", "title": "API 密钥管理",
@@ -221,6 +232,27 @@
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。", "claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
"claude_models_add_btn": "添加模型", "claude_models_add_btn": "添加模型",
"claude_models_count": "模型数量", "claude_models_count": "模型数量",
"vertex_title": "Vertex API 配置",
"vertex_add_button": "添加配置",
"vertex_empty_title": "暂无Vertex配置",
"vertex_empty_desc": "点击上方按钮添加第一个配置",
"vertex_item_title": "Vertex配置",
"vertex_add_modal_title": "添加Vertex API配置",
"vertex_add_modal_key_label": "API密钥:",
"vertex_add_modal_key_placeholder": "请输入Vertex API密钥",
"vertex_add_modal_url_label": "Base URL (必填):",
"vertex_add_modal_url_placeholder": "例如: https://example.com/api",
"vertex_add_modal_proxy_label": "代理 URL (可选):",
"vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "编辑Vertex API配置",
"vertex_edit_modal_key_label": "API密钥:",
"vertex_edit_modal_url_label": "Base URL (必填):",
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
"vertex_delete_confirm": "确定要删除这个Vertex配置吗",
"vertex_models_label": "模型映射 (别名必填):",
"vertex_models_add_btn": "添加映射",
"vertex_models_hint": "每条映射需要填写原模型与别名。",
"vertex_models_count": "映射数量",
"ampcode_title": "Amp CLI 集成 (ampcode)", "ampcode_title": "Amp CLI 集成 (ampcode)",
"ampcode_modal_title": "配置 Ampcode", "ampcode_modal_title": "配置 Ampcode",
"ampcode_upstream_url_label": "Upstream URL", "ampcode_upstream_url_label": "Upstream URL",
@@ -261,12 +293,12 @@
"openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free", "openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free",
"openai_model_alias_placeholder": "模型别名 (可选)", "openai_model_alias_placeholder": "模型别名 (可选)",
"openai_models_add_btn": "添加模型", "openai_models_add_btn": "添加模型",
"openai_models_fetch_button": "从 /v1/models 获取", "openai_models_fetch_button": "从 /models 获取",
"openai_models_fetch_title": "从 /v1/models 选择模型", "openai_models_fetch_title": "从 /models 选择模型",
"openai_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带首个 API KeyBearer与自定义请求头。", "openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API KeyBearer与自定义请求头。",
"openai_models_fetch_url_label": "请求地址", "openai_models_fetch_url_label": "请求地址",
"openai_models_fetch_refresh": "重新获取", "openai_models_fetch_refresh": "重新获取",
"openai_models_fetch_loading": "正在从 /v1/models 获取模型列表...", "openai_models_fetch_loading": "正在从 /models 获取模型列表...",
"openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。", "openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
"openai_models_fetch_error": "获取模型失败", "openai_models_fetch_error": "获取模型失败",
"openai_models_fetch_back": "返回编辑", "openai_models_fetch_back": "返回编辑",
@@ -312,6 +344,7 @@
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!", "delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!", "delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
"upload_error_json": "只能上传JSON文件", "upload_error_json": "只能上传JSON文件",
"upload_error_size": "文件大小不能超过 {{maxSize}}",
"upload_success": "文件上传成功", "upload_success": "文件上传成功",
"download_success": "文件下载成功", "download_success": "文件下载成功",
"delete_success": "文件删除成功", "delete_success": "文件删除成功",
@@ -327,6 +360,9 @@
"search_placeholder": "输入名称、类型或提供方关键字", "search_placeholder": "输入名称、类型或提供方关键字",
"page_size_label": "单页数量", "page_size_label": "单页数量",
"page_size_unit": "个/页", "page_size_unit": "个/页",
"view_mode_paged": "按页显示",
"view_mode_all": "显示全部",
"too_many_files_warning": "您的凭证总数过多,全部加载会导致页面卡顿,请保持单页浏览。",
"filter_all": "全部", "filter_all": "全部",
"filter_qwen": "Qwen", "filter_qwen": "Qwen",
"filter_gemini": "Gemini", "filter_gemini": "Gemini",
@@ -359,7 +395,19 @@
"models_unsupported": "当前版本不支持此功能", "models_unsupported": "当前版本不支持此功能",
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试", "models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
"models_excluded_badge": "已排除", "models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除" "models_excluded_hint": "此模型已被 OAuth 排除",
"status_toggle_label": "启用",
"status_enabled_success": "已启用 \"{{name}}\"",
"status_disabled_success": "已停用 \"{{name}}\"",
"prefix_proxy_button": "配置 prefix/proxy_url",
"prefix_proxy_loading": "正在加载凭证文件...",
"prefix_proxy_source_label": "凭证 JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
}, },
"antigravity_quota": { "antigravity_quota": {
"title": "Antigravity 额度", "title": "Antigravity 额度",
@@ -464,6 +512,40 @@
"upgrade_required_title": "需要升级 CPA 版本", "upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。" "upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
}, },
"oauth_model_mappings": {
"title": "OAuth 模型映射",
"add": "新增映射",
"add_title": "新增提供商模型映射",
"provider_label": "提供商",
"provider_placeholder": "例如 gemini-cli / vertex",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"model_source_label": "模型来源认证文件",
"model_source_placeholder": "选择认证文件(用于原模型下拉建议)",
"model_source_hint": "选择一个认证文件后,“原模型名称”支持下拉选择;也可手动输入自定义模型。",
"model_source_loading": "正在加载模型列表...",
"model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。",
"model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。",
"mappings_label": "模型映射",
"mapping_name_placeholder": "原模型名称",
"mapping_alias_placeholder": "别名 (必填)",
"mapping_fork_label": "保留原名",
"mappings_hint": "留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
"add_mapping": "添加映射",
"save": "保存/更新",
"save_success": "模型映射已更新",
"save_failed": "更新模型映射失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的模型映射吗?",
"delete_success": "已删除该提供商的模型映射",
"delete_failed": "删除模型映射失败",
"no_models": "未配置模型映射",
"model_count": "映射 {{count}} 条模型",
"list_empty_all": "暂无任何提供商的模型映射,点击“新增映射”创建。",
"provider_required": "请先填写提供商名称",
"upgrade_required": "当前 CPA 版本不支持模型映射功能,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型映射功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"auth_login": { "auth_login": {
"codex_oauth_title": "Codex OAuth", "codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "开始 Codex 登录", "codex_oauth_button": "开始 Codex 登录",
@@ -548,7 +630,7 @@
"iflow_oauth_polling_error": "检查认证状态失败:", "iflow_oauth_polling_error": "检查认证状态失败:",
"iflow_cookie_title": "iFlow Cookie 登录", "iflow_cookie_title": "iFlow Cookie 登录",
"iflow_cookie_label": "Cookie 内容:", "iflow_cookie_label": "Cookie 内容:",
"iflow_cookie_placeholder": "粘贴浏览器中的 Cookie例如 sessionid=...;", "iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。", "iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。", "iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
"iflow_cookie_button": "提交 Cookie 登录", "iflow_cookie_button": "提交 Cookie 登录",
@@ -709,7 +791,8 @@
"quota_management": { "quota_management": {
"title": "配额管理", "title": "配额管理",
"description": "集中查看 OAuth 额度与剩余情况", "description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件" "refresh_files": "刷新认证文件",
"refresh_files_and_quota": "刷新认证文件&额度"
}, },
"system_info": { "system_info": {
"title": "管理中心信息", "title": "管理中心信息",
@@ -722,9 +805,9 @@
"not_loaded": "未加载", "not_loaded": "未加载",
"seconds_ago": "秒前", "seconds_ago": "秒前",
"models_title": "可用模型列表", "models_title": "可用模型列表",
"models_desc": "展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。", "models_desc": "展示 /models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
"models_loading": "正在加载可用模型...", "models_loading": "正在加载可用模型...",
"models_empty": "未从 /v1/models 获取到模型数据", "models_empty": "未从 /models 获取到模型数据",
"models_error": "获取模型列表失败", "models_error": "获取模型列表失败",
"models_count": "可用模型 {{count}} 个", "models_count": "可用模型 {{count}} 个",
"version_check_title": "版本检查", "version_check_title": "版本检查",
@@ -761,8 +844,11 @@
"quota_switch_preview_updated": "预览模型切换设置已更新", "quota_switch_preview_updated": "预览模型切换设置已更新",
"usage_statistics_updated": "使用统计设置已更新", "usage_statistics_updated": "使用统计设置已更新",
"logging_to_file_updated": "日志记录设置已更新", "logging_to_file_updated": "日志记录设置已更新",
"logs_max_total_size_updated": "日志容量设置已更新",
"request_log_updated": "请求日志设置已更新", "request_log_updated": "请求日志设置已更新",
"force_model_prefix_updated": "模型前缀设置已更新",
"ws_auth_updated": "WebSocket 鉴权设置已更新", "ws_auth_updated": "WebSocket 鉴权设置已更新",
"routing_strategy_updated": "路由策略已更新",
"login_storage_cleared": "本地登录信息已清理", "login_storage_cleared": "本地登录信息已清理",
"api_key_added": "API密钥添加成功", "api_key_added": "API密钥添加成功",
"api_key_updated": "API密钥更新成功", "api_key_updated": "API密钥更新成功",
@@ -781,6 +867,10 @@
"claude_config_added": "Claude配置添加成功", "claude_config_added": "Claude配置添加成功",
"claude_config_updated": "Claude配置更新成功", "claude_config_updated": "Claude配置更新成功",
"claude_config_deleted": "Claude配置删除成功", "claude_config_deleted": "Claude配置删除成功",
"vertex_config_added": "Vertex配置添加成功",
"vertex_config_updated": "Vertex配置更新成功",
"vertex_config_deleted": "Vertex配置删除成功",
"vertex_base_url_required": "请填写Vertex Base URL",
"config_enabled": "配置已启用", "config_enabled": "配置已启用",
"config_disabled": "配置已停用", "config_disabled": "配置已停用",
"field_required": "必填字段不能为空", "field_required": "必填字段不能为空",

View File

@@ -7,11 +7,13 @@ import {
CodexSection, CodexSection,
GeminiSection, GeminiSection,
OpenAISection, OpenAISection,
VertexSection,
useProviderStats, useProviderStats,
type GeminiFormState, type GeminiFormState,
type OpenAIFormState, type OpenAIFormState,
type ProviderFormState, type ProviderFormState,
type ProviderModal, type ProviderModal,
type VertexFormState,
} from '@/components/providers'; } from '@/components/providers';
import { import {
parseExcludedModels, parseExcludedModels,
@@ -21,12 +23,12 @@ import {
import { ampcodeApi, providersApi } from '@/services/api'; import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { buildHeaderObject } from '@/utils/headers';
import styles from './AiProvidersPage.module.scss'; import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() { export function AiProvidersPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
@@ -41,6 +43,7 @@ export function AiProvidersPage() {
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]); const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]);
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]); const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]);
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]); const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
const [vertexConfigs, setVertexConfigs] = useState<ProviderKeyConfig[]>([]);
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]); const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -63,17 +66,32 @@ export function AiProvidersPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const data = await fetchConfig(); const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
fetchConfig(),
providersApi.getVertexConfigs(),
ampcodeApi.getAmpcode(),
]);
if (configResult.status !== 'fulfilled') {
throw configResult.reason;
}
const data = configResult.value;
setGeminiKeys(data?.geminiApiKeys || []); setGeminiKeys(data?.geminiApiKeys || []);
setCodexConfigs(data?.codexApiKeys || []); setCodexConfigs(data?.codexApiKeys || []);
setClaudeConfigs(data?.claudeApiKeys || []); setClaudeConfigs(data?.claudeApiKeys || []);
setVertexConfigs(data?.vertexApiKeys || []);
setOpenaiProviders(data?.openaiCompatibility || []); setOpenaiProviders(data?.openaiCompatibility || []);
try {
const ampcode = await ampcodeApi.getAmpcode(); if (vertexResult.status === 'fulfilled') {
updateConfigValue('ampcode', ampcode); setVertexConfigs(vertexResult.value || []);
updateConfigValue('vertex-api-key', vertexResult.value || []);
clearCache('vertex-api-key');
}
if (ampcodeResult.status === 'fulfilled') {
updateConfigValue('ampcode', ampcodeResult.value);
clearCache('ampcode'); clearCache('ampcode');
} catch {
// ignore
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = getErrorMessage(err) || t('notification.refresh_failed'); const message = getErrorMessage(err) || t('notification.refresh_failed');
@@ -92,11 +110,13 @@ export function AiProvidersPage() {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys); if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys); if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys); if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys);
if (config?.vertexApiKeys) setVertexConfigs(config.vertexApiKeys);
if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility); if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility);
}, [ }, [
config?.geminiApiKeys, config?.geminiApiKeys,
config?.codexApiKeys, config?.codexApiKeys,
config?.claudeApiKeys, config?.claudeApiKeys,
config?.vertexApiKeys,
config?.openaiCompatibility, config?.openaiCompatibility,
]); ]);
@@ -112,6 +132,10 @@ export function AiProvidersPage() {
setModal({ type, index }); setModal({ type, index });
}; };
const openVertexModal = (index: number | null) => {
setModal({ type: 'vertex', index });
};
const openAmpcodeModal = () => { const openAmpcodeModal = () => {
setModal({ type: 'ampcode', index: null }); setModal({ type: 'ampcode', index: null });
}; };
@@ -127,7 +151,7 @@ export function AiProvidersPage() {
apiKey: form.apiKey.trim(), apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined, prefix: form.prefix?.trim() || undefined,
baseUrl: form.baseUrl?.trim() || undefined, baseUrl: form.baseUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(form.headers)), headers: buildHeaderObject(form.headers),
excludedModels: parseExcludedModels(form.excludedText), excludedModels: parseExcludedModels(form.excludedText),
}; };
const nextList = const nextList =
@@ -156,7 +180,12 @@ export function AiProvidersPage() {
const deleteGemini = async (index: number) => { const deleteGemini = async (index: number) => {
const entry = geminiKeys[index]; const entry = geminiKeys[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t('ai_providers.gemini_delete_confirm'))) return; showConfirmation({
title: t('ai_providers.gemini_delete_title', { defaultValue: 'Delete Gemini Key' }),
message: t('ai_providers.gemini_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try { try {
await providersApi.deleteGeminiKey(entry.apiKey); await providersApi.deleteGeminiKey(entry.apiKey);
const next = geminiKeys.filter((_, idx) => idx !== index); const next = geminiKeys.filter((_, idx) => idx !== index);
@@ -168,6 +197,8 @@ export function AiProvidersPage() {
const message = getErrorMessage(err); const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
} }
},
});
}; };
const setConfigEnabled = async ( const setConfigEnabled = async (
@@ -283,7 +314,7 @@ export function AiProvidersPage() {
prefix: form.prefix?.trim() || undefined, prefix: form.prefix?.trim() || undefined,
baseUrl, baseUrl,
proxyUrl: form.proxyUrl?.trim() || undefined, proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(form.headers)), headers: buildHeaderObject(form.headers),
models: entriesToModels(form.modelEntries), models: entriesToModels(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText), excludedModels: parseExcludedModels(form.excludedText),
}; };
@@ -328,7 +359,12 @@ export function AiProvidersPage() {
const source = type === 'codex' ? codexConfigs : claudeConfigs; const source = type === 'codex' ? codexConfigs : claudeConfigs;
const entry = source[index]; const entry = source[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t(`ai_providers.${type}_delete_confirm`))) return; showConfirmation({
title: t(`ai_providers.${type}_delete_title`, { defaultValue: `Delete ${type === 'codex' ? 'Codex' : 'Claude'} Config` }),
message: t(`ai_providers.${type}_delete_confirm`),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try { try {
if (type === 'codex') { if (type === 'codex') {
await providersApi.deleteCodexConfig(entry.apiKey); await providersApi.deleteCodexConfig(entry.apiKey);
@@ -349,6 +385,81 @@ export function AiProvidersPage() {
const message = getErrorMessage(err); const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
} }
},
});
};
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
const baseUrl = trimmedBaseUrl || undefined;
if (!baseUrl) {
showNotification(t('notification.vertex_base_url_required'), 'error');
return;
}
setSaving(true);
try {
const payload: ProviderKeyConfig = {
apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined,
baseUrl,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers),
models: form.modelEntries
.map((entry) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
if (!name || !alias) return null;
return { name, alias };
})
.filter(Boolean) as ProviderKeyConfig['models'],
};
const nextList =
editIndex !== null
? vertexConfigs.map((item, idx) => (idx === editIndex ? payload : item))
: [...vertexConfigs, payload];
await providersApi.saveVertexConfigs(nextList);
setVertexConfigs(nextList);
updateConfigValue('vertex-api-key', nextList);
clearCache('vertex-api-key');
const message =
editIndex !== null
? t('notification.vertex_config_updated')
: t('notification.vertex_config_added');
showNotification(message, 'success');
closeModal();
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const deleteVertex = async (index: number) => {
const entry = vertexConfigs[index];
if (!entry) return;
showConfirmation({
title: t('ai_providers.vertex_delete_title', { defaultValue: 'Delete Vertex Config' }),
message: t('ai_providers.vertex_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteVertexConfig(entry.apiKey);
const next = vertexConfigs.filter((_, idx) => idx !== index);
setVertexConfigs(next);
updateConfigValue('vertex-api-key', next);
clearCache('vertex-api-key');
showNotification(t('notification.vertex_config_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
}; };
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => { const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
@@ -395,7 +506,12 @@ export function AiProvidersPage() {
const deleteOpenai = async (index: number) => { const deleteOpenai = async (index: number) => {
const entry = openaiProviders[index]; const entry = openaiProviders[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t('ai_providers.openai_delete_confirm'))) return; showConfirmation({
title: t('ai_providers.openai_delete_title', { defaultValue: 'Delete OpenAI Provider' }),
message: t('ai_providers.openai_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try { try {
await providersApi.deleteOpenAIProvider(entry.name); await providersApi.deleteOpenAIProvider(entry.name);
const next = openaiProviders.filter((_, idx) => idx !== index); const next = openaiProviders.filter((_, idx) => idx !== index);
@@ -407,11 +523,14 @@ export function AiProvidersPage() {
const message = getErrorMessage(err); const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
} }
},
});
}; };
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null; const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
const codexModalIndex = modal?.type === 'codex' ? modal.index : null; const codexModalIndex = modal?.type === 'codex' ? modal.index : null;
const claudeModalIndex = modal?.type === 'claude' ? modal.index : null; const claudeModalIndex = modal?.type === 'claude' ? modal.index : null;
const vertexModalIndex = modal?.type === 'vertex' ? modal.index : null;
const openaiModalIndex = modal?.type === 'openai' ? modal.index : null; const openaiModalIndex = modal?.type === 'openai' ? modal.index : null;
return ( return (
@@ -475,6 +594,23 @@ export function AiProvidersPage() {
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)} onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
/> />
<VertexSection
configs={vertexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'vertex'}
modalIndex={vertexModalIndex}
onAdd={() => openVertexModal(null)}
onEdit={(index) => openVertexModal(index)}
onDelete={deleteVertex}
onCloseModal={closeModal}
onSave={saveVertex}
/>
<AmpcodeSection <AmpcodeSection
config={config?.ampcode} config={config?.ampcode}
loading={loading} loading={loading}

View File

@@ -14,7 +14,7 @@ import styles from './ApiKeysPage.module.scss';
export function ApiKeysPage() { export function ApiKeysPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
@@ -29,7 +29,6 @@ export function ApiKeysPage() {
const [editingIndex, setEditingIndex] = useState<number | null>(null); const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingIndex, setDeletingIndex] = useState<number | null>(null);
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]); const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
@@ -115,21 +114,42 @@ export function ApiKeysPage() {
} }
}; };
const handleDelete = async (index: number) => { const handleDelete = (index: number) => {
if (!window.confirm(t('api_keys.delete_confirm'))) return; const apiKeyToDelete = apiKeys[index];
setDeletingIndex(index); if (!apiKeyToDelete) {
showNotification(t('notification.delete_failed'), 'error');
return;
}
showConfirmation({
title: t('common.delete'),
message: t('api_keys.delete_confirm'),
variant: 'danger',
onConfirm: async () => {
const latestKeys = useConfigStore.getState().config?.apiKeys;
const currentKeys = Array.isArray(latestKeys) ? latestKeys : [];
const deleteIndex =
currentKeys[index] === apiKeyToDelete
? index
: currentKeys.findIndex((key) => key === apiKeyToDelete);
if (deleteIndex < 0) {
showNotification(t('notification.delete_failed'), 'error');
return;
}
try { try {
await apiKeysApi.delete(index); await apiKeysApi.delete(deleteIndex);
const nextKeys = apiKeys.filter((_, idx) => idx !== index); const nextKeys = currentKeys.filter((_, idx) => idx !== deleteIndex);
setApiKeys(nextKeys); setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys); updateConfigValue('api-keys', nextKeys);
clearCache('api-keys'); clearCache('api-keys');
showNotification(t('notification.api_key_deleted'), 'success'); showNotification(t('notification.api_key_deleted'), 'success');
} catch (err: any) { } catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error'); showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
} finally {
setDeletingIndex(null);
} }
}
});
}; };
const actionButtons = ( const actionButtons = (
@@ -181,8 +201,7 @@ export function ApiKeysPage() {
variant="danger" variant="danger"
size="sm" size="sm"
onClick={() => handleDelete(index)} onClick={() => handleDelete(index)}
disabled={disableControls || deletingIndex === index} disabled={disableControls}
loading={deletingIndex === index}
> >
{t('common.delete')} {t('common.delete')}
</Button> </Button>

View File

@@ -32,6 +32,28 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.titleWrapper {
display: flex;
align-items: center;
gap: $spacing-sm;
line-height: 24px;
}
.countBadge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
min-width: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: var(--count-badge-text);
background-color: var(--count-badge-bg);
box-sizing: border-box;
}
.errorBox { .errorBox {
padding: $spacing-md; padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1); background-color: rgba(239, 68, 68, 0.1);
@@ -134,19 +156,6 @@
} }
} }
.statsInfo {
padding: 8px 12px;
background-color: var(--bg-secondary);
border-radius: $radius-md;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
height: 38px;
box-sizing: border-box;
display: flex;
align-items: center;
}
// 卡片网格 // 卡片网格
.fileGrid { .fileGrid {
display: grid; display: grid;
@@ -268,27 +277,15 @@
} }
.antigravityCard { .antigravityCard {
background-image: linear-gradient( background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
180deg,
rgba(224, 247, 250, 0.12),
rgba(224, 247, 250, 0)
);
} }
.codexCard { .codexCard {
background-image: linear-gradient( background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
180deg,
rgba(255, 243, 224, 0.18),
rgba(255, 243, 224, 0)
);
} }
.geminiCliCard { .geminiCliCard {
background-image: linear-gradient( background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
180deg,
rgba(231, 239, 255, 0.2),
rgba(231, 239, 255, 0)
);
} }
.quotaSection { .quotaSection {
@@ -437,7 +434,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-sm; gap: $spacing-sm;
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast; transition:
transform $transition-fast,
box-shadow $transition-fast,
border-color $transition-fast;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
@@ -446,6 +446,16 @@
} }
} }
.fileCardDisabled {
opacity: 0.6;
&:hover {
transform: none;
box-shadow: none;
border-color: var(--border-color);
}
}
.cardHeader { .cardHeader {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -537,7 +547,9 @@
height: 8px; height: 8px;
border-radius: 2px; border-radius: 2px;
min-width: 6px; min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease; transition:
transform 0.15s ease,
opacity 0.15s ease;
&:hover { &:hover {
transform: scaleY(1.5); transform: scaleY(1.5);
@@ -588,14 +600,90 @@
background: var(--failure-badge-bg, #fee2e2); background: var(--failure-badge-bg, #fee2e2);
} }
.prefixProxyEditor {
display: flex;
flex-direction: column;
gap: $spacing-md;
max-height: 60vh;
overflow: auto;
}
.prefixProxyLoading {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
font-size: 12px;
color: var(--text-secondary);
padding: $spacing-sm 0;
}
.prefixProxyError {
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
border: 1px solid var(--danger-color);
background-color: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
font-size: 12px;
}
.prefixProxyJsonWrapper {
display: flex;
flex-direction: column;
gap: 6px;
}
.prefixProxyLabel {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.prefixProxyTextarea {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
font-family: monospace;
resize: vertical;
min-height: 120px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
}
}
.prefixProxyFields {
display: grid;
grid-template-columns: 1fr;
gap: $spacing-sm;
:global(.form-group) {
margin: 0;
}
}
.cardActions { .cardActions {
display: flex; display: flex;
gap: $spacing-xs; gap: $spacing-xs;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
margin-top: auto; margin-top: auto;
padding-top: $spacing-sm; padding-top: $spacing-sm;
} }
.statusToggle {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: $spacing-sm;
}
.iconButton:global(.btn.btn-sm) { .iconButton:global(.btn.btn-sm) {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -742,6 +830,32 @@
} }
} }
// OAuth 模型映射表单
.mappingRow {
display: grid;
grid-template-columns: 1fr auto 1fr auto auto;
align-items: center;
gap: $spacing-sm;
@include mobile {
grid-template-columns: 1fr;
}
}
.mappingSeparator {
color: var(--text-secondary);
text-align: center;
@include mobile {
display: none;
}
}
.mappingFork {
display: flex;
align-items: center;
}
// 详情弹窗 // 详情弹窗
.detailContent { .detailContent {
max-height: 400px; max-height: 400px;

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { Navigate, useNavigate, useLocation } from 'react-router-dom'; import { Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -50,11 +50,6 @@ export function LoginPage() {
init(); init();
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]); }, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (!managementKey.trim()) { if (!managementKey.trim()) {
setError(t('login.error_required')); setError(t('login.error_required'));
@@ -81,6 +76,21 @@ export function LoginPage() {
} }
}; };
const handleSubmitKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !loading) {
event.preventDefault();
handleSubmit();
}
},
[loading, handleSubmit]
);
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
return ( return (
<div className="login-page"> <div className="login-page">
<div className="login-card"> <div className="login-card">
@@ -129,11 +139,13 @@ export function LoginPage() {
)} )}
<Input <Input
autoFocus
label={t('login.management_key_label')} label={t('login.management_key_label')}
placeholder={t('login.management_key_placeholder')} placeholder={t('login.management_key_placeholder')}
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
value={managementKey} value={managementKey}
onChange={(e) => setManagementKey(e.target.value)} onChange={(e) => setManagementKey(e.target.value)}
onKeyDown={handleSubmitKeyDown}
rightElement={ rightElement={
<button <button
type="button" type="button"

View File

@@ -371,7 +371,7 @@ type TabType = 'logs' | 'errors';
export function LogsPage() { export function LogsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false); const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
@@ -478,7 +478,12 @@ export function LogsPage() {
useHeaderRefresh(() => loadLogs(false)); useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => { const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return; showConfirmation({
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
message: t('logs.clear_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try { try {
await logsApi.clearLogs(); await logsApi.clearLogs();
setLogState({ buffer: [], visibleFrom: 0 }); setLogState({ buffer: [], visibleFrom: 0 });
@@ -491,6 +496,8 @@ export function LogsPage() {
'error' 'error'
); );
} }
},
});
}; };
const downloadLogs = () => { const downloadLogs = () => {

View File

@@ -30,6 +30,37 @@
display: flex; display: flex;
gap: $spacing-sm; gap: $spacing-sm;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
:global(.btn-sm) {
line-height: 16px;
}
:global(svg) {
display: block;
}
}
.titleWrapper {
display: flex;
align-items: center;
gap: $spacing-sm;
line-height: 24px;
}
.countBadge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
min-width: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: var(--count-badge-text);
background-color: var(--count-badge-bg);
box-sizing: border-box;
} }
.errorBox { .errorBox {
@@ -76,11 +107,7 @@
.geminiCliGrid { .geminiCliGrid {
display: grid; display: grid;
gap: $spacing-md; gap: $spacing-md;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile { @include mobile {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -112,28 +139,28 @@
} }
} }
.viewModeToggle {
display: flex;
gap: $spacing-xs;
align-items: center;
}
.antigravityCard { .antigravityCard {
background-image: linear-gradient( background-image: linear-gradient(180deg,
180deg,
rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0.12),
rgba(224, 247, 250, 0) rgba(224, 247, 250, 0));
);
} }
.codexCard { .codexCard {
background-image: linear-gradient( background-image: linear-gradient(180deg,
180deg,
rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0.18),
rgba(255, 243, 224, 0) rgba(255, 243, 224, 0));
);
} }
.geminiCliCard { .geminiCliCard {
background-image: linear-gradient( background-image: linear-gradient(180deg,
180deg,
rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0.2),
rgba(231, 239, 255, 0) rgba(231, 239, 255, 0));
);
} }
.quotaSection { .quotaSection {
@@ -331,3 +358,32 @@
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
border-radius: $radius-md; border-radius: $radius-md;
} }
.warningOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.warningModal {
background-color: var(--bg-primary);
border-radius: $radius-lg;
padding: $spacing-lg;
max-width: 400px;
text-align: center;
box-shadow: $shadow-lg;
p {
margin: 0 0 $spacing-md 0;
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
}
}

View File

@@ -122,6 +122,33 @@
} }
} }
.retryRowAligned {
align-items: flex-start;
.retryButton {
margin-top: calc(1.5em + #{$spacing-xs});
}
@include mobile {
align-items: stretch;
.retryButton {
margin-top: 0;
}
}
}
.retryRowInputGrow {
:global(.form-group) {
flex: 1 1 0;
min-width: 0;
}
.retryInput {
width: 100%;
}
}
.retryInput { .retryInput {
width: 140px; width: 140px;

View File

@@ -13,6 +13,9 @@ type PendingKey =
| 'debug' | 'debug'
| 'proxy' | 'proxy'
| 'retry' | 'retry'
| 'logsMaxSize'
| 'forceModelPrefix'
| 'routingStrategy'
| 'switchProject' | 'switchProject'
| 'switchPreview' | 'switchPreview'
| 'usage' | 'usage'
@@ -31,6 +34,8 @@ export function SettingsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [proxyValue, setProxyValue] = useState(''); const [proxyValue, setProxyValue] = useState('');
const [retryValue, setRetryValue] = useState(0); const [retryValue, setRetryValue] = useState(0);
const [logsMaxTotalSizeMb, setLogsMaxTotalSizeMb] = useState(0);
const [routingStrategy, setRoutingStrategy] = useState('round-robin');
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>); const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -41,9 +46,34 @@ export function SettingsPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const data = (await fetchConfig()) as Config; const [configResult, logsResult, prefixResult, routingResult] = await Promise.allSettled([
fetchConfig(),
configApi.getLogsMaxTotalSizeMb(),
configApi.getForceModelPrefix(),
configApi.getRoutingStrategy(),
]);
if (configResult.status !== 'fulfilled') {
throw configResult.reason;
}
const data = configResult.value as Config;
setProxyValue(data?.proxyUrl ?? ''); setProxyValue(data?.proxyUrl ?? '');
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0); setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
if (logsResult.status === 'fulfilled' && Number.isFinite(logsResult.value)) {
setLogsMaxTotalSizeMb(Math.max(0, Number(logsResult.value)));
updateConfigValue('logs-max-total-size-mb', Math.max(0, Number(logsResult.value)));
}
if (prefixResult.status === 'fulfilled') {
updateConfigValue('force-model-prefix', Boolean(prefixResult.value));
}
if (routingResult.status === 'fulfilled' && routingResult.value) {
setRoutingStrategy(String(routingResult.value));
updateConfigValue('routing/strategy', String(routingResult.value));
}
} catch (err: any) { } catch (err: any) {
setError(err?.message || t('notification.refresh_failed')); setError(err?.message || t('notification.refresh_failed'));
} finally { } finally {
@@ -52,7 +82,7 @@ export function SettingsPage() {
}; };
load(); load();
}, [fetchConfig, t]); }, [fetchConfig, t, updateConfigValue]);
useEffect(() => { useEffect(() => {
if (config) { if (config) {
@@ -60,8 +90,14 @@ export function SettingsPage() {
if (typeof config.requestRetry === 'number') { if (typeof config.requestRetry === 'number') {
setRetryValue(config.requestRetry); setRetryValue(config.requestRetry);
} }
if (typeof config.logsMaxTotalSizeMb === 'number') {
setLogsMaxTotalSizeMb(config.logsMaxTotalSizeMb);
} }
}, [config?.proxyUrl, config?.requestRetry]); if (config.routingStrategy) {
setRoutingStrategy(config.routingStrategy);
}
}
}, [config?.proxyUrl, config?.requestRetry, config?.logsMaxTotalSizeMb, config?.routingStrategy]);
const setPendingFlag = (key: PendingKey, value: boolean) => { const setPendingFlag = (key: PendingKey, value: boolean) => {
setPending((prev) => ({ ...prev, [key]: value })); setPending((prev) => ({ ...prev, [key]: value }));
@@ -69,7 +105,7 @@ export function SettingsPage() {
const toggleSetting = async ( const toggleSetting = async (
section: PendingKey, section: PendingKey,
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth', rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth' | 'force-model-prefix',
value: boolean, value: boolean,
updater: (val: boolean) => Promise<any>, updater: (val: boolean) => Promise<any>,
successMessage: string successMessage: string
@@ -84,6 +120,8 @@ export function SettingsPage() {
return config?.loggingToFile ?? false; return config?.loggingToFile ?? false;
case 'ws-auth': case 'ws-auth':
return config?.wsAuth ?? false; return config?.wsAuth ?? false;
case 'force-model-prefix':
return config?.forceModelPrefix ?? false;
default: default:
return false; return false;
} }
@@ -162,6 +200,52 @@ export function SettingsPage() {
} }
}; };
const handleLogsMaxTotalSizeUpdate = async () => {
const previous = config?.logsMaxTotalSizeMb ?? 0;
const parsed = Number(logsMaxTotalSizeMb);
if (!Number.isFinite(parsed) || parsed < 0) {
showNotification(t('login.error_invalid'), 'error');
setLogsMaxTotalSizeMb(previous);
return;
}
const normalized = Math.max(0, parsed);
setPendingFlag('logsMaxSize', true);
updateConfigValue('logs-max-total-size-mb', normalized);
try {
await configApi.updateLogsMaxTotalSizeMb(normalized);
clearCache('logs-max-total-size-mb');
showNotification(t('notification.logs_max_total_size_updated'), 'success');
} catch (err: any) {
setLogsMaxTotalSizeMb(previous);
updateConfigValue('logs-max-total-size-mb', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('logsMaxSize', false);
}
};
const handleRoutingStrategyUpdate = async () => {
const strategy = routingStrategy.trim();
if (!strategy) {
showNotification(t('login.error_invalid'), 'error');
return;
}
const previous = config?.routingStrategy ?? 'round-robin';
setPendingFlag('routingStrategy', true);
updateConfigValue('routing/strategy', strategy);
try {
await configApi.updateRoutingStrategy(strategy);
clearCache('routing/strategy');
showNotification(t('notification.routing_strategy_updated'), 'success');
} catch (err: any) {
setRoutingStrategy(previous);
updateConfigValue('routing/strategy', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('routingStrategy', false);
}
};
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false; const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false; const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
@@ -226,6 +310,21 @@ export function SettingsPage() {
) )
} }
/> />
<ToggleSwitch
label={t('basic_settings.force_model_prefix_enable')}
checked={config?.forceModelPrefix ?? false}
disabled={disableControls || pending.forceModelPrefix || loading}
onChange={(value) =>
toggleSetting(
'forceModelPrefix',
'force-model-prefix',
value,
configApi.updateForceModelPrefix,
t('notification.force_model_prefix_updated')
)
}
/>
</div> </div>
</Card> </Card>
@@ -271,6 +370,57 @@ export function SettingsPage() {
</div> </div>
</Card> </Card>
<Card title={t('basic_settings.logs_max_total_size_title')}>
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
<Input
label={t('basic_settings.logs_max_total_size_label')}
hint={t('basic_settings.logs_max_total_size_hint')}
type="number"
inputMode="numeric"
min={0}
step={1}
value={logsMaxTotalSizeMb}
onChange={(e) => setLogsMaxTotalSizeMb(Number(e.target.value))}
disabled={disableControls || loading}
className={styles.retryInput}
/>
<Button
className={styles.retryButton}
onClick={handleLogsMaxTotalSizeUpdate}
loading={pending.logsMaxSize}
disabled={disableControls || loading}
>
{t('basic_settings.logs_max_total_size_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.routing_title')}>
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
<div className="form-group">
<label>{t('basic_settings.routing_strategy_label')}</label>
<select
className="input"
value={routingStrategy}
onChange={(e) => setRoutingStrategy(e.target.value)}
disabled={disableControls || loading}
>
<option value="round-robin">{t('basic_settings.routing_strategy_round_robin')}</option>
<option value="fill-first">{t('basic_settings.routing_strategy_fill_first')}</option>
</select>
<div className="hint">{t('basic_settings.routing_strategy_hint')}</div>
</div>
<Button
className={styles.retryButton}
onClick={handleRoutingStrategyUpdate}
loading={pending.routingStrategy}
disabled={disableControls || loading}
>
{t('basic_settings.routing_strategy_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.quota_title')}> <Card title={t('basic_settings.quota_title')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch <ToggleSwitch

View File

@@ -11,7 +11,7 @@ import styles from './SystemPage.module.scss';
export function SystemPage() { export function SystemPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const auth = useAuthStore(); const auth = useAuthStore();
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig); const fetchConfig = useConfigStore((state) => state.fetchConfig);
@@ -106,12 +106,19 @@ export function SystemPage() {
}; };
const handleClearLoginStorage = () => { const handleClearLoginStorage = () => {
if (!window.confirm(t('system_info.clear_login_confirm'))) return; showConfirmation({
title: t('system_info.clear_login_title', { defaultValue: 'Clear Login Storage' }),
message: t('system_info.clear_login_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: () => {
auth.logout(); auth.logout();
if (typeof localStorage === 'undefined') return; if (typeof localStorage === 'undefined') return;
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey']; const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
keysToRemove.forEach((key) => localStorage.removeItem(key)); keysToRemove.forEach((key) => localStorage.removeItem(key));
showNotification(t('notification.login_storage_cleared'), 'success'); showNotification(t('notification.login_storage_cleared'), 'success');
},
});
}; };
useEffect(() => { useEffect(() => {

View File

@@ -4,10 +4,112 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile'; import type { AuthFilesResponse } from '@/types/authFile';
import type { OAuthModelMappingEntry } from '@/types';
type StatusError = { status?: number };
type AuthFileStatusResponse = { status: string; disabled: boolean };
const getStatusCode = (err: unknown): number | undefined => {
if (!err || typeof err !== 'object') return undefined;
if ('status' in err) return (err as StatusError).status;
return undefined;
};
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
if (!payload || typeof payload !== 'object') return {};
const record = payload as Record<string, unknown>;
const source = record['oauth-excluded-models'] ?? record.items ?? payload;
if (!source || typeof source !== 'object') return {};
const result: Record<string, string[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([provider, models]) => {
const key = String(provider ?? '')
.trim()
.toLowerCase();
if (!key) return;
const rawList = Array.isArray(models)
? models
: typeof models === 'string'
? models.split(/[\n,]+/)
: [];
const seen = new Set<string>();
const normalized: string[] = [];
rawList.forEach((item) => {
const trimmed = String(item ?? '').trim();
if (!trimmed) return;
const modelKey = trimmed.toLowerCase();
if (seen.has(modelKey)) return;
seen.add(modelKey);
normalized.push(trimmed);
});
result[key] = normalized;
});
return result;
};
const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthModelMappingEntry[]> => {
if (!payload || typeof payload !== 'object') return {};
const record = payload as Record<string, unknown>;
const source =
record['oauth-model-mappings'] ??
record['oauth-model-alias'] ??
record.items ??
payload;
if (!source || typeof source !== 'object') return {};
const result: Record<string, OAuthModelMappingEntry[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([channel, mappings]) => {
const key = String(channel ?? '')
.trim()
.toLowerCase();
if (!key) return;
if (!Array.isArray(mappings)) return;
const seen = new Set<string>();
const normalized = mappings
.map((item) => {
if (!item || typeof item !== 'object') return null;
const entry = item as Record<string, unknown>;
const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const fork = entry.fork === true;
return fork ? { name, alias, fork } : { name, alias };
})
.filter(Boolean)
.filter((entry) => {
const mapping = entry as OAuthModelMappingEntry;
const dedupeKey = `${mapping.name.toLowerCase()}::${mapping.alias.toLowerCase()}::${mapping.fork ? '1' : '0'}`;
if (seen.has(dedupeKey)) return false;
seen.add(dedupeKey);
return true;
}) as OAuthModelMappingEntry[];
if (normalized.length) {
result[key] = normalized;
}
});
return result;
};
const OAUTH_MODEL_MAPPINGS_ENDPOINT = '/oauth-model-mappings';
const OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT = '/oauth-model-alias';
export const authFilesApi = { export const authFilesApi = {
list: () => apiClient.get<AuthFilesResponse>('/auth-files'), list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
setStatus: (name: string, disabled: boolean) =>
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
upload: (file: File) => { upload: (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file, file.name); formData.append('file', file, file.name);
@@ -18,11 +120,18 @@ export const authFilesApi = {
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }), deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
downloadText: async (name: string): Promise<string> => {
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
responseType: 'blob'
});
const blob = response.data as Blob;
return blob.text();
},
// OAuth 排除模型 // OAuth 排除模型
async getOauthExcludedModels(): Promise<Record<string, string[]>> { async getOauthExcludedModels(): Promise<Record<string, string[]>> {
const data = await apiClient.get('/oauth-excluded-models'); const data = await apiClient.get('/oauth-excluded-models');
const payload = (data && (data['oauth-excluded-models'] ?? data.items ?? data)) as any; return normalizeOauthExcludedModels(data);
return payload && typeof payload === 'object' ? payload : {};
}, },
saveOauthExcludedModels: (provider: string, models: string[]) => saveOauthExcludedModels: (provider: string, models: string[]) =>
@@ -31,6 +140,69 @@ export const authFilesApi = {
deleteOauthExcludedEntry: (provider: string) => deleteOauthExcludedEntry: (provider: string) =>
apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`), apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`),
replaceOauthExcludedModels: (map: Record<string, string[]>) =>
apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)),
// OAuth 模型映射
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
try {
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_ENDPOINT);
return normalizeOauthModelMappings(data);
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT);
return normalizeOauthModelMappings(data);
}
},
saveOauthModelMappings: async (channel: string, mappings: OAuthModelMappingEntry[]) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const normalizedMappings = normalizeOauthModelMappings({ [normalizedChannel]: mappings })[normalizedChannel] ?? [];
try {
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: normalizedMappings });
return;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: normalizedMappings });
}
},
deleteOauthModelMappings: async (channel: string) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const deleteViaPatch = async () => {
try {
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: [] });
return true;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: [] });
return true;
}
};
try {
await deleteViaPatch();
return;
} catch (err: unknown) {
const status = getStatusCode(err);
if (status !== 405) throw err;
}
try {
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
return;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
}
},
// 获取认证凭证支持的模型 // 获取认证凭证支持的模型
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> { async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`); const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);

View File

@@ -68,8 +68,48 @@ export const configApi = {
*/ */
updateLoggingToFile: (enabled: boolean) => apiClient.put('/logging-to-file', { value: enabled }), updateLoggingToFile: (enabled: boolean) => apiClient.put('/logging-to-file', { value: enabled }),
/**
* 获取日志总大小上限MB
*/
async getLogsMaxTotalSizeMb(): Promise<number> {
const data = await apiClient.get('/logs-max-total-size-mb');
return data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
},
/**
* 更新日志总大小上限MB
*/
updateLogsMaxTotalSizeMb: (value: number) =>
apiClient.put('/logs-max-total-size-mb', { value }),
/** /**
* WebSocket 鉴权开关 * WebSocket 鉴权开关
*/ */
updateWsAuth: (enabled: boolean) => apiClient.put('/ws-auth', { value: enabled }), updateWsAuth: (enabled: boolean) => apiClient.put('/ws-auth', { value: enabled }),
/**
* 获取强制模型前缀开关
*/
async getForceModelPrefix(): Promise<boolean> {
const data = await apiClient.get('/force-model-prefix');
return data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false;
},
/**
* 更新强制模型前缀开关
*/
updateForceModelPrefix: (enabled: boolean) => apiClient.put('/force-model-prefix', { value: enabled }),
/**
* 获取路由策略
*/
async getRoutingStrategy(): Promise<string> {
const data = await apiClient.get('/routing/strategy');
return data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy ?? 'round-robin';
},
/**
* 更新路由策略
*/
updateRoutingStrategy: (strategy: string) => apiClient.put('/routing/strategy', { value: strategy }),
}; };

View File

@@ -20,12 +20,21 @@ const normalizeBaseUrl = (baseUrl: string): string => {
const buildModelsEndpoint = (baseUrl: string): string => { const buildModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeBaseUrl(baseUrl); const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return ''; if (!normalized) return '';
return normalized.endsWith('/v1') ? `${normalized}/models` : `${normalized}/v1/models`; return `${normalized}/models`;
};
const buildV1ModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return '';
return `${normalized}/v1/models`;
}; };
export const modelsApi = { export const modelsApi = {
/**
* Fetch available models from /v1/models endpoint (for system info page)
*/
async fetchModels(baseUrl: string, apiKey?: string, headers: Record<string, string> = {}) { async fetchModels(baseUrl: string, apiKey?: string, headers: Record<string, string> = {}) {
const endpoint = buildModelsEndpoint(baseUrl); const endpoint = buildV1ModelsEndpoint(baseUrl);
if (!endpoint) { if (!endpoint) {
throw new Error('Invalid base url'); throw new Error('Invalid base url');
} }
@@ -42,6 +51,9 @@ export const modelsApi = {
return normalizeModelList(payload, { dedupe: true }); return normalizeModelList(payload, { dedupe: true });
}, },
/**
* Fetch models from /models endpoint via api-call (for OpenAI provider discovery)
*/
async fetchModelsViaApiCall( async fetchModelsViaApiCall(
baseUrl: string, baseUrl: string,
apiKey?: string, apiKey?: string,

View File

@@ -61,6 +61,30 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
return payload; return payload;
}; };
const serializeVertexModelAliases = (models?: ModelAlias[]) =>
Array.isArray(models)
? models
.map((model) => {
const name = typeof model?.name === 'string' ? model.name.trim() : '';
const alias = typeof model?.alias === 'string' ? model.alias.trim() : '';
if (!name || !alias) return null;
return { name, alias };
})
.filter(Boolean)
: undefined;
const serializeVertexKey = (config: ProviderKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers;
const models = serializeVertexModelAliases(config.models);
if (models && models.length) payload.models = models;
return payload;
};
const serializeGeminiKey = (config: GeminiKeyConfig) => { const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey }; const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim(); if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
@@ -140,6 +164,22 @@ export const providersApi = {
deleteClaudeConfig: (apiKey: string) => deleteClaudeConfig: (apiKey: string) =>
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`), apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/vertex-api-key');
const list = (data && (data['vertex-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
saveVertexConfigs: (configs: ProviderKeyConfig[]) =>
apiClient.put('/vertex-api-key', configs.map((item) => serializeVertexKey(item))),
updateVertexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/vertex-api-key', { index, value: serializeVertexKey(value) }),
deleteVertexConfig: (apiKey: string) =>
apiClient.delete(`/vertex-api-key?api-key=${encodeURIComponent(apiKey)}`),
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> { async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
const data = await apiClient.get('/openai-compatibility'); const data = await apiClient.get('/openai-compatibility');
const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any; const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any;

View File

@@ -258,7 +258,15 @@ export const normalizeConfigResponse = (raw: any): Config => {
config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled; config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled;
config.requestLog = raw['request-log'] ?? raw.requestLog; config.requestLog = raw['request-log'] ?? raw.requestLog;
config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile; config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile;
config.logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
config.wsAuth = raw['ws-auth'] ?? raw.wsAuth; config.wsAuth = raw['ws-auth'] ?? raw.wsAuth;
config.forceModelPrefix = raw['force-model-prefix'] ?? raw.forceModelPrefix;
const routing = raw.routing;
if (routing && typeof routing === 'object') {
config.routingStrategy = routing.strategy ?? routing['strategy'];
} else {
config.routingStrategy = raw['routing-strategy'] ?? raw.routingStrategy;
}
config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys; config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys;
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys; const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
@@ -282,6 +290,13 @@ export const normalizeConfigResponse = (raw: any): Config => {
.filter(Boolean) as ProviderKeyConfig[]; .filter(Boolean) as ProviderKeyConfig[];
} }
const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys;
if (Array.isArray(vertexList)) {
config.vertexApiKeys = vertexList
.map((item: any) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility; const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
if (Array.isArray(openaiList)) { if (Array.isArray(openaiList)) {
config.openaiCompatibility = openaiList config.openaiCompatibility = openaiList

View File

@@ -38,12 +38,16 @@ const SECTION_KEYS: RawConfigSection[] = [
'usage-statistics-enabled', 'usage-statistics-enabled',
'request-log', 'request-log',
'logging-to-file', 'logging-to-file',
'logs-max-total-size-mb',
'ws-auth', 'ws-auth',
'force-model-prefix',
'routing/strategy',
'api-keys', 'api-keys',
'ampcode', 'ampcode',
'gemini-api-key', 'gemini-api-key',
'codex-api-key', 'codex-api-key',
'claude-api-key', 'claude-api-key',
'vertex-api-key',
'openai-compatibility', 'openai-compatibility',
'oauth-excluded-models' 'oauth-excluded-models'
]; ];
@@ -65,8 +69,14 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
return config.requestLog; return config.requestLog;
case 'logging-to-file': case 'logging-to-file':
return config.loggingToFile; return config.loggingToFile;
case 'logs-max-total-size-mb':
return config.logsMaxTotalSizeMb;
case 'ws-auth': case 'ws-auth':
return config.wsAuth; return config.wsAuth;
case 'force-model-prefix':
return config.forceModelPrefix;
case 'routing/strategy':
return config.routingStrategy;
case 'api-keys': case 'api-keys':
return config.apiKeys; return config.apiKeys;
case 'ampcode': case 'ampcode':
@@ -77,6 +87,8 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
return config.codexApiKeys; return config.codexApiKeys;
case 'claude-api-key': case 'claude-api-key':
return config.claudeApiKeys; return config.claudeApiKeys;
case 'vertex-api-key':
return config.vertexApiKeys;
case 'openai-compatibility': case 'openai-compatibility':
return config.openaiCompatibility; return config.openaiCompatibility;
case 'oauth-excluded-models': case 'oauth-excluded-models':
@@ -194,9 +206,18 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
case 'logging-to-file': case 'logging-to-file':
nextConfig.loggingToFile = value; nextConfig.loggingToFile = value;
break; break;
case 'logs-max-total-size-mb':
nextConfig.logsMaxTotalSizeMb = value;
break;
case 'ws-auth': case 'ws-auth':
nextConfig.wsAuth = value; nextConfig.wsAuth = value;
break; break;
case 'force-model-prefix':
nextConfig.forceModelPrefix = value;
break;
case 'routing/strategy':
nextConfig.routingStrategy = value;
break;
case 'api-keys': case 'api-keys':
nextConfig.apiKeys = value; nextConfig.apiKeys = value;
break; break;
@@ -212,6 +233,9 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
case 'claude-api-key': case 'claude-api-key':
nextConfig.claudeApiKeys = value; nextConfig.claudeApiKeys = value;
break; break;
case 'vertex-api-key':
nextConfig.vertexApiKeys = value;
break;
case 'openai-compatibility': case 'openai-compatibility':
nextConfig.openaiCompatibility = value; nextConfig.openaiCompatibility = value;
break; break;

View File

@@ -8,15 +8,38 @@ import type { Notification, NotificationType } from '@/types';
import { generateId } from '@/utils/helpers'; import { generateId } from '@/utils/helpers';
import { NOTIFICATION_DURATION_MS } from '@/utils/constants'; import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
interface ConfirmationOptions {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'primary' | 'secondary';
onConfirm: () => void | Promise<void>;
onCancel?: () => void;
}
interface NotificationState { interface NotificationState {
notifications: Notification[]; notifications: Notification[];
confirmation: {
isOpen: boolean;
isLoading: boolean;
options: ConfirmationOptions | null;
};
showNotification: (message: string, type?: NotificationType, duration?: number) => void; showNotification: (message: string, type?: NotificationType, duration?: number) => void;
removeNotification: (id: string) => void; removeNotification: (id: string) => void;
clearAll: () => void; clearAll: () => void;
showConfirmation: (options: ConfirmationOptions) => void;
hideConfirmation: () => void;
setConfirmationLoading: (loading: boolean) => void;
} }
export const useNotificationStore = create<NotificationState>((set) => ({ export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [], notifications: [],
confirmation: {
isOpen: false,
isLoading: false,
options: null
},
showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => { showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => {
const id = generateId(); const id = generateId();
@@ -49,5 +72,34 @@ export const useNotificationStore = create<NotificationState>((set) => ({
clearAll: () => { clearAll: () => {
set({ notifications: [] }); set({ notifications: [] });
},
showConfirmation: (options) => {
set({
confirmation: {
isOpen: true,
isLoading: false,
options
}
});
},
hideConfirmation: () => {
set((state) => ({
confirmation: {
...state.confirmation,
isOpen: false,
options: null // Cleanup
}
}));
},
setConfirmationLoading: (loading) => {
set((state) => ({
confirmation: {
...state.confirmation,
isLoading: loading
}
}));
} }
})); }));

View File

@@ -453,6 +453,18 @@ textarea {
&:active { &:active {
transform: scale(0.95); transform: scale(0.95);
} }
&:disabled {
cursor: not-allowed;
opacity: 0.6;
transform: none;
}
&:disabled:hover {
color: var(--text-secondary);
background: var(--bg-secondary);
transform: none;
}
} }
.modal-header { .modal-header {

View File

@@ -32,6 +32,9 @@
--failure-badge-text: #991b1b; --failure-badge-text: #991b1b;
--failure-badge-border: #fca5a5; --failure-badge-border: #fca5a5;
--count-badge-bg: rgba(59, 130, 246, 0.14);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
} }
@@ -66,6 +69,9 @@
--failure-badge-text: #fca5a5; --failure-badge-text: #fca5a5;
--failure-badge-border: #dc2626; --failure-badge-border: #dc2626;
--count-badge-bg: rgba(59, 130, 246, 0.25);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3); --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3);
} }

View File

@@ -19,12 +19,16 @@ export interface Config {
usageStatisticsEnabled?: boolean; usageStatisticsEnabled?: boolean;
requestLog?: boolean; requestLog?: boolean;
loggingToFile?: boolean; loggingToFile?: boolean;
logsMaxTotalSizeMb?: number;
wsAuth?: boolean; wsAuth?: boolean;
forceModelPrefix?: boolean;
routingStrategy?: string;
apiKeys?: string[]; apiKeys?: string[];
ampcode?: AmpcodeConfig; ampcode?: AmpcodeConfig;
geminiApiKeys?: GeminiKeyConfig[]; geminiApiKeys?: GeminiKeyConfig[];
codexApiKeys?: ProviderKeyConfig[]; codexApiKeys?: ProviderKeyConfig[];
claudeApiKeys?: ProviderKeyConfig[]; claudeApiKeys?: ProviderKeyConfig[];
vertexApiKeys?: ProviderKeyConfig[];
openaiCompatibility?: OpenAIProviderConfig[]; openaiCompatibility?: OpenAIProviderConfig[];
oauthExcludedModels?: Record<string, string[]>; oauthExcludedModels?: Record<string, string[]>;
raw?: Record<string, any>; raw?: Record<string, any>;
@@ -38,12 +42,16 @@ export type RawConfigSection =
| 'usage-statistics-enabled' | 'usage-statistics-enabled'
| 'request-log' | 'request-log'
| 'logging-to-file' | 'logging-to-file'
| 'logs-max-total-size-mb'
| 'ws-auth' | 'ws-auth'
| 'force-model-prefix'
| 'routing/strategy'
| 'api-keys' | 'api-keys'
| 'ampcode' | 'ampcode'
| 'gemini-api-key' | 'gemini-api-key'
| 'codex-api-key' | 'codex-api-key'
| 'claude-api-key' | 'claude-api-key'
| 'vertex-api-key'
| 'openai-compatibility' | 'openai-compatibility'
| 'oauth-excluded-models'; | 'oauth-excluded-models';

View File

@@ -33,3 +33,12 @@ export interface OAuthConfig {
export interface OAuthExcludedModels { export interface OAuthExcludedModels {
models: string[]; models: string[];
} }
// OAuth 模型映射
export interface OAuthModelMappingEntry {
name: string;
alias: string;
fork?: boolean;
}
export type OAuthModelMappings = Record<string, OAuthModelMappingEntry[]>;

View File

@@ -55,6 +55,7 @@ export interface AntigravityQuotaGroupDefinition {
export interface GeminiCliQuotaGroupDefinition { export interface GeminiCliQuotaGroupDefinition {
id: string; id: string;
label: string; label: string;
preferredModelId?: string;
modelIds: string[]; modelIds: string[];
} }

View File

@@ -7,14 +7,16 @@
* 隐藏 API Key 中间部分,仅保留前后两位 * 隐藏 API Key 中间部分,仅保留前后两位
*/ */
export function maskApiKey(key: string): string { export function maskApiKey(key: string): string {
if (!key) { const trimmed = String(key || '').trim();
if (!trimmed) {
return ''; return '';
} }
const visibleChars = 2; const MASKED_LENGTH = 10;
const start = key.slice(0, visibleChars); const visibleChars = trimmed.length < 4 ? 1 : 2;
const end = key.slice(-visibleChars); const start = trimmed.slice(0, visibleChars);
const maskedLength = Math.max(key.length - visibleChars * 2, 1); const end = trimmed.slice(-visibleChars);
const maskedLength = Math.max(MASKED_LENGTH - visibleChars * 2, 1);
const masked = '*'.repeat(maskedLength); const masked = '*'.repeat(maskedLength);
return `${start}${masked}${end}`; return `${start}${masked}${end}`;

View File

@@ -8,7 +8,7 @@ import type {
AntigravityQuotaInfo, AntigravityQuotaInfo,
AntigravityModelsPayload, AntigravityModelsPayload,
GeminiCliParsedBucket, GeminiCliParsedBucket,
GeminiCliQuotaBucketState GeminiCliQuotaBucketState,
} from '@/types'; } from '@/types';
import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants'; import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants';
import { normalizeQuotaFraction } from './parsers'; import { normalizeQuotaFraction } from './parsers';
@@ -35,7 +35,19 @@ export function buildGeminiCliQuotaBuckets(
): GeminiCliQuotaBucketState[] { ): GeminiCliQuotaBucketState[] {
if (buckets.length === 0) return []; if (buckets.length === 0) return [];
const grouped = new Map<string, GeminiCliQuotaBucketState & { modelIds: string[] }>(); type GeminiCliQuotaBucketGroup = {
id: string;
label: string;
tokenType: string | null;
modelIds: string[];
preferredModelId?: string;
preferredBucket?: GeminiCliParsedBucket;
fallbackRemainingFraction: number | null;
fallbackRemainingAmount: number | null;
fallbackResetTime: string | undefined;
};
const grouped = new Map<string, GeminiCliQuotaBucketGroup>();
buckets.forEach((bucket) => { buckets.forEach((bucket) => {
if (isIgnoredGeminiCliModel(bucket.modelId)) return; if (isIgnoredGeminiCliModel(bucket.modelId)) return;
@@ -47,37 +59,55 @@ export function buildGeminiCliQuotaBuckets(
const existing = grouped.get(mapKey); const existing = grouped.get(mapKey);
if (!existing) { if (!existing) {
const preferredModelId = group?.preferredModelId;
const preferredBucket =
preferredModelId && bucket.modelId === preferredModelId ? bucket : undefined;
grouped.set(mapKey, { grouped.set(mapKey, {
id: `${groupId}${tokenKey ? `-${tokenKey}` : ''}`, id: `${groupId}${tokenKey ? `-${tokenKey}` : ''}`,
label, label,
remainingFraction: bucket.remainingFraction,
remainingAmount: bucket.remainingAmount,
resetTime: bucket.resetTime,
tokenType: bucket.tokenType, tokenType: bucket.tokenType,
modelIds: [bucket.modelId] modelIds: [bucket.modelId],
preferredModelId,
preferredBucket,
fallbackRemainingFraction: bucket.remainingFraction,
fallbackRemainingAmount: bucket.remainingAmount,
fallbackResetTime: bucket.resetTime,
}); });
return; return;
} }
existing.remainingFraction = minNullableNumber( existing.fallbackRemainingFraction = minNullableNumber(
existing.remainingFraction, existing.fallbackRemainingFraction,
bucket.remainingFraction bucket.remainingFraction
); );
existing.remainingAmount = minNullableNumber(existing.remainingAmount, bucket.remainingAmount); existing.fallbackRemainingAmount = minNullableNumber(
existing.resetTime = pickEarlierResetTime(existing.resetTime, bucket.resetTime); existing.fallbackRemainingAmount,
bucket.remainingAmount
);
existing.fallbackResetTime = pickEarlierResetTime(existing.fallbackResetTime, bucket.resetTime);
existing.modelIds.push(bucket.modelId); existing.modelIds.push(bucket.modelId);
if (existing.preferredModelId && bucket.modelId === existing.preferredModelId) {
existing.preferredBucket = bucket;
}
}); });
return Array.from(grouped.values()).map((bucket) => { return Array.from(grouped.values()).map((bucket) => {
const uniqueModelIds = Array.from(new Set(bucket.modelIds)); const uniqueModelIds = Array.from(new Set(bucket.modelIds));
const preferred = bucket.preferredBucket;
const remainingFraction = preferred
? preferred.remainingFraction
: bucket.fallbackRemainingFraction;
const remainingAmount = preferred ? preferred.remainingAmount : bucket.fallbackRemainingAmount;
const resetTime = preferred ? preferred.resetTime : bucket.fallbackResetTime;
return { return {
id: bucket.id, id: bucket.id,
label: bucket.label, label: bucket.label,
remainingFraction: bucket.remainingFraction, remainingFraction,
remainingAmount: bucket.remainingAmount, remainingAmount,
resetTime: bucket.resetTime, resetTime,
tokenType: bucket.tokenType, tokenType: bucket.tokenType,
modelIds: uniqueModelIds modelIds: uniqueModelIds,
}; };
}); });
} }
@@ -101,7 +131,7 @@ export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
return { return {
remainingFraction, remainingFraction,
resetTime, resetTime,
displayName displayName,
}; };
} }
@@ -150,7 +180,7 @@ export function buildAntigravityQuotaGroups(
id, id,
remainingFraction, remainingFraction,
resetTime: info.resetTime, resetTime: info.resetTime,
displayName: info.displayName displayName: info.displayName,
}; };
}) })
.filter((entry): entry is NonNullable<typeof entry> => entry !== null); .filter((entry): entry is NonNullable<typeof entry> => entry !== null);
@@ -168,7 +198,7 @@ export function buildAntigravityQuotaGroups(
label, label,
models: quotaEntries.map((entry) => entry.id), models: quotaEntries.map((entry) => entry.id),
remainingFraction, remainingFraction,
resetTime resetTime,
}; };
}; };

View File

@@ -5,64 +5,64 @@
import type { import type {
AntigravityQuotaGroupDefinition, AntigravityQuotaGroupDefinition,
GeminiCliQuotaGroupDefinition, GeminiCliQuotaGroupDefinition,
TypeColorSet TypeColorSet,
} from '@/types'; } from '@/types';
// Theme colors for type badges // Theme colors for type badges
export const TYPE_COLORS: Record<string, TypeColorSet> = { export const TYPE_COLORS: Record<string, TypeColorSet> = {
qwen: { qwen: {
light: { bg: '#e8f5e9', text: '#2e7d32' }, light: { bg: '#e8f5e9', text: '#2e7d32' },
dark: { bg: '#1b5e20', text: '#81c784' } dark: { bg: '#1b5e20', text: '#81c784' },
}, },
gemini: { gemini: {
light: { bg: '#e3f2fd', text: '#1565c0' }, light: { bg: '#e3f2fd', text: '#1565c0' },
dark: { bg: '#0d47a1', text: '#64b5f6' } dark: { bg: '#0d47a1', text: '#64b5f6' },
}, },
'gemini-cli': { 'gemini-cli': {
light: { bg: '#e7efff', text: '#1e4fa3' }, light: { bg: '#e7efff', text: '#1e4fa3' },
dark: { bg: '#1c3f73', text: '#a8c7ff' } dark: { bg: '#1c3f73', text: '#a8c7ff' },
}, },
aistudio: { aistudio: {
light: { bg: '#f0f2f5', text: '#2f343c' }, light: { bg: '#f0f2f5', text: '#2f343c' },
dark: { bg: '#373c42', text: '#cfd3db' } dark: { bg: '#373c42', text: '#cfd3db' },
}, },
claude: { claude: {
light: { bg: '#fce4ec', text: '#c2185b' }, light: { bg: '#fce4ec', text: '#c2185b' },
dark: { bg: '#880e4f', text: '#f48fb1' } dark: { bg: '#880e4f', text: '#f48fb1' },
}, },
codex: { codex: {
light: { bg: '#fff3e0', text: '#ef6c00' }, light: { bg: '#fff3e0', text: '#ef6c00' },
dark: { bg: '#e65100', text: '#ffb74d' } dark: { bg: '#e65100', text: '#ffb74d' },
}, },
antigravity: { antigravity: {
light: { bg: '#e0f7fa', text: '#006064' }, light: { bg: '#e0f7fa', text: '#006064' },
dark: { bg: '#004d40', text: '#80deea' } dark: { bg: '#004d40', text: '#80deea' },
}, },
iflow: { iflow: {
light: { bg: '#f3e5f5', text: '#7b1fa2' }, light: { bg: '#f3e5f5', text: '#7b1fa2' },
dark: { bg: '#4a148c', text: '#ce93d8' } dark: { bg: '#4a148c', text: '#ce93d8' },
}, },
empty: { empty: {
light: { bg: '#f5f5f5', text: '#616161' }, light: { bg: '#f5f5f5', text: '#616161' },
dark: { bg: '#424242', text: '#bdbdbd' } dark: { bg: '#424242', text: '#bdbdbd' },
}, },
unknown: { unknown: {
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' }, light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' } dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' },
} },
}; };
// Antigravity API configuration // Antigravity API configuration
export const ANTIGRAVITY_QUOTA_URLS = [ export const ANTIGRAVITY_QUOTA_URLS = [
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels', 'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels', 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels' 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
]; ];
export const ANTIGRAVITY_REQUEST_HEADERS = { export const ANTIGRAVITY_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$', Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.11.5 windows/amd64' 'User-Agent': 'antigravity/1.11.5 windows/amd64',
}; };
export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
@@ -73,40 +73,40 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
'claude-sonnet-4-5-thinking', 'claude-sonnet-4-5-thinking',
'claude-opus-4-5-thinking', 'claude-opus-4-5-thinking',
'claude-sonnet-4-5', 'claude-sonnet-4-5',
'gpt-oss-120b-medium' 'gpt-oss-120b-medium',
] ],
}, },
{ {
id: 'gemini-3-pro', id: 'gemini-3-pro',
label: 'Gemini 3 Pro', label: 'Gemini 3 Pro',
identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low'] identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low'],
}, },
{ {
id: 'gemini-2-5-flash', id: 'gemini-2-5-flash',
label: 'Gemini 2.5 Flash', label: 'Gemini 2.5 Flash',
identifiers: ['gemini-2.5-flash', 'gemini-2.5-flash-thinking'] identifiers: ['gemini-2.5-flash', 'gemini-2.5-flash-thinking'],
}, },
{ {
id: 'gemini-2-5-flash-lite', id: 'gemini-2-5-flash-lite',
label: 'Gemini 2.5 Flash Lite', label: 'Gemini 2.5 Flash Lite',
identifiers: ['gemini-2.5-flash-lite'] identifiers: ['gemini-2.5-flash-lite'],
}, },
{ {
id: 'gemini-2-5-cu', id: 'gemini-2-5-cu',
label: 'Gemini 2.5 CU', label: 'Gemini 2.5 CU',
identifiers: ['rev19-uic3-1p'] identifiers: ['rev19-uic3-1p'],
}, },
{ {
id: 'gemini-3-flash', id: 'gemini-3-flash',
label: 'Gemini 3 Flash', label: 'Gemini 3 Flash',
identifiers: ['gemini-3-flash'] identifiers: ['gemini-3-flash'],
}, },
{ {
id: 'gemini-image', id: 'gemini-image',
label: 'gemini-3-pro-image', label: 'gemini-3-pro-image',
identifiers: ['gemini-3-pro-image'], identifiers: ['gemini-3-pro-image'],
labelFromModel: true labelFromModel: true,
} },
]; ];
// Gemini CLI API configuration // Gemini CLI API configuration
@@ -115,30 +115,22 @@ export const GEMINI_CLI_QUOTA_URL =
export const GEMINI_CLI_REQUEST_HEADERS = { export const GEMINI_CLI_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$', Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json' 'Content-Type': 'application/json',
}; };
export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [ export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
{ {
id: 'gemini-2-5-flash-series', id: 'gemini-flash-series',
label: 'Gemini 2.5 Flash Series', label: 'Gemini Flash Series',
modelIds: ['gemini-2.5-flash', 'gemini-2.5-flash-lite'] preferredModelId: 'gemini-3-flash-preview',
modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'],
}, },
{ {
id: 'gemini-2-5-pro', id: 'gemini-pro-series',
label: 'Gemini 2.5 Pro', label: 'Gemini Pro Series',
modelIds: ['gemini-2.5-pro'] preferredModelId: 'gemini-3-pro-preview',
modelIds: ['gemini-3-pro-preview', 'gemini-2.5-pro'],
}, },
{
id: 'gemini-3-pro-preview',
label: 'Gemini 3 Pro Preview',
modelIds: ['gemini-3-pro-preview']
},
{
id: 'gemini-3-flash-preview',
label: 'Gemini 3 Flash Preview',
modelIds: ['gemini-3-flash-preview']
}
]; ];
export const GEMINI_CLI_GROUP_LOOKUP = new Map( export const GEMINI_CLI_GROUP_LOOKUP = new Map(
@@ -155,5 +147,5 @@ export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
export const CODEX_REQUEST_HEADERS = { export const CODEX_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$', Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal' 'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal',
}; };

View File

@@ -73,6 +73,124 @@ const normalizeAuthIndex = (value: any) => {
return null; return null;
}; };
const USAGE_SOURCE_PREFIX_KEY = 'k:';
const USAGE_SOURCE_PREFIX_MASKED = 'm:';
const USAGE_SOURCE_PREFIX_TEXT = 't:';
const KEY_LIKE_TOKEN_REGEX =
/(sk-[A-Za-z0-9-_]{6,}|sk-ant-[A-Za-z0-9-_]{6,}|AIza[0-9A-Za-z-_]{8,}|AI[a-zA-Z0-9_-]{6,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/;
const MASKED_TOKEN_HINT_REGEX = /^[^\s]{1,24}(\*{2,}|\.{3}|…)[^\s]{1,24}$/;
const keyFingerprintCache = new Map<string, string>();
const fnv1a64Hex = (value: string): string => {
const cached = keyFingerprintCache.get(value);
if (cached) return cached;
const FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
const FNV_PRIME = 0x100000001b3n;
let hash = FNV_OFFSET_BASIS;
for (let i = 0; i < value.length; i++) {
hash ^= BigInt(value.charCodeAt(i));
hash = (hash * FNV_PRIME) & 0xffffffffffffffffn;
}
const hex = hash.toString(16).padStart(16, '0');
keyFingerprintCache.set(value, hex);
return hex;
};
const looksLikeRawSecret = (text: string): boolean => {
if (!text || /\s/.test(text)) return false;
const lower = text.toLowerCase();
if (lower.endsWith('.json')) return false;
if (lower.startsWith('http://') || lower.startsWith('https://')) return false;
if (/[\\/]/.test(text)) return false;
if (KEY_LIKE_TOKEN_REGEX.test(text)) return true;
if (text.length >= 32 && text.length <= 512) {
return true;
}
if (text.length >= 16 && text.length < 32 && /^[A-Za-z0-9._=-]+$/.test(text)) {
return /[A-Za-z]/.test(text) && /\d/.test(text);
}
return false;
};
const extractRawSecretFromText = (text: string): string | null => {
if (!text) return null;
if (looksLikeRawSecret(text)) return text;
const keyLikeMatch = text.match(KEY_LIKE_TOKEN_REGEX);
if (keyLikeMatch?.[0]) return keyLikeMatch[0];
const queryMatch = text.match(
/(?:[?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/i
);
const queryValue = queryMatch?.[2];
if (queryValue && looksLikeRawSecret(queryValue)) {
return queryValue;
}
const headerMatch = text.match(
/(api[-_]?key|key|token|access[-_]?token|authorization)\s*[:=]\s*([A-Za-z0-9._=-]+)/i
);
const headerValue = headerMatch?.[2];
if (headerValue && looksLikeRawSecret(headerValue)) {
return headerValue;
}
const bearerMatch = text.match(/\bBearer\s+([A-Za-z0-9._=-]{6,})/i);
const bearerValue = bearerMatch?.[1];
if (bearerValue && looksLikeRawSecret(bearerValue)) {
return bearerValue;
}
return null;
};
export function normalizeUsageSourceId(
value: unknown,
masker: (val: string) => string = maskApiKey
): string {
const raw = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
const trimmed = raw.trim();
if (!trimmed) return '';
const extracted = extractRawSecretFromText(trimmed);
if (extracted) {
return `${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(extracted)}`;
}
if (MASKED_TOKEN_HINT_REGEX.test(trimmed)) {
return `${USAGE_SOURCE_PREFIX_MASKED}${masker(trimmed)}`;
}
return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`;
}
export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: string }): string[] {
const result: string[] = [];
const prefix = input.prefix?.trim();
if (prefix) {
result.push(`${USAGE_SOURCE_PREFIX_TEXT}${prefix}`);
}
const apiKey = input.apiKey?.trim();
if (apiKey) {
result.push(`${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(apiKey)}`);
result.push(`${USAGE_SOURCE_PREFIX_MASKED}${maskApiKey(apiKey)}`);
}
return Array.from(new Set(result));
}
/** /**
* 对使用数据中的敏感字段进行遮罩 * 对使用数据中的敏感字段进行遮罩
*/ */
@@ -200,6 +318,7 @@ export function collectUsageDetails(usageData: any): UsageDetail[] {
if (detail && detail.timestamp) { if (detail && detail.timestamp) {
details.push({ details.push({
...detail, ...detail,
source: normalizeUsageSourceId(detail.source),
__modelName: modelName __modelName: modelName
}); });
} }
@@ -638,6 +757,8 @@ export interface ChartDataset {
data: number[]; data: number[];
borderColor: string; borderColor: string;
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient); backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
pointBackgroundColor?: string;
pointBorderColor?: string;
fill: boolean; fill: boolean;
tension: number; tension: number;
} }
@@ -743,6 +864,8 @@ export function buildChartData(
backgroundColor: shouldFill backgroundColor: shouldFill
? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor) ? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor)
: style.backgroundColor, : style.backgroundColor,
pointBackgroundColor: style.borderColor,
pointBorderColor: style.borderColor,
fill: shouldFill, fill: shouldFill,
tension: 0.35 tension: 0.35
}; };
@@ -780,11 +903,11 @@ export function calculateStatusBarData(
authIndexFilter?: number authIndexFilter?: number
): StatusBarData { ): StatusBarData {
const BLOCK_COUNT = 20; const BLOCK_COUNT = 20;
const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
const HOUR_MS = 60 * 60 * 1000; const WINDOW_MS = 200 * 60 * 1000; // 200 minutes
const now = Date.now(); const now = Date.now();
const hourAgo = now - HOUR_MS; const windowStart = now - WINDOW_MS;
// Initialize blocks // Initialize blocks
const blockStats: Array<{ success: number; failure: number }> = Array.from( const blockStats: Array<{ success: number; failure: number }> = Array.from(
@@ -798,7 +921,7 @@ export function calculateStatusBarData(
// Filter and bucket the usage details // Filter and bucket the usage details
usageDetails.forEach((detail) => { usageDetails.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp); const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) { if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
return; return;
} }
@@ -874,7 +997,7 @@ export function computeKeyStats(usageData: any, masker: (val: string) => string
const details = modelEntry?.details || []; const details = modelEntry?.details || [];
details.forEach((detail: any) => { details.forEach((detail: any) => {
const source = maskUsageSensitiveValue(detail?.source, masker); const source = normalizeUsageSourceId(detail?.source, masker);
const authIndexKey = normalizeAuthIndex(detail?.auth_index); const authIndexKey = normalizeAuthIndex(detail?.auth_index);
const isFailed = detail?.failed === true; const isFailed = detail?.failed === true;