Compare commits

..

85 Commits
v1.2.24 ... dev

Author SHA1 Message Date
Supra4E8C
b90239d39c Merge pull request #95 from router-for-me/revert-93-claude-quota
Revert "feat(ui): added claude quota display"
2026-02-07 14:01:16 +08:00
Supra4E8C
f8d66917fd Revert "feat(ui): added claude quota display" 2026-02-07 14:00:50 +08:00
LTbinglingfeng
36bfd0fa6a chore(i18n): align en/ru OAuth disablement wording with updated zh-CN copy 2026-02-07 12:43:56 +08:00
LTbinglingfeng
709ce4c8dd feat(config): warn restart required when commercial mode changes 2026-02-07 12:31:17 +08:00
LTbinglingfeng
525b152a76 fix(config): preserve mobile scroll after API key modal close and add one-click key copy 2026-02-07 12:22:16 +08:00
LTbinglingfeng
e053854544 feat(system): redesign system info page and move request-log controls from layout footer 2026-02-07 12:03:40 +08:00
LTbinglingfeng
0b54b6de64 fix(auth-files): add Kimi to OAuth quick-fill provider tags 2026-02-07 10:57:52 +08:00
hkfires
0c8686cefa feat(i18n): update OAuth exclusion terminology to "禁用" for clarity 2026-02-07 07:52:12 +08:00
LTbinglingfeng
385117d01a fix(i18n): switch language via popover menu and complete Russian Kimi translations 2026-02-07 01:13:11 +08:00
LTbinglingfeng
700bff1d03 fix(i18n): harden language switching and enforce language list consistency 2026-02-07 00:43:36 +08:00
Supra4E8C
680b24026c Merge pull request #91 from unchase/feat/ru-localization
Feat: Add Russian localization
2026-02-07 00:24:35 +08:00
LTbinglingfeng
2da4099d0b feat(oauth): add kimi provider support 2026-02-06 23:35:47 +08:00
LTbinglingfeng
8acef95e5a add .gitignore 2026-02-06 22:43:50 +08:00
LTbinglingfeng
c892d939c7 feat(quota-ui): normalize Gemini vertex quota groups and streamline auth card refresh UX 2026-02-06 22:28:01 +08:00
Chebotov Nickolay
50ab96c3ed feat: add language dropdown 2026-02-06 15:20:25 +03:00
Chebotov Nickolay
0bb8090686 fix: address language review feedback 2026-02-06 15:08:53 +03:00
LTbinglingfeng
cade2647d6 feat(quota): add normalization for Gemini CLI model IDs and update quota groups 2026-02-06 19:11:57 +08:00
LTbinglingfeng
3661530f5f fix(ui): make payload visual editor responsive on mobile 2026-02-06 18:38:37 +08:00
LTbinglingfeng
f833f0dfd2 fix(config): align visual editor with backend config semantics 2026-02-06 18:14:13 +08:00
Chebotov Nickolay
d5ccef8b24 chore: restore package lock 2026-02-06 12:29:23 +03:00
Chebotov Nickolay
ad6a3bd732 feat: expand Russian localization 2026-02-06 12:26:46 +03:00
Chebotov Nickolay
ad1387d076 feat(i18n): add Russian locale and enable 'ru' language; translate core keys to Russian 2026-02-06 11:26:32 +03:00
hkfires
26fa1ea98e feat(logs): optimize log loading with auto-prepend functionality 2026-02-06 12:09:25 +08:00
hkfires
e568e4a2b5 feat(ui): show empty state for payload rules editor 2026-02-06 11:29:21 +08:00
hkfires
4a0386472d feat(ui): show success/failure in API usage stats 2026-02-06 11:23:50 +08:00
LTbinglingfeng
b9001c27c5 fix 2026-02-06 03:56:57 +08:00
LTbinglingfeng
e6e62e2992 feat(i18n): add internationalization support for visual config editor 2026-02-06 03:34:38 +08:00
LTbinglingfeng
f53d333198 fix(ui): center Config Panel action bar and move ProviderNav to bottom 2026-02-06 03:13:13 +08:00
LTbinglingfeng
adcf0b6582 refactor(nav): move Config Panel and remove Settings/API Keys pages 2026-02-06 02:47:37 +08:00
LTbinglingfeng
11c2498be6 feat: add visual configuration editor and YAML handling
- Implemented a new hook `useVisualConfig` for managing visual configuration state and YAML parsing.
- Added types for visual configuration in `visualConfig.ts`.
- Enhanced `ConfigPage` to support switching between visual and source editors.
- Introduced floating action buttons for save and reload actions.
- Updated translations for tab labels in English and Chinese.
- Styled the configuration page with new tab and floating action button styles.
2026-02-06 02:15:40 +08:00
LTbinglingfeng
7d41afb5f1 feat(auth-files): add quota management features and enhance UI layout 2026-02-05 02:22:23 +08:00
LTbinglingfeng
d4bc0bc622 fix(model-alias): restore diagram drag-and-drop and add touch tap-to-link fallback 2026-02-05 01:26:01 +08:00
LTbinglingfeng
5241d52b14 fix(model-alias): improve diagram mobile layout and refresh reliability 2026-02-05 00:55:03 +08:00
LTbinglingfeng
9887a78889 fix(layout): add scroll container for improved responsiveness and adjust container styles 2026-02-05 00:23:18 +08:00
LTbinglingfeng
759e369d42 fix(drag-and-drop): add data transfer for source and alias during drag events
fix(i18n): update view mode labels in Chinese localization
fix(auth-files): set fork to true in empty mapping entry and improve error handling in save/delete operations
2026-02-05 00:02:05 +08:00
Supra4E8C
db487dc49d Merge pull request #81 from thanhtunguet/main
Add diagram-based view mode for model alias mapping
2026-02-04 23:08:05 +08:00
LTbinglingfeng
a94a9791bc fix(ui): scope ToggleSwitch styles with CSS Modules to prevent label text collapsing into a vertical column 2026-02-04 22:00:57 +08:00
hkfires
473cece09e fix(quota): classify codex windows by 5-hour and weekly limits 2026-02-04 09:31:09 +08:00
Phạm Thanh Tùng
aebe95d221 Merge branch 'router-for-me:main' into main 2026-02-04 01:50:23 +07:00
LTbinglingfeng
08e8fe2edd fix(ProviderNav): remove unused provider icons and clean up code 2026-02-04 01:42:32 +08:00
LTbinglingfeng
bca7082bb0 feat(ProviderNav): enhance indicator functionality and improve mobile navigation experience 2026-02-03 00:06:21 +08:00
Phạm Thanh Tùng
d9272d6d0e Merge branch 'router-for-me:main' into main 2026-02-01 17:20:32 +07:00
LTbinglingfeng
f8c4a434ed feat(ProviderNav): update mobile layout to use bottom floating navigation and improve scroll handling 2026-02-01 02:24:05 +08:00
LTbinglingfeng
237cca5680 feat(PageTransition): enhance layer management to prevent blank flashes during transitions 2026-02-01 02:18:14 +08:00
LTbinglingfeng
f0735dbc1e feat(store): add OpenAI edit draft state management 2026-02-01 00:48:28 +08:00
thanhtunguet
0d40eecbe7 refactor(ModelMappingDiagram): restructure component to utilize new modular columns and improve type definitions 2026-01-31 21:47:49 +07:00
thanhtunguet
ce47d6d985 style(ModelMappingDiagram): remove unused aliasItem class from SCSS file 2026-01-31 21:34:00 +07:00
thanhtunguet
01a69ff32b refactor(AuthFilesPage): optimize provider list generation using useMemo for better performance 2026-01-31 21:33:26 +07:00
thanhtunguet
fd1174e010 fix: update event type handling in ModelMappingDiagram for improved type safety 2026-01-31 21:07:06 +07:00
thanhtunguet
3e55d601a1 feat: enhance OAuth model alias management with new UI components and localization updates 2026-01-31 21:04:34 +07:00
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
LTbinglingfeng
76e9eb4aa0 feat(auth-files): add disabled state styling for file cards 2026-01-25 00:01:15 +08:00
LTbinglingfeng
f22d392b21 fix 2026-01-24 18:04:59 +08:00
113 changed files with 13609 additions and 3198 deletions

1
.gitignore vendored
View File

@@ -18,6 +18,7 @@ node_modules
dist
dist-ssr
*.local
skills
# Editor directories and files
settings.local.json

