Compare commits

...

85 Commits

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

29
package-lock.json generated
View File

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

View File

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

View File

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

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

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

View File

@@ -14,26 +14,41 @@
gap: $spacing-lg; gap: $spacing-lg;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
background: var(--bg-secondary);
backface-visibility: hidden;
transform: translateZ(0);
// During animation, exit layer uses absolute positioning // During animation, exit layer uses absolute positioning
&--exit { &--exit {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 1;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
will-change: transform, opacity;
}
&--stacked {
display: none;
// 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 { &--animating &__layer {
will-change: transform, opacity; will-change: transform, opacity;
backface-visibility: hidden;
transform-style: preserve-3d;
} }
// When both layers exist, current layer also needs positioning &--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
&--animating &__layer:not(&__layer--exit) {
position: relative; position: relative;
z-index: 0;
} }
} }

View File

@@ -1,4 +1,4 @@
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useLocation, type Location } from 'react-router-dom'; import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap'; import gsap from 'gsap';
import './PageTransition.scss'; import './PageTransition.scss';
@@ -6,14 +6,20 @@ import './PageTransition.scss';
interface PageTransitionProps { interface PageTransitionProps {
render: (location: Location) => ReactNode; render: (location: Location) => ReactNode;
getRouteOrder?: (pathname: string) => number | null; getRouteOrder?: (pathname: string) => number | null;
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
scrollContainerRef?: React.RefObject<HTMLElement | null>; scrollContainerRef?: React.RefObject<HTMLElement | null>;
} }
const TRANSITION_DURATION = 0.5; const VERTICAL_TRANSITION_DURATION = 0.35;
const EXIT_DURATION = 0.45; const VERTICAL_TRAVEL_DISTANCE = 60;
const ENTER_DELAY = 0.08; const IOS_TRANSITION_DURATION = 0.42;
const IOS_ENTER_FROM_X_PERCENT = 100;
const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
const IOS_EXIT_DIM_OPACITY = 0.72;
type LayerStatus = 'current' | 'exiting'; type LayerStatus = 'current' | 'exiting' | 'stacked';
type Layer = { type Layer = {
key: string; key: string;
@@ -23,18 +29,25 @@ type Layer = {
type TransitionDirection = 'forward' | 'backward'; type TransitionDirection = 'forward' | 'backward';
type TransitionVariant = 'vertical' | 'ios';
export function PageTransition({ export function PageTransition({
render, render,
getRouteOrder, getRouteOrder,
getTransitionVariant,
scrollContainerRef, scrollContainerRef,
}: PageTransitionProps) { }: PageTransitionProps) {
const location = useLocation(); const location = useLocation();
const currentLayerRef = useRef<HTMLDivElement>(null); const currentLayerRef = useRef<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null); const exitingLayerRef = useRef<HTMLDivElement>(null);
const transitionDirectionRef = useRef<TransitionDirection>('forward');
const transitionVariantRef = useRef<TransitionVariant>('vertical');
const exitScrollOffsetRef = useRef(0); const exitScrollOffsetRef = useRef(0);
const enterScrollOffsetRef = useRef(0);
const scrollPositionsRef = useRef(new Map<string, number>());
const nextLayersRef = useRef<Layer[] | null>(null);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
const [layers, setLayers] = useState<Layer[]>(() => [ const [layers, setLayers] = useState<Layer[]>(() => [
{ {
key: location.key, key: location.key,
@@ -42,8 +55,10 @@ export function PageTransition({
status: 'current', status: 'current',
}, },
]); ]);
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key; const currentLayer =
const currentLayerPathname = layers[layers.length - 1]?.location.pathname; layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
const currentLayerKey = currentLayer?.key ?? location.key;
const currentLayerPathname = currentLayer?.location.pathname;
const resolveScrollContainer = useCallback(() => { const resolveScrollContainer = useCallback(() => {
if (scrollContainerRef?.current) return scrollContainerRef.current; if (scrollContainerRef?.current) return scrollContainerRef.current;
@@ -51,11 +66,16 @@ export function PageTransition({
return document.scrollingElement as HTMLElement | null; return document.scrollingElement as HTMLElement | null;
}, [scrollContainerRef]); }, [scrollContainerRef]);
useEffect(() => { useLayoutEffect(() => {
if (isAnimating) return; if (isAnimating) return;
if (location.key === currentLayerKey) return; if (location.key === currentLayerKey) return;
if (currentLayerPathname === location.pathname) return;
const scrollContainer = resolveScrollContainer(); const scrollContainer = resolveScrollContainer();
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0; const exitScrollOffset = scrollContainer?.scrollTop ?? 0;
exitScrollOffsetRef.current = exitScrollOffset;
scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset);
enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 0;
const resolveOrderIndex = (pathname?: string) => { const resolveOrderIndex = (pathname?: string) => {
if (!getRouteOrder || !pathname) return null; if (!getRouteOrder || !pathname) return null;
const index = getRouteOrder(pathname); const index = getRouteOrder(pathname);
@@ -63,21 +83,99 @@ export function PageTransition({
}; };
const fromIndex = resolveOrderIndex(currentLayerPathname); const fromIndex = resolveOrderIndex(currentLayerPathname);
const toIndex = resolveOrderIndex(location.pathname); 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 fromIndex === null || toIndex === null || fromIndex === toIndex
? 'forward' ? 'forward'
: toIndex > fromIndex : toIndex > fromIndex
? 'forward' ? 'forward'
: 'backward'; : 'backward';
setTransitionDirection(nextDirection);
// 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;
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) => { setLayers((prev) => {
const prevCurrent = prev[prev.length - 1]; const variant = transitionVariantRef.current;
return [ const direction = transitionDirectionRef.current;
prevCurrent const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
? { ...prevCurrent, status: 'exiting' } const resolvedCurrentIndex =
: { key: location.key, location, status: 'exiting' }, previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
{ key: location.key, location, status: 'current' }, const previousCurrent = prev[resolvedCurrentIndex];
]; const previousStack: Layer[] = prev
.filter((_, idx) => idx !== resolvedCurrentIndex)
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
if (!previousCurrent) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
if (variant === 'ios') {
if (direction === 'forward') {
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
return [...previousStack, exitingLayer, nextCurrent];
}
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
if (targetIndex !== -1) {
const targetStack: Layer[] = prev
.slice(0, targetIndex + 1)
.map((layer, idx): Layer => {
const isTarget = idx === targetIndex;
return {
...layer,
location: isTarget ? location : layer.location,
status: isTarget ? 'current' : 'stacked',
};
});
if (shouldSkipExitLayer) {
nextLayersRef.current = targetStack;
return targetStack;
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = targetStack;
return [...targetStack, exitingLayer];
}
}
if (shouldSkipExitLayer) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = [nextCurrent];
return [exitingLayer, nextCurrent];
}); });
setIsAnimating(true); setIsAnimating(true);
}, [ }, [
@@ -86,7 +184,9 @@ export function PageTransition({
currentLayerKey, currentLayerKey,
currentLayerPathname, currentLayerPathname,
getRouteOrder, getRouteOrder,
getTransitionVariant,
resolveScrollContainer, resolveScrollContainer,
layers,
]); ]);
// Run GSAP animation when animating starts // Run GSAP animation when animating starts
@@ -95,77 +195,179 @@ export function PageTransition({
if (!currentLayerRef.current) return; if (!currentLayerRef.current) return;
const scrollContainer = resolveScrollContainer(); const currentLayerEl = currentLayerRef.current;
const scrollOffset = exitScrollOffsetRef.current; const exitingLayerEl = exitingLayerRef.current;
if (scrollContainer && scrollOffset > 0) { const transitionVariant = transitionVariantRef.current;
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
} }
const containerHeight = scrollContainer?.clientHeight ?? 0; const scrollContainer = resolveScrollContainer();
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight; const exitScrollOffset = exitScrollOffsetRef.current;
const travelDistance = Math.max(containerHeight, viewportHeight, 1); const enterScrollOffset = enterScrollOffsetRef.current;
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance; if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance; scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
const exitBaseY = scrollOffset ? -scrollOffset : 0; }
const transitionDirection = transitionDirectionRef.current;
const isForward = transitionDirection === 'forward';
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
const exitBaseY = enterScrollOffset - exitScrollOffset;
const tl = gsap.timeline({ const tl = gsap.timeline({
onComplete: () => { onComplete: () => {
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting')); const nextLayers = nextLayersRef.current;
nextLayersRef.current = null;
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false); setIsAnimating(false);
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
}, },
}); });
// Exit animation: fly out to top (slow-to-fast) if (transitionVariant === 'ios') {
if (exitingLayerRef.current) { const exitToXPercent = isForward
gsap.set(exitingLayerRef.current, { y: exitBaseY }); ? IOS_EXIT_TO_X_PERCENT_FORWARD
tl.fromTo( : IOS_EXIT_TO_X_PERCENT_BACKWARD;
exitingLayerRef.current, const enterFromXPercent = isForward
{ y: exitBaseY, opacity: 1 }, ? IOS_ENTER_FROM_X_PERCENT
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
if (exitingLayerEl) {
gsap.set(exitingLayerEl, {
y: exitBaseY,
xPercent: 0,
opacity: 1,
});
}
gsap.set(currentLayerEl, {
xPercent: enterFromXPercent,
opacity: 1,
});
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
if (topLayerEl) {
gsap.set(topLayerEl, { boxShadow: shadowValue });
}
if (exitingLayerEl) {
tl.to(
exitingLayerEl,
{ {
y: exitBaseY + exitToY, xPercent: exitToXPercent,
opacity: 0, opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
duration: EXIT_DURATION, duration: IOS_TRANSITION_DURATION,
ease: 'power2.in', // fast finish to clear screen ease: 'power2.out',
force3D: true, force3D: true,
}, },
0 0
); );
} }
// Enter animation: slide in from bottom (slow-to-fast) tl.to(
currentLayerEl,
{
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( tl.fromTo(
currentLayerRef.current, currentLayerEl,
{ y: enterFromY, opacity: 0 }, { y: enterFromY, opacity: 0 },
{ {
y: 0, y: 0,
opacity: 1, opacity: 1,
duration: TRANSITION_DURATION, duration: VERTICAL_TRANSITION_DURATION,
ease: 'power2.out', // smooth settle ease: 'circ.out',
clearProps: 'transform,opacity',
force3D: true, force3D: true,
onComplete: () => {
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
}
}, },
ENTER_DELAY },
0
); );
}
return () => { return () => {
tl.kill(); tl.kill();
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]); gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
}; };
}, [isAnimating, transitionDirection, resolveScrollContainer]); }, [isAnimating, resolveScrollContainer]);
return ( return (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}> <div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
{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 <div
key={layer.key} key={layer.key}
className={`page-transition__layer${ className={[
layer.status === 'exiting' ? ' page-transition__layer--exit' : '' 'page-transition__layer',
}`} layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef} layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
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)} {render(layer.location)}
</div> </div>
))} );
});
})()}
</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

@@ -36,6 +36,7 @@ import {
useThemeStore, useThemeStore,
} from '@/stores'; } from '@/stores';
import { configApi, versionApi } from '@/services/api'; import { configApi, versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
const sidebarIcons: Record<string, ReactNode> = { const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />, dashboard: <IconLayoutDashboard size={18} />,
@@ -374,6 +375,31 @@ export function MainLayout() {
const trimmedPath = const trimmedPath =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath; const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
const aiProvidersIndex = navOrder.indexOf('/ai-providers');
if (aiProvidersIndex !== -1) {
if (normalizedPath === '/ai-providers') return aiProvidersIndex;
if (normalizedPath.startsWith('/ai-providers/')) {
if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1;
if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2;
if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3;
if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4;
if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5;
if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6;
return aiProvidersIndex + 0.05;
}
}
const authFilesIndex = navOrder.indexOf('/auth-files');
if (authFilesIndex !== -1) {
if (normalizedPath === '/auth-files') return authFilesIndex;
if (normalizedPath.startsWith('/auth-files/')) {
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
return authFilesIndex + 0.05;
}
}
const exactIndex = navOrder.indexOf(normalizedPath); const exactIndex = navOrder.indexOf(normalizedPath);
if (exactIndex !== -1) return exactIndex; if (exactIndex !== -1) return exactIndex;
const nestedIndex = navOrder.findIndex( const nestedIndex = navOrder.findIndex(
@@ -382,14 +408,42 @@ export function MainLayout() {
return nestedIndex === -1 ? null : nestedIndex; return nestedIndex === -1 ? null : nestedIndex;
}; };
const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
const normalize = (pathname: string) => {
const trimmed =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
return trimmed === '/dashboard' ? '/' : trimmed;
};
const from = normalize(fromPathname);
const to = normalize(toPathname);
const isAuthFiles = (pathname: string) =>
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
const isAiProviders = (pathname: string) =>
pathname === '/ai-providers' || pathname.startsWith('/ai-providers/');
if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
if (isAiProviders(from) && isAiProviders(to)) return 'ios';
return 'vertical';
}, []);
const handleRefreshAll = async () => { const handleRefreshAll = async () => {
clearCache(); clearCache();
try { const results = await Promise.allSettled([
await fetchConfig(undefined, true); fetchConfig(undefined, true),
showNotification(t('notification.data_refreshed'), 'success'); triggerHeaderRefresh()
} catch (error: any) { ]);
showNotification(`${t('notification.refresh_failed')}: ${error?.message || ''}`, 'error'); const rejected = results.find((result) => result.status === 'rejected');
if (rejected && rejected.status === 'rejected') {
const reason = rejected.reason;
const message =
typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : '';
showNotification(
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
'error'
);
return;
} }
showNotification(t('notification.data_refreshed'), 'success');
}; };
const handleVersionCheck = async () => { const handleVersionCheck = async () => {
@@ -529,6 +583,7 @@ export function MainLayout() {
<PageTransition <PageTransition
render={(location) => <MainRoutes location={location} />} render={(location) => <MainRoutes location={location} />}
getRouteOrder={getRouteOrder} getRouteOrder={getRouteOrder}
getTransitionVariant={getTransitionVariant}
scrollContainerRef={contentRef} scrollContainerRef={contentRef}
/> />
</main> </main>

View File

@@ -21,7 +21,7 @@ interface AmpcodeModalProps {
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) { export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue); const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache); const clearCache = useConfigStore((state) => state.clearCache);
@@ -81,7 +81,12 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]); }, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
const clearAmpcodeUpstreamApiKey = async () => { const clearAmpcodeUpstreamApiKey = async () => {
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return; showConfirmation({
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
@@ -99,14 +104,11 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
} finally { } finally {
setSaving(false); setSaving(false);
} }
},
});
}; };
const saveAmpcode = async () => { const performSaveAmpcode = async () => {
if (!loaded && mappingsDirty) {
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
if (!confirmed) return;
}
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
@@ -173,6 +175,21 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
} }
}; };
const saveAmpcode = async () => {
if (!loaded && mappingsDirty) {
showConfirmation({
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
variant: 'secondary', // Not dangerous, just a warning
confirmText: t('common.confirm'),
onConfirm: performSaveAmpcode,
});
return;
}
await performSaveAmpcode();
};
return ( return (
<Modal <Modal
open={isOpen} open={isOpen}

View File

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

View File

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

View File

@@ -6,13 +6,16 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconClaude from '@/assets/icons/claude.svg'; import iconClaude from '@/assets/icons/claude.svg';
import type { ProviderKeyConfig } from '@/types'; import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import type { ProviderFormState } from '../types';
import { ClaudeModal } from './ClaudeModal';
interface ClaudeSectionProps { interface ClaudeSectionProps {
configs: ProviderKeyConfig[]; configs: ProviderKeyConfig[];
@@ -20,16 +23,11 @@ interface ClaudeSectionProps {
usageDetails: UsageDetail[]; usageDetails: UsageDetail[];
loading: boolean; loading: boolean;
disableControls: boolean; disableControls: boolean;
isSaving: boolean;
isSwitching: boolean; isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void; onAdd: () => void;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void; onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
} }
export function ClaudeSection({ export function ClaudeSection({
@@ -38,33 +36,34 @@ export function ClaudeSection({
usageDetails, usageDetails,
loading, loading,
disableControls, disableControls,
isSaving,
isSwitching, isSwitching,
isModalOpen,
modalIndex,
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
onToggle, onToggle,
onCloseModal,
onSave,
}: ClaudeSectionProps) { }: ClaudeSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching; const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching; const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey)); configs.forEach((config) => {
allApiKeys.forEach((apiKey) => { if (!config.apiKey) return;
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey)); const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
}); });
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
});
return cache; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return ( return (
<> <>
<Card <Card
@@ -99,12 +98,11 @@ export function ClaudeSection({
/> />
)} )}
renderContent={(item) => { renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels); const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? []; const excludedModels = item.excludedModels ?? [];
const statusData = const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return ( return (
<Fragment> <Fragment>
@@ -188,15 +186,6 @@ export function ClaudeSection({
}} }}
/> />
</Card> </Card>
<ClaudeModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</> </>
); );
} }

View File

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

View File

