Compare commits

..

33 Commits

Author SHA1 Message Date
Supra4E8C
c6fabcb6bc fix(ui): sync provider quick switch highlight with scroll target 2026-01-31 17:12:59 +08:00
Supra4E8C
460519ed00 feat: add Codex icons and update references in components 2026-01-31 17:06:27 +08:00
Supra4E8C
1053e91fe4 refactor: move modelsToEntries and entriesToModels to modelInputListUtils for better organization 2026-01-31 16:43:46 +08:00
Supra4E8C
b4d08dd0d7 style(auth-files): add centered padding for OAuth edit pages 2026-01-31 16:12:46 +08:00
Supra4E8C
1502e14ca7 feat: add auth type counts and hide disabled quotas 2026-01-31 16:05:48 +08:00
Supra4E8C
7b77520526 fix 2026-01-31 16:00:40 +08:00
Supra4E8C
525541ea0d feat(ui): add model icons and categories, tweak login redirect delay 2026-01-31 15:53:03 +08:00
Supra4E8C
e7a33f8852 feat(login): enhance error handling with localized messages for various connection issues 2026-01-31 15:24:56 +08:00
Supra4E8C
70968bbc4c feat(login): add auto-login splash UI and simplify app startup 2026-01-31 15:21:13 +08:00
Supra4E8C
c93030370e feat(login): redesign login page with split layout 2026-01-31 14:59:00 +08:00
LTbinglingfeng
96307873c5 fix(ui): remove focus outline on logs tabs 2026-01-31 13:18:35 +08:00
LTbinglingfeng
b4eb2d790c fix: remove unused variables and clean up PageTransition component 2026-01-31 12:40:41 +08:00
LTbinglingfeng
3d33958d9e fix 2026-01-31 02:03:17 +08:00
LTbinglingfeng
e4c5f80b02 feat: add configuration loading and error handling to AiProvidersPage 2026-01-31 01:26:49 +08:00
LTbinglingfeng
291f67e2b9 feat: add floating provider navigation sidebar to AI providers page 2026-01-31 01:09:55 +08:00
LTbinglingfeng
3cdcb7a2a3 feat: enhance scroll position management during page transitions 2026-01-31 00:36:13 +08:00
LTbinglingfeng
3d83d0bfe2 style: add shared content width constraint to AI provider edit pages 2026-01-30 23:35:41 +08:00
LTbinglingfeng
129d89cf67 feat: improve error handling and manage component mount state in AiProvidersAmpcodeEditPage 2026-01-30 02:00:35 +08:00
LTbinglingfeng
5c85df486e feat: replace AI provider modals with dedicated edit pages 2026-01-30 01:30:36 +08:00
LTbinglingfeng
34b6d114d3 feat: add toggle for showing raw logs and update log display logic 2026-01-30 00:01:12 +08:00
LTbinglingfeng
94f0038f19 style: update settings card and header for mobile responsiveness 2026-01-29 23:42:07 +08:00
Supra4E8C
aa9c7d89f9 Merge pull request #74 from router-for-me/codex/remove-blue-box-from-log-view
Remove blue focus ring from Logs page tabs
2026-01-29 20:12:26 +08:00
Supra4E8C
9bbf61e1b6 Remove focus outline from logs tabs 2026-01-29 20:09:49 +08:00
Supra4E8C
73198d6929 Merge pull request #73 from router-for-me/codex/fix-base-url-handling-for-openai-interface
Fix OpenAI test endpoint to preserve /v1 base path
2026-01-29 19:58:55 +08:00
Supra4E8C
ab86fcf674 Fix OpenAI test endpoint base URL 2026-01-29 19:57:09 +08:00
LTbinglingfeng
a88078e171 refactor(PageTransition): optimize layer management and transition handling 2026-01-29 03:10:04 +08:00
LTbinglingfeng
8148851a06 feat: add OAuth model alias editing page and routing 2026-01-29 02:21:04 +08:00
LTbinglingfeng
8b3c4189f1 fix(providers): use /chat/completions for OpenAI test requests 2026-01-28 00:17:31 +08:00
hkfires
db5fb0d125 refactor(i18n): rename model alias translation keys 2026-01-27 15:38:07 +08:00
hkfires
9515d88e3c feat(ui): add model checklist for oauth exclusions 2026-01-27 14:56:23 +08:00
hkfires
2bf721974b feat(auth): load model lists via /model-definitions/{channel} instead of per-file
model sources.
2026-01-27 14:27:26 +08:00
hkfires
0c53dcfa80 docs(i18n): rename model mappings to aliases in ui strings 2026-01-27 12:00:42 +08:00
hkfires
034c086e31 feat(usage): show per-model success/failure counts 2026-01-25 11:29:34 +08:00
71 changed files with 5791 additions and 1671 deletions

View File