29
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1",
"yaml": "^2.8.2",
"zustand": "^5.0.9"
},
"devDependencies": {
@@ -1242,6 +1243,18 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@openai/codex": {
"version": "0.98.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.98.0.tgz",
"integrity": "sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -4241,6 +4254,22 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"dev": true,

View File

@@ -23,6 +23,7 @@
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1",
"yaml": "^2.8.2",
"zustand": "^5.0.9"
},
"devDependencies": {

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

@@ -43,7 +43,11 @@ export function ConfirmationModal() {
return (
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
<p style={{ margin: '1rem 0' }}>{message}</p>
{typeof message === 'string' ? (
<p style={{ margin: '1rem 0' }}>{message}</p>
) : (
<div style={{ margin: '1rem 0' }}>{message}</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
{cancelText || t('common.cancel')}

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,19 +22,33 @@
&--exit {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
pointer-events: none;
will-change: transform, opacity;
}
&--stacked {
display: none;
// Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
// Older stacked layers remain `display: none` for performance.
&.page-transition__layer--stacked-keep {
display: flex;
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
}
}
}
&--animating &__layer {
will-change: transform, opacity;
}
&--animating &__layer:not(.page-transition__layer--exit) {
&--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
position: relative;
z-index: 0;
}
}

View File

@@ -1,4 +1,12 @@
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
ReactNode,
createContext,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import './PageTransition.scss';
@@ -6,13 +14,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 +37,33 @@ type Layer = {
type TransitionDirection = 'forward' | 'backward';
export function PageTransition({ render, getRouteOrder, scrollContainerRef }: PageTransitionProps) {
type TransitionVariant = 'vertical' | 'ios';
type PageTransitionLayerContextValue = {
status: LayerStatus;
};
const PageTransitionLayerContext = createContext<PageTransitionLayerContextValue | null>(null);
export function usePageTransitionLayer() {
return useContext(PageTransitionLayerContext);
}
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 +73,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 +84,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);
@@ -59,40 +101,110 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
};
const fromIndex = resolveOrderIndex(currentLayerPathname);
const toIndex = resolveOrderIndex(location.pathname);
const nextDirection: TransitionDirection =
const nextVariant: TransitionVariant = getTransitionVariant
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
: 'vertical';
let nextDirection: TransitionDirection =
fromIndex === null || toIndex === null || fromIndex === toIndex
? 'forward'
: toIndex > fromIndex
? 'forward'
: 'backward';
// When using iOS-style stacking, history POP within the same "section" can have equal route order.
// In that case, prefer treating navigation to an existing layer as a backward (pop) transition.
if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) {
nextDirection = 'backward';
}
transitionDirectionRef.current = nextDirection;
transitionVariantRef.current = nextVariant;
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 (nextVariant !== 'ios' || nextDirection !== 'backward') return false;
const normalizeSegments = (pathname: string) =>
pathname
.split('/')
.filter(Boolean)
.filter((segment) => segment.length > 0);
const fromSegments = normalizeSegments(currentLayerPathname ?? '');
const toSegments = normalizeSegments(location.pathname);
if (!fromSegments.length || !toSegments.length) return false;
return fromSegments[0] === toSegments[0] && toSegments.length === 1;
})();
setLayers((prev) => {
const variant = transitionVariantRef.current;
const direction = transitionDirectionRef.current;
const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
const resolvedCurrentIndex =
previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
const previousCurrent = prev[resolvedCurrentIndex];
const previousStack: Layer[] = prev
.filter((_, idx) => idx !== resolvedCurrentIndex)
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
if (!previousCurrent) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
if (variant === 'ios') {
if (direction === 'forward') {
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
return [...previousStack, exitingLayer, nextCurrent];
}
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
if (targetIndex !== -1) {
const targetStack: Layer[] = prev
.slice(0, targetIndex + 1)
.map((layer, idx): Layer => {
const isTarget = idx === targetIndex;
return {
...layer,
location: isTarget ? location : layer.location,
status: isTarget ? 'current' : 'stacked',
};
});
if (shouldSkipExitLayer) {
nextLayersRef.current = targetStack;
return targetStack;
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = targetStack;
return [...targetStack, exitingLayer];
}
}
if (shouldSkipExitLayer) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = [nextCurrent];
return [exitingLayer, nextCurrent];
});
return () => {
cancelled = true;
};
setIsAnimating(true);
}, [
isAnimating,
location,
currentLayerKey,
currentLayerPathname,
getRouteOrder,
getTransitionVariant,
resolveScrollContainer,
layers,
]);
// Run GSAP animation when animating starts
@@ -103,59 +215,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();
@@ -165,17 +349,45 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
return (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
{layers.map((layer) => (
{(() => {
const currentIndex = layers.findIndex((layer) => layer.status === 'current');
const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex;
const keepStackedIndex = layers
.slice(0, resolvedCurrentIndex)
.map((layer, index) => ({ layer, index }))
.reverse()
.find(({ layer }) => layer.status === 'stacked')?.index;
return layers.map((layer, index) => {
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
return (
<div
key={layer.key}
className={`page-transition__layer${
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
}`}
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
className={[
'page-transition__layer',
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden={layer.status !== 'current'}
inert={layer.status !== 'current'}
ref={
layer.status === 'exiting'
? exitingLayerRef
: layer.status === 'current'
? currentLayerRef
: undefined
}
>
{render(layer.location)}
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
</div>
))}
);
});
})()}
</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

@@ -0,0 +1,22 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { Card } from '@/components/ui/Card';
interface ConfigSectionProps {
title: ReactNode;
description?: ReactNode;
className?: string;
}
export function ConfigSection({ title, description, className, children }: PropsWithChildren<ConfigSectionProps>) {
return (
<Card title={title} className={className}>
{description && (
<p style={{ margin: '-4px 0 16px 0', color: 'var(--text-secondary)', fontSize: 13 }}>
{description}
</p>
)}
{children}
</Card>
);
}

View File

@@ -0,0 +1,37 @@
.payloadRuleModelRow {
display: grid;
grid-template-columns: 1fr 160px auto;
gap: 8px;
align-items: center;
}
.payloadRuleModelRowProtocolFirst {
grid-template-columns: 160px 1fr auto;
}
.payloadRuleParamRow {
display: grid;
grid-template-columns: 1fr 140px 1fr auto;
gap: 8px;
align-items: center;
}
.payloadFilterModelRow {
display: grid;
grid-template-columns: 1fr 160px auto;
gap: 8px;
align-items: center;
}
@media (max-width: 900px) {
.payloadRuleModelRow,
.payloadRuleModelRowProtocolFirst,
.payloadRuleParamRow,
.payloadFilterModelRow {
grid-template-columns: minmax(0, 1fr);
}
.payloadRowActionButton {
width: 100%;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,6 @@ import {
import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { PageTransition } from '@/components/common/PageTransition';
import { MainRoutes } from '@/router/MainRoutes';
import {
@@ -19,12 +17,10 @@ import {
IconChartLine,
IconFileText,
IconInfo,
IconKey,
IconLayoutDashboard,
IconScrollText,
IconSettings,
IconShield,
IconSlidersHorizontal,
IconTimer,
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
@@ -35,13 +31,13 @@ import {
useNotificationStore,
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
settings: <IconSlidersHorizontal size={18} />,
apiKeys: <IconKey size={18} />,
aiProviders: <IconBot size={18} />,
authFiles: <IconFileText size={18} />,
oauth: <IconShield size={18} />,
@@ -176,44 +172,36 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
};
export function MainLayout() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion);
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme);
const cycleTheme = useThemeStore((state) => state.cycleTheme);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const languageMenuRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullBrandName = 'CLI Proxy API Management Center';
const abbrBrandName = t('title.abbr');
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
const isLogsPage = location.pathname.startsWith('/logs');
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
@@ -245,6 +233,38 @@ export function MainLayout() {
};
}, []);
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
useLayoutEffect(() => {
const updateContentCenter = () => {
const el = contentRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
document.documentElement.style.setProperty('--content-center-x', `${centerX}px`);
};
updateContentCenter();
const resizeObserver =
typeof ResizeObserver !== 'undefined' && contentRef.current
? new ResizeObserver(updateContentCenter)
: null;
if (resizeObserver && contentRef.current) {
resizeObserver.observe(contentRef.current);
}
window.addEventListener('resize', updateContentCenter);
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
window.removeEventListener('resize', updateContentCenter);
document.documentElement.style.removeProperty('--content-center-x');
};
}, []);
// 5秒后自动收起品牌名称
useEffect(() => {
brandCollapseTimer.current = setTimeout(() => {
@@ -259,18 +279,30 @@ export function MainLayout() {
}, []);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
if (!languageMenuOpen) {
return;
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
const handlePointerDown = (event: MouseEvent) => {
if (!languageMenuRef.current?.contains(event.target as Node)) {
setLanguageMenuOpen(false);
}
};
}, []);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setLanguageMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleEscape);
};
}, [languageMenuOpen]);
const handleBrandClick = useCallback(() => {
if (!brandExpanded) {
@@ -285,59 +317,20 @@ export function MainLayout() {
}
}, [brandExpanded]);
const openRequestLogModal = useCallback(() => {
setRequestLogTouched(false);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
const toggleLanguageMenu = useCallback(() => {
setLanguageMenuOpen((prev) => !prev);
}, []);
const handleVersionTap = useCallback(() => {
versionTapCount.current += 1;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
}, 1500);
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
versionTapTimer.current = null;
const handleLanguageSelect = useCallback(
(nextLanguage: string) => {
if (!isSupportedLanguage(nextLanguage)) {
return;
}
openRequestLogModal();
}
}, [openRequestLogModal]);
const handleRequestLogSave = async () => {
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: any) {
updateConfigValue('request-log', previous);
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
} finally {
setRequestLogSaving(false);
}
};
setLanguage(nextLanguage);
setLanguageMenuOpen(false);
},
[setLanguage]
);
useEffect(() => {
fetchConfig().catch(() => {
@@ -357,14 +350,12 @@ export function MainLayout() {
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),
@@ -375,6 +366,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 +399,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([
@@ -498,9 +532,36 @@ export function MainLayout() {
>
{headerIcons.update}
</Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
{headerIcons.language}
</Button>
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
<Button
variant="ghost"
size="sm"
onClick={toggleLanguageMenu}
title={t('language.switch')}
aria-label={t('language.switch')}
aria-haspopup="menu"
aria-expanded={languageMenuOpen}
>
{headerIcons.language}
</Button>
{languageMenuOpen && (
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
{LANGUAGE_ORDER.map((lang) => (
<button
key={lang}
type="button"
className={`language-menu-option ${language === lang ? 'active' : ''}`}
onClick={() => handleLanguageSelect(lang)}
role="menuitemradio"
aria-checked={language === lang}
>
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
{language === lang ? <span className="language-menu-check"></span> : null}
</button>
))}
</div>
)}
</div>
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto'
? headerIcons.autoTheme
@@ -540,60 +601,12 @@ export function MainLayout() {
<PageTransition
render={(location) => <MainRoutes location={location} />}
getRouteOrder={getRouteOrder}
getTransitionVariant={getTransitionVariant}
scrollContainerRef={contentRef}
/>
</main>
<footer className="footer">
<span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span>
<span className="footer-version" onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span>
<span>
{t('footer.build_date')}:{' '}
{serverBuildDate
? new Date(serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown')}
</span>
</footer>
</div>
</div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,359 @@
@use '../../styles/variables' as *;
.scrollContainer {
width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
}
.tapHint {
position: sticky;
left: 0;
z-index: 3;
font-size: 12px;
color: var(--text-secondary);
padding: 0 4px;
margin-bottom: 8px;
}
.container {
display: inline-flex;
position: relative;
min-width: 100%;
min-height: 300px;
justify-content: space-between;
padding: 20px 0;
user-select: none;
@media (max-width: 768px) {
// Give mobile extra horizontal room to reduce line overlap; users can swipe to scroll.
min-width: max(100%, 960px);
padding: 12px 0;
}
}
// SVG layer for connection lines (behind columns so links are visible)
.connections {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
overflow: visible;
path {
fill: none;
stroke-width: 2;
}
}
.column {
display: flex;
flex-direction: column;
gap: 12px;
z-index: 2;
flex: 0 0 auto;
&.providers {
align-items: flex-end;
min-width: 140px;
}
&.sources {
align-items: flex-start;
min-width: 200px;
}
&.aliases {
align-items: flex-start;
min-width: 200px;
}
}
.columnHeader {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 8px;
padding: 0 4px;
}
.item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 280px;
position: relative;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&:hover {
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
z-index: 10;
}
&.dropTarget {
background-color: var(--bg-secondary);
border-color: var(--primary-color);
border-width: 2px;
}
&.selected {
border-color: var(--primary-color);
background-color: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
}
// Mindmap-style provider branch (root node)
.providerItem {
border-left: 3px solid transparent;
padding-left: 8px;
display: flex;
align-items: center;
gap: 8px;
.providerLabel {
font-weight: 600;
font-size: 13px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collapseBtn {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: var(--bg-secondary);
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary);
transition: background-color 0.15s, color 0.15s;
&:hover {
background: var(--border-color);
color: var(--text-primary);
}
}
.chevronDown,
.chevronRight {
display: inline-block;
width: 0;
height: 0;
border-style: solid;
}
.chevronDown {
border-width: 5px 4px 0 4px;
border-color: currentColor transparent transparent transparent;
}
.chevronRight {
border-width: 4px 0 4px 5px;
border-color: transparent transparent transparent currentColor;
}
}
.providerGroup {
display: flex;
align-items: center;
justify-content: flex-end;
width: 100%;
}
.sourceItem,
.aliasItem {
cursor: grab;
&:active {
cursor: grabbing;
}
&.dragging {
opacity: 0.5;
border-style: dashed;
}
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
position: absolute;
top: 50%;
margin-top: -3px;
flex-shrink: 0;
&.dotLeft {
left: -3px;
background: var(--text-tertiary);
}
}
.sourceItem .dot {
right: -3px;
}
.providerBadge {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-secondary);
margin-right: 8px;
font-weight: 500;
}
.itemName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemCount {
font-size: 11px;
color: var(--text-tertiary);
margin-left: 8px;
background: var(--bg-secondary);
padding: 1px 6px;
border-radius: 10px;
}
.contextMenu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9999;
min-width: 120px;
overflow: hidden;
padding: 4px 0;
.menuItem {
padding: 8px 12px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.1s;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background-color: var(--bg-secondary);
}
&.danger {
color: var(--error-color);
&:hover {
background-color: var(--bg-error-light);
}
}
}
.menuDivider {
height: 1px;
margin: 4px 0;
background: var(--border-color);
padding: 0;
cursor: default;
pointer-events: none;
}
}
.settingsEmpty {
color: var(--text-tertiary);
font-size: 13px;
text-align: center;
padding: $spacing-lg 0;
}
.settingsList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.settingsRow {
display: grid;
grid-template-columns: minmax(200px, 1fr) auto;
gap: $spacing-md;
align-items: center;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
@media (max-width: 768px) {
grid-template-columns: 1fr;
align-items: flex-start;
}
}
.settingsNames {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: 13px;
color: var(--text-primary);
min-width: 0;
}
.settingsSource,
.settingsAlias {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.settingsArrow {
color: var(--text-tertiary);
}
.settingsActions {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.settingsLabel {
font-size: 12px;
color: var(--text-secondary);
}
.settingsDelete {
border: 0;
background: transparent;
color: var(--error-color);
padding: 6px;
border-radius: 6px;
cursor: pointer;
&:hover {
background: var(--bg-error-light);
}
}

View File

@@ -0,0 +1,659 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { OAuthModelAliasEntry } from '@/types';
import { useThemeStore } from '@/stores';
import { AliasColumn, ProviderColumn, SourceColumn } from './ModelMappingDiagramColumns';
import { DiagramContextMenu } from './ModelMappingDiagramContextMenu';
import {
AddAliasModal,
RenameAliasModal,
SettingsAliasModal,
SettingsSourceModal
} from './ModelMappingDiagramModals';
import type {
AliasNode,
AuthFileModelItem,
ContextMenuState,
DiagramLine,
SourceNode
} from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
export interface ModelMappingDiagramProps {
modelAlias: Record<string, OAuthModelAliasEntry[]>;
allProviderModels?: Record<string, AuthFileModelItem[]>;
onUpdate?: (provider: string, sourceModel: string, newAlias: string) => void;
onDeleteLink?: (provider: string, sourceModel: string, alias: string) => void;
onToggleFork?: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onRenameAlias?: (oldAlias: string, newAlias: string) => void;
onDeleteAlias?: (alias: string) => void;
onEditProvider?: (provider: string) => void;
onDeleteProvider?: (provider: string) => void;
className?: string;
}
const PROVIDER_COLORS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
];
function getProviderColor(provider: string): string {
const hash = provider.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
return PROVIDER_COLORS[hash % PROVIDER_COLORS.length];
}
export interface ModelMappingDiagramRef {
collapseAll: () => void;
refreshLayout: () => void;
}
export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappingDiagramProps>(function ModelMappingDiagram({
modelAlias,
allProviderModels = {},
onUpdate,
onDeleteLink,
onToggleFork,
onRenameAlias,
onDeleteAlias,
onEditProvider,
onDeleteProvider,
className
}, ref) {
const { t } = useTranslation();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
const enableTapLinking = useMemo(() => {
if (typeof window === 'undefined' || typeof window.matchMedia === 'undefined') return false;
return (
window.matchMedia('(any-pointer: coarse)').matches &&
!window.matchMedia('(any-pointer: fine)').matches
);
}, []);
const containerRef = useRef<HTMLDivElement>(null);
const [lines, setLines] = useState<DiagramLine[]>([]);
const [draggedSource, setDraggedSource] = useState<SourceNode | null>(null);
const [draggedAlias, setDraggedAlias] = useState<string | null>(null);
const [dropTargetAlias, setDropTargetAlias] = useState<string | null>(null);
const [dropTargetSource, setDropTargetSource] = useState<string | null>(null);
const [tapSourceId, setTapSourceId] = useState<string | null>(null);
const [tapAlias, setTapAlias] = useState<string | null>(null);
const [extraAliases, setExtraAliases] = useState<string[]>([]);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(new Set());
const [providerGroupHeights, setProviderGroupHeights] = useState<Record<string, number>>({});
const [renameState, setRenameState] = useState<{ oldAlias: string } | null>(null);
const [renameValue, setRenameValue] = useState('');
const [renameError, setRenameError] = useState('');
const [addAliasOpen, setAddAliasOpen] = useState(false);
const [addAliasValue, setAddAliasValue] = useState('');
const [addAliasError, setAddAliasError] = useState('');
const [settingsAlias, setSettingsAlias] = useState<string | null>(null);
const [settingsSourceId, setSettingsSourceId] = useState<string | null>(null);
// Parse data: each source model (provider+name) and each alias is distinct by id; 1 source -> many aliases.
const { aliasNodes, providerNodes } = useMemo(() => {
const sourceMap = new Map<
string,
{ provider: string; name: string; aliases: Map<string, boolean> }
>();
const aliasSet = new Set<string>();
// 1. Existing mappings: group by (provider, name), each source has a set of aliases
Object.entries(modelAlias).forEach(([provider, mappings]) => {
(mappings ?? []).forEach((m) => {
const name = (m?.name || '').trim();
const alias = (m?.alias || '').trim();
if (!name || !alias) return;
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
if (!sourceMap.has(pk)) {
sourceMap.set(pk, { provider, name, aliases: new Map() });
}
sourceMap.get(pk)!.aliases.set(alias, m?.fork === true);
aliasSet.add(alias);
});
});
// 2. Unmapped models from allProviderModels (no mapping yet)
Object.entries(allProviderModels).forEach(([provider, models]) => {
(models ?? []).forEach((m) => {
const name = (m.id || '').trim();
if (!name) return;
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
if (sourceMap.has(pk)) {
// Already in sourceMap from mappings; keep provider from mapping for correct grouping.
return;
}
sourceMap.set(pk, { provider, name, aliases: new Map() });
});
});
// 3. Source nodes: distinct by id = provider::name
const sources: SourceNode[] = Array.from(sourceMap.entries())
.map(([id, v]) => ({
id,
provider: v.provider,
name: v.name,
aliases: Array.from(v.aliases.entries()).map(([alias, fork]) => ({ alias, fork }))
}))
.sort((a, b) => {
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
return a.name.localeCompare(b.name);
});
// 4. Extra aliases (no mapping yet)
extraAliases.forEach((alias) => aliasSet.add(alias));
// 5. Alias nodes: distinct by id = alias; sources = SourceNodes that have this alias in their aliases
const aliasNodesList: AliasNode[] = Array.from(aliasSet)
.map((alias) => ({
id: alias,
alias,
sources: sources.filter((s) => s.aliases.some((entry) => entry.alias === alias))
}))
.sort((a, b) => {
if (b.sources.length !== a.sources.length) return b.sources.length - a.sources.length;
return a.alias.localeCompare(b.alias);
});
// 6. Group sources by provider
const providerMap = new Map<string, SourceNode[]>();
sources.forEach((s) => {
if (!providerMap.has(s.provider)) providerMap.set(s.provider, []);
providerMap.get(s.provider)!.push(s);
});
const providerNodesList = Array.from(providerMap.entries())
.map(([provider, providerSources]) => ({ provider, sources: providerSources }))
.sort((a, b) => a.provider.localeCompare(b.provider));
return { aliasNodes: aliasNodesList, providerNodes: providerNodesList };
}, [modelAlias, allProviderModels, extraAliases]);
// Track element positions
const providerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const sourceRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const aliasRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const toggleProviderCollapse = (provider: string) => {
setCollapsedProviders((prev) => {
const next = new Set(prev);
if (next.has(provider)) next.delete(provider);
else next.add(provider);
return next;
});
};
// Calculate lines: provider→source, source→alias (when expanded); midpoint + linkData for source→alias
const updateLines = useCallback(() => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const newLines: { path: string; color: string; id: string }[] = [];
const nextProviderGroupHeights: Record<string, number> = {};
const bezier = (
x1: number, y1: number,
x2: number, y2: number
) => {
const cpx1 = x1 + (x2 - x1) * 0.5;
const cpx2 = x2 - (x2 - x1) * 0.5;
return `M ${x1} ${y1} C ${cpx1} ${y1}, ${cpx2} ${y2}, ${x2} ${y2}`;
};
providerNodes.forEach(({ provider, sources }) => {
const collapsed = collapsedProviders.has(provider);
if (collapsed) return;
if (sources.length > 0) {
const firstEl = sourceRefs.current.get(sources[0].id);
const lastEl = sourceRefs.current.get(sources[sources.length - 1].id);
if (firstEl && lastEl) {
const height = Math.max(0, Math.round(lastEl.getBoundingClientRect().bottom - firstEl.getBoundingClientRect().top));
if (height > 0) nextProviderGroupHeights[provider] = height;
}
}
const providerEl = providerRefs.current.get(provider);
if (!providerEl) return;
const providerRect = providerEl.getBoundingClientRect();
const px = providerRect.right - containerRect.left;
const py = providerRect.top + providerRect.height / 2 - containerRect.top;
const color = getProviderColor(provider);
// Provider → Source (branch link, no dot)
sources.forEach((source) => {
const sourceEl = sourceRefs.current.get(source.id);
if (!sourceEl) return;
const sourceRect = sourceEl.getBoundingClientRect();
const sx = sourceRect.left - containerRect.left;
const sy = sourceRect.top + sourceRect.height / 2 - containerRect.top;
newLines.push({
id: `provider-${provider}-source-${source.id}`,
path: bezier(px, py, sx, sy),
color
});
});
// Source → Alias: one line per alias
sources.forEach((source) => {
if (!source.aliases || source.aliases.length === 0) return;
source.aliases.forEach((aliasEntry) => {
const sourceEl = sourceRefs.current.get(source.id);
const aliasEl = aliasRefs.current.get(aliasEntry.alias);
if (!sourceEl || !aliasEl) return;
const sourceRect = sourceEl.getBoundingClientRect();
const aliasRect = aliasEl.getBoundingClientRect();
// Calculate coordinates relative to the container
const x1 = sourceRect.right - containerRect.left;
const y1 = sourceRect.top + sourceRect.height / 2 - containerRect.top;
const x2 = aliasRect.left - containerRect.left;
const y2 = aliasRect.top + aliasRect.height / 2 - containerRect.top;
newLines.push({
id: `${source.id}-${aliasEntry.alias}`,
path: bezier(x1, y1, x2, y2),
color
});
});
});
});
setLines(newLines);
setProviderGroupHeights((prev) => {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(nextProviderGroupHeights);
if (prevKeys.length !== nextKeys.length) return nextProviderGroupHeights;
for (const key of nextKeys) {
if (!(key in prev) || prev[key] !== nextProviderGroupHeights[key]) {
return nextProviderGroupHeights;
}
}
return prev;
});
}, [providerNodes, collapsedProviders]);
useImperativeHandle(
ref,
() => ({
collapseAll: () => setCollapsedProviders(new Set(providerNodes.map((p) => p.provider))),
refreshLayout: () => updateLines()
}),
[providerNodes, updateLines]
);
useLayoutEffect(() => {
// updateLines is called after layout is calculated, ensuring elements are in place.
updateLines();
const raf = requestAnimationFrame(updateLines);
window.addEventListener('resize', updateLines);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', updateLines);
};
}, [updateLines, aliasNodes]);
useLayoutEffect(() => {
updateLines();
const raf = requestAnimationFrame(updateLines);
return () => cancelAnimationFrame(raf);
}, [providerGroupHeights, updateLines]);
useEffect(() => {
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => updateLines());
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [updateLines]);
// Drag and Drop handlers
// 1. Source -> Alias
const handleDragStart = (e: DragEvent, source: SourceNode) => {
setTapSourceId(null);
setTapAlias(null);
setDraggedSource(source);
e.dataTransfer.setData('text/plain', source.id);
e.dataTransfer.effectAllowed = 'link';
};
const handleDragOver = (e: DragEvent, alias: string) => {
if (!draggedSource || draggedSource.aliases.some((entry) => entry.alias === alias)) return;
e.preventDefault(); // Allow drop
e.dataTransfer.dropEffect = 'link';
setDropTargetAlias(alias);
};
const handleDragLeave = () => {
setDropTargetAlias(null);
};
const handleDrop = (e: DragEvent, alias: string) => {
e.preventDefault();
if (draggedSource && !draggedSource.aliases.some((entry) => entry.alias === alias) && onUpdate) {
onUpdate(draggedSource.provider, draggedSource.name, alias);
}
setDraggedSource(null);
setDropTargetAlias(null);
};
// 2. Alias -> Source
const handleDragStartAlias = (e: DragEvent, alias: string) => {
setTapSourceId(null);
setTapAlias(null);
setDraggedAlias(alias);
e.dataTransfer.setData('text/plain', alias);
e.dataTransfer.effectAllowed = 'link';
};
const handleDragOverSource = (e: DragEvent, source: SourceNode) => {
if (!draggedAlias || source.aliases.some((entry) => entry.alias === draggedAlias)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
setDropTargetSource(source.id);
};
const handleDragLeaveSource = () => {
setDropTargetSource(null);
};
const handleDropOnSource = (e: DragEvent, source: SourceNode) => {
e.preventDefault();
if (draggedAlias && !source.aliases.some((entry) => entry.alias === draggedAlias) && onUpdate) {
onUpdate(source.provider, source.name, draggedAlias);
}
setDraggedAlias(null);
setDropTargetSource(null);
};
const handleContextMenu = (
e: ReactMouseEvent,
type: 'alias' | 'background' | 'provider' | 'source',
data?: string
) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
type,
data
});
};
const closeContextMenu = () => setContextMenu(null);
const resolveSourceById = useCallback(
(id: string | null) => {
if (!id) return null;
for (const { sources } of providerNodes) {
const found = sources.find((source) => source.id === id);
if (found) return found;
}
return null;
},
[providerNodes]
);
const handleTapSelectSource = (source: SourceNode) => {
if (!onUpdate) return;
if (tapSourceId === source.id) {
setTapSourceId(null);
return;
}
if (tapAlias) {
onUpdate(source.provider, source.name, tapAlias);
setTapSourceId(null);
setTapAlias(null);
return;
}
setTapSourceId(source.id);
setTapAlias(null);
};
const handleTapSelectAlias = (alias: string) => {
if (!onUpdate) return;
if (tapAlias === alias) {
setTapAlias(null);
return;
}
if (tapSourceId) {
const source = resolveSourceById(tapSourceId);
if (source) {
onUpdate(source.provider, source.name, alias);
}
setTapSourceId(null);
setTapAlias(null);
return;
}
setTapAlias(alias);
setTapSourceId(null);
};
const handleUnlinkSource = (provider: string, sourceModel: string, alias: string) => {
if (onDeleteLink) onDeleteLink(provider, sourceModel, alias);
};
const handleToggleFork = (
provider: string,
sourceModel: string,
alias: string,
value: boolean
) => {
if (onToggleFork) onToggleFork(provider, sourceModel, alias, value);
};
const handleAddAlias = () => {
closeContextMenu();
setAddAliasOpen(true);
setAddAliasValue('');
setAddAliasError('');
};
const handleAddAliasSubmit = () => {
const trimmed = addAliasValue.trim();
if (!trimmed) {
setAddAliasError(t('oauth_model_alias.diagram_please_enter_alias'));
return;
}
if (aliasNodes.some(a => a.alias === trimmed)) {
setAddAliasError(t('oauth_model_alias.diagram_alias_exists'));
return;
}
setExtraAliases(prev => [...prev, trimmed]);
setAddAliasOpen(false);
};
const handleRenameClick = (oldAlias: string) => {
closeContextMenu();
setRenameState({ oldAlias });
setRenameValue(oldAlias);
setRenameError('');
};
const handleRenameSubmit = () => {
const trimmed = renameValue.trim();
if (!trimmed) {
setRenameError(t('oauth_model_alias.diagram_please_enter_alias'));
return;
}
if (trimmed === renameState?.oldAlias) {
setRenameState(null);
return;
}
if (aliasNodes.some(a => a.alias === trimmed)) {
setRenameError(t('oauth_model_alias.diagram_alias_exists'));
return;
}
if (onRenameAlias && renameState) onRenameAlias(renameState.oldAlias, trimmed);
if (extraAliases.includes(renameState?.oldAlias ?? '')) {
setExtraAliases(prev => prev.map(a => a === renameState?.oldAlias ? trimmed : a));
}
setRenameState(null);
};
const handleDeleteClick = (alias: string) => {
closeContextMenu();
const node = aliasNodes.find(n => n.alias === alias);
if (!node) return;
if (node.sources.length === 0) {
setExtraAliases(prev => prev.filter(a => a !== alias));
} else {
if (onDeleteAlias) onDeleteAlias(alias);
}
};
return (
<div className={[styles.scrollContainer, className].filter(Boolean).join(' ')}>
{enableTapLinking && onUpdate && (
<div className={styles.tapHint}>{t('oauth_model_alias.diagram_tap_hint')}</div>
)}
<div
className={styles.container}
ref={containerRef}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
handleContextMenu(e, 'background');
}}
>
<svg className={styles.connections}>
{lines.map((line) => (
<path
key={line.id}
d={line.path}
stroke={line.color}
strokeOpacity={isDark ? 0.4 : 0.3}
/>
))}
</svg>
<ProviderColumn
providerNodes={providerNodes}
collapsedProviders={collapsedProviders}
getProviderColor={getProviderColor}
providerGroupHeights={providerGroupHeights}
providerRefs={providerRefs}
onToggleCollapse={toggleProviderCollapse}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_providers')}
expandLabel={t('oauth_model_alias.diagram_expand')}
collapseLabel={t('oauth_model_alias.diagram_collapse')}
/>
<SourceColumn
providerNodes={providerNodes}
collapsedProviders={collapsedProviders}
sourceRefs={sourceRefs}
getProviderColor={getProviderColor}
selectedSourceId={enableTapLinking ? tapSourceId : null}
onSelectSource={enableTapLinking ? handleTapSelectSource : undefined}
draggedSource={draggedSource}
dropTargetSource={dropTargetSource}
draggable={!!onUpdate}
onDragStart={handleDragStart}
onDragEnd={() => {
setDraggedSource(null);
setDropTargetAlias(null);
}}
onDragOver={handleDragOverSource}
onDragLeave={handleDragLeaveSource}
onDrop={handleDropOnSource}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_source_models')}
/>
<AliasColumn
aliasNodes={aliasNodes}
aliasRefs={aliasRefs}
dropTargetAlias={dropTargetAlias}
draggedAlias={draggedAlias}
selectedAlias={enableTapLinking ? tapAlias : null}
onSelectAlias={enableTapLinking ? handleTapSelectAlias : undefined}
draggable={!!onUpdate}
onDragStart={handleDragStartAlias}
onDragEnd={() => {
setDraggedAlias(null);
setDropTargetSource(null);
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
label={t('oauth_model_alias.diagram_aliases')}
/>
</div>
<DiagramContextMenu
contextMenu={contextMenu}
t={t}
onRequestClose={() => setContextMenu(null)}
onAddAlias={handleAddAlias}
onRenameAlias={handleRenameClick}
onOpenAliasSettings={(alias) => {
setContextMenu(null);
setSettingsAlias(alias);
}}
onDeleteAlias={handleDeleteClick}
onEditProvider={(provider) => {
setContextMenu(null);
onEditProvider?.(provider);
}}
onDeleteProvider={(provider) => {
setContextMenu(null);
onDeleteProvider?.(provider);
}}
onOpenSourceSettings={(sourceId) => {
setContextMenu(null);
setSettingsSourceId(sourceId);
}}
/>
<RenameAliasModal
open={!!renameState}
t={t}
value={renameValue}
error={renameError}
onChange={(value) => {
setRenameValue(value);
setRenameError('');
}}
onClose={() => setRenameState(null)}
onSubmit={handleRenameSubmit}
/>
<AddAliasModal
open={addAliasOpen}
t={t}
value={addAliasValue}
error={addAliasError}
onChange={(value) => {
setAddAliasValue(value);
setAddAliasError('');
}}
onClose={() => setAddAliasOpen(false)}
onSubmit={handleAddAliasSubmit}
/>
<SettingsAliasModal
open={Boolean(settingsAlias)}
t={t}
alias={settingsAlias}
aliasNodes={aliasNodes}
onClose={() => setSettingsAlias(null)}
onToggleFork={handleToggleFork}
onUnlink={handleUnlinkSource}
/>
<SettingsSourceModal
open={Boolean(settingsSourceId)}
t={t}
source={resolveSourceById(settingsSourceId)}
onClose={() => setSettingsSourceId(null)}
onToggleFork={handleToggleFork}
onUnlink={handleUnlinkSource}
/>
</div>
);
});

View File

@@ -0,0 +1,251 @@
import type { DragEvent, MouseEvent as ReactMouseEvent, RefObject } from 'react';
import type { AliasNode, ProviderNode, SourceNode } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface ProviderColumnProps {
providerNodes: ProviderNode[];
collapsedProviders: Set<string>;
getProviderColor: (provider: string) => string;
providerGroupHeights?: Record<string, number>;
providerRefs: RefObject<Map<string, HTMLDivElement>>;
onToggleCollapse: (provider: string) => void;
onContextMenu: (e: ReactMouseEvent, type: 'provider' | 'background', data?: string) => void;
label: string;
expandLabel: string;
collapseLabel: string;
}
export function ProviderColumn({
providerNodes,
collapsedProviders,
getProviderColor,
providerGroupHeights = {},
providerRefs,
onToggleCollapse,
onContextMenu,
label,
expandLabel,
collapseLabel
}: ProviderColumnProps) {
return (
<div
className={`${styles.column} ${styles.providers}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{providerNodes.map(({ provider, sources }) => {
const collapsed = collapsedProviders.has(provider);
const groupHeight = collapsed ? undefined : providerGroupHeights[provider];
return (
<div
key={provider}
className={styles.providerGroup}
style={groupHeight ? { height: groupHeight } : undefined}
>
<div
ref={(el) => {
if (el) providerRefs.current?.set(provider, el);
else providerRefs.current?.delete(provider);
}}
className={`${styles.item} ${styles.providerItem}`}
style={{ borderLeftColor: getProviderColor(provider) }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'provider', provider);
}}
>
<button
type="button"
className={styles.collapseBtn}
onClick={() => onToggleCollapse(provider)}
aria-label={collapsed ? expandLabel : collapseLabel}
title={collapsed ? expandLabel : collapseLabel}
>
<span className={collapsed ? styles.chevronRight : styles.chevronDown} />
</button>
<span className={styles.providerLabel} style={{ color: getProviderColor(provider) }}>
{provider}
</span>
<span className={styles.itemCount}>{sources.length}</span>
</div>
</div>
);
})}
</div>
);
}
interface SourceColumnProps {
providerNodes: ProviderNode[];
collapsedProviders: Set<string>;
sourceRefs: RefObject<Map<string, HTMLDivElement>>;
getProviderColor: (provider: string) => string;
selectedSourceId?: string | null;
onSelectSource?: (source: SourceNode) => void;
draggedSource: SourceNode | null;
dropTargetSource: string | null;
draggable: boolean;
onDragStart: (e: DragEvent, source: SourceNode) => void;
onDragEnd: () => void;
onDragOver: (e: DragEvent, source: SourceNode) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent, source: SourceNode) => void;
onContextMenu: (e: ReactMouseEvent, type: 'source' | 'background', data?: string) => void;
label: string;
}
export function SourceColumn({
providerNodes,
collapsedProviders,
sourceRefs,
getProviderColor,
selectedSourceId,
onSelectSource,
draggedSource,
dropTargetSource,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onContextMenu,
label
}: SourceColumnProps) {
return (
<div
className={`${styles.column} ${styles.sources}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{providerNodes.flatMap(({ provider, sources }) => {
if (collapsedProviders.has(provider)) return [];
return sources.map((source) => (
<div
key={source.id}
ref={(el) => {
if (el) sourceRefs.current?.set(source.id, el);
else sourceRefs.current?.delete(source.id);
}}
className={`${styles.item} ${styles.sourceItem} ${
draggedSource?.id === source.id ? styles.dragging : ''
} ${dropTargetSource === source.id ? styles.dropTarget : ''} ${
selectedSourceId === source.id ? styles.selected : ''
}`}
onClick={() => onSelectSource?.(source)}
draggable={draggable}
onDragStart={(e) => onDragStart(e, source)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, source)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, source)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'source', source.id);
}}
>
<span className={styles.itemName} title={source.name}>
{source.name}
</span>
<div
className={styles.dot}
style={{
background: getProviderColor(source.provider),
opacity: source.aliases.length > 0 ? 1 : 0.3
}}
/>
</div>
));
})}
</div>
);
}
interface AliasColumnProps {
aliasNodes: AliasNode[];
aliasRefs: RefObject<Map<string, HTMLDivElement>>;
dropTargetAlias: string | null;
draggedAlias: string | null;
selectedAlias?: string | null;
onSelectAlias?: (alias: string) => void;
draggable: boolean;
onDragStart: (e: DragEvent, alias: string) => void;
onDragEnd: () => void;
onDragOver: (e: DragEvent, alias: string) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent, alias: string) => void;
onContextMenu: (e: ReactMouseEvent, type: 'alias' | 'background', data?: string) => void;
label: string;
}
export function AliasColumn({
aliasNodes,
aliasRefs,
dropTargetAlias,
draggedAlias,
selectedAlias,
onSelectAlias,
draggable,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onContextMenu,
label
}: AliasColumnProps) {
return (
<div
className={`${styles.column} ${styles.aliases}`}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'background');
}}
>
<div className={styles.columnHeader}>{label}</div>
{aliasNodes.map((node) => (
<div
key={node.id}
ref={(el) => {
if (el) aliasRefs.current?.set(node.id, el);
else aliasRefs.current?.delete(node.id);
}}
className={`${styles.item} ${styles.aliasItem} ${
dropTargetAlias === node.alias ? styles.dropTarget : ''
} ${draggedAlias === node.alias ? styles.dragging : ''} ${
selectedAlias === node.alias ? styles.selected : ''
}`}
onClick={() => onSelectAlias?.(node.alias)}
draggable={draggable}
onDragStart={(e) => onDragStart(e, node.alias)}
onDragEnd={onDragEnd}
onDragOver={(e) => onDragOver(e, node.alias)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, node.alias)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu(e, 'alias', node.alias);
}}
>
<div className={`${styles.dot} ${styles.dotLeft}`} />
<span className={styles.itemName} title={node.alias}>
{node.alias}
</span>
<span className={styles.itemCount}>{node.sources.length}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { TFunction } from 'i18next';
import type { ContextMenuState } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface DiagramContextMenuProps {
contextMenu: ContextMenuState | null;
t: TFunction;
onRequestClose: () => void;
onAddAlias: () => void;
onRenameAlias: (alias: string) => void;
onOpenAliasSettings: (alias: string) => void;
onDeleteAlias: (alias: string) => void;
onEditProvider: (provider: string) => void;
onDeleteProvider: (provider: string) => void;
onOpenSourceSettings: (sourceId: string) => void;
}
export function DiagramContextMenu({
contextMenu,
t,
onRequestClose,
onAddAlias,
onRenameAlias,
onOpenAliasSettings,
onDeleteAlias,
onEditProvider,
onDeleteProvider,
onOpenSourceSettings
}: DiagramContextMenuProps) {
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!contextMenu) return;
const handleClick = (event: globalThis.MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
onRequestClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [contextMenu, onRequestClose]);
if (!contextMenu) return null;
const { type, data } = contextMenu;
const renderBackground = () => (
<div className={styles.menuItem} onClick={onAddAlias}>
<span>{t('oauth_model_alias.diagram_add_alias')}</span>
</div>
);
const renderAlias = () => {
if (!data) return null;
return (
<>
<div className={styles.menuItem} onClick={() => onRenameAlias(data)}>
<span>{t('oauth_model_alias.diagram_rename')}</span>
</div>
<div className={styles.menuItem} onClick={() => onOpenAliasSettings(data)}>
<span>{t('oauth_model_alias.diagram_settings')}</span>
</div>
<div className={styles.menuDivider} />
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteAlias(data)}>
<span>{t('oauth_model_alias.diagram_delete_alias')}</span>
</div>
</>
);
};
const renderProvider = () => {
if (!data) return null;
return (
<>
<div className={styles.menuItem} onClick={() => onEditProvider(data)}>
<span>{t('common.edit')}</span>
</div>
<div className={styles.menuDivider} />
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteProvider(data)}>
<span>{t('oauth_model_alias.delete')}</span>
</div>
</>
);
};
const renderSource = () => {
if (!data) return null;
return (
<div className={styles.menuItem} onClick={() => onOpenSourceSettings(data)}>
<span>{t('oauth_model_alias.diagram_settings')}</span>
</div>
);
};
return createPortal(
<div
ref={menuRef}
className={styles.contextMenu}
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{type === 'background' && renderBackground()}
{type === 'alias' && renderAlias()}
{type === 'provider' && renderProvider()}
{type === 'source' && renderSource()}
</div>,
document.body
);
}