@@ -3,17 +3,20 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconCodexDark from '@/assets/icons/codex_drak.svg';
import type { ProviderKeyConfig } from '@/types'; import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import type { ProviderFormState } from '../types';
import { CodexModal } from './CodexModal';
interface CodexSectionProps { interface CodexSectionProps {
configs: ProviderKeyConfig[]; configs: ProviderKeyConfig[];
@@ -21,17 +24,12 @@ interface CodexSectionProps {
usageDetails: UsageDetail[]; usageDetails: UsageDetail[];
loading: boolean; loading: boolean;
disableControls: boolean; disableControls: boolean;
isSaving: boolean;
isSwitching: boolean; isSwitching: boolean;
resolvedTheme: string; resolvedTheme: string;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void; onAdd: () => void;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void; onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
} }
export function CodexSection({ export function CodexSection({
@@ -40,41 +38,42 @@ export function CodexSection({
usageDetails, usageDetails,
loading, loading,
disableControls, disableControls,
isSaving,
isSwitching, isSwitching,
resolvedTheme, resolvedTheme,
isModalOpen,
modalIndex,
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
onToggle, onToggle,
onCloseModal,
onSave,
}: CodexSectionProps) { }: CodexSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching; const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching; const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey)); configs.forEach((config) => {
allApiKeys.forEach((apiKey) => { if (!config.apiKey) return;
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey)); const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
}); });
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
});
return cache; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return ( return (
<> <>
<Card <Card
title={ title={
<span className={styles.cardTitle}> <span className={styles.cardTitle}>
<img <img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight} src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
alt="" alt=""
className={styles.cardTitleIcon} className={styles.cardTitleIcon}
/> />
@@ -106,12 +105,11 @@ export function CodexSection({
/> />
)} )}
renderContent={(item) => { renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels); const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? []; const excludedModels = item.excludedModels ?? [];
const statusData = const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return ( return (
<Fragment> <Fragment>
@@ -180,15 +178,6 @@ export function CodexSection({
}} }}
/> />
</Card> </Card>
<CodexModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</> </>
); );
} }

View File

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

View File

@@ -6,13 +6,16 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconGemini from '@/assets/icons/gemini.svg'; import iconGemini from '@/assets/icons/gemini.svg';
import type { GeminiKeyConfig } from '@/types'; import type { GeminiKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import type { GeminiFormState } from '../types';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils'; import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import { GeminiModal } from './GeminiModal';
interface GeminiSectionProps { interface GeminiSectionProps {
configs: GeminiKeyConfig[]; configs: GeminiKeyConfig[];
@@ -20,16 +23,11 @@ interface GeminiSectionProps {
usageDetails: UsageDetail[]; usageDetails: UsageDetail[];
loading: boolean; loading: boolean;
disableControls: boolean; disableControls: boolean;
isSaving: boolean;
isSwitching: boolean; isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void; onAdd: () => void;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void; onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
} }
export function GeminiSection({ export function GeminiSection({
@@ -38,33 +36,34 @@ export function GeminiSection({
usageDetails, usageDetails,
loading, loading,
disableControls, disableControls,
isSaving,
isSwitching, isSwitching,
isModalOpen,
modalIndex,
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
onToggle, onToggle,
onCloseModal,
onSave,
}: GeminiSectionProps) { }: GeminiSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching; const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching; const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey)); configs.forEach((config) => {
allApiKeys.forEach((apiKey) => { if (!config.apiKey) return;
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey)); const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
}); });
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
});
return cache; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return ( return (
<> <>
<Card <Card
@@ -99,12 +98,11 @@ export function GeminiSection({
/> />
)} )}
renderContent={(item, index) => { renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey); const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels); const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? []; const excludedModels = item.excludedModels ?? [];
const statusData = const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return ( return (
<Fragment> <Fragment>
@@ -169,15 +167,6 @@ export function GeminiSection({
}} }}
/> />
</Card> </Card>
<GeminiModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</> </>
); );
} }

View File

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

View File

