Compare commits
45 Commits
v1.2.20
...
c6fabcb6bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6fabcb6bc | ||
|
|
460519ed00 | ||
|
|
1053e91fe4 | ||
|
|
b4d08dd0d7 | ||
|
|
1502e14ca7 | ||
|
|
7b77520526 | ||
|
|
525541ea0d | ||
|
|
e7a33f8852 | ||
|
|
70968bbc4c | ||
|
|
c93030370e | ||
|
|
96307873c5 | ||
|
|
b4eb2d790c | ||
|
|
3d33958d9e | ||
|
|
e4c5f80b02 | ||
|
|
291f67e2b9 | ||
|
|
3cdcb7a2a3 | ||
|
|
3d83d0bfe2 | ||
|
|
129d89cf67 | ||
|
|
5c85df486e | ||
|
|
34b6d114d3 | ||
|
|
94f0038f19 | ||
|
|
aa9c7d89f9 | ||
|
|
9bbf61e1b6 | ||
|
|
73198d6929 | ||
|
|
ab86fcf674 | ||
|
|
a88078e171 | ||
|
|
8148851a06 | ||
|
|
8b3c4189f1 | ||
|
|
db5fb0d125 | ||
|
|
9515d88e3c | ||
|
|
2bf721974b | ||
|
|
0c53dcfa80 | ||
|
|
034c086e31 | ||
|
|
76e9eb4aa0 | ||
|
|
f22d392b21 | ||
|
|
2539710075 | ||
|
|
6bdc87aed6 | ||
|
|
268b92c59b | ||
|
|
c89bbd5098 | ||
|
|
2715f44a5e | ||
|
|
305ddef900 | ||
|
|
7e56d33bf0 | ||
|
|
80daf03fa6 | ||
|
|
883059b031 | ||
|
|
d077b5dd26 |
15
package-lock.json
generated
@@ -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"
|
||||||
},
|
},
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/App.tsx
@@ -1,33 +1,21 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useEffect } 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 { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||||
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';
|
||||||
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
|
import { useLanguageStore, useThemeStore } from '@/stores';
|
||||||
|
|
||||||
const SPLASH_DURATION = 1500;
|
|
||||||
const SPLASH_FADE_DURATION = 400;
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||||
const language = useLanguageStore((state) => state.language);
|
const language = useLanguageStore((state) => state.language);
|
||||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
|
||||||
|
|
||||||
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
|
|
||||||
const [showSplash, setShowSplash] = useState(true);
|
|
||||||
const [authReady, setAuthReady] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanupTheme = initializeTheme();
|
const cleanupTheme = initializeTheme();
|
||||||
void restoreSession().finally(() => {
|
|
||||||
setAuthReady(true);
|
|
||||||
});
|
|
||||||
return cleanupTheme;
|
return cleanupTheme;
|
||||||
}, [initializeTheme, restoreSession]);
|
}, [initializeTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLanguage(language);
|
setLanguage(language);
|
||||||
@@ -38,27 +26,6 @@ function App() {
|
|||||||
document.documentElement.lang = language;
|
document.documentElement.lang = language;
|
||||||
}, [language]);
|
}, [language]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setSplashReadyToFade(true);
|
|
||||||
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSplashFinish = useCallback(() => {
|
|
||||||
setShowSplash(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (showSplash) {
|
|
||||||
return (
|
|
||||||
<SplashScreen
|
|
||||||
fadeOut={splashReadyToFade && authReady}
|
|
||||||
onFinish={handleSplashFinish}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<NotificationContainer />
|
<NotificationContainer />
|
||||||
|
|||||||
25
src/assets/icons/codex_drak.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#FFFFFF" stroke="none">
|
||||||
|
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||||
|
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||||
|
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||||
|
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||||
|
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||||
|
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||||
|
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||||
|
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||||
|
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||||
|
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||||
|
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||||
|
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||||
|
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||||
|
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
25
src/assets/icons/codex_light.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||||
|
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||||
|
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||||
|
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||||
|
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||||
|
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||||
|
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||||
|
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||||
|
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||||
|
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||||
|
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||||
|
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||||
|
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||||
|
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/assets/icons/deepseek.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
1
src/assets/icons/glm.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
1
src/assets/icons/grok.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||||
|
After Width: | Height: | Size: 756 B |
1
src/assets/icons/kimi-dark.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#FFFFFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 706 B |
1
src/assets/icons/kimi-light.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 711 B |
1
src/assets/icons/minimax.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -14,26 +14,29 @@
|
|||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
transform: translateZ(0);
|
||||||
|
|
||||||
// During animation, exit layer uses absolute positioning
|
// During animation, exit layer uses absolute positioning
|
||||||
&--exit {
|
&--exit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--stacked {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { useLocation, type Location } from 'react-router-dom';
|
import { useLocation, type Location } from 'react-router-dom';
|
||||||
import gsap from 'gsap';
|
import gsap from 'gsap';
|
||||||
import './PageTransition.scss';
|
import './PageTransition.scss';
|
||||||
@@ -6,14 +6,20 @@ import './PageTransition.scss';
|
|||||||
interface PageTransitionProps {
|
interface PageTransitionProps {
|
||||||
render: (location: Location) => ReactNode;
|
render: (location: Location) => ReactNode;
|
||||||
getRouteOrder?: (pathname: string) => number | null;
|
getRouteOrder?: (pathname: string) => number | null;
|
||||||
|
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
|
||||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSITION_DURATION = 0.5;
|
const VERTICAL_TRANSITION_DURATION = 0.35;
|
||||||
const EXIT_DURATION = 0.45;
|
const VERTICAL_TRAVEL_DISTANCE = 60;
|
||||||
const ENTER_DELAY = 0.08;
|
const IOS_TRANSITION_DURATION = 0.42;
|
||||||
|
const IOS_ENTER_FROM_X_PERCENT = 100;
|
||||||
|
const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
|
||||||
|
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
|
||||||
|
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
|
||||||
|
const IOS_EXIT_DIM_OPACITY = 0.72;
|
||||||
|
|
||||||
type LayerStatus = 'current' | 'exiting';
|
type LayerStatus = 'current' | 'exiting' | 'stacked';
|
||||||
|
|
||||||
type Layer = {
|
type Layer = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -23,18 +29,25 @@ type Layer = {
|
|||||||
|
|
||||||
type TransitionDirection = 'forward' | 'backward';
|
type TransitionDirection = 'forward' | 'backward';
|
||||||
|
|
||||||
|
type TransitionVariant = 'vertical' | 'ios';
|
||||||
|
|
||||||
export function PageTransition({
|
export function PageTransition({
|
||||||
render,
|
render,
|
||||||
getRouteOrder,
|
getRouteOrder,
|
||||||
|
getTransitionVariant,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
}: PageTransitionProps) {
|
}: 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 transitionVariantRef = useRef<TransitionVariant>('vertical');
|
||||||
const exitScrollOffsetRef = useRef(0);
|
const exitScrollOffsetRef = useRef(0);
|
||||||
|
const enterScrollOffsetRef = useRef(0);
|
||||||
|
const scrollPositionsRef = useRef(new Map<string, number>());
|
||||||
|
const nextLayersRef = useRef<Layer[] | null>(null);
|
||||||
|
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [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,
|
||||||
@@ -42,8 +55,10 @@ export function PageTransition({
|
|||||||
status: 'current',
|
status: 'current',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
|
const currentLayer =
|
||||||
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
|
layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
|
||||||
|
const currentLayerKey = currentLayer?.key ?? location.key;
|
||||||
|
const currentLayerPathname = currentLayer?.location.pathname;
|
||||||
|
|
||||||
const resolveScrollContainer = useCallback(() => {
|
const resolveScrollContainer = useCallback(() => {
|
||||||
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
||||||
@@ -51,12 +66,16 @@ export function PageTransition({
|
|||||||
return document.scrollingElement as HTMLElement | null;
|
return document.scrollingElement as HTMLElement | null;
|
||||||
}, [scrollContainerRef]);
|
}, [scrollContainerRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isAnimating) return;
|
if (isAnimating) return;
|
||||||
if (location.key === currentLayerKey) return;
|
if (location.key === currentLayerKey) return;
|
||||||
if (currentLayerPathname === location.pathname) return;
|
if (currentLayerPathname === location.pathname) return;
|
||||||
const scrollContainer = resolveScrollContainer();
|
const scrollContainer = resolveScrollContainer();
|
||||||
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
|
const exitScrollOffset = scrollContainer?.scrollTop ?? 0;
|
||||||
|
exitScrollOffsetRef.current = exitScrollOffset;
|
||||||
|
scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset);
|
||||||
|
|
||||||
|
enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 0;
|
||||||
const resolveOrderIndex = (pathname?: string) => {
|
const resolveOrderIndex = (pathname?: string) => {
|
||||||
if (!getRouteOrder || !pathname) return null;
|
if (!getRouteOrder || !pathname) return null;
|
||||||
const index = getRouteOrder(pathname);
|
const index = getRouteOrder(pathname);
|
||||||
@@ -71,32 +90,93 @@ export function PageTransition({
|
|||||||
? 'forward'
|
? 'forward'
|
||||||
: 'backward';
|
: 'backward';
|
||||||
|
|
||||||
let cancelled = false;
|
transitionDirectionRef.current = nextDirection;
|
||||||
|
transitionVariantRef.current = getTransitionVariant
|
||||||
|
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
||||||
|
: 'vertical';
|
||||||
|
|
||||||
queueMicrotask(() => {
|
const shouldSkipExitLayer = (() => {
|
||||||
if (cancelled) return;
|
if (transitionVariantRef.current !== 'ios' || nextDirection !== 'backward') return false;
|
||||||
setTransitionDirection(nextDirection);
|
const normalizeSegments = (pathname: string) =>
|
||||||
setLayers((prev) => {
|
pathname
|
||||||
const prevCurrent = prev[prev.length - 1];
|
.split('/')
|
||||||
return [
|
.filter(Boolean)
|
||||||
prevCurrent
|
.filter((segment) => segment.length > 0);
|
||||||
? { ...prevCurrent, status: 'exiting' }
|
const fromSegments = normalizeSegments(currentLayerPathname ?? '');
|
||||||
: { key: location.key, location, status: 'exiting' },
|
const toSegments = normalizeSegments(location.pathname);
|
||||||
{ key: location.key, location, status: 'current' },
|
if (!fromSegments.length || !toSegments.length) return false;
|
||||||
];
|
return fromSegments[0] === toSegments[0] && toSegments.length === 1;
|
||||||
});
|
})();
|
||||||
setIsAnimating(true);
|
|
||||||
|
setLayers((prev) => {
|
||||||
|
const variant = transitionVariantRef.current;
|
||||||
|
const direction = transitionDirectionRef.current;
|
||||||
|
const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
|
||||||
|
const resolvedCurrentIndex =
|
||||||
|
previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
|
||||||
|
const previousCurrent = prev[resolvedCurrentIndex];
|
||||||
|
const previousStack: Layer[] = prev
|
||||||
|
.filter((_, idx) => idx !== resolvedCurrentIndex)
|
||||||
|
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
|
||||||
|
|
||||||
|
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
|
||||||
|
|
||||||
|
if (!previousCurrent) {
|
||||||
|
nextLayersRef.current = [nextCurrent];
|
||||||
|
return [nextCurrent];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'ios') {
|
||||||
|
if (direction === 'forward') {
|
||||||
|
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||||
|
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
|
||||||
|
|
||||||
|
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
|
||||||
|
return [...previousStack, exitingLayer, nextCurrent];
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
|
||||||
|
if (targetIndex !== -1) {
|
||||||
|
const targetStack: Layer[] = prev
|
||||||
|
.slice(0, targetIndex + 1)
|
||||||
|
.map((layer, idx): Layer => {
|
||||||
|
const isTarget = idx === targetIndex;
|
||||||
|
return {
|
||||||
|
...layer,
|
||||||
|
location: isTarget ? location : layer.location,
|
||||||
|
status: isTarget ? 'current' : 'stacked',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldSkipExitLayer) {
|
||||||
|
nextLayersRef.current = targetStack;
|
||||||
|
return targetStack;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||||
|
nextLayersRef.current = targetStack;
|
||||||
|
return [...targetStack, exitingLayer];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldSkipExitLayer) {
|
||||||
|
nextLayersRef.current = [nextCurrent];
|
||||||
|
return [nextCurrent];
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||||
|
|
||||||
|
nextLayersRef.current = [nextCurrent];
|
||||||
|
return [exitingLayer, nextCurrent];
|
||||||
});
|
});
|
||||||
|
setIsAnimating(true);
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
isAnimating,
|
isAnimating,
|
||||||
location,
|
location,
|
||||||
currentLayerKey,
|
currentLayerKey,
|
||||||
currentLayerPathname,
|
currentLayerPathname,
|
||||||
getRouteOrder,
|
getRouteOrder,
|
||||||
|
getTransitionVariant,
|
||||||
resolveScrollContainer,
|
resolveScrollContainer,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -106,73 +186,159 @@ export function PageTransition({
|
|||||||
|
|
||||||
if (!currentLayerRef.current) return;
|
if (!currentLayerRef.current) return;
|
||||||
|
|
||||||
const scrollContainer = resolveScrollContainer();
|
const currentLayerEl = currentLayerRef.current;
|
||||||
const scrollOffset = exitScrollOffsetRef.current;
|
const exitingLayerEl = exitingLayerRef.current;
|
||||||
if (scrollContainer && scrollOffset > 0) {
|
const transitionVariant = transitionVariantRef.current;
|
||||||
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
|
||||||
|
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerHeight = scrollContainer?.clientHeight ?? 0;
|
const scrollContainer = resolveScrollContainer();
|
||||||
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
|
const exitScrollOffset = exitScrollOffsetRef.current;
|
||||||
const travelDistance = Math.max(containerHeight, viewportHeight, 1);
|
const enterScrollOffset = enterScrollOffsetRef.current;
|
||||||
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
|
if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
|
||||||
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
|
scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
|
||||||
const exitBaseY = scrollOffset ? -scrollOffset : 0;
|
}
|
||||||
|
|
||||||
|
const transitionDirection = transitionDirectionRef.current;
|
||||||
|
const isForward = transitionDirection === 'forward';
|
||||||
|
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
|
||||||
|
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
|
||||||
|
const exitBaseY = enterScrollOffset - exitScrollOffset;
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
|
const nextLayers = nextLayersRef.current;
|
||||||
|
nextLayersRef.current = null;
|
||||||
|
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
|
||||||
setIsAnimating(false);
|
setIsAnimating(false);
|
||||||
|
|
||||||
|
if (currentLayerEl) {
|
||||||
|
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
|
}
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Exit animation: fly out to top (slow-to-fast)
|
if (transitionVariant === 'ios') {
|
||||||
if (exitingLayerRef.current) {
|
const exitToXPercent = isForward
|
||||||
gsap.set(exitingLayerRef.current, { y: exitBaseY });
|
? IOS_EXIT_TO_X_PERCENT_FORWARD
|
||||||
tl.fromTo(
|
: IOS_EXIT_TO_X_PERCENT_BACKWARD;
|
||||||
exitingLayerRef.current,
|
const enterFromXPercent = isForward
|
||||||
{ y: exitBaseY, opacity: 1 },
|
? IOS_ENTER_FROM_X_PERCENT
|
||||||
|
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
|
||||||
|
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
gsap.set(exitingLayerEl, {
|
||||||
|
y: exitBaseY,
|
||||||
|
xPercent: 0,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.set(currentLayerEl, {
|
||||||
|
xPercent: enterFromXPercent,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
|
||||||
|
|
||||||
|
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
|
||||||
|
if (topLayerEl) {
|
||||||
|
gsap.set(topLayerEl, { boxShadow: shadowValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
tl.to(
|
||||||
|
exitingLayerEl,
|
||||||
|
{
|
||||||
|
xPercent: exitToXPercent,
|
||||||
|
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
|
||||||
|
duration: IOS_TRANSITION_DURATION,
|
||||||
|
ease: 'power2.out',
|
||||||
|
force3D: true,
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tl.to(
|
||||||
|
currentLayerEl,
|
||||||
{
|
{
|
||||||
y: exitBaseY + exitToY,
|
xPercent: 0,
|
||||||
opacity: 0,
|
opacity: 1,
|
||||||
duration: EXIT_DURATION,
|
duration: IOS_TRANSITION_DURATION,
|
||||||
ease: 'power2.in', // fast finish to clear screen
|
ease: 'power2.out',
|
||||||
force3D: true,
|
force3D: true,
|
||||||
},
|
},
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// Exit animation: fade out with slight movement (runs simultaneously)
|
||||||
|
if (exitingLayerEl) {
|
||||||
|
gsap.set(exitingLayerEl, { y: exitBaseY });
|
||||||
|
tl.to(
|
||||||
|
exitingLayerEl,
|
||||||
|
{
|
||||||
|
y: exitBaseY + exitToY,
|
||||||
|
opacity: 0,
|
||||||
|
duration: VERTICAL_TRANSITION_DURATION,
|
||||||
|
ease: 'circ.out',
|
||||||
|
force3D: true,
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter animation: fade in with slight movement (runs simultaneously)
|
||||||
|
tl.fromTo(
|
||||||
|
currentLayerEl,
|
||||||
|
{ y: enterFromY, opacity: 0 },
|
||||||
|
{
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
duration: VERTICAL_TRANSITION_DURATION,
|
||||||
|
ease: 'circ.out',
|
||||||
|
force3D: true,
|
||||||
|
onComplete: () => {
|
||||||
|
if (currentLayerEl) {
|
||||||
|
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter animation: slide in from bottom (slow-to-fast)
|
|
||||||
tl.fromTo(
|
|
||||||
currentLayerRef.current,
|
|
||||||
{ y: enterFromY, opacity: 0 },
|
|
||||||
{
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
duration: TRANSITION_DURATION,
|
|
||||||
ease: 'power2.out', // smooth settle
|
|
||||||
clearProps: 'transform,opacity',
|
|
||||||
force3D: true,
|
|
||||||
},
|
|
||||||
ENTER_DELAY
|
|
||||||
);
|
|
||||||
|
|
||||||
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' : ''}`}>
|
||||||
{layers.map((layer) => (
|
{layers.map((layer) => (
|
||||||
<div
|
<div
|
||||||
key={layer.key}
|
key={layer.key}
|
||||||
className={`page-transition__layer${
|
className={[
|
||||||
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
|
'page-transition__layer',
|
||||||
}`}
|
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
||||||
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
|
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
ref={
|
||||||
|
layer.status === 'exiting'
|
||||||
|
? exitingLayerRef
|
||||||
|
: layer.status === 'current'
|
||||||
|
? currentLayerRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{render(layer.location)}
|
{render(layer.location)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
84
src/components/common/SecondaryScreenShell.module.scss
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBarTitle {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 10px;
|
||||||
|
justify-self: start;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton > span:last-child {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backText {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightSlot {
|
||||||
|
justify-self: end;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingState {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-2xl 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
78
src/components/common/SecondaryScreenShell.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { forwardRef, type ReactNode } from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { IconChevronLeft } from '@/components/ui/icons';
|
||||||
|
import styles from './SecondaryScreenShell.module.scss';
|
||||||
|
|
||||||
|
export type SecondaryScreenShellProps = {
|
||||||
|
title: ReactNode;
|
||||||
|
onBack?: () => void;
|
||||||
|
backLabel?: string;
|
||||||
|
backAriaLabel?: string;
|
||||||
|
rightAction?: ReactNode;
|
||||||
|
isLoading?: boolean;
|
||||||
|
loadingLabel?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenShellProps>(
|
||||||
|
function SecondaryScreenShell(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
onBack,
|
||||||
|
backLabel = 'Back',
|
||||||
|
backAriaLabel,
|
||||||
|
rightAction,
|
||||||
|
isLoading = false,
|
||||||
|
loadingLabel = 'Loading...',
|
||||||
|
className = '',
|
||||||
|
contentClassName = '',
|
||||||
|
children,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const containerClassName = [styles.container, className].filter(Boolean).join(' ');
|
||||||
|
const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' ');
|
||||||
|
const titleTooltip = typeof title === 'string' ? title : undefined;
|
||||||
|
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClassName} ref={ref}>
|
||||||
|
<div className={styles.topBar}>
|
||||||
|
{onBack ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onBack}
|
||||||
|
className={styles.backButton}
|
||||||
|
aria-label={resolvedBackAriaLabel}
|
||||||
|
>
|
||||||
|
<span className={styles.backIcon}>
|
||||||
|
<IconChevronLeft size={18} />
|
||||||
|
</span>
|
||||||
|
<span className={styles.backText}>{backLabel}</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<div className={styles.topBarTitle} title={titleTooltip}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className={styles.rightSlot}>{rightAction}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className={styles.loadingState}>
|
||||||
|
<LoadingSpinner size={16} />
|
||||||
|
<span>{loadingLabel}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={contentClasses}>{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
@@ -375,6 +375,31 @@ export function MainLayout() {
|
|||||||
const trimmedPath =
|
const trimmedPath =
|
||||||
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||||
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
|
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
|
||||||
|
|
||||||
|
const aiProvidersIndex = navOrder.indexOf('/ai-providers');
|
||||||
|
if (aiProvidersIndex !== -1) {
|
||||||
|
if (normalizedPath === '/ai-providers') return aiProvidersIndex;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/')) {
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5;
|
||||||
|
if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6;
|
||||||
|
return aiProvidersIndex + 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authFilesIndex = navOrder.indexOf('/auth-files');
|
||||||
|
if (authFilesIndex !== -1) {
|
||||||
|
if (normalizedPath === '/auth-files') return authFilesIndex;
|
||||||
|
if (normalizedPath.startsWith('/auth-files/')) {
|
||||||
|
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
|
||||||
|
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
|
||||||
|
return authFilesIndex + 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const exactIndex = navOrder.indexOf(normalizedPath);
|
const exactIndex = navOrder.indexOf(normalizedPath);
|
||||||
if (exactIndex !== -1) return exactIndex;
|
if (exactIndex !== -1) return exactIndex;
|
||||||
const nestedIndex = navOrder.findIndex(
|
const nestedIndex = navOrder.findIndex(
|
||||||
@@ -383,6 +408,24 @@ export function MainLayout() {
|
|||||||
return nestedIndex === -1 ? null : nestedIndex;
|
return nestedIndex === -1 ? null : nestedIndex;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
|
||||||
|
const normalize = (pathname: string) => {
|
||||||
|
const trimmed =
|
||||||
|
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||||
|
return trimmed === '/dashboard' ? '/' : trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const from = normalize(fromPathname);
|
||||||
|
const to = normalize(toPathname);
|
||||||
|
const isAuthFiles = (pathname: string) =>
|
||||||
|
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
|
||||||
|
const isAiProviders = (pathname: string) =>
|
||||||
|
pathname === '/ai-providers' || pathname.startsWith('/ai-providers/');
|
||||||
|
if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
|
||||||
|
if (isAiProviders(from) && isAiProviders(to)) return 'ios';
|
||||||
|
return 'vertical';
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRefreshAll = async () => {
|
const handleRefreshAll = async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
@@ -540,6 +583,7 @@ export function MainLayout() {
|
|||||||
<PageTransition
|
<PageTransition
|
||||||
render={(location) => <MainRoutes location={location} />}
|
render={(location) => <MainRoutes location={location} />}
|
||||||
getRouteOrder={getRouteOrder}
|
getRouteOrder={getRouteOrder}
|
||||||
|
getTransitionVariant={getTransitionVariant}
|
||||||
scrollContainerRef={contentRef}
|
scrollContainerRef={contentRef}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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,32 +81,34 @@ 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({
|
||||||
setSaving(true);
|
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
|
||||||
setError('');
|
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
|
||||||
try {
|
variant: 'danger',
|
||||||
await ampcodeApi.clearUpstreamApiKey();
|
confirmText: t('common.confirm'),
|
||||||
const previous = config?.ampcode ?? {};
|
onConfirm: async () => {
|
||||||
const next: AmpcodeConfig = { ...previous };
|
setSaving(true);
|
||||||
delete next.upstreamApiKey;
|
setError('');
|
||||||
updateConfigValue('ampcode', next);
|
try {
|
||||||
clearCache('ampcode');
|
await ampcodeApi.clearUpstreamApiKey();
|
||||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
const previous = config?.ampcode ?? {};
|
||||||
} catch (err: unknown) {
|
const next: AmpcodeConfig = { ...previous };
|
||||||
const message = getErrorMessage(err);
|
delete next.upstreamApiKey;
|
||||||
setError(message);
|
updateConfigValue('ampcode', next);
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
clearCache('ampcode');
|
||||||
} finally {
|
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||||
setSaving(false);
|
} catch (err: unknown) {
|
||||||
}
|
const message = getErrorMessage(err);
|
||||||
|
setError(message);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
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}
|
||||||
|
|||||||
@@ -5,34 +5,24 @@ import type { AmpcodeConfig } from '@/types';
|
|||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AmpcodeModal } from './AmpcodeModal';
|
|
||||||
|
|
||||||
interface AmpcodeSectionProps {
|
interface AmpcodeSectionProps {
|
||||||
config: AmpcodeConfig | null | undefined;
|
config: AmpcodeConfig | null | undefined;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
isBusy: boolean;
|
onEdit: () => void;
|
||||||
isModalOpen: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
onCloseModal: () => void;
|
|
||||||
onBusyChange: (busy: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AmpcodeSection({
|
export function AmpcodeSection({
|
||||||
config,
|
config,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
isBusy,
|
onEdit,
|
||||||
isModalOpen,
|
|
||||||
onOpen,
|
|
||||||
onCloseModal,
|
|
||||||
onBusyChange,
|
|
||||||
}: AmpcodeSectionProps) {
|
}: AmpcodeSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const showLoadingPlaceholder = loading && !config;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -46,14 +36,14 @@ export function AmpcodeSection({
|
|||||||
extra={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onOpen}
|
onClick={onEdit}
|
||||||
disabled={disableControls || isSaving || isBusy || isSwitching}
|
disabled={disableControls || loading || isSwitching}
|
||||||
>
|
>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{showLoadingPlaceholder ? (
|
||||||
<div className="hint">{t('common.loading')}</div>
|
<div className="hint">{t('common.loading')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -99,13 +89,6 @@ export function AmpcodeSection({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<AmpcodeModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
disableControls={disableControls}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onBusyChange={onBusyChange}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||||
|
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
import { headersToEntries } from '@/utils/headers';
|
import { headersToEntries } from '@/utils/headers';
|
||||||
import { excludedModelsToText } from '../utils';
|
import { excludedModelsToText } from '../utils';
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ 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';
|
||||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
import type { ProviderFormState } from '../types';
|
|
||||||
import { ClaudeModal } from './ClaudeModal';
|
|
||||||
|
|
||||||
interface ClaudeSectionProps {
|
interface ClaudeSectionProps {
|
||||||
configs: ProviderKeyConfig[];
|
configs: ProviderKeyConfig[];
|
||||||
@@ -20,16 +23,11 @@ interface ClaudeSectionProps {
|
|||||||
usageDetails: UsageDetail[];
|
usageDetails: UsageDetail[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onToggle: (index: number, enabled: boolean) => void;
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeSection({
|
export function ClaudeSection({
|
||||||
@@ -38,33 +36,34 @@ export function ClaudeSection({
|
|||||||
usageDetails,
|
usageDetails,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggle,
|
onToggle,
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: ClaudeSectionProps) {
|
}: ClaudeSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
const toggleDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -99,12 +98,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>
|
||||||
@@ -188,15 +186,6 @@ export function ClaudeSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ClaudeModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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 { headersToEntries } from '@/utils/headers';
|
import { headersToEntries } from '@/utils/headers';
|
||||||
import { modelsToEntries } from '@/components/ui/ModelInputList';
|
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||||
import { excludedModelsToText } from '../utils';
|
import { excludedModelsToText } from '../utils';
|
||||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
import type { ProviderFormState, ProviderModalProps } from '../types';
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
import iconCodexDark from '@/assets/icons/codex_drak.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';
|
||||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
import type { ProviderFormState } from '../types';
|
|
||||||
import { CodexModal } from './CodexModal';
|
|
||||||
|
|
||||||
interface CodexSectionProps {
|
interface CodexSectionProps {
|
||||||
configs: ProviderKeyConfig[];
|
configs: ProviderKeyConfig[];
|
||||||
@@ -21,17 +24,12 @@ interface CodexSectionProps {
|
|||||||
usageDetails: UsageDetail[];
|
usageDetails: UsageDetail[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
resolvedTheme: string;
|
resolvedTheme: string;
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onToggle: (index: number, enabled: boolean) => void;
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodexSection({
|
export function CodexSection({
|
||||||
@@ -40,41 +38,42 @@ export function CodexSection({
|
|||||||
usageDetails,
|
usageDetails,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggle,
|
onToggle,
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: CodexSectionProps) {
|
}: CodexSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
const toggleDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<span className={styles.cardTitle}>
|
<span className={styles.cardTitle}>
|
||||||
<img
|
<img
|
||||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
|
||||||
alt=""
|
alt=""
|
||||||
className={styles.cardTitleIcon}
|
className={styles.cardTitleIcon}
|
||||||
/>
|
/>
|
||||||
@@ -106,12 +105,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>
|
||||||
@@ -180,15 +178,6 @@ export function CodexSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CodexModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ 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 { ProviderList } from '../ProviderList';
|
import { ProviderList } from '../ProviderList';
|
||||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||||
import { GeminiModal } from './GeminiModal';
|
|
||||||
|
|
||||||
interface GeminiSectionProps {
|
interface GeminiSectionProps {
|
||||||
configs: GeminiKeyConfig[];
|
configs: GeminiKeyConfig[];
|
||||||
@@ -20,16 +23,11 @@ interface GeminiSectionProps {
|
|||||||
usageDetails: UsageDetail[];
|
usageDetails: UsageDetail[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onToggle: (index: number, enabled: boolean) => void;
|
onToggle: (index: number, enabled: boolean) => void;
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GeminiSection({
|
export function GeminiSection({
|
||||||
@@ -38,33 +36,34 @@ export function GeminiSection({
|
|||||||
usageDetails,
|
usageDetails,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggle,
|
onToggle,
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: GeminiSectionProps) {
|
}: GeminiSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
|
const toggleDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -99,12 +98,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>
|
||||||
@@ -169,15 +167,6 @@ export function GeminiSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<GeminiModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||||
|
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||||
import { useNotificationStore } from '@/stores';
|
import { useNotificationStore } from '@/stores';
|
||||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||||
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
|
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ 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';
|
||||||
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||||
import type { OpenAIFormState } from '../types';
|
|
||||||
import { OpenAIModal } from './OpenAIModal';
|
|
||||||
|
|
||||||
interface OpenAISectionProps {
|
interface OpenAISectionProps {
|
||||||
configs: OpenAIProviderConfig[];
|
configs: OpenAIProviderConfig[];
|
||||||
@@ -21,16 +24,11 @@ interface OpenAISectionProps {
|
|||||||
usageDetails: UsageDetail[];
|
usageDetails: UsageDetail[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
resolvedTheme: string;
|
resolvedTheme: string;
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenAISection({
|
export function OpenAISection({
|
||||||
@@ -39,34 +37,34 @@ export function OpenAISection({
|
|||||||
usageDetails,
|
usageDetails,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: OpenAISectionProps) {
|
}: OpenAISectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
const statusBarCache = useMemo(() => {
|
const statusBarCache = useMemo(() => {
|
||||||
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));
|
||||||
});
|
});
|
||||||
|
|
||||||
return cache;
|
return cache;
|
||||||
}, [configs, usageDetails]);
|
}, [configs, usageDetails]);
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -96,7 +94,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 +128,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>
|
||||||
@@ -192,15 +190,6 @@ export function OpenAISection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<OpenAIModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function ProviderList<T>({
|
|||||||
}: ProviderListProps<T>) {
|
}: ProviderListProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (loading) {
|
if (loading && items.length === 0) {
|
||||||
return <div className="hint">{t('common.loading')}</div>;
|
return <div className="hint">{t('common.loading')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
src/components/providers/ProviderNav/ProviderNav.module.scss
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
@use '../../../styles/variables' as *;
|
||||||
|
|
||||||
|
.navContainer {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
box-shadow: inset 0 0 0 2px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暗色主题适配
|
||||||
|
:global([data-theme='dark']) {
|
||||||
|
.navList {
|
||||||
|
background: rgba(30, 30, 30, 0.7);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
background: rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小屏幕隐藏悬浮导航
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.navContainer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/components/providers/ProviderNav/ProviderNav.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useThemeStore } from '@/stores';
|
||||||
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
|
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||||
|
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||||
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
|
import iconVertex from '@/assets/icons/vertex.svg';
|
||||||
|
import iconAmp from '@/assets/icons/amp.svg';
|
||||||
|
import styles from './ProviderNav.module.scss';
|
||||||
|
|
||||||
|
export type ProviderId = 'gemini' | 'codex' | 'claude' | 'vertex' | 'ampcode' | 'openai';
|
||||||
|
|
||||||
|
interface ProviderNavItem {
|
||||||
|
id: ProviderId;
|
||||||
|
label: string;
|
||||||
|
getIcon: (theme: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS: ProviderNavItem[] = [
|
||||||
|
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
|
||||||
|
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
|
||||||
|
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
|
||||||
|
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
|
||||||
|
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
|
||||||
|
{ id: 'openai', label: 'OpenAI', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HEADER_OFFSET = 24;
|
||||||
|
|
||||||
|
export function ProviderNav() {
|
||||||
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const getScrollContainer = useCallback(() => {
|
||||||
|
if (scrollContainerRef.current) return scrollContainerRef.current;
|
||||||
|
const container = document.querySelector('.content') as HTMLElement | null;
|
||||||
|
scrollContainerRef.current = container;
|
||||||
|
return container;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const container = getScrollContainer();
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const activationLine = containerRect.top + HEADER_OFFSET + 1;
|
||||||
|
let currentActive: ProviderId | null = null;
|
||||||
|
|
||||||
|
for (const provider of PROVIDERS) {
|
||||||
|
const element = document.getElementById(`provider-${provider.id}`);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.top <= activationLine) {
|
||||||
|
currentActive = provider.id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentActive) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentActive) {
|
||||||
|
const firstVisible = PROVIDERS.find((provider) =>
|
||||||
|
document.getElementById(`provider-${provider.id}`)
|
||||||
|
);
|
||||||
|
currentActive = firstVisible?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveProvider(currentActive);
|
||||||
|
}, [getScrollContainer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = getScrollContainer();
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
handleScroll();
|
||||||
|
return () => container.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [handleScroll, getScrollContainer]);
|
||||||
|
|
||||||
|
const scrollToProvider = (providerId: ProviderId) => {
|
||||||
|
const container = getScrollContainer();
|
||||||
|
const element = document.getElementById(`provider-${providerId}`);
|
||||||
|
if (!element || !container) return;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
|
||||||
|
|
||||||
|
setActiveProvider(providerId);
|
||||||
|
container.scrollTo({
|
||||||
|
top: scrollTop,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const navContent = (
|
||||||
|
<div className={styles.navContainer}>
|
||||||
|
<div className={styles.navList}>
|
||||||
|
{PROVIDERS.map((provider) => {
|
||||||
|
const isActive = activeProvider === provider.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
|
||||||
|
onClick={() => scrollToProvider(provider.id)}
|
||||||
|
title={provider.label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={provider.getIcon(resolvedTheme)}
|
||||||
|
alt={provider.label}
|
||||||
|
className={styles.icon}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
|
||||||
|
return createPortal(navContent, document.body);
|
||||||
|
}
|
||||||
2
src/components/providers/ProviderNav/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ProviderNav } from './ProviderNav';
|
||||||
|
export type { ProviderId } from './ProviderNav';
|
||||||
@@ -4,7 +4,8 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
|
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||||
|
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||||
import type { ProviderKeyConfig } from '@/types';
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
import { headersToEntries } from '@/utils/headers';
|
import { headersToEntries } from '@/utils/headers';
|
||||||
import type { ProviderModalProps, VertexFormState } from '../types';
|
import type { ProviderModalProps, VertexFormState } from '../types';
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { Card } from '@/components/ui/Card';
|
|||||||
import iconVertex from '@/assets/icons/vertex.svg';
|
import iconVertex from '@/assets/icons/vertex.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';
|
||||||
import { getStatsBySource } from '../utils';
|
import { getStatsBySource } from '../utils';
|
||||||
import type { VertexFormState } from '../types';
|
|
||||||
import { VertexModal } from './VertexModal';
|
|
||||||
|
|
||||||
interface VertexSectionProps {
|
interface VertexSectionProps {
|
||||||
configs: ProviderKeyConfig[];
|
configs: ProviderKeyConfig[];
|
||||||
@@ -19,15 +22,10 @@ interface VertexSectionProps {
|
|||||||
usageDetails: UsageDetail[];
|
usageDetails: UsageDetail[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disableControls: boolean;
|
disableControls: boolean;
|
||||||
isSaving: boolean;
|
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
isModalOpen: boolean;
|
|
||||||
modalIndex: number | null;
|
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (index: number) => void;
|
onEdit: (index: number) => void;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onCloseModal: () => void;
|
|
||||||
onSave: (data: VertexFormState, index: number | null) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VertexSection({
|
export function VertexSection({
|
||||||
@@ -36,31 +34,32 @@ export function VertexSection({
|
|||||||
usageDetails,
|
usageDetails,
|
||||||
loading,
|
loading,
|
||||||
disableControls,
|
disableControls,
|
||||||
isSaving,
|
|
||||||
isSwitching,
|
isSwitching,
|
||||||
isModalOpen,
|
|
||||||
modalIndex,
|
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCloseModal,
|
|
||||||
onSave,
|
|
||||||
}: VertexSectionProps) {
|
}: VertexSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
const actionsDisabled = disableControls || loading || isSwitching;
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -86,10 +85,9 @@ export function VertexSection({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
actionsDisabled={actionsDisabled}
|
actionsDisabled={actionsDisabled}
|
||||||
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 statusData =
|
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
@@ -156,15 +154,6 @@ export function VertexSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<VertexModal
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
editIndex={modalIndex}
|
|
||||||
initialData={initialData}
|
|
||||||
onClose={onCloseModal}
|
|
||||||
onSave={onSave}
|
|
||||||
isSaving={isSaving}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export { OpenAISection } from './OpenAISection';
|
|||||||
export { VertexSection } from './VertexSection';
|
export { VertexSection } from './VertexSection';
|
||||||
export { ProviderList } from './ProviderList';
|
export { ProviderList } from './ProviderList';
|
||||||
export { ProviderStatusBar } from './ProviderStatusBar';
|
export { ProviderStatusBar } from './ProviderStatusBar';
|
||||||
|
export { ProviderNav } from './ProviderNav';
|
||||||
export * from './hooks/useProviderStats';
|
export * from './hooks/useProviderStats';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|||||||
@@ -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 => {
|
||||||
@@ -55,40 +55,57 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
|||||||
if (trimmed.endsWith('/chat/completions')) {
|
if (trimmed.endsWith('/chat/completions')) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`;
|
return `${trimmed}/chat/completions`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
||||||
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 => ({
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
getStatusFromError,
|
getStatusFromError,
|
||||||
isAntigravityFile,
|
isAntigravityFile,
|
||||||
isCodexFile,
|
isCodexFile,
|
||||||
|
isDisabledAuthFile,
|
||||||
isGeminiCliFile,
|
isGeminiCliFile,
|
||||||
isRuntimeOnlyAuthFile
|
isRuntimeOnlyAuthFile
|
||||||
} from '@/utils/quota';
|
} from '@/utils/quota';
|
||||||
@@ -116,11 +117,6 @@ const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string>
|
|||||||
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
|
||||||
@@ -132,7 +128,7 @@ const fetchAntigravityQuota = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectId = await resolveAntigravityProjectId(file);
|
const projectId = await resolveAntigravityProjectId(file);
|
||||||
const requestBodies = [JSON.stringify({ projectId }), JSON.stringify({ project: projectId })];
|
const requestBody = JSON.stringify({ project: projectId });
|
||||||
|
|
||||||
let lastError = '';
|
let lastError = '';
|
||||||
let lastStatus: number | undefined;
|
let lastStatus: number | undefined;
|
||||||
@@ -140,55 +136,46 @@ const fetchAntigravityQuota = async (
|
|||||||
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: requestBody
|
||||||
data: requestBodies[attempt]
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
lastError = getApiCallErrorMessage(result);
|
lastError = getApiCallErrorMessage(result);
|
||||||
lastStatus = result.statusCode;
|
lastStatus = result.statusCode;
|
||||||
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;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
hadSuccess = true;
|
hadSuccess = true;
|
||||||
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
||||||
const models = payload?.models;
|
const models = payload?.models;
|
||||||
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
||||||
lastError = t('antigravity_quota.empty_models');
|
lastError = t('antigravity_quota.empty_models');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
lastError = t('antigravity_quota.empty_models');
|
lastError = t('antigravity_quota.empty_models');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
||||||
const status = getStatusFromError(err);
|
const status = getStatusFromError(err);
|
||||||
if (status) {
|
if (status) {
|
||||||
lastStatus = status;
|
lastStatus = status;
|
||||||
if (status === 403 || status === 404) {
|
if (status === 403 || status === 404) {
|
||||||
priorityStatus ??= status;
|
priorityStatus ??= status;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -533,7 +520,7 @@ const renderGeminiCliItems = (
|
|||||||
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||||
type: 'antigravity',
|
type: 'antigravity',
|
||||||
i18nPrefix: 'antigravity_quota',
|
i18nPrefix: 'antigravity_quota',
|
||||||
filterFn: (file) => isAntigravityFile(file),
|
filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
|
||||||
fetchQuota: fetchAntigravityQuota,
|
fetchQuota: fetchAntigravityQuota,
|
||||||
storeSelector: (state) => state.antigravityQuota,
|
storeSelector: (state) => state.antigravityQuota,
|
||||||
storeSetter: 'setAntigravityQuota',
|
storeSetter: 'setAntigravityQuota',
|
||||||
@@ -558,7 +545,7 @@ export const CODEX_CONFIG: QuotaConfig<
|
|||||||
> = {
|
> = {
|
||||||
type: 'codex',
|
type: 'codex',
|
||||||
i18nPrefix: 'codex_quota',
|
i18nPrefix: 'codex_quota',
|
||||||
filterFn: (file) => isCodexFile(file),
|
filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
|
||||||
fetchQuota: fetchCodexQuota,
|
fetchQuota: fetchCodexQuota,
|
||||||
storeSelector: (state) => state.codexQuota,
|
storeSelector: (state) => state.codexQuota,
|
||||||
storeSetter: 'setCodexQuota',
|
storeSetter: 'setCodexQuota',
|
||||||
@@ -584,7 +571,8 @@ export const CODEX_CONFIG: QuotaConfig<
|
|||||||
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||||
type: 'gemini-cli',
|
type: 'gemini-cli',
|
||||||
i18nPrefix: 'gemini_cli_quota',
|
i18nPrefix: 'gemini_cli_quota',
|
||||||
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file),
|
filterFn: (file) =>
|
||||||
|
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
|
||||||
fetchQuota: fetchGeminiCliQuota,
|
fetchQuota: fetchGeminiCliQuota,
|
||||||
storeSelector: (state) => state.geminiCliQuota,
|
storeSelector: (state) => state.geminiCliQuota,
|
||||||
storeSetter: 'setGeminiCliQuota',
|
storeSetter: 'setGeminiCliQuota',
|
||||||
|
|||||||
175
src/components/ui/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
|
||||||
|
import { IconChevronDown } from './icons';
|
||||||
|
|
||||||
|
interface AutocompleteInputProps {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: string[] | { value: string; label?: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
error?: string;
|
||||||
|
className?: string;
|
||||||
|
wrapperClassName?: string;
|
||||||
|
wrapperStyle?: React.CSSProperties;
|
||||||
|
id?: string;
|
||||||
|
rightElement?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutocompleteInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
className = '',
|
||||||
|
wrapperClassName = '',
|
||||||
|
wrapperStyle,
|
||||||
|
id,
|
||||||
|
rightElement
|
||||||
|
}: AutocompleteInputProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const normalizedOptions = options.map(opt =>
|
||||||
|
typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value }
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredOptions = normalizedOptions.filter(opt => {
|
||||||
|
const v = value.toLowerCase();
|
||||||
|
return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v));
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
onChange(selectedValue);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHighlightedIndex(prev =>
|
||||||
|
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSelect(filteredOptions[highlightedIndex].value);
|
||||||
|
} else if (isOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
|
||||||
|
{label && <label htmlFor={id}>{label}</label>}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={`input ${className}`.trim()}
|
||||||
|
value={value}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ paddingRight: 32 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
pointerEvents: disabled ? 'none' : 'auto',
|
||||||
|
cursor: 'pointer',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
{rightElement}
|
||||||
|
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && filteredOptions.length > 0 && !disabled && (
|
||||||
|
<div className="autocomplete-dropdown" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'calc(100% + 4px)',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
|
||||||
|
}}>
|
||||||
|
{filteredOptions.map((opt, index) => (
|
||||||
|
<div
|
||||||
|
key={`${opt.value}-${index}`}
|
||||||
|
onClick={() => handleSelect(opt.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
fontSize: '0.9rem'
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 500 }}>{opt.value}</span>
|
||||||
|
{opt.label && opt.label !== opt.value && (
|
||||||
|
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>{opt.label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hint && <div className="hint">{hint}</div>}
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { IconX } from './icons';
|
import { IconX } from './icons';
|
||||||
import type { ModelAlias } from '@/types';
|
import type { ModelEntry } from './modelInputListUtils';
|
||||||
|
|
||||||
interface ModelEntry {
|
|
||||||
name: string;
|
|
||||||
alias: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelInputListProps {
|
interface ModelInputListProps {
|
||||||
entries: ModelEntry[];
|
entries: ModelEntry[];
|
||||||
@@ -17,29 +12,6 @@ interface ModelInputListProps {
|
|||||||
aliasPlaceholder?: string;
|
aliasPlaceholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
|
|
||||||
if (!Array.isArray(models) || models.length === 0) {
|
|
||||||
return [{ name: '', alias: '' }];
|
|
||||||
}
|
|
||||||
return models.map((m) => ({
|
|
||||||
name: m.name || '',
|
|
||||||
alias: m.alias || ''
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
|
|
||||||
return entries
|
|
||||||
.filter((entry) => entry.name.trim())
|
|
||||||
.map((entry) => {
|
|
||||||
const model: ModelAlias = { name: entry.name.trim() };
|
|
||||||
const alias = entry.alias.trim();
|
|
||||||
if (alias && alias !== model.name) {
|
|
||||||
model.alias = alias;
|
|
||||||
}
|
|
||||||
return model;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ModelInputList({
|
export function ModelInputList({
|
||||||
entries,
|
entries,
|
||||||
onChange,
|
onChange,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -164,6 +164,14 @@ export function IconChevronDown({ size = 20, ...props }: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconChevronLeft({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function IconSearch({ size = 20, ...props }: IconProps) {
|
export function IconSearch({ size = 20, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
|||||||
29
src/components/ui/modelInputListUtils.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ModelAlias } from '@/types';
|
||||||
|
|
||||||
|
export interface ModelEntry {
|
||||||
|
name: string;
|
||||||
|
alias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
|
||||||
|
if (!Array.isArray(models) || models.length === 0) {
|
||||||
|
return [{ name: '', alias: '' }];
|
||||||
|
}
|
||||||
|
return models.map((model) => ({
|
||||||
|
name: model.name || '',
|
||||||
|
alias: model.alias || ''
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.name.trim())
|
||||||
|
.map((entry) => {
|
||||||
|
const model: ModelAlias = { name: entry.name.trim() };
|
||||||
|
const alias = entry.alias.trim();
|
||||||
|
if (alias && alias !== model.name) {
|
||||||
|
model.alias = alias;
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -6,6 +6,8 @@ import styles from '@/pages/UsagePage.module.scss';
|
|||||||
export interface ModelStat {
|
export interface ModelStat {
|
||||||
model: string;
|
model: string;
|
||||||
requests: number;
|
requests: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
}
|
}
|
||||||
@@ -38,7 +40,15 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
|
|||||||
{modelStats.map((stat) => (
|
{modelStats.map((stat) => (
|
||||||
<tr key={stat.model}>
|
<tr key={stat.model}>
|
||||||
<td className={styles.modelCell}>{stat.model}</td>
|
<td className={styles.modelCell}>{stat.model}</td>
|
||||||
<td>{stat.requests.toLocaleString()}</td>
|
<td>
|
||||||
|
<span className={styles.requestCountCell}>
|
||||||
|
<span>{stat.requests.toLocaleString()}</span>
|
||||||
|
<span className={styles.requestBreakdown}>
|
||||||
|
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||||
|
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>{formatTokensInMillions(stat.tokens)}</td>
|
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
103
src/hooks/useEdgeSwipeBack.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
type SwipeBackOptions = {
|
||||||
|
enabled?: boolean;
|
||||||
|
edgeSize?: number;
|
||||||
|
threshold?: number;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActiveGesture = {
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_EDGE_SIZE = 28;
|
||||||
|
const DEFAULT_THRESHOLD = 90;
|
||||||
|
const VERTICAL_TOLERANCE_RATIO = 1.2;
|
||||||
|
|
||||||
|
export function useEdgeSwipeBack({
|
||||||
|
enabled = true,
|
||||||
|
edgeSize = DEFAULT_EDGE_SIZE,
|
||||||
|
threshold = DEFAULT_THRESHOLD,
|
||||||
|
onBack,
|
||||||
|
}: SwipeBackOptions) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const gestureRef = useRef<ActiveGesture | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
gestureRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (!gesture?.active) return;
|
||||||
|
if (event.pointerId !== gesture.pointerId) return;
|
||||||
|
|
||||||
|
const dx = event.clientX - gesture.startX;
|
||||||
|
const dy = event.clientY - gesture.startY;
|
||||||
|
|
||||||
|
if (Math.abs(dy) > Math.abs(dx) * VERTICAL_TOLERANCE_RATIO) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (!gesture?.active) return;
|
||||||
|
if (event.pointerId !== gesture.pointerId) return;
|
||||||
|
|
||||||
|
const dx = event.clientX - gesture.startX;
|
||||||
|
const dy = event.clientY - gesture.startY;
|
||||||
|
const isHorizontal = Math.abs(dx) > Math.abs(dy) * VERTICAL_TOLERANCE_RATIO;
|
||||||
|
|
||||||
|
reset();
|
||||||
|
|
||||||
|
if (dx >= threshold && isHorizontal) {
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerCancel = (event: PointerEvent) => {
|
||||||
|
const gesture = gestureRef.current;
|
||||||
|
if (!gesture?.active) return;
|
||||||
|
if (event.pointerId !== gesture.pointerId) return;
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (event.pointerType !== 'touch') return;
|
||||||
|
if (!event.isPrimary) return;
|
||||||
|
if (event.clientX > edgeSize) return;
|
||||||
|
|
||||||
|
gestureRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('pointerdown', handlePointerDown, { passive: true });
|
||||||
|
window.addEventListener('pointermove', handlePointerMove, { passive: true });
|
||||||
|
window.addEventListener('pointerup', handlePointerUp, { passive: true });
|
||||||
|
window.addEventListener('pointercancel', handlePointerCancel, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove);
|
||||||
|
window.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
window.removeEventListener('pointercancel', handlePointerCancel);
|
||||||
|
};
|
||||||
|
}, [edgeSize, enabled, onBack, threshold]);
|
||||||
|
|
||||||
|
return containerRef;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
|
"back": "Back",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -71,7 +72,15 @@
|
|||||||
"submitting": "Connecting...",
|
"submitting": "Connecting...",
|
||||||
"error_title": "Login Failed",
|
"error_title": "Login Failed",
|
||||||
"error_required": "Please fill in complete connection information",
|
"error_required": "Please fill in complete connection information",
|
||||||
"error_invalid": "Connection failed, please check address and key"
|
"error_invalid": "Connection failed, please check address and key",
|
||||||
|
"error_network": "Network connection failed, please check your network or server address",
|
||||||
|
"error_timeout": "Connection timed out, server not responding",
|
||||||
|
"error_unauthorized": "Authentication failed, invalid management key",
|
||||||
|
"error_forbidden": "Access denied, insufficient permissions",
|
||||||
|
"error_not_found": "Server address invalid or management API not enabled",
|
||||||
|
"error_server": "Internal server error, please try again later",
|
||||||
|
"error_cors": "Cross-origin request blocked, please check server configuration",
|
||||||
|
"error_ssl": "SSL/TLS certificate verification failed"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"check_connection": "Check Connection",
|
"check_connection": "Check Connection",
|
||||||
@@ -249,10 +258,10 @@
|
|||||||
"vertex_edit_modal_url_label": "Base URL (Required):",
|
"vertex_edit_modal_url_label": "Base URL (Required):",
|
||||||
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
|
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
|
||||||
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
|
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
|
||||||
"vertex_models_label": "Model mappings (alias required):",
|
"vertex_models_label": "Model aliases (alias required):",
|
||||||
"vertex_models_add_btn": "Add Mapping",
|
"vertex_models_add_btn": "Add Mapping",
|
||||||
"vertex_models_hint": "Each mapping needs both the original model and its alias.",
|
"vertex_models_hint": "Each alias needs both the original model and the alias.",
|
||||||
"vertex_models_count": "Mapping count",
|
"vertex_models_count": "Alias 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",
|
||||||
@@ -293,12 +302,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",
|
||||||
@@ -316,7 +325,7 @@
|
|||||||
"openai_keys_count": "Keys Count",
|
"openai_keys_count": "Keys Count",
|
||||||
"openai_models_count": "Models Count",
|
"openai_models_count": "Models Count",
|
||||||
"openai_test_title": "Connection Test",
|
"openai_test_title": "Connection Test",
|
||||||
"openai_test_hint": "Send a /v1/chat/completions request with the current settings to verify availability.",
|
"openai_test_hint": "Send a /chat/completions request with the current settings to verify availability.",
|
||||||
"openai_test_model_placeholder": "Model to test",
|
"openai_test_model_placeholder": "Model to test",
|
||||||
"openai_test_action": "Run Test",
|
"openai_test_action": "Run Test",
|
||||||
"openai_test_running": "Sending test request...",
|
"openai_test_running": "Sending test request...",
|
||||||
@@ -395,7 +404,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",
|
||||||
@@ -476,8 +497,10 @@
|
|||||||
"provider_placeholder": "e.g. gemini-cli",
|
"provider_placeholder": "e.g. gemini-cli",
|
||||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
||||||
"models_label": "Models to exclude",
|
"models_label": "Models to exclude",
|
||||||
"models_placeholder": "gpt-4.1-mini\n*-preview",
|
"models_loading": "Loading models...",
|
||||||
"models_hint": "Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.",
|
"models_unsupported": "Current CPA version does not support fetching model lists.",
|
||||||
|
"models_loaded": "{{count}} models loaded. Check the models to exclude.",
|
||||||
|
"no_models_available": "No models available for this provider.",
|
||||||
"save": "Save/Update",
|
"save": "Save/Update",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"save_success": "Excluded models updated",
|
"save_success": "Excluded models updated",
|
||||||
@@ -500,33 +523,35 @@
|
|||||||
"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": {
|
"oauth_model_alias": {
|
||||||
"title": "OAuth Model Mappings",
|
"title": "OAuth Model Aliases",
|
||||||
"add": "Add Mapping",
|
"add": "Add Alias",
|
||||||
"add_title": "Add provider model mappings",
|
"add_title": "Add provider model aliases",
|
||||||
"provider_label": "Provider",
|
"provider_label": "Provider",
|
||||||
"provider_placeholder": "e.g. gemini-cli / vertex",
|
"provider_placeholder": "e.g. gemini-cli / vertex",
|
||||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
||||||
"mappings_label": "Model mappings",
|
"model_source_loading": "Loading models...",
|
||||||
"mapping_name_placeholder": "Source model name",
|
"model_source_unsupported": "The current CPA version does not support fetching model lists (manual input still works).",
|
||||||
"mapping_alias_placeholder": "Alias (required)",
|
"model_source_loaded": "{{count}} models loaded. Use the dropdown in 'Source model name', or type custom values. Saving an empty list removes that provider. Enable 'Keep original' to keep the original name while adding the alias.",
|
||||||
"mapping_fork_label": "Keep original",
|
"alias_label": "Model aliases",
|
||||||
"mappings_hint": "Saving an empty list removes that provider. Enable “Keep original” to keep the original name while adding the alias.",
|
"alias_name_placeholder": "Source model name",
|
||||||
"add_mapping": "Add mapping",
|
"alias_placeholder": "Alias (required)",
|
||||||
|
"alias_fork_label": "Keep original",
|
||||||
|
"add_alias": "Add alias",
|
||||||
"save": "Save/Update",
|
"save": "Save/Update",
|
||||||
"save_success": "Model mappings updated",
|
"save_success": "Model aliases updated",
|
||||||
"save_failed": "Failed to update model mappings",
|
"save_failed": "Failed to update model aliases",
|
||||||
"delete": "Delete Provider",
|
"delete": "Delete Provider",
|
||||||
"delete_confirm": "Delete model mappings for {{provider}}?",
|
"delete_confirm": "Delete model aliases for {{provider}}?",
|
||||||
"delete_success": "Model mappings removed",
|
"delete_success": "Model aliases removed",
|
||||||
"delete_failed": "Failed to delete model mappings",
|
"delete_failed": "Failed to delete model aliases",
|
||||||
"no_models": "No model mappings",
|
"no_models": "No model aliases",
|
||||||
"model_count": "{{count}} mappings",
|
"model_count": "{{count}} aliases",
|
||||||
"list_empty_all": "No model mappings yet—use “Add Mapping” to create one.",
|
"list_empty_all": "No model aliases yet—use “Add Alias” to create one.",
|
||||||
"provider_required": "Please enter a provider first",
|
"provider_required": "Please enter a provider first",
|
||||||
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
|
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
|
||||||
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
"upgrade_required_title": "Please upgrade CLI Proxy API",
|
||||||
"upgrade_required_desc": "The current server does not support the OAuth model mappings API. Please upgrade to the latest CLI Proxy API (CPA) version."
|
"upgrade_required_desc": "The current server does not support the OAuth model aliases 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",
|
||||||
@@ -735,6 +760,8 @@
|
|||||||
"loaded_lines": "Loaded: {{count}} lines",
|
"loaded_lines": "Loaded: {{count}} lines",
|
||||||
"filtered_lines": "Filtered: {{count}} lines",
|
"filtered_lines": "Filtered: {{count}} lines",
|
||||||
"hide_management_logs": "Hide {{prefix}} logs",
|
"hide_management_logs": "Hide {{prefix}} logs",
|
||||||
|
"show_raw_logs": "Show Raw Logs",
|
||||||
|
"show_raw_logs_hint": "Show original log text for easier multi-line copy",
|
||||||
"search_placeholder": "Search logs by content or keyword",
|
"search_placeholder": "Search logs by content or keyword",
|
||||||
"search_empty_title": "No matching logs found",
|
"search_empty_title": "No matching logs found",
|
||||||
"search_empty_desc": "Try a different keyword or clear the filters.",
|
"search_empty_desc": "Try a different keyword or clear the filters.",
|
||||||
@@ -787,9 +814,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",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"login": "登录",
|
"login": "登录",
|
||||||
"logout": "登出",
|
"logout": "登出",
|
||||||
|
"back": "返回",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
@@ -71,7 +72,15 @@
|
|||||||
"submitting": "连接中...",
|
"submitting": "连接中...",
|
||||||
"error_title": "登录失败",
|
"error_title": "登录失败",
|
||||||
"error_required": "请填写完整的连接信息",
|
"error_required": "请填写完整的连接信息",
|
||||||
"error_invalid": "连接失败,请检查地址和密钥"
|
"error_invalid": "连接失败,请检查地址和密钥",
|
||||||
|
"error_network": "网络连接失败,请检查网络或服务器地址",
|
||||||
|
"error_timeout": "连接超时,服务器无响应",
|
||||||
|
"error_unauthorized": "认证失败,管理密钥无效",
|
||||||
|
"error_forbidden": "访问被拒绝,权限不足",
|
||||||
|
"error_not_found": "服务器地址无效或管理接口未启用",
|
||||||
|
"error_server": "服务器内部错误,请稍后重试",
|
||||||
|
"error_cors": "跨域请求被阻止,请检查服务器配置",
|
||||||
|
"error_ssl": "SSL/TLS 证书验证失败"
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
"check_connection": "检查连接",
|
"check_connection": "检查连接",
|
||||||
@@ -249,10 +258,10 @@
|
|||||||
"vertex_edit_modal_url_label": "Base URL (必填):",
|
"vertex_edit_modal_url_label": "Base URL (必填):",
|
||||||
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
|
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
|
||||||
"vertex_delete_confirm": "确定要删除这个Vertex配置吗?",
|
"vertex_delete_confirm": "确定要删除这个Vertex配置吗?",
|
||||||
"vertex_models_label": "模型映射 (别名必填):",
|
"vertex_models_label": "模型别名 (别名必填):",
|
||||||
"vertex_models_add_btn": "添加映射",
|
"vertex_models_add_btn": "添加映射",
|
||||||
"vertex_models_hint": "每条映射需要填写原模型与别名。",
|
"vertex_models_hint": "每条别名需要填写原模型与别名。",
|
||||||
"vertex_models_count": "映射数量",
|
"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",
|
||||||
@@ -293,12 +302,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 Key(Bearer)与自定义请求头。",
|
"openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API Key(Bearer)与自定义请求头。",
|
||||||
"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": "返回编辑",
|
||||||
@@ -316,7 +325,7 @@
|
|||||||
"openai_keys_count": "密钥数量",
|
"openai_keys_count": "密钥数量",
|
||||||
"openai_models_count": "模型数量",
|
"openai_models_count": "模型数量",
|
||||||
"openai_test_title": "连通性测试",
|
"openai_test_title": "连通性测试",
|
||||||
"openai_test_hint": "使用当前配置向 /v1/chat/completions 请求,验证是否可用。",
|
"openai_test_hint": "使用当前配置向 /chat/completions 请求,验证是否可用。",
|
||||||
"openai_test_model_placeholder": "选择或输入要测试的模型",
|
"openai_test_model_placeholder": "选择或输入要测试的模型",
|
||||||
"openai_test_action": "发送测试",
|
"openai_test_action": "发送测试",
|
||||||
"openai_test_running": "正在发送测试请求...",
|
"openai_test_running": "正在发送测试请求...",
|
||||||
@@ -395,7 +404,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 额度",
|
||||||
@@ -476,8 +497,10 @@
|
|||||||
"provider_placeholder": "例如 gemini-cli / openai",
|
"provider_placeholder": "例如 gemini-cli / openai",
|
||||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||||
"models_label": "排除的模型",
|
"models_label": "排除的模型",
|
||||||
"models_placeholder": "gpt-4.1-mini\n*-preview",
|
"models_loading": "正在加载模型列表...",
|
||||||
"models_hint": "逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。",
|
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
|
||||||
|
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
|
||||||
|
"no_models_available": "该提供商暂无可用模型列表。",
|
||||||
"save": "保存/更新",
|
"save": "保存/更新",
|
||||||
"saving": "正在保存...",
|
"saving": "正在保存...",
|
||||||
"save_success": "排除列表已更新",
|
"save_success": "排除列表已更新",
|
||||||
@@ -500,33 +523,35 @@
|
|||||||
"upgrade_required_title": "需要升级 CPA 版本",
|
"upgrade_required_title": "需要升级 CPA 版本",
|
||||||
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||||
},
|
},
|
||||||
"oauth_model_mappings": {
|
"oauth_model_alias": {
|
||||||
"title": "OAuth 模型映射",
|
"title": "OAuth 模型别名",
|
||||||
"add": "新增映射",
|
"add": "新增别名",
|
||||||
"add_title": "新增提供商模型映射",
|
"add_title": "新增提供商模型别名",
|
||||||
"provider_label": "提供商",
|
"provider_label": "提供商",
|
||||||
"provider_placeholder": "例如 gemini-cli / vertex",
|
"provider_placeholder": "例如 gemini-cli / vertex",
|
||||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||||
"mappings_label": "模型映射",
|
"model_source_loading": "正在加载模型列表...",
|
||||||
"mapping_name_placeholder": "原模型名称",
|
"model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。",
|
||||||
"mapping_alias_placeholder": "别名 (必填)",
|
"model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
|
||||||
"mapping_fork_label": "保留原名",
|
"alias_label": "模型别名",
|
||||||
"mappings_hint": "留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
|
"alias_name_placeholder": "原模型名称",
|
||||||
"add_mapping": "添加映射",
|
"alias_placeholder": "别名 (必填)",
|
||||||
|
"alias_fork_label": "保留原名",
|
||||||
|
"add_alias": "添加别名",
|
||||||
"save": "保存/更新",
|
"save": "保存/更新",
|
||||||
"save_success": "模型映射已更新",
|
"save_success": "模型别名已更新",
|
||||||
"save_failed": "更新模型映射失败",
|
"save_failed": "更新模型别名失败",
|
||||||
"delete": "删除提供商",
|
"delete": "删除提供商",
|
||||||
"delete_confirm": "确定要删除 {{provider}} 的模型映射吗?",
|
"delete_confirm": "确定要删除 {{provider}} 的模型别名吗?",
|
||||||
"delete_success": "已删除该提供商的模型映射",
|
"delete_success": "已删除该提供商的模型别名",
|
||||||
"delete_failed": "删除模型映射失败",
|
"delete_failed": "删除模型别名失败",
|
||||||
"no_models": "未配置模型映射",
|
"no_models": "未配置模型别名",
|
||||||
"model_count": "映射 {{count}} 条模型",
|
"model_count": "{{count}} 条别名",
|
||||||
"list_empty_all": "暂无任何提供商的模型映射,点击“新增映射”创建。",
|
"list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。",
|
||||||
"provider_required": "请先填写提供商名称",
|
"provider_required": "请先填写提供商名称",
|
||||||
"upgrade_required": "当前 CPA 版本不支持模型映射功能,请升级 CPA 版本",
|
"upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本",
|
||||||
"upgrade_required_title": "需要升级 CPA 版本",
|
"upgrade_required_title": "需要升级 CPA 版本",
|
||||||
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型映射功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型别名功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||||
},
|
},
|
||||||
"auth_login": {
|
"auth_login": {
|
||||||
"codex_oauth_title": "Codex OAuth",
|
"codex_oauth_title": "Codex OAuth",
|
||||||
@@ -735,6 +760,8 @@
|
|||||||
"loaded_lines": "已载入 {{count}} 行",
|
"loaded_lines": "已载入 {{count}} 行",
|
||||||
"filtered_lines": "已过滤 {{count}} 行",
|
"filtered_lines": "已过滤 {{count}} 行",
|
||||||
"hide_management_logs": "屏蔽 {{prefix}} 日志",
|
"hide_management_logs": "屏蔽 {{prefix}} 日志",
|
||||||
|
"show_raw_logs": "显示原始日志",
|
||||||
|
"show_raw_logs_hint": "直接显示原始日志文本,方便多行复制",
|
||||||
"search_placeholder": "搜索日志内容或关键字",
|
"search_placeholder": "搜索日志内容或关键字",
|
||||||
"search_empty_title": "未找到匹配的日志",
|
"search_empty_title": "未找到匹配的日志",
|
||||||
"search_empty_desc": "尝试更换关键字或清空筛选条件。",
|
"search_empty_desc": "尝试更换关键字或清空筛选条件。",
|
||||||
@@ -787,9 +814,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": "版本检查",
|
||||||
|
|||||||
312
src/pages/AiProvidersAmpcodeEditPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
|
import { ampcodeApi } from '@/services/api';
|
||||||
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
|
import type { AmpcodeConfig } from '@/types';
|
||||||
|
import { maskApiKey } from '@/utils/format';
|
||||||
|
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '@/components/providers/utils';
|
||||||
|
import type { AmpcodeFormState } from '@/components/providers';
|
||||||
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
|
type LocationState = { fromAiProviders?: boolean } | null;
|
||||||
|
|
||||||
|
const getErrorMessage = (err: unknown) => {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersAmpcodeEditPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const config = useConfigStore((state) => state.config);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [mappingsDirty, setMappingsDirty] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
const mountedRef = useRef(false);
|
||||||
|
|
||||||
|
const title = useMemo(() => t('ai_providers.ampcode_modal_title'), [t]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
const state = location.state as LocationState;
|
||||||
|
if (state?.fromAiProviders) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/ai-providers', { replace: true });
|
||||||
|
}, [location.state, navigate]);
|
||||||
|
|
||||||
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initializedRef.current) return;
|
||||||
|
initializedRef.current = true;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setLoaded(false);
|
||||||
|
setMappingsDirty(false);
|
||||||
|
setError('');
|
||||||
|
setForm(buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null));
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const ampcode = await ampcodeApi.getAmpcode();
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
setLoaded(true);
|
||||||
|
updateConfigValue('ampcode', ampcode);
|
||||||
|
clearCache('ampcode');
|
||||||
|
setForm(buildAmpcodeFormState(ampcode));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [clearCache, t, updateConfigValue]);
|
||||||
|
|
||||||
|
const clearAmpcodeUpstreamApiKey = async () => {
|
||||||
|
showConfirmation({
|
||||||
|
title: t('ai_providers.ampcode_clear_upstream_api_key_title', {
|
||||||
|
defaultValue: 'Clear Upstream API Key',
|
||||||
|
}),
|
||||||
|
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
|
||||||
|
variant: 'danger',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await ampcodeApi.clearUpstreamApiKey();
|
||||||
|
const previous = config?.ampcode ?? {};
|
||||||
|
const next: AmpcodeConfig = { ...previous };
|
||||||
|
delete next.upstreamApiKey;
|
||||||
|
updateConfigValue('ampcode', next);
|
||||||
|
clearCache('ampcode');
|
||||||
|
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
setError(message);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const performSaveAmpcode = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const upstreamUrl = form.upstreamUrl.trim();
|
||||||
|
const overrideKey = form.upstreamApiKey.trim();
|
||||||
|
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
||||||
|
|
||||||
|
if (upstreamUrl) {
|
||||||
|
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
||||||
|
} else {
|
||||||
|
await ampcodeApi.clearUpstreamUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
||||||
|
|
||||||
|
if (loaded || mappingsDirty) {
|
||||||
|
if (modelMappings.length) {
|
||||||
|
await ampcodeApi.saveModelMappings(modelMappings);
|
||||||
|
} else {
|
||||||
|
await ampcodeApi.clearModelMappings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrideKey) {
|
||||||
|
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = config?.ampcode ?? {};
|
||||||
|
const next: AmpcodeConfig = {
|
||||||
|
upstreamUrl: upstreamUrl || undefined,
|
||||||
|
forceModelMappings: form.forceModelMappings,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (previous.upstreamApiKey) {
|
||||||
|
next.upstreamApiKey = previous.upstreamApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(previous.modelMappings)) {
|
||||||
|
next.modelMappings = previous.modelMappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrideKey) {
|
||||||
|
next.upstreamApiKey = overrideKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loaded || mappingsDirty) {
|
||||||
|
if (modelMappings.length) {
|
||||||
|
next.modelMappings = modelMappings;
|
||||||
|
} else {
|
||||||
|
delete next.modelMappings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfigValue('ampcode', next);
|
||||||
|
clearCache('ampcode');
|
||||||
|
showNotification(t('notification.ampcode_updated'), 'success');
|
||||||
|
handleBack();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
setError(message);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAmpcode = async () => {
|
||||||
|
if (!loaded && mappingsDirty) {
|
||||||
|
showConfirmation({
|
||||||
|
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
||||||
|
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
||||||
|
variant: 'secondary',
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
onConfirm: performSaveAmpcode,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await performSaveAmpcode();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSave = !disableControls && !saving && !loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryScreenShell
|
||||||
|
ref={swipeRef}
|
||||||
|
contentClassName={layoutStyles.content}
|
||||||
|
title={title}
|
||||||
|
onBack={handleBack}
|
||||||
|
backLabel={t('common.back')}
|
||||||
|
backAriaLabel={t('common.back')}
|
||||||
|
rightAction={
|
||||||
|
<Button size="sm" onClick={() => void saveAmpcode()} loading={saving} disabled={!canSave}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
isLoading={loading}
|
||||||
|
loadingLabel={t('common.loading')}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.ampcode_upstream_url_label')}
|
||||||
|
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
||||||
|
value={form.upstreamUrl}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
||||||
|
disabled={loading || saving || disableControls}
|
||||||
|
hint={t('ai_providers.ampcode_upstream_url_hint')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.ampcode_upstream_api_key_label')}
|
||||||
|
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
|
||||||
|
type="password"
|
||||||
|
value={form.upstreamApiKey}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
||||||
|
disabled={loading || saving || disableControls}
|
||||||
|
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: -8,
|
||||||
|
marginBottom: 12,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="hint" style={{ margin: 0 }}>
|
||||||
|
{t('ai_providers.ampcode_upstream_api_key_current', {
|
||||||
|
key: config?.ampcode?.upstreamApiKey
|
||||||
|
? maskApiKey(config.ampcode.upstreamApiKey)
|
||||||
|
: t('common.not_set'),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void clearAmpcodeUpstreamApiKey()}
|
||||||
|
disabled={loading || saving || disableControls || !config?.ampcode?.upstreamApiKey}
|
||||||
|
>
|
||||||
|
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||||
|
checked={form.forceModelMappings}
|
||||||
|
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
||||||
|
disabled={loading || saving || disableControls}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
|
||||||
|
<ModelInputList
|
||||||
|
entries={form.mappingEntries}
|
||||||
|
onChange={(entries) => {
|
||||||
|
setMappingsDirty(true);
|
||||||
|
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
||||||
|
}}
|
||||||
|
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||||
|
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
||||||
|
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
||||||
|
disabled={loading || saving || disableControls}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</SecondaryScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
278
src/pages/AiProvidersClaudeEditPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
|
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||||
|
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||||
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
|
import { providersApi } from '@/services/api';
|
||||||
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||||
|
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||||
|
import type { ProviderFormState } from '@/components/providers';
|
||||||
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
|
type LocationState = { fromAiProviders?: boolean } | null;
|
||||||
|
|
||||||
|
const buildEmptyForm = (): ProviderFormState => ({
|
||||||
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
|
baseUrl: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
headers: [],
|
||||||
|
models: [],
|
||||||
|
excludedModels: [],
|
||||||
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
|
excludedText: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseIndexParam = (value: string | undefined) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersClaudeEditPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const params = useParams<{ index?: string }>();
|
||||||
|
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
|
||||||
|
const [configs, setConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
||||||
|
|
||||||
|
const hasIndexParam = typeof params.index === 'string';
|
||||||
|
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||||
|
const invalidIndexParam = hasIndexParam && editIndex === null;
|
||||||
|
|
||||||
|
const initialData = useMemo(() => {
|
||||||
|
if (editIndex === null) return undefined;
|
||||||
|
return configs[editIndex];
|
||||||
|
}, [configs, editIndex]);
|
||||||
|
|
||||||
|
const invalidIndex = editIndex !== null && !initialData;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
editIndex !== null
|
||||||
|
? t('ai_providers.claude_edit_modal_title')
|
||||||
|
: t('ai_providers.claude_add_modal_title');
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
const state = location.state as LocationState;
|
||||||
|
if (state?.fromAiProviders) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/ai-providers', { replace: true });
|
||||||
|
}, [location.state, navigate]);
|
||||||
|
|
||||||
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
fetchConfig('claude-api-key')
|
||||||
|
.then((value) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setConfigs(Array.isArray(value) ? (value as ProviderKeyConfig[]) : []);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
setError(message || t('notification.refresh_failed'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fetchConfig, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
setForm({
|
||||||
|
...initialData,
|
||||||
|
headers: headersToEntries(initialData.headers),
|
||||||
|
modelEntries: modelsToEntries(initialData.models),
|
||||||
|
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(buildEmptyForm());
|
||||||
|
}, [initialData, loading]);
|
||||||
|
|
||||||
|
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload: ProviderKeyConfig = {
|
||||||
|
apiKey: form.apiKey.trim(),
|
||||||
|
prefix: form.prefix?.trim() || undefined,
|
||||||
|
baseUrl: (form.baseUrl ?? '').trim() || undefined,
|
||||||
|
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||||
|
headers: buildHeaderObject(form.headers),
|
||||||
|
models: form.modelEntries
|
||||||
|
.map((entry) => {
|
||||||
|
const name = entry.name.trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const alias = entry.alias.trim();
|
||||||
|
return { name, alias: alias || name };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as ProviderKeyConfig['models'],
|
||||||
|
excludedModels: parseExcludedModels(form.excludedText),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextList =
|
||||||
|
editIndex !== null
|
||||||
|
? configs.map((item, idx) => (idx === editIndex ? payload : item))
|
||||||
|
: [...configs, payload];
|
||||||
|
|
||||||
|
await providersApi.saveClaudeConfigs(nextList);
|
||||||
|
updateConfigValue('claude-api-key', nextList);
|
||||||
|
clearCache('claude-api-key');
|
||||||
|
showNotification(
|
||||||
|
editIndex !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
handleBack();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
setError(message);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
canSave,
|
||||||
|
clearCache,
|
||||||
|
configs,
|
||||||
|
editIndex,
|
||||||
|
form,
|
||||||
|
handleBack,
|
||||||
|
showNotification,
|
||||||
|
t,
|
||||||
|
updateConfigValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryScreenShell
|
||||||
|
ref={swipeRef}
|
||||||
|
contentClassName={layoutStyles.content}
|
||||||
|
title={title}
|
||||||
|
onBack={handleBack}
|
||||||
|
backLabel={t('common.back')}
|
||||||
|
backAriaLabel={t('common.back')}
|
||||||
|
rightAction={
|
||||||
|
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
isLoading={loading}
|
||||||
|
loadingLabel={t('common.loading')}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
{invalidIndexParam || invalidIndex ? (
|
||||||
|
<div className="hint">Invalid provider index.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.claude_add_modal_key_label')}
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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')}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.claude_add_modal_url_label')}
|
||||||
|
value={form.baseUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.claude_add_modal_proxy_label')}
|
||||||
|
value={form.proxyUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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')}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.claude_models_label')}</label>
|
||||||
|
<ModelInputList
|
||||||
|
entries={form.modelEntries}
|
||||||
|
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||||
|
addLabel={t('ai_providers.claude_models_add_btn')}
|
||||||
|
namePlaceholder={t('common.model_name_placeholder')}
|
||||||
|
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||||
|
value={form.excludedText}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</SecondaryScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
src/pages/AiProvidersCodexEditPage.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
|
import { providersApi } from '@/services/api';
|
||||||
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||||
|
import { entriesToModels } from '@/components/ui/modelInputListUtils';
|
||||||
|
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||||
|
import type { ProviderFormState } from '@/components/providers';
|
||||||
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
|
type LocationState = { fromAiProviders?: boolean } | null;
|
||||||
|
|
||||||
|
const buildEmptyForm = (): ProviderFormState => ({
|
||||||
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
|
baseUrl: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
headers: [],
|
||||||
|
models: [],
|
||||||
|
excludedModels: [],
|
||||||
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
|
excludedText: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseIndexParam = (value: string | undefined) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersCodexEditPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const params = useParams<{ index?: string }>();
|
||||||
|
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
|
||||||
|
const [configs, setConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
|
||||||
|
|
||||||
|
const hasIndexParam = typeof params.index === 'string';
|
||||||
|
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||||
|
const invalidIndexParam = hasIndexParam && editIndex === null;
|
||||||
|
|
||||||
|
const initialData = useMemo(() => {
|
||||||
|
if (editIndex === null) return undefined;
|
||||||
|
return configs[editIndex];
|
||||||
|
}, [configs, editIndex]);
|
||||||
|
|
||||||
|
const invalidIndex = editIndex !== null && !initialData;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
editIndex !== null ? t('ai_providers.codex_edit_modal_title') : t('ai_providers.codex_add_modal_title');
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
const state = location.state as LocationState;
|
||||||
|
if (state?.fromAiProviders) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/ai-providers', { replace: true });
|
||||||
|
}, [location.state, navigate]);
|
||||||
|
|
||||||
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
fetchConfig('codex-api-key')
|
||||||
|
.then((value) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setConfigs(Array.isArray(value) ? (value as ProviderKeyConfig[]) : []);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
setError(message || t('notification.refresh_failed'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fetchConfig, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
setForm({
|
||||||
|
...initialData,
|
||||||
|
headers: headersToEntries(initialData.headers),
|
||||||
|
modelEntries: (initialData.models || []).map((model) => ({
|
||||||
|
name: model.name,
|
||||||
|
alias: model.alias ?? '',
|
||||||
|
})),
|
||||||
|
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(buildEmptyForm());
|
||||||
|
}, [initialData, loading]);
|
||||||
|
|
||||||
|
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
|
||||||
|
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
|
||||||
|
const baseUrl = trimmedBaseUrl || undefined;
|
||||||
|
if (!baseUrl) {
|
||||||
|
showNotification(t('notification.codex_base_url_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload: ProviderKeyConfig = {
|
||||||
|
apiKey: form.apiKey.trim(),
|
||||||
|
prefix: form.prefix?.trim() || undefined,
|
||||||
|
baseUrl,
|
||||||
|
proxyUrl: form.proxyUrl?.trim() || undefined,
|
||||||
|
headers: buildHeaderObject(form.headers),
|
||||||
|
models: entriesToModels(form.modelEntries),
|
||||||
|
excludedModels: parseExcludedModels(form.excludedText),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextList =
|
||||||
|
editIndex !== null
|
||||||
|
? configs.map((item, idx) => (idx === editIndex ? payload : item))
|
||||||
|
: [...configs, payload];
|
||||||
|
|
||||||
|
await providersApi.saveCodexConfigs(nextList);
|
||||||
|
updateConfigValue('codex-api-key', nextList);
|
||||||
|
clearCache('codex-api-key');
|
||||||
|
showNotification(
|
||||||
|
editIndex !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
handleBack();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
setError(message);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
canSave,
|
||||||
|
clearCache,
|
||||||
|
configs,
|
||||||
|
editIndex,
|
||||||
|
form,
|
||||||
|
handleBack,
|
||||||
|
showNotification,
|
||||||
|
t,
|
||||||
|
updateConfigValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryScreenShell
|
||||||
|
ref={swipeRef}
|
||||||
|
contentClassName={layoutStyles.content}
|
||||||
|
title={title}
|
||||||
|
onBack={handleBack}
|
||||||
|
backLabel={t('common.back')}
|
||||||
|
backAriaLabel={t('common.back')}
|
||||||
|
rightAction={
|
||||||
|
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
isLoading={loading}
|
||||||
|
loadingLabel={t('common.loading')}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
{invalidIndexParam || invalidIndex ? (
|
||||||
|
<div className="hint">Invalid provider index.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.codex_add_modal_key_label')}
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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')}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.codex_add_modal_url_label')}
|
||||||
|
value={form.baseUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.codex_add_modal_proxy_label')}
|
||||||
|
value={form.proxyUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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')}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||||
|
value={form.excludedText}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</SecondaryScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/pages/AiProvidersEditLayout.module.scss
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
246
src/pages/AiProvidersGeminiEditPage.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
|
import { providersApi } from '@/services/api';
|
||||||
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
|
import type { GeminiKeyConfig } from '@/types';
|
||||||
|
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||||
|
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
|
||||||
|
import type { GeminiFormState } from '@/components/providers';
|
||||||
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
|
type LocationState = { fromAiProviders?: boolean } | null;
|
||||||
|
|
||||||
|
const buildEmptyForm = (): GeminiFormState => ({
|
||||||
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
|
baseUrl: '',
|
||||||
|
headers: [],
|
||||||
|
excludedModels: [],
|
||||||
|
excludedText: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseIndexParam = (value: string | undefined) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersGeminiEditPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const params = useParams<{ index?: string }>();
|
||||||
|
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
|
||||||
|
const [configs, setConfigs] = useState<GeminiKeyConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
|
||||||
|
|
||||||
|
const hasIndexParam = typeof params.index === 'string';
|
||||||
|
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||||
|
const invalidIndexParam = hasIndexParam && editIndex === null;
|
||||||
|
|
||||||
|
const initialData = useMemo(() => {
|
||||||
|
if (editIndex === null) return undefined;
|
||||||
|
return configs[editIndex];
|
||||||
|
}, [configs, editIndex]);
|
||||||
|
|
||||||
|
const invalidIndex = editIndex !== null && !initialData;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
editIndex !== null ? t('ai_providers.gemini_edit_modal_title') : t('ai_providers.gemini_add_modal_title');
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
const state = location.state as LocationState;
|
||||||
|
if (state?.fromAiProviders) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/ai-providers', { replace: true });
|
||||||
|
}, [location.state, navigate]);
|
||||||
|
|
||||||
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
fetchConfig('gemini-api-key')
|
||||||
|
.then((value) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setConfigs(Array.isArray(value) ? (value as GeminiKeyConfig[]) : []);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
setError(message || t('notification.refresh_failed'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fetchConfig, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
setForm({
|
||||||
|
...initialData,
|
||||||
|
headers: headersToEntries(initialData.headers),
|
||||||
|
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(buildEmptyForm());
|
||||||
|
}, [initialData, loading]);
|
||||||
|
|
||||||
|
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload: GeminiKeyConfig = {
|
||||||
|
apiKey: form.apiKey.trim(),
|
||||||
|
prefix: form.prefix?.trim() || undefined,
|
||||||
|
baseUrl: form.baseUrl?.trim() || undefined,
|
||||||
|
headers: buildHeaderObject(form.headers),
|
||||||
|
excludedModels: parseExcludedModels(form.excludedText),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextList =
|
||||||
|
editIndex !== null
|
||||||
|
? configs.map((item, idx) => (idx === editIndex ? payload : item))
|
||||||
|
: [...configs, payload];
|
||||||
|
|
||||||
|
await providersApi.saveGeminiKeys(nextList);
|
||||||
|
updateConfigValue('gemini-api-key', nextList);
|
||||||
|
clearCache('gemini-api-key');
|
||||||
|
showNotification(
|
||||||
|
editIndex !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
handleBack();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
setError(message);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
canSave,
|
||||||
|
clearCache,
|
||||||
|
configs,
|
||||||
|
editIndex,
|
||||||
|
form,
|
||||||
|
handleBack,
|
||||||
|
showNotification,
|
||||||
|
t,
|
||||||
|
updateConfigValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryScreenShell
|
||||||
|
ref={swipeRef}
|
||||||
|
contentClassName={layoutStyles.content}
|
||||||
|
title={title}
|
||||||
|
onBack={handleBack}
|
||||||
|
backLabel={t('common.back')}
|
||||||
|
backAriaLabel={t('common.back')}
|
||||||
|
rightAction={
|
||||||
|
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
isLoading={loading}
|
||||||
|
loadingLabel={t('common.loading')}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
{invalidIndexParam || invalidIndex ? (
|
||||||
|
<div className="hint">Invalid provider index.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.gemini_add_modal_key_label')}
|
||||||
|
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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')}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.gemini_base_url_label')}
|
||||||
|
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
||||||
|
value={form.baseUrl ?? ''}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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')}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||||
|
value={form.excludedText}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</SecondaryScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
290
src/pages/AiProvidersOpenAIEditLayout.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { providersApi } from '@/services/api';
|
||||||
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
|
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||||
|
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
|
||||||
|
import type { ModelInfo } from '@/utils/models';
|
||||||
|
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||||
|
import { buildApiKeyEntry } from '@/components/providers/utils';
|
||||||
|
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
|
||||||
|
|
||||||
|
type LocationState = { fromAiProviders?: boolean } | null;
|
||||||
|
|
||||||
|
export type OpenAIEditOutletContext = {
|
||||||
|
hasIndexParam: boolean;
|
||||||
|
editIndex: number | null;
|
||||||
|
invalidIndexParam: boolean;
|
||||||
|
invalidIndex: boolean;
|
||||||
|
disableControls: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
form: OpenAIFormState;
|
||||||
|
setForm: Dispatch<SetStateAction<OpenAIFormState>>;
|
||||||
|
testModel: string;
|
||||||
|
setTestModel: Dispatch<SetStateAction<string>>;
|
||||||
|
testStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>;
|
||||||
|
testMessage: string;
|
||||||
|
setTestMessage: Dispatch<SetStateAction<string>>;
|
||||||
|
availableModels: string[];
|
||||||
|
handleBack: () => void;
|
||||||
|
handleSave: () => Promise<void>;
|
||||||
|
mergeDiscoveredModels: (selectedModels: ModelInfo[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildEmptyForm = (): OpenAIFormState => ({
|
||||||
|
name: '',
|
||||||
|
prefix: '',
|
||||||
|
baseUrl: '',
|
||||||
|
headers: [],
|
||||||
|
apiKeyEntries: [buildApiKeyEntry()],
|
||||||
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
|
testModel: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseIndexParam = (value: string | undefined) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getErrorMessage = (err: unknown) => {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersOpenAIEditLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
|
||||||
|
const params = useParams<{ index?: string }>();
|
||||||
|
const hasIndexParam = typeof params.index === 'string';
|
||||||
|
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||||
|
const invalidIndexParam = hasIndexParam && editIndex === null;
|
||||||
|
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
|
||||||
|
const [providers, setProviders] = useState<OpenAIProviderConfig[]>([]);
|
||||||
|
const [form, setForm] = useState<OpenAIFormState>(() => buildEmptyForm());
|
||||||
|
const [testModel, setTestModel] = useState('');
|
||||||
|
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
|
const [testMessage, setTestMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const initialData = useMemo(() => {
|
||||||
|
if (editIndex === null) return undefined;
|
||||||
|
return providers[editIndex];
|
||||||
|
}, [editIndex, providers]);
|
||||||
|
|
||||||
|
const invalidIndex = editIndex !== null && !initialData;
|
||||||
|
|
||||||
|
const availableModels = useMemo(
|
||||||
|
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
|
||||||
|
[form.modelEntries]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
const state = location.state as LocationState;
|
||||||
|
if (state?.fromAiProviders) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/ai-providers', { replace: true });
|
||||||
|
}, [location.state, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
fetchConfig('openai-compatibility')
|
||||||
|
.then((value) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setProviders(Array.isArray(value) ? (value as OpenAIProviderConfig[]) : []);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message = getErrorMessage(err) || t('notification.refresh_failed');
|
||||||
|
showNotification(`${t('notification.load_failed')}: ${message}`, 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [fetchConfig, showNotification, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
const modelEntries = modelsToEntries(initialData.models);
|
||||||
|
setForm({
|
||||||
|
name: initialData.name,
|
||||||
|
prefix: initialData.prefix ?? '',
|
||||||
|
baseUrl: initialData.baseUrl,
|
||||||
|
headers: headersToEntries(initialData.headers),
|
||||||
|
testModel: initialData.testModel,
|
||||||
|
modelEntries,
|
||||||
|
apiKeyEntries: initialData.apiKeyEntries?.length
|
||||||
|
? initialData.apiKeyEntries
|
||||||
|
: [buildApiKeyEntry()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
||||||
|
const initialTestModel =
|
||||||
|
initialData.testModel && available.includes(initialData.testModel)
|
||||||
|
? initialData.testModel
|
||||||
|
: available[0] || '';
|
||||||
|
setTestModel(initialTestModel);
|
||||||
|
} else {
|
||||||
|
setForm(buildEmptyForm());
|
||||||
|
setTestModel('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestStatus('idle');
|
||||||
|
setTestMessage('');
|
||||||
|
}, [initialData, loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
if (availableModels.length === 0) {
|
||||||
|
if (testModel) {
|
||||||
|
setTestModel('');
|
||||||
|
setTestStatus('idle');
|
||||||
|
setTestMessage('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!testModel || !availableModels.includes(testModel)) {
|
||||||
|
setTestModel(availableModels[0]);
|
||||||
|
setTestStatus('idle');
|
||||||
|
setTestMessage('');
|
||||||
|
}
|
||||||
|
}, [availableModels, loading, testModel]);
|
||||||
|
|
||||||
|
const mergeDiscoveredModels = useCallback(
|
||||||
|
(selectedModels: ModelInfo[]) => {
|
||||||
|
if (!selectedModels.length) return;
|
||||||
|
|
||||||
|
const mergedMap = new Map<string, ModelEntry>();
|
||||||
|
form.modelEntries.forEach((entry) => {
|
||||||
|
const name = entry.name.trim();
|
||||||
|
if (!name) return;
|
||||||
|
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
let addedCount = 0;
|
||||||
|
selectedModels.forEach((model) => {
|
||||||
|
const name = model.name.trim();
|
||||||
|
if (!name || mergedMap.has(name)) return;
|
||||||
|
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
||||||
|
addedCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedEntries = Array.from(mergedMap.values());
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (addedCount > 0) {
|
||||||
|
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form.modelEntries, showNotification, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: OpenAIProviderConfig = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
prefix: form.prefix?.trim() || undefined,
|
||||||
|
baseUrl: form.baseUrl.trim(),
|
||||||
|
headers: buildHeaderObject(form.headers),
|
||||||
|
apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({
|
||||||
|
apiKey: entry.apiKey.trim(),
|
||||||
|
proxyUrl: entry.proxyUrl?.trim() || undefined,
|
||||||
|
headers: entry.headers,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (form.testModel) payload.testModel = form.testModel.trim();
|
||||||
|
const models = entriesToModels(form.modelEntries);
|
||||||
|
if (models.length) payload.models = models;
|
||||||
|
|
||||||
|
const nextList =
|
||||||
|
editIndex !== null
|
||||||
|
? providers.map((item, idx) => (idx === editIndex ? payload : item))
|
||||||
|
: [...providers, payload];
|
||||||
|
|
||||||
|
await providersApi.saveOpenAIProviders(nextList);
|
||||||
|
setProviders(nextList);
|
||||||
|
updateConfigValue('openai-compatibility', nextList);
|
||||||
|
clearCache('openai-compatibility');
|
||||||
|
showNotification(
|
||||||
|
editIndex !== null
|
||||||
|
? t('notification.openai_provider_updated')
|
||||||
|
: t('notification.openai_provider_added'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
handleBack();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clearCache,
|
||||||
|
editIndex,
|
||||||
|
form,
|
||||||
|
handleBack,
|
||||||
|
providers,
|
||||||
|
showNotification,
|
||||||
|
t,
|
||||||
|
updateConfigValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Outlet
|
||||||
|
context={{
|
||||||
|
hasIndexParam,
|
||||||
|
editIndex,
|
||||||
|
invalidIndexParam,
|
||||||
|
invalidIndex,
|
||||||
|
disableControls,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
testModel,
|
||||||
|
setTestModel,
|
||||||
|
testStatus,
|
||||||
|
setTestStatus,
|
||||||
|
testMessage,
|
||||||
|
setTestMessage,
|
||||||
|
availableModels,
|
||||||
|
handleBack,
|
||||||
|
handleSave,
|
||||||
|
mergeDiscoveredModels,
|
||||||
|
} satisfies OpenAIEditOutletContext}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
374
src/pages/AiProvidersOpenAIEditPage.tsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||||
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
|
import { useNotificationStore } from '@/stores';
|
||||||
|
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||||
|
import type { ApiKeyEntry } from '@/types';
|
||||||
|
import { buildHeaderObject } from '@/utils/headers';
|
||||||
|
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
|
||||||
|
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||||
|
import styles from './AiProvidersPage.module.scss';
|
||||||
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
|
const OPENAI_TEST_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
const getErrorMessage = (err: unknown) => {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersOpenAIEditPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
const {
|
||||||
|
hasIndexParam,
|
||||||
|
invalidIndexParam,
|
||||||
|
invalidIndex,
|
||||||
|
disableControls,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
testModel,
|
||||||
|
setTestModel,
|
||||||
|
testStatus,
|
||||||
|
setTestStatus,
|
||||||
|
testMessage,
|
||||||
|
setTestMessage,
|
||||||
|
availableModels,
|
||||||
|
handleBack,
|
||||||
|
handleSave,
|
||||||
|
} = useOutletContext<OpenAIEditOutletContext>();
|
||||||
|
|
||||||
|
const title = hasIndexParam
|
||||||
|
? t('ai_providers.openai_edit_modal_title')
|
||||||
|
: t('ai_providers.openai_add_modal_title');
|
||||||
|
|
||||||
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
|
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex;
|
||||||
|
|
||||||
|
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
||||||
|
const list = entries.length ? entries : [buildApiKeyEntry()];
|
||||||
|
|
||||||
|
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
||||||
|
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
||||||
|
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEntry = (idx: number) => {
|
||||||
|
const next = list.filter((_, i) => i !== idx);
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEntry = () => {
|
||||||
|
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
{list.map((entry, index) => (
|
||||||
|
<div key={index} className="item-row">
|
||||||
|
<div className="item-meta">
|
||||||
|
<Input
|
||||||
|
label={`${t('common.api_key')} #${index + 1}`}
|
||||||
|
value={entry.apiKey}
|
||||||
|
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('common.proxy_url')}
|
||||||
|
value={entry.proxyUrl ?? ''}
|
||||||
|
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="item-actions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeEntry(index)}
|
||||||
|
disabled={saving || disableControls || list.length <= 1}
|
||||||
|
>
|
||||||
|
{t('common.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={addEntry}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
>
|
||||||
|
{t('ai_providers.openai_keys_add_btn')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openOpenaiModelDiscovery = () => {
|
||||||
|
const baseUrl = form.baseUrl.trim();
|
||||||
|
if (!baseUrl) {
|
||||||
|
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('models');
|
||||||
|
};
|
||||||
|
|
||||||
|
const testOpenaiProviderConnection = async () => {
|
||||||
|
const baseUrl = form.baseUrl.trim();
|
||||||
|
if (!baseUrl) {
|
||||||
|
const message = t('notification.openai_test_url_required');
|
||||||
|
setTestStatus('error');
|
||||||
|
setTestMessage(message);
|
||||||
|
showNotification(message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
|
||||||
|
if (!endpoint) {
|
||||||
|
const message = t('notification.openai_test_url_required');
|
||||||
|
setTestStatus('error');
|
||||||
|
setTestMessage(message);
|
||||||
|
showNotification(message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
|
||||||
|
if (!firstKeyEntry) {
|
||||||
|
const message = t('notification.openai_test_key_required');
|
||||||
|
setTestStatus('error');
|
||||||
|
setTestMessage(message);
|
||||||
|
showNotification(message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelName = testModel.trim() || availableModels[0] || '';
|
||||||
|
if (!modelName) {
|
||||||
|
const message = t('notification.openai_test_model_required');
|
||||||
|
setTestStatus('error');
|
||||||
|
setTestMessage(message);
|
||||||
|
showNotification(message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customHeaders = buildHeaderObject(form.headers);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...customHeaders,
|
||||||
|
};
|
||||||
|
if (!headers.Authorization && !headers['authorization']) {
|
||||||
|
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestStatus('loading');
|
||||||
|
setTestMessage(t('ai_providers.openai_test_running'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiCallApi.request(
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
url: endpoint,
|
||||||
|
header: Object.keys(headers).length ? headers : undefined,
|
||||||
|
data: JSON.stringify({
|
||||||
|
model: modelName,
|
||||||
|
messages: [{ role: 'user', content: 'Hi' }],
|
||||||
|
stream: false,
|
||||||
|
max_tokens: 5,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw new Error(getApiCallErrorMessage(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestStatus('success');
|
||||||
|
setTestMessage(t('ai_providers.openai_test_success'));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setTestStatus('error');
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
const errorCode =
|
||||||
|
typeof err === 'object' && err !== null && 'code' in err
|
||||||
|
? String((err as { code?: string }).code)
|
||||||
|
: '';
|
||||||
|
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
||||||
|
if (isTimeout) {
|
||||||
|
setTestMessage(
|
||||||
|
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryScreenShell
|
||||||
|
ref={swipeRef}
|
||||||
|
contentClassName={layoutStyles.content}
|
||||||
|
title={title}
|
||||||
|
onBack={handleBack}
|
||||||
|
backLabel={t('common.back')}
|
||||||
|
backAriaLabel={t('common.back')}
|
||||||
|
rightAction={
|
||||||
|
<Button size="sm" onClick={() => void handleSave()} loading={saving} disabled={!canSave}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
isLoading={loading}
|
||||||
|
loadingLabel={t('common.loading')}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
{invalidIndexParam || invalidIndex ? (
|
||||||
|
<div className="hint">Invalid provider index.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.openai_add_modal_name_label')}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
/>
|
||||||
|
<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')}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.openai_add_modal_url_label')}
|
||||||
|
value={form.baseUrl}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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')}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
{hasIndexParam
|
||||||
|
? t('ai_providers.openai_edit_modal_models_label')
|
||||||
|
: t('ai_providers.openai_add_modal_models_label')}
|
||||||
|
</label>
|
||||||
|
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
||||||
|
<ModelInputList
|
||||||
|
entries={form.modelEntries}
|
||||||
|
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||||
|
addLabel={t('ai_providers.openai_models_add_btn')}
|
||||||
|
namePlaceholder={t('common.model_name_placeholder')}
|
||||||
|
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={openOpenaiModelDiscovery}
|
||||||
|
disabled={saving || disableControls}
|
||||||
|
>
|
||||||
|
{t('ai_providers.openai_models_fetch_button')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.openai_test_title')}</label>
|
||||||
|
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<select
|
||||||
|
className={`input ${styles.openaiTestSelect}`}
|
||||||
|
value={testModel}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTestModel(e.target.value);
|
||||||
|
setTestStatus('idle');
|
||||||
|
setTestMessage('');
|
||||||
|
}}
|
||||||
|
disabled={saving || disableControls || availableModels.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{availableModels.length
|
||||||
|
? t('ai_providers.openai_test_select_placeholder')
|
||||||
|
: t('ai_providers.openai_test_select_empty')}
|
||||||
|
</option>
|
||||||
|
{form.modelEntries
|
||||||
|
.filter((entry) => entry.name.trim())
|
||||||
|
.map((entry, idx) => {
|
||||||
|
const name = entry.name.trim();
|
||||||
|
const alias = entry.alias.trim();
|
||||||
|
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
||||||
|
return (
|
||||||
|
<option key={`${name}-${idx}`} value={name}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
||||||
|
className={`${styles.openaiTestButton} ${
|
||||||
|
testStatus === 'success' ? styles.openaiTestButtonSuccess : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => void testOpenaiProviderConnection()}
|
||||||
|
loading={testStatus === 'loading'}
|
||||||
|
disabled={saving || disableControls || availableModels.length === 0}
|
||||||
|
>
|
||||||
|
{t('ai_providers.openai_test_action')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{testMessage && (
|
||||||
|
<div
|
||||||
|
className={`status-badge ${
|
||||||
|
testStatus === 'error'
|
||||||
|
? 'error'
|
||||||
|
: testStatus === 'success'
|
||||||
|
? 'success'
|
||||||
|
: 'muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||||
|
{renderKeyEntries(form.apiKeyEntries)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</SecondaryScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
src/pages/AiProvidersOpenAIModelsPage.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
|
import { modelsApi } from '@/services/api';
|
||||||
|
import type { ModelInfo } from '@/utils/models';
|
||||||
|
import { buildHeaderObject } from '@/utils/headers';
|
||||||
|
import { buildOpenAIModelsEndpoint } from '@/components/providers/utils';
|
||||||
|
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
|
||||||
|
import styles from './AiProvidersPage.module.scss';
|
||||||
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
|
const getErrorMessage = (err: unknown) => {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersOpenAIModelsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
disableControls,
|
||||||
|
loading: initialLoading,
|
||||||
|
saving,
|
||||||
|
form,
|
||||||
|
mergeDiscoveredModels,
|
||||||
|
} = useOutletContext<OpenAIEditOutletContext>();
|
||||||
|
|
||||||
|
const [endpoint, setEndpoint] = useState('');
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const filteredModels = useMemo(() => {
|
||||||
|
const filter = search.trim().toLowerCase();
|
||||||
|
if (!filter) return models;
|
||||||
|
return models.filter((model) => {
|
||||||
|
const name = (model.name || '').toLowerCase();
|
||||||
|
const alias = (model.alias || '').toLowerCase();
|
||||||
|
const desc = (model.description || '').toLowerCase();
|
||||||
|
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||||
|
});
|
||||||
|
}, [models, search]);
|
||||||
|
|
||||||
|
const fetchOpenaiModelDiscovery = useCallback(
|
||||||
|
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
||||||
|
const trimmedBaseUrl = form.baseUrl.trim();
|
||||||
|
if (!trimmedBaseUrl) return;
|
||||||
|
|
||||||
|
setFetching(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const headerObject = buildHeaderObject(form.headers);
|
||||||
|
const firstKey = form.apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
||||||
|
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
||||||
|
const list = await modelsApi.fetchModelsViaApiCall(
|
||||||
|
trimmedBaseUrl,
|
||||||
|
hasAuthHeader ? undefined : firstKey,
|
||||||
|
headerObject
|
||||||
|
);
|
||||||
|
setModels(list);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (allowFallback) {
|
||||||
|
try {
|
||||||
|
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
|
||||||
|
setModels(list);
|
||||||
|
return;
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
|
||||||
|
setModels([]);
|
||||||
|
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setModels([]);
|
||||||
|
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setFetching(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form.apiKeyEntries, form.baseUrl, form.headers, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialLoading) return;
|
||||||
|
setEndpoint(buildOpenAIModelsEndpoint(form.baseUrl));
|
||||||
|
setModels([]);
|
||||||
|
setSearch('');
|
||||||
|
setSelected(new Set());
|
||||||
|
setError('');
|
||||||
|
void fetchOpenaiModelDiscovery();
|
||||||
|
}, [fetchOpenaiModelDiscovery, form.baseUrl, initialLoading]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
navigate(-1);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
|
const toggleSelection = (name: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) {
|
||||||
|
next.delete(name);
|
||||||
|
} else {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||||
|
if (selectedModels.length) {
|
||||||
|
mergeDiscoveredModels(selectedModels);
|
||||||
|
}
|
||||||
|
handleBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canApply = !disableControls && !saving && !fetching;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryScreenShell
|
||||||
|
ref={swipeRef}
|
||||||
|
contentClassName={layoutStyles.content}
|
||||||
|
title={t('ai_providers.openai_models_fetch_title')}
|
||||||
|
onBack={handleBack}
|
||||||
|
backLabel={t('common.back')}
|
||||||
|
backAriaLabel={t('common.back')}
|
||||||
|
rightAction={
|
||||||
|
<Button size="sm" onClick={handleApply} disabled={!canApply}>
|
||||||
|
{t('ai_providers.openai_models_fetch_apply')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
isLoading={initialLoading}
|
||||||
|
loadingLabel={t('common.loading')}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<div className="hint" style={{ marginBottom: 8 }}>
|
||||||
|
{t('ai_providers.openai_models_fetch_hint')}
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input className="input" readOnly value={endpoint} />
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
||||||
|
loading={fetching}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
>
|
||||||
|
{t('ai_providers.openai_models_fetch_refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label={t('ai_providers.openai_models_search_label')}
|
||||||
|
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
disabled={fetching}
|
||||||
|
/>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
{fetching ? (
|
||||||
|
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||||
|
) : models.length === 0 ? (
|
||||||
|
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||||
|
) : filteredModels.length === 0 ? (
|
||||||
|
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.modelDiscoveryList}>
|
||||||
|
{filteredModels.map((model) => {
|
||||||
|
const checked = selected.has(model.name);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={model.name}
|
||||||
|
className={`${styles.modelDiscoveryRow} ${
|
||||||
|
checked ? styles.modelDiscoveryRowSelected : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleSelection(model.name)}
|
||||||
|
/>
|
||||||
|
<div className={styles.modelDiscoveryMeta}>
|
||||||
|
<div className={styles.modelDiscoveryName}>
|
||||||
|
{model.name}
|
||||||
|
{model.alias && (
|
||||||
|
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{model.description && (
|
||||||
|
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</SecondaryScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { entriesToModels } from '@/components/ui/ModelInputList';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
AmpcodeSection,
|
AmpcodeSection,
|
||||||
ClaudeSection,
|
ClaudeSection,
|
||||||
@@ -8,27 +8,22 @@ import {
|
|||||||
GeminiSection,
|
GeminiSection,
|
||||||
OpenAISection,
|
OpenAISection,
|
||||||
VertexSection,
|
VertexSection,
|
||||||
|
ProviderNav,
|
||||||
useProviderStats,
|
useProviderStats,
|
||||||
type GeminiFormState,
|
|
||||||
type OpenAIFormState,
|
|
||||||
type ProviderFormState,
|
|
||||||
type ProviderModal,
|
|
||||||
type VertexFormState,
|
|
||||||
} from '@/components/providers';
|
} from '@/components/providers';
|
||||||
import {
|
import {
|
||||||
parseExcludedModels,
|
|
||||||
withDisableAllModelsRule,
|
withDisableAllModelsRule,
|
||||||
withoutDisableAllModelsRule,
|
withoutDisableAllModelsRule,
|
||||||
} from '@/components/providers/utils';
|
} from '@/components/providers/utils';
|
||||||
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 } 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 navigate = useNavigate();
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -36,20 +31,29 @@ export function AiProvidersPage() {
|
|||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
const isCacheValid = useConfigStore((state) => state.isCacheValid);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const hasMounted = useRef(false);
|
||||||
|
const [loading, setLoading] = useState(() => !isCacheValid());
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]);
|
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>(
|
||||||
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]);
|
() => config?.geminiApiKeys || []
|
||||||
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
|
);
|
||||||
const [vertexConfigs, setVertexConfigs] = useState<ProviderKeyConfig[]>([]);
|
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>(
|
||||||
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
|
() => config?.codexApiKeys || []
|
||||||
|
);
|
||||||
|
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>(
|
||||||
|
() => config?.claudeApiKeys || []
|
||||||
|
);
|
||||||
|
const [vertexConfigs, setVertexConfigs] = useState<ProviderKeyConfig[]>(
|
||||||
|
() => config?.vertexApiKeys || []
|
||||||
|
);
|
||||||
|
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>(
|
||||||
|
() => config?.openaiCompatibility || []
|
||||||
|
);
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null);
|
const [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null);
|
||||||
const [modal, setModal] = useState<ProviderModal | null>(null);
|
|
||||||
const [ampcodeBusy, setAmpcodeBusy] = useState(false);
|
|
||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
const isSwitching = Boolean(configSwitchingKey);
|
const isSwitching = Boolean(configSwitchingKey);
|
||||||
@@ -63,7 +67,10 @@ export function AiProvidersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadConfigs = useCallback(async () => {
|
const loadConfigs = useCallback(async () => {
|
||||||
setLoading(true);
|
const hasValidCache = isCacheValid();
|
||||||
|
if (!hasValidCache) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
|
const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
|
||||||
@@ -99,9 +106,11 @@ export function AiProvidersPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [clearCache, fetchConfig, t, updateConfigValue]);
|
}, [clearCache, fetchConfig, isCacheValid, t, updateConfigValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasMounted.current) return;
|
||||||
|
hasMounted.current = true;
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
loadKeyStats();
|
loadKeyStats();
|
||||||
}, [loadConfigs, loadKeyStats]);
|
}, [loadConfigs, loadKeyStats]);
|
||||||
@@ -120,78 +129,35 @@ export function AiProvidersPage() {
|
|||||||
config?.openaiCompatibility,
|
config?.openaiCompatibility,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const closeModal = () => {
|
const openEditor = useCallback(
|
||||||
setModal(null);
|
(path: string) => {
|
||||||
};
|
navigate(path, { state: { fromAiProviders: true } });
|
||||||
|
},
|
||||||
const openGeminiModal = (index: number | null) => {
|
[navigate]
|
||||||
setModal({ type: 'gemini', index });
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const openProviderModal = (type: 'codex' | 'claude', index: number | null) => {
|
|
||||||
setModal({ type, index });
|
|
||||||
};
|
|
||||||
|
|
||||||
const openVertexModal = (index: number | null) => {
|
|
||||||
setModal({ type: 'vertex', index });
|
|
||||||
};
|
|
||||||
|
|
||||||
const openAmpcodeModal = () => {
|
|
||||||
setModal({ type: 'ampcode', index: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const openOpenaiModal = (index: number | null) => {
|
|
||||||
setModal({ type: 'openai', index });
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveGemini = async (form: GeminiFormState, editIndex: number | null) => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const payload: GeminiKeyConfig = {
|
|
||||||
apiKey: form.apiKey.trim(),
|
|
||||||
prefix: form.prefix?.trim() || undefined,
|
|
||||||
baseUrl: form.baseUrl?.trim() || undefined,
|
|
||||||
headers: buildHeaderObject(form.headers),
|
|
||||||
excludedModels: parseExcludedModels(form.excludedText),
|
|
||||||
};
|
|
||||||
const nextList =
|
|
||||||
editIndex !== null
|
|
||||||
? geminiKeys.map((item, idx) => (idx === editIndex ? payload : item))
|
|
||||||
: [...geminiKeys, payload];
|
|
||||||
|
|
||||||
await providersApi.saveGeminiKeys(nextList);
|
|
||||||
setGeminiKeys(nextList);
|
|
||||||
updateConfigValue('gemini-api-key', nextList);
|
|
||||||
clearCache('gemini-api-key');
|
|
||||||
const message =
|
|
||||||
editIndex !== null
|
|
||||||
? t('notification.gemini_key_updated')
|
|
||||||
: t('notification.gemini_key_added');
|
|
||||||
showNotification(message, 'success');
|
|
||||||
closeModal();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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({
|
||||||
try {
|
title: t('ai_providers.gemini_delete_title', { defaultValue: 'Delete Gemini Key' }),
|
||||||
await providersApi.deleteGeminiKey(entry.apiKey);
|
message: t('ai_providers.gemini_delete_confirm'),
|
||||||
const next = geminiKeys.filter((_, idx) => idx !== index);
|
variant: 'danger',
|
||||||
setGeminiKeys(next);
|
confirmText: t('common.confirm'),
|
||||||
updateConfigValue('gemini-api-key', next);
|
onConfirm: async () => {
|
||||||
clearCache('gemini-api-key');
|
try {
|
||||||
showNotification(t('notification.gemini_key_deleted'), 'success');
|
await providersApi.deleteGeminiKey(entry.apiKey);
|
||||||
} catch (err: unknown) {
|
const next = geminiKeys.filter((_, idx) => idx !== index);
|
||||||
const message = getErrorMessage(err);
|
setGeminiKeys(next);
|
||||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
updateConfigValue('gemini-api-key', next);
|
||||||
}
|
clearCache('gemini-api-key');
|
||||||
|
showNotification(t('notification.gemini_key_deleted'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setConfigEnabled = async (
|
const setConfigEnabled = async (
|
||||||
@@ -286,334 +252,181 @@ export function AiProvidersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveProvider = async (
|
|
||||||
type: 'codex' | 'claude',
|
|
||||||
form: ProviderFormState,
|
|
||||||
editIndex: number | null
|
|
||||||
) => {
|
|
||||||
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
|
|
||||||
const baseUrl = trimmedBaseUrl || undefined;
|
|
||||||
if (type === 'codex' && !baseUrl) {
|
|
||||||
showNotification(t('notification.codex_base_url_required'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const source = type === 'codex' ? codexConfigs : claudeConfigs;
|
|
||||||
|
|
||||||
const payload: ProviderKeyConfig = {
|
|
||||||
apiKey: form.apiKey.trim(),
|
|
||||||
prefix: form.prefix?.trim() || undefined,
|
|
||||||
baseUrl,
|
|
||||||
proxyUrl: form.proxyUrl?.trim() || undefined,
|
|
||||||
headers: buildHeaderObject(form.headers),
|
|
||||||
models: entriesToModels(form.modelEntries),
|
|
||||||
excludedModels: parseExcludedModels(form.excludedText),
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextList =
|
|
||||||
editIndex !== null
|
|
||||||
? source.map((item, idx) => (idx === editIndex ? payload : item))
|
|
||||||
: [...source, payload];
|
|
||||||
|
|
||||||
if (type === 'codex') {
|
|
||||||
await providersApi.saveCodexConfigs(nextList);
|
|
||||||
setCodexConfigs(nextList);
|
|
||||||
updateConfigValue('codex-api-key', nextList);
|
|
||||||
clearCache('codex-api-key');
|
|
||||||
const message =
|
|
||||||
editIndex !== null
|
|
||||||
? t('notification.codex_config_updated')
|
|
||||||
: t('notification.codex_config_added');
|
|
||||||
showNotification(message, 'success');
|
|
||||||
} else {
|
|
||||||
await providersApi.saveClaudeConfigs(nextList);
|
|
||||||
setClaudeConfigs(nextList);
|
|
||||||
updateConfigValue('claude-api-key', nextList);
|
|
||||||
clearCache('claude-api-key');
|
|
||||||
const message =
|
|
||||||
editIndex !== null
|
|
||||||
? t('notification.claude_config_updated')
|
|
||||||
: t('notification.claude_config_added');
|
|
||||||
showNotification(message, 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModal();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteProviderEntry = async (type: 'codex' | 'claude', index: number) => {
|
const deleteProviderEntry = async (type: 'codex' | 'claude', index: number) => {
|
||||||
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({
|
||||||
try {
|
title: t(`ai_providers.${type}_delete_title`, { defaultValue: `Delete ${type === 'codex' ? 'Codex' : 'Claude'} Config` }),
|
||||||
if (type === 'codex') {
|
message: t(`ai_providers.${type}_delete_confirm`),
|
||||||
await providersApi.deleteCodexConfig(entry.apiKey);
|
variant: 'danger',
|
||||||
const next = codexConfigs.filter((_, idx) => idx !== index);
|
confirmText: t('common.confirm'),
|
||||||
setCodexConfigs(next);
|
onConfirm: async () => {
|
||||||
updateConfigValue('codex-api-key', next);
|
try {
|
||||||
clearCache('codex-api-key');
|
if (type === 'codex') {
|
||||||
showNotification(t('notification.codex_config_deleted'), 'success');
|
await providersApi.deleteCodexConfig(entry.apiKey);
|
||||||
} else {
|
const next = codexConfigs.filter((_, idx) => idx !== index);
|
||||||
await providersApi.deleteClaudeConfig(entry.apiKey);
|
setCodexConfigs(next);
|
||||||
const next = claudeConfigs.filter((_, idx) => idx !== index);
|
updateConfigValue('codex-api-key', next);
|
||||||
setClaudeConfigs(next);
|
clearCache('codex-api-key');
|
||||||
updateConfigValue('claude-api-key', next);
|
showNotification(t('notification.codex_config_deleted'), 'success');
|
||||||
clearCache('claude-api-key');
|
} else {
|
||||||
showNotification(t('notification.claude_config_deleted'), 'success');
|
await providersApi.deleteClaudeConfig(entry.apiKey);
|
||||||
}
|
const next = claudeConfigs.filter((_, idx) => idx !== index);
|
||||||
} catch (err: unknown) {
|
setClaudeConfigs(next);
|
||||||
const message = getErrorMessage(err);
|
updateConfigValue('claude-api-key', next);
|
||||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
clearCache('claude-api-key');
|
||||||
}
|
showNotification(t('notification.claude_config_deleted'), 'success');
|
||||||
};
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
|
const message = getErrorMessage(err);
|
||||||
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
|
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||||
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 deleteVertex = async (index: number) => {
|
||||||
const entry = vertexConfigs[index];
|
const entry = vertexConfigs[index];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!window.confirm(t('ai_providers.vertex_delete_confirm'))) return;
|
showConfirmation({
|
||||||
try {
|
title: t('ai_providers.vertex_delete_title', { defaultValue: 'Delete Vertex Config' }),
|
||||||
await providersApi.deleteVertexConfig(entry.apiKey);
|
message: t('ai_providers.vertex_delete_confirm'),
|
||||||
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
variant: 'danger',
|
||||||
setVertexConfigs(next);
|
confirmText: t('common.confirm'),
|
||||||
updateConfigValue('vertex-api-key', next);
|
onConfirm: async () => {
|
||||||
clearCache('vertex-api-key');
|
try {
|
||||||
showNotification(t('notification.vertex_config_deleted'), 'success');
|
await providersApi.deleteVertexConfig(entry.apiKey);
|
||||||
} catch (err: unknown) {
|
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
||||||
const message = getErrorMessage(err);
|
setVertexConfigs(next);
|
||||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
updateConfigValue('vertex-api-key', next);
|
||||||
}
|
clearCache('vertex-api-key');
|
||||||
};
|
showNotification(t('notification.vertex_config_deleted'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
|
const message = getErrorMessage(err);
|
||||||
setSaving(true);
|
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||||
try {
|
}
|
||||||
const payload: OpenAIProviderConfig = {
|
},
|
||||||
name: form.name.trim(),
|
});
|
||||||
prefix: form.prefix?.trim() || undefined,
|
|
||||||
baseUrl: form.baseUrl.trim(),
|
|
||||||
headers: buildHeaderObject(form.headers),
|
|
||||||
apiKeyEntries: form.apiKeyEntries.map((entry) => ({
|
|
||||||
apiKey: entry.apiKey.trim(),
|
|
||||||
proxyUrl: entry.proxyUrl?.trim() || undefined,
|
|
||||||
headers: entry.headers,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
if (form.testModel) payload.testModel = form.testModel.trim();
|
|
||||||
const models = entriesToModels(form.modelEntries);
|
|
||||||
if (models.length) payload.models = models;
|
|
||||||
|
|
||||||
const nextList =
|
|
||||||
editIndex !== null
|
|
||||||
? openaiProviders.map((item, idx) => (idx === editIndex ? payload : item))
|
|
||||||
: [...openaiProviders, payload];
|
|
||||||
|
|
||||||
await providersApi.saveOpenAIProviders(nextList);
|
|
||||||
setOpenaiProviders(nextList);
|
|
||||||
updateConfigValue('openai-compatibility', nextList);
|
|
||||||
clearCache('openai-compatibility');
|
|
||||||
const message =
|
|
||||||
editIndex !== null
|
|
||||||
? t('notification.openai_provider_updated')
|
|
||||||
: t('notification.openai_provider_added');
|
|
||||||
showNotification(message, 'success');
|
|
||||||
closeModal();
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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({
|
||||||
try {
|
title: t('ai_providers.openai_delete_title', { defaultValue: 'Delete OpenAI Provider' }),
|
||||||
await providersApi.deleteOpenAIProvider(entry.name);
|
message: t('ai_providers.openai_delete_confirm'),
|
||||||
const next = openaiProviders.filter((_, idx) => idx !== index);
|
variant: 'danger',
|
||||||
setOpenaiProviders(next);
|
confirmText: t('common.confirm'),
|
||||||
updateConfigValue('openai-compatibility', next);
|
onConfirm: async () => {
|
||||||
clearCache('openai-compatibility');
|
try {
|
||||||
showNotification(t('notification.openai_provider_deleted'), 'success');
|
await providersApi.deleteOpenAIProvider(entry.name);
|
||||||
} catch (err: unknown) {
|
const next = openaiProviders.filter((_, idx) => idx !== index);
|
||||||
const message = getErrorMessage(err);
|
setOpenaiProviders(next);
|
||||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
updateConfigValue('openai-compatibility', next);
|
||||||
}
|
clearCache('openai-compatibility');
|
||||||
|
showNotification(t('notification.openai_provider_deleted'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
|
|
||||||
const codexModalIndex = modal?.type === 'codex' ? 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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
|
||||||
<GeminiSection
|
<div id="provider-gemini">
|
||||||
configs={geminiKeys}
|
<GeminiSection
|
||||||
keyStats={keyStats}
|
configs={geminiKeys}
|
||||||
usageDetails={usageDetails}
|
keyStats={keyStats}
|
||||||
loading={loading}
|
usageDetails={usageDetails}
|
||||||
disableControls={disableControls}
|
loading={loading}
|
||||||
isSaving={saving}
|
disableControls={disableControls}
|
||||||
isSwitching={isSwitching}
|
isSwitching={isSwitching}
|
||||||
isModalOpen={modal?.type === 'gemini'}
|
onAdd={() => openEditor('/ai-providers/gemini/new')}
|
||||||
modalIndex={geminiModalIndex}
|
onEdit={(index) => openEditor(`/ai-providers/gemini/${index}`)}
|
||||||
onAdd={() => openGeminiModal(null)}
|
onDelete={deleteGemini}
|
||||||
onEdit={(index) => openGeminiModal(index)}
|
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
|
||||||
onDelete={deleteGemini}
|
/>
|
||||||
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
|
</div>
|
||||||
onCloseModal={closeModal}
|
|
||||||
onSave={saveGemini}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CodexSection
|
<div id="provider-codex">
|
||||||
configs={codexConfigs}
|
<CodexSection
|
||||||
keyStats={keyStats}
|
configs={codexConfigs}
|
||||||
usageDetails={usageDetails}
|
keyStats={keyStats}
|
||||||
loading={loading}
|
usageDetails={usageDetails}
|
||||||
disableControls={disableControls}
|
loading={loading}
|
||||||
isSaving={saving}
|
disableControls={disableControls}
|
||||||
isSwitching={isSwitching}
|
isSwitching={isSwitching}
|
||||||
resolvedTheme={resolvedTheme}
|
resolvedTheme={resolvedTheme}
|
||||||
isModalOpen={modal?.type === 'codex'}
|
onAdd={() => openEditor('/ai-providers/codex/new')}
|
||||||
modalIndex={codexModalIndex}
|
onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)}
|
||||||
onAdd={() => openProviderModal('codex', null)}
|
onDelete={(index) => void deleteProviderEntry('codex', index)}
|
||||||
onEdit={(index) => openProviderModal('codex', index)}
|
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
|
||||||
onDelete={(index) => void deleteProviderEntry('codex', index)}
|
/>
|
||||||
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
|
</div>
|
||||||
onCloseModal={closeModal}
|
|
||||||
onSave={(form, editIndex) => saveProvider('codex', form, editIndex)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ClaudeSection
|
<div id="provider-claude">
|
||||||
configs={claudeConfigs}
|
<ClaudeSection
|
||||||
keyStats={keyStats}
|
configs={claudeConfigs}
|
||||||
usageDetails={usageDetails}
|
keyStats={keyStats}
|
||||||
loading={loading}
|
usageDetails={usageDetails}
|
||||||
disableControls={disableControls}
|
loading={loading}
|
||||||
isSaving={saving}
|
disableControls={disableControls}
|
||||||
isSwitching={isSwitching}
|
isSwitching={isSwitching}
|
||||||
isModalOpen={modal?.type === 'claude'}
|
onAdd={() => openEditor('/ai-providers/claude/new')}
|
||||||
modalIndex={claudeModalIndex}
|
onEdit={(index) => openEditor(`/ai-providers/claude/${index}`)}
|
||||||
onAdd={() => openProviderModal('claude', null)}
|
onDelete={(index) => void deleteProviderEntry('claude', index)}
|
||||||
onEdit={(index) => openProviderModal('claude', index)}
|
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
|
||||||
onDelete={(index) => void deleteProviderEntry('claude', index)}
|
/>
|
||||||
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
|
</div>
|
||||||
onCloseModal={closeModal}
|
|
||||||
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VertexSection
|
<div id="provider-vertex">
|
||||||
configs={vertexConfigs}
|
<VertexSection
|
||||||
keyStats={keyStats}
|
configs={vertexConfigs}
|
||||||
usageDetails={usageDetails}
|
keyStats={keyStats}
|
||||||
loading={loading}
|
usageDetails={usageDetails}
|
||||||
disableControls={disableControls}
|
loading={loading}
|
||||||
isSaving={saving}
|
disableControls={disableControls}
|
||||||
isSwitching={isSwitching}
|
isSwitching={isSwitching}
|
||||||
isModalOpen={modal?.type === 'vertex'}
|
onAdd={() => openEditor('/ai-providers/vertex/new')}
|
||||||
modalIndex={vertexModalIndex}
|
onEdit={(index) => openEditor(`/ai-providers/vertex/${index}`)}
|
||||||
onAdd={() => openVertexModal(null)}
|
onDelete={deleteVertex}
|
||||||
onEdit={(index) => openVertexModal(index)}
|
/>
|
||||||
onDelete={deleteVertex}
|
</div>
|
||||||
onCloseModal={closeModal}
|
|
||||||
onSave={saveVertex}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AmpcodeSection
|
<div id="provider-ampcode">
|
||||||
config={config?.ampcode}
|
<AmpcodeSection
|
||||||
loading={loading}
|
config={config?.ampcode}
|
||||||
disableControls={disableControls}
|
loading={loading}
|
||||||
isSaving={saving}
|
disableControls={disableControls}
|
||||||
isSwitching={isSwitching}
|
isSwitching={isSwitching}
|
||||||
isBusy={ampcodeBusy}
|
onEdit={() => openEditor('/ai-providers/ampcode')}
|
||||||
isModalOpen={modal?.type === 'ampcode'}
|
/>
|
||||||
onOpen={openAmpcodeModal}
|
</div>
|
||||||
onCloseModal={closeModal}
|
|
||||||
onBusyChange={setAmpcodeBusy}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OpenAISection
|
<div id="provider-openai">
|
||||||
configs={openaiProviders}
|
<OpenAISection
|
||||||
keyStats={keyStats}
|
configs={openaiProviders}
|
||||||
usageDetails={usageDetails}
|
keyStats={keyStats}
|
||||||
loading={loading}
|
usageDetails={usageDetails}
|
||||||
disableControls={disableControls}
|
loading={loading}
|
||||||
isSaving={saving}
|
disableControls={disableControls}
|
||||||
isSwitching={isSwitching}
|
isSwitching={isSwitching}
|
||||||
resolvedTheme={resolvedTheme}
|
resolvedTheme={resolvedTheme}
|
||||||
isModalOpen={modal?.type === 'openai'}
|
onAdd={() => openEditor('/ai-providers/openai/new')}
|
||||||
modalIndex={openaiModalIndex}
|
onEdit={(index) => openEditor(`/ai-providers/openai/${index}`)}
|
||||||
onAdd={() => openOpenaiModal(null)}
|
onDelete={deleteOpenai}
|
||||||
onEdit={(index) => openOpenaiModal(index)}
|
/>
|
||||||
onDelete={deleteOpenai}
|
</div>
|
||||||
onCloseModal={closeModal}
|
|
||||||
onSave={saveOpenai}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ProviderNav />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
278
src/pages/AiProvidersVertexEditPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { HeaderInputList } from '@/components/ui/HeaderInputList';
|
||||||
|
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||||
|
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
|
||||||
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
|
import { providersApi } from '@/services/api';
|
||||||
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
|
import type { ProviderKeyConfig } from '@/types';
|
||||||
|
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||||
|
import type { VertexFormState } from '@/components/providers';
|
||||||
|
import layoutStyles from './AiProvidersEditLayout.module.scss';
|
||||||
|
|
||||||
|
type LocationState = { fromAiProviders?: boolean } | null;
|
||||||
|
|
||||||
|
const buildEmptyForm = (): VertexFormState => ({
|
||||||
|
apiKey: '',
|
||||||
|
prefix: '',
|
||||||
|
baseUrl: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
headers: [],
|
||||||
|
models: [],
|
||||||
|
modelEntries: [{ name: '', alias: '' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseIndexParam = (value: string | undefined) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AiProvidersVertexEditPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const params = useParams<{ index?: string }>();
|
||||||
|
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
|
||||||
|
const [configs, setConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [form, setForm] = useState<VertexFormState>(() => buildEmptyForm());
|
||||||
|
|
||||||
|
const hasIndexParam = typeof params.index === 'string';
|
||||||
|
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
|
||||||
|
const invalidIndexParam = hasIndexParam && editIndex === null;
|
||||||
|
|
||||||
|
const initialData = useMemo(() => {
|
||||||
|
if (editIndex === null) return undefined;
|
||||||
|
return configs[editIndex];
|
||||||
|
}, [configs, editIndex]);
|
||||||
|
|
||||||
|
const invalidIndex = editIndex !== null && !initialData;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
editIndex !== null ? t('ai_providers.vertex_edit_modal_title') : t('ai_providers.vertex_add_modal_title');
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
const state = location.state as LocationState;
|
||||||
|
if (state?.fromAiProviders) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/ai-providers', { replace: true });
|
||||||
|
}, [location.state, navigate]);
|
||||||
|
|
||||||
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
Promise.all([fetchConfig('vertex-api-key'), providersApi.getVertexConfigs()])
|
||||||
|
.then(([configResult, vertexResult]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const list = Array.isArray(vertexResult)
|
||||||
|
? (vertexResult as ProviderKeyConfig[])
|
||||||
|
: Array.isArray(configResult)
|
||||||
|
? (configResult as ProviderKeyConfig[])
|
||||||
|
: [];
|
||||||
|
setConfigs(list);
|
||||||
|
updateConfigValue('vertex-api-key', list);
|
||||||
|
clearCache('vertex-api-key');
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
setError(message || t('notification.refresh_failed'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [clearCache, fetchConfig, t, updateConfigValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
if (initialData) {
|
||||||
|
setForm({
|
||||||
|
...initialData,
|
||||||
|
headers: headersToEntries(initialData.headers),
|
||||||
|
modelEntries: modelsToEntries(initialData.models),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(buildEmptyForm());
|
||||||
|
}, [initialData, loading]);
|
||||||
|
|
||||||
|
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
|
||||||
|
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
|
||||||
|
const baseUrl = trimmedBaseUrl || undefined;
|
||||||
|
if (!baseUrl) {
|
||||||
|
showNotification(t('notification.vertex_base_url_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
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
|
||||||
|
? configs.map((item, idx) => (idx === editIndex ? payload : item))
|
||||||
|
: [...configs, payload];
|
||||||
|
|
||||||
|
await providersApi.saveVertexConfigs(nextList);
|
||||||
|
updateConfigValue('vertex-api-key', nextList);
|
||||||
|
clearCache('vertex-api-key');
|
||||||
|
showNotification(
|
||||||
|
editIndex !== null ? t('notification.vertex_config_updated') : t('notification.vertex_config_added'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
handleBack();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
setError(message);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
canSave,
|
||||||
|
clearCache,
|
||||||
|
configs,
|
||||||
|
editIndex,
|
||||||
|
form,
|
||||||
|
handleBack,
|
||||||
|
showNotification,
|
||||||
|
t,
|
||||||
|
updateConfigValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryScreenShell
|
||||||
|
ref={swipeRef}
|
||||||
|
contentClassName={layoutStyles.content}
|
||||||
|
title={title}
|
||||||
|
onBack={handleBack}
|
||||||
|
backLabel={t('common.back')}
|
||||||
|
backAriaLabel={t('common.back')}
|
||||||
|
rightAction={
|
||||||
|
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
isLoading={loading}
|
||||||
|
loadingLabel={t('common.loading')}
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
{invalidIndexParam || invalidIndex ? (
|
||||||
|
<div className="hint">Invalid provider index.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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 }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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')}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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 }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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 }))}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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')}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<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={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</SecondaryScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/pages/AuthFilesOAuthExcludedEditPage.module.scss
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
@use '../styles/variables' as *;
|
||||||
|
@use '../styles/mixins' as *;
|
||||||
|
|
||||||
|
.pageContent {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 $spacing-lg $spacing-2xl;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsCard {
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
padding: $spacing-md $spacing-lg;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsHeaderTitle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsHeaderHint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-md $spacing-lg $spacing-lg;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsDesc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsControl {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: min(360px, 45%);
|
||||||
|
min-width: 220px;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagActive {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelsHint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingModels {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-xl 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelList {
|
||||||
|
max-height: 520px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: $spacing-sm $spacing-lg $spacing-lg;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelId {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelDisplayName {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyModels {
|
||||||
|
padding: $spacing-xl $spacing-lg;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
433
src/pages/AuthFilesOAuthExcludedEditPage.tsx
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { IconInfo } from '@/components/ui/icons';
|
||||||
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
|
import { useAuthStore, useNotificationStore } from '@/stores';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
||||||
|
import styles from './AuthFilesOAuthExcludedEditPage.module.scss';
|
||||||
|
|
||||||
|
type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||||||
|
|
||||||
|
type LocationState = { fromAuthFiles?: boolean } | null;
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_PRESETS = [
|
||||||
|
'gemini-cli',
|
||||||
|
'vertex',
|
||||||
|
'aistudio',
|
||||||
|
'antigravity',
|
||||||
|
'claude',
|
||||||
|
'codex',
|
||||||
|
'qwen',
|
||||||
|
'iflow',
|
||||||
|
];
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
||||||
|
|
||||||
|
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
export function AuthFilesOAuthExcludedEditPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const providerFromParams = searchParams.get('provider') ?? '';
|
||||||
|
|
||||||
|
const [provider, setProvider] = useState(providerFromParams);
|
||||||
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
|
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||||
|
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
const [excludedUnsupported, setExcludedUnsupported] = useState(false);
|
||||||
|
|
||||||
|
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
|
||||||
|
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
|
||||||
|
const [modelsLoading, setModelsLoading] = useState(false);
|
||||||
|
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProvider(providerFromParams);
|
||||||
|
}, [providerFromParams]);
|
||||||
|
|
||||||
|
const providerOptions = useMemo(() => {
|
||||||
|
const extraProviders = new Set<string>();
|
||||||
|
Object.keys(excluded).forEach((value) => extraProviders.add(value));
|
||||||
|
Object.keys(modelAlias).forEach((value) => extraProviders.add(value));
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (typeof file.type === 'string') {
|
||||||
|
extraProviders.add(file.type);
|
||||||
|
}
|
||||||
|
if (typeof file.provider === 'string') {
|
||||||
|
extraProviders.add(file.provider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedExtras = Array.from(extraProviders)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
|
||||||
|
|
||||||
|
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
|
||||||
|
const extraList = normalizedExtras
|
||||||
|
.filter((value) => !baseSet.has(value.toLowerCase()))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
|
||||||
|
}, [excluded, files, modelAlias]);
|
||||||
|
|
||||||
|
const getTypeLabel = useCallback(
|
||||||
|
(type: string): string => {
|
||||||
|
const key = `auth_files.filter_${type}`;
|
||||||
|
const translated = t(key);
|
||||||
|
if (translated !== key) return translated;
|
||||||
|
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedProviderKey = useMemo(() => normalizeProviderKey(provider), [provider]);
|
||||||
|
const isEditing = useMemo(() => {
|
||||||
|
if (!resolvedProviderKey) return false;
|
||||||
|
return Object.prototype.hasOwnProperty.call(excluded, resolvedProviderKey);
|
||||||
|
}, [excluded, resolvedProviderKey]);
|
||||||
|
|
||||||
|
const title = useMemo(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
return t('oauth_excluded.edit_title', { provider: provider.trim() || resolvedProviderKey });
|
||||||
|
}
|
||||||
|
return t('oauth_excluded.add_title');
|
||||||
|
}, [isEditing, provider, resolvedProviderKey, t]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
const state = location.state as LocationState;
|
||||||
|
if (state?.fromAuthFiles) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/auth-files', { replace: true });
|
||||||
|
}, [location.state, navigate]);
|
||||||
|
|
||||||
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setInitialLoading(true);
|
||||||
|
setExcludedUnsupported(false);
|
||||||
|
try {
|
||||||
|
const [filesResult, excludedResult, aliasResult] = await Promise.allSettled([
|
||||||
|
authFilesApi.list(),
|
||||||
|
authFilesApi.getOauthExcludedModels(),
|
||||||
|
authFilesApi.getOauthModelAlias(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (filesResult.status === 'fulfilled') {
|
||||||
|
setFiles(filesResult.value?.files ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aliasResult.status === 'fulfilled') {
|
||||||
|
setModelAlias(aliasResult.value ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludedResult.status === 'fulfilled') {
|
||||||
|
setExcluded(excludedResult.value ?? {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = excludedResult.status === 'rejected' ? excludedResult.reason : null;
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setExcludedUnsupported(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load().catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resolvedProviderKey) {
|
||||||
|
setSelectedModels(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = excluded[resolvedProviderKey] ?? [];
|
||||||
|
setSelectedModels(new Set(existing));
|
||||||
|
}, [excluded, resolvedProviderKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resolvedProviderKey || excludedUnsupported) {
|
||||||
|
setModelsList([]);
|
||||||
|
setModelsError(null);
|
||||||
|
setModelsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setModelsLoading(true);
|
||||||
|
setModelsError(null);
|
||||||
|
|
||||||
|
authFilesApi
|
||||||
|
.getModelDefinitions(resolvedProviderKey)
|
||||||
|
.then((models) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setModelsList(models);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setModelsList([]);
|
||||||
|
setModelsError('unsupported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setModelsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [excludedUnsupported, resolvedProviderKey, showNotification, t]);
|
||||||
|
|
||||||
|
const updateProvider = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setProvider(value);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
next.set('provider', trimmed);
|
||||||
|
} else {
|
||||||
|
next.delete('provider');
|
||||||
|
}
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleModel = useCallback((modelId: string, checked: boolean) => {
|
||||||
|
setSelectedModels((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (checked) {
|
||||||
|
next.add(modelId);
|
||||||
|
} else {
|
||||||
|
next.delete(modelId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
const normalizedProvider = normalizeProviderKey(provider);
|
||||||
|
if (!normalizedProvider) {
|
||||||
|
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = [...selectedModels];
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (models.length) {
|
||||||
|
await authFilesApi.saveOauthExcludedModels(normalizedProvider, models);
|
||||||
|
} else {
|
||||||
|
await authFilesApi.deleteOauthExcludedEntry(normalizedProvider);
|
||||||
|
}
|
||||||
|
showNotification(t('oauth_excluded.save_success'), 'success');
|
||||||
|
handleBack();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [handleBack, provider, selectedModels, showNotification, t]);
|
||||||
|
|
||||||
|
const canSave = !disableControls && !saving && !excludedUnsupported;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryScreenShell
|
||||||
|
ref={swipeRef}
|
||||||
|
title={title}
|
||||||
|
onBack={handleBack}
|
||||||
|
backLabel={t('common.back')}
|
||||||
|
backAriaLabel={t('common.back')}
|
||||||
|
contentClassName={styles.pageContent}
|
||||||
|
rightAction={
|
||||||
|
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||||
|
{t('oauth_excluded.save')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
isLoading={initialLoading}
|
||||||
|
loadingLabel={t('common.loading')}
|
||||||
|
>
|
||||||
|
{excludedUnsupported ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
title={t('oauth_excluded.upgrade_required_title')}
|
||||||
|
description={t('oauth_excluded.upgrade_required_desc')}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className={styles.settingsCard}>
|
||||||
|
<div className={styles.settingsHeader}>
|
||||||
|
<div className={styles.settingsHeaderTitle}>
|
||||||
|
<IconInfo size={16} />
|
||||||
|
<span>{t('oauth_excluded.title')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingsHeaderHint}>{t('oauth_excluded.description')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.settingsSection}>
|
||||||
|
<div className={styles.settingsRow}>
|
||||||
|
<div className={styles.settingsInfo}>
|
||||||
|
<div className={styles.settingsLabel}>{t('oauth_excluded.provider_label')}</div>
|
||||||
|
<div className={styles.settingsDesc}>{t('oauth_excluded.provider_hint')}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingsControl}>
|
||||||
|
<AutocompleteInput
|
||||||
|
id="oauth-excluded-provider"
|
||||||
|
placeholder={t('oauth_excluded.provider_placeholder')}
|
||||||
|
value={provider}
|
||||||
|
onChange={updateProvider}
|
||||||
|
options={providerOptions}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
wrapperStyle={{ marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{providerOptions.length > 0 && (
|
||||||
|
<div className={styles.tagList}>
|
||||||
|
{providerOptions.map((option) => {
|
||||||
|
const isActive = normalizeProviderKey(provider) === option.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.tag} ${isActive ? styles.tagActive : ''}`}
|
||||||
|
onClick={() => updateProvider(option)}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
>
|
||||||
|
{getTypeLabel(option)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={styles.settingsCard}>
|
||||||
|
<div className={styles.settingsHeader}>
|
||||||
|
<div className={styles.settingsHeaderTitle}>{t('oauth_excluded.models_label')}</div>
|
||||||
|
{resolvedProviderKey && (
|
||||||
|
<div className={styles.modelsHint}>
|
||||||
|
{modelsLoading ? (
|
||||||
|
<>
|
||||||
|
<LoadingSpinner size={14} />
|
||||||
|
<span>{t('oauth_excluded.models_loading')}</span>
|
||||||
|
</>
|
||||||
|
) : modelsError === 'unsupported' ? (
|
||||||
|
<span>{t('oauth_excluded.models_unsupported')}</span>
|
||||||
|
) : modelsList.length > 0 ? (
|
||||||
|
<span>{t('oauth_excluded.models_loaded', { count: modelsList.length })}</span>
|
||||||
|
) : (
|
||||||
|
<span>{t('oauth_excluded.no_models_available')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modelsLoading ? (
|
||||||
|
<div className={styles.loadingModels}>
|
||||||
|
<LoadingSpinner size={16} />
|
||||||
|
<span>{t('common.loading')}</span>
|
||||||
|
</div>
|
||||||
|
) : modelsList.length > 0 ? (
|
||||||
|
<div className={styles.modelList}>
|
||||||
|
{modelsList.map((model) => {
|
||||||
|
const checked = selectedModels.has(model.id);
|
||||||
|
return (
|
||||||
|
<label key={model.id} className={styles.modelItem}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
onChange={(event) => toggleModel(model.id, event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className={styles.modelText}>
|
||||||
|
<span className={styles.modelId}>{model.id}</span>
|
||||||
|
{model.display_name && model.display_name !== model.id && (
|
||||||
|
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : resolvedProviderKey ? (
|
||||||
|
<div className={styles.emptyModels}>
|
||||||
|
{modelsError === 'unsupported'
|
||||||
|
? t('oauth_excluded.models_unsupported')
|
||||||
|
: t('oauth_excluded.no_models_available')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyModels}>{t('oauth_excluded.provider_required')}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SecondaryScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
src/pages/AuthFilesOAuthModelAliasEditPage.module.scss
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
@use '../styles/variables' as *;
|
||||||
|
@use '../styles/mixins' as *;
|
||||||
|
|
||||||
|
.pageContent {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 $spacing-lg $spacing-2xl;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsCard {
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
padding: $spacing-md $spacing-lg;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsHeaderTitle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsHeaderHint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-md $spacing-lg $spacing-lg;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsDesc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingsControl {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: min(360px, 45%);
|
||||||
|
min-width: 220px;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagActive {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mappingsHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-md $spacing-lg;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mappingsTitle {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelsHint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
padding: $spacing-sm $spacing-lg;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mappingsBody {
|
||||||
|
padding: $spacing-sm $spacing-lg $spacing-lg;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mappingRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mappingSeparator {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mappingAliasInput {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mappingFork {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
482
src/pages/AuthFilesOAuthModelAliasEditPage.tsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
|
||||||
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { IconInfo, IconX } from '@/components/ui/icons';
|
||||||
|
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
|
||||||
|
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||||
|
import { useAuthStore, useNotificationStore } from '@/stores';
|
||||||
|
import { authFilesApi } from '@/services/api';
|
||||||
|
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
||||||
|
import { generateId } from '@/utils/helpers';
|
||||||
|
import styles from './AuthFilesOAuthModelAliasEditPage.module.scss';
|
||||||
|
|
||||||
|
type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||||||
|
|
||||||
|
type LocationState = { fromAuthFiles?: boolean } | null;
|
||||||
|
|
||||||
|
type OAuthModelMappingFormEntry = OAuthModelAliasEntry & { id: string };
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_PRESETS = [
|
||||||
|
'gemini-cli',
|
||||||
|
'vertex',
|
||||||
|
'aistudio',
|
||||||
|
'antigravity',
|
||||||
|
'claude',
|
||||||
|
'codex',
|
||||||
|
'qwen',
|
||||||
|
'iflow',
|
||||||
|
];
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
||||||
|
|
||||||
|
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
|
||||||
|
id: generateId(),
|
||||||
|
name: '',
|
||||||
|
alias: '',
|
||||||
|
fork: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeMappingEntries = (
|
||||||
|
entries?: OAuthModelAliasEntry[]
|
||||||
|
): OAuthModelMappingFormEntry[] => {
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) {
|
||||||
|
return [buildEmptyMappingEntry()];
|
||||||
|
}
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
id: generateId(),
|
||||||
|
name: entry.name ?? '',
|
||||||
|
alias: entry.alias ?? '',
|
||||||
|
fork: Boolean(entry.fork),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthFilesOAuthModelAliasEditPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const providerFromParams = searchParams.get('provider') ?? '';
|
||||||
|
|
||||||
|
const [provider, setProvider] = useState(providerFromParams);
|
||||||
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
|
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||||
|
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
const [modelAliasUnsupported, setModelAliasUnsupported] = useState(false);
|
||||||
|
|
||||||
|
const [mappings, setMappings] = useState<OAuthModelMappingFormEntry[]>([buildEmptyMappingEntry()]);
|
||||||
|
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
|
||||||
|
const [modelsLoading, setModelsLoading] = useState(false);
|
||||||
|
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProvider(providerFromParams);
|
||||||
|
}, [providerFromParams]);
|
||||||
|
|
||||||
|
const providerOptions = useMemo(() => {
|
||||||
|
const extraProviders = new Set<string>();
|
||||||
|
Object.keys(excluded).forEach((value) => extraProviders.add(value));
|
||||||
|
Object.keys(modelAlias).forEach((value) => extraProviders.add(value));
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (typeof file.type === 'string') {
|
||||||
|
extraProviders.add(file.type);
|
||||||
|
}
|
||||||
|
if (typeof file.provider === 'string') {
|
||||||
|
extraProviders.add(file.provider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedExtras = Array.from(extraProviders)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
|
||||||
|
|
||||||
|
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
|
||||||
|
const extraList = normalizedExtras
|
||||||
|
.filter((value) => !baseSet.has(value.toLowerCase()))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
|
||||||
|
}, [excluded, files, modelAlias]);
|
||||||
|
|
||||||
|
const getTypeLabel = useCallback(
|
||||||
|
(type: string): string => {
|
||||||
|
const key = `auth_files.filter_${type}`;
|
||||||
|
const translated = t(key);
|
||||||
|
if (translated !== key) return translated;
|
||||||
|
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolvedProviderKey = useMemo(() => normalizeProviderKey(provider), [provider]);
|
||||||
|
const title = useMemo(() => t('oauth_model_alias.add_title'), [t]);
|
||||||
|
const headerHint = useMemo(() => {
|
||||||
|
if (!provider.trim()) {
|
||||||
|
return t('oauth_model_alias.provider_hint');
|
||||||
|
}
|
||||||
|
if (modelsLoading) {
|
||||||
|
return t('oauth_model_alias.model_source_loading');
|
||||||
|
}
|
||||||
|
if (modelsError === 'unsupported') {
|
||||||
|
return t('oauth_model_alias.model_source_unsupported');
|
||||||
|
}
|
||||||
|
return t('oauth_model_alias.model_source_loaded', { count: modelsList.length });
|
||||||
|
}, [modelsError, modelsList.length, modelsLoading, provider, t]);
|
||||||
|
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
const state = location.state as LocationState;
|
||||||
|
if (state?.fromAuthFiles) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate('/auth-files', { replace: true });
|
||||||
|
}, [location.state, navigate]);
|
||||||
|
|
||||||
|
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleBack]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setInitialLoading(true);
|
||||||
|
setModelAliasUnsupported(false);
|
||||||
|
try {
|
||||||
|
const [filesResult, excludedResult, aliasResult] = await Promise.allSettled([
|
||||||
|
authFilesApi.list(),
|
||||||
|
authFilesApi.getOauthExcludedModels(),
|
||||||
|
authFilesApi.getOauthModelAlias(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (filesResult.status === 'fulfilled') {
|
||||||
|
setFiles(filesResult.value?.files ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludedResult.status === 'fulfilled') {
|
||||||
|
setExcluded(excludedResult.value ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aliasResult.status === 'fulfilled') {
|
||||||
|
setModelAlias(aliasResult.value ?? {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = aliasResult.status === 'rejected' ? aliasResult.reason : null;
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setModelAliasUnsupported(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load().catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resolvedProviderKey) {
|
||||||
|
setMappings([buildEmptyMappingEntry()]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = modelAlias[resolvedProviderKey] ?? [];
|
||||||
|
setMappings(normalizeMappingEntries(existing));
|
||||||
|
}, [modelAlias, resolvedProviderKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resolvedProviderKey || modelAliasUnsupported) {
|
||||||
|
setModelsList([]);
|
||||||
|
setModelsError(null);
|
||||||
|
setModelsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setModelsLoading(true);
|
||||||
|
setModelsError(null);
|
||||||
|
|
||||||
|
authFilesApi
|
||||||
|
.getModelDefinitions(resolvedProviderKey)
|
||||||
|
.then((models) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setModelsList(models);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const status =
|
||||||
|
typeof err === 'object' && err !== null && 'status' in err
|
||||||
|
? (err as { status?: unknown }).status
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
setModelsList([]);
|
||||||
|
setModelsError('unsupported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setModelsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [modelAliasUnsupported, resolvedProviderKey, showNotification, t]);
|
||||||
|
|
||||||
|
const updateProvider = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setProvider(value);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
next.set('provider', trimmed);
|
||||||
|
} else {
|
||||||
|
next.delete('provider');
|
||||||
|
}
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
},
|
||||||
|
[searchParams, setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateMappingEntry = useCallback(
|
||||||
|
(index: number, field: keyof OAuthModelAliasEntry, value: string | boolean) => {
|
||||||
|
setMappings((prev) =>
|
||||||
|
prev.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addMappingEntry = useCallback(() => {
|
||||||
|
setMappings((prev) => [...prev, buildEmptyMappingEntry()]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeMappingEntry = useCallback((index: number) => {
|
||||||
|
setMappings((prev) => {
|
||||||
|
const next = prev.filter((_, idx) => idx !== index);
|
||||||
|
return next.length ? next : [buildEmptyMappingEntry()];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
const channel = provider.trim();
|
||||||
|
if (!channel) {
|
||||||
|
showNotification(t('oauth_model_alias.provider_required'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const normalized = mappings
|
||||||
|
.map((entry) => {
|
||||||
|
const name = String(entry.name ?? '').trim();
|
||||||
|
const alias = String(entry.alias ?? '').trim();
|
||||||
|
if (!name || !alias) return null;
|
||||||
|
const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}`;
|
||||||
|
if (seen.has(key)) return null;
|
||||||
|
seen.add(key);
|
||||||
|
return entry.fork ? { name, alias, fork: true } : { name, alias };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as OAuthModelAliasEntry[];
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (normalized.length) {
|
||||||
|
await authFilesApi.saveOauthModelAlias(channel, normalized);
|
||||||
|
} else {
|
||||||
|
await authFilesApi.deleteOauthModelAlias(channel);
|
||||||
|
}
|
||||||
|
showNotification(t('oauth_model_alias.save_success'), 'success');
|
||||||
|
handleBack();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [handleBack, mappings, provider, showNotification, t]);
|
||||||
|
|
||||||
|
const canSave = !disableControls && !saving && !modelAliasUnsupported;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryScreenShell
|
||||||
|
ref={swipeRef}
|
||||||
|
title={title}
|
||||||
|
onBack={handleBack}
|
||||||
|
backLabel={t('common.back')}
|
||||||
|
backAriaLabel={t('common.back')}
|
||||||
|
contentClassName={styles.pageContent}
|
||||||
|
rightAction={
|
||||||
|
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
|
||||||
|
{t('oauth_model_alias.save')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
isLoading={initialLoading}
|
||||||
|
loadingLabel={t('common.loading')}
|
||||||
|
>
|
||||||
|
{modelAliasUnsupported ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
title={t('oauth_model_alias.upgrade_required_title')}
|
||||||
|
description={t('oauth_model_alias.upgrade_required_desc')}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className={styles.settingsCard}>
|
||||||
|
<div className={styles.settingsHeader}>
|
||||||
|
<div className={styles.settingsHeaderTitle}>
|
||||||
|
<IconInfo size={16} />
|
||||||
|
<span>{t('oauth_model_alias.title')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingsHeaderHint}>{headerHint}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.settingsSection}>
|
||||||
|
<div className={styles.settingsRow}>
|
||||||
|
<div className={styles.settingsInfo}>
|
||||||
|
<div className={styles.settingsLabel}>{t('oauth_model_alias.provider_label')}</div>
|
||||||
|
<div className={styles.settingsDesc}>{t('oauth_model_alias.provider_hint')}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingsControl}>
|
||||||
|
<AutocompleteInput
|
||||||
|
id="oauth-model-alias-provider"
|
||||||
|
placeholder={t('oauth_model_alias.provider_placeholder')}
|
||||||
|
value={provider}
|
||||||
|
onChange={updateProvider}
|
||||||
|
options={providerOptions}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
wrapperStyle={{ marginBottom: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{providerOptions.length > 0 && (
|
||||||
|
<div className={styles.tagList}>
|
||||||
|
{providerOptions.map((option) => {
|
||||||
|
const isActive = normalizeProviderKey(provider) === option.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.tag} ${isActive ? styles.tagActive : ''}`}
|
||||||
|
onClick={() => updateProvider(option)}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
>
|
||||||
|
{getTypeLabel(option)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={styles.settingsCard}>
|
||||||
|
<div className={styles.mappingsHeader}>
|
||||||
|
<div className={styles.mappingsTitle}>{t('oauth_model_alias.alias_label')}</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={addMappingEntry}
|
||||||
|
disabled={disableControls || saving || modelAliasUnsupported}
|
||||||
|
>
|
||||||
|
{t('oauth_model_alias.add_alias')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.mappingsBody}>
|
||||||
|
{mappings.map((entry, index) => (
|
||||||
|
<div key={entry.id} className={styles.mappingRow}>
|
||||||
|
<AutocompleteInput
|
||||||
|
wrapperStyle={{ flex: 1, marginBottom: 0 }}
|
||||||
|
placeholder={t('oauth_model_alias.alias_name_placeholder')}
|
||||||
|
value={entry.name}
|
||||||
|
onChange={(val) => updateMappingEntry(index, 'name', val)}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
options={modelsList.map((model) => ({
|
||||||
|
value: model.id,
|
||||||
|
label:
|
||||||
|
model.display_name && model.display_name !== model.id
|
||||||
|
? model.display_name
|
||||||
|
: undefined,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<span className={styles.mappingSeparator}>→</span>
|
||||||
|
<input
|
||||||
|
className={`input ${styles.mappingAliasInput}`}
|
||||||
|
placeholder={t('oauth_model_alias.alias_placeholder')}
|
||||||
|
value={entry.alias}
|
||||||
|
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
<div className={styles.mappingFork}>
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('oauth_model_alias.alias_fork_label')}
|
||||||
|
labelPosition="left"
|
||||||
|
checked={Boolean(entry.fork)}
|
||||||
|
onChange={(value) => updateMappingEntry(index, 'fork', value)}
|
||||||
|
disabled={disableControls || saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeMappingEntry(index)}
|
||||||
|
disabled={disableControls || saving || mappings.length <= 1}
|
||||||
|
title={t('common.delete')}
|
||||||
|
aria-label={t('common.delete')}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SecondaryScreenShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,6 +79,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filterTag {
|
.filterTag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -97,6 +100,16 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filterTagLabel {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterTagCount {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.filterControls {
|
.filterControls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
@@ -277,27 +290,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 {
|
||||||
@@ -446,7 +447,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);
|
||||||
@@ -455,6 +459,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;
|
||||||
@@ -546,7 +560,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);
|
||||||
@@ -597,14 +613,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;
|
||||||
@@ -916,3 +1008,53 @@
|
|||||||
border: 1px solid var(--danger-color);
|
border: 1px solid var(--danger-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排除模型勾选列表
|
||||||
|
.excludedCheckList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-sm;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.excludedCheckItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-xs $spacing-sm;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.excludedCheckLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.excludedCheckDisplayName {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|||||||
328
src/pages/LoginPage.module.scss
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
@use '../styles/variables.scss' as *;
|
||||||
|
|
||||||
|
// 主容器 - 左右分栏布局
|
||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左侧品牌展示区
|
||||||
|
.brandPanel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: #000000;
|
||||||
|
padding: $spacing-2xl;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// 移动端隐藏
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 品牌文字容器
|
||||||
|
.brandContent {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 品牌大字淡入动画
|
||||||
|
@keyframes brandFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: var(--target-opacity, 0.9);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 品牌大字
|
||||||
|
.brandWord {
|
||||||
|
font-size: 14vw;
|
||||||
|
font-weight: 900;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 0.85;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 0;
|
||||||
|
opacity: 0;
|
||||||
|
animation: brandFadeIn 0.8s ease-out forwards;
|
||||||
|
|
||||||
|
// 不同字有不同的透明度和延迟,从上到下依次显现
|
||||||
|
&:nth-child(1) {
|
||||||
|
--target-opacity: 0.95;
|
||||||
|
animation-delay: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
--target-opacity: 0.7;
|
||||||
|
animation-delay: 0.35s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
--target-opacity: 0.45;
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧功能交互区
|
||||||
|
.formPanel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: $spacing-2xl;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
padding: $spacing-lg;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧内容容器
|
||||||
|
.formContent {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
.logo {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 3px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录表单卡片
|
||||||
|
.loginCard {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: $spacing-xl;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
padding: $spacing-lg;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录头部
|
||||||
|
.loginHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题行
|
||||||
|
.titleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
.title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 副标题
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语言切换按钮
|
||||||
|
.languageBtn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接信息框
|
||||||
|
.connectionBox {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-md;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复选框行
|
||||||
|
.toggleAdvanced {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误提示框
|
||||||
|
.errorBox {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
color: $error-color;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 启动动画(右侧) ==========
|
||||||
|
|
||||||
|
// 启动动画进入效果
|
||||||
|
@keyframes splashEnter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logo 脉冲效果
|
||||||
|
@keyframes splashLogoPulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载条动画
|
||||||
|
@keyframes splashLoading {
|
||||||
|
0% {
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleX(1);
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
50.01% {
|
||||||
|
transform-origin: right;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画内容容器
|
||||||
|
.splashContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
animation: splashEnter 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画 Logo
|
||||||
|
.splashLogo {
|
||||||
|
height: 80px;
|
||||||
|
width: auto;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
animation: splashLogoPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画标题
|
||||||
|
.splashTitle {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画副标题
|
||||||
|
.splashSubtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
margin-top: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画加载条容器
|
||||||
|
.splashLoader {
|
||||||
|
width: 120px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: $radius-full;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动动画加载条
|
||||||
|
.splashLoaderBar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: $radius-full;
|
||||||
|
animation: splashLoading 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
@@ -6,6 +6,52 @@ import { Input } from '@/components/ui/Input';
|
|||||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||||
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||||
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
|
import type { ApiError } from '@/types';
|
||||||
|
import styles from './LoginPage.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 API 错误转换为本地化的用户友好消息
|
||||||
|
*/
|
||||||
|
function getLocalizedErrorMessage(error: any, t: (key: string) => string): string {
|
||||||
|
const apiError = error as ApiError;
|
||||||
|
const status = apiError?.status;
|
||||||
|
const code = apiError?.code;
|
||||||
|
const message = apiError?.message || '';
|
||||||
|
|
||||||
|
// 根据 HTTP 状态码判断
|
||||||
|
if (status === 401) {
|
||||||
|
return t('login.error_unauthorized');
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
|
return t('login.error_forbidden');
|
||||||
|
}
|
||||||
|
if (status === 404) {
|
||||||
|
return t('login.error_not_found');
|
||||||
|
}
|
||||||
|
if (status && status >= 500) {
|
||||||
|
return t('login.error_server');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 axios 错误码判断
|
||||||
|
if (code === 'ECONNABORTED' || message.toLowerCase().includes('timeout')) {
|
||||||
|
return t('login.error_timeout');
|
||||||
|
}
|
||||||
|
if (code === 'ERR_NETWORK' || message.toLowerCase().includes('network error')) {
|
||||||
|
return t('login.error_network');
|
||||||
|
}
|
||||||
|
if (code === 'ERR_CERT_AUTHORITY_INVALID' || message.toLowerCase().includes('certificate')) {
|
||||||
|
return t('login.error_ssl');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 CORS 错误
|
||||||
|
if (message.toLowerCase().includes('cors') || message.toLowerCase().includes('cross-origin')) {
|
||||||
|
return t('login.error_cors');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认错误消息
|
||||||
|
return t('login.error_invalid');
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -28,6 +74,7 @@ export function LoginPage() {
|
|||||||
const [rememberPassword, setRememberPassword] = useState(false);
|
const [rememberPassword, setRememberPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [autoLoading, setAutoLoading] = useState(true);
|
const [autoLoading, setAutoLoading] = useState(true);
|
||||||
|
const [autoLoginSuccess, setAutoLoginSuccess] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||||
@@ -37,20 +84,30 @@ export function LoginPage() {
|
|||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
const autoLoggedIn = await restoreSession();
|
const autoLoggedIn = await restoreSession();
|
||||||
if (!autoLoggedIn) {
|
if (autoLoggedIn) {
|
||||||
|
setAutoLoginSuccess(true);
|
||||||
|
// 延迟跳转,让用户看到成功动画
|
||||||
|
setTimeout(() => {
|
||||||
|
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||||
|
navigate(redirect, { replace: true });
|
||||||
|
}, 1300);
|
||||||
|
} else {
|
||||||
setApiBase(storedBase || detectedBase);
|
setApiBase(storedBase || detectedBase);
|
||||||
setManagementKey(storedKey || '');
|
setManagementKey(storedKey || '');
|
||||||
setRememberPassword(storedRememberPassword || Boolean(storedKey));
|
setRememberPassword(storedRememberPassword || Boolean(storedKey));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setAutoLoading(false);
|
if (!autoLoginSuccess) {
|
||||||
|
setAutoLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!managementKey.trim()) {
|
if (!managementKey.trim()) {
|
||||||
setError(t('login.error_required'));
|
setError(t('login.error_required'));
|
||||||
return;
|
return;
|
||||||
@@ -68,13 +125,13 @@ export function LoginPage() {
|
|||||||
showNotification(t('common.connected_status'), 'success');
|
showNotification(t('common.connected_status'), 'success');
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const message = err?.message || t('login.error_invalid');
|
const message = getLocalizedErrorMessage(err, t);
|
||||||
setError(message);
|
setError(message);
|
||||||
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
|
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [apiBase, detectedBase, login, managementKey, navigate, rememberPassword, showNotification, t]);
|
||||||
|
|
||||||
const handleSubmitKeyDown = useCallback(
|
const handleSubmitKeyDown = useCallback(
|
||||||
(event: React.KeyboardEvent) => {
|
(event: React.KeyboardEvent) => {
|
||||||
@@ -86,107 +143,134 @@ export function LoginPage() {
|
|||||||
[loading, handleSubmit]
|
[loading, handleSubmit]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
|
||||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||||
return <Navigate to={redirect} replace />;
|
return <Navigate to={redirect} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示启动动画(自动登录中或自动登录成功)
|
||||||
|
const showSplash = autoLoading || autoLoginSuccess;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="login-page">
|
<div className={styles.container}>
|
||||||
<div className="login-card">
|
{/* 左侧品牌展示区 */}
|
||||||
<div className="login-header">
|
<div className={styles.brandPanel}>
|
||||||
<div className="login-title-row">
|
<div className={styles.brandContent}>
|
||||||
<div className="title">{t('title.login')}</div>
|
<span className={styles.brandWord}>CLI</span>
|
||||||
<Button
|
<span className={styles.brandWord}>PROXY</span>
|
||||||
type="button"
|
<span className={styles.brandWord}>API</span>
|
||||||
variant="ghost"
|
</div>
|
||||||
size="sm"
|
</div>
|
||||||
className="login-language-btn"
|
|
||||||
onClick={toggleLanguage}
|
{/* 右侧功能交互区 */}
|
||||||
title={t('language.switch')}
|
<div className={styles.formPanel}>
|
||||||
aria-label={t('language.switch')}
|
{showSplash ? (
|
||||||
>
|
/* 启动动画 */
|
||||||
{nextLanguageLabel}
|
<div className={styles.splashContent}>
|
||||||
</Button>
|
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.splashLogo} />
|
||||||
|
<h1 className={styles.splashTitle}>CLI Proxy API</h1>
|
||||||
|
<p className={styles.splashSubtitle}>Management Center</p>
|
||||||
|
<div className={styles.splashLoader}>
|
||||||
|
<div className={styles.splashLoaderBar} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="subtitle">{t('login.subtitle')}</div>
|
) : (
|
||||||
</div>
|
/* 登录表单 */
|
||||||
|
<div className={styles.formContent}>
|
||||||
|
{/* Logo */}
|
||||||
|
<img src={INLINE_LOGO_JPEG} alt="Logo" className={styles.logo} />
|
||||||
|
|
||||||
<div className="connection-box">
|
{/* 登录表单卡片 */}
|
||||||
<div className="label">{t('login.connection_current')}</div>
|
<div className={styles.loginCard}>
|
||||||
<div className="value">{apiBase || detectedBase}</div>
|
<div className={styles.loginHeader}>
|
||||||
<div className="hint">{t('login.connection_auto_hint')}</div>
|
<div className={styles.titleRow}>
|
||||||
</div>
|
<div className={styles.title}>{t('title.login')}</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={styles.languageBtn}
|
||||||
|
onClick={toggleLanguage}
|
||||||
|
title={t('language.switch')}
|
||||||
|
aria-label={t('language.switch')}
|
||||||
|
>
|
||||||
|
{nextLanguageLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.subtitle}>{t('login.subtitle')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="toggle-advanced">
|
<div className={styles.connectionBox}>
|
||||||
<input
|
<div className={styles.label}>{t('login.connection_current')}</div>
|
||||||
id="custom-connection-toggle"
|
<div className={styles.value}>{apiBase || detectedBase}</div>
|
||||||
type="checkbox"
|
<div className={styles.hint}>{t('login.connection_auto_hint')}</div>
|
||||||
checked={showCustomBase}
|
</div>
|
||||||
onChange={(e) => setShowCustomBase(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCustomBase && (
|
<div className={styles.toggleAdvanced}>
|
||||||
<Input
|
<input
|
||||||
label={t('login.custom_connection_label')}
|
id="custom-connection-toggle"
|
||||||
placeholder={t('login.custom_connection_placeholder')}
|
type="checkbox"
|
||||||
value={apiBase}
|
checked={showCustomBase}
|
||||||
onChange={(e) => setApiBase(e.target.value)}
|
onChange={(e) => setShowCustomBase(e.target.checked)}
|
||||||
hint={t('login.custom_connection_hint')}
|
/>
|
||||||
/>
|
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<Input
|
{showCustomBase && (
|
||||||
autoFocus
|
<Input
|
||||||
label={t('login.management_key_label')}
|
label={t('login.custom_connection_label')}
|
||||||
placeholder={t('login.management_key_placeholder')}
|
placeholder={t('login.custom_connection_placeholder')}
|
||||||
type={showKey ? 'text' : 'password'}
|
value={apiBase}
|
||||||
value={managementKey}
|
onChange={(e) => setApiBase(e.target.value)}
|
||||||
onChange={(e) => setManagementKey(e.target.value)}
|
hint={t('login.custom_connection_hint')}
|
||||||
onKeyDown={handleSubmitKeyDown}
|
/>
|
||||||
rightElement={
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={() => setShowKey((prev) => !prev)}
|
|
||||||
aria-label={
|
|
||||||
showKey
|
|
||||||
? t('login.hide_key', { defaultValue: '隐藏密钥' })
|
|
||||||
: t('login.show_key', { defaultValue: '显示密钥' })
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
showKey
|
|
||||||
? t('login.hide_key', { defaultValue: '隐藏密钥' })
|
|
||||||
: t('login.show_key', { defaultValue: '显示密钥' })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{showKey ? <IconEyeOff size={16} /> : <IconEye size={16} />}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="toggle-advanced">
|
<Input
|
||||||
<input
|
autoFocus
|
||||||
id="remember-password-toggle"
|
label={t('login.management_key_label')}
|
||||||
type="checkbox"
|
placeholder={t('login.management_key_placeholder')}
|
||||||
checked={rememberPassword}
|
type={showKey ? 'text' : 'password'}
|
||||||
onChange={(e) => setRememberPassword(e.target.checked)}
|
value={managementKey}
|
||||||
/>
|
onChange={(e) => setManagementKey(e.target.value)}
|
||||||
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
|
onKeyDown={handleSubmitKeyDown}
|
||||||
</div>
|
rightElement={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setShowKey((prev) => !prev)}
|
||||||
|
aria-label={
|
||||||
|
showKey
|
||||||
|
? t('login.hide_key', { defaultValue: '隐藏密钥' })
|
||||||
|
: t('login.show_key', { defaultValue: '显示密钥' })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
showKey
|
||||||
|
? t('login.hide_key', { defaultValue: '隐藏密钥' })
|
||||||
|
: t('login.show_key', { defaultValue: '显示密钥' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showKey ? <IconEyeOff size={16} /> : <IconEye size={16} />}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
<div className={styles.toggleAdvanced}>
|
||||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
<input
|
||||||
</Button>
|
id="remember-password-toggle"
|
||||||
|
type="checkbox"
|
||||||
|
checked={rememberPassword}
|
||||||
|
onChange={(e) => setRememberPassword(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||||
|
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{autoLoading && (
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
<div className="connection-box">
|
</div>
|
||||||
<div className="label">{t('auto_login.title')}</div>
|
|
||||||
<div className="value">{t('auto_login.message')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,12 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabActive {
|
.tabActive {
|
||||||
@@ -262,6 +268,30 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rawLog {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: text;
|
||||||
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||||
|
monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.logRow {
|
.logRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 170px 1fr;
|
grid-template-columns: 170px 1fr;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Modal } from '@/components/ui/Modal';
|
|||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import {
|
import {
|
||||||
IconDownload,
|
IconDownload,
|
||||||
|
IconCode,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconRefreshCw,
|
IconRefreshCw,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
@@ -371,7 +372,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);
|
||||||
|
|
||||||
@@ -383,6 +384,7 @@ export function LogsPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||||
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
||||||
|
const [showRawLogs, setShowRawLogs] = useState(false);
|
||||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||||
const [loadingErrors, setLoadingErrors] = useState(false);
|
const [loadingErrors, setLoadingErrors] = useState(false);
|
||||||
const [errorLogsError, setErrorLogsError] = useState('');
|
const [errorLogsError, setErrorLogsError] = useState('');
|
||||||
@@ -478,19 +480,26 @@ export function LogsPage() {
|
|||||||
useHeaderRefresh(() => loadLogs(false));
|
useHeaderRefresh(() => loadLogs(false));
|
||||||
|
|
||||||
const clearLogs = async () => {
|
const clearLogs = async () => {
|
||||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
showConfirmation({
|
||||||
try {
|
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
|
||||||
await logsApi.clearLogs();
|
message: t('logs.clear_confirm'),
|
||||||
setLogState({ buffer: [], visibleFrom: 0 });
|
variant: 'danger',
|
||||||
latestTimestampRef.current = 0;
|
confirmText: t('common.confirm'),
|
||||||
showNotification(t('logs.clear_success'), 'success');
|
onConfirm: async () => {
|
||||||
} catch (err: unknown) {
|
try {
|
||||||
const message = getErrorMessage(err);
|
await logsApi.clearLogs();
|
||||||
showNotification(
|
setLogState({ buffer: [], visibleFrom: 0 });
|
||||||
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
|
latestTimestampRef.current = 0;
|
||||||
'error'
|
showNotification(t('logs.clear_success'), 'success');
|
||||||
);
|
} catch (err: unknown) {
|
||||||
}
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadLogs = () => {
|
const downloadLogs = () => {
|
||||||
@@ -625,10 +634,12 @@ export function LogsPage() {
|
|||||||
return { filteredLines: working, removedCount: removed };
|
return { filteredLines: working, removedCount: removed };
|
||||||
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
|
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
|
||||||
|
|
||||||
const parsedVisibleLines = useMemo(
|
const parsedVisibleLines = useMemo(() => {
|
||||||
() => filteredLines.map((line) => parseLogLine(line)),
|
if (showRawLogs) return [];
|
||||||
[filteredLines]
|
return filteredLines.map((line) => parseLogLine(line));
|
||||||
);
|
}, [filteredLines, showRawLogs]);
|
||||||
|
|
||||||
|
const rawVisibleText = useMemo(() => filteredLines.join('\n'), [filteredLines]);
|
||||||
|
|
||||||
const canLoadMore = !isSearching && logState.visibleFrom > 0;
|
const canLoadMore = !isSearching && logState.visibleFrom > 0;
|
||||||
|
|
||||||
@@ -810,6 +821,22 @@ export function LogsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={showRawLogs}
|
||||||
|
onChange={setShowRawLogs}
|
||||||
|
label={
|
||||||
|
<span
|
||||||
|
className={styles.switchLabel}
|
||||||
|
title={t('logs.show_raw_logs_hint', {
|
||||||
|
defaultValue: 'Show original log text for easier multi-line copy',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconCode size={16} />
|
||||||
|
{t('logs.show_raw_logs', { defaultValue: 'Show raw logs' })}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -863,14 +890,14 @@ export function LogsPage() {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="hint">{t('logs.loading')}</div>
|
<div className="hint">{t('logs.loading')}</div>
|
||||||
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
) : logState.buffer.length > 0 && filteredLines.length > 0 ? (
|
||||||
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
|
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
|
||||||
{canLoadMore && (
|
{canLoadMore && (
|
||||||
<div className={styles.loadMoreBanner}>
|
<div className={styles.loadMoreBanner}>
|
||||||
<span>{t('logs.load_more_hint')}</span>
|
<span>{t('logs.load_more_hint')}</span>
|
||||||
<div className={styles.loadMoreStats}>
|
<div className={styles.loadMoreStats}>
|
||||||
<span>
|
<span>
|
||||||
{t('logs.loaded_lines', { count: parsedVisibleLines.length })}
|
{t('logs.loaded_lines', { count: filteredLines.length })}
|
||||||
</span>
|
</span>
|
||||||
{removedCount > 0 && (
|
{removedCount > 0 && (
|
||||||
<span className={styles.loadMoreCount}>
|
<span className={styles.loadMoreCount}>
|
||||||
@@ -883,103 +910,109 @@ export function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.logList}>
|
{showRawLogs ? (
|
||||||
{parsedVisibleLines.map((line, index) => {
|
<pre className={styles.rawLog} spellCheck={false}>
|
||||||
const rowClassNames = [styles.logRow];
|
{rawVisibleText}
|
||||||
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
|
</pre>
|
||||||
if (line.level === 'error' || line.level === 'fatal')
|
) : (
|
||||||
rowClassNames.push(styles.rowError);
|
<div className={styles.logList}>
|
||||||
return (
|
{parsedVisibleLines.map((line, index) => {
|
||||||
<div
|
const rowClassNames = [styles.logRow];
|
||||||
key={`${logState.visibleFrom + index}-${line.raw}`}
|
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
|
||||||
className={rowClassNames.join(' ')}
|
if (line.level === 'error' || line.level === 'fatal')
|
||||||
onDoubleClick={() => {
|
rowClassNames.push(styles.rowError);
|
||||||
void copyLogLine(line.raw);
|
return (
|
||||||
}}
|
<div
|
||||||
onPointerDown={(event) => startLongPress(event, line.requestId)}
|
key={`${logState.visibleFrom + index}-${line.raw}`}
|
||||||
onPointerUp={cancelLongPress}
|
className={rowClassNames.join(' ')}
|
||||||
onPointerLeave={cancelLongPress}
|
onDoubleClick={() => {
|
||||||
onPointerCancel={cancelLongPress}
|
void copyLogLine(line.raw);
|
||||||
onPointerMove={handleLongPressMove}
|
}}
|
||||||
title={t('logs.double_click_copy_hint', {
|
onPointerDown={(event) => startLongPress(event, line.requestId)}
|
||||||
defaultValue: 'Double-click to copy',
|
onPointerUp={cancelLongPress}
|
||||||
})}
|
onPointerLeave={cancelLongPress}
|
||||||
>
|
onPointerCancel={cancelLongPress}
|
||||||
<div className={styles.timestamp}>{line.timestamp || ''}</div>
|
onPointerMove={handleLongPressMove}
|
||||||
<div className={styles.rowMain}>
|
title={t('logs.double_click_copy_hint', {
|
||||||
{line.level && (
|
defaultValue: 'Double-click to copy',
|
||||||
<span
|
})}
|
||||||
className={[
|
>
|
||||||
styles.badge,
|
<div className={styles.timestamp}>{line.timestamp || ''}</div>
|
||||||
line.level === 'info' ? styles.levelInfo : '',
|
<div className={styles.rowMain}>
|
||||||
line.level === 'warn' ? styles.levelWarn : '',
|
{line.level && (
|
||||||
line.level === 'error' || line.level === 'fatal'
|
<span
|
||||||
? styles.levelError
|
className={[
|
||||||
: '',
|
styles.badge,
|
||||||
line.level === 'debug' ? styles.levelDebug : '',
|
line.level === 'info' ? styles.levelInfo : '',
|
||||||
line.level === 'trace' ? styles.levelTrace : '',
|
line.level === 'warn' ? styles.levelWarn : '',
|
||||||
]
|
line.level === 'error' || line.level === 'fatal'
|
||||||
.filter(Boolean)
|
? styles.levelError
|
||||||
.join(' ')}
|
: '',
|
||||||
>
|
line.level === 'debug' ? styles.levelDebug : '',
|
||||||
{line.level.toUpperCase()}
|
line.level === 'trace' ? styles.levelTrace : '',
|
||||||
</span>
|
]
|
||||||
)}
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{line.level.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{line.source && (
|
{line.source && (
|
||||||
<span className={styles.source} title={line.source}>
|
<span className={styles.source} title={line.source}>
|
||||||
{line.source}
|
{line.source}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{line.requestId && (
|
{line.requestId && (
|
||||||
<span
|
<span
|
||||||
className={[styles.badge, styles.requestIdBadge].join(' ')}
|
className={[styles.badge, styles.requestIdBadge].join(' ')}
|
||||||
title={line.requestId}
|
title={line.requestId}
|
||||||
>
|
>
|
||||||
{line.requestId}
|
{line.requestId}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{typeof line.statusCode === 'number' && (
|
{typeof line.statusCode === 'number' && (
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
styles.badge,
|
styles.badge,
|
||||||
styles.statusBadge,
|
styles.statusBadge,
|
||||||
line.statusCode >= 200 && line.statusCode < 300
|
line.statusCode >= 200 && line.statusCode < 300
|
||||||
? styles.statusSuccess
|
? styles.statusSuccess
|
||||||
: line.statusCode >= 300 && line.statusCode < 400
|
: line.statusCode >= 300 && line.statusCode < 400
|
||||||
? styles.statusInfo
|
? styles.statusInfo
|
||||||
: line.statusCode >= 400 && line.statusCode < 500
|
: line.statusCode >= 400 && line.statusCode < 500
|
||||||
? styles.statusWarn
|
? styles.statusWarn
|
||||||
: styles.statusError,
|
: styles.statusError,
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{line.statusCode}
|
{line.statusCode}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{line.latency && <span className={styles.pill}>{line.latency}</span>}
|
{line.latency && <span className={styles.pill}>{line.latency}</span>}
|
||||||
{line.ip && <span className={styles.pill}>{line.ip}</span>}
|
{line.ip && <span className={styles.pill}>{line.ip}</span>}
|
||||||
|
|
||||||
{line.method && (
|
{line.method && (
|
||||||
<span className={[styles.badge, styles.methodBadge].join(' ')}>
|
<span className={[styles.badge, styles.methodBadge].join(' ')}>
|
||||||
{line.method}
|
{line.method}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{line.path && (
|
{line.path && (
|
||||||
<span className={styles.path} title={line.path}>
|
<span className={styles.path} title={line.path}>
|
||||||
{line.path}
|
{line.path}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{line.message && <span className={styles.message}>{line.message}</span>}
|
{line.message && <span className={styles.message}>{line.message}</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : logState.buffer.length > 0 ? (
|
) : logState.buffer.length > 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
import { useCallback, useEffect, useRef, useState, type ChangeEvent } 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';
|
||||||
@@ -7,8 +7,8 @@ import { useNotificationStore, useThemeStore } from '@/stores';
|
|||||||
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
||||||
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
||||||
import styles from './OAuthPage.module.scss';
|
import styles from './OAuthPage.module.scss';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||||
import iconClaude from '@/assets/icons/claude.svg';
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
import iconAntigravity from '@/assets/icons/antigravity.svg';
|
import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
@@ -55,7 +55,7 @@ interface VertexImportState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
||||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
|
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } },
|
||||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
||||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
||||||
@@ -85,11 +85,16 @@ export function OAuthPage() {
|
|||||||
const timers = useRef<Record<string, number>>({});
|
const timers = useRef<Record<string, number>>({});
|
||||||
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
|
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const clearTimers = useCallback(() => {
|
||||||
|
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
|
||||||
|
timers.current = {};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
|
clearTimers();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [clearTimers]);
|
||||||
|
|
||||||
const updateProviderState = (provider: OAuthProvider, next: Partial<ProviderState>) => {
|
const updateProviderState = (provider: OAuthProvider, next: Partial<ProviderState>) => {
|
||||||
setStates((prev) => ({
|
setStates((prev) => ({
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function SettingsPage() {
|
|||||||
setRoutingStrategy(config.routingStrategy);
|
setRoutingStrategy(config.routingStrategy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [config?.proxyUrl, config?.requestRetry, config?.logsMaxTotalSizeMb, config?.routingStrategy]);
|
}, [config]);
|
||||||
|
|
||||||
const setPendingFlag = (key: PendingKey, value: boolean) => {
|
const setPendingFlag = (key: PendingKey, value: boolean) => {
|
||||||
setPending((prev) => ({ ...prev, [key]: value }));
|
setPending((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|||||||
@@ -90,6 +90,18 @@
|
|||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.groupTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupIcon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.modelTag {
|
.modelTag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,15 +3,39 @@ 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 { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
|
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
|
||||||
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
|
||||||
import { apiKeysApi } from '@/services/api/apiKeys';
|
import { apiKeysApi } from '@/services/api/apiKeys';
|
||||||
import { classifyModels } from '@/utils/models';
|
import { classifyModels } from '@/utils/models';
|
||||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||||
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
|
import iconQwen from '@/assets/icons/qwen.svg';
|
||||||
|
import iconKimiLight from '@/assets/icons/kimi-light.svg';
|
||||||
|
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
|
||||||
|
import iconGlm from '@/assets/icons/glm.svg';
|
||||||
|
import iconGrok from '@/assets/icons/grok.svg';
|
||||||
|
import iconDeepseek from '@/assets/icons/deepseek.svg';
|
||||||
|
import iconMinimax from '@/assets/icons/minimax.svg';
|
||||||
import styles from './SystemPage.module.scss';
|
import styles from './SystemPage.module.scss';
|
||||||
|
|
||||||
|
const MODEL_CATEGORY_ICONS: Record<string, string | { light: string; dark: string }> = {
|
||||||
|
gpt: { light: iconOpenaiLight, dark: iconOpenaiDark },
|
||||||
|
claude: iconClaude,
|
||||||
|
gemini: iconGemini,
|
||||||
|
qwen: iconQwen,
|
||||||
|
kimi: { light: iconKimiLight, dark: iconKimiDark },
|
||||||
|
glm: iconGlm,
|
||||||
|
grok: iconGrok,
|
||||||
|
deepseek: iconDeepseek,
|
||||||
|
minimax: iconMinimax,
|
||||||
|
};
|
||||||
|
|
||||||
export function SystemPage() {
|
export function SystemPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification, showConfirmation } = useNotificationStore();
|
||||||
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
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);
|
||||||
@@ -31,6 +55,13 @@ export function SystemPage() {
|
|||||||
);
|
);
|
||||||
const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]);
|
const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]);
|
||||||
|
|
||||||
|
const getIconForCategory = (categoryId: string): string | null => {
|
||||||
|
const iconEntry = MODEL_CATEGORY_ICONS[categoryId];
|
||||||
|
if (!iconEntry) return null;
|
||||||
|
if (typeof iconEntry === 'string') return iconEntry;
|
||||||
|
return resolvedTheme === 'dark' ? iconEntry.dark : iconEntry.light;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeApiKeyList = (input: any): string[] => {
|
const normalizeApiKeyList = (input: any): string[] => {
|
||||||
if (!Array.isArray(input)) return [];
|
if (!Array.isArray(input)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -106,12 +137,19 @@ export function SystemPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClearLoginStorage = () => {
|
const handleClearLoginStorage = () => {
|
||||||
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
|
showConfirmation({
|
||||||
auth.logout();
|
title: t('system_info.clear_login_title', { defaultValue: 'Clear Login Storage' }),
|
||||||
if (typeof localStorage === 'undefined') return;
|
message: t('system_info.clear_login_confirm'),
|
||||||
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
|
variant: 'danger',
|
||||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
confirmText: t('common.confirm'),
|
||||||
showNotification(t('notification.login_storage_cleared'), 'success');
|
onConfirm: () => {
|
||||||
|
auth.logout();
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
showNotification(t('notification.login_storage_cleared'), 'success');
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -235,26 +273,32 @@ export function SystemPage() {
|
|||||||
<div className="hint">{t('system_info.models_empty')}</div>
|
<div className="hint">{t('system_info.models_empty')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="item-list">
|
<div className="item-list">
|
||||||
{groupedModels.map((group) => (
|
{groupedModels.map((group) => {
|
||||||
<div key={group.id} className="item-row">
|
const iconSrc = getIconForCategory(group.id);
|
||||||
<div className="item-meta">
|
return (
|
||||||
<div className="item-title">{group.label}</div>
|
<div key={group.id} className="item-row">
|
||||||
<div className="item-subtitle">{t('system_info.models_count', { count: group.items.length })}</div>
|
<div className="item-meta">
|
||||||
|
<div className={styles.groupTitle}>
|
||||||
|
{iconSrc && <img src={iconSrc} alt="" className={styles.groupIcon} />}
|
||||||
|
<span className="item-title">{group.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="item-subtitle">{t('system_info.models_count', { count: group.items.length })}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelTags}>
|
||||||
|
{group.items.map((model) => (
|
||||||
|
<span
|
||||||
|
key={`${model.name}-${model.alias ?? 'default'}`}
|
||||||
|
className={styles.modelTag}
|
||||||
|
title={model.description || ''}
|
||||||
|
>
|
||||||
|
<span className={styles.modelName}>{model.name}</span>
|
||||||
|
{model.alias && <span className={styles.modelAlias}>{model.alias}</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modelTags}>
|
);
|
||||||
{group.items.map((model) => (
|
})}
|
||||||
<span
|
|
||||||
key={`${model.name}-${model.alias ?? 'default'}`}
|
|
||||||
className={styles.modelTag}
|
|
||||||
title={model.description || ''}
|
|
||||||
>
|
|
||||||
<span className={styles.modelName}>{model.name}</span>
|
|
||||||
{model.alias && <span className={styles.modelAlias}>{model.alias}</span>}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -456,6 +456,18 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.requestCountCell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requestBreakdown {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
// Pricing Section (80%比例)
|
// Pricing Section (80%比例)
|
||||||
.pricingSection {
|
.pricingSection {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,7 +3,17 @@ import { DashboardPage } from '@/pages/DashboardPage';
|
|||||||
import { SettingsPage } from '@/pages/SettingsPage';
|
import { SettingsPage } from '@/pages/SettingsPage';
|
||||||
import { ApiKeysPage } from '@/pages/ApiKeysPage';
|
import { ApiKeysPage } from '@/pages/ApiKeysPage';
|
||||||
import { AiProvidersPage } from '@/pages/AiProvidersPage';
|
import { AiProvidersPage } from '@/pages/AiProvidersPage';
|
||||||
|
import { AiProvidersAmpcodeEditPage } from '@/pages/AiProvidersAmpcodeEditPage';
|
||||||
|
import { AiProvidersClaudeEditPage } from '@/pages/AiProvidersClaudeEditPage';
|
||||||
|
import { AiProvidersCodexEditPage } from '@/pages/AiProvidersCodexEditPage';
|
||||||
|
import { AiProvidersGeminiEditPage } from '@/pages/AiProvidersGeminiEditPage';
|
||||||
|
import { AiProvidersOpenAIEditLayout } from '@/pages/AiProvidersOpenAIEditLayout';
|
||||||
|
import { AiProvidersOpenAIEditPage } from '@/pages/AiProvidersOpenAIEditPage';
|
||||||
|
import { AiProvidersOpenAIModelsPage } from '@/pages/AiProvidersOpenAIModelsPage';
|
||||||
|
import { AiProvidersVertexEditPage } from '@/pages/AiProvidersVertexEditPage';
|
||||||
import { AuthFilesPage } from '@/pages/AuthFilesPage';
|
import { AuthFilesPage } from '@/pages/AuthFilesPage';
|
||||||
|
import { AuthFilesOAuthExcludedEditPage } from '@/pages/AuthFilesOAuthExcludedEditPage';
|
||||||
|
import { AuthFilesOAuthModelAliasEditPage } from '@/pages/AuthFilesOAuthModelAliasEditPage';
|
||||||
import { OAuthPage } from '@/pages/OAuthPage';
|
import { OAuthPage } from '@/pages/OAuthPage';
|
||||||
import { QuotaPage } from '@/pages/QuotaPage';
|
import { QuotaPage } from '@/pages/QuotaPage';
|
||||||
import { UsagePage } from '@/pages/UsagePage';
|
import { UsagePage } from '@/pages/UsagePage';
|
||||||
@@ -16,8 +26,36 @@ const mainRoutes = [
|
|||||||
{ path: '/dashboard', element: <DashboardPage /> },
|
{ path: '/dashboard', element: <DashboardPage /> },
|
||||||
{ path: '/settings', element: <SettingsPage /> },
|
{ path: '/settings', element: <SettingsPage /> },
|
||||||
{ path: '/api-keys', element: <ApiKeysPage /> },
|
{ path: '/api-keys', element: <ApiKeysPage /> },
|
||||||
|
{ path: '/ai-providers/gemini/new', element: <AiProvidersGeminiEditPage /> },
|
||||||
|
{ path: '/ai-providers/gemini/:index', element: <AiProvidersGeminiEditPage /> },
|
||||||
|
{ path: '/ai-providers/codex/new', element: <AiProvidersCodexEditPage /> },
|
||||||
|
{ path: '/ai-providers/codex/:index', element: <AiProvidersCodexEditPage /> },
|
||||||
|
{ path: '/ai-providers/claude/new', element: <AiProvidersClaudeEditPage /> },
|
||||||
|
{ path: '/ai-providers/claude/:index', element: <AiProvidersClaudeEditPage /> },
|
||||||
|
{ path: '/ai-providers/vertex/new', element: <AiProvidersVertexEditPage /> },
|
||||||
|
{ path: '/ai-providers/vertex/:index', element: <AiProvidersVertexEditPage /> },
|
||||||
|
{
|
||||||
|
path: '/ai-providers/openai/new',
|
||||||
|
element: <AiProvidersOpenAIEditLayout />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <AiProvidersOpenAIEditPage /> },
|
||||||
|
{ path: 'models', element: <AiProvidersOpenAIModelsPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ai-providers/openai/:index',
|
||||||
|
element: <AiProvidersOpenAIEditLayout />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <AiProvidersOpenAIEditPage /> },
|
||||||
|
{ path: 'models', element: <AiProvidersOpenAIModelsPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '/ai-providers/ampcode', element: <AiProvidersAmpcodeEditPage /> },
|
||||||
{ path: '/ai-providers', element: <AiProvidersPage /> },
|
{ path: '/ai-providers', element: <AiProvidersPage /> },
|
||||||
|
{ path: '/ai-providers/*', element: <AiProvidersPage /> },
|
||||||
{ path: '/auth-files', element: <AuthFilesPage /> },
|
{ path: '/auth-files', element: <AuthFilesPage /> },
|
||||||
|
{ path: '/auth-files/oauth-excluded', element: <AuthFilesOAuthExcludedEditPage /> },
|
||||||
|
{ path: '/auth-files/oauth-model-alias', element: <AuthFilesOAuthModelAliasEditPage /> },
|
||||||
{ path: '/oauth', element: <OAuthPage /> },
|
{ path: '/oauth', element: <OAuthPage /> },
|
||||||
{ path: '/quota', element: <QuotaPage /> },
|
{ path: '/quota', element: <QuotaPage /> },
|
||||||
{ path: '/usage', element: <UsagePage /> },
|
{ path: '/usage', element: <UsagePage /> },
|
||||||
|
|||||||
@@ -4,12 +4,22 @@
|
|||||||
|
|
||||||
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';
|
import type { OAuthModelAliasEntry } 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[]> => {
|
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
|
||||||
if (!payload || typeof payload !== 'object') return {};
|
if (!payload || typeof payload !== 'object') return {};
|
||||||
|
|
||||||
const source = (payload as any)['oauth-excluded-models'] ?? (payload as any).items ?? payload;
|
const record = payload as Record<string, unknown>;
|
||||||
|
const source = record['oauth-excluded-models'] ?? record.items ?? payload;
|
||||||
if (!source || typeof source !== 'object') return {};
|
if (!source || typeof source !== 'object') return {};
|
||||||
|
|
||||||
const result: Record<string, string[]> = {};
|
const result: Record<string, string[]> = {};
|
||||||
@@ -43,9 +53,61 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeOauthModelAlias = (payload: unknown): Record<string, OAuthModelAliasEntry[]> => {
|
||||||
|
if (!payload || typeof payload !== 'object') return {};
|
||||||
|
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
const source =
|
||||||
|
record['oauth-model-alias'] ??
|
||||||
|
record.items ??
|
||||||
|
payload;
|
||||||
|
if (!source || typeof source !== 'object') return {};
|
||||||
|
|
||||||
|
const result: Record<string, OAuthModelAliasEntry[]> = {};
|
||||||
|
|
||||||
|
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 aliasEntry = entry as OAuthModelAliasEntry;
|
||||||
|
const dedupeKey = `${aliasEntry.name.toLowerCase()}::${aliasEntry.alias.toLowerCase()}::${aliasEntry.fork ? '1' : '0'}`;
|
||||||
|
if (seen.has(dedupeKey)) return false;
|
||||||
|
seen.add(dedupeKey);
|
||||||
|
return true;
|
||||||
|
}) as OAuthModelAliasEntry[];
|
||||||
|
|
||||||
|
if (normalized.length) {
|
||||||
|
result[key] = normalized;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OAUTH_MODEL_ALIAS_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);
|
||||||
@@ -79,40 +141,45 @@ export const authFilesApi = {
|
|||||||
replaceOauthExcludedModels: (map: Record<string, string[]>) =>
|
replaceOauthExcludedModels: (map: Record<string, string[]>) =>
|
||||||
apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)),
|
apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)),
|
||||||
|
|
||||||
// OAuth 模型映射
|
// OAuth 模型别名
|
||||||
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
|
async getOauthModelAlias(): Promise<Record<string, OAuthModelAliasEntry[]>> {
|
||||||
const data = await apiClient.get('/oauth-model-alias');
|
const data = await apiClient.get(OAUTH_MODEL_ALIAS_ENDPOINT);
|
||||||
const payload = (data && (data['oauth-model-alias'] ?? data.items ?? data)) as any;
|
return normalizeOauthModelAlias(data);
|
||||||
if (!payload || typeof payload !== 'object') return {};
|
|
||||||
const result: Record<string, OAuthModelMappingEntry[]> = {};
|
|
||||||
Object.entries(payload).forEach(([channel, mappings]) => {
|
|
||||||
if (!Array.isArray(mappings)) return;
|
|
||||||
const normalized = mappings
|
|
||||||
.map((item) => {
|
|
||||||
if (!item || typeof item !== 'object') return null;
|
|
||||||
const name = String(item.name ?? item.id ?? item.model ?? '').trim();
|
|
||||||
const alias = String(item.alias ?? '').trim();
|
|
||||||
if (!name || !alias) return null;
|
|
||||||
const fork = item.fork === true;
|
|
||||||
return fork ? { name, alias, fork } : { name, alias };
|
|
||||||
})
|
|
||||||
.filter(Boolean) as OAuthModelMappingEntry[];
|
|
||||||
if (normalized.length) {
|
|
||||||
result[channel] = normalized;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
|
saveOauthModelAlias: async (channel: string, aliases: OAuthModelAliasEntry[]) => {
|
||||||
apiClient.patch('/oauth-model-alias', { channel, aliases: mappings }),
|
const normalizedChannel = String(channel ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const normalizedAliases = normalizeOauthModelAlias({ [normalizedChannel]: aliases })[normalizedChannel] ?? [];
|
||||||
|
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: normalizedAliases });
|
||||||
|
},
|
||||||
|
|
||||||
deleteOauthModelMappings: (channel: string) =>
|
deleteOauthModelAlias: async (channel: string) => {
|
||||||
apiClient.delete(`/oauth-model-alias?channel=${encodeURIComponent(channel)}`),
|
const normalizedChannel = String(channel ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: [] });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status = getStatusCode(err);
|
||||||
|
if (status !== 405) throw err;
|
||||||
|
await apiClient.delete(`${OAUTH_MODEL_ALIAS_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)}`);
|
||||||
return (data && Array.isArray(data['models'])) ? data['models'] : [];
|
return (data && Array.isArray(data['models'])) ? data['models'] : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取指定 channel 的模型定义
|
||||||
|
async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
||||||
|
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
|
||||||
|
if (!normalizedChannel) return [];
|
||||||
|
const data = await apiClient.get(`/model-definitions/${encodeURIComponent(normalizedChannel)}`);
|
||||||
|
return (data && Array.isArray(data['models'])) ? data['models'] : [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch {
|
||||||
set({
|
set({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
connectionStatus: 'error'
|
connectionStatus: 'error'
|
||||||
|
|||||||
@@ -407,98 +407,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: $spacing-lg;
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 520px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: $radius-lg;
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
padding: $spacing-xl;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-language-btn {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-box {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px dashed var(--border-color);
|
|
||||||
border-radius: $radius-md;
|
|
||||||
padding: $spacing-md;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-xs;
|
|
||||||
|
|
||||||
.label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: $spacing-md;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-advanced {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
gap: $spacing-xs;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-box {
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
|
||||||
border-radius: $radius-md;
|
|
||||||
padding: $spacing-sm $spacing-md;
|
|
||||||
color: $error-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ export interface OAuthExcludedModels {
|
|||||||
models: string[];
|
models: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth 模型映射
|
// OAuth 模型别名
|
||||||
export interface OAuthModelMappingEntry {
|
export interface OAuthModelAliasEntry {
|
||||||
name: string;
|
name: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
fork?: boolean;
|
fork?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OAuthModelMappings = Record<string, OAuthModelMappingEntry[]>;
|
export type OAuthModelAlias = Record<string, OAuthModelAliasEntry[]>;
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const MODEL_CATEGORIES = [
|
|||||||
{ id: 'qwen', label: 'Qwen', patterns: [/qwen/i] },
|
{ id: 'qwen', label: 'Qwen', patterns: [/qwen/i] },
|
||||||
{ id: 'glm', label: 'GLM', patterns: [/glm/i, /chatglm/i] },
|
{ id: 'glm', label: 'GLM', patterns: [/glm/i, /chatglm/i] },
|
||||||
{ id: 'grok', label: 'Grok', patterns: [/grok/i] },
|
{ id: 'grok', label: 'Grok', patterns: [/grok/i] },
|
||||||
{ id: 'deepseek', label: 'DeepSeek', patterns: [/deepseek/i] }
|
{ id: 'deepseek', label: 'DeepSeek', patterns: [/deepseek/i] },
|
||||||
|
{ id: 'minimax', label: 'MiniMax', patterns: [/minimax/i, /abab/i] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const matchCategory = (text: string) => {
|
const matchCategory = (text: string) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isDisabledAuthFile(file: AuthFileItem): boolean {
|
||||||
|
const raw = (file as { disabled?: unknown }).disabled;
|
||||||
|
if (typeof raw === 'boolean') return raw;
|
||||||
|
if (typeof raw === 'number') return raw !== 0;
|
||||||
|
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function isIgnoredGeminiCliModel(modelId: string): boolean {
|
export function isIgnoredGeminiCliModel(modelId: string): boolean {
|
||||||
return GEMINI_CLI_IGNORED_MODEL_PREFIXES.some(
|
return GEMINI_CLI_IGNORED_MODEL_PREFIXES.some(
|
||||||
(prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`)
|
(prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`)
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -460,6 +579,8 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
|
|||||||
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
|
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
|
||||||
model: string;
|
model: string;
|
||||||
requests: number;
|
requests: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
tokens: number;
|
tokens: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
}> {
|
}> {
|
||||||
@@ -467,20 +588,39 @@ export function getModelStats(usageData: any, modelPrices: Record<string, ModelP
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelMap = new Map<string, { requests: number; tokens: number; cost: number }>();
|
const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
|
||||||
|
|
||||||
Object.values(usageData.apis as Record<string, any>).forEach(apiData => {
|
Object.values(usageData.apis as Record<string, any>).forEach(apiData => {
|
||||||
const models = apiData?.models || {};
|
const models = apiData?.models || {};
|
||||||
Object.entries(models as Record<string, any>).forEach(([modelName, modelData]) => {
|
Object.entries(models as Record<string, any>).forEach(([modelName, modelData]) => {
|
||||||
const existing = modelMap.get(modelName) || { requests: 0, tokens: 0, cost: 0 };
|
const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
|
||||||
existing.requests += modelData.total_requests || 0;
|
existing.requests += modelData.total_requests || 0;
|
||||||
existing.tokens += modelData.total_tokens || 0;
|
existing.tokens += modelData.total_tokens || 0;
|
||||||
|
|
||||||
|
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
||||||
|
|
||||||
const price = modelPrices[modelName];
|
const price = modelPrices[modelName];
|
||||||
if (price) {
|
|
||||||
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
const hasExplicitCounts =
|
||||||
|
typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number';
|
||||||
|
if (hasExplicitCounts) {
|
||||||
|
existing.successCount += Number(modelData.success_count) || 0;
|
||||||
|
existing.failureCount += Number(modelData.failure_count) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.length > 0 && (!hasExplicitCounts || price)) {
|
||||||
details.forEach((detail: any) => {
|
details.forEach((detail: any) => {
|
||||||
existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
|
if (!hasExplicitCounts) {
|
||||||
|
if (detail?.failed === true) {
|
||||||
|
existing.failureCount += 1;
|
||||||
|
} else {
|
||||||
|
existing.successCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (price) {
|
||||||
|
existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
modelMap.set(modelName, existing);
|
modelMap.set(modelName, existing);
|
||||||
@@ -784,11 +924,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(
|
||||||
@@ -802,7 +942,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,7 +1018,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;
|
||||||
|
|
||||||
|
|||||||