View File

@@ -0,0 +1,267 @@
import type { KeyboardEvent } from 'react';
import type { TFunction } from 'i18next';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconTrash2 } from '@/components/ui/icons';
import type { AliasNode, SourceNode } from './ModelMappingDiagramTypes';
import styles from './ModelMappingDiagram.module.scss';
interface RenameAliasModalProps {
open: boolean;
t: TFunction;
value: string;
error: string;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export function RenameAliasModal({
open,
t,
value,
error,
onChange,
onClose,
onSubmit
}: RenameAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_rename_alias_title')}
width={400}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_rename_btn')}</Button>
</>
}
>
<Input
label={t('oauth_model_alias.diagram_rename_alias_label')}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') onSubmit();
}}
error={error}
placeholder={t('oauth_model_alias.diagram_rename_placeholder')}
autoFocus
/>
</Modal>
);
}
interface AddAliasModalProps {
open: boolean;
t: TFunction;
value: string;
error: string;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export function AddAliasModal({
open,
t,
value,
error,
onChange,
onClose,
onSubmit
}: AddAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_add_alias_title')}
width={400}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_add_btn')}</Button>
</>
}
>
<Input
label={t('oauth_model_alias.diagram_add_alias_label')}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') onSubmit();
}}
error={error}
placeholder={t('oauth_model_alias.diagram_add_placeholder')}
autoFocus
/>
</Modal>
);
}
interface SettingsAliasModalProps {
open: boolean;
t: TFunction;
alias: string | null;
aliasNodes: AliasNode[];
onClose: () => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
}
export function SettingsAliasModal({
open,
t,
alias,
aliasNodes,
onClose,
onToggleFork,
onUnlink
}: SettingsAliasModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_settings_title', { alias: alias ?? '' })}
width={720}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{alias ? (
(() => {
const node = aliasNodes.find((n) => n.alias === alias);
if (!node || node.sources.length === 0) {
return <div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>;
}
return (
<div className={styles.settingsList}>
{node.sources.map((source) => {
const entry = source.aliases.find((item) => item.alias === alias);
const forkEnabled = entry?.fork === true;
return (
<div key={source.id} className={styles.settingsRow}>
<div className={styles.settingsNames}>
<span className={styles.settingsSource}>{source.name}</span>
<span className={styles.settingsArrow}></span>
<span className={styles.settingsAlias}>{alias}</span>
</div>
<div className={styles.settingsActions}>
<span className={styles.settingsLabel}>
{t('oauth_model_alias.alias_fork_label')}
</span>
<ToggleSwitch
checked={forkEnabled}
onChange={(value) => onToggleFork(source.provider, source.name, alias, value)}
ariaLabel={t('oauth_model_alias.alias_fork_label')}
/>
<button
type="button"
className={styles.settingsDelete}
onClick={() => onUnlink(source.provider, source.name, alias)}
aria-label={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
title={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
>
<IconTrash2 size={14} />
</button>
</div>
</div>
);
})}
</div>
);
})()
) : null}
</Modal>
);
}
interface SettingsSourceModalProps {
open: boolean;
t: TFunction;
source: SourceNode | null;
onClose: () => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
}
export function SettingsSourceModal({
open,
t,
source,
onClose,
onToggleFork,
onUnlink
}: SettingsSourceModalProps) {
return (
<Modal
open={open}
onClose={onClose}
title={t('oauth_model_alias.diagram_settings_source_title')}
width={720}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{source ? (
source.aliases.length === 0 ? (
<div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>
) : (
<div className={styles.settingsList}>
{source.aliases.map((entry) => (
<div key={`${source.id}-${entry.alias}`} className={styles.settingsRow}>
<div className={styles.settingsNames}>
<span className={styles.settingsSource}>{source.name}</span>
<span className={styles.settingsArrow}></span>
<span className={styles.settingsAlias}>{entry.alias}</span>
</div>
<div className={styles.settingsActions}>
<span className={styles.settingsLabel}>
{t('oauth_model_alias.alias_fork_label')}
</span>
<ToggleSwitch
checked={entry.fork === true}
onChange={(value) => onToggleFork(source.provider, source.name, entry.alias, value)}
ariaLabel={t('oauth_model_alias.alias_fork_label')}
/>
<button
type="button"
className={styles.settingsDelete}
onClick={() => onUnlink(source.provider, source.name, entry.alias)}
aria-label={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
title={t('oauth_model_alias.diagram_delete_link', {
provider: source.provider,
name: source.name
})}
>
<IconTrash2 size={14} />
</button>
</div>
</div>
))}
</div>
)
) : null}
</Modal>
);
}

View File

@@ -0,0 +1,33 @@
export interface AuthFileModelItem {
id: string;
display_name?: string;
type?: string;
owned_by?: string;
}
export interface SourceNode {
id: string; // unique: provider::name
provider: string;
name: string;
aliases: { alias: string; fork: boolean }[]; // all aliases this source maps to
}
export interface AliasNode {
id: string; // alias
alias: string;
sources: SourceNode[];
}
export interface ProviderNode {
provider: string;
sources: SourceNode[];
}
export interface ContextMenuState {
x: number;
y: number;
type: 'alias' | 'background' | 'provider' | 'source';
data?: string;
}
export type DiagramLine = { path: string; color: string; id: string };

View File

@@ -0,0 +1,2 @@
export { ModelMappingDiagram } from './ModelMappingDiagram';
export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram';

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,153 @@
@use '../../../styles/variables' as *;
.navContainer {
position: fixed;
left: var(--content-center-x, 50%);
bottom: calc(12px + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 50;
pointer-events: auto;
width: fit-content;
max-width: calc(100vw - 24px);
}
.navList {
position: relative;
display: inline-flex;
flex-direction: row;
gap: 6px;
padding: 10px 12px;
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: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
overflow-x: auto;
scrollbar-width: none;
max-width: inherit;
&::-webkit-scrollbar {
display: none;
}
}
.indicator {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
opacity: 0;
border-radius: 999px;
background: rgba(59, 130, 246, 0.15);
box-shadow: inset 0 0 0 2px var(--primary-color);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
height 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 120ms ease;
will-change: transform, width, height;
}
.indicatorVisible {
opacity: 1;
}
.indicatorNoTransition {
transition: none;
}
.navItem {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
background: transparent;
border-radius: 999px;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.15s ease;
flex: 0 0 auto;
&:hover {
background: rgba(0, 0, 0, 0.06);
transform: scale(1.08);
}
&:active {
transform: scale(0.95);
}
}
.navItem.active {
&:hover {
background: transparent;
transform: none;
}
}
.icon {
width: 24px;
height: 24px;
object-fit: contain;
}
.active {
// Active highlight is rendered by the sliding indicator.
background: transparent;
box-shadow: none;
}
// 暗色主题适配
: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);
}
}
.indicator {
background: rgba(59, 130, 246, 0.25);
}
}
// 小屏幕进一步收紧尺寸
@media (max-width: 1200px) {
.navContainer {
max-width: calc(100vw - 16px);
}
.navList {
gap: 6px;
padding: 8px 10px;
}
.navItem {
width: 36px;
height: 36px;
}
.icon {
width: 22px;
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.indicator {
transition: none;
}
.navItem {
transition: background-color 0.2s ease;
}
}

View File

@@ -0,0 +1,285 @@
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useLocation } from 'react-router-dom';
import { usePageTransitionLayer } from '@/components/common/PageTransition';
import { useThemeStore } from '@/stores';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconCodexDark from '@/assets/icons/codex_drak.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import iconAmp from '@/assets/icons/amp.svg';
import styles from './ProviderNav.module.scss';
export type ProviderId = 'gemini' | 'codex' | 'claude' | 'vertex' | 'ampcode' | 'openai';
interface ProviderNavItem {
id: ProviderId;
label: string;
getIcon: (theme: string) => string;
}
const PROVIDERS: ProviderNavItem[] = [
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
{ id: 'openai', label: 'OpenAI', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) },
];
const HEADER_OFFSET = 24;
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
export function ProviderNav() {
const location = useLocation();
const pageTransitionLayer = usePageTransitionLayer();
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
const contentScrollerRef = useRef<HTMLElement | null>(null);
const navListRef = useRef<HTMLDivElement | null>(null);
const navContainerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<ProviderId, HTMLButtonElement | null>>({
gemini: null,
codex: null,
claude: null,
vertex: null,
ampcode: null,
openai: null,
});
const [indicatorRect, setIndicatorRect] = useState<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
const [indicatorTransitionsEnabled, setIndicatorTransitionsEnabled] = useState(false);
const indicatorHasEnabledTransitionsRef = useRef(false);
// Only show this quick-switch overlay on the AI Providers list page.
// Note: The app uses iOS-style stacked page transitions inside `/ai-providers/*`,
// so this component can stay mounted while the user is on an edit route.
const normalizedPathname =
location.pathname.length > 1 && location.pathname.endsWith('/')
? location.pathname.slice(0, -1)
: location.pathname;
const shouldShow = isCurrentLayer && normalizedPathname === '/ai-providers';
const getHeaderHeight = useCallback(() => {
const header = document.querySelector('.main-header') as HTMLElement | null;
if (header) return header.getBoundingClientRect().height;
const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height');
const value = Number.parseFloat(raw);
return Number.isFinite(value) ? value : 0;
}, []);
const getContentScroller = useCallback(() => {
if (contentScrollerRef.current && document.contains(contentScrollerRef.current)) {
return contentScrollerRef.current;
}
const container = document.querySelector('.content') as HTMLElement | null;
contentScrollerRef.current = container;
return container;
}, []);
const getScrollContainer = useCallback((): ScrollContainer => {
// Mobile layout uses document scroll (layout switches at 768px); desktop uses the `.content` scroller.
const isMobile = window.matchMedia('(max-width: 768px)').matches;
if (isMobile) return window;
return getContentScroller() ?? window;
}, [getContentScroller]);
const handleScroll = useCallback(() => {
const container = getScrollContainer();
if (!container) return;
const isElementScroller = container instanceof HTMLElement;
const headerHeight = isElementScroller ? 0 : getHeaderHeight();
const containerTop = isElementScroller ? container.getBoundingClientRect().top : 0;
const activationLine = containerTop + headerHeight + HEADER_OFFSET + 1;
let currentActive: ProviderId | null = null;
for (const provider of PROVIDERS) {
const element = document.getElementById(`provider-${provider.id}`);
if (!element) continue;
const rect = element.getBoundingClientRect();
if (rect.top <= activationLine) {
currentActive = provider.id;
continue;
}
if (currentActive) break;
}
if (!currentActive) {
const firstVisible = PROVIDERS.find((provider) =>
document.getElementById(`provider-${provider.id}`)
);
currentActive = firstVisible?.id ?? null;
}
setActiveProvider(currentActive);
}, [getHeaderHeight, getScrollContainer]);
useEffect(() => {
if (!shouldShow) return;
const contentScroller = getContentScroller();
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
window.addEventListener('scroll', handleScroll, { passive: true });
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll);
handleScroll();
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
contentScroller?.removeEventListener('scroll', handleScroll);
};
}, [getContentScroller, handleScroll, shouldShow]);
const updateIndicator = useCallback((providerId: ProviderId | null) => {
if (!providerId) {
setIndicatorRect(null);
return;
}
const itemEl = itemRefs.current[providerId];
if (!itemEl) return;
setIndicatorRect({
x: itemEl.offsetLeft,
y: itemEl.offsetTop,
width: itemEl.offsetWidth,
height: itemEl.offsetHeight,
});
// Avoid animating from an initial (0,0) state on first paint.
if (!indicatorHasEnabledTransitionsRef.current) {
indicatorHasEnabledTransitionsRef.current = true;
requestAnimationFrame(() => setIndicatorTransitionsEnabled(true));
}
}, []);
useLayoutEffect(() => {
if (!shouldShow) return;
updateIndicator(activeProvider);
}, [activeProvider, shouldShow, updateIndicator]);
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
useLayoutEffect(() => {
if (!shouldShow) return;
const el = navContainerRef.current;
if (!el) return;
const updateHeight = () => {
const height = el.getBoundingClientRect().height;
document.documentElement.style.setProperty('--provider-nav-height', `${height}px`);
};
updateHeight();
window.addEventListener('resize', updateHeight);
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateHeight);
ro?.observe(el);
return () => {
ro?.disconnect();
window.removeEventListener('resize', updateHeight);
document.documentElement.style.removeProperty('--provider-nav-height');
};
}, [shouldShow]);
const scrollToProvider = (providerId: ProviderId) => {
const container = getScrollContainer();
const element = document.getElementById(`provider-${providerId}`);
if (!element || !container) return;
setActiveProvider(providerId);
updateIndicator(providerId);
// Mobile: scroll the document (header is fixed, so offset by header height).
if (!(container instanceof HTMLElement)) {
const headerHeight = getHeaderHeight();
const elementTop = element.getBoundingClientRect().top + window.scrollY;
const target = Math.max(0, elementTop - headerHeight - HEADER_OFFSET);
window.scrollTo({ top: target, behavior: 'smooth' });
return;
}
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
};
useEffect(() => {
if (!shouldShow) return;
const handleResize = () => updateIndicator(activeProvider);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [activeProvider, shouldShow, updateIndicator]);
const navContent = (
<div className={styles.navContainer} ref={navContainerRef}>
<div className={styles.navList} ref={navListRef}>
<div
className={[
styles.indicator,
indicatorRect ? styles.indicatorVisible : '',
indicatorTransitionsEnabled ? '' : styles.indicatorNoTransition,
]
.filter(Boolean)
.join(' ')}
style={
(indicatorRect
? ({
transform: `translate3d(${indicatorRect.x}px, ${indicatorRect.y}px, 0)`,
width: indicatorRect.width,
height: indicatorRect.height,
} satisfies CSSProperties)
: undefined) as CSSProperties | undefined
}
/>
{PROVIDERS.map((provider) => {
const isActive = activeProvider === provider.id;
return (
<button
key={provider.id}
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
ref={(node) => {
itemRefs.current[provider.id] = node;
}}
onClick={() => scrollToProvider(provider.id)}
title={provider.label}
type="button"
aria-label={provider.label}
aria-pressed={isActive}
>
<img
src={provider.getIcon(resolvedTheme)}
alt={provider.label}
className={styles.icon}
/>
</button>
);
})}
</div>
</div>
);
if (typeof document === 'undefined') return null;
if (!shouldShow) 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

@@ -10,13 +10,14 @@ import type {
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
CodexRateLimitInfo,
CodexQuotaState,
CodexUsageWindow,
CodexQuotaWindow,
CodexUsagePayload,
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
GeminiCliQuotaState
GeminiCliQuotaState,
} from '@/types';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import {
@@ -27,6 +28,7 @@ import {
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS,
normalizeAuthIndexValue,
normalizeGeminiCliModelId,
normalizeNumberValue,
normalizePlanType,
normalizeQuotaFraction,
@@ -45,8 +47,9 @@ import {
getStatusFromError,
isAntigravityFile,
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
isRuntimeOnlyAuthFile
isRuntimeOnlyAuthFile,
} from '@/utils/quota';
import type { QuotaRenderHelpers } from './QuotaCard';
import styles from '@/pages/QuotaPage.module.scss';
@@ -116,11 +119,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 +130,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 +138,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;
}
}
}
@@ -202,6 +191,15 @@ const fetchAntigravityQuota = async (
};
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
const FIVE_HOUR_SECONDS = 18000;
const WEEK_SECONDS = 604800;
const WINDOW_META = {
codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' },
codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' },
codeReviewFiveHour: { id: 'code-review-five-hour', labelKey: 'codex_quota.code_review_primary_window' },
codeReviewWeekly: { id: 'code-review-weekly', labelKey: 'codex_quota.code_review_secondary_window' },
} as const;
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const windows: CodexQuotaWindow[] = [];
@@ -223,30 +221,74 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
label: t(labelKey),
labelKey,
usedPercent,
resetLabel
resetLabel,
});
};
const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => {
if (!window) return null;
return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds);
};
const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached;
const rawAllowed = rateLimit?.allowed;
const pickClassifiedWindows = (
limitInfo?: CodexRateLimitInfo | null
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
const rawWindows = [
limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null,
limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null,
];
let fiveHourWindow: CodexUsageWindow | null = null;
let weeklyWindow: CodexUsageWindow | null = null;
for (const window of rawWindows) {
if (!window) continue;
const seconds = getWindowSeconds(window);
if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) {
fiveHourWindow = window;
} else if (seconds === WEEK_SECONDS && !weeklyWindow) {
weeklyWindow = window;
}
}
return { fiveHourWindow, weeklyWindow };
};
const rateWindows = pickClassifiedWindows(rateLimit);
addWindow(
'primary',
'codex_quota.primary_window',
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
WINDOW_META.codeFiveHour.id,
WINDOW_META.codeFiveHour.labelKey,
rateWindows.fiveHourWindow,
rawLimitReached,
rawAllowed
);
addWindow(
'secondary',
'codex_quota.secondary_window',
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
WINDOW_META.codeWeekly.id,
WINDOW_META.codeWeekly.labelKey,
rateWindows.weeklyWindow,
rawLimitReached,
rawAllowed
);
const codeReviewWindows = pickClassifiedWindows(codeReviewLimit);
const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached;
const codeReviewAllowed = codeReviewLimit?.allowed;
addWindow(
WINDOW_META.codeReviewFiveHour.id,
WINDOW_META.codeReviewFiveHour.labelKey,
codeReviewWindows.fiveHourWindow,
codeReviewLimitReached,
codeReviewAllowed
);
addWindow(
'code-review',
'codex_quota.code_review_window',
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
codeReviewLimit?.allowed
WINDOW_META.codeReviewWeekly.id,
WINDOW_META.codeReviewWeekly.labelKey,
codeReviewWindows.weeklyWindow,
codeReviewLimitReached,
codeReviewAllowed
);
return windows;
@@ -270,14 +312,14 @@ const fetchCodexQuota = async (
const requestHeader: Record<string, string> = {
...CODEX_REQUEST_HEADERS,
'Chatgpt-Account-Id': accountId
'Chatgpt-Account-Id': accountId,
};
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CODEX_USAGE_URL,
header: requestHeader
header: requestHeader,
});
if (result.statusCode < 200 || result.statusCode >= 300) {
@@ -314,7 +356,7 @@ const fetchGeminiCliQuota = async (
method: 'POST',
url: GEMINI_CLI_QUOTA_URL,
header: { ...GEMINI_CLI_REQUEST_HEADERS },
data: JSON.stringify({ project: projectId })
data: JSON.stringify({ project: projectId }),
});
if (result.statusCode < 200 || result.statusCode >= 300) {
@@ -327,13 +369,15 @@ const fetchGeminiCliQuota = async (
const parsedBuckets = buckets
.map((bucket) => {
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
const modelId = normalizeGeminiCliModelId(bucket.modelId ?? bucket.model_id);
if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
const remainingFractionRaw = normalizeQuotaFraction(
bucket.remainingFraction ?? bucket.remaining_fraction
);
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
const remainingAmount = normalizeNumberValue(
bucket.remainingAmount ?? bucket.remaining_amount
);
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
let fallbackFraction: number | null = null;
if (remainingAmount !== null) {
@@ -347,7 +391,7 @@ const fetchGeminiCliQuota = async (
tokenType,
remainingFraction,
remainingAmount,
resetTime
resetTime,
};
})
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
@@ -379,11 +423,7 @@ const renderAntigravityItems = (
h(
'div',
{ className: styleMap.quotaRowHeader },
h(
'span',
{ className: styleMap.quotaModel, title: group.models.join(', ') },
group.label
),
h('span', { className: styleMap.quotaModel, title: group.models.join(', ') }, group.label),
h(
'div',
{ className: styleMap.quotaMeta },
@@ -416,7 +456,6 @@ const renderCodexItems = (
};
const planLabel = getPlanLabel(planType);
const isFreePlan = normalizePlanType(planType) === 'free';
const nodes: ReactNode[] = [];
if (planLabel) {
@@ -430,17 +469,6 @@ const renderCodexItems = (
);
}
if (isFreePlan) {
nodes.push(
h(
'div',
{ key: 'warning', className: styleMap.quotaWarning },
t('codex_quota.no_access')
)
);
return h(Fragment, null, ...nodes);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
@@ -500,7 +528,7 @@ const renderGeminiCliItems = (
bucket.remainingAmount === null || bucket.remainingAmount === undefined
? null
: t('gemini_cli_quota.remaining_amount', {
count: bucket.remainingAmount
count: bucket.remainingAmount,
});
const titleBase =
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
@@ -533,7 +561,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',
@@ -543,13 +571,13 @@ export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQ
status: 'error',
groups: [],
error: message,
errorStatus: status
errorStatus: status,
}),
cardClassName: styles.antigravityCard,
controlsClassName: styles.antigravityControls,
controlClassName: styles.antigravityControl,
gridClassName: styles.antigravityGrid,
renderQuotaItems: renderAntigravityItems
renderQuotaItems: renderAntigravityItems,
};
export const CODEX_CONFIG: QuotaConfig<
@@ -558,7 +586,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',
@@ -566,25 +594,26 @@ export const CODEX_CONFIG: QuotaConfig<
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
planType: data.planType
planType: data.planType,
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status
errorStatus: status,
}),
cardClassName: styles.codexCard,
controlsClassName: styles.codexControls,
controlClassName: styles.codexControl,
gridClassName: styles.codexGrid,
renderQuotaItems: renderCodexItems
renderQuotaItems: renderCodexItems,
};
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',
@@ -594,11 +623,11 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
status: 'error',
buckets: [],
error: message,
errorStatus: status
errorStatus: status,
}),
cardClassName: styles.geminiCliCard,
controlsClassName: styles.geminiCliControls,
controlClassName: styles.geminiCliControl,
gridClassName: styles.geminiCliGrid,
renderQuotaItems: renderGeminiCliItems
renderQuotaItems: renderGeminiCliItems,
};