@@ -7,13 +7,16 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import type { OpenAIProviderConfig } from '@/types'; import type { OpenAIProviderConfig } from '@/types';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage'; import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss'; import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList'; import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar'; import { ProviderStatusBar } from '../ProviderStatusBar';
import { getOpenAIProviderStats, getStatsBySource } from '../utils'; import { getOpenAIProviderStats, getStatsBySource } from '../utils';
import type { OpenAIFormState } from '../types';
import { OpenAIModal } from './OpenAIModal';
interface OpenAISectionProps { interface OpenAISectionProps {
configs: OpenAIProviderConfig[]; configs: OpenAIProviderConfig[];
@@ -21,16 +24,11 @@ interface OpenAISectionProps {
usageDetails: UsageDetail[]; usageDetails: UsageDetail[];
loading: boolean; loading: boolean;
disableControls: boolean; disableControls: boolean;
isSaving: boolean;
isSwitching: boolean; isSwitching: boolean;
resolvedTheme: string; resolvedTheme: string;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void; onAdd: () => void;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onCloseModal: () => void;
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
} }
export function OpenAISection({ export function OpenAISection({
@@ -39,34 +37,34 @@ export function OpenAISection({
usageDetails, usageDetails,
loading, loading,
disableControls, disableControls,
isSaving,
isSwitching, isSwitching,
resolvedTheme, resolvedTheme,
isModalOpen,
modalIndex,
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
onCloseModal,
onSave,
}: OpenAISectionProps) { }: OpenAISectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching; const actionsDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => { const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>(); const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((provider) => { configs.forEach((provider) => {
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean); const sourceIds = new Set<string>();
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source)); buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
});
const filteredDetails = sourceIds.size
? usageDetails.filter((detail) => sourceIds.has(detail.source))
: [];
cache.set(provider.name, calculateStatusBarData(filteredDetails)); cache.set(provider.name, calculateStatusBarData(filteredDetails));
}); });
return cache; return cache;
}, [configs, usageDetails]); }, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return ( return (
<> <>
<Card <Card
@@ -96,7 +94,7 @@ export function OpenAISection({
onDelete={onDelete} onDelete={onDelete}
actionsDisabled={actionsDisabled} actionsDisabled={actionsDisabled}
renderContent={(item) => { renderContent={(item) => {
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey); const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {}); const headerEntries = Object.entries(item.headers || {});
const apiKeyEntries = item.apiKeyEntries || []; const apiKeyEntries = item.apiKeyEntries || [];
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]); const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
@@ -130,7 +128,7 @@ export function OpenAISection({
</div> </div>
<div className={styles.apiKeyEntryList}> <div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => { {apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey); const entryStats = getStatsBySource(entry.apiKey, keyStats);
return ( return (
<div key={entryIndex} className={styles.apiKeyEntryCard}> <div key={entryIndex} className={styles.apiKeyEntryCard}>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span> <span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
@@ -192,15 +190,6 @@ export function OpenAISection({
}} }}
/> />
</Card> </Card>
<OpenAIModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</> </>
); );
} }

View File

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

View File

@@ -0,0 +1,113 @@
@use '../../../styles/variables' as *;
.navContainer {
position: fixed;
right: 24px;
top: 50%;
transform: translateY(-50%);
z-index: 50;
pointer-events: auto;
}
.navList {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 8px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
}
.navItem {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
background: transparent;
border-radius: 10px;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.15s ease;
&:hover {
background: rgba(0, 0, 0, 0.06);
transform: scale(1.08);
}
&:active {
transform: scale(0.95);
}
}
.icon {
width: 28px;
height: 28px;
object-fit: contain;
}
.active {
background: rgba(59, 130, 246, 0.15);
box-shadow: inset 0 0 0 2px var(--primary-color);
}
// 暗色主题适配
:global([data-theme='dark']) {
.navList {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.navItem {
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
.active {
background: rgba(59, 130, 246, 0.25);
}
}
// 小屏幕改为底部横向浮层
@media (max-width: 1200px) {
.navContainer {
top: auto;
right: auto;
left: 50%;
bottom: calc(12px + env(safe-area-inset-bottom));
transform: translateX(-50%);
width: min(520px, calc(100vw - 24px));
}
.navList {
flex-direction: row;
gap: 6px;
padding: 8px 10px;
border-radius: 999px;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.navItem {
width: 36px;
height: 36px;
border-radius: 999px;
flex: 0 0 auto;
}
.icon {
width: 22px;
height: 22px;
}
}

View File

@@ -0,0 +1,164 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useThemeStore } from '@/stores';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconCodexDark from '@/assets/icons/codex_drak.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import iconAmp from '@/assets/icons/amp.svg';
import styles from './ProviderNav.module.scss';
export type ProviderId = 'gemini' | 'codex' | 'claude' | 'vertex' | 'ampcode' | 'openai';
interface ProviderNavItem {
id: ProviderId;
label: string;
getIcon: (theme: string) => string;
}
const PROVIDERS: ProviderNavItem[] = [
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
{ id: 'openai', label: 'OpenAI', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) },
];
const HEADER_OFFSET = 24;
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
export function ProviderNav() {
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
const contentScrollerRef = useRef<HTMLElement | null>(null);
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(() => {
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]);
const scrollToProvider = (providerId: ProviderId) => {
const container = getScrollContainer();
const element = document.getElementById(`provider-${providerId}`);
if (!element || !container) return;
setActiveProvider(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' });
};
const navContent = (
<div className={styles.navContainer}>
<div className={styles.navList}>
{PROVIDERS.map((provider) => {
const isActive = activeProvider === provider.id;
return (
<button
key={provider.id}
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
onClick={() => scrollToProvider(provider.id)}
title={provider.label}
type="button"
>
<img
src={provider.getIcon(resolvedTheme)}
alt={provider.label}
className={styles.icon}
/>
</button>
);
})}
</div>
</div>
);
if (typeof document === 'undefined') return null;
return createPortal(navContent, document.body);
}

View File

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

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } 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';
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): VertexFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
modelEntries: [{ name: '', alias: '' }],
});
export function VertexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: VertexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.vertex_edit_modal_title')
: t('ai_providers.vertex_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.vertex_add_modal_key_label')}
placeholder={t('ai_providers.vertex_add_modal_key_placeholder')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.vertex_add_modal_url_label')}
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.vertex_add_modal_proxy_label')}
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.vertex_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.vertex_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,159 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import iconVertex from '@/assets/icons/vertex.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
calculateStatusBarData,
type KeyStats,
type UsageDetail,
} from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource } from '../utils';
interface VertexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
}
export function VertexSection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSwitching,
onAdd,
onEdit,
onDelete,
}: VertexSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((config) => {
if (!config.apiKey) return;
const candidates = buildCandidateUsageSourceIds({
apiKey: config.apiKey,
prefix: config.prefix,
});
if (!candidates.length) return;
const candidateSet = new Set(candidates);
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
});
return cache;
}, [configs, usageDetails]);
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.vertex_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.vertex_add_button')}
</Button>
}
>
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.vertex_empty_title')}
emptyDescription={t('ai_providers.vertex_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
return (
<Fragment>
<div className="item-title">
{t('ai_providers.vertex_item_title')} #{index + 1}
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{item.baseUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
)}
{item.proxyUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{item.models?.length ? (
<div className={styles.modelTagList}>
<span className={styles.modelCountLabel}>
{t('ai_providers.vertex_models_count')}: {item.models.length}
</span>
{item.models.map((model) => (
<span key={`${model.name}-${model.alias || 'default'}`} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
</Card>
</>
);
}

View File

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

View File

@@ -3,8 +3,10 @@ export { ClaudeSection } from './ClaudeSection';
export { CodexSection } from './CodexSection'; export { CodexSection } from './CodexSection';
export { GeminiSection } from './GeminiSection'; export { GeminiSection } from './GeminiSection';
export { OpenAISection } from './OpenAISection'; export { OpenAISection } from './OpenAISection';
export { VertexSection } from './VertexSection';
export { ProviderList } from './ProviderList'; export { ProviderList } from './ProviderList';
export { ProviderStatusBar } from './ProviderStatusBar'; export { ProviderStatusBar } from './ProviderStatusBar';
export { ProviderNav } from './ProviderNav';
export * from './hooks/useProviderStats'; export * from './hooks/useProviderStats';
export * from './types'; export * from './types';
export * from './utils'; export * from './utils';

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ import type {
GeminiCliQuotaBucketState, GeminiCliQuotaBucketState,
GeminiCliQuotaState GeminiCliQuotaState
} from '@/types'; } from '@/types';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import { import {
ANTIGRAVITY_QUOTA_URLS, ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS, ANTIGRAVITY_REQUEST_HEADERS,
@@ -45,6 +45,7 @@ import {
getStatusFromError, getStatusFromError,
isAntigravityFile, isAntigravityFile,
isCodexFile, isCodexFile,
isDisabledAuthFile,
isGeminiCliFile, isGeminiCliFile,
isRuntimeOnlyAuthFile isRuntimeOnlyAuthFile
} from '@/utils/quota'; } from '@/utils/quota';
@@ -55,6 +56,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli'; type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore { export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>; antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>; codexQuota: Record<string, CodexQuotaState>;
@@ -82,6 +85,38 @@ export interface QuotaConfig<TState, TData> {
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode; renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
} }
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
try {
const text = await authFilesApi.downloadText(file.name);
const trimmed = text.trim();
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
if (topLevel) return topLevel;
const installed =
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
? (parsed.installed as Record<string, unknown>)
: null;
const installedProjectId = installed
? normalizeStringValue(installed.project_id ?? installed.projectId)
: null;
if (installedProjectId) return installedProjectId;
const web =
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
? (parsed.web as Record<string, unknown>)
: null;
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
if (webProjectId) return webProjectId;
} catch {
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
}
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
};
const fetchAntigravityQuota = async ( const fetchAntigravityQuota = async (
file: AuthFileItem, file: AuthFileItem,
t: TFunction t: TFunction
@@ -92,6 +127,9 @@ const fetchAntigravityQuota = async (
throw new Error(t('antigravity_quota.missing_auth_index')); throw new Error(t('antigravity_quota.missing_auth_index'));
} }
const projectId = await resolveAntigravityProjectId(file);
const requestBody = JSON.stringify({ project: projectId });
let lastError = ''; let lastError = '';
let lastStatus: number | undefined; let lastStatus: number | undefined;
let priorityStatus: number | undefined; let priorityStatus: number | undefined;
@@ -104,7 +142,7 @@ const fetchAntigravityQuota = async (
method: 'POST', method: 'POST',
url, url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS }, header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: '{}' data: requestBody
}); });
if (result.statusCode < 200 || result.statusCode >= 300) { if (result.statusCode < 200 || result.statusCode >= 300) {
@@ -482,7 +520,7 @@ const renderGeminiCliItems = (
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = { export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity', type: 'antigravity',
i18nPrefix: 'antigravity_quota', i18nPrefix: 'antigravity_quota',
filterFn: (file) => isAntigravityFile(file), filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchAntigravityQuota, fetchQuota: fetchAntigravityQuota,
storeSelector: (state) => state.antigravityQuota, storeSelector: (state) => state.antigravityQuota,
storeSetter: 'setAntigravityQuota', storeSetter: 'setAntigravityQuota',
@@ -507,7 +545,7 @@ export const CODEX_CONFIG: QuotaConfig<
> = { > = {
type: 'codex', type: 'codex',
i18nPrefix: 'codex_quota', i18nPrefix: 'codex_quota',
filterFn: (file) => isCodexFile(file), filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchCodexQuota, fetchQuota: fetchCodexQuota,
storeSelector: (state) => state.codexQuota, storeSelector: (state) => state.codexQuota,
storeSetter: 'setCodexQuota', storeSetter: 'setCodexQuota',
@@ -533,7 +571,8 @@ export const CODEX_CONFIG: QuotaConfig<
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = { export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
type: 'gemini-cli', type: 'gemini-cli',
i18nPrefix: 'gemini_cli_quota', i18nPrefix: 'gemini_cli_quota',
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file), filterFn: (file) =>
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchGeminiCliQuota, fetchQuota: fetchGeminiCliQuota,
storeSelector: (state) => state.geminiCliQuota, storeSelector: (state) => state.geminiCliQuota,
storeSetter: 'setGeminiCliQuota', storeSetter: 'setGeminiCliQuota',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -164,6 +164,14 @@ export function IconChevronDown({ size = 20, ...props }: IconProps) {
); );
} }
export function IconChevronLeft({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="m15 18-6-6 6-6" />
</svg>
);
}
export function IconSearch({ size = 20, ...props }: IconProps) { export function IconSearch({ size = 20, ...props }: IconProps) {
return ( return (
<svg {...baseSvgProps} width={size} height={size} {...props}> <svg {...baseSvgProps} width={size} height={size} {...props}>

View File

@@ -0,0 +1,29 @@
import type { ModelAlias } from '@/types';
export interface ModelEntry {
name: string;
alias: string;
}
export const modelsToEntries = (models?: ModelAlias[]): ModelEntry[] => {
if (!Array.isArray(models) || models.length === 0) {
return [{ name: '', alias: '' }];
}
return models.map((model) => ({
name: model.name || '',
alias: model.alias || ''
}));
};
export const entriesToModels = (entries: ModelEntry[]): ModelAlias[] => {
return entries
.filter((entry) => entry.name.trim())
.map((entry) => {
const model: ModelAlias = { name: entry.name.trim() };
const alias = entry.alias.trim();
if (alias && alias !== model.name) {
model.alias = alias;
}
return model;
});
};

View File

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

View File

@@ -8,3 +8,4 @@ export { useLocalStorage } from './useLocalStorage';
export { useInterval } from './useInterval'; export { useInterval } from './useInterval';
export { useMediaQuery } from './useMediaQuery'; export { useMediaQuery } from './useMediaQuery';
export { usePagination } from './usePagination'; export { usePagination } from './usePagination';
export { useHeaderRefresh } from './useHeaderRefresh';

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,24 @@
import { useEffect } from 'react';
export type HeaderRefreshHandler = () => void | Promise<void>;
let activeHeaderRefreshHandler: HeaderRefreshHandler | null = null;
export const triggerHeaderRefresh = async () => {
if (!activeHeaderRefreshHandler) return;
await activeHeaderRefreshHandler();
};
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
useEffect(() => {
if (!handler) return;
activeHeaderRefreshHandler = handler;
return () => {
if (activeHeaderRefreshHandler === handler) {
activeHeaderRefreshHandler = null;
}
};
}, [handler]);
};

View File

@@ -2,6 +2,7 @@
"common": { "common": {
"login": "Login", "login": "Login",
"logout": "Logout", "logout": "Logout",
"back": "Back",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
"save": "Save", "save": "Save",
@@ -71,7 +72,15 @@
"submitting": "Connecting...", "submitting": "Connecting...",
"error_title": "Login Failed", "error_title": "Login Failed",
"error_required": "Please fill in complete connection information", "error_required": "Please fill in complete connection information",
"error_invalid": "Connection failed, please check address and key" "error_invalid": "Connection failed, please check address and key",
"error_network": "Network connection failed, please check your network or server address",
"error_timeout": "Connection timed out, server not responding",
"error_unauthorized": "Authentication failed, invalid management key",
"error_forbidden": "Access denied, insufficient permissions",
"error_not_found": "Server address invalid or management API not enabled",
"error_server": "Internal server error, please try again later",
"error_cors": "Cross-origin request blocked, please check server configuration",
"error_ssl": "SSL/TLS certificate verification failed"
}, },
"header": { "header": {
"check_connection": "Check Connection", "check_connection": "Check Connection",
@@ -137,11 +146,22 @@
"usage_statistics_enable": "Enable usage statistics", "usage_statistics_enable": "Enable usage statistics",
"logging_title": "Logging", "logging_title": "Logging",
"logging_to_file_enable": "Enable logging to file", "logging_to_file_enable": "Enable logging to file",
"logs_max_total_size_title": "Log Size Limit",
"logs_max_total_size_label": "Total log size cap (MB):",
"logs_max_total_size_hint": "Set to 0 to disable the limit.",
"logs_max_total_size_update": "Update",
"request_log_title": "Request Logging", "request_log_title": "Request Logging",
"request_log_enable": "Enable request logging", "request_log_enable": "Enable request logging",
"request_log_warning": "Keep this off unless you need detailed troubleshooting.", "request_log_warning": "Keep this off unless you need detailed troubleshooting.",
"force_model_prefix_enable": "Force model prefix",
"ws_auth_title": "WebSocket Authentication", "ws_auth_title": "WebSocket Authentication",
"ws_auth_enable": "Require auth for /ws/*" "ws_auth_enable": "Require auth for /ws/*",
"routing_title": "Routing Strategy",
"routing_strategy_label": "Routing strategy:",
"routing_strategy_hint": "round-robin cycles through keys; fill-first prioritizes the first available key.",
"routing_strategy_update": "Update",
"routing_strategy_round_robin": "round-robin (cycle)",
"routing_strategy_fill_first": "fill-first (prioritize)"
}, },
"api_keys": { "api_keys": {
"title": "API Keys Management", "title": "API Keys Management",
@@ -221,6 +241,27 @@
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.", "claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
"claude_models_add_btn": "Add Model", "claude_models_add_btn": "Add Model",
"claude_models_count": "Models Count", "claude_models_count": "Models Count",
"vertex_title": "Vertex API Configuration",
"vertex_add_button": "Add Configuration",
"vertex_empty_title": "No Vertex Configuration",
"vertex_empty_desc": "Click the button above to add the first configuration",
"vertex_item_title": "Vertex Configuration",
"vertex_add_modal_title": "Add Vertex API Configuration",
"vertex_add_modal_key_label": "API Key:",
"vertex_add_modal_key_placeholder": "Please enter Vertex API key",
"vertex_add_modal_url_label": "Base URL (Required):",
"vertex_add_modal_url_placeholder": "e.g.: https://example.com/api",
"vertex_add_modal_proxy_label": "Proxy URL (Optional):",
"vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "Edit Vertex API Configuration",
"vertex_edit_modal_key_label": "API Key:",
"vertex_edit_modal_url_label": "Base URL (Required):",
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
"vertex_models_label": "Model aliases (alias required):",
"vertex_models_add_btn": "Add Mapping",
"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_title": "Amp CLI Integration (ampcode)",
"ampcode_modal_title": "Configure Ampcode", "ampcode_modal_title": "Configure Ampcode",
"ampcode_upstream_url_label": "Upstream URL", "ampcode_upstream_url_label": "Upstream URL",
@@ -261,12 +302,12 @@
"openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free", "openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free",
"openai_model_alias_placeholder": "Model alias (optional)", "openai_model_alias_placeholder": "Model alias (optional)",
"openai_models_add_btn": "Add Model", "openai_models_add_btn": "Add Model",
"openai_models_fetch_button": "Fetch via /v1/models", "openai_models_fetch_button": "Fetch via /models",
"openai_models_fetch_title": "Pick Models from /v1/models", "openai_models_fetch_title": "Pick Models from /models",
"openai_models_fetch_hint": "Call the /v1/models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.", "openai_models_fetch_hint": "Call the /models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.",
"openai_models_fetch_url_label": "Request URL", "openai_models_fetch_url_label": "Request URL",
"openai_models_fetch_refresh": "Refresh", "openai_models_fetch_refresh": "Refresh",
"openai_models_fetch_loading": "Fetching models from /v1/models...", "openai_models_fetch_loading": "Fetching models from /models...",
"openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.", "openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.",
"openai_models_fetch_error": "Failed to fetch models", "openai_models_fetch_error": "Failed to fetch models",
"openai_models_fetch_back": "Back to edit", "openai_models_fetch_back": "Back to edit",
@@ -284,7 +325,7 @@
"openai_keys_count": "Keys Count", "openai_keys_count": "Keys Count",
"openai_models_count": "Models Count", "openai_models_count": "Models Count",
"openai_test_title": "Connection Test", "openai_test_title": "Connection Test",
"openai_test_hint": "Send a /v1/chat/completions request with the current settings to verify availability.", "openai_test_hint": "Send a /chat/completions request with the current settings to verify availability.",
"openai_test_model_placeholder": "Model to test", "openai_test_model_placeholder": "Model to test",
"openai_test_action": "Run Test", "openai_test_action": "Run Test",
"openai_test_running": "Sending test request...", "openai_test_running": "Sending test request...",
@@ -312,6 +353,7 @@
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!", "delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!", "delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
"upload_error_json": "Only JSON files are allowed", "upload_error_json": "Only JSON files are allowed",
"upload_error_size": "File size cannot exceed {{maxSize}}",
"upload_success": "File uploaded successfully", "upload_success": "File uploaded successfully",
"download_success": "File downloaded successfully", "download_success": "File downloaded successfully",
"delete_success": "File deleted successfully", "delete_success": "File deleted successfully",
@@ -327,6 +369,9 @@
"search_placeholder": "Filter by name, type, or provider", "search_placeholder": "Filter by name, type, or provider",
"page_size_label": "Per page", "page_size_label": "Per page",
"page_size_unit": "items", "page_size_unit": "items",
"view_mode_paged": "Paged",
"view_mode_all": "Show all",
"too_many_files_warning": "Too many credentials. Showing all may cause performance issues, please use paged view.",
"filter_all": "All", "filter_all": "All",
"filter_qwen": "Qwen", "filter_qwen": "Qwen",
"filter_gemini": "Gemini", "filter_gemini": "Gemini",
@@ -359,7 +404,19 @@
"models_unsupported": "This feature is not supported in the current version", "models_unsupported": "This feature is not supported in the current version",
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again", "models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
"models_excluded_badge": "Excluded", "models_excluded_badge": "Excluded",
"models_excluded_hint": "This model is excluded by OAuth" "models_excluded_hint": "This model is excluded by OAuth",
"status_toggle_label": "Enabled",
"status_enabled_success": "\"{{name}}\" enabled",
"status_disabled_success": "\"{{name}}\" disabled",
"prefix_proxy_button": "Edit prefix/proxy_url",
"prefix_proxy_loading": "Loading credential...",
"prefix_proxy_source_label": "Credential JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
}, },
"antigravity_quota": { "antigravity_quota": {
"title": "Antigravity Quota", "title": "Antigravity Quota",
@@ -440,8 +497,10 @@
"provider_placeholder": "e.g. gemini-cli", "provider_placeholder": "e.g. gemini-cli",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.", "provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"models_label": "Models to exclude", "models_label": "Models to exclude",
"models_placeholder": "gpt-4.1-mini\n*-preview", "models_loading": "Loading models...",
"models_hint": "Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.", "models_unsupported": "Current CPA version does not support fetching model lists.",
"models_loaded": "{{count}} models loaded. Check the models to exclude.",
"no_models_available": "No models available for this provider.",
"save": "Save/Update", "save": "Save/Update",
"saving": "Saving...", "saving": "Saving...",
"save_success": "Excluded models updated", "save_success": "Excluded models updated",
@@ -464,6 +523,36 @@
"upgrade_required_title": "Please upgrade CLI Proxy API", "upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version." "upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
}, },
"oauth_model_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_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. 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 aliases updated",
"save_failed": "Failed to update model aliases",
"delete": "Delete Provider",
"delete_confirm": "Delete model aliases for {{provider}}?",
"delete_success": "Model aliases removed",
"delete_failed": "Failed to delete model aliases",
"no_models": "No model aliases",
"model_count": "{{count}} aliases",
"list_empty_all": "No model aliases yet—use “Add Alias” to create one.",
"provider_required": "Please enter a provider first",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth model aliases API. Please upgrade to the latest CLI Proxy API (CPA) version."
},
"auth_login": { "auth_login": {
"codex_oauth_title": "Codex OAuth", "codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "Start Codex Login", "codex_oauth_button": "Start Codex Login",
@@ -548,7 +637,7 @@
"iflow_oauth_polling_error": "Failed to check authentication status:", "iflow_oauth_polling_error": "Failed to check authentication status:",
"iflow_cookie_title": "iFlow Cookie Login", "iflow_cookie_title": "iFlow Cookie Login",
"iflow_cookie_label": "Cookie Value:", "iflow_cookie_label": "Cookie Value:",
"iflow_cookie_placeholder": "Paste browser cookie, e.g. sessionid=...;", "iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.", "iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
"iflow_cookie_key_hint": "Note: Create a key on the platform first.", "iflow_cookie_key_hint": "Note: Create a key on the platform first.",
"iflow_cookie_button": "Submit Cookie Login", "iflow_cookie_button": "Submit Cookie Login",
@@ -671,6 +760,8 @@
"loaded_lines": "Loaded: {{count}} lines", "loaded_lines": "Loaded: {{count}} lines",
"filtered_lines": "Filtered: {{count}} lines", "filtered_lines": "Filtered: {{count}} lines",
"hide_management_logs": "Hide {{prefix}} logs", "hide_management_logs": "Hide {{prefix}} logs",
"show_raw_logs": "Show Raw Logs",
"show_raw_logs_hint": "Show original log text for easier multi-line copy",
"search_placeholder": "Search logs by content or keyword", "search_placeholder": "Search logs by content or keyword",
"search_empty_title": "No matching logs found", "search_empty_title": "No matching logs found",
"search_empty_desc": "Try a different keyword or clear the filters.", "search_empty_desc": "Try a different keyword or clear the filters.",
@@ -709,7 +800,8 @@
"quota_management": { "quota_management": {
"title": "Quota Management", "title": "Quota Management",
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.", "description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
"refresh_files": "Refresh auth files" "refresh_files": "Refresh auth files",
"refresh_files_and_quota": "Refresh files & quota"
}, },
"system_info": { "system_info": {
"title": "Management Center Info", "title": "Management Center Info",
@@ -722,9 +814,9 @@
"not_loaded": "Not Loaded", "not_loaded": "Not Loaded",
"seconds_ago": "seconds ago", "seconds_ago": "seconds ago",
"models_title": "Available Models", "models_title": "Available Models",
"models_desc": "Shows the /v1/models response and uses saved API keys for auth automatically.", "models_desc": "Shows the /models response and uses saved API keys for auth automatically.",
"models_loading": "Loading available models...", "models_loading": "Loading available models...",
"models_empty": "No models returned by /v1/models", "models_empty": "No models returned by /models",
"models_error": "Failed to load model list", "models_error": "Failed to load model list",
"models_count": "{{count}} available models", "models_count": "{{count}} available models",
"version_check_title": "Update Check", "version_check_title": "Update Check",
@@ -761,12 +853,16 @@
"quota_switch_preview_updated": "Preview model switch settings updated", "quota_switch_preview_updated": "Preview model switch settings updated",
"usage_statistics_updated": "Usage statistics settings updated", "usage_statistics_updated": "Usage statistics settings updated",
"logging_to_file_updated": "Logging settings updated", "logging_to_file_updated": "Logging settings updated",
"logs_max_total_size_updated": "Log size limit updated",
"request_log_updated": "Request logging setting updated", "request_log_updated": "Request logging setting updated",
"force_model_prefix_updated": "Model prefix setting updated",
"ws_auth_updated": "WebSocket authentication setting updated", "ws_auth_updated": "WebSocket authentication setting updated",
"routing_strategy_updated": "Routing strategy updated",
"login_storage_cleared": "Local login data cleared", "login_storage_cleared": "Local login data cleared",
"api_key_added": "API key added successfully", "api_key_added": "API key added successfully",
"api_key_updated": "API key updated successfully", "api_key_updated": "API key updated successfully",
"api_key_deleted": "API key deleted successfully", "api_key_deleted": "API key deleted successfully",
"api_key_invalid_chars": "API key can only contain letters, numbers, and symbols",
"gemini_key_added": "Gemini key added successfully", "gemini_key_added": "Gemini key added successfully",
"gemini_key_updated": "Gemini key updated successfully", "gemini_key_updated": "Gemini key updated successfully",
"gemini_key_deleted": "Gemini key deleted successfully", "gemini_key_deleted": "Gemini key deleted successfully",
@@ -780,6 +876,10 @@
"claude_config_added": "Claude configuration added successfully", "claude_config_added": "Claude configuration added successfully",
"claude_config_updated": "Claude configuration updated successfully", "claude_config_updated": "Claude configuration updated successfully",
"claude_config_deleted": "Claude configuration deleted successfully", "claude_config_deleted": "Claude configuration deleted successfully",
"vertex_config_added": "Vertex configuration added successfully",
"vertex_config_updated": "Vertex configuration updated successfully",
"vertex_config_deleted": "Vertex configuration deleted successfully",
"vertex_base_url_required": "Please enter the Vertex Base URL",
"config_enabled": "Configuration enabled", "config_enabled": "Configuration enabled",
"config_disabled": "Configuration disabled", "config_disabled": "Configuration disabled",
"field_required": "Required fields cannot be empty", "field_required": "Required fields cannot be empty",

View File

@@ -2,6 +2,7 @@
"common": { "common": {
"login": "登录", "login": "登录",
"logout": "登出", "logout": "登出",
"back": "返回",
"cancel": "取消", "cancel": "取消",
"confirm": "确认", "confirm": "确认",
"save": "保存", "save": "保存",
@@ -71,7 +72,15 @@
"submitting": "连接中...", "submitting": "连接中...",
"error_title": "登录失败", "error_title": "登录失败",
"error_required": "请填写完整的连接信息", "error_required": "请填写完整的连接信息",
"error_invalid": "连接失败,请检查地址和密钥" "error_invalid": "连接失败,请检查地址和密钥",
"error_network": "网络连接失败,请检查网络或服务器地址",
"error_timeout": "连接超时,服务器无响应",
"error_unauthorized": "认证失败,管理密钥无效",
"error_forbidden": "访问被拒绝,权限不足",
"error_not_found": "服务器地址无效或管理接口未启用",
"error_server": "服务器内部错误,请稍后重试",
"error_cors": "跨域请求被阻止,请检查服务器配置",
"error_ssl": "SSL/TLS 证书验证失败"
}, },
"header": { "header": {
"check_connection": "检查连接", "check_connection": "检查连接",
@@ -137,11 +146,22 @@
"usage_statistics_enable": "启用使用统计", "usage_statistics_enable": "启用使用统计",
"logging_title": "日志记录", "logging_title": "日志记录",
"logging_to_file_enable": "启用日志记录到文件", "logging_to_file_enable": "启用日志记录到文件",
"logs_max_total_size_title": "日志容量限制",
"logs_max_total_size_label": "日志总大小上限 (MB):",
"logs_max_total_size_hint": "设置为 0 表示不限制。",
"logs_max_total_size_update": "更新",
"request_log_title": "请求日志", "request_log_title": "请求日志",
"request_log_enable": "启用请求日志", "request_log_enable": "启用请求日志",
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。", "request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
"force_model_prefix_enable": "强制模型前缀",
"ws_auth_title": "WebSocket 鉴权", "ws_auth_title": "WebSocket 鉴权",
"ws_auth_enable": "启用 /ws/* 鉴权" "ws_auth_enable": "启用 /ws/* 鉴权",
"routing_title": "路由策略",
"routing_strategy_label": "路由策略:",
"routing_strategy_hint": "round-robin 为轮询fill-first 为优先填充。",
"routing_strategy_update": "更新",
"routing_strategy_round_robin": "round-robin (轮询)",
"routing_strategy_fill_first": "fill-first (优先填充)"
}, },
"api_keys": { "api_keys": {
"title": "API 密钥管理", "title": "API 密钥管理",
@@ -221,6 +241,27 @@
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。", "claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
"claude_models_add_btn": "添加模型", "claude_models_add_btn": "添加模型",
"claude_models_count": "模型数量", "claude_models_count": "模型数量",
"vertex_title": "Vertex API 配置",
"vertex_add_button": "添加配置",
"vertex_empty_title": "暂无Vertex配置",
"vertex_empty_desc": "点击上方按钮添加第一个配置",
"vertex_item_title": "Vertex配置",
"vertex_add_modal_title": "添加Vertex API配置",
"vertex_add_modal_key_label": "API密钥:",
"vertex_add_modal_key_placeholder": "请输入Vertex API密钥",
"vertex_add_modal_url_label": "Base URL (必填):",
"vertex_add_modal_url_placeholder": "例如: https://example.com/api",
"vertex_add_modal_proxy_label": "代理 URL (可选):",
"vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "编辑Vertex API配置",
"vertex_edit_modal_key_label": "API密钥:",
"vertex_edit_modal_url_label": "Base URL (必填):",
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
"vertex_delete_confirm": "确定要删除这个Vertex配置吗",
"vertex_models_label": "模型别名 (别名必填):",
"vertex_models_add_btn": "添加映射",
"vertex_models_hint": "每条别名需要填写原模型与别名。",
"vertex_models_count": "别名数量",
"ampcode_title": "Amp CLI 集成 (ampcode)", "ampcode_title": "Amp CLI 集成 (ampcode)",
"ampcode_modal_title": "配置 Ampcode", "ampcode_modal_title": "配置 Ampcode",
"ampcode_upstream_url_label": "Upstream URL", "ampcode_upstream_url_label": "Upstream URL",
@@ -261,12 +302,12 @@
"openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free", "openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free",
"openai_model_alias_placeholder": "模型别名 (可选)", "openai_model_alias_placeholder": "模型别名 (可选)",
"openai_models_add_btn": "添加模型", "openai_models_add_btn": "添加模型",
"openai_models_fetch_button": "从 /v1/models 获取", "openai_models_fetch_button": "从 /models 获取",
"openai_models_fetch_title": "从 /v1/models 选择模型", "openai_models_fetch_title": "从 /models 选择模型",
"openai_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带首个 API KeyBearer与自定义请求头。", "openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API KeyBearer与自定义请求头。",
"openai_models_fetch_url_label": "请求地址", "openai_models_fetch_url_label": "请求地址",
"openai_models_fetch_refresh": "重新获取", "openai_models_fetch_refresh": "重新获取",
"openai_models_fetch_loading": "正在从 /v1/models 获取模型列表...", "openai_models_fetch_loading": "正在从 /models 获取模型列表...",
"openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。", "openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
"openai_models_fetch_error": "获取模型失败", "openai_models_fetch_error": "获取模型失败",
"openai_models_fetch_back": "返回编辑", "openai_models_fetch_back": "返回编辑",
@@ -284,7 +325,7 @@
"openai_keys_count": "密钥数量", "openai_keys_count": "密钥数量",
"openai_models_count": "模型数量", "openai_models_count": "模型数量",
"openai_test_title": "连通性测试", "openai_test_title": "连通性测试",
"openai_test_hint": "使用当前配置向 /v1/chat/completions 请求,验证是否可用。", "openai_test_hint": "使用当前配置向 /chat/completions 请求,验证是否可用。",
"openai_test_model_placeholder": "选择或输入要测试的模型", "openai_test_model_placeholder": "选择或输入要测试的模型",
"openai_test_action": "发送测试", "openai_test_action": "发送测试",
"openai_test_running": "正在发送测试请求...", "openai_test_running": "正在发送测试请求...",
@@ -312,6 +353,7 @@
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!", "delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!", "delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
"upload_error_json": "只能上传JSON文件", "upload_error_json": "只能上传JSON文件",
"upload_error_size": "文件大小不能超过 {{maxSize}}",
"upload_success": "文件上传成功", "upload_success": "文件上传成功",
"download_success": "文件下载成功", "download_success": "文件下载成功",
"delete_success": "文件删除成功", "delete_success": "文件删除成功",
@@ -327,6 +369,9 @@
"search_placeholder": "输入名称、类型或提供方关键字", "search_placeholder": "输入名称、类型或提供方关键字",
"page_size_label": "单页数量", "page_size_label": "单页数量",
"page_size_unit": "个/页", "page_size_unit": "个/页",
"view_mode_paged": "按页显示",
"view_mode_all": "显示全部",
"too_many_files_warning": "您的凭证总数过多,全部加载会导致页面卡顿,请保持单页浏览。",
"filter_all": "全部", "filter_all": "全部",
"filter_qwen": "Qwen", "filter_qwen": "Qwen",
"filter_gemini": "Gemini", "filter_gemini": "Gemini",
@@ -359,7 +404,19 @@
"models_unsupported": "当前版本不支持此功能", "models_unsupported": "当前版本不支持此功能",
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试", "models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
"models_excluded_badge": "已排除", "models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除" "models_excluded_hint": "此模型已被 OAuth 排除",
"status_toggle_label": "启用",
"status_enabled_success": "已启用 \"{{name}}\"",
"status_disabled_success": "已停用 \"{{name}}\"",
"prefix_proxy_button": "配置 prefix/proxy_url",
"prefix_proxy_loading": "正在加载凭证文件...",
"prefix_proxy_source_label": "凭证 JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
}, },
"antigravity_quota": { "antigravity_quota": {
"title": "Antigravity 额度", "title": "Antigravity 额度",
@@ -440,8 +497,10 @@
"provider_placeholder": "例如 gemini-cli / openai", "provider_placeholder": "例如 gemini-cli / openai",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。", "provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"models_label": "排除的模型", "models_label": "排除的模型",
"models_placeholder": "gpt-4.1-mini\n*-preview", "models_loading": "正在加载模型列表...",
"models_hint": "逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。", "models_unsupported": "当前 CPA 版本不支持获取模型列表。",
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
"no_models_available": "该提供商暂无可用模型列表。",
"save": "保存/更新", "save": "保存/更新",
"saving": "正在保存...", "saving": "正在保存...",
"save_success": "排除列表已更新", "save_success": "排除列表已更新",
@@ -464,6 +523,36 @@
"upgrade_required_title": "需要升级 CPA 版本", "upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。" "upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
}, },
"oauth_model_alias": {
"title": "OAuth 模型别名",
"add": "新增别名",
"add_title": "新增提供商模型别名",
"provider_label": "提供商",
"provider_placeholder": "例如 gemini-cli / vertex",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"model_source_loading": "正在加载模型列表...",
"model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。",
"model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
"alias_label": "模型别名",
"alias_name_placeholder": "原模型名称",
"alias_placeholder": "别名 (必填)",
"alias_fork_label": "保留原名",
"add_alias": "添加别名",
"save": "保存/更新",
"save_success": "模型别名已更新",
"save_failed": "更新模型别名失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的模型别名吗?",
"delete_success": "已删除该提供商的模型别名",
"delete_failed": "删除模型别名失败",
"no_models": "未配置模型别名",
"model_count": "{{count}} 条别名",
"list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。",
"provider_required": "请先填写提供商名称",
"upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型别名功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"auth_login": { "auth_login": {
"codex_oauth_title": "Codex OAuth", "codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "开始 Codex 登录", "codex_oauth_button": "开始 Codex 登录",
@@ -548,7 +637,7 @@
"iflow_oauth_polling_error": "检查认证状态失败:", "iflow_oauth_polling_error": "检查认证状态失败:",
"iflow_cookie_title": "iFlow Cookie 登录", "iflow_cookie_title": "iFlow Cookie 登录",
"iflow_cookie_label": "Cookie 内容:", "iflow_cookie_label": "Cookie 内容:",
"iflow_cookie_placeholder": "粘贴浏览器中的 Cookie例如 sessionid=...;", "iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。", "iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。", "iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
"iflow_cookie_button": "提交 Cookie 登录", "iflow_cookie_button": "提交 Cookie 登录",
@@ -671,6 +760,8 @@
"loaded_lines": "已载入 {{count}} 行", "loaded_lines": "已载入 {{count}} 行",
"filtered_lines": "已过滤 {{count}} 行", "filtered_lines": "已过滤 {{count}} 行",
"hide_management_logs": "屏蔽 {{prefix}} 日志", "hide_management_logs": "屏蔽 {{prefix}} 日志",
"show_raw_logs": "显示原始日志",
"show_raw_logs_hint": "直接显示原始日志文本,方便多行复制",
"search_placeholder": "搜索日志内容或关键字", "search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志", "search_empty_title": "未找到匹配的日志",
"search_empty_desc": "尝试更换关键字或清空筛选条件。", "search_empty_desc": "尝试更换关键字或清空筛选条件。",
@@ -709,7 +800,8 @@
"quota_management": { "quota_management": {
"title": "配额管理", "title": "配额管理",
"description": "集中查看 OAuth 额度与剩余情况", "description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件" "refresh_files": "刷新认证文件",
"refresh_files_and_quota": "刷新认证文件&额度"
}, },
"system_info": { "system_info": {
"title": "管理中心信息", "title": "管理中心信息",
@@ -722,9 +814,9 @@
"not_loaded": "未加载", "not_loaded": "未加载",
"seconds_ago": "秒前", "seconds_ago": "秒前",
"models_title": "可用模型列表", "models_title": "可用模型列表",
"models_desc": "展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。", "models_desc": "展示 /models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
"models_loading": "正在加载可用模型...", "models_loading": "正在加载可用模型...",
"models_empty": "未从 /v1/models 获取到模型数据", "models_empty": "未从 /models 获取到模型数据",
"models_error": "获取模型列表失败", "models_error": "获取模型列表失败",
"models_count": "可用模型 {{count}} 个", "models_count": "可用模型 {{count}} 个",
"version_check_title": "版本检查", "version_check_title": "版本检查",
@@ -761,12 +853,16 @@
"quota_switch_preview_updated": "预览模型切换设置已更新", "quota_switch_preview_updated": "预览模型切换设置已更新",
"usage_statistics_updated": "使用统计设置已更新", "usage_statistics_updated": "使用统计设置已更新",
"logging_to_file_updated": "日志记录设置已更新", "logging_to_file_updated": "日志记录设置已更新",
"logs_max_total_size_updated": "日志容量设置已更新",
"request_log_updated": "请求日志设置已更新", "request_log_updated": "请求日志设置已更新",
"force_model_prefix_updated": "模型前缀设置已更新",
"ws_auth_updated": "WebSocket 鉴权设置已更新", "ws_auth_updated": "WebSocket 鉴权设置已更新",
"routing_strategy_updated": "路由策略已更新",
"login_storage_cleared": "本地登录信息已清理", "login_storage_cleared": "本地登录信息已清理",
"api_key_added": "API密钥添加成功", "api_key_added": "API密钥添加成功",
"api_key_updated": "API密钥更新成功", "api_key_updated": "API密钥更新成功",
"api_key_deleted": "API密钥删除成功", "api_key_deleted": "API密钥删除成功",
"api_key_invalid_chars": "API密钥仅支持英文字母、数字和符号",
"gemini_key_added": "Gemini密钥添加成功", "gemini_key_added": "Gemini密钥添加成功",
"gemini_key_updated": "Gemini密钥更新成功", "gemini_key_updated": "Gemini密钥更新成功",
"gemini_key_deleted": "Gemini密钥删除成功", "gemini_key_deleted": "Gemini密钥删除成功",
@@ -780,6 +876,10 @@
"claude_config_added": "Claude配置添加成功", "claude_config_added": "Claude配置添加成功",
"claude_config_updated": "Claude配置更新成功", "claude_config_updated": "Claude配置更新成功",
"claude_config_deleted": "Claude配置删除成功", "claude_config_deleted": "Claude配置删除成功",
"vertex_config_added": "Vertex配置添加成功",
"vertex_config_updated": "Vertex配置更新成功",
"vertex_config_deleted": "Vertex配置删除成功",
"vertex_base_url_required": "请填写Vertex Base URL",
"config_enabled": "配置已启用", "config_enabled": "配置已启用",
"config_disabled": "配置已停用", "config_disabled": "配置已停用",
"field_required": "必填字段不能为空", "field_required": "必填字段不能为空",

View File

@@ -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,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-xl; gap: $spacing-xl;
@include mobile {
padding-bottom: calc(72px + env(safe-area-inset-bottom));
}
} }
.section { .section {

View File

@@ -1,32 +1,29 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { entriesToModels } from '@/components/ui/ModelInputList'; import { useNavigate } from 'react-router-dom';
import { import {
AmpcodeSection, AmpcodeSection,
ClaudeSection, ClaudeSection,
CodexSection, CodexSection,
GeminiSection, GeminiSection,
OpenAISection, OpenAISection,
VertexSection,
ProviderNav,
useProviderStats, useProviderStats,
type GeminiFormState,
type OpenAIFormState,
type ProviderFormState,
type ProviderModal,
} from '@/components/providers'; } from '@/components/providers';
import { import {
parseExcludedModels,
withDisableAllModelsRule, withDisableAllModelsRule,
withoutDisableAllModelsRule, withoutDisableAllModelsRule,
} from '@/components/providers/utils'; } from '@/components/providers/utils';
import { ampcodeApi, providersApi } from '@/services/api'; import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import styles from './AiProvidersPage.module.scss'; import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() { export function AiProvidersPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const navigate = useNavigate();
const { showNotification, showConfirmation } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
@@ -34,19 +31,29 @@ export function AiProvidersPage() {
const fetchConfig = useConfigStore((state) => state.fetchConfig); const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue); const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache); const clearCache = useConfigStore((state) => state.clearCache);
const isCacheValid = useConfigStore((state) => state.isCacheValid);
const [loading, setLoading] = useState(true); const hasMounted = useRef(false);
const [loading, setLoading] = useState(() => !isCacheValid());
const [error, setError] = useState(''); const [error, setError] = useState('');
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]); const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>(
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]); () => config?.geminiApiKeys || []
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]); );
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]); 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 [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null);
const [modal, setModal] = useState<ProviderModal | null>(null);
const [ampcodeBusy, setAmpcodeBusy] = useState(false);
const disableControls = connectionStatus !== 'connected'; const disableControls = connectionStatus !== 'connected';
const isSwitching = Boolean(configSwitchingKey); const isSwitching = Boolean(configSwitchingKey);
@@ -60,20 +67,38 @@ export function AiProvidersPage() {
}; };
const loadConfigs = useCallback(async () => { const loadConfigs = useCallback(async () => {
const hasValidCache = isCacheValid();
if (!hasValidCache) {
setLoading(true); setLoading(true);
}
setError(''); setError('');
try { try {
const data = await fetchConfig(); const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
fetchConfig(),
providersApi.getVertexConfigs(),
ampcodeApi.getAmpcode(),
]);
if (configResult.status !== 'fulfilled') {
throw configResult.reason;
}
const data = configResult.value;
setGeminiKeys(data?.geminiApiKeys || []); setGeminiKeys(data?.geminiApiKeys || []);
setCodexConfigs(data?.codexApiKeys || []); setCodexConfigs(data?.codexApiKeys || []);
setClaudeConfigs(data?.claudeApiKeys || []); setClaudeConfigs(data?.claudeApiKeys || []);
setVertexConfigs(data?.vertexApiKeys || []);
setOpenaiProviders(data?.openaiCompatibility || []); setOpenaiProviders(data?.openaiCompatibility || []);
try {
const ampcode = await ampcodeApi.getAmpcode(); if (vertexResult.status === 'fulfilled') {
updateConfigValue('ampcode', ampcode); setVertexConfigs(vertexResult.value || []);
updateConfigValue('vertex-api-key', vertexResult.value || []);
clearCache('vertex-api-key');
}
if (ampcodeResult.status === 'fulfilled') {
updateConfigValue('ampcode', ampcodeResult.value);
clearCache('ampcode'); clearCache('ampcode');
} catch {
// ignore
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = getErrorMessage(err) || t('notification.refresh_failed'); const message = getErrorMessage(err) || t('notification.refresh_failed');
@@ -81,9 +106,11 @@ export function AiProvidersPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [clearCache, fetchConfig, t, updateConfigValue]); }, [clearCache, fetchConfig, isCacheValid, t, updateConfigValue]);
useEffect(() => { useEffect(() => {
if (hasMounted.current) return;
hasMounted.current = true;
loadConfigs(); loadConfigs();
loadKeyStats(); loadKeyStats();
}, [loadConfigs, loadKeyStats]); }, [loadConfigs, loadKeyStats]);
@@ -92,71 +119,32 @@ export function AiProvidersPage() {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys); if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys); if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys); if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys);
if (config?.vertexApiKeys) setVertexConfigs(config.vertexApiKeys);
if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility); if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility);
}, [ }, [
config?.geminiApiKeys, config?.geminiApiKeys,
config?.codexApiKeys, config?.codexApiKeys,
config?.claudeApiKeys, config?.claudeApiKeys,
config?.vertexApiKeys,
config?.openaiCompatibility, config?.openaiCompatibility,
]); ]);
const closeModal = () => { const openEditor = useCallback(
setModal(null); (path: string) => {
}; navigate(path, { state: { fromAiProviders: true } });
},
const openGeminiModal = (index: number | null) => { [navigate]
setModal({ type: 'gemini', index }); );
};
const openProviderModal = (type: 'codex' | 'claude', index: number | null) => {
setModal({ type, index });
};
const 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(headersToEntries(form.headers)),
excludedModels: parseExcludedModels(form.excludedText),
};
const nextList =
editIndex !== null
? geminiKeys.map((item, idx) => (idx === editIndex ? payload : item))
: [...geminiKeys, payload];
await providersApi.saveGeminiKeys(nextList);
setGeminiKeys(nextList);
updateConfigValue('gemini-api-key', nextList);
clearCache('gemini-api-key');
const message =
editIndex !== null
? t('notification.gemini_key_updated')
: t('notification.gemini_key_added');
showNotification(message, 'success');
closeModal();
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const deleteGemini = async (index: number) => { const deleteGemini = async (index: number) => {
const entry = geminiKeys[index]; const entry = geminiKeys[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t('ai_providers.gemini_delete_confirm'))) return; showConfirmation({
title: t('ai_providers.gemini_delete_title', { defaultValue: 'Delete Gemini Key' }),
message: t('ai_providers.gemini_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try { try {
await providersApi.deleteGeminiKey(entry.apiKey); await providersApi.deleteGeminiKey(entry.apiKey);
const next = geminiKeys.filter((_, idx) => idx !== index); const next = geminiKeys.filter((_, idx) => idx !== index);
@@ -168,6 +156,8 @@ export function AiProvidersPage() {
const message = getErrorMessage(err); const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
} }
},
});
}; };
const setConfigEnabled = async ( const setConfigEnabled = async (
@@ -262,73 +252,16 @@ 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(headersToEntries(form.headers)),
models: entriesToModels(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText),
};
const nextList =
editIndex !== null
? source.map((item, idx) => (idx === editIndex ? payload : item))
: [...source, payload];
if (type === 'codex') {
await providersApi.saveCodexConfigs(nextList);
setCodexConfigs(nextList);
updateConfigValue('codex-api-key', nextList);
clearCache('codex-api-key');
const message =
editIndex !== null
? t('notification.codex_config_updated')
: t('notification.codex_config_added');
showNotification(message, 'success');
} else {
await providersApi.saveClaudeConfigs(nextList);
setClaudeConfigs(nextList);
updateConfigValue('claude-api-key', nextList);
clearCache('claude-api-key');
const message =
editIndex !== null
? t('notification.claude_config_updated')
: t('notification.claude_config_added');
showNotification(message, 'success');
}
closeModal();
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const deleteProviderEntry = async (type: 'codex' | 'claude', index: number) => { const deleteProviderEntry = async (type: 'codex' | 'claude', index: number) => {
const source = type === 'codex' ? codexConfigs : claudeConfigs; const source = type === 'codex' ? codexConfigs : claudeConfigs;
const entry = source[index]; const entry = source[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t(`ai_providers.${type}_delete_confirm`))) return; showConfirmation({
title: t(`ai_providers.${type}_delete_title`, { defaultValue: `Delete ${type === 'codex' ? 'Codex' : 'Claude'} Config` }),
message: t(`ai_providers.${type}_delete_confirm`),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try { try {
if (type === 'codex') { if (type === 'codex') {
await providersApi.deleteCodexConfig(entry.apiKey); await providersApi.deleteCodexConfig(entry.apiKey);
@@ -349,53 +282,43 @@ export function AiProvidersPage() {
const message = getErrorMessage(err); const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
} }
},
});
}; };
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => { const deleteVertex = async (index: number) => {
setSaving(true); const entry = vertexConfigs[index];
if (!entry) return;
showConfirmation({
title: t('ai_providers.vertex_delete_title', { defaultValue: 'Delete Vertex Config' }),
message: t('ai_providers.vertex_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try { try {
const payload: OpenAIProviderConfig = { await providersApi.deleteVertexConfig(entry.apiKey);
name: form.name.trim(), const next = vertexConfigs.filter((_, idx) => idx !== index);
prefix: form.prefix?.trim() || undefined, setVertexConfigs(next);
baseUrl: form.baseUrl.trim(), updateConfigValue('vertex-api-key', next);
headers: buildHeaderObject(form.headers), clearCache('vertex-api-key');
apiKeyEntries: form.apiKeyEntries.map((entry) => ({ showNotification(t('notification.vertex_config_deleted'), 'success');
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) { } catch (err: unknown) {
const message = getErrorMessage(err); const message = getErrorMessage(err);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
} }
},
});
}; };
const deleteOpenai = async (index: number) => { const deleteOpenai = async (index: number) => {
const entry = openaiProviders[index]; const entry = openaiProviders[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t('ai_providers.openai_delete_confirm'))) return; showConfirmation({
title: t('ai_providers.openai_delete_title', { defaultValue: 'Delete OpenAI Provider' }),
message: t('ai_providers.openai_delete_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try { try {
await providersApi.deleteOpenAIProvider(entry.name); await providersApi.deleteOpenAIProvider(entry.name);
const next = openaiProviders.filter((_, idx) => idx !== index); const next = openaiProviders.filter((_, idx) => idx !== index);
@@ -407,105 +330,103 @@ export function AiProvidersPage() {
const message = getErrorMessage(err); const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
} }
},
});
}; };
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
const codexModalIndex = modal?.type === 'codex' ? modal.index : null;
const claudeModalIndex = modal?.type === 'claude' ? modal.index : null;
const openaiModalIndex = modal?.type === 'openai' ? modal.index : null;
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1> <h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.content}> <div className={styles.content}>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
<div id="provider-gemini">
<GeminiSection <GeminiSection
configs={geminiKeys} configs={geminiKeys}
keyStats={keyStats} keyStats={keyStats}
usageDetails={usageDetails} usageDetails={usageDetails}
loading={loading} loading={loading}
disableControls={disableControls} disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching} isSwitching={isSwitching}
isModalOpen={modal?.type === 'gemini'} onAdd={() => openEditor('/ai-providers/gemini/new')}
modalIndex={geminiModalIndex} onEdit={(index) => openEditor(`/ai-providers/gemini/${index}`)}
onAdd={() => openGeminiModal(null)}
onEdit={(index) => openGeminiModal(index)}
onDelete={deleteGemini} onDelete={deleteGemini}
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)} onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
onCloseModal={closeModal}
onSave={saveGemini}
/> />
</div>
<div id="provider-codex">
<CodexSection <CodexSection
configs={codexConfigs} configs={codexConfigs}
keyStats={keyStats} keyStats={keyStats}
usageDetails={usageDetails} usageDetails={usageDetails}
loading={loading} loading={loading}
disableControls={disableControls} disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching} isSwitching={isSwitching}
resolvedTheme={resolvedTheme} resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'codex'} onAdd={() => openEditor('/ai-providers/codex/new')}
modalIndex={codexModalIndex} onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)}
onAdd={() => openProviderModal('codex', null)}
onEdit={(index) => openProviderModal('codex', index)}
onDelete={(index) => void deleteProviderEntry('codex', index)} onDelete={(index) => void deleteProviderEntry('codex', index)}
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)} onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('codex', form, editIndex)}
/> />
</div>
<div id="provider-claude">
<ClaudeSection <ClaudeSection
configs={claudeConfigs} configs={claudeConfigs}
keyStats={keyStats} keyStats={keyStats}
usageDetails={usageDetails} usageDetails={usageDetails}
loading={loading} loading={loading}
disableControls={disableControls} disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching} isSwitching={isSwitching}
isModalOpen={modal?.type === 'claude'} onAdd={() => openEditor('/ai-providers/claude/new')}
modalIndex={claudeModalIndex} onEdit={(index) => openEditor(`/ai-providers/claude/${index}`)}
onAdd={() => openProviderModal('claude', null)}
onEdit={(index) => openProviderModal('claude', index)}
onDelete={(index) => void deleteProviderEntry('claude', index)} onDelete={(index) => void deleteProviderEntry('claude', index)}
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)} onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
/> />
</div>
<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>
<div id="provider-ampcode">
<AmpcodeSection <AmpcodeSection
config={config?.ampcode} config={config?.ampcode}
loading={loading} loading={loading}
disableControls={disableControls} disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching} isSwitching={isSwitching}
isBusy={ampcodeBusy} onEdit={() => openEditor('/ai-providers/ampcode')}
isModalOpen={modal?.type === 'ampcode'}
onOpen={openAmpcodeModal}
onCloseModal={closeModal}
onBusyChange={setAmpcodeBusy}
/> />
</div>
<div id="provider-openai">
<OpenAISection <OpenAISection
configs={openaiProviders} configs={openaiProviders}
keyStats={keyStats} keyStats={keyStats}
usageDetails={usageDetails} usageDetails={usageDetails}
loading={loading} loading={loading}
disableControls={disableControls} disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching} isSwitching={isSwitching}
resolvedTheme={resolvedTheme} resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'openai'} onAdd={() => openEditor('/ai-providers/openai/new')}
modalIndex={openaiModalIndex} onEdit={(index) => openEditor(`/ai-providers/openai/${index}`)}
onAdd={() => openOpenaiModal(null)}
onEdit={(index) => openOpenaiModal(index)}
onDelete={deleteOpenai} onDelete={deleteOpenai}
onCloseModal={closeModal}
onSave={saveOpenai}
/> />
</div> </div>
</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

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