@@ -1,33 +1,21 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect } from 'react';
import { HashRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage';
import { NotificationContainer } from '@/components/common/NotificationContainer';
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
import { SplashScreen } from '@/components/common/SplashScreen';
import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute';
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
const SPLASH_DURATION = 1500;
const SPLASH_FADE_DURATION = 400;
import { useLanguageStore, useThemeStore } from '@/stores';
function App() {
const initializeTheme = useThemeStore((state) => state.initializeTheme);
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const restoreSession = useAuthStore((state) => state.restoreSession);
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
const [showSplash, setShowSplash] = useState(true);
const [authReady, setAuthReady] = useState(false);
useEffect(() => {
const cleanupTheme = initializeTheme();
void restoreSession().finally(() => {
setAuthReady(true);
});
return cleanupTheme;
}, [initializeTheme, restoreSession]);
}, [initializeTheme]);
useEffect(() => {
setLanguage(language);
@@ -38,27 +26,6 @@ function App() {
document.documentElement.lang = language;
}, [language]);
useEffect(() => {
const timer = setTimeout(() => {
setSplashReadyToFade(true);
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
return () => clearTimeout(timer);
}, []);
const handleSplashFinish = useCallback(() => {
setShowSplash(false);
}, []);
if (showSplash) {
return (
<SplashScreen
fadeOut={splashReadyToFade && authReady}
onFinish={handleSplashFinish}
/>
);
}
return (
<HashRouter>
<NotificationContainer />

View 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

View 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

View 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
View 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

View 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

View 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

View 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

View 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

View File

@@ -14,6 +14,7 @@
gap: $spacing-lg;
min-height: 0;
flex: 1;
background: var(--bg-secondary);
backface-visibility: hidden;
transform: translateZ(0);
@@ -21,11 +22,14 @@
&--exit {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
pointer-events: none;
will-change: transform, opacity;
}
&--stacked {
display: none;
}
}
&--animating &__layer {
@@ -34,6 +38,5 @@
&--animating &__layer:not(.page-transition__layer--exit) {
position: relative;
z-index: 0;
}
}

View File

@@ -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 gsap from 'gsap';
import './PageTransition.scss';
@@ -6,13 +6,20 @@ import './PageTransition.scss';
interface PageTransitionProps {
render: (location: Location) => ReactNode;
getRouteOrder?: (pathname: string) => number | null;
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
const TRANSITION_DURATION = 0.35;
const TRAVEL_DISTANCE = 60;
const VERTICAL_TRANSITION_DURATION = 0.35;
const VERTICAL_TRAVEL_DISTANCE = 60;
const IOS_TRANSITION_DURATION = 0.42;
const IOS_ENTER_FROM_X_PERCENT = 100;
const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
const IOS_EXIT_DIM_OPACITY = 0.72;
type LayerStatus = 'current' | 'exiting';
type LayerStatus = 'current' | 'exiting' | 'stacked';
type Layer = {
key: string;
@@ -22,12 +29,23 @@ type Layer = {
type TransitionDirection = 'forward' | 'backward';
export function PageTransition({ render, getRouteOrder, scrollContainerRef }: PageTransitionProps) {
type TransitionVariant = 'vertical' | 'ios';
export function PageTransition({
render,
getRouteOrder,
getTransitionVariant,
scrollContainerRef,
}: PageTransitionProps) {
const location = useLocation();
const currentLayerRef = useRef<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null);
const transitionDirectionRef = useRef<TransitionDirection>('forward');
const transitionVariantRef = useRef<TransitionVariant>('vertical');
const exitScrollOffsetRef = useRef(0);
const enterScrollOffsetRef = useRef(0);
const scrollPositionsRef = useRef(new Map<string, number>());
const nextLayersRef = useRef<Layer[] | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [layers, setLayers] = useState<Layer[]>(() => [
@@ -37,8 +55,10 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
status: 'current',
},
]);
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
const currentLayer =
layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
const currentLayerKey = currentLayer?.key ?? location.key;
const currentLayerPathname = currentLayer?.location.pathname;
const resolveScrollContainer = useCallback(() => {
if (scrollContainerRef?.current) return scrollContainerRef.current;
@@ -46,12 +66,16 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
return document.scrollingElement as HTMLElement | null;
}, [scrollContainerRef]);
useEffect(() => {
useLayoutEffect(() => {
if (isAnimating) return;
if (location.key === currentLayerKey) return;
if (currentLayerPathname === location.pathname) return;
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) => {
if (!getRouteOrder || !pathname) return null;
const index = getRouteOrder(pathname);
@@ -67,31 +91,92 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
: 'backward';
transitionDirectionRef.current = nextDirection;
transitionVariantRef.current = getTransitionVariant
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
: 'vertical';
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setLayers((prev) => {
const prevCurrent = prev[prev.length - 1];
return [
prevCurrent
? { ...prevCurrent, status: 'exiting' }
: { key: location.key, location, status: 'exiting' },
{ key: location.key, location, status: 'current' },
];
});
setIsAnimating(true);
const shouldSkipExitLayer = (() => {
if (transitionVariantRef.current !== 'ios' || nextDirection !== 'backward') return false;
const normalizeSegments = (pathname: string) =>
pathname
.split('/')
.filter(Boolean)
.filter((segment) => segment.length > 0);
const fromSegments = normalizeSegments(currentLayerPathname ?? '');
const toSegments = normalizeSegments(location.pathname);
if (!fromSegments.length || !toSegments.length) return false;
return fromSegments[0] === toSegments[0] && toSegments.length === 1;
})();
setLayers((prev) => {
const variant = transitionVariantRef.current;
const direction = transitionDirectionRef.current;
const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
const resolvedCurrentIndex =
previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
const previousCurrent = prev[resolvedCurrentIndex];
const previousStack: Layer[] = prev
.filter((_, idx) => idx !== resolvedCurrentIndex)
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
if (!previousCurrent) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
if (variant === 'ios') {
if (direction === 'forward') {
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
return [...previousStack, exitingLayer, nextCurrent];
}
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
if (targetIndex !== -1) {
const targetStack: Layer[] = prev
.slice(0, targetIndex + 1)
.map((layer, idx): Layer => {
const isTarget = idx === targetIndex;
return {
...layer,
location: isTarget ? location : layer.location,
status: isTarget ? 'current' : 'stacked',
};
});
if (shouldSkipExitLayer) {
nextLayersRef.current = targetStack;
return targetStack;
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = targetStack;
return [...targetStack, exitingLayer];
}
}
if (shouldSkipExitLayer) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = [nextCurrent];
return [exitingLayer, nextCurrent];
});
return () => {
cancelled = true;
};
setIsAnimating(true);
}, [
isAnimating,
location,
currentLayerKey,
currentLayerPathname,
getRouteOrder,
getTransitionVariant,
resolveScrollContainer,
]);
@@ -103,59 +188,131 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
const currentLayerEl = currentLayerRef.current;
const exitingLayerEl = exitingLayerRef.current;
const transitionVariant = transitionVariantRef.current;
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
const scrollContainer = resolveScrollContainer();
const scrollOffset = exitScrollOffsetRef.current;
if (scrollContainer && scrollOffset > 0) {
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
const exitScrollOffset = exitScrollOffsetRef.current;
const enterScrollOffset = enterScrollOffsetRef.current;
if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
}
const transitionDirection = transitionDirectionRef.current;
const enterFromY = transitionDirection === 'forward' ? TRAVEL_DISTANCE : -TRAVEL_DISTANCE;
const exitToY = transitionDirection === 'forward' ? -TRAVEL_DISTANCE : TRAVEL_DISTANCE;
const exitBaseY = scrollOffset ? -scrollOffset : 0;
const isForward = transitionDirection === 'forward';
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
const exitBaseY = enterScrollOffset - exitScrollOffset;
const tl = gsap.timeline({
onComplete: () => {
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);
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
},
});
// Exit animation: fade out with slight movement (runs simultaneously)
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { y: exitBaseY });
if (transitionVariant === 'ios') {
const exitToXPercent = isForward
? IOS_EXIT_TO_X_PERCENT_FORWARD
: IOS_EXIT_TO_X_PERCENT_BACKWARD;
const enterFromXPercent = isForward
? IOS_ENTER_FROM_X_PERCENT
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
if (exitingLayerEl) {
gsap.set(exitingLayerEl, {
y: exitBaseY,
xPercent: 0,
opacity: 1,
});
}
gsap.set(currentLayerEl, {
xPercent: enterFromXPercent,
opacity: 1,
});
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
if (topLayerEl) {
gsap.set(topLayerEl, { boxShadow: shadowValue });
}
if (exitingLayerEl) {
tl.to(
exitingLayerEl,
{
xPercent: exitToXPercent,
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
},
0
);
}
tl.to(
exitingLayerEl,
currentLayerEl,
{
y: exitBaseY + exitToY,
opacity: 0,
duration: TRANSITION_DURATION,
ease: 'circ.out',
xPercent: 0,
opacity: 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
},
0
);
}
} else {
// Exit animation: fade out with slight movement (runs simultaneously)
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { y: exitBaseY });
tl.to(
exitingLayerEl,
{
y: exitBaseY + exitToY,
opacity: 0,
duration: VERTICAL_TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
},
0
);
}
// Enter animation: fade in with slight movement (runs simultaneously)
tl.fromTo(
currentLayerEl,
{ y: enterFromY, opacity: 0 },
{
y: 0,
opacity: 1,
duration: TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
onComplete: () => {
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
}
// 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
);
0
);
}
return () => {
tl.kill();
@@ -168,10 +325,20 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
{layers.map((layer) => (
<div
key={layer.key}
className={`page-transition__layer${
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
}`}
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
className={[
'page-transition__layer',
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
]
.filter(Boolean)
.join(' ')}
ref={
layer.status === 'exiting'
? exitingLayerRef
: layer.status === 'current'
? currentLayerRef
: undefined
}
>
{render(layer.location)}
</div>

View 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;
}

View 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>
);
}
);

View File

@@ -375,6 +375,31 @@ export function MainLayout() {
const trimmedPath =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
const aiProvidersIndex = navOrder.indexOf('/ai-providers');
if (aiProvidersIndex !== -1) {
if (normalizedPath === '/ai-providers') return aiProvidersIndex;
if (normalizedPath.startsWith('/ai-providers/')) {
if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1;
if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2;
if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3;
if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4;
if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5;
if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6;
return aiProvidersIndex + 0.05;
}
}
const authFilesIndex = navOrder.indexOf('/auth-files');
if (authFilesIndex !== -1) {
if (normalizedPath === '/auth-files') return authFilesIndex;
if (normalizedPath.startsWith('/auth-files/')) {
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
return authFilesIndex + 0.05;
}
}
const exactIndex = navOrder.indexOf(normalizedPath);
if (exactIndex !== -1) return exactIndex;
const nestedIndex = navOrder.findIndex(
@@ -383,6 +408,24 @@ export function MainLayout() {
return nestedIndex === -1 ? null : nestedIndex;
};
const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
const normalize = (pathname: string) => {
const trimmed =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
return trimmed === '/dashboard' ? '/' : trimmed;
};
const from = normalize(fromPathname);
const to = normalize(toPathname);
const isAuthFiles = (pathname: string) =>
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
const isAiProviders = (pathname: string) =>
pathname === '/ai-providers' || pathname.startsWith('/ai-providers/');
if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
if (isAiProviders(from) && isAiProviders(to)) return 'ios';
return 'vertical';
}, []);
const handleRefreshAll = async () => {
clearCache();
const results = await Promise.allSettled([
@@ -540,6 +583,7 @@ export function MainLayout() {
<PageTransition
render={(location) => <MainRoutes location={location} />}
getRouteOrder={getRouteOrder}
getTransitionVariant={getTransitionVariant}
scrollContainerRef={contentRef}
/>
</main>

View File

@@ -5,34 +5,24 @@ import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import styles from '@/pages/AiProvidersPage.module.scss';
import { useTranslation } from 'react-i18next';
import { AmpcodeModal } from './AmpcodeModal';
interface AmpcodeSectionProps {
config: AmpcodeConfig | null | undefined;
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
isBusy: boolean;
isModalOpen: boolean;
onOpen: () => void;
onCloseModal: () => void;
onBusyChange: (busy: boolean) => void;
onEdit: () => void;
}
export function AmpcodeSection({
config,
loading,
disableControls,
isSaving,
isSwitching,
isBusy,
isModalOpen,
onOpen,
onCloseModal,
onBusyChange,
onEdit,
}: AmpcodeSectionProps) {
const { t } = useTranslation();
const showLoadingPlaceholder = loading && !config;
return (
<>
@@ -46,14 +36,14 @@ export function AmpcodeSection({
extra={
<Button
size="sm"
onClick={onOpen}
disabled={disableControls || isSaving || isBusy || isSwitching}
onClick={onEdit}
disabled={disableControls || loading || isSwitching}
>
{t('common.edit')}
</Button>
}
>
{loading ? (
{showLoadingPlaceholder ? (
<div className="hint">{t('common.loading')}</div>
) : (
<>
@@ -99,13 +89,6 @@ export function AmpcodeSection({
</>
)}
</Card>
<AmpcodeModal
isOpen={isModalOpen}
disableControls={disableControls}
onClose={onCloseModal}
onBusyChange={onBusyChange}
/>
</>
);
}

View File

@@ -4,7 +4,8 @@ import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';

View File

@@ -16,8 +16,6 @@ import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import type { ProviderFormState } from '../types';
import { ClaudeModal } from './ClaudeModal';
interface ClaudeSectionProps {
configs: ProviderKeyConfig[];
@@ -25,16 +23,11 @@ interface ClaudeSectionProps {
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
}
export function ClaudeSection({
@@ -43,20 +36,15 @@ export function ClaudeSection({
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onToggle,
onCloseModal,
onSave,
}: ClaudeSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
@@ -76,8 +64,6 @@ export function ClaudeSection({
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
@@ -200,15 +186,6 @@ export function ClaudeSection({
}}
/>
</Card>
<ClaudeModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import { modelsToEntries } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';

View File

@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconCodexDark from '@/assets/icons/codex_drak.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
@@ -17,8 +17,6 @@ import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import type { ProviderFormState } from '../types';
import { CodexModal } from './CodexModal';
interface CodexSectionProps {
configs: ProviderKeyConfig[];
@@ -26,17 +24,12 @@ interface CodexSectionProps {
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
resolvedTheme: string;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
}
export function CodexSection({
@@ -45,21 +38,16 @@ export function CodexSection({
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
resolvedTheme,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onToggle,
onCloseModal,
onSave,
}: CodexSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
@@ -79,15 +67,13 @@ export function CodexSection({
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
alt=""
className={styles.cardTitleIcon}
/>
@@ -192,15 +178,6 @@ export function CodexSection({
}}
/>
</Card>
<CodexModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -13,11 +13,9 @@ import {
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import type { GeminiFormState } from '../types';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import { GeminiModal } from './GeminiModal';
interface GeminiSectionProps {
configs: GeminiKeyConfig[];
@@ -25,16 +23,11 @@ interface GeminiSectionProps {
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
}
export function GeminiSection({
@@ -43,20 +36,15 @@ export function GeminiSection({
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onToggle,
onCloseModal,
onSave,
}: GeminiSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
@@ -76,8 +64,6 @@ export function GeminiSection({
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
@@ -181,15 +167,6 @@ export function GeminiSection({
}}
/>
</Card>
<GeminiModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -4,7 +4,8 @@ import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import { useNotificationStore } from '@/stores';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';

View File

@@ -17,8 +17,6 @@ import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
import type { OpenAIFormState } from '../types';
import { OpenAIModal } from './OpenAIModal';
interface OpenAISectionProps {
configs: OpenAIProviderConfig[];
@@ -26,16 +24,11 @@ interface OpenAISectionProps {
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
resolvedTheme: string;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onCloseModal: () => void;
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
}
export function OpenAISection({
@@ -44,19 +37,14 @@ export function OpenAISection({
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
resolvedTheme,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onCloseModal,
onSave,
}: OpenAISectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
const actionsDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
@@ -77,8 +65,6 @@ export function OpenAISection({
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
@@ -204,15 +190,6 @@ export function OpenAISection({
}}
/>
</Card>
<OpenAIModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -34,7 +34,7 @@ export function ProviderList<T>({
}: ProviderListProps<T>) {
const { t } = useTranslation();
if (loading) {
if (loading && items.length === 0) {
return <div className="hint">{t('common.loading')}</div>;
}

View 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;
}
}

View 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);
}

View File

@@ -0,0 +1,2 @@
export { ProviderNav } from './ProviderNav';
export type { ProviderId } from './ProviderNav';

View File

@@ -4,7 +4,8 @@ import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import type { ProviderModalProps, VertexFormState } from '../types';

View File

@@ -15,8 +15,6 @@ import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource } from '../utils';
import type { VertexFormState } from '../types';
import { VertexModal } from './VertexModal';
interface VertexSectionProps {
configs: ProviderKeyConfig[];
@@ -24,15 +22,10 @@ interface VertexSectionProps {
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onCloseModal: () => void;
onSave: (data: VertexFormState, index: number | null) => Promise<void>;
}
export function VertexSection({
@@ -41,18 +34,13 @@ export function VertexSection({
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onCloseModal,
onSave,
}: VertexSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
const actionsDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
@@ -72,8 +60,6 @@ export function VertexSection({
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
@@ -168,15 +154,6 @@ export function VertexSection({
}}
/>
</Card>
<VertexModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -6,6 +6,7 @@ export { OpenAISection } from './OpenAISection';
export { VertexSection } from './VertexSection';
export { ProviderList } from './ProviderList';
export { ProviderStatusBar } from './ProviderStatusBar';
export { ProviderNav } from './ProviderNav';
export * from './hooks/useProviderStats';
export * from './types';
export * from './utils';

View File

@@ -55,7 +55,7 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
if (trimmed.endsWith('/chat/completions')) {
return trimmed;
}
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`;
return `${trimmed}/chat/completions`;
};
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致

View File

@@ -45,6 +45,7 @@ import {
getStatusFromError,
isAntigravityFile,
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
isRuntimeOnlyAuthFile
} from '@/utils/quota';
@@ -116,11 +117,6 @@ const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string>
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
};
const isAntigravityUnknownFieldError = (message: string): boolean => {
const normalized = message.toLowerCase();
return normalized.includes('unknown name') && normalized.includes('cannot find field');
};
const fetchAntigravityQuota = async (
file: AuthFileItem,
t: TFunction
@@ -132,7 +128,7 @@ const fetchAntigravityQuota = async (
}
const projectId = await resolveAntigravityProjectId(file);
const requestBodies = [JSON.stringify({ projectId }), JSON.stringify({ project: projectId })];
const requestBody = JSON.stringify({ project: projectId });
let lastError = '';
let lastStatus: number | undefined;
@@ -140,55 +136,46 @@ const fetchAntigravityQuota = async (
let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) {
for (let attempt = 0; attempt < requestBodies.length; attempt++) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: requestBodies[attempt]
});
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: requestBody
});
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
}
if (
result.statusCode === 400 &&
isAntigravityUnknownFieldError(lastError) &&
attempt < requestBodies.length - 1
) {
continue;
}
break;
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
}
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
}
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
}
}
}
@@ -533,7 +520,7 @@ const renderGeminiCliItems = (
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',
filterFn: (file) => isAntigravityFile(file),
filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchAntigravityQuota,
storeSelector: (state) => state.antigravityQuota,
storeSetter: 'setAntigravityQuota',
@@ -558,7 +545,7 @@ export const CODEX_CONFIG: QuotaConfig<
> = {
type: 'codex',
i18nPrefix: 'codex_quota',
filterFn: (file) => isCodexFile(file),
filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchCodexQuota,
storeSelector: (state) => state.codexQuota,
storeSetter: 'setCodexQuota',
@@ -584,7 +571,8 @@ export const CODEX_CONFIG: QuotaConfig<
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
type: 'gemini-cli',
i18nPrefix: 'gemini_cli_quota',
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file),
filterFn: (file) =>
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchGeminiCliQuota,
storeSelector: (state) => state.geminiCliQuota,
storeSetter: 'setGeminiCliQuota',

View File

@@ -1,12 +1,7 @@
import { Fragment } from 'react';
import { Button } from './Button';
import { IconX } from './icons';
import type { ModelAlias } from '@/types';
interface ModelEntry {
name: string;
alias: string;
}
import type { ModelEntry } from './modelInputListUtils';
interface ModelInputListProps {
entries: ModelEntry[];
@@ -17,29 +12,6 @@ interface ModelInputListProps {
aliasPlaceholder?: string;
}
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
if (!Array.isArray(models) || models.length === 0) {
return [{ name: '', alias: '' }];
}
return models.map((m) => ({
name: m.name || '',
alias: m.alias || ''
}));
};
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
return entries
.filter((entry) => entry.name.trim())
.map((entry) => {
const model: ModelAlias = { name: entry.name.trim() };
const alias = entry.alias.trim();
if (alias && alias !== model.name) {
model.alias = alias;
}
return model;
});
};
export function ModelInputList({
entries,
onChange,

View File

@@ -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) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>

View 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;
});
};

View File

@@ -6,6 +6,8 @@ import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat {
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}
@@ -38,7 +40,15 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
{modelStats.map((stat) => (
<tr key={stat.model}>
<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>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>

View 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;
}

View File

@@ -2,6 +2,7 @@
"common": {
"login": "Login",
"logout": "Logout",
"back": "Back",
"cancel": "Cancel",
"confirm": "Confirm",
"save": "Save",
@@ -71,7 +72,15 @@
"submitting": "Connecting...",
"error_title": "Login Failed",
"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": {
"check_connection": "Check Connection",
@@ -249,10 +258,10 @@
"vertex_edit_modal_url_label": "Base URL (Required):",
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
"vertex_models_label": "Model mappings (alias required):",
"vertex_models_label": "Model aliases (alias required):",
"vertex_models_add_btn": "Add Mapping",
"vertex_models_hint": "Each mapping needs both the original model and its alias.",
"vertex_models_count": "Mapping count",
"vertex_models_hint": "Each alias needs both the original model and the alias.",
"vertex_models_count": "Alias count",
"ampcode_title": "Amp CLI Integration (ampcode)",
"ampcode_modal_title": "Configure Ampcode",
"ampcode_upstream_url_label": "Upstream URL",
@@ -316,7 +325,7 @@
"openai_keys_count": "Keys Count",
"openai_models_count": "Models Count",
"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_action": "Run Test",
"openai_test_running": "Sending test request...",
@@ -488,8 +497,10 @@
"provider_placeholder": "e.g. gemini-cli",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"models_label": "Models to exclude",
"models_placeholder": "gpt-4.1-mini\n*-preview",
"models_hint": "Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.",
"models_loading": "Loading models...",
"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",
"saving": "Saving...",
"save_success": "Excluded models updated",
@@ -512,39 +523,35 @@
"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."
},
"oauth_model_mappings": {
"title": "OAuth Model Mappings",
"add": "Add Mapping",
"add_title": "Add provider model mappings",
"oauth_model_alias": {
"title": "OAuth Model Aliases",
"add": "Add Alias",
"add_title": "Add provider model aliases",
"provider_label": "Provider",
"provider_placeholder": "e.g. gemini-cli / vertex",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"model_source_label": "Auth file model source",
"model_source_placeholder": "Select an auth file (for model suggestions)",
"model_source_hint": "Pick an auth file to enable model suggestions for “Source model name”. You can still type custom values.",
"model_source_loading": "Loading models...",
"model_source_unsupported": "The current CPA version does not support fetching model lists (manual input still works).",
"model_source_loaded": "{{count}} models loaded. Use the dropdown in Source model name, or type custom values.",
"mappings_label": "Model mappings",
"mapping_name_placeholder": "Source model name",
"mapping_alias_placeholder": "Alias (required)",
"mapping_fork_label": "Keep original",
"mappings_hint": "Saving an empty list removes that provider. Enable “Keep original” to keep the original name while adding the alias.",
"add_mapping": "Add mapping",
"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.",
"alias_label": "Model aliases",
"alias_name_placeholder": "Source model name",
"alias_placeholder": "Alias (required)",
"alias_fork_label": "Keep original",
"add_alias": "Add alias",
"save": "Save/Update",
"save_success": "Model mappings updated",
"save_failed": "Failed to update model mappings",
"save_success": "Model aliases updated",
"save_failed": "Failed to update model aliases",
"delete": "Delete Provider",
"delete_confirm": "Delete model mappings for {{provider}}?",
"delete_success": "Model mappings removed",
"delete_failed": "Failed to delete model mappings",
"no_models": "No model mappings",
"model_count": "{{count}} mappings",
"list_empty_all": "No model mappings yet—use “Add Mapping” to create one.",
"delete_confirm": "Delete model aliases for {{provider}}?",
"delete_success": "Model aliases removed",
"delete_failed": "Failed to delete model aliases",
"no_models": "No model aliases",
"model_count": "{{count}} aliases",
"list_empty_all": "No model aliases yet—use “Add Alias” to create one.",
"provider_required": "Please enter a provider first",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth model mappings API. Please upgrade to the latest CLI Proxy API (CPA) version."
"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": {
"codex_oauth_title": "Codex OAuth",
@@ -753,6 +760,8 @@
"loaded_lines": "Loaded: {{count}} lines",
"filtered_lines": "Filtered: {{count}} lines",
"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_empty_title": "No matching logs found",
"search_empty_desc": "Try a different keyword or clear the filters.",

View File

@@ -2,6 +2,7 @@
"common": {
"login": "登录",
"logout": "登出",
"back": "返回",
"cancel": "取消",
"confirm": "确认",
"save": "保存",
@@ -71,7 +72,15 @@
"submitting": "连接中...",
"error_title": "登录失败",
"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": {
"check_connection": "检查连接",
@@ -249,10 +258,10 @@
"vertex_edit_modal_url_label": "Base URL (必填):",
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
"vertex_delete_confirm": "确定要删除这个Vertex配置吗",
"vertex_models_label": "模型映射 (别名必填):",
"vertex_models_label": "模型别名 (别名必填):",
"vertex_models_add_btn": "添加映射",
"vertex_models_hint": "每条映射需要填写原模型与别名。",
"vertex_models_count": "映射数量",
"vertex_models_hint": "每条别名需要填写原模型与别名。",
"vertex_models_count": "别名数量",
"ampcode_title": "Amp CLI 集成 (ampcode)",
"ampcode_modal_title": "配置 Ampcode",
"ampcode_upstream_url_label": "Upstream URL",
@@ -316,7 +325,7 @@
"openai_keys_count": "密钥数量",
"openai_models_count": "模型数量",
"openai_test_title": "连通性测试",
"openai_test_hint": "使用当前配置向 /v1/chat/completions 请求,验证是否可用。",
"openai_test_hint": "使用当前配置向 /chat/completions 请求,验证是否可用。",
"openai_test_model_placeholder": "选择或输入要测试的模型",
"openai_test_action": "发送测试",
"openai_test_running": "正在发送测试请求...",
@@ -488,8 +497,10 @@
"provider_placeholder": "例如 gemini-cli / openai",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"models_label": "排除的模型",
"models_placeholder": "gpt-4.1-mini\n*-preview",
"models_hint": "逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。",
"models_loading": "正在加载模型列表...",
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
"no_models_available": "该提供商暂无可用模型列表。",
"save": "保存/更新",
"saving": "正在保存...",
"save_success": "排除列表已更新",
@@ -512,39 +523,35 @@
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"oauth_model_mappings": {
"title": "OAuth 模型映射",
"add": "新增映射",
"add_title": "新增提供商模型映射",
"oauth_model_alias": {
"title": "OAuth 模型别名",
"add": "新增别名",
"add_title": "新增提供商模型别名",
"provider_label": "提供商",
"provider_placeholder": "例如 gemini-cli / vertex",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"model_source_label": "模型来源认证文件",
"model_source_placeholder": "选择认证文件(用于原模型下拉建议)",
"model_source_hint": "选择一个认证文件后,“原模型名称”支持下拉选择;也可手动输入自定义模型。",
"model_source_loading": "正在加载模型列表...",
"model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。",
"model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。",
"mappings_label": "模型映射",
"mapping_name_placeholder": "原模型名称",
"mapping_alias_placeholder": "别名 (必填)",
"mapping_fork_label": "保留原名",
"mappings_hint": "留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名",
"add_mapping": "添加映射",
"model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
"alias_label": "模型别名",
"alias_name_placeholder": "原模型名称",
"alias_placeholder": "别名 (必填)",
"alias_fork_label": "保留原名",
"add_alias": "添加别名",
"save": "保存/更新",
"save_success": "模型映射已更新",
"save_failed": "更新模型映射失败",
"save_success": "模型别名已更新",
"save_failed": "更新模型别名失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的模型映射吗?",
"delete_success": "已删除该提供商的模型映射",
"delete_failed": "删除模型映射失败",
"no_models": "未配置模型映射",
"model_count": "映射 {{count}} 条模型",
"list_empty_all": "暂无任何提供商的模型映射,点击“新增映射”创建。",
"delete_confirm": "确定要删除 {{provider}} 的模型别名吗?",
"delete_success": "已删除该提供商的模型别名",
"delete_failed": "删除模型别名失败",
"no_models": "未配置模型别名",
"model_count": "{{count}} 条别名",
"list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。",
"provider_required": "请先填写提供商名称",
"upgrade_required": "当前 CPA 版本不支持模型映射功能,请升级 CPA 版本",
"upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型映射功能,请升级到最新版本的 CPACLI Proxy API后重试。"
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型别名功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"auth_login": {
"codex_oauth_title": "Codex OAuth",
@@ -753,6 +760,8 @@
"loaded_lines": "已载入 {{count}} 行",
"filtered_lines": "已过滤 {{count}} 行",
"hide_management_logs": "屏蔽 {{prefix}} 日志",
"show_raw_logs": "显示原始日志",
"show_raw_logs_hint": "直接显示原始日志文本,方便多行复制",
"search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志",
"search_empty_desc": "尝试更换关键字或清空筛选条件。",

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
.content {
width: 100%;
max-width: 960px;
margin: 0 auto;
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { entriesToModels } from '@/components/ui/ModelInputList';
import { useNavigate } from 'react-router-dom';
import {
AmpcodeSection,
ClaudeSection,
@@ -8,26 +8,21 @@ import {
GeminiSection,
OpenAISection,
VertexSection,
ProviderNav,
useProviderStats,
type GeminiFormState,
type OpenAIFormState,
type ProviderFormState,
type ProviderModal,
type VertexFormState,
} from '@/components/providers';
import {
parseExcludedModels,
withDisableAllModelsRule,
withoutDisableAllModelsRule,
} from '@/components/providers/utils';
import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import { buildHeaderObject } from '@/utils/headers';
import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { showNotification, showConfirmation } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
@@ -36,20 +31,29 @@ export function AiProvidersPage() {
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
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 [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]);
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]);
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
const [vertexConfigs, setVertexConfigs] = useState<ProviderKeyConfig[]>([]);
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>(
() => config?.geminiApiKeys || []
);
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>(
() => 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 [modal, setModal] = useState<ProviderModal | null>(null);
const [ampcodeBusy, setAmpcodeBusy] = useState(false);
const disableControls = connectionStatus !== 'connected';
const isSwitching = Boolean(configSwitchingKey);
@@ -63,7 +67,10 @@ export function AiProvidersPage() {
};
const loadConfigs = useCallback(async () => {
setLoading(true);
const hasValidCache = isCacheValid();
if (!hasValidCache) {
setLoading(true);
}
setError('');
try {
const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
@@ -99,9 +106,11 @@ export function AiProvidersPage() {
} finally {
setLoading(false);
}
}, [clearCache, fetchConfig, t, updateConfigValue]);
}, [clearCache, fetchConfig, isCacheValid, t, updateConfigValue]);
useEffect(() => {
if (hasMounted.current) return;
hasMounted.current = true;
loadConfigs();
loadKeyStats();
}, [loadConfigs, loadKeyStats]);
@@ -120,62 +129,12 @@ export function AiProvidersPage() {
config?.openaiCompatibility,
]);
const closeModal = () => {
setModal(null);
};
const openGeminiModal = (index: number | null) => {
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 openEditor = useCallback(
(path: string) => {
navigate(path, { state: { fromAiProviders: true } });
},
[navigate]
);
const deleteGemini = async (index: number) => {
const entry = geminiKeys[index];
@@ -293,68 +252,6 @@ 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 source = type === 'codex' ? codexConfigs : claudeConfigs;
const entry = source[index];
@@ -389,55 +286,6 @@ export function AiProvidersPage() {
});
};
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
const baseUrl = trimmedBaseUrl || undefined;
if (!baseUrl) {
showNotification(t('notification.vertex_base_url_required'), 'error');
return;
}
setSaving(true);
try {
const payload: ProviderKeyConfig = {
apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined,
baseUrl,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers),
models: form.modelEntries
.map((entry) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
if (!name || !alias) return null;
return { name, alias };
})
.filter(Boolean) as ProviderKeyConfig['models'],
};
const nextList =
editIndex !== null
? vertexConfigs.map((item, idx) => (idx === editIndex ? payload : item))
: [...vertexConfigs, payload];
await providersApi.saveVertexConfigs(nextList);
setVertexConfigs(nextList);
updateConfigValue('vertex-api-key', nextList);
clearCache('vertex-api-key');
const message =
editIndex !== null
? t('notification.vertex_config_updated')
: t('notification.vertex_config_added');
showNotification(message, 'success');
closeModal();
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const deleteVertex = async (index: number) => {
const entry = vertexConfigs[index];
if (!entry) return;
@@ -462,47 +310,6 @@ export function AiProvidersPage() {
});
};
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
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) => ({
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 entry = openaiProviders[index];
if (!entry) return;
@@ -527,121 +334,99 @@ export function AiProvidersPage() {
});
};
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 (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.content}>
{error && <div className="error-box">{error}</div>}
<GeminiSection
configs={geminiKeys}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'gemini'}
modalIndex={geminiModalIndex}
onAdd={() => openGeminiModal(null)}
onEdit={(index) => openGeminiModal(index)}
onDelete={deleteGemini}
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
onCloseModal={closeModal}
onSave={saveGemini}
/>
<div id="provider-gemini">
<GeminiSection
configs={geminiKeys}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
onAdd={() => openEditor('/ai-providers/gemini/new')}
onEdit={(index) => openEditor(`/ai-providers/gemini/${index}`)}
onDelete={deleteGemini}
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
/>
</div>
<CodexSection
configs={codexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'codex'}
modalIndex={codexModalIndex}
onAdd={() => openProviderModal('codex', null)}
onEdit={(index) => openProviderModal('codex', index)}
onDelete={(index) => void deleteProviderEntry('codex', index)}
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('codex', form, editIndex)}
/>
<div id="provider-codex">
<CodexSection
configs={codexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
onAdd={() => openEditor('/ai-providers/codex/new')}
onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)}
onDelete={(index) => void deleteProviderEntry('codex', index)}
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
/>
</div>
<ClaudeSection
configs={claudeConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'claude'}
modalIndex={claudeModalIndex}
onAdd={() => openProviderModal('claude', null)}
onEdit={(index) => openProviderModal('claude', index)}
onDelete={(index) => void deleteProviderEntry('claude', index)}
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
/>
<div id="provider-claude">
<ClaudeSection
configs={claudeConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
onAdd={() => openEditor('/ai-providers/claude/new')}
onEdit={(index) => openEditor(`/ai-providers/claude/${index}`)}
onDelete={(index) => void deleteProviderEntry('claude', index)}
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
/>
</div>
<VertexSection
configs={vertexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'vertex'}
modalIndex={vertexModalIndex}
onAdd={() => openVertexModal(null)}
onEdit={(index) => openVertexModal(index)}
onDelete={deleteVertex}
onCloseModal={closeModal}
onSave={saveVertex}
/>
<div id="provider-vertex">
<VertexSection
configs={vertexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
onAdd={() => openEditor('/ai-providers/vertex/new')}
onEdit={(index) => openEditor(`/ai-providers/vertex/${index}`)}
onDelete={deleteVertex}
/>
</div>
<AmpcodeSection
config={config?.ampcode}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isBusy={ampcodeBusy}
isModalOpen={modal?.type === 'ampcode'}
onOpen={openAmpcodeModal}
onCloseModal={closeModal}
onBusyChange={setAmpcodeBusy}
/>
<div id="provider-ampcode">
<AmpcodeSection
config={config?.ampcode}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
onEdit={() => openEditor('/ai-providers/ampcode')}
/>
</div>
<OpenAISection
configs={openaiProviders}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'openai'}
modalIndex={openaiModalIndex}
onAdd={() => openOpenaiModal(null)}
onEdit={(index) => openOpenaiModal(index)}
onDelete={deleteOpenai}
onCloseModal={closeModal}
onSave={saveOpenai}
/>
<div id="provider-openai">
<OpenAISection
configs={openaiProviders}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
onAdd={() => openEditor('/ai-providers/openai/new')}
onEdit={(index) => openEditor(`/ai-providers/openai/${index}`)}
onDelete={deleteOpenai}
/>
</div>
</div>
<ProviderNav />
</div>
);
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View File

@@ -79,6 +79,9 @@
}
.filterTag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
@@ -97,6 +100,16 @@
font-weight: 600;
}
.filterTagLabel {
white-space: nowrap;
}
.filterTagCount {
font-size: 12px;
font-weight: 600;
opacity: 0.85;
}
.filterControls {
display: flex;
gap: $spacing-md;
@@ -995,3 +1008,53 @@
border: 1px solid var(--danger-color);
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;
}

View File

@@ -1,12 +1,12 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
@@ -16,12 +16,11 @@ import {
IconDownload,
IconInfo,
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import type { AuthFileItem, OAuthModelMappingEntry } from '@/types';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import {
calculateStatusBarData,
collectUsageDetails,
@@ -31,7 +30,6 @@ import {
type UsageDetail,
} from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import { generateId } from '@/utils/helpers';
import styles from './AuthFilesPage.module.scss';
type ThemeColors = { bg: string; text: string; border?: string };
@@ -83,36 +81,41 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
},
};
const OAUTH_PROVIDER_PRESETS = [
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'claude',
'codex',
'qwen',
'iflow',
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
const MIN_CARD_PAGE_SIZE = 3;
const MAX_CARD_PAGE_SIZE = 30;
const MAX_AUTH_FILE_SIZE = 50 * 1024;
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
interface ExcludedFormState {
provider: string;
modelsText: string;
}
type AuthFilesUiState = {
filter?: string;
search?: string;
page?: number;
pageSize?: number;
};
type OAuthModelMappingFormEntry = OAuthModelMappingEntry & { id: string };
const readAuthFilesUiState = (): AuthFilesUiState | null => {
if (typeof window === 'undefined') return null;
try {
const raw = window.sessionStorage.getItem(AUTH_FILES_UI_STATE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthFilesUiState;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
};
interface ModelMappingsFormState {
provider: string;
mappings: OAuthModelMappingFormEntry[];
}
const writeAuthFilesUiState = (state: AuthFilesUiState) => {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
} catch {
// ignore
}
};
interface PrefixProxyEditorState {
fileName: string;
@@ -125,13 +128,6 @@ interface PrefixProxyEditorState {
prefix: string;
proxyUrl: string;
}
const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
id: generateId(),
name: '',
alias: '',
fork: false,
});
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -197,6 +193,7 @@ export function AuthFilesPage() {
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const navigate = useNavigate();
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -229,26 +226,10 @@ export function AuthFilesPage() {
// OAuth 排除模型相关
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({
provider: '',
modelsText: '',
});
const [savingExcluded, setSavingExcluded] = useState(false);
// OAuth 模型映射相关
const [modelMappings, setModelMappings] = useState<Record<string, OAuthModelMappingEntry[]>>({});
const [modelMappingsError, setModelMappingsError] = useState<'unsupported' | null>(null);
const [mappingModalOpen, setMappingModalOpen] = useState(false);
const [mappingForm, setMappingForm] = useState<ModelMappingsFormState>({
provider: '',
mappings: [buildEmptyMappingEntry()],
});
const [mappingModelsFileName, setMappingModelsFileName] = useState('');
const [mappingModelsList, setMappingModelsList] = useState<AuthFileModelItem[]>([]);
const [mappingModelsLoading, setMappingModelsLoading] = useState(false);
const [mappingModelsError, setMappingModelsError] = useState<'unsupported' | null>(null);
const [savingMappings, setSavingMappings] = useState(false);
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
const [modelAliasError, setModelAliasError] = useState<'unsupported' | null>(null);
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
@@ -262,99 +243,30 @@ export function AuthFilesPage() {
const disableControls = connectionStatus !== 'connected';
useEffect(() => {
setPageSizeInput(String(pageSize));
}, [pageSize]);
const persisted = readAuthFilesUiState();
if (!persisted) return;
const modelSourceFileOptions = useMemo(() => {
const normalizedProvider = normalizeProviderKey(mappingForm.provider);
const matching: string[] = [];
const others: string[] = [];
const seen = new Set<string>();
files.forEach((file) => {
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
const isAistudio = (file.type || '').toLowerCase() === 'aistudio';
const canShowModels = !isRuntimeOnly || isAistudio;
if (!canShowModels) return;
const fileName = String(file.name || '').trim();
if (!fileName) return;
if (seen.has(fileName)) return;
seen.add(fileName);
if (!normalizedProvider) {
matching.push(fileName);
return;
}
const typeKey = normalizeProviderKey(String(file.type || ''));
const providerKey = normalizeProviderKey(String(file.provider || ''));
const isMatch = typeKey === normalizedProvider || providerKey === normalizedProvider;
if (isMatch) {
matching.push(fileName);
} else {
others.push(fileName);
}
});
matching.sort((a, b) => a.localeCompare(b));
others.sort((a, b) => a.localeCompare(b));
return [...matching, ...others];
}, [files, mappingForm.provider]);
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
setFilter(persisted.filter);
}
if (typeof persisted.search === 'string') {
setSearch(persisted.search);
}
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
setPage(Math.max(1, Math.round(persisted.page)));
}
if (typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)) {
setPageSize(clampCardPageSize(persisted.pageSize));
}
}, []);
useEffect(() => {
if (!mappingModalOpen) return;
writeAuthFilesUiState({ filter, search, page, pageSize });
}, [filter, search, page, pageSize]);
const fileName = mappingModelsFileName.trim();
if (!fileName) {
setMappingModelsList([]);
setMappingModelsError(null);
setMappingModelsLoading(false);
return;
}
const cached = modelsCacheRef.current.get(fileName);
if (cached) {
setMappingModelsList(cached);
setMappingModelsError(null);
setMappingModelsLoading(false);
return;
}
let cancelled = false;
setMappingModelsLoading(true);
setMappingModelsError(null);
authFilesApi
.getModelsForAuthFile(fileName)
.then((models) => {
if (cancelled) return;
modelsCacheRef.current.set(fileName, models);
setMappingModelsList(models);
})
.catch((err: unknown) => {
if (cancelled) return;
const errorMessage = err instanceof Error ? err.message : '';
if (
errorMessage.includes('404') ||
errorMessage.includes('not found') ||
errorMessage.includes('Not Found')
) {
setMappingModelsList([]);
setMappingModelsError('unsupported');
return;
}
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
})
.finally(() => {
if (cancelled) return;
setMappingModelsLoading(false);
});
return () => {
cancelled = true;
};
}, [mappingModalOpen, mappingModelsFileName, showNotification, t]);
useEffect(() => {
setPageSizeInput(String(pageSize));
}, [pageSize]);
const prefixProxyUpdatedText = useMemo(() => {
if (!prefixProxyEditor?.json) return prefixProxyEditor?.rawText ?? '';
@@ -489,12 +401,12 @@ export function AuthFilesPage() {
}, [showNotification, t]);
// 加载 OAuth 模型映射
const loadModelMappings = useCallback(async () => {
const loadModelAlias = useCallback(async () => {
try {
const res = await authFilesApi.getOauthModelMappings();
const res = await authFilesApi.getOauthModelAlias();
mappingsUnsupportedRef.current = false;
setModelMappings(res || {});
setModelMappingsError(null);
setModelAlias(res || {});
setModelAliasError(null);
} catch (err: unknown) {
const status =
typeof err === 'object' && err !== null && 'status' in err
@@ -502,11 +414,11 @@ export function AuthFilesPage() {
: undefined;
if (status === 404) {
setModelMappings({});
setModelMappingsError('unsupported');
setModelAlias({});
setModelAliasError('unsupported');
if (!mappingsUnsupportedRef.current) {
mappingsUnsupportedRef.current = true;
showNotification(t('oauth_model_mappings.upgrade_required'), 'warning');
showNotification(t('oauth_model_alias.upgrade_required'), 'warning');
}
return;
}
@@ -515,8 +427,8 @@ export function AuthFilesPage() {
}, [showNotification, t]);
const handleHeaderRefresh = useCallback(async () => {
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelMappings()]);
}, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]);
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelAlias()]);
}, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
useHeaderRefresh(handleHeaderRefresh);
@@ -524,8 +436,8 @@ export function AuthFilesPage() {
loadFiles();
loadKeyStats();
loadExcluded();
loadModelMappings();
}, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]);
loadModelAlias();
}, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
@@ -541,57 +453,14 @@ export function AuthFilesPage() {
return Array.from(types);
}, [files]);
const excludedProviderLookup = useMemo(() => {
const lookup = new Map<string, string>();
Object.keys(excluded).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key && !lookup.has(key)) {
lookup.set(key, provider);
}
});
return lookup;
}, [excluded]);
const mappingProviderLookup = useMemo(() => {
const lookup = new Map<string, string>();
Object.keys(modelMappings).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key && !lookup.has(key)) {
lookup.set(key, provider);
}
});
return lookup;
}, [modelMappings]);
const providerOptions = useMemo(() => {
const extraProviders = new Set<string>();
Object.keys(excluded).forEach((provider) => {
extraProviders.add(provider);
});
Object.keys(modelMappings).forEach((provider) => {
extraProviders.add(provider);
});
const typeCounts = useMemo(() => {
const counts: Record<string, number> = { all: files.length };
files.forEach((file) => {
if (typeof file.type === 'string') {
extraProviders.add(file.type);
}
if (typeof file.provider === 'string') {
extraProviders.add(file.provider);
}
if (!file.type) return;
counts[file.type] = (counts[file.type] || 0) + 1;
});
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, modelMappings]);
return counts;
}, [files]);
// 过滤和搜索
const filtered = useMemo(() => {
@@ -1037,46 +906,16 @@ export function AuthFilesPage() {
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
};
// OAuth 排除相关方法
const openExcludedModal = (provider?: string) => {
const normalizedProvider = normalizeProviderKey(provider || '');
const fallbackProvider =
normalizedProvider || (filter !== 'all' ? normalizeProviderKey(String(filter)) : '');
const lookupKey = fallbackProvider ? excludedProviderLookup.get(fallbackProvider) : undefined;
const models = lookupKey ? excluded[lookupKey] : [];
setExcludedForm({
provider: lookupKey || fallbackProvider,
modelsText: Array.isArray(models) ? models.join('\n') : '',
const openExcludedEditor = (provider?: string) => {
const providerValue = (provider || (filter !== 'all' ? String(filter) : '')).trim();
const params = new URLSearchParams();
if (providerValue) {
params.set('provider', providerValue);
}
const search = params.toString();
navigate(`/auth-files/oauth-excluded${search ? `?${search}` : ''}`, {
state: { fromAuthFiles: true },
});
setExcludedModalOpen(true);
};
const saveExcludedModels = async () => {
const provider = normalizeProviderKey(excludedForm.provider);
if (!provider) {
showNotification(t('oauth_excluded.provider_required'), 'error');
return;
}
const models = excludedForm.modelsText
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
setSavingExcluded(true);
try {
if (models.length) {
await authFilesApi.saveOauthExcludedModels(provider, models);
} else {
await authFilesApi.deleteOauthExcludedEntry(provider);
}
await loadExcluded();
showNotification(t('oauth_excluded.save_success'), 'success');
setExcludedModalOpen(false);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error');
} finally {
setSavingExcluded(false);
}
};
const deleteExcluded = async (provider: string) => {
@@ -1121,137 +960,32 @@ export function AuthFilesPage() {
});
};
// OAuth 模型映射相关方法
const normalizeMappingEntries = (
entries?: OAuthModelMappingEntry[]
): OAuthModelMappingFormEntry[] => {
if (!Array.isArray(entries) || entries.length === 0) {
return [buildEmptyMappingEntry()];
const openModelAliasEditor = (provider?: string) => {
const providerValue = (provider || (filter !== 'all' ? String(filter) : '')).trim();
const params = new URLSearchParams();
if (providerValue) {
params.set('provider', providerValue);
}
return entries.map((entry) => ({
id: generateId(),
name: entry.name ?? '',
alias: entry.alias ?? '',
fork: Boolean(entry.fork),
}));
};
const openMappingsModal = (provider?: string) => {
const normalizedProvider = (provider || '').trim();
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : '');
const lookupKey = fallbackProvider
? mappingProviderLookup.get(fallbackProvider.toLowerCase())
: undefined;
const mappings = lookupKey ? modelMappings[lookupKey] : [];
const providerValue = lookupKey || fallbackProvider;
const normalizedProviderKey = normalizeProviderKey(providerValue);
const defaultModelsFileName = files
.filter((file) => {
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
const isAistudio = (file.type || '').toLowerCase() === 'aistudio';
const canShowModels = !isRuntimeOnly || isAistudio;
if (!canShowModels) return false;
if (!normalizedProviderKey) return false;
const typeKey = normalizeProviderKey(String(file.type || ''));
const providerKey = normalizeProviderKey(String(file.provider || ''));
return typeKey === normalizedProviderKey || providerKey === normalizedProviderKey;
})
.map((file) => file.name)
.sort((a, b) => a.localeCompare(b))[0];
setMappingForm({
provider: providerValue,
mappings: normalizeMappingEntries(mappings),
});
setMappingModelsFileName(defaultModelsFileName || '');
setMappingModelsList([]);
setMappingModelsError(null);
setMappingModalOpen(true);
};
const updateMappingEntry = (
index: number,
field: keyof OAuthModelMappingEntry,
value: string | boolean
) => {
setMappingForm((prev) => ({
...prev,
mappings: prev.mappings.map((entry, idx) =>
idx === index ? { ...entry, [field]: value } : entry
),
}));
};
const addMappingEntry = () => {
setMappingForm((prev) => ({
...prev,
mappings: [...prev.mappings, buildEmptyMappingEntry()],
}));
};
const removeMappingEntry = (index: number) => {
setMappingForm((prev) => {
const next = prev.mappings.filter((_, idx) => idx !== index);
return {
...prev,
mappings: next.length ? next : [buildEmptyMappingEntry()],
};
const search = params.toString();
navigate(`/auth-files/oauth-model-alias${search ? `?${search}` : ''}`, {
state: { fromAuthFiles: true },
});
};
const saveModelMappings = async () => {
const provider = mappingForm.provider.trim();
if (!provider) {
showNotification(t('oauth_model_mappings.provider_required'), 'error');
return;
}
const seen = new Set<string>();
const mappings = mappingForm.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 OAuthModelMappingEntry[];
setSavingMappings(true);
try {
if (mappings.length) {
await authFilesApi.saveOauthModelMappings(provider, mappings);
} else {
await authFilesApi.deleteOauthModelMappings(provider);
}
await loadModelMappings();
showNotification(t('oauth_model_mappings.save_success'), 'success');
setMappingModalOpen(false);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_mappings.save_failed')}: ${errorMessage}`, 'error');
} finally {
setSavingMappings(false);
}
};
const deleteModelMappings = async (provider: string) => {
const deleteModelAlias = async (provider: string) => {
showConfirmation({
title: t('oauth_model_mappings.delete_title', { defaultValue: 'Delete Mappings' }),
message: t('oauth_model_mappings.delete_confirm', { provider }),
title: t('oauth_model_alias.delete_title', { defaultValue: 'Delete Mappings' }),
message: t('oauth_model_alias.delete_confirm', { provider }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await authFilesApi.deleteOauthModelMappings(provider);
await loadModelMappings();
showNotification(t('oauth_model_mappings.delete_success'), 'success');
await authFilesApi.deleteOauthModelAlias(provider);
await loadModelAlias();
showNotification(t('oauth_model_alias.delete_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_mappings.delete_failed')}: ${errorMessage}`, 'error');
showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error');
}
},
});
@@ -1281,7 +1015,8 @@ export function AuthFilesPage() {
setPage(1);
}}
>
{getTypeLabel(type)}
<span className={styles.filterTagLabel}>{getTypeLabel(type)}</span>
<span className={styles.filterTagCount}>{typeCounts[type] ?? 0}</span>
</button>
);
})}
@@ -1615,7 +1350,7 @@ export function AuthFilesPage() {
extra={
<Button
size="sm"
onClick={() => openExcludedModal()}
onClick={() => openExcludedEditor()}
disabled={disableControls || excludedError === 'unsupported'}
>
{t('oauth_excluded.add')}
@@ -1642,7 +1377,7 @@ export function AuthFilesPage() {
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => openExcludedModal(provider)}>
<Button variant="secondary" size="sm" onClick={() => openExcludedEditor(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => deleteExcluded(provider)}>
@@ -1657,42 +1392,46 @@ export function AuthFilesPage() {
{/* OAuth 模型映射卡片 */}
<Card
title={t('oauth_model_mappings.title')}
title={t('oauth_model_alias.title')}
extra={
<Button
size="sm"
onClick={() => openMappingsModal()}
disabled={disableControls || modelMappingsError === 'unsupported'}
onClick={() => openModelAliasEditor()}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_mappings.add')}
{t('oauth_model_alias.add')}
</Button>
}
>
{modelMappingsError === 'unsupported' ? (
{modelAliasError === 'unsupported' ? (
<EmptyState
title={t('oauth_model_mappings.upgrade_required_title')}
description={t('oauth_model_mappings.upgrade_required_desc')}
title={t('oauth_model_alias.upgrade_required_title')}
description={t('oauth_model_alias.upgrade_required_desc')}
/>
) : Object.keys(modelMappings).length === 0 ? (
<EmptyState title={t('oauth_model_mappings.list_empty_all')} />
) : Object.keys(modelAlias).length === 0 ? (
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
) : (
<div className={styles.excludedList}>
{Object.entries(modelMappings).map(([provider, mappings]) => (
{Object.entries(modelAlias).map(([provider, mappings]) => (
<div key={provider} className={styles.excludedItem}>
<div className={styles.excludedInfo}>
<div className={styles.excludedProvider}>{provider}</div>
<div className={styles.excludedModels}>
{mappings?.length
? t('oauth_model_mappings.model_count', { count: mappings.length })
: t('oauth_model_mappings.no_models')}
? t('oauth_model_alias.model_count', { count: mappings.length })
: t('oauth_model_alias.no_models')}
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => openMappingsModal(provider)}>
<Button
variant="secondary"
size="sm"
onClick={() => openModelAliasEditor(provider)}
>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => deleteModelMappings(provider)}>
{t('oauth_model_mappings.delete')}
<Button variant="danger" size="sm" onClick={() => deleteModelAlias(provider)}>
{t('oauth_model_alias.delete')}
</Button>
</div>
</div>
@@ -1887,202 +1626,6 @@ export function AuthFilesPage() {
)}
</Modal>
{/* OAuth 排除弹窗 */}
<Modal
open={excludedModalOpen}
onClose={() => setExcludedModalOpen(false)}
title={t('oauth_excluded.add_title')}
footer={
<>
<Button
variant="secondary"
onClick={() => setExcludedModalOpen(false)}
disabled={savingExcluded}
>
{t('common.cancel')}
</Button>
<Button onClick={saveExcludedModels} loading={savingExcluded}>
{t('oauth_excluded.save')}
</Button>
</>
}
>
<div className={styles.providerField}>
<AutocompleteInput
id="oauth-excluded-provider"
label={t('oauth_excluded.provider_label')}
hint={t('oauth_excluded.provider_hint')}
placeholder={t('oauth_excluded.provider_placeholder')}
value={excludedForm.provider}
onChange={(val) => setExcludedForm((prev) => ({ ...prev, provider: val }))}
options={providerOptions}
/>
{providerOptions.length > 0 && (
<div className={styles.providerTagList}>
{providerOptions.map((provider) => {
const isActive =
excludedForm.provider.trim().toLowerCase() === provider.toLowerCase();
return (
<button
key={provider}
type="button"
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
onClick={() => setExcludedForm((prev) => ({ ...prev, provider }))}
disabled={savingExcluded}
>
{getTypeLabel(provider)}
</button>
);
})}
</div>
)}
</div>
<div className={styles.formGroup}>
<label>{t('oauth_excluded.models_label')}</label>
<textarea
className={styles.textarea}
rows={4}
placeholder={t('oauth_excluded.models_placeholder')}
value={excludedForm.modelsText}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))}
/>
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
</div>
</Modal>
{/* OAuth 模型映射弹窗 */}
<Modal
open={mappingModalOpen}
onClose={() => setMappingModalOpen(false)}
title={t('oauth_model_mappings.add_title')}
footer={
<>
<Button
variant="secondary"
onClick={() => setMappingModalOpen(false)}
disabled={savingMappings}
>
{t('common.cancel')}
</Button>
<Button onClick={saveModelMappings} loading={savingMappings}>
{t('oauth_model_mappings.save')}
</Button>
</>
}
>
<div className={styles.providerField}>
<AutocompleteInput
id="oauth-model-alias-provider"
label={t('oauth_model_mappings.provider_label')}
hint={t('oauth_model_mappings.provider_hint')}
placeholder={t('oauth_model_mappings.provider_placeholder')}
value={mappingForm.provider}
onChange={(val) => setMappingForm((prev) => ({ ...prev, provider: val }))}
options={providerOptions}
/>
{providerOptions.length > 0 && (
<div className={styles.providerTagList}>
{providerOptions.map((provider) => {
const isActive =
mappingForm.provider.trim().toLowerCase() === provider.toLowerCase();
return (
<button
key={provider}
type="button"
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
onClick={() => setMappingForm((prev) => ({ ...prev, provider }))}
disabled={savingMappings}
>
{getTypeLabel(provider)}
</button>
);
})}
</div>
)}
</div>
<div className={styles.providerField}>
<AutocompleteInput
id="oauth-model-mapping-model-source"
label={t('oauth_model_mappings.model_source_label')}
hint={
mappingModelsLoading
? t('oauth_model_mappings.model_source_loading')
: mappingModelsError === 'unsupported'
? t('oauth_model_mappings.model_source_unsupported')
: !mappingModelsFileName.trim()
? t('oauth_model_mappings.model_source_hint')
: t('oauth_model_mappings.model_source_loaded', {
count: mappingModelsList.length,
})
}
placeholder={t('oauth_model_mappings.model_source_placeholder')}
value={mappingModelsFileName}
onChange={(val) => setMappingModelsFileName(val)}
disabled={savingMappings}
options={modelSourceFileOptions}
/>
</div>
<div className={styles.formGroup}>
<label>{t('oauth_model_mappings.mappings_label')}</label>
<div className="header-input-list">
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
(entry, index) => (
<div key={entry.id} className={styles.mappingRow}>
<AutocompleteInput
wrapperStyle={{ flex: 1, marginBottom: 0 }}
placeholder={t('oauth_model_mappings.mapping_name_placeholder')}
value={entry.name}
onChange={(val) => updateMappingEntry(index, 'name', val)}
disabled={savingMappings}
options={mappingModelsList.map((m) => ({
value: m.id,
label: m.display_name && m.display_name !== m.id ? m.display_name : undefined,
}))}
/>
<span className={styles.mappingSeparator}></span>
<input
className="input"
placeholder={t('oauth_model_mappings.mapping_alias_placeholder')}
value={entry.alias}
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
disabled={savingMappings}
style={{ flex: 1 }}
/>
<div className={styles.mappingFork}>
<ToggleSwitch
label={t('oauth_model_mappings.mapping_fork_label')}
labelPosition="left"
checked={Boolean(entry.fork)}
onChange={(value) => updateMappingEntry(index, 'fork', value)}
disabled={savingMappings}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeMappingEntry(index)}
disabled={savingMappings || mappingForm.mappings.length <= 1}
title={t('common.delete')}
aria-label={t('common.delete')}
>
<IconX size={14} />
</Button>
</div>
)
)}
<Button
variant="secondary"
size="sm"
onClick={addMappingEntry}
disabled={savingMappings}
className="align-start"
>
{t('oauth_model_mappings.add_mapping')}
</Button>
</div>
<div className={styles.hint}>{t('oauth_model_mappings.mappings_hint')}</div>
</div>
</Modal>
</div>
);
}

View 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;
}

View File

@@ -6,6 +6,52 @@ import { Input } from '@/components/ui/Input';
import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
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() {
const { t } = useTranslation();
@@ -28,6 +74,7 @@ export function LoginPage() {
const [rememberPassword, setRememberPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [autoLoading, setAutoLoading] = useState(true);
const [autoLoginSuccess, setAutoLoginSuccess] = useState(false);
const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
@@ -37,20 +84,30 @@ export function LoginPage() {
const init = async () => {
try {
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);
setManagementKey(storedKey || '');
setRememberPassword(storedRememberPassword || Boolean(storedKey));
}
} finally {
setAutoLoading(false);
if (!autoLoginSuccess) {
setAutoLoading(false);
}
}
};
init();
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSubmit = async () => {
const handleSubmit = useCallback(async () => {
if (!managementKey.trim()) {
setError(t('login.error_required'));
return;
@@ -68,13 +125,13 @@ export function LoginPage() {
showNotification(t('common.connected_status'), 'success');
navigate('/', { replace: true });
} catch (err: any) {
const message = err?.message || t('login.error_invalid');
const message = getLocalizedErrorMessage(err, t);
setError(message);
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
} finally {
setLoading(false);
}
};
}, [apiBase, detectedBase, login, managementKey, navigate, rememberPassword, showNotification, t]);
const handleSubmitKeyDown = useCallback(
(event: React.KeyboardEvent) => {
@@ -86,107 +143,134 @@ export function LoginPage() {
[loading, handleSubmit]
);
if (isAuthenticated) {
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
// 显示启动动画(自动登录中或自动登录成功)
const showSplash = autoLoading || autoLoginSuccess;
return (
<div className="login-page">
<div className="login-card">
<div className="login-header">
<div className="login-title-row">
<div className="title">{t('title.login')}</div>
<Button
type="button"
variant="ghost"
size="sm"
className="login-language-btn"
onClick={toggleLanguage}
title={t('language.switch')}
aria-label={t('language.switch')}
>
{nextLanguageLabel}
</Button>
<div className={styles.container}>
{/* 左侧品牌展示区 */}
<div className={styles.brandPanel}>
<div className={styles.brandContent}>
<span className={styles.brandWord}>CLI</span>
<span className={styles.brandWord}>PROXY</span>
<span className={styles.brandWord}>API</span>
</div>
</div>
{/* 右侧功能交互区 */}
<div className={styles.formPanel}>
{showSplash ? (
/* 启动动画 */
<div className={styles.splashContent}>
<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 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="value">{apiBase || detectedBase}</div>
<div className="hint">{t('login.connection_auto_hint')}</div>
</div>
{/* 登录表单卡片 */}
<div className={styles.loginCard}>
<div className={styles.loginHeader}>
<div className={styles.titleRow}>
<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">
<input
id="custom-connection-toggle"
type="checkbox"
checked={showCustomBase}
onChange={(e) => setShowCustomBase(e.target.checked)}
/>
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
</div>
<div className={styles.connectionBox}>
<div className={styles.label}>{t('login.connection_current')}</div>
<div className={styles.value}>{apiBase || detectedBase}</div>
<div className={styles.hint}>{t('login.connection_auto_hint')}</div>
</div>
{showCustomBase && (
<Input
label={t('login.custom_connection_label')}
placeholder={t('login.custom_connection_placeholder')}
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
hint={t('login.custom_connection_hint')}
/>
)}
<div className={styles.toggleAdvanced}>
<input
id="custom-connection-toggle"
type="checkbox"
checked={showCustomBase}
onChange={(e) => setShowCustomBase(e.target.checked)}
/>
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
</div>
<Input
autoFocus
label={t('login.management_key_label')}
placeholder={t('login.management_key_placeholder')}
type={showKey ? 'text' : 'password'}
value={managementKey}
onChange={(e) => setManagementKey(e.target.value)}
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>
}
/>
{showCustomBase && (
<Input
label={t('login.custom_connection_label')}
placeholder={t('login.custom_connection_placeholder')}
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
hint={t('login.custom_connection_hint')}
/>
)}
<div className="toggle-advanced">
<input
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>
<Input
autoFocus
label={t('login.management_key_label')}
placeholder={t('login.management_key_placeholder')}
type={showKey ? 'text' : 'password'}
value={managementKey}
onChange={(e) => setManagementKey(e.target.value)}
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>
}
/>
<Button fullWidth onClick={handleSubmit} loading={loading}>
{loading ? t('login.submitting') : t('login.submit_button')}
</Button>
<div className={styles.toggleAdvanced}>
<input
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 && (
<div className="connection-box">
<div className="label">{t('auto_login.title')}</div>
<div className="value">{t('auto_login.message')}</div>
{error && <div className={styles.errorBox}>{error}</div>}
</div>
</div>
)}
</div>

View File

@@ -44,6 +44,12 @@
&:hover {
color: var(--text-primary);
}
&:focus,
&:focus-visible {
outline: none;
box-shadow: none;
}
}
.tabActive {
@@ -262,6 +268,30 @@
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 {
display: grid;
grid-template-columns: 170px 1fr;

View File

@@ -9,6 +9,7 @@ import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import {
IconDownload,
IconCode,
IconEyeOff,
IconRefreshCw,
IconSearch,
@@ -383,6 +384,7 @@ export function LogsPage() {
const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(true);
const [showRawLogs, setShowRawLogs] = useState(false);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const [errorLogsError, setErrorLogsError] = useState('');
@@ -632,10 +634,12 @@ export function LogsPage() {
return { filteredLines: working, removedCount: removed };
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
const parsedVisibleLines = useMemo(
() => filteredLines.map((line) => parseLogLine(line)),
[filteredLines]
);
const parsedVisibleLines = useMemo(() => {
if (showRawLogs) return [];
return filteredLines.map((line) => parseLogLine(line));
}, [filteredLines, showRawLogs]);
const rawVisibleText = useMemo(() => filteredLines.join('\n'), [filteredLines]);
const canLoadMore = !isSearching && logState.visibleFrom > 0;
@@ -817,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}>
<Button
variant="secondary"
@@ -870,14 +890,14 @@ export function LogsPage() {
{loading ? (
<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}>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<div className={styles.loadMoreStats}>
<span>
{t('logs.loaded_lines', { count: parsedVisibleLines.length })}
{t('logs.loaded_lines', { count: filteredLines.length })}
</span>
{removedCount > 0 && (
<span className={styles.loadMoreCount}>
@@ -890,103 +910,109 @@ export function LogsPage() {
</div>
</div>
)}
<div className={styles.logList}>
{parsedVisibleLines.map((line, index) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal')
rowClassNames.push(styles.rowError);
return (
<div
key={`${logState.visibleFrom + index}-${line.raw}`}
className={rowClassNames.join(' ')}
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
onPointerDown={(event) => startLongPress(event, line.requestId)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
onPointerMove={handleLongPressMove}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal'
? styles.levelError
: '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : '',
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
{showRawLogs ? (
<pre className={styles.rawLog} spellCheck={false}>
{rawVisibleText}
</pre>
) : (
<div className={styles.logList}>
{parsedVisibleLines.map((line, index) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal')
rowClassNames.push(styles.rowError);
return (
<div
key={`${logState.visibleFrom + index}-${line.raw}`}
className={rowClassNames.join(' ')}
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
onPointerDown={(event) => startLongPress(event, line.requestId)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
onPointerMove={handleLongPressMove}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal'
? styles.levelError
: '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : '',
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{line.requestId && (
<span
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={line.requestId}
>
{line.requestId}
</span>
)}
{line.requestId && (
<span
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={line.requestId}
>
{line.requestId}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError,
].join(' ')}
>
{line.statusCode}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError,
].join(' ')}
>
{line.statusCode}
</span>
)}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</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>
) : logState.buffer.length > 0 ? (
<EmptyState

View File

@@ -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 { Card } from '@/components/ui/Card';
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 { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import styles from './OAuthPage.module.scss';
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 iconAntigravity from '@/assets/icons/antigravity.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 } }[] = [
{ 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: '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 },
@@ -85,11 +85,16 @@ export function OAuthPage() {
const timers = useRef<Record<string, number>>({});
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
const clearTimers = useCallback(() => {
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
timers.current = {};
}, []);
useEffect(() => {
return () => {
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
clearTimers();
};
}, []);
}, [clearTimers]);
const updateProviderState = (provider: OAuthProvider, next: Partial<ProviderState>) => {
setStates((prev) => ({

View File

@@ -97,7 +97,7 @@ export function SettingsPage() {
setRoutingStrategy(config.routingStrategy);
}
}
}, [config?.proxyUrl, config?.requestRetry, config?.logsMaxTotalSizeMb, config?.routingStrategy]);
}, [config]);
const setPendingFlag = (key: PendingKey, value: boolean) => {
setPending((prev) => ({ ...prev, [key]: value }));

View File

@@ -90,6 +90,18 @@
gap: $spacing-sm;
}
.groupTitle {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.groupIcon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.modelTag {
display: inline-flex;
align-items: center;

View File

@@ -3,15 +3,39 @@ import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
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 { classifyModels } from '@/utils/models';
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';
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() {
const { t, i18n } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const auth = useAuthStore();
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
@@ -31,6 +55,13 @@ export function SystemPage() {
);
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[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
@@ -242,26 +273,32 @@ export function SystemPage() {
<div className="hint">{t('system_info.models_empty')}</div>
) : (
<div className="item-list">
{groupedModels.map((group) => (
<div key={group.id} className="item-row">
<div className="item-meta">
<div className="item-title">{group.label}</div>
<div className="item-subtitle">{t('system_info.models_count', { count: group.items.length })}</div>
{groupedModels.map((group) => {
const iconSrc = getIconForCategory(group.id);
return (
<div key={group.id} className="item-row">
<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 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>
)}
</Card>

View File

@@ -456,6 +456,18 @@
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%比例)
.pricingSection {
display: flex;

View File

@@ -3,7 +3,17 @@ import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage';
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 { AuthFilesOAuthExcludedEditPage } from '@/pages/AuthFilesOAuthExcludedEditPage';
import { AuthFilesOAuthModelAliasEditPage } from '@/pages/AuthFilesOAuthModelAliasEditPage';
import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { UsagePage } from '@/pages/UsagePage';
@@ -16,8 +26,36 @@ const mainRoutes = [
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ 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: '/auth-files', element: <AuthFilesPage /> },
{ path: '/auth-files/oauth-excluded', element: <AuthFilesOAuthExcludedEditPage /> },
{ path: '/auth-files/oauth-model-alias', element: <AuthFilesOAuthModelAliasEditPage /> },
{ path: '/oauth', element: <OAuthPage /> },
{ path: '/quota', element: <QuotaPage /> },
{ path: '/usage', element: <UsagePage /> },

View File

@@ -4,7 +4,7 @@
import { apiClient } from './client';
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 };
@@ -53,18 +53,17 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
return result;
};
const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthModelMappingEntry[]> => {
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-mappings'] ??
record['oauth-model-alias'] ??
record.items ??
payload;
if (!source || typeof source !== 'object') return {};
const result: Record<string, OAuthModelMappingEntry[]> = {};
const result: Record<string, OAuthModelAliasEntry[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([channel, mappings]) => {
const key = String(channel ?? '')
@@ -86,12 +85,12 @@ const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthMode
})
.filter(Boolean)
.filter((entry) => {
const mapping = entry as OAuthModelMappingEntry;
const dedupeKey = `${mapping.name.toLowerCase()}::${mapping.alias.toLowerCase()}::${mapping.fork ? '1' : '0'}`;
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 OAuthModelMappingEntry[];
}) as OAuthModelAliasEntry[];
if (normalized.length) {
result[key] = normalized;
@@ -101,8 +100,7 @@ const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthMode
return result;
};
const OAUTH_MODEL_MAPPINGS_ENDPOINT = '/oauth-model-mappings';
const OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT = '/oauth-model-alias';
const OAUTH_MODEL_ALIAS_ENDPOINT = '/oauth-model-alias';
export const authFilesApi = {
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
@@ -143,63 +141,31 @@ export const authFilesApi = {
replaceOauthExcludedModels: (map: Record<string, string[]>) =>
apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)),
// OAuth 模型映射
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
try {
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_ENDPOINT);
return normalizeOauthModelMappings(data);
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT);
return normalizeOauthModelMappings(data);
}
// OAuth 模型别名
async getOauthModelAlias(): Promise<Record<string, OAuthModelAliasEntry[]>> {
const data = await apiClient.get(OAUTH_MODEL_ALIAS_ENDPOINT);
return normalizeOauthModelAlias(data);
},
saveOauthModelMappings: async (channel: string, mappings: OAuthModelMappingEntry[]) => {
saveOauthModelAlias: async (channel: string, aliases: OAuthModelAliasEntry[]) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const normalizedMappings = normalizeOauthModelMappings({ [normalizedChannel]: mappings })[normalizedChannel] ?? [];
try {
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: normalizedMappings });
return;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: normalizedMappings });
}
const normalizedAliases = normalizeOauthModelAlias({ [normalizedChannel]: aliases })[normalizedChannel] ?? [];
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: normalizedAliases });
},
deleteOauthModelMappings: async (channel: string) => {
deleteOauthModelAlias: async (channel: string) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const deleteViaPatch = async () => {
try {
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: [] });
return true;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: [] });
return true;
}
};
try {
await deleteViaPatch();
return;
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: [] });
} catch (err: unknown) {
const status = getStatusCode(err);
if (status !== 405) throw err;
}
try {
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
return;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
await apiClient.delete(`${OAUTH_MODEL_ALIAS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
}
},
@@ -207,5 +173,13 @@ export const authFilesApi = {
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)}`);
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'] : [];
}
};

View File

@@ -163,7 +163,7 @@ export const useAuthStore = create<AuthStoreState>()(
});
return true;
} catch (error) {
} catch {
set({
isAuthenticated: false,
connectionStatus: 'error'

View File

@@ -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 {
display: grid;

View File

@@ -34,11 +34,11 @@ export interface OAuthExcludedModels {
models: string[];
}
// OAuth 模型映射
export interface OAuthModelMappingEntry {
// OAuth 模型别名
export interface OAuthModelAliasEntry {
name: string;
alias: string;
fork?: boolean;
}
export type OAuthModelMappings = Record<string, OAuthModelMappingEntry[]>;
export type OAuthModelAlias = Record<string, OAuthModelAliasEntry[]>;

View File

@@ -17,7 +17,8 @@ const MODEL_CATEGORIES = [
{ id: 'qwen', label: 'Qwen', patterns: [/qwen/i] },
{ id: 'glm', label: 'GLM', patterns: [/glm/i, /chatglm/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) => {

View File

@@ -29,6 +29,14 @@ export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
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 {
return GEMINI_CLI_IGNORED_MODEL_PREFIXES.some(
(prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`)

View File

@@ -579,6 +579,8 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}> {
@@ -586,20 +588,39 @@ export function getModelStats(usageData: any, modelPrices: Record<string, ModelP
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 => {
const models = apiData?.models || {};
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.tokens += modelData.total_tokens || 0;
const details = Array.isArray(modelData.details) ? modelData.details : [];
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) => {
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);