View File

@@ -8,6 +8,7 @@ interface ModalProps {
onClose: () => void;
footer?: ReactNode;
width?: number | string;
className?: string;
closeDisabled?: boolean;
}
@@ -15,11 +16,53 @@ const CLOSE_ANIMATION_DURATION = 350;
const MODAL_LOCK_CLASS = 'modal-open';
let activeModalCount = 0;
const scrollLockSnapshot = {
scrollY: 0,
contentScrollTop: 0,
contentEl: null as HTMLElement | null,
bodyPosition: '',
bodyTop: '',
bodyLeft: '',
bodyRight: '',
bodyWidth: '',
bodyOverflow: '',
htmlOverflow: '',
};
const resolveContentScrollContainer = () => {
if (typeof document === 'undefined') return null;
const contentEl = document.querySelector('.content');
return contentEl instanceof HTMLElement ? contentEl : null;
};
const lockScroll = () => {
if (typeof document === 'undefined') return;
if (activeModalCount === 0) {
document.body?.classList.add(MODAL_LOCK_CLASS);
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
const body = document.body;
const html = document.documentElement;
const contentEl = resolveContentScrollContainer();
scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0;
scrollLockSnapshot.contentEl = contentEl;
scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0;
scrollLockSnapshot.bodyPosition = body.style.position;
scrollLockSnapshot.bodyTop = body.style.top;
scrollLockSnapshot.bodyLeft = body.style.left;
scrollLockSnapshot.bodyRight = body.style.right;
scrollLockSnapshot.bodyWidth = body.style.width;
scrollLockSnapshot.bodyOverflow = body.style.overflow;
scrollLockSnapshot.htmlOverflow = html.style.overflow;
body.classList.add(MODAL_LOCK_CLASS);
html.classList.add(MODAL_LOCK_CLASS);
body.style.position = 'fixed';
body.style.top = `-${scrollLockSnapshot.scrollY}px`;
body.style.left = '0';
body.style.right = '0';
body.style.width = '100%';
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
}
activeModalCount += 1;
};
@@ -28,8 +71,31 @@ const unlockScroll = () => {
if (typeof document === 'undefined') return;
activeModalCount = Math.max(0, activeModalCount - 1);
if (activeModalCount === 0) {
document.body?.classList.remove(MODAL_LOCK_CLASS);
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
const body = document.body;
const html = document.documentElement;
const scrollY = scrollLockSnapshot.scrollY;
const contentScrollTop = scrollLockSnapshot.contentScrollTop;
const contentEl = scrollLockSnapshot.contentEl;
body.classList.remove(MODAL_LOCK_CLASS);
html.classList.remove(MODAL_LOCK_CLASS);
body.style.position = scrollLockSnapshot.bodyPosition;
body.style.top = scrollLockSnapshot.bodyTop;
body.style.left = scrollLockSnapshot.bodyLeft;
body.style.right = scrollLockSnapshot.bodyRight;
body.style.width = scrollLockSnapshot.bodyWidth;
body.style.overflow = scrollLockSnapshot.bodyOverflow;
html.style.overflow = scrollLockSnapshot.htmlOverflow;
if (contentEl) {
contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' });
}
window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' });
scrollLockSnapshot.scrollY = 0;
scrollLockSnapshot.contentScrollTop = 0;
scrollLockSnapshot.contentEl = null;
}
};
@@ -39,6 +105,7 @@ export function Modal({
onClose,
footer,
width = 520,
className,
closeDisabled = false,
children
}: PropsWithChildren<ModalProps>) {
@@ -110,7 +177,7 @@ export function Modal({
if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`;
const modalContent = (
<div className={overlayClass}>

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

@@ -0,0 +1,58 @@
.root {
position: relative;
display: inline-flex;
align-items: center;
gap: $spacing-sm;
cursor: pointer;
}
.labelLeft {
.label {
order: -1;
}
}
.disabled {
cursor: not-allowed;
}
.root input {
width: 0;
height: 0;
opacity: 0;
position: absolute;
}
.track {
width: 44px;
height: 24px;
background: var(--border-color);
border-radius: $radius-full;
position: relative;
transition: background $transition-fast;
}
.thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
background: #fff;
border-radius: $radius-full;
box-shadow: $shadow-sm;
transition: transform $transition-fast;
}
.root input:checked + .track {
background: var(--primary-color);
}
.root input:checked + .track .thumb {
transform: translateX(20px);
}
.label {
color: var(--text-primary);
font-weight: 600;
}

View File

@@ -1,4 +1,5 @@
import type { ChangeEvent, ReactNode } from 'react';
import styles from './ToggleSwitch.module.scss';
interface ToggleSwitchProps {
checked: boolean;
@@ -21,7 +22,11 @@ export function ToggleSwitch({
onChange(event.target.checked);
};
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
const className = [
styles.root,
labelPosition === 'left' ? styles.labelLeft : '',
disabled ? styles.disabled : '',
]
.filter(Boolean)
.join(' ');
@@ -34,10 +39,10 @@ export function ToggleSwitch({
disabled={disabled}
aria-label={ariaLabel}
/>
<span className="track">
<span className="thumb" />
<span className={styles.track}>
<span className={styles.thumb} />
</span>
{label && <span className="label">{label}</span>}
{label && <span className={styles.label}>{label}</span>}
</label>
);
}

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

@@ -39,10 +39,18 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
{t('usage_stats.requests_count')}: {api.totalRequests}
<span className={styles.requestCountCell}>
<span>
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.apiBadge}>
Tokens: {formatTokensInMillions(api.totalTokens)}
{t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
@@ -61,7 +69,13 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
{stats.requests} {t('usage_stats.requests_count')}
<span className={styles.requestCountCell}>
<span>{stats.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
</div>

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

@@ -0,0 +1,458 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import type {
PayloadFilterRule,
PayloadParamValueType,
PayloadRule,
VisualConfigValues,
} from '@/types/visualConfig';
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
function hasOwn(obj: unknown, key: string): obj is Record<string, unknown> {
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function extractApiKeyValue(raw: unknown): string | null {
if (typeof raw === 'string') {
const trimmed = raw.trim();
return trimmed ? trimmed : null;
}
const record = asRecord(raw);
if (!record) return null;
const candidates = [record['api-key'], record.apiKey, record.key, record.Key];
for (const candidate of candidates) {
if (typeof candidate === 'string') {
const trimmed = candidate.trim();
if (trimmed) return trimmed;
}
}
return null;
}
function parseApiKeysText(raw: unknown): string {
if (!Array.isArray(raw)) return '';
const keys: string[] = [];
for (const item of raw) {
const key = extractApiKeyValue(item);
if (key) keys.push(key);
}
return keys.join('\n');
}
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asRecord(parent[key]);
if (existing) return existing;
const next: Record<string, unknown> = {};
parent[key] = next;
return next;
}
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void {
const value = asRecord(parent[key]);
if (!value) return;
if (Object.keys(value).length === 0) delete parent[key];
}
function setBoolean(obj: Record<string, unknown>, key: string, value: boolean): void {
if (value) {
obj[key] = true;
return;
}
if (hasOwn(obj, key)) obj[key] = false;
}
function setString(obj: Record<string, unknown>, key: string, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
if (trimmed !== '') {
obj[key] = safe;
return;
}
if (hasOwn(obj, key)) delete obj[key];
}
function setIntFromString(obj: Record<string, unknown>, key: string, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
if (trimmed === '') {
if (hasOwn(obj, key)) delete obj[key];
return;
}
const parsed = Number.parseInt(trimmed, 10);
if (Number.isFinite(parsed)) {
obj[key] = parsed;
return;
}
if (hasOwn(obj, key)) delete obj[key];
}
function deepClone<T>(value: T): T {
if (typeof structuredClone === 'function') return structuredClone(value);
return JSON.parse(JSON.stringify(value)) as T;
}
function parsePayloadParamValue(raw: unknown): { valueType: PayloadParamValueType; value: string } {
if (typeof raw === 'number') {
return { valueType: 'number', value: String(raw) };
}
if (typeof raw === 'boolean') {
return { valueType: 'boolean', value: String(raw) };
}
if (raw === null || typeof raw === 'object') {
try {
const json = JSON.stringify(raw, null, 2);
return { valueType: 'json', value: json ?? 'null' };
} catch {
return { valueType: 'json', value: String(raw) };
}
}
return { valueType: 'string', value: String(raw ?? '') };
}
function parsePayloadRules(rules: unknown): PayloadRule[] {
if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => ({
id: `payload-rule-${index}`,
models: Array.isArray((rule as any)?.models)
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
id: `model-${index}-${modelIndex}`,
name: typeof model === 'string' ? model : model?.name || '',
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
}))
: [],
params: (rule as any)?.params
? Object.entries((rule as any).params as Record<string, unknown>).map(([path, value], pIndex) => {
const parsedValue = parsePayloadParamValue(value);
return {
id: `param-${index}-${pIndex}`,
path,
valueType: parsedValue.valueType,
value: parsedValue.value,
};
})
: [],
}));
}
function parsePayloadFilterRules(rules: unknown): PayloadFilterRule[] {
if (!Array.isArray(rules)) return [];
return rules.map((rule, index) => ({
id: `payload-filter-rule-${index}`,
models: Array.isArray((rule as any)?.models)
? ((rule as any).models as unknown[]).map((model: any, modelIndex: number) => ({
id: `filter-model-${index}-${modelIndex}`,
name: typeof model === 'string' ? model : model?.name || '',
protocol: typeof model === 'object' ? (model?.protocol as any) : undefined,
}))
: [],
params: Array.isArray((rule as any)?.params) ? ((rule as any).params as unknown[]).map(String) : [],
}));
}
function serializePayloadRulesForYaml(rules: PayloadRule[]): any[] {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, any> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
const params: Record<string, any> = {};
for (const param of rule.params || []) {
if (!param.path?.trim()) continue;
let value: any = param.value;
if (param.valueType === 'number') {
const num = Number(param.value);
value = Number.isFinite(num) ? num : param.value;
} else if (param.valueType === 'boolean') {
value = param.value === 'true';
} else if (param.valueType === 'json') {
try {
value = JSON.parse(param.value);
} catch {
value = param.value;
}
}
params[param.path.trim()] = value;
}
return { models, params };
})
.filter((rule) => rule.models.length > 0);
}
function serializePayloadFilterRulesForYaml(rules: PayloadFilterRule[]): any[] {
return rules
.map((rule) => {
const models = (rule.models || [])
.filter((m) => m.name?.trim())
.map((m) => {
const obj: Record<string, any> = { name: m.name.trim() };
if (m.protocol) obj.protocol = m.protocol;
return obj;
});
const params = (Array.isArray(rule.params) ? rule.params : [])
.map((path) => String(path).trim())
.filter(Boolean);
return { models, params };
})
.filter((rule) => rule.models.length > 0);
}
export function useVisualConfig() {
const [visualValues, setVisualValuesState] = useState<VisualConfigValues>({
...DEFAULT_VISUAL_VALUES,
});
const baselineValues = useRef<VisualConfigValues>({ ...DEFAULT_VISUAL_VALUES });
const visualDirty = useMemo(() => {
return JSON.stringify(visualValues) !== JSON.stringify(baselineValues.current);
}, [visualValues]);
const loadVisualValuesFromYaml = useCallback((yamlContent: string) => {
try {
const parsed: any = parseYaml(yamlContent) || {};
const newValues: VisualConfigValues = {
host: parsed.host || '',
port: String(parsed.port ?? ''),
tlsEnable: Boolean(parsed.tls?.enable),
tlsCert: parsed.tls?.cert || '',
tlsKey: parsed.tls?.key || '',
rmAllowRemote: Boolean(parsed['remote-management']?.['allow-remote']),
rmSecretKey: parsed['remote-management']?.['secret-key'] || '',
rmDisableControlPanel: Boolean(parsed['remote-management']?.['disable-control-panel']),
rmPanelRepo:
parsed['remote-management']?.['panel-github-repository'] ??
parsed['remote-management']?.['panel-repo'] ??
'',
authDir: parsed['auth-dir'] || '',
apiKeysText: parseApiKeysText(parsed['api-keys']),
debug: Boolean(parsed.debug),
commercialMode: Boolean(parsed['commercial-mode']),
loggingToFile: Boolean(parsed['logging-to-file']),
logsMaxTotalSizeMb: String(parsed['logs-max-total-size-mb'] ?? ''),
usageStatisticsEnabled: Boolean(parsed['usage-statistics-enabled']),
proxyUrl: parsed['proxy-url'] || '',
forceModelPrefix: Boolean(parsed['force-model-prefix']),
requestRetry: String(parsed['request-retry'] ?? ''),
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
wsAuth: Boolean(parsed['ws-auth']),
quotaSwitchProject: Boolean(parsed['quota-exceeded']?.['switch-project'] ?? true),
quotaSwitchPreviewModel: Boolean(
parsed['quota-exceeded']?.['switch-preview-model'] ?? true
),
routingStrategy: (parsed.routing?.strategy || 'round-robin') as 'round-robin' | 'fill-first',
payloadDefaultRules: parsePayloadRules(parsed.payload?.default),
payloadOverrideRules: parsePayloadRules(parsed.payload?.override),
payloadFilterRules: parsePayloadFilterRules(parsed.payload?.filter),
streaming: {
keepaliveSeconds: String(parsed.streaming?.['keepalive-seconds'] ?? ''),
bootstrapRetries: String(parsed.streaming?.['bootstrap-retries'] ?? ''),
nonstreamKeepaliveInterval: String(parsed['nonstream-keepalive-interval'] ?? ''),
},
};
setVisualValuesState(newValues);
baselineValues.current = deepClone(newValues);
} catch {
setVisualValuesState({ ...DEFAULT_VISUAL_VALUES });
baselineValues.current = deepClone(DEFAULT_VISUAL_VALUES);
}
}, []);
const applyVisualChangesToYaml = useCallback(
(currentYaml: string): string => {
try {
const parsed = (parseYaml(currentYaml) || {}) as Record<string, unknown>;
const values = visualValues;
setString(parsed, 'host', values.host);
setIntFromString(parsed, 'port', values.port);
if (
hasOwn(parsed, 'tls') ||
values.tlsEnable ||
values.tlsCert.trim() ||
values.tlsKey.trim()
) {
const tls = ensureRecord(parsed, 'tls');
setBoolean(tls, 'enable', values.tlsEnable);
setString(tls, 'cert', values.tlsCert);
setString(tls, 'key', values.tlsKey);
deleteIfEmpty(parsed, 'tls');
}
if (
hasOwn(parsed, 'remote-management') ||
values.rmAllowRemote ||
values.rmSecretKey.trim() ||
values.rmDisableControlPanel ||
values.rmPanelRepo.trim()
) {
const rm = ensureRecord(parsed, 'remote-management');
setBoolean(rm, 'allow-remote', values.rmAllowRemote);
setString(rm, 'secret-key', values.rmSecretKey);
setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel);
setString(rm, 'panel-github-repository', values.rmPanelRepo);
if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo'];
deleteIfEmpty(parsed, 'remote-management');
}
setString(parsed, 'auth-dir', values.authDir);
if (values.apiKeysText !== baselineValues.current.apiKeysText) {
const apiKeys = values.apiKeysText
.split('\n')
.map((key) => key.trim())
.filter(Boolean);
if (apiKeys.length > 0) {
parsed['api-keys'] = apiKeys;
} else if (hasOwn(parsed, 'api-keys')) {
delete parsed['api-keys'];
}
}
setBoolean(parsed, 'debug', values.debug);
setBoolean(parsed, 'commercial-mode', values.commercialMode);
setBoolean(parsed, 'logging-to-file', values.loggingToFile);
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb);
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled);
setString(parsed, 'proxy-url', values.proxyUrl);
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix);
setIntFromString(parsed, 'request-retry', values.requestRetry);
setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval);
setBoolean(parsed, 'ws-auth', values.wsAuth);
if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) {
const quota = ensureRecord(parsed, 'quota-exceeded');
quota['switch-project'] = values.quotaSwitchProject;
quota['switch-preview-model'] = values.quotaSwitchPreviewModel;
deleteIfEmpty(parsed, 'quota-exceeded');
}
if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') {
const routing = ensureRecord(parsed, 'routing');
routing.strategy = values.routingStrategy;
deleteIfEmpty(parsed, 'routing');
}
const keepaliveSeconds =
typeof values.streaming?.keepaliveSeconds === 'string' ? values.streaming.keepaliveSeconds : '';
const bootstrapRetries =
typeof values.streaming?.bootstrapRetries === 'string' ? values.streaming.bootstrapRetries : '';
const nonstreamKeepaliveInterval =
typeof values.streaming?.nonstreamKeepaliveInterval === 'string'
? values.streaming.nonstreamKeepaliveInterval
: '';
const streamingDefined =
hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim();
if (streamingDefined) {
const streaming = ensureRecord(parsed, 'streaming');
setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds);
setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries);
deleteIfEmpty(parsed, 'streaming');
}
setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval);
if (
hasOwn(parsed, 'payload') ||
values.payloadDefaultRules.length > 0 ||
values.payloadOverrideRules.length > 0 ||
values.payloadFilterRules.length > 0
) {
const payload = ensureRecord(parsed, 'payload');
if (values.payloadDefaultRules.length > 0) {
payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules);
} else if (hasOwn(payload, 'default')) {
delete payload.default;
}
if (values.payloadOverrideRules.length > 0) {
payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules);
} else if (hasOwn(payload, 'override')) {
delete payload.override;
}
if (values.payloadFilterRules.length > 0) {
payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules);
} else if (hasOwn(payload, 'filter')) {
delete payload.filter;
}
deleteIfEmpty(parsed, 'payload');
}
return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 });
} catch {
return currentYaml;
}
},
[visualValues]
);
const setVisualValues = useCallback((newValues: Partial<VisualConfigValues>) => {
setVisualValuesState((prev) => {
const next: VisualConfigValues = { ...prev, ...newValues } as VisualConfigValues;
if (newValues.streaming) {
next.streaming = { ...prev.streaming, ...newValues.streaming };
}
return next;
});
}, []);
return {
visualValues,
visualDirty,
loadVisualValuesFromYaml,
applyVisualChangesToYaml,
setVisualValues,
};
}
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
{ value: '', label: '默认' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'claude', label: 'Claude' },
{ value: 'codex', label: 'Codex' },
{ value: 'antigravity', label: 'Antigravity' },
] as const;
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
{ value: 'string', label: '字符串' },
{ value: 'number', label: '数字' },
{ value: 'boolean', label: '布尔' },
{ value: 'json', label: 'JSON' },
] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>;

View File

@@ -6,12 +6,14 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json';
import en from './locales/en.json';
import ru from './locales/ru.json';
import { getInitialLanguage } from '@/utils/language';
i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
en: { translation: en }
en: { translation: en },
ru: { translation: ru }
},
lng: getInitialLanguage(),
fallbackLng: 'zh-CN',

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",
@@ -93,7 +102,7 @@
"oauth": "OAuth Login",
"quota_management": "Quota Management",
"usage_stats": "Usage Statistics",
"config_management": "Config Management",
"config_management": "Config Panel",
"logs": "Logs Viewer",
"system_info": "Management Center Info"
},
@@ -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...",
@@ -367,6 +376,7 @@
"filter_qwen": "Qwen",
"filter_gemini": "Gemini",
"filter_gemini-cli": "GeminiCLI",
"filter_kimi": "Kimi",
"filter_aistudio": "AIStudio",
"filter_claude": "Claude",
"filter_codex": "Codex",
@@ -378,6 +388,7 @@
"type_qwen": "Qwen",
"type_gemini": "Gemini",
"type_gemini-cli": "GeminiCLI",
"type_kimi": "Kimi",
"type_aistudio": "AIStudio",
"type_claude": "Claude",
"type_codex": "Codex",
@@ -394,8 +405,8 @@
"models_empty_desc": "This credential may not be loaded by the server yet, or no models are bound to it.",
"models_unsupported": "This feature is not supported in the current version",
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
"models_excluded_badge": "Excluded",
"models_excluded_hint": "This model is excluded by OAuth",
"models_excluded_badge": "Disabled",
"models_excluded_hint": "This OAuth model is disabled",
"status_toggle_label": "Enabled",
"status_enabled_success": "\"{{name}}\" enabled",
"status_disabled_success": "\"{{name}}\" disabled",
@@ -407,13 +418,15 @@
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
},
"antigravity_quota": {
"title": "Antigravity Quota",
"empty_title": "No Antigravity Auth Files",
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
"idle": "Not loaded. Click Refresh Button.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
@@ -425,7 +438,7 @@
"title": "Codex Quota",
"empty_title": "No Codex Auth Files",
"empty_desc": "Upload a Codex credential to view quota.",
"idle": "Not loaded. Click Refresh Button.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
@@ -436,7 +449,8 @@
"fetch_all": "Fetch All",
"primary_window": "5-hour limit",
"secondary_window": "Weekly limit",
"code_review_window": "Code review limit",
"code_review_primary_window": "Code review 5-hour limit",
"code_review_secondary_window": "Code review weekly limit",
"plan_label": "Plan",
"plan_plus": "Plus",
"plan_team": "Team",
@@ -446,7 +460,7 @@
"title": "Gemini CLI Quota",
"empty_title": "No Gemini CLI Auth Files",
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
"idle": "Not loaded. Click Refresh Button.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
@@ -476,75 +490,105 @@
"result_file": "Persisted file"
},
"oauth_excluded": {
"title": "OAuth Excluded Models",
"description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.",
"add": "Add Exclusion",
"add_title": "Add provider exclusion",
"edit_title": "Edit exclusions for {{provider}}",
"title": "OAuth Model Disablement",
"description": "Per-provider model disablement is shown as cards; click a card to edit or delete. Wildcards * are supported and the scope follows the auth file filter.",
"add": "Add Disablement",
"add_title": "Add provider model disablement",
"edit_title": "Edit model disablement for {{provider}}",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"provider_label": "Provider",
"provider_auto": "Follow current filter",
"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_label": "Models to disable",
"models_loading": "Loading models...",
"models_unsupported": "Current CPA version does not support fetching model lists.",
"models_loaded": "{{count}} models loaded. Check the models to disable.",
"no_models_available": "No models available for this provider.",
"save": "Save/Update",
"saving": "Saving...",
"save_success": "Excluded models updated",
"save_failed": "Failed to update excluded models",
"save_success": "Model disablement updated",
"save_failed": "Failed to update model disablement",
"delete": "Delete Provider",
"delete_confirm": "Delete the exclusion list for {{provider}}?",
"delete_success": "Exclusion list removed",
"delete_failed": "Failed to delete exclusion list",
"delete_confirm": "Delete model disablement for {{provider}}?",
"delete_success": "Provider model disablement removed",
"delete_failed": "Failed to delete model disablement",
"deleting": "Deleting...",
"no_models": "No excluded models",
"model_count": "{{count}} models excluded",
"list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.",
"list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.",
"disconnected": "Connect to the server to view exclusions",
"load_failed": "Failed to load exclusion list",
"no_models": "No disabled models configured",
"model_count": "{{count}} models disabled",
"list_empty_all": "No provider model disablement yet; click “Add Disablement” to create one.",
"list_empty_filtered": "No disabled items in this scope; click “Add Disablement” to add.",
"disconnected": "Connect to the server to view model disablement",
"load_failed": "Failed to load model disablement",
"provider_required": "Please enter a provider first",
"scope_all": "Scope: All providers",
"scope_provider": "Scope: {{provider}}",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required": "Current CPA version does not support OAuth model disablement. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
"upgrade_required_desc": "The current server version does not support fetching OAuth model disablement. Please upgrade to the latest CPA (CLI Proxy API) version and try again."
},
"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_link_title": "Unlink mapping",
"delete_link_confirm": "Unlink mapping from <code>{{sourceModel}}</code> ({{provider}}) to alias <code>{{alias}}</code>?",
"delete_alias_title": "Delete Alias",
"delete_alias_confirm": "Delete alias <code>{{alias}}</code> and unmap all associated models?",
"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.",
"chart_title": "All mappings overview",
"diagram_providers": "Providers",
"diagram_source_models": "Source Models",
"diagram_aliases": "Aliases",
"diagram_expand": "Expand",
"diagram_collapse": "Collapse",
"diagram_add_alias": "Add Alias",
"diagram_rename": "Rename",
"diagram_rename_alias_title": "Rename alias",
"diagram_rename_alias_label": "New alias name",
"diagram_rename_placeholder": "Enter alias name...",
"diagram_delete_link": "Unlink from {{provider}} / {{name}}",
"diagram_delete_alias": "Delete alias",
"diagram_please_enter_alias": "Please enter an alias name.",
"diagram_alias_exists": "This alias already exists.",
"diagram_add_alias_title": "Add alias",
"diagram_add_alias_label": "Alias name",
"diagram_add_placeholder": "Enter new alias name...",
"diagram_rename_btn": "Rename",
"diagram_add_btn": "Add",
"diagram_settings": "Settings",
"diagram_settings_title": "Alias settings — {{alias}}",
"diagram_settings_source_title": "Source model settings",
"diagram_settings_empty": "No mappings for this alias yet.",
"diagram_tap_hint": "On touch devices: tap a source model, then tap an alias to link.",
"view_mode": "View mode",
"view_mode_diagram": "Diagram",
"view_mode_list": "List",
"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",
@@ -595,6 +639,17 @@
"gemini_cli_oauth_status_error": "Authentication failed:",
"gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:",
"gemini_cli_oauth_polling_error": "Failed to check authentication status:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "Start Kimi Login",
"kimi_oauth_hint": "Login to Kimi service through OAuth device flow, automatically obtain and save authentication files.",
"kimi_oauth_url_label": "Authorization URL:",
"kimi_open_link": "Open Link",
"kimi_copy_link": "Copy Link",
"kimi_oauth_status_waiting": "Waiting for authentication...",
"kimi_oauth_status_success": "Authentication successful!",
"kimi_oauth_status_error": "Authentication failed:",
"kimi_oauth_start_error": "Failed to start Kimi OAuth:",
"kimi_oauth_polling_error": "Failed to check authentication status:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Start Qwen Login",
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
@@ -753,6 +808,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.",
@@ -765,11 +822,11 @@
"upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature."
},
"config_management": {
"title": "Config Management",
"title": "Config Panel",
"editor_title": "Configuration File",
"reload": "Reload",
"save": "Save",
"description": "View and edit the server-side config.yaml file. Validate the syntax before saving.",
"description": "Edit config.yaml via visual editor or source file",
"status_idle": "Waiting for action",
"status_loading": "Loading configuration...",
"status_loaded": "Configuration loaded",
@@ -786,7 +843,143 @@
"search_button": "Search",
"search_no_results": "No results",
"search_prev": "Previous",
"search_next": "Next"
"search_next": "Next",
"tabs": {
"visual": "Visual Editor",
"source": "Source File Editor"
},
"visual": {
"sections": {
"server": {
"title": "Server Configuration",
"description": "Basic server settings",
"host": "Host Address",
"port": "Port"
},
"tls": {
"title": "TLS/SSL Configuration",
"description": "HTTPS secure connection settings",
"enable": "Enable TLS",
"enable_desc": "Enable HTTPS secure connection",
"cert": "Certificate File Path",
"key": "Private Key File Path"
},
"remote": {
"title": "Remote Management",
"description": "Remote access and control panel settings",
"allow_remote": "Allow Remote Access",
"allow_remote_desc": "Allow management access from other hosts",
"disable_panel": "Disable Control Panel",
"disable_panel_desc": "Disable the built-in web control panel",
"secret_key": "Management Key",
"secret_key_placeholder": "Set management key",
"panel_repo": "Panel Repository"
},
"auth": {
"title": "Authentication Configuration",
"description": "API keys and authentication directory settings",
"auth_dir": "Auth Directory (auth-dir)",
"auth_dir_hint": "Directory path for authentication files (supports ~)"
},
"system": {
"title": "System Configuration",
"description": "Debug, logging, statistics, and performance settings",
"debug": "Debug Mode",
"debug_desc": "Enable verbose debug logging",
"commercial_mode": "Commercial Mode",
"commercial_mode_desc": "Disable high-overhead middleware to support high concurrency",
"logging_to_file": "Log to File",
"logging_to_file_desc": "Save logs to files",
"usage_statistics": "Usage Statistics",
"usage_statistics_desc": "Collect usage statistics",
"logs_max_size": "Log File Size Limit (MB)"
},
"network": {
"title": "Network Configuration",
"description": "Proxy, retry, and routing settings",
"proxy_url": "Proxy URL",
"request_retry": "Request Retry Count",
"max_retry_interval": "Max Retry Interval (seconds)",
"routing_strategy": "Routing Strategy",
"routing_strategy_hint": "Select credential selection strategy",
"strategy_round_robin": "Round Robin",
"strategy_fill_first": "Fill First",
"force_model_prefix": "Force Model Prefix",
"force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix",
"ws_auth": "WebSocket Authentication",
"ws_auth_desc": "Enable WebSocket authentication (/v1/ws)"
},
"quota": {
"title": "Quota Fallback",
"description": "Fallback strategy when quota is exceeded",
"switch_project": "Switch Project",
"switch_project_desc": "Automatically switch to another project when quota is exceeded",
"switch_preview_model": "Switch to Preview Model",
"switch_preview_model_desc": "Switch to preview model version when quota is exceeded"
},
"streaming": {
"title": "Streaming Configuration",
"description": "Keepalive and bootstrap retry settings",
"keepalive_seconds": "Keepalive Seconds",
"keepalive_hint": "Set to 0 or leave empty to disable keepalive",
"bootstrap_retries": "Bootstrap Retries",
"bootstrap_hint": "Number of retries during stream startup (before first byte)",
"nonstream_keepalive": "Non-stream Keepalive Interval (seconds)",
"nonstream_keepalive_hint": "Send blank lines every N seconds for non-streaming responses to prevent idle timeout, set to 0 or leave empty to disable",
"disabled": "Disabled"
},
"payload": {
"title": "Payload Configuration",
"description": "Default values, override rules, and filter rules",
"default_rules": "Default Rules",
"default_rules_desc": "Use these default values when parameters are not specified in the request",
"override_rules": "Override Rules",
"override_rules_desc": "Force override parameter values in the request",
"filter_rules": "Filter Rules",
"filter_rules_desc": "Pre-filter upstream request body via JSON Path, automatically remove non-compliant/redundant parameters (Request Sanitization)"
}
},
"api_keys": {
"label": "API Keys List (api-keys)",
"add": "Add API Key",
"empty": "No API keys",
"hint": "Each entry represents an API key (consistent with 'API Key Management' page style)",
"edit_title": "Edit API Key",
"add_title": "Add API Key",
"input_label": "API Key",
"input_placeholder": "Paste your API key",
"input_hint": "This only modifies the local config file content, it will not sync to the API Key Management interface",
"error_empty": "Please enter an API key",
"error_invalid": "API key contains invalid characters"
},
"payload_rules": {
"rule": "Rule",
"models": "Applicable Models",
"model_name": "Model Name",
"provider_type": "Provider Type",
"add_model": "Add Model",
"params": "Parameter Settings",
"remove_params": "Remove Parameters",
"json_path": "JSON Path (e.g., temperature)",
"json_path_filter": "JSON Path (gjson/sjson), e.g., generationConfig.thinkingConfig.thinkingBudget",
"param_type": "Parameter Type",
"add_param": "Add Parameter",
"no_rules": "No rules",
"add_rule": "Add Rule",
"value_string": "String value",
"value_number": "Number value (e.g., 0.7)",
"value_boolean": "true or false",
"value_json": "JSON value",
"value_default": "Value"
},
"common": {
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"update": "Update",
"add": "Add"
}
}
},
"quota_management": {
"title": "Quota Management",
@@ -796,6 +989,7 @@
},
"system_info": {
"title": "Management Center Info",
"about_title": "CLI Proxy API Management Center",
"connection_status_title": "Connection Status",
"api_status_label": "API Status:",
"config_status_label": "Config Status:",
@@ -900,12 +1094,15 @@
"gemini_api_key": "Gemini API key",
"codex_api_key": "Codex API key",
"claude_api_key": "Claude API key",
"commercial_mode_restart_required": "Commercial mode setting changed. Please restart the service for it to take effect",
"copy_failed": "Copy failed",
"link_copied": "Link copied to clipboard"
},
"language": {
"switch": "Language",
"chinese": "中文",
"english": "English"
"english": "English",
"russian": "Русский"
},
"theme": {
"switch": "Theme",

1130
src/i18n/locales/ru.json Normal file

File diff suppressed because it is too large Load Diff

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": "检查连接",
@@ -93,7 +102,7 @@
"oauth": "OAuth 登录",
"quota_management": "配额管理",
"usage_stats": "使用统计",
"config_management": "配置管理",
"config_management": "配置面板",
"logs": "日志查看",
"system_info": "中心信息"
},
@@ -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": "正在发送测试请求...",
@@ -367,6 +376,7 @@
"filter_qwen": "Qwen",
"filter_gemini": "Gemini",
"filter_gemini-cli": "GeminiCLI",
"filter_kimi": "Kimi",
"filter_aistudio": "AIStudio",
"filter_claude": "Claude",
"filter_codex": "Codex",
@@ -378,6 +388,7 @@
"type_qwen": "Qwen",
"type_gemini": "Gemini",
"type_gemini-cli": "GeminiCLI",
"type_kimi": "Kimi",
"type_aistudio": "AIStudio",
"type_claude": "Claude",
"type_codex": "Codex",
@@ -394,8 +405,8 @@
"models_empty_desc": "该认证凭证可能尚未被服务器加载或没有绑定任何模型",
"models_unsupported": "当前版本不支持此功能",
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
"models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除",
"models_excluded_badge": "已禁用",
"models_excluded_hint": "此 OAuth 模型已被禁用",
"status_toggle_label": "启用",
"status_enabled_success": "已启用 \"{{name}}\"",
"status_disabled_success": "已停用 \"{{name}}\"",
@@ -407,13 +418,15 @@
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
},
"antigravity_quota": {
"title": "Antigravity 额度",
"empty_title": "暂无 Antigravity 认证",
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
@@ -425,7 +438,7 @@
"title": "Codex 额度",
"empty_title": "暂无 Codex 认证",
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
@@ -436,7 +449,8 @@
"fetch_all": "获取全部",
"primary_window": "5 小时限额",
"secondary_window": "周限额",
"code_review_window": "代码审查限额",
"code_review_primary_window": "代码审查 5 小时限额",
"code_review_secondary_window": "代码审查周限额",
"plan_label": "套餐",
"plan_plus": "Plus",
"plan_team": "Team",
@@ -446,7 +460,7 @@
"title": "Gemini CLI 额度",
"empty_title": "暂无 Gemini CLI 认证",
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
@@ -476,75 +490,105 @@
"result_file": "存储文件"
},
"oauth_excluded": {
"title": "OAuth 排除列表",
"title": "OAuth 模型禁用",
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
"add": "新增排除",
"add_title": "新增提供商排除列表",
"edit_title": "编辑 {{provider}} 的排除列表",
"add": "新增禁用",
"add_title": "新增提供商模型禁用",
"edit_title": "编辑 {{provider}} 的模型禁用",
"refresh": "刷新",
"refreshing": "刷新中...",
"provider_label": "提供商",
"provider_auto": "跟随当前过滤",
"provider_placeholder": "例如 gemini-cli / openai",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"models_label": "排除的模型",
"models_placeholder": "gpt-4.1-mini\n*-preview",
"models_hint": "逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。",
"models_label": "禁用的模型",
"models_loading": "正在加载模型列表...",
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
"models_loaded": "已加载 {{count}} 个模型,勾选要禁用的模型。",
"no_models_available": "该提供商暂无可用模型列表。",
"save": "保存/更新",
"saving": "正在保存...",
"save_success": "排除列表已更新",
"save_failed": "更新排除列表失败",
"save_success": "模型禁用已更新",
"save_failed": "更新模型禁用失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的排除列表吗?",
"delete_success": "已删除该提供商的排除列表",
"delete_failed": "删除排除列表失败",
"delete_confirm": "确定要删除 {{provider}} 的模型禁用吗?",
"delete_success": "已删除该提供商的模型禁用",
"delete_failed": "删除模型禁用失败",
"deleting": "正在删除...",
"no_models": "未配置排除模型",
"model_count": "排除 {{count}} 个模型",
"list_empty_all": "暂无任何提供商的排除列表,点击“新增排除”创建。",
"list_empty_filtered": "当前筛选下没有排除项,点击“新增排除”添加。",
"disconnected": "请先连接服务器以查看排除列表",
"load_failed": "加载排除列表失败",
"no_models": "未配置禁用模型",
"model_count": "禁用 {{count}} 个模型",
"list_empty_all": "暂无任何提供商的模型禁用,点击“新增禁用”创建。",
"list_empty_filtered": "当前筛选下没有禁用项,点击“新增禁用”添加。",
"disconnected": "请先连接服务器以查看模型禁用",
"load_failed": "加载模型禁用失败",
"provider_required": "请先填写提供商名称",
"scope_all": "当前范围:全局(显示所有提供商)",
"scope_provider": "当前范围:{{provider}}",
"upgrade_required": "当前 CPA 版本不支持模型排除列表,请升级 CPA 版本",
"upgrade_required": "当前 CPA 版本不支持 OAuth 模型禁用,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
"upgrade_required_desc": "当前服务器版本不支持获取 OAuth 模型禁用功能,请升级到最新版本的 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_link_title": "取消链接",
"delete_link_confirm": "确定取消 <code>{{sourceModel}}</code>{{provider}})到别名 <code>{{alias}}</code> 的映射?",
"delete_alias_title": "删除别名",
"delete_alias_confirm": "确定删除别名 <code>{{alias}}</code> 并取消所有关联模型的映射?",
"delete_success": "已删除该提供商的模型别名",
"delete_failed": "删除模型别名失败",
"no_models": "未配置模型别名",
"model_count": "{{count}} 条别名",
"list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。",
"chart_title": "全部映射概览",
"diagram_providers": "提供商",
"diagram_source_models": "源模型",
"diagram_aliases": "别名",
"diagram_expand": "展开",
"diagram_collapse": "收起",
"diagram_add_alias": "添加别名",
"diagram_rename": "重命名",
"diagram_rename_alias_title": "重命名别名",
"diagram_rename_alias_label": "新别名名称",
"diagram_rename_placeholder": "输入别名名称...",
"diagram_delete_link": "取消链接 {{provider}} / {{name}}",
"diagram_delete_alias": "删除别名",
"diagram_please_enter_alias": "请输入别名名称。",
"diagram_alias_exists": "该别名已存在。",
"diagram_add_alias_title": "添加别名",
"diagram_add_alias_label": "别名名称",
"diagram_add_placeholder": "输入新别名名称...",
"diagram_rename_btn": "重命名",
"diagram_add_btn": "添加",
"diagram_settings": "设置",
"diagram_settings_title": "别名设置 — {{alias}}",
"diagram_settings_source_title": "源模型设置",
"diagram_settings_empty": "该别名暂无映射。",
"diagram_tap_hint": "触摸设备上:先点选源模型,再点选别名即可建立映射。",
"view_mode": "视图模式",
"view_mode_diagram": "概览",
"view_mode_list": "管理",
"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",
@@ -595,6 +639,17 @@
"gemini_cli_oauth_status_error": "认证失败:",
"gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:",
"gemini_cli_oauth_polling_error": "检查认证状态失败:",
"kimi_oauth_title": "Kimi OAuth",
"kimi_oauth_button": "开始 Kimi 登录",
"kimi_oauth_hint": "通过设备授权流程登录 Kimi 服务,自动获取并保存认证文件。",
"kimi_oauth_url_label": "授权链接:",
"kimi_open_link": "打开链接",
"kimi_copy_link": "复制链接",
"kimi_oauth_status_waiting": "等待认证中...",
"kimi_oauth_status_success": "认证成功!",
"kimi_oauth_status_error": "认证失败:",
"kimi_oauth_start_error": "启动 Kimi OAuth 失败:",
"kimi_oauth_polling_error": "检查认证状态失败:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "开始 Qwen 登录",
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
@@ -753,6 +808,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": "尝试更换关键字或清空筛选条件。",
@@ -765,11 +822,11 @@
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
},
"config_management": {
"title": "配置管理",
"title": "配置面板",
"editor_title": "配置文件",
"reload": "重新加载",
"save": "保存",
"description": "查看并编辑服务器上的 config.yaml 配置文件。保存前请确认语法正确。",
"description": "通过可视化或者源文件方式编辑 config.yaml 配置文件",
"status_idle": "等待操作",
"status_loading": "加载配置中...",
"status_loaded": "配置已加载",
@@ -786,7 +843,143 @@
"search_button": "搜索",
"search_no_results": "无结果",
"search_prev": "上一个",
"search_next": "下一个"
"search_next": "下一个",
"tabs": {
"visual": "可视化编辑",
"source": "源文件编辑"
},
"visual": {
"sections": {
"server": {
"title": "服务器配置",
"description": "基础服务器设置",
"host": "主机地址",
"port": "端口"
},
"tls": {
"title": "TLS/SSL 配置",
"description": "HTTPS 安全连接设置",
"enable": "启用 TLS",
"enable_desc": "启用 HTTPS 安全连接",
"cert": "证书文件路径",
"key": "私钥文件路径"
},
"remote": {
"title": "远程管理",
"description": "远程访问和控制面板设置",
"allow_remote": "允许远程访问",
"allow_remote_desc": "允许从其他主机访问管理接口",
"disable_panel": "禁用控制面板",
"disable_panel_desc": "禁用内置的 Web 控制面板",
"secret_key": "管理密钥",
"secret_key_placeholder": "设置管理密钥",
"panel_repo": "面板仓库"
},
"auth": {
"title": "认证配置",
"description": "API 密钥与认证文件目录设置",
"auth_dir": "认证文件目录 (auth-dir)",
"auth_dir_hint": "存放认证文件的目录路径(支持 ~"
},
"system": {
"title": "系统配置",
"description": "调试、日志、统计与性能调试设置",
"debug": "调试模式",
"debug_desc": "启用详细的调试日志",
"commercial_mode": "商业模式",
"commercial_mode_desc": "禁用高开销中间件以支持高并发",
"logging_to_file": "写入日志文件",
"logging_to_file_desc": "将日志保存到文件",
"usage_statistics": "使用统计",
"usage_statistics_desc": "收集使用统计信息",
"logs_max_size": "日志文件大小限制 (MB)"
},
"network": {
"title": "网络配置",
"description": "代理、重试和路由设置",
"proxy_url": "代理 URL",
"request_retry": "请求重试次数",
"max_retry_interval": "最大重试间隔 (秒)",
"routing_strategy": "路由策略",
"routing_strategy_hint": "选择凭据选择策略",
"strategy_round_robin": "轮询 (Round Robin)",
"strategy_fill_first": "填充优先 (Fill First)",
"force_model_prefix": "强制模型前缀",
"force_model_prefix_desc": "未带前缀的模型请求只使用无前缀凭据",
"ws_auth": "WebSocket 认证",
"ws_auth_desc": "启用 WebSocket 连接认证 (/v1/ws)"
},
"quota": {
"title": "配额回退",
"description": "配额耗尽时的回退策略",
"switch_project": "切换项目",
"switch_project_desc": "配额耗尽时自动切换到其他项目",
"switch_preview_model": "切换预览模型",
"switch_preview_model_desc": "配额耗尽时切换到预览版本模型"
},
"streaming": {
"title": "流式传输配置",
"description": "Keepalive 与 bootstrap 重试设置",
"keepalive_seconds": "Keepalive 秒数",
"keepalive_hint": "设置为 0 或留空表示禁用 keepalive",
"bootstrap_retries": "Bootstrap 重试次数",
"bootstrap_hint": "流式传输启动时(首包前)的重试次数",
"nonstream_keepalive": "非流式 Keepalive 间隔 (秒)",
"nonstream_keepalive_hint": "非流式响应时每隔 N 秒发送空行以防止空闲超时,设置为 0 或留空表示禁用",
"disabled": "已禁用"
},
"payload": {
"title": "Payload 配置",
"description": "默认值、覆盖规则与过滤规则",
"default_rules": "默认规则",
"default_rules_desc": "当请求中未指定参数时,使用这些默认值",
"override_rules": "覆盖规则",
"override_rules_desc": "强制覆盖请求中的参数值",
"filter_rules": "过滤规则",
"filter_rules_desc": "通过 JSON Path 预过滤上游请求体,自动剔除不合规/冗余参数Request Sanitization"
}
},
"api_keys": {
"label": "API 密钥列表 (api-keys)",
"add": "添加 API 密钥",
"empty": "暂无 API 密钥",
"hint": "每个条目代表一个 API 密钥(与 「API 密钥管理」 页面样式一致)",
"edit_title": "编辑 API 密钥",
"add_title": "添加 API 密钥",
"input_label": "API 密钥",
"input_placeholder": "粘贴你的 API 密钥",
"input_hint": "此处仅修改本地配置文件内容,不会自动同步到 API 密钥管理接口",
"error_empty": "请输入 API 密钥",
"error_invalid": "API 密钥包含无效字符"
},
"payload_rules": {
"rule": "规则",
"models": "适用模型",
"model_name": "模型名称",
"provider_type": "供应商类型",
"add_model": "添加模型",
"params": "参数设置",
"remove_params": "移除参数",
"json_path": "JSON 路径 (如 temperature)",
"json_path_filter": "JSON 路径 (gjson/sjson),如 generationConfig.thinkingConfig.thinkingBudget",
"param_type": "参数类型",
"add_param": "添加参数",
"no_rules": "暂无规则",
"add_rule": "添加规则",
"value_string": "字符串值",
"value_number": "数字值 (如 0.7)",
"value_boolean": "true 或 false",
"value_json": "JSON 值",
"value_default": "值"
},
"common": {
"edit": "编辑",
"delete": "删除",
"cancel": "取消",
"update": "更新",
"add": "添加"
}
}
},
"quota_management": {
"title": "配额管理",
@@ -796,6 +989,7 @@
},
"system_info": {
"title": "管理中心信息",
"about_title": "CLI Proxy API Management Center",
"connection_status_title": "连接状态",
"api_status_label": "API 状态:",
"config_status_label": "配置状态:",
@@ -900,12 +1094,15 @@
"gemini_api_key": "Gemini API密钥",
"codex_api_key": "Codex API密钥",
"claude_api_key": "Claude API密钥",
"commercial_mode_restart_required": "商业模式开关已变更,请重启服务后生效",
"copy_failed": "复制失败",
"link_copied": "已复制"
},
"language": {
"switch": "语言",
"chinese": "中文",
"english": "English"
"english": "English",
"russian": "Русский"
},
"theme": {
"switch": "主题",

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,362 @@
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, useOpenAIEditDraftStore } 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 config = useConfigStore((state) => state.config);
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 [providers, setProviders] = useState<OpenAIProviderConfig[]>(
() => config?.openaiCompatibility ?? []
);
const [loading, setLoading] = useState(
() => !isCacheValid('openai-compatibility')
);
const [saving, setSaving] = useState(false);
const draftKey = useMemo(() => {
if (invalidIndexParam) return `openai:invalid:${params.index ?? 'unknown'}`;
if (editIndex === null) return 'openai:new';
return `openai:${editIndex}`;
}, [editIndex, invalidIndexParam, params.index]);
const draft = useOpenAIEditDraftStore((state) => state.drafts[draftKey]);
const ensureDraft = useOpenAIEditDraftStore((state) => state.ensureDraft);
const initDraft = useOpenAIEditDraftStore((state) => state.initDraft);
const clearDraft = useOpenAIEditDraftStore((state) => state.clearDraft);
const setDraftForm = useOpenAIEditDraftStore((state) => state.setDraftForm);
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
const form = draft?.form ?? buildEmptyForm();
const testModel = draft?.testModel ?? '';
const testStatus = draft?.testStatus ?? 'idle';
const testMessage = draft?.testMessage ?? '';
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
(action) => {
setDraftForm(draftKey, action);
},
[draftKey, setDraftForm]
);
const setTestModel: Dispatch<SetStateAction<string>> = useCallback(
(action) => {
setDraftTestModel(draftKey, action);
},
[draftKey, setDraftTestModel]
);
const setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>> =
useCallback(
(action) => {
setDraftTestStatus(draftKey, action);
},
[draftKey, setDraftTestStatus]
);
const setTestMessage: Dispatch<SetStateAction<string>> = useCallback(
(action) => {
setDraftTestMessage(draftKey, action);
},
[draftKey, setDraftTestMessage]
);
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]
);
useEffect(() => {
ensureDraft(draftKey);
}, [draftKey, ensureDraft]);
const handleBack = useCallback(() => {
clearDraft(draftKey);
const state = location.state as LocationState;
if (state?.fromAiProviders) {
navigate(-1);
return;
}
navigate('/ai-providers', { replace: true });
}, [clearDraft, draftKey, location.state, navigate]);
useEffect(() => {
let cancelled = false;
const hasValidCache = isCacheValid('openai-compatibility');
if (!hasValidCache) {
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, isCacheValid, showNotification, t]);
useEffect(() => {
if (loading) return;
if (draft?.initialized) return;
if (initialData) {
const modelEntries = modelsToEntries(initialData.models);
const seededForm: OpenAIFormState = {
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] || '';
initDraft(draftKey, {
form: seededForm,
testModel: initialTestModel,
testStatus: 'idle',
testMessage: '',
});
} else {
initDraft(draftKey, {
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
testMessage: '',
});
}
}, [draft?.initialized, draftKey, initDraft, 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;
let addedCount = 0;
setForm((prev) => {
const mergedMap = new Map<string, ModelEntry>();
prev.modelEntries.forEach((entry) => {
const name = entry.name.trim();
if (!name) return;
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
});
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());
return {
...prev,
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
};
});
if (addedCount > 0) {
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
}
},
[setForm, 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,
})),
};
const resolvedTestModel = testModel.trim();
if (resolvedTestModel) payload.testModel = resolvedTestModel;
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,
testModel,
showNotification,
t,
updateConfigValue,
]);
const resolvedLoading = !draft?.initialized;
return (
<Outlet
context={{
hasIndexParam,
editIndex,
invalidIndexParam,
invalidIndex,
disableControls,
loading: resolvedLoading,
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

@@ -27,6 +27,9 @@
display: flex;
flex-direction: column;
gap: $spacing-xl;
padding-bottom: calc(
var(--provider-nav-height, 60px) + 12px + env(safe-area-inset-bottom) + #{$spacing-md}
);
}
.section {

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

@@ -1,56 +0,0 @@
@use '../styles/mixins' as *;
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.actions {
display: flex;
gap: $spacing-sm;
}
.emptyState {
text-align: center;
padding: $spacing-2xl;
color: var(--text-secondary);
i {
font-size: 48px;
margin-bottom: $spacing-md;
opacity: 0.5;
}
h3 {
margin: 0 0 $spacing-sm 0;
color: var(--text-primary);
}
p {
margin: 0;
}
}

View File

@@ -1,246 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { apiKeysApi } from '@/services/api';
import { maskApiKey } from '@/utils/format';
import { isValidApiKeyCharset } from '@/utils/validation';
import styles from './ApiKeysPage.module.scss';
export function ApiKeysPage() {
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [apiKeys, setApiKeys] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [inputValue, setInputValue] = useState('');
const [saving, setSaving] = useState(false);
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
const loadApiKeys = useCallback(
async (force = false) => {
setLoading(true);
setError('');
try {
const result = (await fetchConfig('api-keys', force)) as string[] | undefined;
const list = Array.isArray(result) ? result : [];
setApiKeys(list);
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
},
[fetchConfig, t]
);
useEffect(() => {
loadApiKeys();
}, [loadApiKeys]);
useEffect(() => {
if (Array.isArray(config?.apiKeys)) {
setApiKeys(config.apiKeys);
}
}, [config?.apiKeys]);
const openAddModal = () => {
setEditingIndex(null);
setInputValue('');
setModalOpen(true);
};
const openEditModal = (index: number) => {
setEditingIndex(index);
setInputValue(apiKeys[index] ?? '');
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setInputValue('');
setEditingIndex(null);
};
const handleSave = async () => {
const trimmed = inputValue.trim();
if (!trimmed) {
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
return;
}
if (!isValidApiKeyCharset(trimmed)) {
showNotification(t('notification.api_key_invalid_chars'), 'error');
return;
}
const isEdit = editingIndex !== null;
const nextKeys = isEdit
? apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key))
: [...apiKeys, trimmed];
setSaving(true);
try {
if (isEdit && editingIndex !== null) {
await apiKeysApi.update(editingIndex, trimmed);
showNotification(t('notification.api_key_updated'), 'success');
} else {
await apiKeysApi.replace(nextKeys);
showNotification(t('notification.api_key_added'), 'success');
}
setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys);
clearCache('api-keys');
closeModal();
} catch (err: any) {
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setSaving(false);
}
};
const handleDelete = (index: number) => {
const apiKeyToDelete = apiKeys[index];
if (!apiKeyToDelete) {
showNotification(t('notification.delete_failed'), 'error');
return;
}
showConfirmation({
title: t('common.delete'),
message: t('api_keys.delete_confirm'),
variant: 'danger',
onConfirm: async () => {
const latestKeys = useConfigStore.getState().config?.apiKeys;
const currentKeys = Array.isArray(latestKeys) ? latestKeys : [];
const deleteIndex =
currentKeys[index] === apiKeyToDelete
? index
: currentKeys.findIndex((key) => key === apiKeyToDelete);
if (deleteIndex < 0) {
showNotification(t('notification.delete_failed'), 'error');
return;
}
try {
await apiKeysApi.delete(deleteIndex);
const nextKeys = currentKeys.filter((_, idx) => idx !== deleteIndex);
setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys);
clearCache('api-keys');
showNotification(t('notification.api_key_deleted'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
}
});
};
const actionButtons = (
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="secondary" size="sm" onClick={() => loadApiKeys(true)} disabled={loading}>
{t('common.refresh')}
</Button>
<Button size="sm" onClick={openAddModal} disabled={disableControls}>
{t('api_keys.add_button')}
</Button>
</div>
);
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('api_keys.title')}</h1>
<Card title={t('api_keys.proxy_auth_title')} extra={actionButtons}>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="flex-center" style={{ padding: '24px 0' }}>
<LoadingSpinner size={28} />
</div>
) : apiKeys.length === 0 ? (
<EmptyState
title={t('api_keys.empty_title')}
description={t('api_keys.empty_desc')}
action={
<Button onClick={openAddModal} disabled={disableControls}>
{t('api_keys.add_button')}
</Button>
}
/>
) : (
<div className="item-list">
{apiKeys.map((key, index) => (
<div key={index} className="item-row">
<div className="item-meta">
<div className="pill">#{index + 1}</div>
<div className="item-title">{t('api_keys.item_title')}</div>
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disableControls}>
{t('common.edit')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(index)}
disabled={disableControls}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
</div>
)}
<Modal
open={modalOpen}
onClose={closeModal}
title={editingIndex !== null ? t('api_keys.edit_modal_title') : t('api_keys.add_modal_title')}
footer={
<>
<Button variant="secondary" onClick={closeModal} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} loading={saving}>
{editingIndex !== null ? t('common.update') : t('common.add')}
</Button>
</>
}
>
<Input
label={
editingIndex !== null ? t('api_keys.edit_modal_key_label') : t('api_keys.add_modal_key_label')
}
placeholder={
editingIndex !== null
? t('api_keys.edit_modal_key_label')
: t('api_keys.add_modal_key_placeholder')
}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
disabled={saving}
/>
</Modal>
</Card>
</div>
);
}

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,434 @@
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',
'kimi',
'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,483 @@
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',
'kimi',
'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: true,
});
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;
@@ -171,6 +184,18 @@
}
}
.fileGridQuotaManaged {
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
}
.antigravityGrid {
display: grid;
gap: $spacing-md;
@@ -389,6 +414,24 @@
padding: $spacing-sm 0;
}
.quotaMessageAction {
width: 100%;
border: none;
background: none;
cursor: pointer;
text-decoration: underline;
&:hover:not(:disabled) {
color: var(--text-primary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
text-decoration: none;
}
}
.quotaError {
font-size: 12px;
color: var(--danger-color);
@@ -446,6 +489,30 @@
}
}
.fileCardDisabled {
opacity: 0.6;
&:hover {
transform: none;
box-shadow: none;
border-color: var(--border-color);
}
}
.fileCardLayout {
display: flex;
align-items: stretch;
gap: $spacing-md;
}
.fileCardMain {
display: flex;
flex-direction: column;
gap: $spacing-sm;
flex: 1;
min-width: 0;
}
.cardHeader {
display: flex;
align-items: center;
@@ -718,7 +785,7 @@
border-radius: $radius-md;
}
// OAuth 排除列表
// OAuth 模型禁用
.excludedList {
display: flex;
flex-direction: column;
@@ -766,7 +833,7 @@
flex-shrink: 0;
}
// OAuth 排除列表表单:提供商快捷标签
// OAuth 模型禁用表单:提供商快捷标签
.providerField {
display: flex;
flex-direction: column;
@@ -820,6 +887,49 @@
}
}
// OAuth 模型别名 - 映射概览
.aliasChartSection {
margin-bottom: $spacing-lg;
padding-bottom: $spacing-lg;
border-bottom: 1px solid var(--border-color);
}
.aliasChartHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
margin-bottom: $spacing-sm;
}
.aliasChartTitle {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
}
.aliasChart {
width: 100%;
min-height: 120px;
}
.cardExtraButtons {
display: flex;
gap: $spacing-sm;
align-items: center;
}
.viewModeSwitch {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
padding: 2px;
border-radius: $radius-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
// OAuth 模型映射表单
.mappingRow {
display: grid;
@@ -985,3 +1095,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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,9 @@
display: flex;
flex-direction: column;
overflow-y: auto;
padding-bottom: calc(
var(--config-action-bar-height, 0px) + 16px + env(safe-area-inset-bottom) + #{$spacing-md}
);
}
.pageTitle {
@@ -21,6 +24,76 @@
margin: 0 0 $spacing-xl 0;
}
.tabBar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
margin-bottom: $spacing-lg;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: $radius-full;
width: fit-content;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
@include mobile {
width: 100%;
.tabItem {
flex: 1;
}
}
}
.tabItem {
@include button-reset;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
background: transparent;
border: 1px solid transparent;
border-radius: $radius-full;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover:not(:disabled) {
color: var(--text-primary);
background: var(--bg-tertiary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&:focus {
outline: none;
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
.tabActive {
color: var(--text-primary);
background: var(--bg-primary);
border-color: var(--border-color);
box-shadow: var(--shadow);
}
.content {
display: flex;
flex-direction: column;
@@ -242,6 +315,130 @@
}
}
.floatingActionContainer {
position: fixed;
left: var(--content-center-x, 50%);
bottom: calc(16px + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 50;
pointer-events: auto;
width: fit-content;
max-width: calc(100vw - 24px);
}
.floatingActionList {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
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: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
max-width: inherit;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.floatingStatus {
font-size: 11px;
font-weight: 600;
padding: 5px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.06);
text-align: center;
max-width: min(280px, 46vw);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.floatingActionButton {
@include button-reset;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 999px;
cursor: pointer;
color: var(--text-primary);
transition: background-color 0.2s ease, transform 0.15s ease;
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
transform: scale(1.08);
}
&:active:not(:disabled) {
transform: scale(0.95);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
}
.dirtyDot {
position: absolute;
top: 8px;
right: 8px;
width: 7px;
height: 7px;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25);
}
:global([data-theme='dark']) {
.floatingActionList {
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);
}
.floatingStatus {
background: rgba(255, 255, 255, 0.08);
}
.floatingActionButton {
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
}
}
@media (max-width: 1200px) {
.floatingActionContainer {
bottom: calc(12px + env(safe-area-inset-bottom));
max-width: calc(100vw - 16px);
}
.floatingActionList {
gap: 6px;
padding: 8px 10px;
}
.floatingStatus {
display: none;
}
.floatingActionButton {
width: 38px;
height: 38px;
flex: 0 0 auto;
}
}
@media (max-height: 820px) {
.pageTitle {
font-size: 24px;

View File

@@ -1,23 +1,53 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import CodeMirror, { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { yaml } from '@codemirror/lang-yaml';
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { keymap } from '@codemirror/view';
import { parse as parseYaml } from 'yaml';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { IconChevronDown, IconChevronUp, IconSearch } from '@/components/ui/icons';
import { IconCheck, IconChevronDown, IconChevronUp, IconRefreshCw, IconSearch } from '@/components/ui/icons';
import { VisualConfigEditor } from '@/components/config/VisualConfigEditor';
import { useVisualConfig } from '@/hooks/useVisualConfig';
import { useNotificationStore, useAuthStore, useThemeStore } from '@/stores';
import { configFileApi } from '@/services/api/configFile';
import styles from './ConfigPage.module.scss';
type ConfigEditorTab = 'visual' | 'source';
function readCommercialModeFromYaml(yamlContent: string): boolean {
try {
const parsed = parseYaml(yamlContent);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
return Boolean((parsed as Record<string, unknown>)['commercial-mode']);
} catch {
return false;
}
}
export function ConfigPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const {
visualValues,
visualDirty,
loadVisualValuesFromYaml,
applyVisualChangesToYaml,
setVisualValues
} = useVisualConfig();
const [activeTab, setActiveTab] = useState<ConfigEditorTab>(() => {
const saved = localStorage.getItem('config-management:tab');
if (saved === 'visual' || saved === 'source') return saved;
return 'visual';
});
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -31,8 +61,10 @@ export function ConfigPage() {
const editorRef = useRef<ReactCodeMirrorRef>(null);
const floatingControlsRef = useRef<HTMLDivElement>(null);
const editorWrapperRef = useRef<HTMLDivElement>(null);
const floatingActionsRef = useRef<HTMLDivElement>(null);
const disableControls = connectionStatus !== 'connected';
const isDirty = dirty || visualDirty;
const loadConfig = useCallback(async () => {
setLoading(true);
@@ -41,13 +73,14 @@ export function ConfigPage() {
const data = await configFileApi.fetchConfigYaml();
setContent(data);
setDirty(false);
loadVisualValuesFromYaml(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('notification.refresh_failed');
setError(message);
} finally {
setLoading(false);
}
}, [t]);
}, [loadVisualValuesFromYaml, t]);
useEffect(() => {
loadConfig();
@@ -56,9 +89,19 @@ export function ConfigPage() {
const handleSave = async () => {
setSaving(true);
try {
await configFileApi.saveConfigYaml(content);
const previousCommercialMode = readCommercialModeFromYaml(content);
const nextContent = activeTab === 'visual' ? applyVisualChangesToYaml(content) : content;
const nextCommercialMode = readCommercialModeFromYaml(nextContent);
const commercialModeChanged = previousCommercialMode !== nextCommercialMode;
await configFileApi.saveConfigYaml(nextContent);
const latestContent = await configFileApi.fetchConfigYaml();
setDirty(false);
setContent(latestContent);
loadVisualValuesFromYaml(latestContent);
showNotification(t('config_management.save_success'), 'success');
if (commercialModeChanged) {
showNotification(t('notification.commercial_mode_restart_required'), 'warning');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(`${t('notification.save_failed')}: ${message}`, 'error');
@@ -72,6 +115,23 @@ export function ConfigPage() {
setDirty(true);
}, []);
const handleTabChange = useCallback((tab: ConfigEditorTab) => {
if (tab === activeTab) return;
if (tab === 'source') {
const nextContent = applyVisualChangesToYaml(content);
if (nextContent !== content) {
setContent(nextContent);
setDirty(true);
}
} else {
loadVisualValuesFromYaml(content);
}
setActiveTab(tab);
localStorage.setItem('config-management:tab', tab);
}, [activeTab, applyVisualChangesToYaml, content, loadVisualValuesFromYaml]);
// Search functionality
const performSearch = useCallback((query: string, direction: 'next' | 'prev' = 'next') => {
if (!query || !editorRef.current?.view) return;
@@ -173,6 +233,8 @@ export function ConfigPage() {
// Keep floating controls from covering editor content by syncing its height to a CSS variable.
useLayoutEffect(() => {
if (activeTab !== 'source') return;
const controlsEl = floatingControlsRef.current;
const wrapperEl = editorWrapperRef.current;
if (!controlsEl || !wrapperEl) return;
@@ -192,6 +254,31 @@ export function ConfigPage() {
ro?.disconnect();
window.removeEventListener('resize', updatePadding);
};
}, [activeTab]);
// Keep bottom floating actions from covering page content by syncing its height to a CSS variable.
useLayoutEffect(() => {
if (typeof window === 'undefined') return;
const actionsEl = floatingActionsRef.current;
if (!actionsEl) return;
const updatePadding = () => {
const height = actionsEl.getBoundingClientRect().height;
document.documentElement.style.setProperty('--config-action-bar-height', `${height}px`);
};
updatePadding();
window.addEventListener('resize', updatePadding);
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePadding);
ro?.observe(actionsEl);
return () => {
ro?.disconnect();
window.removeEventListener('resize', updatePadding);
document.documentElement.style.removeProperty('--config-action-bar-height');
};
}, []);
// CodeMirror extensions
@@ -208,131 +295,185 @@ export function ConfigPage() {
if (loading) return t('config_management.status_loading');
if (error) return t('config_management.status_load_failed');
if (saving) return t('config_management.status_saving');
if (dirty) return t('config_management.status_dirty');
if (isDirty) return t('config_management.status_dirty');
return t('config_management.status_loaded');
};
const isLoadedStatus = !disableControls && !loading && !error && !saving && !isDirty;
const getStatusClass = () => {
if (error) return styles.error;
if (dirty) return styles.modified;
if (isDirty) return styles.modified;
if (!loading && !saving) return styles.saved;
return '';
};
const floatingActions = (
<div className={styles.floatingActionContainer} ref={floatingActionsRef}>
<div className={styles.floatingActionList}>
<div className={`${styles.floatingStatus} ${styles.status} ${getStatusClass()}`}>{getStatusText()}</div>
<button
type="button"
className={styles.floatingActionButton}
onClick={loadConfig}
disabled={loading}
title={t('config_management.reload')}
aria-label={t('config_management.reload')}
>
<IconRefreshCw size={16} />
</button>
<button
type="button"
className={styles.floatingActionButton}
onClick={handleSave}
disabled={disableControls || loading || saving || !isDirty}
title={t('config_management.save')}
aria-label={t('config_management.save')}
>
<IconCheck size={16} />
{isDirty && <span className={styles.dirtyDot} aria-hidden="true" />}
</button>
</div>
</div>
);
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
<p className={styles.description}>{t('config_management.description')}</p>
<div className={styles.tabBar}>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'visual' ? styles.tabActive : ''}`}
onClick={() => handleTabChange('visual')}
disabled={saving || loading}
>
{t('config_management.tabs.visual', { defaultValue: '可视化编辑' })}
</button>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'source' ? styles.tabActive : ''}`}
onClick={() => handleTabChange('source')}
disabled={saving || loading}
>
{t('config_management.tabs.source', { defaultValue: '源代码编辑' })}
</button>
</div>
<Card className={styles.configCard}>
<div className={styles.content}>
{/* Editor */}
{error && <div className="error-box">{error}</div>}
<div className={styles.editorWrapper} ref={editorWrapperRef}>
{/* Floating search controls */}
<div className={styles.floatingControls} ref={floatingControlsRef}>
<div className={styles.searchInputWrapper}>
<Input
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={t('config_management.search_placeholder', {
defaultValue: '搜索配置内容...'
})}
disabled={disableControls || loading}
className={styles.searchInput}
rightElement={
<div className={styles.searchRight}>
{searchQuery && lastSearchedQuery === searchQuery && (
<span className={styles.searchCount}>
{searchResults.total > 0
? `${searchResults.current} / ${searchResults.total}`
: t('config_management.search_no_results', { defaultValue: '无结果' })}
</span>
)}
<button
type="button"
className={styles.searchButton}
onClick={() => executeSearch('next')}
disabled={!searchQuery || disableControls || loading}
title={t('config_management.search_button', { defaultValue: '搜索' })}
>
<IconSearch size={16} />
</button>
</div>
}
/>
</div>
<div className={styles.searchActions}>
<Button
variant="secondary"
size="sm"
onClick={handlePrevMatch}
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
title={t('config_management.search_prev', { defaultValue: '上一个' })}
>
<IconChevronUp size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleNextMatch}
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
title={t('config_management.search_next', { defaultValue: '下一个' })}
>
<IconChevronDown size={16} />
</Button>
</div>
</div>
<CodeMirror
ref={editorRef}
value={content}
onChange={handleChange}
extensions={extensions}
theme={resolvedTheme}
editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')}
height="100%"
style={{ height: '100%' }}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightActiveLine: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: false,
rectangularSelection: true,
crosshairCursor: false,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
searchKeymap: true,
foldKeymap: true,
completionKeymap: false,
lintKeymap: true
}}
{activeTab === 'visual' ? (
<VisualConfigEditor
values={visualValues}
disabled={disableControls || loading}
onChange={setVisualValues}
/>
</div>
) : (
<div className={styles.editorWrapper} ref={editorWrapperRef}>
{/* Floating search controls */}
<div className={styles.floatingControls} ref={floatingControlsRef}>
<div className={styles.searchInputWrapper}>
<Input
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={t('config_management.search_placeholder', {
defaultValue: '搜索配置内容...'
})}
disabled={disableControls || loading}
className={styles.searchInput}
rightElement={
<div className={styles.searchRight}>
{searchQuery && lastSearchedQuery === searchQuery && (
<span className={styles.searchCount}>
{searchResults.total > 0
? `${searchResults.current} / ${searchResults.total}`
: t('config_management.search_no_results', { defaultValue: '无结果' })}
</span>
)}
<button
type="button"
className={styles.searchButton}
onClick={() => executeSearch('next')}
disabled={!searchQuery || disableControls || loading}
title={t('config_management.search_button', { defaultValue: '搜索' })}
>
<IconSearch size={16} />
</button>
</div>
}
/>
</div>
<div className={styles.searchActions}>
<Button
variant="secondary"
size="sm"
onClick={handlePrevMatch}
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
title={t('config_management.search_prev', { defaultValue: '上一个' })}
>
<IconChevronUp size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleNextMatch}
disabled={!searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0}
title={t('config_management.search_next', { defaultValue: '下一个' })}
>
<IconChevronDown size={16} />
</Button>
</div>
</div>
<CodeMirror
ref={editorRef}
value={content}
onChange={handleChange}
extensions={extensions}
theme={resolvedTheme}
editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')}
height="100%"
style={{ height: '100%' }}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightActiveLine: true,
foldGutter: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: false,
rectangularSelection: true,
crosshairCursor: false,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
searchKeymap: true,
foldKeymap: true,
completionKeymap: false,
lintKeymap: true
}}
/>
</div>
)}
{/* Controls */}
<div className={styles.controls}>
<span className={`${styles.status} ${getStatusClass()}`}>
{getStatusText()}
</span>
<div className={styles.actions}>
<Button variant="secondary" size="sm" onClick={loadConfig} disabled={loading}>
{t('config_management.reload')}
</Button>
<Button size="sm" onClick={handleSave} loading={saving} disabled={disableControls || loading || !dirty}>
{t('config_management.save')}
</Button>
</div>
{!isLoadedStatus && (
<span className={`${styles.status} ${getStatusClass()}`}>
{getStatusText()}
</span>
)}
</div>
</div>
</Card>
{typeof document !== 'undefined' ? createPortal(floatingActions, document.body) : null}
</div>
);
}

View File

@@ -172,12 +172,12 @@ export function DashboardPage() {
const quickStats: QuickStat[] = [
{
label: t('nav.api_keys'),
label: t('dashboard.management_keys'),
value: stats.apiKeys ?? '-',
icon: <IconKey size={24} />,
path: '/api-keys',
path: '/config',
loading: loading && stats.apiKeys === null,
sublabel: t('dashboard.management_keys')
sublabel: t('nav.config_management')
},
{
label: t('nav.ai_providers'),
@@ -309,7 +309,7 @@ export function DashboardPage() {
</div>
)}
</div>
<Link to="/settings" className={styles.viewMoreLink}>
<Link to="/config" className={styles.viewMoreLink}>
{t('dashboard.edit_settings')}
</Link>
</div>

View File

@@ -0,0 +1,343 @@
@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;
}
// 语言下拉选择
.languageSelect {
white-space: nowrap;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px 12px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
height: 40px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
// 连接信息框
.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,54 @@ 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 { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
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();
@@ -13,7 +61,7 @@ export function LoginPage() {
const location = useLocation();
const { showNotification } = useNotificationStore();
const language = useLanguageStore((state) => state.language);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const login = useAuthStore((state) => state.login);
const restoreSession = useAuthStore((state) => state.restoreSession);
@@ -28,29 +76,49 @@ 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(), []);
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
const handleLanguageChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const selectedLanguage = event.target.value;
if (!isSupportedLanguage(selectedLanguage)) {
return;
}
setLanguage(selectedLanguage);
},
[setLanguage]
);
useEffect(() => {
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 });
}, 1500);
} 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 +136,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 +154,136 @@ 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>
<select
className={styles.languageSelect}
value={language}
onChange={handleLanguageChange}
title={t('language.switch')}
aria-label={t('language.switch')}
>
{LANGUAGE_ORDER.map((lang) => (
<option key={lang} value={lang}>
{t(LANGUAGE_LABEL_KEYS[lang])}
</option>
))}
</select>
</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

@@ -1,4 +1,4 @@
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
@@ -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,13 +634,38 @@ 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;
const prependVisibleLines = useCallback(() => {
const node = logViewerRef.current;
if (!node) return;
if (pendingPrependScrollRef.current) return;
if (isSearching) return;
setLogState((prev) => {
if (prev.visibleFrom <= 0) {
return prev;
}
pendingPrependScrollRef.current = {
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
};
return {
...prev,
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
};
});
}, [isSearching]);
const handleLogScroll = () => {
const node = logViewerRef.current;
if (!node) return;
@@ -647,14 +674,7 @@ export function LogsPage() {
if (pendingPrependScrollRef.current) return;
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
pendingPrependScrollRef.current = {
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
};
setLogState((prev) => ({
...prev,
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
}));
prependVisibleLines();
};
useLayoutEffect(() => {
@@ -667,6 +687,53 @@ export function LogsPage() {
pendingPrependScrollRef.current = null;
}, [logState.visibleFrom]);
const tryAutoLoadMoreUntilScrollable = useCallback(() => {
const node = logViewerRef.current;
if (!node) return;
if (!canLoadMore) return;
if (isSearching) return;
if (pendingPrependScrollRef.current) return;
const hasVerticalOverflow = node.scrollHeight > node.clientHeight + 1;
if (hasVerticalOverflow) return;
prependVisibleLines();
}, [canLoadMore, isSearching, prependVisibleLines]);
useEffect(() => {
if (loading) return;
if (activeTab !== 'logs') return;
const raf = window.requestAnimationFrame(() => {
tryAutoLoadMoreUntilScrollable();
});
return () => {
window.cancelAnimationFrame(raf);
};
}, [
activeTab,
loading,
tryAutoLoadMoreUntilScrollable,
filteredLines.length,
showRawLogs,
logState.visibleFrom,
]);
useEffect(() => {
if (activeTab !== 'logs') return;
const onResize = () => {
window.requestAnimationFrame(() => {
tryAutoLoadMoreUntilScrollable();
});
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [activeTab, tryAutoLoadMoreUntilScrollable]);
const copyLogLine = async (raw: string) => {
const ok = await copyToClipboard(raw);
if (ok) {
@@ -817,6 +884,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 +953,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 +973,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,11 +7,13 @@ 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';
import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg';
@@ -55,10 +57,11 @@ 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 },
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } },
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
];
@@ -85,11 +88,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

@@ -1,164 +0,0 @@
@use '../../styles/mixins' as *;
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.grid {
display: grid;
gap: $spacing-lg;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
@include mobile {
grid-template-columns: 1fr;
}
}
.settingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
}
.settingInfo {
flex: 1;
h4 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 $spacing-xs 0;
}
p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
}
.switch {
position: relative;
display: inline-block;
width: 52px;
height: 28px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
&:checked + .slider {
background-color: var(--primary-color);
&:before {
transform: translateX(24px);
}
}
&:focus + .slider {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
}
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--border-color);
transition: $transition-fast;
border-radius: $radius-full;
&:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: $transition-fast;
border-radius: $radius-full;
}
}
.formGroup {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.buttonGroup {
display: flex;
gap: $spacing-sm;
}
.retryRow {
display: flex;
align-items: flex-end;
gap: $spacing-md;
flex-wrap: wrap;
:global(.form-group) {
margin-bottom: 0;
}
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.retryRowAligned {
align-items: flex-start;
.retryButton {
margin-top: calc(1.5em + #{$spacing-xs});
}
@include mobile {
align-items: stretch;
.retryButton {
margin-top: 0;
}
}
}
.retryRowInputGrow {
:global(.form-group) {
flex: 1 1 0;
min-width: 0;
}
.retryInput {
width: 100%;
}
}
.retryInput {
width: 140px;
@include mobile {
width: 100%;
}
}
.retryButton {
@include mobile {
width: 100%;
}
}

View File

@@ -1,477 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { configApi } from '@/services/api';
import type { Config } from '@/types';
import styles from './Settings/Settings.module.scss';
type PendingKey =
| 'debug'
| 'proxy'
| 'retry'
| 'logsMaxSize'
| 'forceModelPrefix'
| 'routingStrategy'
| 'switchProject'
| 'switchPreview'
| 'usage'
| 'loggingToFile'
| 'wsAuth';
export function SettingsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [loading, setLoading] = useState(true);
const [proxyValue, setProxyValue] = useState('');
const [retryValue, setRetryValue] = useState(0);
const [logsMaxTotalSizeMb, setLogsMaxTotalSizeMb] = useState(0);
const [routingStrategy, setRoutingStrategy] = useState('round-robin');
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
const [error, setError] = useState('');
const disableControls = connectionStatus !== 'connected';
useEffect(() => {
const load = async () => {
setLoading(true);
setError('');
try {
const [configResult, logsResult, prefixResult, routingResult] = await Promise.allSettled([
fetchConfig(),
configApi.getLogsMaxTotalSizeMb(),
configApi.getForceModelPrefix(),
configApi.getRoutingStrategy(),
]);
if (configResult.status !== 'fulfilled') {
throw configResult.reason;
}
const data = configResult.value as Config;
setProxyValue(data?.proxyUrl ?? '');
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
if (logsResult.status === 'fulfilled' && Number.isFinite(logsResult.value)) {
setLogsMaxTotalSizeMb(Math.max(0, Number(logsResult.value)));
updateConfigValue('logs-max-total-size-mb', Math.max(0, Number(logsResult.value)));
}
if (prefixResult.status === 'fulfilled') {
updateConfigValue('force-model-prefix', Boolean(prefixResult.value));
}
if (routingResult.status === 'fulfilled' && routingResult.value) {
setRoutingStrategy(String(routingResult.value));
updateConfigValue('routing/strategy', String(routingResult.value));
}
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
};
load();
}, [fetchConfig, t, updateConfigValue]);
useEffect(() => {
if (config) {
setProxyValue(config.proxyUrl ?? '');
if (typeof config.requestRetry === 'number') {
setRetryValue(config.requestRetry);
}
if (typeof config.logsMaxTotalSizeMb === 'number') {
setLogsMaxTotalSizeMb(config.logsMaxTotalSizeMb);
}
if (config.routingStrategy) {
setRoutingStrategy(config.routingStrategy);
}
}
}, [config?.proxyUrl, config?.requestRetry, config?.logsMaxTotalSizeMb, config?.routingStrategy]);
const setPendingFlag = (key: PendingKey, value: boolean) => {
setPending((prev) => ({ ...prev, [key]: value }));
};
const toggleSetting = async (
section: PendingKey,
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth' | 'force-model-prefix',
value: boolean,
updater: (val: boolean) => Promise<any>,
successMessage: string
) => {
const previous = (() => {
switch (rawKey) {
case 'debug':
return config?.debug ?? false;
case 'usage-statistics-enabled':
return config?.usageStatisticsEnabled ?? false;
case 'logging-to-file':
return config?.loggingToFile ?? false;
case 'ws-auth':
return config?.wsAuth ?? false;
case 'force-model-prefix':
return config?.forceModelPrefix ?? false;
default:
return false;
}
})();
setPendingFlag(section, true);
updateConfigValue(rawKey, value);
try {
await updater(value);
clearCache(rawKey);
showNotification(successMessage, 'success');
} catch (err: any) {
updateConfigValue(rawKey, previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag(section, false);
}
};
const handleProxyUpdate = async () => {
const previous = config?.proxyUrl ?? '';
setPendingFlag('proxy', true);
updateConfigValue('proxy-url', proxyValue);
try {
await configApi.updateProxyUrl(proxyValue.trim());
clearCache('proxy-url');
showNotification(t('notification.proxy_updated'), 'success');
} catch (err: any) {
setProxyValue(previous);
updateConfigValue('proxy-url', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('proxy', false);
}
};
const handleProxyClear = async () => {
const previous = config?.proxyUrl ?? '';
setPendingFlag('proxy', true);
updateConfigValue('proxy-url', '');
try {
await configApi.clearProxyUrl();
clearCache('proxy-url');
setProxyValue('');
showNotification(t('notification.proxy_cleared'), 'success');
} catch (err: any) {
setProxyValue(previous);
updateConfigValue('proxy-url', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('proxy', false);
}
};
const handleRetryUpdate = async () => {
const previous = config?.requestRetry ?? 0;
const parsed = Number(retryValue);
if (!Number.isFinite(parsed) || parsed < 0) {
showNotification(t('login.error_invalid'), 'error');
setRetryValue(previous);
return;
}
setPendingFlag('retry', true);
updateConfigValue('request-retry', parsed);
try {
await configApi.updateRequestRetry(parsed);
clearCache('request-retry');
showNotification(t('notification.retry_updated'), 'success');
} catch (err: any) {
setRetryValue(previous);
updateConfigValue('request-retry', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('retry', false);
}
};
const handleLogsMaxTotalSizeUpdate = async () => {
const previous = config?.logsMaxTotalSizeMb ?? 0;
const parsed = Number(logsMaxTotalSizeMb);
if (!Number.isFinite(parsed) || parsed < 0) {
showNotification(t('login.error_invalid'), 'error');
setLogsMaxTotalSizeMb(previous);
return;
}
const normalized = Math.max(0, parsed);
setPendingFlag('logsMaxSize', true);
updateConfigValue('logs-max-total-size-mb', normalized);
try {
await configApi.updateLogsMaxTotalSizeMb(normalized);
clearCache('logs-max-total-size-mb');
showNotification(t('notification.logs_max_total_size_updated'), 'success');
} catch (err: any) {
setLogsMaxTotalSizeMb(previous);
updateConfigValue('logs-max-total-size-mb', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('logsMaxSize', false);
}
};
const handleRoutingStrategyUpdate = async () => {
const strategy = routingStrategy.trim();
if (!strategy) {
showNotification(t('login.error_invalid'), 'error');
return;
}
const previous = config?.routingStrategy ?? 'round-robin';
setPendingFlag('routingStrategy', true);
updateConfigValue('routing/strategy', strategy);
try {
await configApi.updateRoutingStrategy(strategy);
clearCache('routing/strategy');
showNotification(t('notification.routing_strategy_updated'), 'success');
} catch (err: any) {
setRoutingStrategy(previous);
updateConfigValue('routing/strategy', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('routingStrategy', false);
}
};
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('basic_settings.title')}</h1>
<div className={styles.grid}>
<Card>
{error && <div className="error-box">{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch
label={t('basic_settings.debug_enable')}
checked={config?.debug ?? false}
disabled={disableControls || pending.debug || loading}
onChange={(value) =>
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
}
/>
<ToggleSwitch
label={t('basic_settings.usage_statistics_enable')}
checked={config?.usageStatisticsEnabled ?? false}
disabled={disableControls || pending.usage || loading}
onChange={(value) =>
toggleSetting(
'usage',
'usage-statistics-enabled',
value,
configApi.updateUsageStatistics,
t('notification.usage_statistics_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.logging_to_file_enable')}
checked={config?.loggingToFile ?? false}
disabled={disableControls || pending.loggingToFile || loading}
onChange={(value) =>
toggleSetting(
'loggingToFile',
'logging-to-file',
value,
configApi.updateLoggingToFile,
t('notification.logging_to_file_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.ws_auth_enable')}
checked={config?.wsAuth ?? false}
disabled={disableControls || pending.wsAuth || loading}
onChange={(value) =>
toggleSetting(
'wsAuth',
'ws-auth',
value,
configApi.updateWsAuth,
t('notification.ws_auth_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.force_model_prefix_enable')}
checked={config?.forceModelPrefix ?? false}
disabled={disableControls || pending.forceModelPrefix || loading}
onChange={(value) =>
toggleSetting(
'forceModelPrefix',
'force-model-prefix',
value,
configApi.updateForceModelPrefix,
t('notification.force_model_prefix_updated')
)
}
/>
</div>
</Card>
<Card title={t('basic_settings.proxy_title')}>
<Input
label={t('basic_settings.proxy_url_label')}
placeholder={t('basic_settings.proxy_url_placeholder')}
value={proxyValue}
onChange={(e) => setProxyValue(e.target.value)}
disabled={disableControls || loading}
/>
<div style={{ display: 'flex', gap: 12 }}>
<Button variant="secondary" onClick={handleProxyClear} disabled={disableControls || pending.proxy || loading}>
{t('basic_settings.proxy_clear')}
</Button>
<Button onClick={handleProxyUpdate} loading={pending.proxy} disabled={disableControls || loading}>
{t('basic_settings.proxy_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.retry_title')}>
<div className={styles.retryRow}>
<Input
label={t('basic_settings.retry_count_label')}
type="number"
inputMode="numeric"
min={0}
step={1}
value={retryValue}
onChange={(e) => setRetryValue(Number(e.target.value))}
disabled={disableControls || loading}
className={styles.retryInput}
/>
<Button
className={styles.retryButton}
onClick={handleRetryUpdate}
loading={pending.retry}
disabled={disableControls || loading}
>
{t('basic_settings.retry_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.logs_max_total_size_title')}>
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
<Input
label={t('basic_settings.logs_max_total_size_label')}
hint={t('basic_settings.logs_max_total_size_hint')}
type="number"
inputMode="numeric"
min={0}
step={1}
value={logsMaxTotalSizeMb}
onChange={(e) => setLogsMaxTotalSizeMb(Number(e.target.value))}
disabled={disableControls || loading}
className={styles.retryInput}
/>
<Button
className={styles.retryButton}
onClick={handleLogsMaxTotalSizeUpdate}
loading={pending.logsMaxSize}
disabled={disableControls || loading}
>
{t('basic_settings.logs_max_total_size_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.routing_title')}>
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
<div className="form-group">
<label>{t('basic_settings.routing_strategy_label')}</label>
<select
className="input"
value={routingStrategy}
onChange={(e) => setRoutingStrategy(e.target.value)}
disabled={disableControls || loading}
>
<option value="round-robin">{t('basic_settings.routing_strategy_round_robin')}</option>
<option value="fill-first">{t('basic_settings.routing_strategy_fill_first')}</option>
</select>
<div className="hint">{t('basic_settings.routing_strategy_hint')}</div>
</div>
<Button
className={styles.retryButton}
onClick={handleRoutingStrategyUpdate}
loading={pending.routingStrategy}
disabled={disableControls || loading}
>
{t('basic_settings.routing_strategy_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.quota_title')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch
label={t('basic_settings.quota_switch_project')}
checked={quotaSwitchProject}
disabled={disableControls || pending.switchProject || loading}
onChange={(value) =>
(async () => {
const previous = config?.quotaExceeded?.switchProject ?? false;
const nextQuota = { ...(config?.quotaExceeded || {}), switchProject: value };
setPendingFlag('switchProject', true);
updateConfigValue('quota-exceeded', nextQuota);
try {
await configApi.updateSwitchProject(value);
clearCache('quota-exceeded');
showNotification(t('notification.quota_switch_project_updated'), 'success');
} catch (err: any) {
updateConfigValue('quota-exceeded', { ...(config?.quotaExceeded || {}), switchProject: previous });
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('switchProject', false);
}
})()
}
/>
<ToggleSwitch
label={t('basic_settings.quota_switch_preview')}
checked={quotaSwitchPreview}
disabled={disableControls || pending.switchPreview || loading}
onChange={(value) =>
(async () => {
const previous = config?.quotaExceeded?.switchPreviewModel ?? false;
const nextQuota = { ...(config?.quotaExceeded || {}), switchPreviewModel: value };
setPendingFlag('switchPreview', true);
updateConfigValue('quota-exceeded', nextQuota);
try {
await configApi.updateSwitchPreviewModel(value);
clearCache('quota-exceeded');
showNotification(t('notification.quota_switch_preview_updated'), 'success');
} catch (err: any) {
updateConfigValue('quota-exceeded', { ...(config?.quotaExceeded || {}), switchPreviewModel: previous });
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('switchPreview', false);
}
})()
}
/>
</div>
</Card>
</div>
</div>
);
}

View File

@@ -15,6 +15,108 @@
gap: $spacing-xl;
}
.aboutCard {
overflow: hidden;
}
.aboutHeader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
gap: $spacing-md;
padding: $spacing-lg 0 $spacing-xl;
}
.aboutLogo {
width: 108px;
height: 108px;
border-radius: 26px;
object-fit: cover;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
}
.aboutTitle {
width: min(100%, 920px);
font-size: clamp(28px, 4.2vw, 44px);
font-weight: 800;
line-height: 1.12;
color: var(--text-primary);
letter-spacing: -0.02em;
text-align: center;
text-wrap: balance;
white-space: normal;
overflow-wrap: anywhere;
}
.aboutInfoGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $spacing-md;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
.infoTile {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 120px;
padding: $spacing-md $spacing-lg;
border-radius: $radius-lg;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
text-align: left;
}
.tapTile {
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
color: inherit;
padding: $spacing-md $spacing-lg;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-1px);
border-color: var(--primary-color);
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.15);
}
&:active {
transform: translateY(0);
}
}
.tileLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.tileValue {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.25;
word-break: break-word;
}
.tileSub {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.aboutActions {
display: flex;
justify-content: flex-end;
margin-top: $spacing-lg;
}
.section {
display: flex;
flex-direction: column;
@@ -90,6 +192,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;
@@ -219,3 +333,29 @@
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.aboutLogo {
width: 92px;
height: 92px;
border-radius: 22px;
}
.aboutTitle {
width: min(100%, 24ch);
font-size: clamp(22px, 6.6vw, 34px);
font-weight: 700;
line-height: 1.18;
letter-spacing: -0.012em;
}
}
@media (max-width: 520px) {
.aboutTitle {
width: min(100%, 19ch);
font-size: clamp(20px, 7.2vw, 28px);
font-weight: 600;
line-height: 1.22;
letter-spacing: -0.006em;
}
}

View File

@@ -2,19 +2,49 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
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 { configApi } from '@/services/api';
import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
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);
const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const models = useModelsStore((state) => state.models);
const modelsLoading = useModelsStore((state) => state.loading);
@@ -22,14 +52,36 @@ export function SystemPage() {
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const apiKeysCache = useRef<string[]>([]);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const otherLabel = useMemo(
() => (i18n.language?.toLowerCase().startsWith('zh') ? '其他' : 'Other'),
[i18n.language]
);
const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]);
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = auth.connectionStatus === 'connected' && Boolean(config);
const appVersion = __APP_VERSION__ || t('system_info.version_unknown');
const apiVersion = auth.serverVersion || t('system_info.version_unknown');
const buildTime = auth.serverBuildDate
? new Date(auth.serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown');
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 [];
@@ -121,12 +173,80 @@ export function SystemPage() {
});
};
const openRequestLogModal = useCallback(() => {
setRequestLogTouched(false);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleInfoVersionTap = useCallback(() => {
versionTapCount.current += 1;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
versionTapTimer.current = null;
openRequestLogModal();
return;
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
versionTapTimer.current = null;
}, 1500);
}, [openRequestLogModal]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
}, []);
const handleRequestLogSave = async () => {
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: any) {
updateConfigValue('request-log', previous);
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
} finally {
setRequestLogSaving(false);
}
};
useEffect(() => {
fetchConfig().catch(() => {
// ignore
});
}, [fetchConfig]);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
};
}, []);
useEffect(() => {
fetchModels();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -136,33 +256,43 @@ export function SystemPage() {
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('system_info.title')}</h1>
<div className={styles.content}>
<Card
title={t('system_info.connection_status_title')}
extra={
<Card className={styles.aboutCard}>
<div className={styles.aboutHeader}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
</div>
<div className={styles.aboutInfoGrid}>
<button
type="button"
className={`${styles.infoTile} ${styles.tapTile}`}
onClick={handleInfoVersionTap}
>
<div className={styles.tileLabel}>{t('footer.version')}</div>
<div className={styles.tileValue}>{appVersion}</div>
</button>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
<div className={styles.tileValue}>{apiVersion}</div>
</div>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
<div className={styles.tileValue}>{buildTime}</div>
</div>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('connection.status')}</div>
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status` as any)}</div>
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
</div>
</div>
<div className={styles.aboutActions}>
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
{t('common.refresh')}
</Button>
}
>
<div className="grid cols-2">
<div className="stat-card">
<div className="stat-label">{t('connection.server_address')}</div>
<div className="stat-value">{auth.apiBase || '-'}</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('footer.api_version')}</div>
<div className="stat-value">{auth.serverVersion || t('system_info.version_unknown')}</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('footer.build_date')}</div>
<div className="stat-value">
{auth.serverBuildDate ? new Date(auth.serverBuildDate).toLocaleString() : t('system_info.version_unknown')}
</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('connection.status')}</div>
<div className="stat-value">{t(`common.${auth.connectionStatus}_status` as any)}</div>
</div>
</div>
</Card>
@@ -242,26 +372,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>
@@ -275,6 +411,40 @@ export function SystemPage() {
</div>
</Card>
</div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div>
);
}

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