View File

@@ -0,0 +1,219 @@
@use '../styles/variables' as *;
@use '../styles/mixins' as *;
.pageContent {
width: 100%;
max-width: 1000px;
margin: 0 auto;
padding: 0 $spacing-lg $spacing-2xl;
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}
.settingsCard {
padding: 0;
overflow: visible;
}
.settingsHeader {
display: flex;
flex-direction: column;
gap: $spacing-xs;
padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color);
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}
.settingsHeaderTitle {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
font-weight: 700;
color: var(--text-primary);
}
.settingsHeaderHint {
font-size: 13px;
color: var(--text-secondary);
}
.settingsSection {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding: $spacing-md $spacing-lg $spacing-lg;
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}
.settingsRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-lg;
@include mobile {
flex-direction: column;
align-items: stretch;
gap: $spacing-sm;
}
}
.settingsInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.settingsLabel {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.settingsDesc {
font-size: 13px;
color: var(--text-secondary);
}
.settingsControl {
flex: 0 0 auto;
width: min(360px, 45%);
min-width: 220px;
@include mobile {
width: 100%;
min-width: 0;
}
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: var(--primary-color);
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.tagActive {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
&:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
}
.modelsHint {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: 13px;
color: var(--text-secondary);
}
.loadingModels {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
padding: $spacing-xl 0;
color: var(--text-secondary);
}
.modelList {
max-height: 520px;
overflow: auto;
padding: $spacing-sm $spacing-lg $spacing-lg;
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}
.modelItem {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
&:last-child {
border-bottom: none;
}
input {
width: 16px;
height: 16px;
}
}
.modelText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.modelId {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
}
.modelDisplayName {
font-size: 12px;
color: var(--text-secondary);
word-break: break-all;
}
.emptyModels {
padding: $spacing-xl $spacing-lg;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}

View File

@@ -0,0 +1,433 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { EmptyState } from '@/components/ui/EmptyState';
import { IconInfo } from '@/components/ui/icons';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useAuthStore, useNotificationStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import styles from './AuthFilesOAuthExcludedEditPage.module.scss';
type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
type LocationState = { fromAuthFiles?: boolean } | null;
const OAUTH_PROVIDER_PRESETS = [
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'claude',
'codex',
'qwen',
'iflow',
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
export function AuthFilesOAuthExcludedEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const disableControls = connectionStatus !== 'connected';
const [searchParams, setSearchParams] = useSearchParams();
const providerFromParams = searchParams.get('provider') ?? '';
const [provider, setProvider] = useState(providerFromParams);
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
const [initialLoading, setInitialLoading] = useState(true);
const [excludedUnsupported, setExcludedUnsupported] = useState(false);
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
setProvider(providerFromParams);
}, [providerFromParams]);
const providerOptions = useMemo(() => {
const extraProviders = new Set<string>();
Object.keys(excluded).forEach((value) => extraProviders.add(value));
Object.keys(modelAlias).forEach((value) => extraProviders.add(value));
files.forEach((file) => {
if (typeof file.type === 'string') {
extraProviders.add(file.type);
}
if (typeof file.provider === 'string') {
extraProviders.add(file.provider);
}
});
const normalizedExtras = Array.from(extraProviders)
.map((value) => value.trim())
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
const extraList = normalizedExtras
.filter((value) => !baseSet.has(value.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
}, [excluded, files, modelAlias]);
const getTypeLabel = useCallback(
(type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
if (translated !== key) return translated;
if (type.toLowerCase() === 'iflow') return 'iFlow';
return type.charAt(0).toUpperCase() + type.slice(1);
},
[t]
);
const resolvedProviderKey = useMemo(() => normalizeProviderKey(provider), [provider]);
const isEditing = useMemo(() => {
if (!resolvedProviderKey) return false;
return Object.prototype.hasOwnProperty.call(excluded, resolvedProviderKey);
}, [excluded, resolvedProviderKey]);
const title = useMemo(() => {
if (isEditing) {
return t('oauth_excluded.edit_title', { provider: provider.trim() || resolvedProviderKey });
}
return t('oauth_excluded.add_title');
}, [isEditing, provider, resolvedProviderKey, t]);
const handleBack = useCallback(() => {
const state = location.state as LocationState;
if (state?.fromAuthFiles) {
navigate(-1);
return;
}
navigate('/auth-files', { replace: true });
}, [location.state, navigate]);
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleBack();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleBack]);
useEffect(() => {
let cancelled = false;
const load = async () => {
setInitialLoading(true);
setExcludedUnsupported(false);
try {
const [filesResult, excludedResult, aliasResult] = await Promise.allSettled([
authFilesApi.list(),
authFilesApi.getOauthExcludedModels(),
authFilesApi.getOauthModelAlias(),
]);
if (cancelled) return;
if (filesResult.status === 'fulfilled') {
setFiles(filesResult.value?.files ?? []);
}
if (aliasResult.status === 'fulfilled') {
setModelAlias(aliasResult.value ?? {});
}
if (excludedResult.status === 'fulfilled') {
setExcluded(excludedResult.value ?? {});
return;
}
const err = excludedResult.status === 'rejected' ? excludedResult.reason : null;
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setExcludedUnsupported(true);
return;
}
} finally {
if (!cancelled) {
setInitialLoading(false);
}
}
};
load().catch(() => {
if (!cancelled) {
setInitialLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!resolvedProviderKey) {
setSelectedModels(new Set());
return;
}
const existing = excluded[resolvedProviderKey] ?? [];
setSelectedModels(new Set(existing));
}, [excluded, resolvedProviderKey]);
useEffect(() => {
if (!resolvedProviderKey || excludedUnsupported) {
setModelsList([]);
setModelsError(null);
setModelsLoading(false);
return;
}
let cancelled = false;
setModelsLoading(true);
setModelsError(null);
authFilesApi
.getModelDefinitions(resolvedProviderKey)
.then((models) => {
if (cancelled) return;
setModelsList(models);
})
.catch((err: unknown) => {
if (cancelled) return;
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setModelsList([]);
setModelsError('unsupported');
return;
}
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
})
.finally(() => {
if (cancelled) return;
setModelsLoading(false);
});
return () => {
cancelled = true;
};
}, [excludedUnsupported, resolvedProviderKey, showNotification, t]);
const updateProvider = useCallback(
(value: string) => {
setProvider(value);
const next = new URLSearchParams(searchParams);
const trimmed = value.trim();
if (trimmed) {
next.set('provider', trimmed);
} else {
next.delete('provider');
}
setSearchParams(next, { replace: true });
},
[searchParams, setSearchParams]
);
const toggleModel = useCallback((modelId: string, checked: boolean) => {
setSelectedModels((prev) => {
const next = new Set(prev);
if (checked) {
next.add(modelId);
} else {
next.delete(modelId);
}
return next;
});
}, []);
const handleSave = useCallback(async () => {
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) {
showNotification(t('oauth_excluded.provider_required'), 'error');
return;
}
const models = [...selectedModels];
setSaving(true);
try {
if (models.length) {
await authFilesApi.saveOauthExcludedModels(normalizedProvider, models);
} else {
await authFilesApi.deleteOauthExcludedEntry(normalizedProvider);
}
showNotification(t('oauth_excluded.save_success'), 'success');
handleBack();
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error');
} finally {
setSaving(false);
}
}, [handleBack, provider, selectedModels, showNotification, t]);
const canSave = !disableControls && !saving && !excludedUnsupported;
return (
<SecondaryScreenShell
ref={swipeRef}
title={title}
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
contentClassName={styles.pageContent}
rightAction={
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
{t('oauth_excluded.save')}
</Button>
}
isLoading={initialLoading}
loadingLabel={t('common.loading')}
>
{excludedUnsupported ? (
<Card>
<EmptyState
title={t('oauth_excluded.upgrade_required_title')}
description={t('oauth_excluded.upgrade_required_desc')}
/>
</Card>
) : (
<>
<Card className={styles.settingsCard}>
<div className={styles.settingsHeader}>
<div className={styles.settingsHeaderTitle}>
<IconInfo size={16} />
<span>{t('oauth_excluded.title')}</span>
</div>
<div className={styles.settingsHeaderHint}>{t('oauth_excluded.description')}</div>
</div>
<div className={styles.settingsSection}>
<div className={styles.settingsRow}>
<div className={styles.settingsInfo}>
<div className={styles.settingsLabel}>{t('oauth_excluded.provider_label')}</div>
<div className={styles.settingsDesc}>{t('oauth_excluded.provider_hint')}</div>
</div>
<div className={styles.settingsControl}>
<AutocompleteInput
id="oauth-excluded-provider"
placeholder={t('oauth_excluded.provider_placeholder')}
value={provider}
onChange={updateProvider}
options={providerOptions}
disabled={disableControls || saving}
wrapperStyle={{ marginBottom: 0 }}
/>
</div>
</div>
{providerOptions.length > 0 && (
<div className={styles.tagList}>
{providerOptions.map((option) => {
const isActive = normalizeProviderKey(provider) === option.toLowerCase();
return (
<button
key={option}
type="button"
className={`${styles.tag} ${isActive ? styles.tagActive : ''}`}
onClick={() => updateProvider(option)}
disabled={disableControls || saving}
>
{getTypeLabel(option)}
</button>
);
})}
</div>
)}
</div>
</Card>
<Card className={styles.settingsCard}>
<div className={styles.settingsHeader}>
<div className={styles.settingsHeaderTitle}>{t('oauth_excluded.models_label')}</div>
{resolvedProviderKey && (
<div className={styles.modelsHint}>
{modelsLoading ? (
<>
<LoadingSpinner size={14} />
<span>{t('oauth_excluded.models_loading')}</span>
</>
) : modelsError === 'unsupported' ? (
<span>{t('oauth_excluded.models_unsupported')}</span>
) : modelsList.length > 0 ? (
<span>{t('oauth_excluded.models_loaded', { count: modelsList.length })}</span>
) : (
<span>{t('oauth_excluded.no_models_available')}</span>
)}
</div>
)}
</div>
{modelsLoading ? (
<div className={styles.loadingModels}>
<LoadingSpinner size={16} />
<span>{t('common.loading')}</span>
</div>
) : modelsList.length > 0 ? (
<div className={styles.modelList}>
{modelsList.map((model) => {
const checked = selectedModels.has(model.id);
return (
<label key={model.id} className={styles.modelItem}>
<input
type="checkbox"
checked={checked}
disabled={disableControls || saving}
onChange={(event) => toggleModel(model.id, event.target.checked)}
/>
<span className={styles.modelText}>
<span className={styles.modelId}>{model.id}</span>
{model.display_name && model.display_name !== model.id && (
<span className={styles.modelDisplayName}>{model.display_name}</span>
)}
</span>
</label>
);
})}
</div>
) : resolvedProviderKey ? (
<div className={styles.emptyModels}>
{modelsError === 'unsupported'
? t('oauth_excluded.models_unsupported')
: t('oauth_excluded.no_models_available')}
</div>
) : (
<div className={styles.emptyModels}>{t('oauth_excluded.provider_required')}</div>
)}
</Card>
</>
)}
</SecondaryScreenShell>
);
}

View File

@@ -0,0 +1,225 @@
@use '../styles/variables' as *;
@use '../styles/mixins' as *;
.pageContent {
width: 100%;
max-width: 1000px;
margin: 0 auto;
padding: 0 $spacing-lg $spacing-2xl;
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}
.settingsCard {
padding: 0;
overflow: visible;
}
.settingsHeader {
display: flex;
flex-direction: column;
gap: $spacing-xs;
padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color);
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}
.settingsHeaderTitle {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
font-weight: 700;
color: var(--text-primary);
}
.settingsHeaderHint {
font-size: 13px;
color: var(--text-secondary);
}
.settingsSection {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding: $spacing-md $spacing-lg $spacing-lg;
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}
.settingsRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-lg;
@include mobile {
flex-direction: column;
align-items: stretch;
gap: $spacing-sm;
}
}
.settingsInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.settingsLabel {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.settingsDesc {
font-size: 13px;
color: var(--text-secondary);
}
.settingsControl {
flex: 0 0 auto;
width: min(360px, 45%);
min-width: 220px;
@include mobile {
width: 100%;
min-width: 0;
}
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: var(--primary-color);
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.tagActive {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
&:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
}
.mappingsHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color);
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}
.mappingsTitle {
font-weight: 700;
color: var(--text-primary);
}
.modelsHint {
display: flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-sm $spacing-lg;
font-size: 13px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}
.mappingsBody {
padding: $spacing-sm $spacing-lg $spacing-lg;
@include mobile {
padding-left: $spacing-md;
padding-right: $spacing-md;
}
}
.mappingRow {
display: grid;
grid-template-columns: 1fr auto 1fr auto auto;
align-items: center;
gap: $spacing-sm;
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
&:last-child {
border-bottom: none;
}
@include mobile {
grid-template-columns: 1fr;
gap: $spacing-sm;
}
}
.mappingSeparator {
color: var(--text-secondary);
text-align: center;
@include mobile {
display: none;
}
}
.mappingAliasInput {
width: 100%;
}
.mappingFork {
display: flex;
align-items: center;
@include mobile {
justify-content: flex-start;
}
}