@@ -1,9 +1,17 @@
import { Navigate, useRoutes, type Location } from 'react-router-dom';
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';
@@ -14,10 +22,38 @@ import { SystemPage } from '@/pages/SystemPage';
const mainRoutes = [
{ path: '/', element: <DashboardPage /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/api-keys', element: <ApiKeysPage /> },
{ path: '/settings', element: <Navigate to="/config" replace /> },
{ path: '/api-keys', element: <Navigate to="/config" replace /> },
{ 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

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

View File

@@ -9,6 +9,7 @@ export type OAuthProvider =
| 'anthropic'
| 'antigravity'
| 'gemini-cli'
| 'kimi'
| 'qwen';
export interface OAuthStartResponse {

View File

@@ -9,3 +9,4 @@ export { useAuthStore } from './useAuthStore';
export { useConfigStore } from './useConfigStore';
export { useModelsStore } from './useModelsStore';
export { useQuotaStore } from './useQuotaStore';
export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore';

View File

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

View File

@@ -6,13 +6,13 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import { LANGUAGE_ORDER, STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import i18n from '@/i18n';
import { getInitialLanguage } from '@/utils/language';
import { getInitialLanguage, isSupportedLanguage } from '@/utils/language';
interface LanguageState {
language: Language;
setLanguage: (language: Language) => void;
setLanguage: (language: string) => void;
toggleLanguage: () => void;
}
@@ -22,6 +22,9 @@ export const useLanguageStore = create<LanguageState>()(
language: getInitialLanguage(),
setLanguage: (language) => {
if (!isSupportedLanguage(language)) {
return;
}
// 切换 i18next 语言
i18n.changeLanguage(language);
set({ language });
@@ -29,12 +32,24 @@ export const useLanguageStore = create<LanguageState>()(
toggleLanguage: () => {
const { language, setLanguage } = get();
const newLanguage: Language = language === 'zh-CN' ? 'en' : 'zh-CN';
setLanguage(newLanguage);
const currentIndex = LANGUAGE_ORDER.indexOf(language);
const nextLanguage = LANGUAGE_ORDER[(currentIndex + 1) % LANGUAGE_ORDER.length];
setLanguage(nextLanguage);
}
}),
{
name: STORAGE_KEY_LANGUAGE
name: STORAGE_KEY_LANGUAGE,
merge: (persistedState, currentState) => {
const nextLanguage = (persistedState as Partial<LanguageState>)?.language;
if (typeof nextLanguage === 'string' && isSupportedLanguage(nextLanguage)) {
return {
...currentState,
...(persistedState as Partial<LanguageState>),
language: nextLanguage
};
}
return currentState;
}
}
)
);

View File

@@ -4,13 +4,14 @@
*/
import { create } from 'zustand';
import type { ReactNode } from 'react';
import type { Notification, NotificationType } from '@/types';
import { generateId } from '@/utils/helpers';
import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
interface ConfirmationOptions {
title?: string;
message: string;
message: ReactNode;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'primary' | 'secondary';

View File

@@ -0,0 +1,148 @@
/**
* OpenAI provider editor draft state.
*
* Why this exists:
* - The app uses `PageTransition` with iOS-style stacked routes for `/ai-providers/*`.
* - Entering `/ai-providers/openai/.../models` creates a new route layer, so component-local state
* inside the OpenAI edit layout is not shared between the edit screen and the model picker screen.
* - This store makes the OpenAI edit draft shared across route layers keyed by provider index/new.
*/
import type { SetStateAction } from 'react';
import { create } from 'zustand';
import type { OpenAIFormState } from '@/components/providers/types';
import { buildApiKeyEntry } from '@/components/providers/utils';
export type OpenAITestStatus = 'idle' | 'loading' | 'success' | 'error';
export type OpenAIEditDraft = {
initialized: boolean;
form: OpenAIFormState;
testModel: string;
testStatus: OpenAITestStatus;
testMessage: string;
};
interface OpenAIEditDraftState {
drafts: Record<string, OpenAIEditDraft>;
ensureDraft: (key: string) => void;
initDraft: (key: string, draft: Omit<OpenAIEditDraft, 'initialized'>) => void;
setDraftForm: (key: string, action: SetStateAction<OpenAIFormState>) => void;
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
clearDraft: (key: string) => void;
}
const resolveAction = <T,>(action: SetStateAction<T>, prev: T): T =>
typeof action === 'function' ? (action as (previous: T) => T)(prev) : action;
const buildEmptyForm = (): OpenAIFormState => ({
name: '',
prefix: '',
baseUrl: '',
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
modelEntries: [{ name: '', alias: '' }],
testModel: undefined,
});
const buildEmptyDraft = (): OpenAIEditDraft => ({
initialized: false,
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
testMessage: '',
});
export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) => ({
drafts: {},
ensureDraft: (key) => {
if (!key) return;
const existing = get().drafts[key];
if (existing) return;
set((state) => ({
drafts: { ...state.drafts, [key]: buildEmptyDraft() },
}));
},
initDraft: (key, draft) => {
if (!key) return;
const existing = get().drafts[key];
if (existing?.initialized) return;
set((state) => ({
drafts: {
...state.drafts,
[key]: { ...draft, initialized: true },
},
}));
},
setDraftForm: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextForm = resolveAction(action, existing.form);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, form: nextForm },
},
};
});
},
setDraftTestModel: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextValue = resolveAction(action, existing.testModel);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, testModel: nextValue },
},
};
});
},
setDraftTestStatus: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextValue = resolveAction(action, existing.testStatus);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, testStatus: nextValue },
},
};
});
},
setDraftTestMessage: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextValue = resolveAction(action, existing.testMessage);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, testMessage: nextValue },
},
};
});
},
clearDraft: (key) => {
if (!key) return;
set((state) => {
if (!state.drafts[key]) return state;
const next = { ...state.drafts };
delete next[key];
return { drafts: next };
});
},
}));

Some files were not shown because too many files have changed in this diff Show More