View File

@@ -0,0 +1,482 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconInfo, IconX } from '@/components/ui/icons';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useAuthStore, useNotificationStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import { generateId } from '@/utils/helpers';
import styles from './AuthFilesOAuthModelAliasEditPage.module.scss';
type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
type LocationState = { fromAuthFiles?: boolean } | null;
type OAuthModelMappingFormEntry = OAuthModelAliasEntry & { id: string };
const OAUTH_PROVIDER_PRESETS = [
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'claude',
'codex',
'qwen',
'iflow',
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
id: generateId(),
name: '',
alias: '',
fork: false,
});
const normalizeMappingEntries = (
entries?: OAuthModelAliasEntry[]
): OAuthModelMappingFormEntry[] => {
if (!Array.isArray(entries) || entries.length === 0) {
return [buildEmptyMappingEntry()];
}
return entries.map((entry) => ({
id: generateId(),
name: entry.name ?? '',
alias: entry.alias ?? '',
fork: Boolean(entry.fork),
}));
};
export function AuthFilesOAuthModelAliasEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const disableControls = connectionStatus !== 'connected';
const [searchParams, setSearchParams] = useSearchParams();
const providerFromParams = searchParams.get('provider') ?? '';
const [provider, setProvider] = useState(providerFromParams);
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
const [initialLoading, setInitialLoading] = useState(true);
const [modelAliasUnsupported, setModelAliasUnsupported] = useState(false);
const [mappings, setMappings] = useState<OAuthModelMappingFormEntry[]>([buildEmptyMappingEntry()]);
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
setProvider(providerFromParams);
}, [providerFromParams]);
const providerOptions = useMemo(() => {
const extraProviders = new Set<string>();
Object.keys(excluded).forEach((value) => extraProviders.add(value));
Object.keys(modelAlias).forEach((value) => extraProviders.add(value));
files.forEach((file) => {
if (typeof file.type === 'string') {
extraProviders.add(file.type);
}
if (typeof file.provider === 'string') {
extraProviders.add(file.provider);
}
});
const normalizedExtras = Array.from(extraProviders)
.map((value) => value.trim())
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
const extraList = normalizedExtras
.filter((value) => !baseSet.has(value.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
}, [excluded, files, modelAlias]);
const getTypeLabel = useCallback(
(type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
if (translated !== key) return translated;
if (type.toLowerCase() === 'iflow') return 'iFlow';
return type.charAt(0).toUpperCase() + type.slice(1);
},
[t]
);
const resolvedProviderKey = useMemo(() => normalizeProviderKey(provider), [provider]);
const title = useMemo(() => t('oauth_model_alias.add_title'), [t]);
const headerHint = useMemo(() => {
if (!provider.trim()) {
return t('oauth_model_alias.provider_hint');
}
if (modelsLoading) {
return t('oauth_model_alias.model_source_loading');
}
if (modelsError === 'unsupported') {
return t('oauth_model_alias.model_source_unsupported');
}
return t('oauth_model_alias.model_source_loaded', { count: modelsList.length });
}, [modelsError, modelsList.length, modelsLoading, provider, t]);
const handleBack = useCallback(() => {
const state = location.state as LocationState;
if (state?.fromAuthFiles) {
navigate(-1);
return;
}
navigate('/auth-files', { replace: true });
}, [location.state, navigate]);
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleBack();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleBack]);
useEffect(() => {
let cancelled = false;
const load = async () => {
setInitialLoading(true);
setModelAliasUnsupported(false);
try {
const [filesResult, excludedResult, aliasResult] = await Promise.allSettled([
authFilesApi.list(),
authFilesApi.getOauthExcludedModels(),
authFilesApi.getOauthModelAlias(),
]);
if (cancelled) return;
if (filesResult.status === 'fulfilled') {
setFiles(filesResult.value?.files ?? []);
}
if (excludedResult.status === 'fulfilled') {
setExcluded(excludedResult.value ?? {});
}
if (aliasResult.status === 'fulfilled') {
setModelAlias(aliasResult.value ?? {});
return;
}
const err = aliasResult.status === 'rejected' ? aliasResult.reason : null;
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setModelAliasUnsupported(true);
return;
}
} finally {
if (!cancelled) {
setInitialLoading(false);
}
}
};
load().catch(() => {
if (!cancelled) {
setInitialLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!resolvedProviderKey) {
setMappings([buildEmptyMappingEntry()]);
return;
}
const existing = modelAlias[resolvedProviderKey] ?? [];
setMappings(normalizeMappingEntries(existing));
}, [modelAlias, resolvedProviderKey]);
useEffect(() => {
if (!resolvedProviderKey || modelAliasUnsupported) {
setModelsList([]);
setModelsError(null);
setModelsLoading(false);
return;
}
let cancelled = false;
setModelsLoading(true);
setModelsError(null);
authFilesApi
.getModelDefinitions(resolvedProviderKey)
.then((models) => {
if (cancelled) return;
setModelsList(models);
})
.catch((err: unknown) => {
if (cancelled) return;
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setModelsList([]);
setModelsError('unsupported');
return;
}
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
})
.finally(() => {
if (cancelled) return;
setModelsLoading(false);
});
return () => {
cancelled = true;
};
}, [modelAliasUnsupported, resolvedProviderKey, showNotification, t]);
const updateProvider = useCallback(
(value: string) => {
setProvider(value);
const next = new URLSearchParams(searchParams);
const trimmed = value.trim();
if (trimmed) {
next.set('provider', trimmed);
} else {
next.delete('provider');
}
setSearchParams(next, { replace: true });
},
[searchParams, setSearchParams]
);
const updateMappingEntry = useCallback(
(index: number, field: keyof OAuthModelAliasEntry, value: string | boolean) => {
setMappings((prev) =>
prev.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry))
);
},
[]
);
const addMappingEntry = useCallback(() => {
setMappings((prev) => [...prev, buildEmptyMappingEntry()]);
}, []);
const removeMappingEntry = useCallback((index: number) => {
setMappings((prev) => {
const next = prev.filter((_, idx) => idx !== index);
return next.length ? next : [buildEmptyMappingEntry()];
});
}, []);
const handleSave = useCallback(async () => {
const channel = provider.trim();
if (!channel) {
showNotification(t('oauth_model_alias.provider_required'), 'error');
return;
}
const seen = new Set<string>();
const normalized = mappings
.map((entry) => {
const name = String(entry.name ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}`;
if (seen.has(key)) return null;
seen.add(key);
return entry.fork ? { name, alias, fork: true } : { name, alias };
})
.filter(Boolean) as OAuthModelAliasEntry[];
setSaving(true);
try {
if (normalized.length) {
await authFilesApi.saveOauthModelAlias(channel, normalized);
} else {
await authFilesApi.deleteOauthModelAlias(channel);
}
showNotification(t('oauth_model_alias.save_success'), 'success');
handleBack();
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
} finally {
setSaving(false);
}
}, [handleBack, mappings, provider, showNotification, t]);
const canSave = !disableControls && !saving && !modelAliasUnsupported;
return (
<SecondaryScreenShell
ref={swipeRef}
title={title}
onBack={handleBack}
backLabel={t('common.back')}
backAriaLabel={t('common.back')}
contentClassName={styles.pageContent}
rightAction={
<Button size="sm" onClick={handleSave} loading={saving} disabled={!canSave}>
{t('oauth_model_alias.save')}
</Button>
}
isLoading={initialLoading}
loadingLabel={t('common.loading')}
>
{modelAliasUnsupported ? (
<Card>
<EmptyState
title={t('oauth_model_alias.upgrade_required_title')}
description={t('oauth_model_alias.upgrade_required_desc')}
/>
</Card>
) : (
<>
<Card className={styles.settingsCard}>
<div className={styles.settingsHeader}>
<div className={styles.settingsHeaderTitle}>
<IconInfo size={16} />
<span>{t('oauth_model_alias.title')}</span>
</div>
<div className={styles.settingsHeaderHint}>{headerHint}</div>
</div>
<div className={styles.settingsSection}>
<div className={styles.settingsRow}>
<div className={styles.settingsInfo}>
<div className={styles.settingsLabel}>{t('oauth_model_alias.provider_label')}</div>
<div className={styles.settingsDesc}>{t('oauth_model_alias.provider_hint')}</div>
</div>
<div className={styles.settingsControl}>
<AutocompleteInput
id="oauth-model-alias-provider"
placeholder={t('oauth_model_alias.provider_placeholder')}
value={provider}
onChange={updateProvider}
options={providerOptions}
disabled={disableControls || saving}
wrapperStyle={{ marginBottom: 0 }}
/>
</div>
</div>
{providerOptions.length > 0 && (
<div className={styles.tagList}>
{providerOptions.map((option) => {
const isActive = normalizeProviderKey(provider) === option.toLowerCase();
return (
<button
key={option}
type="button"
className={`${styles.tag} ${isActive ? styles.tagActive : ''}`}
onClick={() => updateProvider(option)}
disabled={disableControls || saving}
>
{getTypeLabel(option)}
</button>
);
})}
</div>
)}
</div>
</Card>
<Card className={styles.settingsCard}>
<div className={styles.mappingsHeader}>
<div className={styles.mappingsTitle}>{t('oauth_model_alias.alias_label')}</div>
<Button
variant="secondary"
size="sm"
onClick={addMappingEntry}
disabled={disableControls || saving || modelAliasUnsupported}
>
{t('oauth_model_alias.add_alias')}
</Button>
</div>
<div className={styles.mappingsBody}>
{mappings.map((entry, index) => (
<div key={entry.id} className={styles.mappingRow}>
<AutocompleteInput
wrapperStyle={{ flex: 1, marginBottom: 0 }}
placeholder={t('oauth_model_alias.alias_name_placeholder')}
value={entry.name}
onChange={(val) => updateMappingEntry(index, 'name', val)}
disabled={disableControls || saving}
options={modelsList.map((model) => ({
value: model.id,
label:
model.display_name && model.display_name !== model.id
? model.display_name
: undefined,
}))}
/>
<span className={styles.mappingSeparator}></span>
<input
className={`input ${styles.mappingAliasInput}`}
placeholder={t('oauth_model_alias.alias_placeholder')}
value={entry.alias}
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
disabled={disableControls || saving}
/>
<div className={styles.mappingFork}>
<ToggleSwitch
label={t('oauth_model_alias.alias_fork_label')}
labelPosition="left"
checked={Boolean(entry.fork)}
onChange={(value) => updateMappingEntry(index, 'fork', value)}
disabled={disableControls || saving}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeMappingEntry(index)}
disabled={disableControls || saving || mappings.length <= 1}
title={t('common.delete')}
aria-label={t('common.delete')}
>
<IconX size={14} />
</Button>
</div>
))}
</div>
</Card>
</>
)}
</SecondaryScreenShell>
);
}

View File

@@ -32,6 +32,28 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.titleWrapper {
display: flex;
align-items: center;
gap: $spacing-sm;
line-height: 24px;
}
.countBadge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
min-width: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: var(--count-badge-text);
background-color: var(--count-badge-bg);
box-sizing: border-box;
}
.errorBox { .errorBox {
padding: $spacing-md; padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1); background-color: rgba(239, 68, 68, 0.1);
@@ -57,6 +79,9 @@
} }
.filterTag { .filterTag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px; padding: 6px 14px;
border-radius: 20px; border-radius: 20px;
font-size: 13px; font-size: 13px;
@@ -75,6 +100,16 @@
font-weight: 600; font-weight: 600;
} }
.filterTagLabel {
white-space: nowrap;
}
.filterTagCount {
font-size: 12px;
font-weight: 600;
opacity: 0.85;
}
.filterControls { .filterControls {
display: flex; display: flex;
gap: $spacing-md; gap: $spacing-md;
@@ -134,19 +169,6 @@
} }
} }
.statsInfo {
padding: 8px 12px;
background-color: var(--bg-secondary);
border-radius: $radius-md;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
height: 38px;
box-sizing: border-box;
display: flex;
align-items: center;
}
// 卡片网格 // 卡片网格
.fileGrid { .fileGrid {
display: grid; display: grid;
@@ -268,27 +290,15 @@
} }
.antigravityCard { .antigravityCard {
background-image: linear-gradient( background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
180deg,
rgba(224, 247, 250, 0.12),
rgba(224, 247, 250, 0)
);
} }
.codexCard { .codexCard {
background-image: linear-gradient( background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
180deg,
rgba(255, 243, 224, 0.18),
rgba(255, 243, 224, 0)
);
} }
.geminiCliCard { .geminiCliCard {
background-image: linear-gradient( background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
180deg,
rgba(231, 239, 255, 0.2),
rgba(231, 239, 255, 0)
);
} }
.quotaSection { .quotaSection {
@@ -437,7 +447,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-sm; gap: $spacing-sm;
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast; transition:
transform $transition-fast,
box-shadow $transition-fast,
border-color $transition-fast;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
@@ -446,6 +459,16 @@
} }
} }
.fileCardDisabled {
opacity: 0.6;
&:hover {
transform: none;
box-shadow: none;
border-color: var(--border-color);
}
}
.cardHeader { .cardHeader {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -537,7 +560,9 @@
height: 8px; height: 8px;
border-radius: 2px; border-radius: 2px;
min-width: 6px; min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease; transition:
transform 0.15s ease,
opacity 0.15s ease;
&:hover { &:hover {
transform: scaleY(1.5); transform: scaleY(1.5);
@@ -588,14 +613,90 @@
background: var(--failure-badge-bg, #fee2e2); background: var(--failure-badge-bg, #fee2e2);
} }
.prefixProxyEditor {
display: flex;
flex-direction: column;
gap: $spacing-md;
max-height: 60vh;
overflow: auto;
}
.prefixProxyLoading {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
font-size: 12px;
color: var(--text-secondary);
padding: $spacing-sm 0;
}
.prefixProxyError {
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
border: 1px solid var(--danger-color);
background-color: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
font-size: 12px;
}
.prefixProxyJsonWrapper {
display: flex;
flex-direction: column;
gap: 6px;
}
.prefixProxyLabel {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.prefixProxyTextarea {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
font-family: monospace;
resize: vertical;
min-height: 120px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
}
}
.prefixProxyFields {
display: grid;
grid-template-columns: 1fr;
gap: $spacing-sm;
:global(.form-group) {
margin: 0;
}
}
.cardActions { .cardActions {
display: flex; display: flex;
gap: $spacing-xs; gap: $spacing-xs;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
margin-top: auto; margin-top: auto;
padding-top: $spacing-sm; padding-top: $spacing-sm;
} }
.statusToggle {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: $spacing-sm;
}
.iconButton:global(.btn.btn-sm) { .iconButton:global(.btn.btn-sm) {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -742,6 +843,32 @@
} }
} }
// OAuth 模型映射表单
.mappingRow {
display: grid;
grid-template-columns: 1fr auto 1fr auto auto;
align-items: center;
gap: $spacing-sm;
@include mobile {
grid-template-columns: 1fr;
}
}
.mappingSeparator {
color: var(--text-secondary);
text-align: center;
@include mobile {
display: none;
}
}
.mappingFork {
display: flex;
align-items: center;
}
// 详情弹窗 // 详情弹窗
.detailContent { .detailContent {
max-height: 400px; max-height: 400px;
@@ -881,3 +1008,53 @@
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
flex-shrink: 0; flex-shrink: 0;
} }
// 排除模型勾选列表
.excludedCheckList {
display: flex;
flex-direction: column;
gap: $spacing-xs;
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: $spacing-sm;
background-color: var(--bg-secondary);
}
.excludedCheckItem {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-sm;
cursor: pointer;
transition: background-color $transition-fast;
&:hover {
background-color: var(--bg-hover);
}
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary-color);
}
}
.excludedCheckLabel {
display: flex;
align-items: center;
gap: $spacing-sm;
font-size: 13px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
color: var(--text-primary);
word-break: break-all;
}
.excludedCheckDisplayName {
font-size: 12px;
color: var(--text-tertiary);
font-family: inherit;
}

File diff suppressed because it is too large Load Diff

View File

@@ -133,14 +133,18 @@
.editorWrapper { .editorWrapper {
width: 100%; width: 100%;
flex: 1; flex: 0 0 auto;
min-height: 800px; height: clamp(360px, 60vh, 920px);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: $radius-lg; border-radius: $radius-lg;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
--floating-controls-height: 0px; --floating-controls-height: 0px;
@supports (height: 100dvh) {
height: clamp(360px, 60dvh, 920px);
}
// Floating search toolbar on top of the editor (but not covering content). // Floating search toolbar on top of the editor (but not covering content).
.floatingControls { .floatingControls {
position: absolute; position: absolute;
@@ -219,8 +223,8 @@
.configCard { .configCard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 1120px; flex: 1;
flex-shrink: 0; min-height: 0;
overflow: visible; overflow: visible;
} }
@@ -253,11 +257,6 @@
} }
.configCard { .configCard {
height: 880px;
padding: $spacing-md; padding: $spacing-md;
} }
.editorWrapper {
min-height: 600px;
}
} }

View File

@@ -0,0 +1,328 @@
@use '../styles/variables.scss' as *;
// 主容器 - 左右分栏布局
.container {
min-height: 100vh;
display: flex;
background: var(--bg-primary);
}
// 左侧品牌展示区
.brandPanel {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #000000;
padding: $spacing-2xl;
position: relative;
overflow: hidden;
// 移动端隐藏
@media (max-width: $breakpoint-mobile) {
display: none;
}
}
// 品牌文字容器
.brandContent {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: flex-end;
width: 100%;
padding: 0;
gap: 0;
}
// 品牌大字淡入动画
@keyframes brandFadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: var(--target-opacity, 0.9);
transform: translateY(0);
}
}
// 品牌大字
.brandWord {
font-size: 14vw;
font-weight: 900;
color: rgba(255, 255, 255, 0.9);
letter-spacing: -0.02em;
line-height: 0.85;
text-transform: uppercase;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
text-align: right;
padding-right: 0;
opacity: 0;
animation: brandFadeIn 0.8s ease-out forwards;
// 不同字有不同的透明度和延迟,从上到下依次显现
&:nth-child(1) {
--target-opacity: 0.95;
animation-delay: 0.1s;
}
&:nth-child(2) {
--target-opacity: 0.7;
animation-delay: 0.35s;
}
&:nth-child(3) {
--target-opacity: 0.45;
animation-delay: 0.6s;
}
}
// 右侧功能交互区
.formPanel {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: $spacing-2xl;
background: var(--bg-primary);
position: relative;
@media (max-width: $breakpoint-mobile) {
padding: $spacing-lg;
min-height: 100vh;
}
}
// 右侧内容容器
.formContent {
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-xl;
}
// Logo
.logo {
width: 80px;
height: 80px;
border-radius: $radius-lg;
object-fit: cover;
box-shadow: var(--shadow-lg);
border: 3px solid var(--border-color);
}
// 登录表单卡片
.loginCard {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
box-shadow: var(--shadow-lg);
padding: $spacing-xl;
display: flex;
flex-direction: column;
gap: $spacing-lg;
@media (max-width: $breakpoint-mobile) {
padding: $spacing-lg;
box-shadow: none;
border: none;
background: transparent;
}
}
// 登录头部
.loginHeader {
display: flex;
flex-direction: column;
gap: $spacing-sm;
text-align: center;
}
// 标题行
.titleRow {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
// 标题
.title {
font-size: 22px;
font-weight: 800;
color: var(--text-primary);
}
// 副标题
.subtitle {
color: var(--text-secondary);
font-size: 14px;
}
// 语言切换按钮
.languageBtn {
white-space: nowrap;
}
// 连接信息框
.connectionBox {
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
border-radius: $radius-md;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-xs;
.label {
color: var(--text-secondary);
font-size: 14px;
}
.value {
font-weight: 700;
color: var(--text-primary);
word-break: break-all;
}
.hint {
color: var(--text-secondary);
font-size: 12px;
}
}
// 复选框行
.toggleAdvanced {
display: flex;
justify-content: flex-start;
align-items: center;
gap: $spacing-xs;
color: var(--text-secondary);
cursor: pointer;
input[type='checkbox'] {
cursor: pointer;
}
label {
cursor: pointer;
font-size: 14px;
}
}
// 错误提示框
.errorBox {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: $radius-md;
padding: $spacing-sm $spacing-md;
color: $error-color;
font-size: 14px;
}
// ========== 启动动画(右侧) ==========
// 启动动画进入效果
@keyframes splashEnter {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
// Logo 脉冲效果
@keyframes splashLogoPulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
// 加载条动画
@keyframes splashLoading {
0% {
transform: scaleX(0);
transform-origin: left;
}
50% {
transform: scaleX(1);
transform-origin: left;
}
50.01% {
transform-origin: right;
}
100% {
transform: scaleX(0);
transform-origin: right;
}
}
// 启动动画内容容器
.splashContent {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-md;
animation: splashEnter 0.6s ease-out;
}
// 启动动画 Logo
.splashLogo {
height: 80px;
width: auto;
border-radius: $radius-lg;
box-shadow: $shadow-lg;
animation: splashLogoPulse 1.5s ease-in-out infinite;
}
// 启动动画标题
.splashTitle {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.5px;
}
// 启动动画副标题
.splashSubtitle {
font-size: 16px;
font-weight: 500;
color: var(--text-secondary);
margin: 0;
margin-top: -8px;
}
// 启动动画加载条容器
.splashLoader {
width: 120px;
height: 3px;
background: var(--border-color);
border-radius: $radius-full;
overflow: hidden;
margin-top: $spacing-md;
}
// 启动动画加载条
.splashLoaderBar {
width: 100%;
height: 100%;
background: var(--primary-color);
border-radius: $radius-full;
animation: splashLoading 1.2s ease-in-out infinite;
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { Navigate, useNavigate, useLocation } from 'react-router-dom'; import { Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -6,6 +6,52 @@ import { Input } from '@/components/ui/Input';
import { IconEye, IconEyeOff } from '@/components/ui/icons'; import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores'; import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection'; import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import type { ApiError } from '@/types';
import styles from './LoginPage.module.scss';
/**
* 将 API 错误转换为本地化的用户友好消息
*/
function getLocalizedErrorMessage(error: any, t: (key: string) => string): string {
const apiError = error as ApiError;
const status = apiError?.status;
const code = apiError?.code;
const message = apiError?.message || '';
// 根据 HTTP 状态码判断
if (status === 401) {
return t('login.error_unauthorized');
}
if (status === 403) {
return t('login.error_forbidden');
}
if (status === 404) {
return t('login.error_not_found');
}
if (status && status >= 500) {
return t('login.error_server');
}
// 根据 axios 错误码判断
if (code === 'ECONNABORTED' || message.toLowerCase().includes('timeout')) {
return t('login.error_timeout');
}
if (code === 'ERR_NETWORK' || message.toLowerCase().includes('network error')) {
return t('login.error_network');
}
if (code === 'ERR_CERT_AUTHORITY_INVALID' || message.toLowerCase().includes('certificate')) {
return t('login.error_ssl');
}
// 检查 CORS 错误
if (message.toLowerCase().includes('cors') || message.toLowerCase().includes('cross-origin')) {
return t('login.error_cors');
}
// 默认错误消息
return t('login.error_invalid');
}
export function LoginPage() { export function LoginPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -28,6 +74,7 @@ export function LoginPage() {
const [rememberPassword, setRememberPassword] = useState(false); const [rememberPassword, setRememberPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [autoLoading, setAutoLoading] = useState(true); const [autoLoading, setAutoLoading] = useState(true);
const [autoLoginSuccess, setAutoLoginSuccess] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []); const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
@@ -37,25 +84,30 @@ export function LoginPage() {
const init = async () => { const init = async () => {
try { try {
const autoLoggedIn = await restoreSession(); const autoLoggedIn = await restoreSession();
if (!autoLoggedIn) { if (autoLoggedIn) {
setAutoLoginSuccess(true);
// 延迟跳转,让用户看到成功动画
setTimeout(() => {
const redirect = (location.state as any)?.from?.pathname || '/';
navigate(redirect, { replace: true });
}, 1500);
} else {
setApiBase(storedBase || detectedBase); setApiBase(storedBase || detectedBase);
setManagementKey(storedKey || ''); setManagementKey(storedKey || '');
setRememberPassword(storedRememberPassword || Boolean(storedKey)); setRememberPassword(storedRememberPassword || Boolean(storedKey));
} }
} finally { } finally {
if (!autoLoginSuccess) {
setAutoLoading(false); setAutoLoading(false);
} }
}
}; };
init(); init();
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isAuthenticated) { const handleSubmit = useCallback(async () => {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
const handleSubmit = async () => {
if (!managementKey.trim()) { if (!managementKey.trim()) {
setError(t('login.error_required')); setError(t('login.error_required'));
return; return;
@@ -73,25 +125,71 @@ export function LoginPage() {
showNotification(t('common.connected_status'), 'success'); showNotification(t('common.connected_status'), 'success');
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (err: any) { } catch (err: any) {
const message = err?.message || t('login.error_invalid'); const message = getLocalizedErrorMessage(err, t);
setError(message); setError(message);
showNotification(`${t('notification.login_failed')}: ${message}`, 'error'); showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [apiBase, detectedBase, login, managementKey, navigate, rememberPassword, showNotification, t]);
const handleSubmitKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !loading) {
event.preventDefault();
handleSubmit();
}
},
[loading, handleSubmit]
);
if (isAuthenticated && !autoLoading && !autoLoginSuccess) {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
// 显示启动动画(自动登录中或自动登录成功)
const showSplash = autoLoading || autoLoginSuccess;
return ( return (
<div className="login-page"> <div className={styles.container}>
<div className="login-card"> {/* 左侧品牌展示区 */}
<div className="login-header"> <div className={styles.brandPanel}>
<div className="login-title-row"> <div className={styles.brandContent}>
<div className="title">{t('title.login')}</div> <span className={styles.brandWord}>CLI</span>
<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={styles.formContent}>
{/* Logo */}
<img src={INLINE_LOGO_JPEG} alt="Logo" className={styles.logo} />
{/* 登录表单卡片 */}
<div className={styles.loginCard}>
<div className={styles.loginHeader}>
<div className={styles.titleRow}>
<div className={styles.title}>{t('title.login')}</div>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="login-language-btn" className={styles.languageBtn}
onClick={toggleLanguage} onClick={toggleLanguage}
title={t('language.switch')} title={t('language.switch')}
aria-label={t('language.switch')} aria-label={t('language.switch')}
@@ -99,16 +197,16 @@ export function LoginPage() {
{nextLanguageLabel} {nextLanguageLabel}
</Button> </Button>
</div> </div>
<div className="subtitle">{t('login.subtitle')}</div> <div className={styles.subtitle}>{t('login.subtitle')}</div>
</div> </div>
<div className="connection-box"> <div className={styles.connectionBox}>
<div className="label">{t('login.connection_current')}</div> <div className={styles.label}>{t('login.connection_current')}</div>
<div className="value">{apiBase || detectedBase}</div> <div className={styles.value}>{apiBase || detectedBase}</div>
<div className="hint">{t('login.connection_auto_hint')}</div> <div className={styles.hint}>{t('login.connection_auto_hint')}</div>
</div> </div>
<div className="toggle-advanced"> <div className={styles.toggleAdvanced}>
<input <input
id="custom-connection-toggle" id="custom-connection-toggle"
type="checkbox" type="checkbox"
@@ -129,11 +227,13 @@ export function LoginPage() {
)} )}
<Input <Input
autoFocus
label={t('login.management_key_label')} label={t('login.management_key_label')}
placeholder={t('login.management_key_placeholder')} placeholder={t('login.management_key_placeholder')}
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
value={managementKey} value={managementKey}
onChange={(e) => setManagementKey(e.target.value)} onChange={(e) => setManagementKey(e.target.value)}
onKeyDown={handleSubmitKeyDown}
rightElement={ rightElement={
<button <button
type="button" type="button"
@@ -155,7 +255,7 @@ export function LoginPage() {
} }
/> />
<div className="toggle-advanced"> <div className={styles.toggleAdvanced}>
<input <input
id="remember-password-toggle" id="remember-password-toggle"
type="checkbox" type="checkbox"
@@ -169,12 +269,8 @@ export function LoginPage() {
{loading ? t('login.submitting') : t('login.submit_button')} {loading ? t('login.submitting') : t('login.submit_button')}
</Button> </Button>
{error && <div className="error-box">{error}</div>} {error && <div className={styles.errorBox}>{error}</div>}
</div>
{autoLoading && (
<div className="connection-box">
<div className="label">{t('auto_login.title')}</div>
<div className="value">{t('auto_login.message')}</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -44,6 +44,12 @@
&:hover { &:hover {
color: var(--text-primary); color: var(--text-primary);
} }
&:focus,
&:focus-visible {
outline: none;
box-shadow: none;
}
} }
.tabActive { .tabActive {
@@ -262,6 +268,30 @@
flex-direction: column; flex-direction: column;
} }
.rawLog {
margin: 0;
padding: 10px 12px;
cursor: text;
user-select: text;
white-space: pre;
color: var(--text-primary);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 12.5px;
line-height: 1.45;
@include tablet {
padding: 8px 10px;
font-size: 12px;
}
@include mobile {
padding: 8px 10px;
font-size: 11.5px;
}
}
.logRow { .logRow {
display: grid; display: grid;
grid-template-columns: 170px 1fr; grid-template-columns: 170px 1fr;

View File

@@ -9,6 +9,7 @@ import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { import {
IconDownload, IconDownload,
IconCode,
IconEyeOff, IconEyeOff,
IconRefreshCw, IconRefreshCw,
IconSearch, IconSearch,
@@ -16,6 +17,7 @@ import {
IconTrash2, IconTrash2,
IconX, IconX,
} from '@/components/ui/icons'; } from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs'; import { logsApi } from '@/services/api/logs';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants'; import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
@@ -370,7 +372,7 @@ type TabType = 'logs' | 'errors';
export function LogsPage() { export function LogsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false); const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
@@ -382,6 +384,7 @@ export function LogsPage() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery); const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(true); const [hideManagementLogs, setHideManagementLogs] = useState(true);
const [showRawLogs, setShowRawLogs] = useState(false);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]); const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false); const [loadingErrors, setLoadingErrors] = useState(false);
const [errorLogsError, setErrorLogsError] = useState(''); const [errorLogsError, setErrorLogsError] = useState('');
@@ -474,8 +477,15 @@ export function LogsPage() {
} }
}; };
useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => { const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return; showConfirmation({
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
message: t('logs.clear_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try { try {
await logsApi.clearLogs(); await logsApi.clearLogs();
setLogState({ buffer: [], visibleFrom: 0 }); setLogState({ buffer: [], visibleFrom: 0 });
@@ -488,6 +498,8 @@ export function LogsPage() {
'error' 'error'
); );
} }
},
});
}; };
const downloadLogs = () => { const downloadLogs = () => {
@@ -622,10 +634,12 @@ export function LogsPage() {
return { filteredLines: working, removedCount: removed }; return { filteredLines: working, removedCount: removed };
}, [baseLines, hideManagementLogs, trimmedSearchQuery]); }, [baseLines, hideManagementLogs, trimmedSearchQuery]);
const parsedVisibleLines = useMemo( const parsedVisibleLines = useMemo(() => {
() => filteredLines.map((line) => parseLogLine(line)), if (showRawLogs) return [];
[filteredLines] return filteredLines.map((line) => parseLogLine(line));
); }, [filteredLines, showRawLogs]);
const rawVisibleText = useMemo(() => filteredLines.join('\n'), [filteredLines]);
const canLoadMore = !isSearching && logState.visibleFrom > 0; const canLoadMore = !isSearching && logState.visibleFrom > 0;
@@ -807,6 +821,22 @@ export function LogsPage() {
} }
/> />
<ToggleSwitch
checked={showRawLogs}
onChange={setShowRawLogs}
label={
<span
className={styles.switchLabel}
title={t('logs.show_raw_logs_hint', {
defaultValue: 'Show original log text for easier multi-line copy',
})}
>
<IconCode size={16} />
{t('logs.show_raw_logs', { defaultValue: 'Show raw logs' })}
</span>
}
/>
<div className={styles.toolbar}> <div className={styles.toolbar}>
<Button <Button
variant="secondary" variant="secondary"
@@ -860,14 +890,14 @@ export function LogsPage() {
{loading ? ( {loading ? (
<div className="hint">{t('logs.loading')}</div> <div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? ( ) : logState.buffer.length > 0 && filteredLines.length > 0 ? (
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}> <div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{canLoadMore && ( {canLoadMore && (
<div className={styles.loadMoreBanner}> <div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span> <span>{t('logs.load_more_hint')}</span>
<div className={styles.loadMoreStats}> <div className={styles.loadMoreStats}>
<span> <span>
{t('logs.loaded_lines', { count: parsedVisibleLines.length })} {t('logs.loaded_lines', { count: filteredLines.length })}
</span> </span>
{removedCount > 0 && ( {removedCount > 0 && (
<span className={styles.loadMoreCount}> <span className={styles.loadMoreCount}>
@@ -880,6 +910,11 @@ export function LogsPage() {
</div> </div>
</div> </div>
)} )}
{showRawLogs ? (
<pre className={styles.rawLog} spellCheck={false}>
{rawVisibleText}
</pre>
) : (
<div className={styles.logList}> <div className={styles.logList}>
{parsedVisibleLines.map((line, index) => { {parsedVisibleLines.map((line, index) => {
const rowClassNames = [styles.logRow]; const rowClassNames = [styles.logRow];
@@ -977,6 +1012,7 @@ export function LogsPage() {
); );
})} })}
</div> </div>
)}
</div> </div>
) : logState.buffer.length > 0 ? ( ) : logState.buffer.length > 0 ? (
<EmptyState <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 { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -7,8 +7,8 @@ import { useNotificationStore, useThemeStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth'; import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex'; import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import styles from './OAuthPage.module.scss'; import styles from './OAuthPage.module.scss';
import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconCodexDark from '@/assets/icons/codex_drak.svg';
import iconClaude from '@/assets/icons/claude.svg'; import iconClaude from '@/assets/icons/claude.svg';
import iconAntigravity from '@/assets/icons/antigravity.svg'; import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg'; import iconGemini from '@/assets/icons/gemini.svg';
@@ -55,7 +55,7 @@ interface VertexImportState {
} }
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } }, { id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude }, { id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity }, { id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini }, { id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
@@ -85,11 +85,16 @@ export function OAuthPage() {
const timers = useRef<Record<string, number>>({}); const timers = useRef<Record<string, number>>({});
const vertexFileInputRef = useRef<HTMLInputElement | null>(null); const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
const clearTimers = useCallback(() => {
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
timers.current = {};
}, []);
useEffect(() => { useEffect(() => {
return () => { return () => {
Object.values(timers.current).forEach((timer) => window.clearInterval(timer)); clearTimers();
}; };
}, []); }, [clearTimers]);
const updateProviderState = (provider: OAuthProvider, next: Partial<ProviderState>) => { const updateProviderState = (provider: OAuthProvider, next: Partial<ProviderState>) => {
setStates((prev) => ({ setStates((prev) => ({

View File

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

View File

@@ -4,9 +4,9 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore } from '@/stores'; import { useAuthStore } from '@/stores';
import { authFilesApi } from '@/services/api'; import { authFilesApi, configFileApi } from '@/services/api';
import { import {
QuotaSection, QuotaSection,
ANTIGRAVITY_CONFIG, ANTIGRAVITY_CONFIG,
@@ -26,6 +26,15 @@ export function QuotaPage() {
const disableControls = connectionStatus !== 'connected'; const disableControls = connectionStatus !== 'connected';
const loadConfig = useCallback(async () => {
try {
await configFileApi.fetchConfigYaml();
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
setError((prev) => prev || errorMessage);
}
}, [t]);
const loadFiles = useCallback(async () => { const loadFiles = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -40,20 +49,22 @@ export function QuotaPage() {
} }
}, [t]); }, [t]);
const handleHeaderRefresh = useCallback(async () => {
await Promise.all([loadConfig(), loadFiles()]);
}, [loadConfig, loadFiles]);
useHeaderRefresh(handleHeaderRefresh);
useEffect(() => { useEffect(() => {
loadFiles(); loadFiles();
}, [loadFiles]); loadConfig();
}, [loadFiles, loadConfig]);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('quota_management.title')}</h1> <h1 className={styles.pageTitle}>{t('quota_management.title')}</h1>
<p className={styles.description}>{t('quota_management.description')}</p> <p className={styles.description}>{t('quota_management.description')}</p>
<div className={styles.headerActions}>
<Button variant="secondary" size="sm" onClick={loadFiles} disabled={loading}>
{t('quota_management.refresh_files')}
</Button>
</div>
</div> </div>
{error && <div className={styles.errorBox}>{error}</div>} {error && <div className={styles.errorBox}>{error}</div>}

View File

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

View File

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

View File

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

View File

@@ -3,15 +3,39 @@ import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons'; import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
import { apiKeysApi } from '@/services/api/apiKeys'; import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models'; import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants'; import { STORAGE_KEY_AUTH } from '@/utils/constants';
import iconGemini from '@/assets/icons/gemini.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
import iconGlm from '@/assets/icons/glm.svg';
import iconGrok from '@/assets/icons/grok.svg';
import iconDeepseek from '@/assets/icons/deepseek.svg';
import iconMinimax from '@/assets/icons/minimax.svg';
import styles from './SystemPage.module.scss'; import styles from './SystemPage.module.scss';
const MODEL_CATEGORY_ICONS: Record<string, string | { light: string; dark: string }> = {
gpt: { light: iconOpenaiLight, dark: iconOpenaiDark },
claude: iconClaude,
gemini: iconGemini,
qwen: iconQwen,
kimi: { light: iconKimiLight, dark: iconKimiDark },
glm: iconGlm,
grok: iconGrok,
deepseek: iconDeepseek,
minimax: iconMinimax,
};
export function SystemPage() { export function SystemPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const auth = useAuthStore(); const auth = useAuthStore();
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig); const fetchConfig = useConfigStore((state) => state.fetchConfig);
@@ -31,6 +55,13 @@ export function SystemPage() {
); );
const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]); const groupedModels = useMemo(() => classifyModels(models, { otherLabel }), [models, otherLabel]);
const getIconForCategory = (categoryId: string): string | null => {
const iconEntry = MODEL_CATEGORY_ICONS[categoryId];
if (!iconEntry) return null;
if (typeof iconEntry === 'string') return iconEntry;
return resolvedTheme === 'dark' ? iconEntry.dark : iconEntry.light;
};
const normalizeApiKeyList = (input: any): string[] => { const normalizeApiKeyList = (input: any): string[] => {
if (!Array.isArray(input)) return []; if (!Array.isArray(input)) return [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -106,12 +137,19 @@ export function SystemPage() {
}; };
const handleClearLoginStorage = () => { const handleClearLoginStorage = () => {
if (!window.confirm(t('system_info.clear_login_confirm'))) return; showConfirmation({
title: t('system_info.clear_login_title', { defaultValue: 'Clear Login Storage' }),
message: t('system_info.clear_login_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: () => {
auth.logout(); auth.logout();
if (typeof localStorage === 'undefined') return; if (typeof localStorage === 'undefined') return;
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey']; const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
keysToRemove.forEach((key) => localStorage.removeItem(key)); keysToRemove.forEach((key) => localStorage.removeItem(key));
showNotification(t('notification.login_storage_cleared'), 'success'); showNotification(t('notification.login_storage_cleared'), 'success');
},
});
}; };
useEffect(() => { useEffect(() => {
@@ -235,10 +273,15 @@ export function SystemPage() {
<div className="hint">{t('system_info.models_empty')}</div> <div className="hint">{t('system_info.models_empty')}</div>
) : ( ) : (
<div className="item-list"> <div className="item-list">
{groupedModels.map((group) => ( {groupedModels.map((group) => {
const iconSrc = getIconForCategory(group.id);
return (
<div key={group.id} className="item-row"> <div key={group.id} className="item-row">
<div className="item-meta"> <div className="item-meta">
<div className="item-title">{group.label}</div> <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 className="item-subtitle">{t('system_info.models_count', { count: group.items.length })}</div>
</div> </div>
<div className={styles.modelTags}> <div className={styles.modelTags}>
@@ -254,7 +297,8 @@ export function SystemPage() {
))} ))}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
)} )}
</Card> </Card>

View File

@@ -456,6 +456,18 @@
word-break: break-all; word-break: break-all;
} }
.requestCountCell {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-variant-numeric: tabular-nums;
}
.requestBreakdown {
color: var(--text-secondary);
white-space: nowrap;
}
// Pricing Section (80%比例) // Pricing Section (80%比例)
.pricingSection { .pricingSection {
display: flex; display: flex;

View File

@@ -14,6 +14,7 @@ import {
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore } from '@/stores'; import { useThemeStore } from '@/stores';
import { import {
StatCards, StatCards,
@@ -63,6 +64,8 @@ export function UsagePage() {
importing importing
} = useUsageData(); } = useUsageData();
useHeaderRefresh(loadUsage);
// Chart lines state // Chart lines state
const [chartLines, setChartLines] = useState<string[]>(['all']); const [chartLines, setChartLines] = useState<string[]>(['all']);
const MAX_CHART_LINES = 9; const MAX_CHART_LINES = 9;

View File

@@ -3,7 +3,17 @@ import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage'; import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage'; import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage'; import { AiProvidersPage } from '@/pages/AiProvidersPage';
import { AiProvidersAmpcodeEditPage } from '@/pages/AiProvidersAmpcodeEditPage';
import { AiProvidersClaudeEditPage } from '@/pages/AiProvidersClaudeEditPage';
import { AiProvidersCodexEditPage } from '@/pages/AiProvidersCodexEditPage';
import { AiProvidersGeminiEditPage } from '@/pages/AiProvidersGeminiEditPage';
import { AiProvidersOpenAIEditLayout } from '@/pages/AiProvidersOpenAIEditLayout';
import { AiProvidersOpenAIEditPage } from '@/pages/AiProvidersOpenAIEditPage';
import { AiProvidersOpenAIModelsPage } from '@/pages/AiProvidersOpenAIModelsPage';
import { AiProvidersVertexEditPage } from '@/pages/AiProvidersVertexEditPage';
import { AuthFilesPage } from '@/pages/AuthFilesPage'; import { AuthFilesPage } from '@/pages/AuthFilesPage';
import { AuthFilesOAuthExcludedEditPage } from '@/pages/AuthFilesOAuthExcludedEditPage';
import { AuthFilesOAuthModelAliasEditPage } from '@/pages/AuthFilesOAuthModelAliasEditPage';
import { OAuthPage } from '@/pages/OAuthPage'; import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage'; import { QuotaPage } from '@/pages/QuotaPage';
import { UsagePage } from '@/pages/UsagePage'; import { UsagePage } from '@/pages/UsagePage';
@@ -16,8 +26,36 @@ const mainRoutes = [
{ path: '/dashboard', element: <DashboardPage /> }, { path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings', element: <SettingsPage /> }, { path: '/settings', element: <SettingsPage /> },
{ path: '/api-keys', element: <ApiKeysPage /> }, { path: '/api-keys', element: <ApiKeysPage /> },
{ path: '/ai-providers/gemini/new', element: <AiProvidersGeminiEditPage /> },
{ path: '/ai-providers/gemini/:index', element: <AiProvidersGeminiEditPage /> },
{ path: '/ai-providers/codex/new', element: <AiProvidersCodexEditPage /> },
{ path: '/ai-providers/codex/:index', element: <AiProvidersCodexEditPage /> },
{ path: '/ai-providers/claude/new', element: <AiProvidersClaudeEditPage /> },
{ path: '/ai-providers/claude/:index', element: <AiProvidersClaudeEditPage /> },
{ path: '/ai-providers/vertex/new', element: <AiProvidersVertexEditPage /> },
{ path: '/ai-providers/vertex/:index', element: <AiProvidersVertexEditPage /> },
{
path: '/ai-providers/openai/new',
element: <AiProvidersOpenAIEditLayout />,
children: [
{ index: true, element: <AiProvidersOpenAIEditPage /> },
{ path: 'models', element: <AiProvidersOpenAIModelsPage /> },
],
},
{
path: '/ai-providers/openai/:index',
element: <AiProvidersOpenAIEditLayout />,
children: [
{ index: true, element: <AiProvidersOpenAIEditPage /> },
{ path: 'models', element: <AiProvidersOpenAIModelsPage /> },
],
},
{ path: '/ai-providers/ampcode', element: <AiProvidersAmpcodeEditPage /> },
{ path: '/ai-providers', element: <AiProvidersPage /> }, { path: '/ai-providers', element: <AiProvidersPage /> },
{ path: '/ai-providers/*', element: <AiProvidersPage /> },
{ path: '/auth-files', element: <AuthFilesPage /> }, { path: '/auth-files', element: <AuthFilesPage /> },
{ path: '/auth-files/oauth-excluded', element: <AuthFilesOAuthExcludedEditPage /> },
{ path: '/auth-files/oauth-model-alias', element: <AuthFilesOAuthModelAliasEditPage /> },
{ path: '/oauth', element: <OAuthPage /> }, { path: '/oauth', element: <OAuthPage /> },
{ path: '/quota', element: <QuotaPage /> }, { path: '/quota', element: <QuotaPage /> },
{ path: '/usage', element: <UsagePage /> }, { path: '/usage', element: <UsagePage /> },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 };
});
},
}));

View File

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

View File

@@ -407,98 +407,6 @@
} }
} }
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
padding: $spacing-lg;
.login-card {
width: 100%;
max-width: 520px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
box-shadow: var(--shadow-lg);
padding: $spacing-xl;
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.login-header {
display: flex;
flex-direction: column;
gap: $spacing-sm;
text-align: center;
.title {
font-size: 22px;
font-weight: 800;
color: var(--text-primary);
}
.subtitle {
color: var(--text-secondary);
}
}
.login-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.login-language-btn {
white-space: nowrap;
}
.connection-box {
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
border-radius: $radius-md;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-xs;
.label {
color: var(--text-secondary);
font-size: 14px;
}
.value {
font-weight: 700;
color: var(--text-primary);
}
.item-actions {
display: flex;
gap: $spacing-md;
}
}
.toggle-advanced {
display: flex;
justify-content: flex-start;
align-items: center;
gap: $spacing-xs;
color: var(--text-secondary);
cursor: pointer;
}
.error-box {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: $radius-md;
padding: $spacing-sm $spacing-md;
color: $error-color;
}
}
.grid { .grid {
display: grid; display: grid;

View File

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

View File

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

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