Compare commits

...

241 Commits

Author SHA1 Message Date
Supra4E8C
c6fabcb6bc fix(ui): sync provider quick switch highlight with scroll target 2026-01-31 17:12:59 +08:00
Supra4E8C
460519ed00 feat: add Codex icons and update references in components 2026-01-31 17:06:27 +08:00
Supra4E8C
1053e91fe4 refactor: move modelsToEntries and entriesToModels to modelInputListUtils for better organization 2026-01-31 16:43:46 +08:00
Supra4E8C
b4d08dd0d7 style(auth-files): add centered padding for OAuth edit pages 2026-01-31 16:12:46 +08:00
Supra4E8C
1502e14ca7 feat: add auth type counts and hide disabled quotas 2026-01-31 16:05:48 +08:00
Supra4E8C
7b77520526 fix 2026-01-31 16:00:40 +08:00
Supra4E8C
525541ea0d feat(ui): add model icons and categories, tweak login redirect delay 2026-01-31 15:53:03 +08:00
Supra4E8C
e7a33f8852 feat(login): enhance error handling with localized messages for various connection issues 2026-01-31 15:24:56 +08:00
Supra4E8C
70968bbc4c feat(login): add auto-login splash UI and simplify app startup 2026-01-31 15:21:13 +08:00
Supra4E8C
c93030370e feat(login): redesign login page with split layout 2026-01-31 14:59:00 +08:00
LTbinglingfeng
96307873c5 fix(ui): remove focus outline on logs tabs 2026-01-31 13:18:35 +08:00
LTbinglingfeng
b4eb2d790c fix: remove unused variables and clean up PageTransition component 2026-01-31 12:40:41 +08:00
LTbinglingfeng
3d33958d9e fix 2026-01-31 02:03:17 +08:00
LTbinglingfeng
e4c5f80b02 feat: add configuration loading and error handling to AiProvidersPage 2026-01-31 01:26:49 +08:00
LTbinglingfeng
291f67e2b9 feat: add floating provider navigation sidebar to AI providers page 2026-01-31 01:09:55 +08:00
LTbinglingfeng
3cdcb7a2a3 feat: enhance scroll position management during page transitions 2026-01-31 00:36:13 +08:00
LTbinglingfeng
3d83d0bfe2 style: add shared content width constraint to AI provider edit pages 2026-01-30 23:35:41 +08:00
LTbinglingfeng
129d89cf67 feat: improve error handling and manage component mount state in AiProvidersAmpcodeEditPage 2026-01-30 02:00:35 +08:00
LTbinglingfeng
5c85df486e feat: replace AI provider modals with dedicated edit pages 2026-01-30 01:30:36 +08:00
LTbinglingfeng
34b6d114d3 feat: add toggle for showing raw logs and update log display logic 2026-01-30 00:01:12 +08:00
LTbinglingfeng
94f0038f19 style: update settings card and header for mobile responsiveness 2026-01-29 23:42:07 +08:00
Supra4E8C
aa9c7d89f9 Merge pull request #74 from router-for-me/codex/remove-blue-box-from-log-view
Remove blue focus ring from Logs page tabs
2026-01-29 20:12:26 +08:00
Supra4E8C
9bbf61e1b6 Remove focus outline from logs tabs 2026-01-29 20:09:49 +08:00
Supra4E8C
73198d6929 Merge pull request #73 from router-for-me/codex/fix-base-url-handling-for-openai-interface
Fix OpenAI test endpoint to preserve /v1 base path
2026-01-29 19:58:55 +08:00
Supra4E8C
ab86fcf674 Fix OpenAI test endpoint base URL 2026-01-29 19:57:09 +08:00
LTbinglingfeng
a88078e171 refactor(PageTransition): optimize layer management and transition handling 2026-01-29 03:10:04 +08:00
LTbinglingfeng
8148851a06 feat: add OAuth model alias editing page and routing 2026-01-29 02:21:04 +08:00
LTbinglingfeng
8b3c4189f1 fix(providers): use /chat/completions for OpenAI test requests 2026-01-28 00:17:31 +08:00
hkfires
db5fb0d125 refactor(i18n): rename model alias translation keys 2026-01-27 15:38:07 +08:00
hkfires
9515d88e3c feat(ui): add model checklist for oauth exclusions 2026-01-27 14:56:23 +08:00
hkfires
2bf721974b feat(auth): load model lists via /model-definitions/{channel} instead of per-file
model sources.
2026-01-27 14:27:26 +08:00
hkfires
0c53dcfa80 docs(i18n): rename model mappings to aliases in ui strings 2026-01-27 12:00:42 +08:00
hkfires
034c086e31 feat(usage): show per-model success/failure counts 2026-01-25 11:29:34 +08:00
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
LTbinglingfeng
8232812ac2 feat(ui): show AIStudio models for virtual auth files and adjust Gemini OAuth spacing 2026-01-02 22:42:20 +08:00
LTbinglingfeng
2ae06a8860 perf(ui): smooth gsap page transitions 2026-01-02 20:26:41 +08:00
LTbinglingfeng
dc58a0701f fix(logs): parse latency durations with minutes 2026-01-02 20:11:16 +08:00
Supra4E8C
3446280987 refactor(quota,auth): change page size selector to number input with range 3-30 2026-01-02 17:27:35 +08:00
Supra4E8C
82bf1806ed refactor(quota): consolidate quota sections into config-driven components 2026-01-02 17:14:40 +08:00
Supra4E8C
47f0042bf0 refactor(usage): modularize UsagePage into separate section components 2026-01-02 16:19:04 +08:00
Supra4E8C
58154063ed refactor(quota): modularize QuotaPage into separate section components 2026-01-02 15:55:17 +08:00
Supra4E8C
cc467889d0 refactor(providers): modularize AiProvidersPage into separate provider components 2026-01-02 15:29:16 +08:00
Supra4E8C
469e5d2ed4 Merge branch 'dev' 2026-01-02 14:18:56 +08:00
LTbinglingfeng
6ce301d7e0 fix(transition): avoid HiDPI text blur from page transitions 2026-01-02 11:17:57 +08:00
LTbinglingfeng
8461de124f Merge branch 'dev' 2026-01-02 02:04:17 +08:00
Supra4E8C
276f416ec9 Merge pull request #41 from yanyuhualb/feature/make-project-id-optional
feat: make Gemini CLI project ID optional
2026-01-02 02:03:04 +08:00
LTbinglingfeng
583a844771 Merge branch 'dev' 2026-01-02 01:58:06 +08:00
LTbinglingfeng
62fa437285 fix(ui): use resolvedTheme for OpenAI icon switching in providers page 2026-01-02 01:54:22 +08:00
LTbinglingfeng
daab589c49 fix(ui): remove redundant add button from empty state in providers list 2026-01-02 01:42:43 +08:00
LTbinglingfeng
e18e9b25ce fix(transition): allow page container to shrink for proper layout 2026-01-02 01:37:17 +08:00
LTbinglingfeng
4cfb77dd44 fix(ui): center modals in viewport and lock background scroll 2026-01-02 01:15:04 +08:00
LTbinglingfeng
7cab1e8782 fix(oauth): remove iFlow provider from OAuth flow 2026-01-02 00:58:24 +08:00
LTbinglingfeng
079f37ec93 fix(quota): translate Codex window labels at render time 2026-01-02 00:41:35 +08:00
LTbinglingfeng
7ce97a616f fix(transition): preserve scroll position during page animations 2026-01-02 00:29:42 +08:00
LTbinglingfeng
946ed36af0 feat(router): add GSAP page transition animations 2026-01-02 00:01:25 +08:00
yanyuhualb
f139598526 feat: make Gemini CLI project ID optional
- Remove required validation for project ID field
- Update translations to indicate field is optional (zh-CN, en)
- Auto-select first available project when left empty
- Backend already supports empty project ID by fetching project list

This improves user experience by eliminating the need to manually enter project ID for users with only one or a preferred default project.
2025-12-31 23:06:44 +08:00
Supra4E8C
40ddd3c066 Merge pull request #40 from router-for-me/dev
feat(auth): add remember-password login and clear local auth data card
2025-12-31 20:48:25 +08:00
Supra4E8C
3a66dc225d feat(auth): add remember-password login and clear local auth data card 2025-12-31 20:04:32 +08:00
Supra4E8C
eadfd7a957 feat(quota): group Gemini CLI buckets and refine Gemini quota groups 2025-12-30 21:48:12 +08:00
Supra4E8C
f739e0b372 style(config): double editor height 2025-12-30 18:29:47 +08:00
Supra4E8C
23fb88e5fd feat(quota): add zustand store for quota state caching 2025-12-30 18:29:28 +08:00
Supra4E8C
49b9259452 feat(quota): add quota page and update i18n 2025-12-30 14:13:04 +08:00
Supra4E8C
4e26b6c92d feat(auth-files): add Gemini CLI quota card and API call 2025-12-30 12:18:20 +08:00
Supra4E8C
215ce61b48 fix: error display 2025-12-30 00:17:51 +08:00
Supra4E8C
a48e06a28c fix(auth-files): use account id for codex quota and show remaining 2025-12-29 23:13:55 +08:00
Supra4E8C
8a59ab73a1 chore(i18n): update antigravity refresh label 2025-12-29 12:33:04 +08:00
Supra4E8C
66d58288b4 fix(auth): update antigravity fetchAvailableModels endpoint 2025-12-29 12:09:37 +08:00
Supra4E8C
be3f58f0a8 fix(auth-files): cache Antigravity quota to avoid auto refresh on reopen 2025-12-29 01:18:18 +08:00
Supra4E8C
c299e403cc feat(auth-files): add Antigravity quota page size 2025-12-29 00:48:31 +08:00
Supra4E8C
769c05e459 fix: defult language 2025-12-29 00:17:44 +08:00
Supra4E8C
5ef3406068 fix(config-page): restore page and editor scrolling with fixed card height 2025-12-28 23:50:08 +08:00
Supra4E8C
95cbfb8c59 feat(auth-files): add antigravity quota cards with grouping, pagination, and i18n 2025-12-28 23:39:26 +08:00
Supra4E8C
c17217875c fix(ai-providers): route openai compat model fetch/test through api-call to avoid CORS 2025-12-28 18:10:21 +08:00
Supra4E8C
981f7ac9b2 refactor(i18n): support per-provider empty state and OAuth messages 2025-12-28 17:41:25 +08:00
Supra4E8C
762db81252 fix: lang fix 2025-12-28 11:53:58 +08:00
Supra4E8C
79f6d87d7b fix(api): improve version header parsing for non-plain headers 2025-12-28 10:55:34 +08:00
Supra4E8C
c5d4356d6c fix 2025-12-28 01:04:22 +08:00
Supra4E8C
c989dbf1b6 feat(auth-files): add oauth excluded provider tag 2025-12-28 00:48:36 +08:00
Supra4E8C
3cffa19319 fix(footer): prevent copying management center version text 2025-12-28 00:03:16 +08:00
Supra4E8C
2367f122a8 fix(logs): remove action hint text 2025-12-27 23:59:57 +08:00
Supra4E8C
69a8e1657e fix(layout): keep header fixed on mobile scroll 2025-12-27 23:53:23 +08:00
Supra4E8C
987ce0ec4b feat(modal): add floating close button and collapse animatio 2025-12-27 23:40:56 +08:00
Supra4E8C
03bf58671e fix(logs): improve responsive layout for logs page on mobile and small screens 2025-12-27 17:07:31 +08:00
Supra4E8C
cb6b810d6d perf(providers,auth-files): cache status bar data and add auto-refresh 2025-12-27 15:26:38 +08:00
Supra4E8C
408e6e5872 feat(auth-files): add visual status bar for auth file health monitoring 2025-12-27 15:12:38 +08:00
Supra4E8C
b3808add0f feat(providers): add visual status bar for API key health monitoring 2025-12-27 15:02:32 +08:00
Supra4E8C
0b2e6efe28 fix(logs): keep log panel scroll within viewport 2025-12-27 14:42:10 +08:00
Supra4E8C
8ca6d31a26 feat(oauth): add vertex json login via vertex/import 2025-12-27 08:02:46 +08:00
Supra4E8C
66c6073bbc feat(usage): add usage stats export/import actions 2025-12-27 00:49:41 +08:00
Supra4E8C
2dd3f233d3 feat(logs): add long-press request log download and hints 2025-12-27 00:30:18 +08:00
Supra4E8C
7a65e03ad3 fix(layout): prevent footer gap on non-scroll pages 2025-12-26 23:53:13 +08:00
Supra4E8C
589a5bad4c fix(oauth): use resolvedTheme for provider icons 2025-12-26 18:54:16 +08:00
Supra4E8C
bcaa0c8545 Merge branch 'main' of https://github.com/router-for-me/Cli-Proxy-API-Management-Center 2025-12-26 18:42:47 +08:00
Supra4E8C
312a06a8b8 fix(logs): clarify error request logs list behavior 2025-12-26 18:42:41 +08:00
Supra4E8C
24861dabd2 Merge pull request #29 from XYenon/feat/auto-theme-mode
feat: add auto theme mode (follow system preference)
2025-12-26 18:04:40 +08:00
Supra4E8C
ea1bdc3ac1 fix(logs): make error log panel scrollable 2025-12-26 17:52:23 +08:00
Supra4E8C
46701b40ad fix(layout): restore scroll and set panel heights 2025-12-26 17:37:49 +08:00
XYenon
c9fc22bae5 fix: use resolvedTheme instead of theme for dark mode detection
When theme is set to 'auto', checking theme === 'dark' returns false even
when the system preference is dark mode. This caused charts and custom
styles to use light mode colors in a dark UI.

Fixed by using resolvedTheme which correctly resolves to 'light' or 'dark'
based on system preference when in auto mode.

Fixes the issue reported in PR #29 review.
2025-12-26 00:19:04 +08:00
Supra4E8C
ff9bd8a33b fix(ai-providers): allow empty Claude base URL 2025-12-25 23:55:31 +08:00
Supra4E8C
d0c376fc31 fix(layout): restore full-height panels on mobile/HiDPI 2025-12-25 23:33:52 +08:00
Supra4E8C
d09db34c34 Merge pull request #28 from notdp/main
feat(oauth): add provider icons to oauth login cards
2025-12-25 20:57:51 +08:00
dp
9dd37245bd feat(icons): convert png to svg and add icons to ai providers page 2025-12-25 20:48:12 +08:00
Supra4E8C
834ba43231 fix: ConfigPage.module.scss LogPage.moudle.scss 2025-12-25 01:09:00 +08:00
Supra4E8C
684502c8b6 fix: layout.scss 2025-12-24 23:39:40 +08:00
hkfires
0aee78c072 fix(logs): improve request id and status code parsing 2025-12-24 22:46:08 +08:00
hkfires
8780ea7ec5 fix(logs): wrap long log messages 2025-12-24 15:38:11 +08:00
hkfires
40fe33aeae fix(config): optimize layout for full height 2025-12-24 12:45:30 +08:00
hkfires
2a94be08fa fix(logs): optimize layout for full height 2025-12-24 12:38:57 +08:00
hkfires
0758cfe08a feat(logs): implement tabbed view for logs and error files 2025-12-24 11:45:14 +08:00
hkfires
02a01e5afc feat(logs): add request id parsing and refactor row layout 2025-12-24 10:46:33 +08:00
XYenon
961cc802b2 fix: address PR review feedback
- Use unique ID prefix for clipPath to avoid duplicate ID issues
- Add cleanup function to initializeTheme to prevent memory leak
- Change tooltip to show action description instead of current theme name
2025-12-24 00:18:44 +08:00
XYenon
5f7df33469 feat: add auto theme mode (follow system preference)
- Add 'auto' to Theme type
- Implement cycleTheme (light -> dark -> auto)
- Add autoTheme icon (sun with half-filled center)
- Listen to system theme changes in auto mode

Also includes some Prettier formatting fixes.
2025-12-24 00:02:59 +08:00
dp
39847fa56d feat(oauth): add provider icons to oauth login cards 2025-12-23 14:05:35 +08:00
Supra4E8C
561e06503c feat: update README.md README_CN.md 2025-12-22 23:20:31 +08:00
Supra4E8C
94962158ef feat(settings): move request logging toggle behind hidden entry 2025-12-22 22:41:50 +08:00
Supra4E8C
68974ffc68 feat(ai-providers): add prefix editing for provider configs 2025-12-21 23:46:39 +08:00
Supra4E8C
f8ed787f92 fix(splash): prevent login flicker on startup 2025-12-21 20:22:22 +08:00
Supra4E8C
dea106cf47 fix(splash): preserve logo aspect ratio 2025-12-21 16:58:14 +08:00
Supra4E8C
76ef1b68af fix(dashboard): improve stats loading and i18n date formatting 2025-12-21 16:54:17 +08:00
Supra4E8C
39a003bdd4 refactor(dashboard): simplify stats and add available models card 2025-12-21 16:27:28 +08:00
Supra4E8C
b1426ccefc feat(dashboard): enhance dashboard with provider breakdown and usage stats 2025-12-21 16:06:33 +08:00
Supra4E8C
a9df58cba7 feat(dashboard): add dashboard page with stats and splash screen 2025-12-21 16:05:09 +08:00
Supra4E8C
f6563490a6 fix(webui): normalize gemini endpoint and oauth callback status 2025-12-21 10:40:04 +08:00
Supra4E8C
18c1ba6c3c feat(ampcode): remove localhost-only management toggle 2025-12-20 18:32:32 +08:00
Supra4E8C
c2627cac3e fix: release auto write 2025-12-20 18:17:40 +08:00
Supra4E8C
df472119e7 feat: add commit-based release notes, usage loading spinner, and 60s logs timeout 2025-12-20 12:34:45 +08:00
Supra4E8C
10f2262753 fix(ai-providers): gate Claude models input and refine excluded tag styles 2025-12-19 23:54:26 +08:00
Supra4E8C
39d86d133a feat(oauth): add callback URL submission and require Gemini CLI project ID 2025-12-19 18:04:14 +08:00
Supra4E8C
ddbd7d00bd fix 2025-12-18 17:49:59 +08:00
Supra4E8C
e44beb541f feat 2025-12-18 12:36:17 +08:00
Supra4E8C
aecd5875d6 feat(usage): add loading overlay and 60s API timeout 2025-12-18 01:03:12 +08:00
Supra4E8C
ec4b5ab46a feat: add quick links section to System page 2025-12-17 18:16:59 +08:00
Supra4E8C
cd6c142324 fix: fix log page timestamp display and optimize AuthFiles layout
- Add formatUnixTimestamp utility to auto-detect timestamp precision (s/ms/μs/ns)
  - Fix incorrect file modification time display in logs page
  - Remove fixed height constraint from AuthFilesPage model list
2025-12-17 18:03:25 +08:00
Supra4E8C
0ebf62b564 fix: usage layout 2025-12-17 12:14:35 +08:00
Supra4E8C
16f3442a11 fix: refactor usage 2025-12-17 00:35:02 +08:00
Supra4E8C
3328e686ee fix: ip address in the log is displayed incorrectly 2025-12-16 23:32:49 +08:00
Supra4E8C
f60bdb0a8e fix: change the dark mode color 2025-12-16 23:10:17 +08:00
Supra4E8C
5eed3e787b Delete .claude/settings.local.json 2025-12-15 23:39:40 +08:00
Supra4E8C
5ebc845a1f Delete CLEAR_STORAGE.html 2025-12-15 23:39:14 +08:00
Supra4E8C
03c1cd1dc8 fix: Preparations before release 2025-12-15 23:22:08 +08:00
Supra4E8C
db6d5ca4b5 feat: A timeout failure was provided for the model test of OpenAI compatible providers 2025-12-15 23:02:33 +08:00
Supra4E8C
8d606aa456 fix(ui): optimize mobile layout for header and settings page 2025-12-15 18:53:03 +08:00
Supra4E8C
a993299cb5 fix:Continue from the previous one 2025-12-15 17:48:23 +08:00
Supra4E8C
8bcd172c5a fix(auth): add singleton pattern to restoreSession and unify config fetch 2025-12-15 17:47:32 +08:00
Supra4E8C
4d898b3e20 feat(logs): redesign LogsPage with structured log parsing and virtual scrolling
- Add log line parser to extract timestamp, level, status code, latency, IP, HTTP method, and path
  - Implement virtual scrolling with load-more on scroll-up to handle large log files efficiently
  - Replace monolithic pre block with structured grid layout for better readability
  - Add visual badges for log levels and HTTP status codes with color-coded severity
  - Add IconRefreshCw icon component
  - Update ToggleSwitch to accept ReactNode as label
  - Fix fetchConfig calls to use default parameters consistently
  - Add request deduplication in useConfigStore to prevent duplicate /config API calls
  - Add i18n keys for load_more_hint and hidden_lines
2025-12-15 17:37:09 +08:00
Supra4E8C
f17329b0ff feat(layout): add logout button to header
Add a logout icon and button to the main layout header,
  allowing users to log out directly from the navigation bar.
2025-12-15 01:37:21 +08:00
Supra4E8C
2757d82007 feat: improve iFlow cookie auth UX with duplicate config handling
- Add 409 conflict handling for duplicate iFlow config files
  - Add key creation hint in cookie login section
  - Move extra actions button after delete button for consistency
  - Improve OAuth status badge display logic (hide when idle)
  - Add config toggle enable/disable i18n translations
  - Adjust item-actions spacing from sm to md
2025-12-15 01:19:57 +08:00
Supra4E8C
340c1f1ae5 fix(i18n): correct interpolation syntax and add missing translation keys
- Fix i18next interpolation from {var} to {{var}} format in en.json
  - Add gemini_base_url_label translation key for better form labeling
  - Add virtual auth file and model list related translations
  - Adjust UsagePage title font size to 28px for consistency
2025-12-14 23:44:25 +08:00
Supra4E8C
09c17c03b9 feat: unify page layout with consistent page titles and structure
- Add page title (h1) to all main pages for consistent hierarchy
  - Wrap page content in container/content div structure
  - Handle 404 error for unsupported OAuth excluded models API
  - Add cache price input field in usage page model pricing
  - Add upgrade required i18n messages for older CPA versions
  - Import mixins in page-level SCSS modules
2025-12-14 20:05:59 +08:00
Supra4E8C
9d648e3404 feat: add cache token pricing support in usage calculation
- Add cache price field to ModelPrice interface
  - Support both cached_tokens and cache_tokens fields for compatibility
  - Separate prompt, cache, and completion pricing in cost calculation
  - Deduct cached tokens from input tokens before prompt pricing
  - Refactor getApiStats/getModelStats to reuse calculateCost function
  - Update i18n labels for model pricing
2025-12-14 18:50:37 +08:00
Supra4E8C
e615979757 Merge pull request #22 from AoaoMH/feature/auth-model-check
feat: add model list viewer for auth file cards
2025-12-14 18:06:25 +08:00
Supra4E8C
ea2ce4047f feat: improve iFlow cookie auth UX with duplicate config handling
- Add 409 conflict handling for duplicate iFlow config files
  - Add key creation hint in cookie login section
  - Move extra actions button after delete button for consistency
  - Improve OAuth status badge display logic (hide when idle)
  - Add config toggle enable/disable i18n translations
  - Adjust item-actions spacing from sm to md
2025-12-14 18:04:26 +08:00
Test
2a87a4d82a feat: Add 404 judgment; Virtual authentication files also support obtaining model lists; Chinese support for virtual authentication file i18n; 2025-12-14 17:19:51 +08:00
Test
abf9b5f8c9 feat: add model list viewer for auth file cards 2025-12-14 15:17:00 +08:00
Supra4E8C
aea1ceb6be feat: Added disabling features for some of the AI providers 2025-12-14 12:21:54 +08:00
Supra4E8C
20a69a25bc feat:log update 2025-12-14 01:51:23 +08:00
Supra4E8C
e0584af365 feat: add Ampcode (Amp CLI Integration) support with configuration UI and i18n
- Add ampcodeApi service for upstream URL, API key, and model mappings management
  - Implement Ampcode configuration modal in AiProvidersPage
  - Add complete i18n translations for Ampcode features (en and zh-CN)
  - Enhance UsagePage with mobile-responsive chart improvements and legend display
  - Optimize chart rendering for smaller screens
  - Improve page layout styles (SystemPage, AiProvidersPage alignment)
2025-12-14 00:31:05 +08:00
Supra4E8C
c4034c6467 Update README.md 2025-12-13 17:27:00 +08:00
Supra4E8C
ccc82e5802 fix: refine AI providers and auth files styling and layout alignment
- Remove inset box-shadow from stat badges for cleaner appearance
  - Add modelCountLabel style for consistent model count display
  - Refactor model count layout in AiProvidersPage
  - Add openaiTestButton style for proper button height alignment
  - Add item-actions flexbox utility style to layout.scss
2025-12-13 17:15:37 +08:00
Supra4E8C
13d1804e66 feat: improve Settings page retry config UI and enhance excludedModels API support
- Reorganize retry settings into separate Card for better visual hierarchy
  - Move retry update button inline with input field via rightElement
  - Add excluded-models serialization in provider key configuration
  - Add excluded-models normalization support in API transformers with fallback parsing
2025-12-13 16:30:20 +08:00
Supra4E8C
62486534e4 feat: add excluded models support for Codex/Claude providers and fix header alignment
- Add excludedModels field to ProviderKeyConfig type for Codex and Claude providers
  - Add excluded models textarea input in Codex/Claude edit modal
  - Display excluded models badges in Codex and Claude provider cards
  - Fix header connection status badge vertical alignment with IP address
  - Update dark theme to use pure black color scheme
2025-12-13 02:20:08 +08:00
Supra4E8C
da9469c5aa feat: add models cache store and fix search placeholder truncation
- Add useModelsStore with 30s cache for model list to reduce API calls
  - Refactor SystemPage to use cached models from store
  - Shorten ConfigPage search placeholder to prevent text truncation
2025-12-13 01:35:08 +08:00
Supra4E8C
a7b77ffa25 feat:update icon 2025-12-13 00:51:01 +08:00
Supra4E8C
bcf82252ea feat: add notification animations and improve UI across pages Add enter/exit animations to NotificationContainer with smooth slide effects Refactor ConfigPage search bar to float over editor with improved UX Enhance AuthFilesPage type badges with proper light/dark theme color support Fix grid layout in AuthFilesPage to use consistent 3-column layout Update icon button sizing and loading state handlin Update i18n translations for search functionality 2025-12-13 00:46:07 +08:00
Supra4E8C
7c0a2280a4 feat: implement versioning system by extracting version from environment, git tags, or package.json, and display app version in MainLayout; enhance ConfigPage with search functionality and CodeMirror integration for YAML editing 2025-12-12 19:10:09 +08:00
Supra4E8C
bae7ff8752 feat: enhance AiProvidersPage with OpenAI model discovery functionality, improve localization for model selection messages, and update styles for better user experience 2025-12-12 18:53:51 +08:00
Supra4E8C
2a57055f81 feat: introduce ModelInputList component for managing model entries in AiProvidersPage, enhance MainLayout with header action icons, and improve styling for success and failure statistics across pages 2025-12-12 17:58:23 +08:00
Supra4E8C
ad92f0c2ed feat: add success and failure statistics display to AiProvidersPage, refactor data retrieval methods for better clarity and consistency 2025-12-11 12:34:05 +08:00
Supra4E8C
d425332eb0 feat: enhance AiProvidersPage with key statistics loading, improve layout styles, and update AuthFilesPage for better visual representation of success and failure stats 2025-12-11 12:24:29 +08:00
Supra4E8C
3c1a600994 feat: improve AuthFilesPage styles by enhancing input component dimensions, adjusting filterItem widths for better layout, and ensuring consistent box-sizing across elements 2025-12-11 01:27:40 +08:00
Supra4E8C
673ab15ad4 feat: update document title for clarity, add logo image, enhance OAuthPage with iFlow cookie authentication feature, and improve localization for remote access messages 2025-12-11 01:18:32 +08:00
Supra4E8C
95218676db feat: update document title and favicon in main.tsx, remove isLocalhost check from OAuthPage for cleaner logic, and enhance overall user experience 2025-12-11 00:17:52 +08:00
Supra4E8C
defa633f92 refactor: remove isLocalhost check from MainLayout, update UsagePage styles for improved layout and spacing, and adjust layout.scss for better sidebar toggle appearance 2025-12-10 23:49:01 +08:00
Supra4E8C
841dfa8a61 feat: add INLINE_LOGO_JPEG to MainLayout for branding, enhance layout styles in UsagePage for improved spacing and responsiveness, and update layout.scss for better logo handling and alignment 2025-12-10 22:56:47 +08:00
Supra4E8C
bf5f34be0d feat: enhance AuthFilesPage with improved layout and styling, add filter and pagination features, and implement detailed file statistics and actions for better user interaction 2025-12-10 12:31:40 +08:00
Supra4E8C
e8d918ba98 refactor: simplify AiProvidersPage by removing unused delete confirmation text and enhance brand header styles for better layout and responsiveness 2025-12-10 02:02:18 +08:00
Supra4E8C
c71af9a8a5 feat: enhance MainLayout with header height management using useLayoutEffect, improve AiProvidersPage by removing priority field, and update UsagePage with dynamic stats cards and sparkline charts for better data visualization 2025-12-10 01:42:21 +08:00
Supra4E8C
d8f540cdb1 fix: adjust sidebar positioning in layout.scss for improved responsiveness and visual consistency 2025-12-09 19:07:52 +08:00
Supra4E8C
18b1adb4e2 feat: enhance MainLayout with brand name expansion feature, sidebar toggle improvements, and responsive design adjustments for better user experience 2025-12-09 19:04:40 +08:00
Supra4E8C
5d5334afb1 refactor: remove README and REFACTOR_PROGRESS files; enhance MainLayout with sidebar icons and improved navigation item display 2025-12-09 01:46:58 +08:00
Supra4E8C
2ca662e971 refactor: update button styles and layout in MainLayout for improved UI consistency and accessibility 2025-12-09 01:05:56 +08:00
Supra4E8C
e417d3c771 feat: refactor MainLayout component to implement sidebar collapse functionality, enhance navigation item display, and improve layout responsiveness 2025-12-09 00:59:40 +08:00
Supra4E8C
b6765b074e feat: enhance logging functionality with incremental loading, improved error handling, and UI updates for better user experience 2025-12-09 00:35:17 +08:00
Supra4E8C
9d7db57c6a feat: update SCSS imports to use new Sass module system, enhance SystemPage with model tags display and API key handling, and improve model fetching logic with better error handling and notifications 2025-12-08 20:20:47 +08:00
Supra4E8C
450964fb1a feat: initialize new React application structure with TypeScript, ESLint, and Prettier configurations, while removing legacy files and adding new components and pages for enhanced functionality 2025-12-07 11:32:31 +08:00
Supra4E8C
8e4132200d feat: refactor model price display and editing functionality with improved layout and interaction 2025-12-06 16:46:37 +08:00
Supra4E8C
fc10db3b0a feat: update layout for usage filter actions and chart line group to improve responsiveness and visual hierarchy 2025-12-06 16:36:23 +08:00
Supra4E8C
2bcaf15fe8 feat: enhance usage statistics overview with responsive design, improved layout, and sparkline charts for better data visualization 2025-12-06 16:32:47 +08:00
Supra4E8C
28750ab068 feat: implement responsive brand title behavior for mobile viewports with animation handling and CSS adjustments 2025-12-06 14:57:19 +08:00
Supra4E8C
69f808e180 feat: enhance file upload functionality to support multiple JSON file uploads with improved validation and notification handling 2025-12-06 13:16:09 +08:00
Supra4E8C
86edc1ee95 feat: implement OpenAI provider connection testing with UI integration, status updates, and internationalization support 2025-12-06 01:25:04 +08:00
Supra4E8C
112f86966d feat: add version check functionality with UI integration, status updates, and internationalization support 2025-12-06 00:15:44 +08:00
Supra4E8C
658814bf6a refactor: streamline model name handling in updateApiStatsTable function for improved readability 2025-12-05 19:02:49 +08:00
255 changed files with 37146 additions and 20336 deletions

20
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
};

View File

@@ -15,6 +15,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -36,27 +39,25 @@ jobs:
mv index.html management.html
ls -lh management.html
- name: Generate release notes
run: |
set -euo pipefail
current_tag="${GITHUB_REF_NAME}"
previous_tag="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${current_tag}$" | head -n 1 || true)"
if [ -n "${previous_tag}" ]; then
range="${previous_tag}..${current_tag}"
else
range="${current_tag}"
fi
: > release-notes.md
git log --pretty=format:"- %h %s" "${range}" >> release-notes.md
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: dist/management.html
body: |
## CLI Proxy API Management Center - ${{ github.ref_name }}
### Download and Usage
1. Download the `management.html` file
2. Open it directly in your browser
3. All assets (CSS, JavaScript, images) are bundled into this single file
### Features
- Single file, no external dependencies required
- Complete management interface for CLI Proxy API
- Support for local and remote connections
- Multi-language support (Chinese/English)
- Dark/Light theme support
---
🤖 Generated with GitHub Actions
body_path: release-notes.md
draft: false
prerelease: false
env:

56
.gitignore vendored
View File

@@ -1,28 +1,32 @@
# Node modules
node_modules/
# Build output
dist/
# Temporary build files
index.build.html
# npm lock files
package-lock.json
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
api.md
usage.json
CLAUDE.md
.claude
AGENTS.md
.codex
.serena
antigravity_usage.json
codex_usage.json
style.md
node_modules
dist
dist-ssr
*.local
# Editor directories and files
settings.local.json
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always"
}

View File

@@ -1,69 +0,0 @@
# Build and Release Instructions
## Overview
This project uses webpack to bundle all HTML, CSS, JavaScript, and images into a single all-in-one HTML file. The GitHub workflow automatically builds and releases this file when you create a new tag.
## How to Create a Release
1. Make sure all your changes are committed
2. Create and push a new tag:
```bash
git tag v1.0.0
git push origin v1.0.0
```
3. The GitHub workflow will automatically:
- Install dependencies
- Build the all-in-one HTML file using webpack
- Create a new release with the tag
- Upload the bundled HTML file to the release
## Manual Build
To build locally:
```bash
# Install dependencies
npm install
# Build the all-in-one HTML file
npm run build
```
The output will be in the `dist/` directory as `index.html`.
## How It Works
1. **build-scripts/prepare-html.js**: Pre-build script
- Reads the original `index.html`
- Removes local CSS and JavaScript references
- Generates temporary `index.build.html` for webpack
2. **webpack.config.js**: Configures webpack to bundle all assets
- Uses `style-loader` to inline CSS
- Uses `asset/inline` to embed images as base64
- Uses `html-inline-script-webpack-plugin` to inline JavaScript
- Uses `index.build.html` as template (generated dynamically)
3. **bundle-entry.js**: Entry point that imports all resources
- Imports CSS files
- Imports JavaScript modules
- Imports and sets logo image
4. **package.json scripts**:
- `prebuild`: Automatically runs before build to generate `index.build.html`
- `build`: Runs webpack to bundle everything
- `postbuild`: Cleans up temporary `index.build.html` file
5. **.github/workflows/release.yml**: GitHub workflow
- Triggers on tag push
- Builds the project (prebuild → build → postbuild)
- Creates a release with the bundled HTML file
## External Dependencies
The bundled HTML file still relies on these CDN resources:
- Font Awesome (icons)
- Chart.js (charts and graphs)
These are loaded from CDN to keep the file size reasonable and leverage browser caching.

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Supra4E8C
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

181
README.md
View File

@@ -1,107 +1,130 @@
# Cli-Proxy-API-Management-Center
This is the modern WebUI for managing the CLI Proxy API.
# CLI Proxy API Management Center
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
[中文文档](README_CN.md)
Main Project: https://github.com/router-for-me/CLIProxyAPI
Example URL: https://remote.router-for.me/
Minimum required version: ≥ 6.3.0 (recommended ≥ 6.5.0)
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
**Example URL**: https://remote.router-for.me/
**Minimum Required Version**: ≥ 6.3.0 (recommended ≥ 6.5.0)
Since 6.0.19 the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
## Features
## What this is (and isnt)
### Capabilities
- **Login & UX**: Auto-detects the current address (manual override/reset supported), encrypted auto-login, language/theme toggles, responsive layout with mobile sidebar.
- **Basic Settings**: Debug, proxy URL, request retries, quota fallback (auto-switch project/preview models), usage-statistics toggle, request logging & logging-to-file switches, WebSocket `/ws/*` auth switch.
- **Keys & Providers**: Manage proxy auth keys, Gemini/Codex/Claude configs, OpenAI-compatible providers (custom base URLs/headers/proxy/model aliases), Vertex AI credential import from service-account JSON with optional location.
- **Auth Files & OAuth**: Upload/download/search/paginate JSON credentials; type filters (Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty); delete-all; OAuth/Device flows for Codex, Anthropic (Claude), Antigravity (Google), Gemini CLI (optional project), Qwen; iFlow OAuth and cookie login.
- **Logs**: Live viewer with auto-refresh/incremental updates, download and clear; section appears when logging-to-file is enabled.
- **Usage Analytics**: Overview cards, hourly/daily toggles, up to three model lines per chart, per-API stats table (Chart.js).
- **Config Management**: In-browser CodeMirror YAML editor for `/config.yaml` with reload/save, syntax highlighting, and status feedback.
- **System Info & Versioning**: Connection/config cache status, last refresh time, server version/build date, and UI version in the footer.
- **Security & Preferences**: Masked secrets, secure local storage, persistent theme/language/sidebar state, real-time status feedback.
- This repository is the WebUI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
- It is **not** a proxy and does not forward traffic.
## How to Use
## Quick start
1) **After CLI Proxy API is running (recommended)**
Visit `http://your-server:8317/management.html`.
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
2) **Direct static use after build**
The single file `dist/index.html` generated by `npm run build`
1. Start your CLI Proxy API service.
2. Open: `http://<host>:<api_port>/management.html`
3. Enter your **management key** and connect.
The address is auto-detected from the current page URL; manual override is supported.
### Option B: Run the dev server
3) **Local server**
```bash
npm install
npm start # http://localhost:3000
npm run dev # optional dev port: 3090
# or
python -m http.server 8000
npm run dev
```
Then open the corresponding localhost URL.
4) **Configure connection**
The login page shows the detected address; you can override it, enter the management key, and click Connect. Saved credentials use encrypted local storage for auto-login.
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
Tip: The Logs navigation item appears after enabling "Logging to file" in Basic Settings.
### Option C: Build a single HTML file
## Tech Stack
```bash
npm install
npm run build
```
- **Frontend**: Plain HTML, CSS, JavaScript (ES6+)
- **Styling**: CSS3 + Flexbox/Grid with CSS Variables
- **Icons**: Font Awesome 6.4.0
- **Charts**: Chart.js for interactive data visualization
- **Editor/Parsing**: CodeMirror + js-yaml
- **Fonts**: Segoe UI system font
- **Internationalization**: Custom i18n (EN/CN) and theme system (light/dark)
- **API**: RESTful management endpoints with automatic authentication
- **Storage**: LocalStorage with lightweight encryption for preferences/credentials
- Output: `dist/index.html` (all assets are inlined).
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
- To preview locally: `npm run preview`
## Build & Development
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
- `npm run build` bundles everything into `dist/index.html` via webpack (`build.cjs`, `bundle-entry.js`, `build-scripts/prepare-html.js`).
- External CDNs remain for Font Awesome, Chart.js, and CodeMirror to keep the bundle lean.
- Development servers: `npm start` (3000) or `npm run dev` (3090); Python `http.server` also works for static hosting.
## Connecting to the server
### API address
You can enter any of the following; the UI will normalize it:
- `localhost:8317`
- `http://192.168.1.10:8317`
- `https://example.com:8317`
- `http://example.com:8317/v0/management` (also accepted; the suffix is removed internally)
### Management key (not the same as API keys)
The management key is sent with every request as:
- `Authorization: Bearer <MANAGEMENT_KEY>` (default)
This is different from the proxy `api-keys` you manage inside the UI (those are for client requests to the proxy endpoints).
### Remote management
If you connect from a non-localhost browser, the server must allow remote management (e.g. `allow-remote-management: true`).
See `api.md` for the full authentication rules, server-side limits, and edge cases.
## What you can manage (mapped to the UI pages)
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth.
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
- **AI Providers**:
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side “chat/completions” test).
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards).
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
## Build & release notes
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
- Tagging `vX.Y.Z` triggers `.github/workflows/release.yml` to publish `dist/management.html`.
- The UI version shown in the footer is injected at build time (env `VERSION`, git tag, or `package.json` fallback).
## Security notes
- The management key is stored in browser `localStorage` using a lightweight obfuscation format (`enc::v1::...`) to avoid plaintext storage; treat it as sensitive.
- Use a dedicated browser profile/device for management. Be cautious when enabling remote management and evaluate its exposure surface.
## Troubleshooting
### Connection Issues
1. Confirm that the CLI Proxy API service is running.
2. Check if the API address is correct.
3. Verify that the management key is valid.
4. Ensure your firewall settings allow the connection.
- **Cant connect / 401**: confirm the API address and management key; remote access may require enabling remote management in the server config.
- **Repeated auth failures**: the server may temporarily block remote IPs.
- **Logs page missing**: enable “Logging to file” in Basic Settings; the navigation item is shown only when file logging is enabled.
- **Some features show “unsupported”**: the backend may be too old or the endpoint is disabled/absent (common for model lists per auth file, excluded models, logs).
- **OpenAI provider test fails**: the test runs in the browser and depends on network/CORS of the provider endpoint; a failure here does not always mean the server cannot reach it.
### Data Not Updating
1. Click the "Refresh All" button.
2. Check your network connection.
3. Check the browser's console for any error messages.
## Development
### Logs & Config Editor
- Logs: Requires server-side logging-to-file; 404 indicates the server build is too old or logging is disabled.
- Config editor: Requires `/config.yaml` endpoint; keep YAML valid before saving.
### Usage Stats
- Enable "Usage statistics" if charts stay empty; data resets on server restart.
## Project Structure
```
├── index.html
├── styles.css
├── app.js
├── i18n.js
├── src/ # Core/modules/utils source code
├── build.cjs # Webpack build script
├── bundle-entry.js # Bundling entry
├── build-scripts/ # Build utilities
│ └── prepare-html.js
├── dist/ # Bundled single-file output
├── BUILD_RELEASE.md
├── LICENSE
├── README.md
└── README_CN.md
```bash
npm run dev # Vite dev server
npm run build # tsc + Vite build
npm run preview # serve dist locally
npm run lint # ESLint (fails on warnings)
npm run format # Prettier
npm run type-check # tsc --noEmit
```
## Contributing
We welcome Issues and Pull Requests to improve this project! We encourage more developers to contribute to the enhancement of this WebUI!
This project is licensed under the MIT License.
Issues and PRs are welcome. Please include:
- Reproduction steps (server version + UI version)
- Screenshots for UI changes
- Verification notes (`npm run lint`, `npm run type-check`)
## License
MIT

View File

@@ -1,106 +1,130 @@
# Cli-Proxy-API-Management-Center
这是一个用于管理 CLI Proxy API 的现代化 Web 界面。
# CLI Proxy API 管理中心
用于管理与排障 **CLI Proxy API** 的单文件 WebUIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
[English](README.md)
主项目: https://github.com/router-for-me/CLIProxyAPI
示例网站: https://remote.router-for.me/
最低版本 ≥ 6.3.0(推荐 ≥ 6.5.0
**主项目**: https://github.com/router-for-me/CLIProxyAPI
**示例地址**: https://remote.router-for.me/
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0
6.0.19 WebUI 已集成到主程序中,启动后可通过 `/management.html` 访问。
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
## 功能特点
## 这是什么(以及不是什么)
### 主要能力
- **登录与体验**: 自动检测当前地址(可自定义/重置),加密自动登录,语言/主题切换,响应式布局与移动端侧边栏
- **基础设置**: 调试、代理 URL、请求重试配额溢出自动切换项目/预览模型使用统计开关请求日志与文件日志开关WebSocket `/ws/*` 鉴权开关。
- **密钥与提供商**: 管理代理服务密钥Gemini/Codex/Claude 配置OpenAI 兼容提供商(自定义 Base URL/Headers/Proxy/模型别名Vertex AI 服务账号导入(可选区域)。
- **认证文件与 OAuth**: 上传/下载/搜索/分页 JSON 凭据类型筛选Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty一键删除全部Codex、Anthropic(Claude)、Antigravity(Google)、Gemini CLI可选项目、Qwen 设备码、iFlow OAuth 与 Cookie 登录。
- **日志**: 实时查看并增量刷新,支持下载和清空;启用“写入日志文件”后出现日志栏目。
- **使用统计**: 概览卡片、小时/天切换、最多三条模型曲线、按 API 统计表Chart.js
- **配置管理**: 内置 CodeMirror YAML 编辑器,在线读取/保存 `/config.yaml`,语法高亮与状态提示。
- **系统与版本**: 连接/配置缓存状态、最后刷新时间,底栏显示服务版本、构建时间与 UI 版本。
- **安全与偏好**: 密钥遮蔽、加密本地存储,主题/语言/侧边栏状态持久化,实时状态反馈。
- 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**`/v0/management`)读取/修改配置、上传凭据、查看日志与使用统计。
- **不是** 代理本体,不参与流量转发
## 使用方法
## 快速开始
1) **主程序启动后使用(推荐)**
访问 `http://您的服务器:8317/management.html`
### 方式 A使用 CLIProxyAPI 自带的 WebUI推荐
2) **构建后直接静态打开**
`npm run build` 生成的 `dist/index.html` 单文件
1. 启动 CLI Proxy API 服务。
2. 打开:`http://<host>:<api_port>/management.html`
3. 输入 **管理密钥** 并连接。
页面会根据当前地址自动推断 API 地址,也支持手动修改。
### 方式 B开发调试
3) **本地服务器**
```bash
npm install
npm start # 默认 http://localhost:3000
npm run dev # 可选开发端口 3090
# 或
python -m http.server 8000
npm run dev
```
然后在浏览器打开对应的 localhost 地址。
4) **配置连接**
登录页会显示自动检测的地址,可自行修改,填入管理密钥后点击连接。凭据将加密保存以便下次自动登录。
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
提示: 开启“写入日志文件”后才会显示“日志查看”导航。
### 方式 C构建单文件 HTML
## 技术栈
- **前端**: 纯 HTML、CSS、JavaScript (ES6+)
- **样式**: CSS3 + Flexbox/Grid支持 CSS 变量
- **图标**: Font Awesome 6.4.0
- **图表**: Chart.js 交互式数据可视化
- **编辑/解析**: CodeMirror + js-yaml
- **国际化**: 自定义 i18n中/英)与主题系统(明/暗)
- **API**: RESTful 管理接口,自动附加认证
- **存储**: LocalStorage 轻量加密存储偏好与凭据
## 构建与开发
- `npm run build` 通过 webpack`build.cjs``bundle-entry.js``build-scripts/prepare-html.js`)打包为 `dist/index.html`
- Font Awesome、Chart.js、CodeMirror 仍走 CDN减小打包体积。
- 开发可用 `npm start` (3000) / `npm run dev` (3090) 或 `python -m http.server` 静态托管。
## 故障排除
### 连接问题
1. 确认 CLI Proxy API 服务正在运行
2. 检查 API 地址是否正确
3. 验证管理密钥是否有效
4. 确认防火墙设置允许连接
### 数据不更新
1. 点击"刷新全部"按钮
2. 检查网络连接
3. 查看浏览器控制台错误信息
### 日志与配置编辑
- 日志: 需要服务端开启写文件日志;返回 404 说明版本过旧或未启用。
- 配置编辑: 依赖 `/config.yaml` 接口,保存前请确保 YAML 语法正确。
### 使用统计
- 若图表为空,请开启“使用统计”;数据在服务重启后会清空。
## 项目结构
```bash
npm install
npm run build
```
├── index.html
├── styles.css
├── app.js
├── i18n.js
├── src/ # 核心/模块/工具源码
├── build.cjs # Webpack 构建脚本
├── bundle-entry.js # 打包入口
├── build-scripts/ # 构建工具
│ └── prepare-html.js
├── dist/ # 打包输出单文件
├── BUILD_RELEASE.md
├── LICENSE
├── README.md
└── README_CN.md
- 构建产物:`dist/index.html`(资源已全部内联)。
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`
- 本地预览:`npm run preview`
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
## 连接说明
### API 地址怎么填
以下格式均可WebUI 会自动归一化:
- `localhost:8317`
- `http://192.168.1.10:8317`
- `https://example.com:8317`
- `http://example.com:8317/v0/management`(也可填写,后缀会被自动去除)
### 管理密钥(注意:不是 API Keys
管理密钥会以如下方式随请求发送:
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
这与 WebUI 中“API Keys”页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
### 远程管理
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
完整鉴权规则、限制与边界情况请查看 `api.md`
## 功能一览(按页面对应)
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退切项目/切预览模型、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **API Keys**:管理代理 `api-keys`(增/改/删)。
- **AI 提供商**
- Gemini/Codex/Claude 配置Base URL、Headers、代理、模型别名、排除模型、Prefix
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only查看单个凭据可用模型依赖后端支持管理 OAuth 排除模型(支持 `*` 通配符)。
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
- **配置文件**:浏览器内编辑 `/config.yaml`YAML 高亮 + 搜索),保存/重载。
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
## 构建与发布说明
- 使用 Vite 输出 **单文件 HTML**`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
-`vX.Y.Z` 标签会触发 `.github/workflows/release.yml`,发布 `dist/management.html`
- 页脚显示的 UI 版本在构建期注入(优先使用环境变量 `VERSION`,否则使用 git tag / `package.json`)。
## 安全提示
- 管理密钥会存入浏览器 `localStorage`,并使用轻量混淆格式(`enc::v1::...`)避免明文;仍应视为敏感信息。
- 建议使用独立浏览器配置/设备进行管理;开启远程管理时请谨慎评估暴露面。
## 常见问题
- **无法连接 / 401**:确认 API 地址与管理密钥;远程访问可能需要服务端开启远程管理。
- **反复输错密钥**:服务端可能对远程 IP 进行临时封禁。
- **日志页面不显示**:需要在“基础设置”里开启“写入日志文件”,导航项才会出现。
- **功能提示不支持**:多为后端版本较旧或接口未启用/不存在(如:认证文件模型列表、排除模型、日志相关接口)。
- **OpenAI 提供商测试失败**:测试在浏览器侧执行,会受网络与 CORS 影响;这里失败不一定代表服务端不可用。
## 开发命令
```bash
npm run dev # 启动开发服务器
npm run build # tsc + Vite 构建
npm run preview # 本地预览 dist
npm run lint # ESLintwarnings 视为失败)
npm run format # Prettier
npm run type-check # tsc --noEmit
```
## 贡献
欢迎提交 Issue 和 Pull Request 来改进这个项目!我们欢迎更多的大佬来对这个 WebUI 进行更新!
本项目采用 MIT 许可。
欢迎提 Issue 与 PR。建议附上
- 复现步骤(服务端版本 + UI 版本)
- UI 改动截图
- 验证记录(`npm run lint``npm run type-check`
## 许可证
MIT

824
app.js
View File

@@ -1,824 +0,0 @@
// 模块导入
import { themeModule } from './src/modules/theme.js';
import { navigationModule } from './src/modules/navigation.js';
import { languageModule } from './src/modules/language.js';
import { loginModule } from './src/modules/login.js';
import { configEditorModule } from './src/modules/config-editor.js';
import { logsModule } from './src/modules/logs.js';
import { apiKeysModule } from './src/modules/api-keys.js';
import { authFilesModule } from './src/modules/auth-files.js';
import { oauthModule } from './src/modules/oauth.js';
import { usageModule } from './src/modules/usage.js';
import { settingsModule } from './src/modules/settings.js';
import { aiProvidersModule } from './src/modules/ai-providers.js';
// 工具函数导入
import { escapeHtml } from './src/utils/html.js';
import { maskApiKey, formatFileSize } from './src/utils/string.js';
import { normalizeArrayResponse } from './src/utils/array.js';
import { debounce } from './src/utils/dom.js';
import {
CACHE_EXPIRY_MS,
MAX_LOG_LINES,
LOG_FETCH_LIMIT,
DEFAULT_AUTH_FILES_PAGE_SIZE,
MIN_AUTH_FILES_PAGE_SIZE,
MAX_AUTH_FILES_PAGE_SIZE,
OAUTH_CARD_IDS,
STORAGE_KEY_AUTH_FILES_PAGE_SIZE,
NOTIFICATION_DURATION_MS
} from './src/utils/constants.js';
// 核心服务导入
import { createErrorHandler } from './src/core/error-handler.js';
import { connectionModule } from './src/core/connection.js';
import { ApiClient } from './src/core/api-client.js';
import { ConfigService } from './src/core/config-service.js';
import { createEventBus } from './src/core/event-bus.js';
// CLI Proxy API 管理界面 JavaScript
class CLIProxyManager {
constructor() {
// 事件总线
this.events = createEventBus();
// API 客户端(规范化基础地址、封装请求)
this.apiClient = new ApiClient({
onVersionUpdate: (headers) => this.updateVersionFromHeaders(headers)
});
const detectedBase = this.detectApiBaseFromLocation();
this.apiClient.setApiBase(detectedBase);
this.apiBase = this.apiClient.apiBase;
this.apiUrl = this.apiClient.apiUrl;
this.managementKey = '';
this.isConnected = false;
this.isLoggedIn = false;
this.uiVersion = null;
this.serverVersion = null;
this.serverBuildDate = null;
// 配置缓存 - 改为分段缓存(交由 ConfigService 管理)
this.cacheExpiry = CACHE_EXPIRY_MS;
this.configService = new ConfigService({
apiClient: this.apiClient,
cacheExpiry: this.cacheExpiry
});
this.configCache = this.configService.cache;
this.cacheTimestamps = this.configService.cacheTimestamps;
this.availableModels = [];
this.availableModelApiKeysCache = null;
this.availableModelsLoading = false;
// 状态更新定时器
this.statusUpdateTimer = null;
this.lastConnectionStatusEmitted = null;
this.isGlobalRefreshInProgress = false;
this.registerCoreEventHandlers();
// 日志自动刷新定时器
this.logsRefreshTimer = null;
// 当前展示的日志行
this.allLogLines = [];
this.displayedLogLines = [];
this.logSearchQuery = '';
this.maxDisplayLogLines = MAX_LOG_LINES;
this.logFetchLimit = LOG_FETCH_LIMIT;
// 日志时间戳(用于增量加载)
this.latestLogTimestamp = null;
// Auth file filter state cache
this.currentAuthFileFilter = 'all';
this.cachedAuthFiles = [];
this.authFilesPagination = {
pageSize: DEFAULT_AUTH_FILES_PAGE_SIZE,
currentPage: 1,
totalPages: 1
};
this.authFileStatsCache = {};
this.authFileSearchQuery = '';
this.authFilesPageSizeKey = STORAGE_KEY_AUTH_FILES_PAGE_SIZE;
this.loadAuthFilePreferences();
// OAuth 模型排除列表状态
this.oauthExcludedModels = {};
this._oauthExcludedLoading = false;
// Vertex AI credential import state
this.vertexImportState = {
file: null,
loading: false,
result: null
};
// 主题管理
this.currentTheme = 'light';
// 配置文件编辑器状态
this.configYamlCache = '';
this.isConfigEditorDirty = false;
this.configEditorElements = {
textarea: null,
editorInstance: null,
saveBtn: null,
reloadBtn: null,
statusEl: null
};
this.lastConfigFetchUrl = null;
this.lastEditorConnectionState = null;
// 初始化错误处理器
this.errorHandler = createErrorHandler((message, type) => this.showNotification(message, type));
this.init();
}
loadAuthFilePreferences() {
try {
if (typeof localStorage === 'undefined') {
return;
}
const savedPageSize = parseInt(localStorage.getItem(this.authFilesPageSizeKey), 10);
if (Number.isFinite(savedPageSize)) {
this.authFilesPagination.pageSize = this.normalizeAuthFilesPageSize(savedPageSize);
}
} catch (error) {
console.warn('Failed to restore auth file preferences:', error);
}
}
normalizeAuthFilesPageSize(value) {
const defaultSize = DEFAULT_AUTH_FILES_PAGE_SIZE;
const minSize = MIN_AUTH_FILES_PAGE_SIZE;
const maxSize = MAX_AUTH_FILES_PAGE_SIZE;
const parsed = parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return defaultSize;
}
return Math.min(maxSize, Math.max(minSize, parsed));
}
init() {
this.initUiVersion();
this.initializeTheme();
this.registerCoreEventHandlers();
this.registerSettingsListeners();
this.registerUsageListeners();
if (typeof this.registerLogsListeners === 'function') {
this.registerLogsListeners();
}
if (typeof this.registerConfigEditorListeners === 'function') {
this.registerConfigEditorListeners();
}
this.checkLoginStatus();
this.bindEvents();
this.setupNavigation();
this.setupLanguageSwitcher();
this.setupThemeSwitcher();
this.setupConfigEditor();
this.updateConfigEditorAvailability();
// loadSettings 将在登录成功后调用
this.updateLoginConnectionInfo();
// 检查主机名,如果不是 localhost 或 127.0.0.1,则隐藏 OAuth 登录框
this.checkHostAndHideOAuth();
if (typeof this.registerAuthFilesListeners === 'function') {
this.registerAuthFilesListeners();
}
}
registerCoreEventHandlers() {
if (!this.events || typeof this.events.on !== 'function') {
return;
}
this.events.on('config:refresh-requested', async (event) => {
const detail = event?.detail || {};
const forceRefresh = detail.forceRefresh !== false;
// 避免并发触发导致重复请求
if (this.isGlobalRefreshInProgress) {
return;
}
await this.runGlobalRefresh(forceRefresh);
});
}
async runGlobalRefresh(forceRefresh = false) {
this.isGlobalRefreshInProgress = true;
try {
await this.loadAllData(forceRefresh);
} finally {
this.isGlobalRefreshInProgress = false;
}
}
// 检查主机名并隐藏 OAuth 登录框
checkHostAndHideOAuth() {
const hostname = window.location.hostname;
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
if (!isLocalhost) {
// 隐藏所有 OAuth 登录卡片
OAUTH_CARD_IDS.forEach(cardId => {
const card = document.getElementById(cardId);
if (card) {
card.style.display = 'none';
}
});
// 如果找不到具体的卡片 ID尝试通过类名查找
const oauthCardElements = document.querySelectorAll('.card');
oauthCardElements.forEach(card => {
const cardText = card.textContent || '';
if (cardText.includes('Codex OAuth') ||
cardText.includes('Anthropic OAuth') ||
cardText.includes('Antigravity OAuth') ||
cardText.includes('Gemini CLI OAuth') ||
cardText.includes('Qwen OAuth') ||
cardText.includes('iFlow OAuth')) {
card.style.display = 'none';
}
});
console.log(`当前主机名: ${hostname},已隐藏 OAuth 登录框`);
}
}
// 检查登录状态
// 处理登录表单提交
// 事件绑定
bindEvents() {
// 登录相关(安全绑定)
const loginSubmit = document.getElementById('login-submit');
const logoutBtn = document.getElementById('logout-btn');
if (loginSubmit) {
loginSubmit.addEventListener('click', () => this.handleLogin());
}
if (logoutBtn) {
logoutBtn.addEventListener('click', () => this.logout());
}
// 密钥可见性切换事件
this.setupKeyVisibilityToggle();
// 主页面元素(延迟绑定,在显示主页面时绑定)
this.bindMainPageEvents();
}
// 设置密钥可见性切换
setupKeyVisibilityToggle() {
const toggleButtons = document.querySelectorAll('.toggle-key-visibility');
toggleButtons.forEach(button => {
button.addEventListener('click', () => this.toggleLoginKeyVisibility(button));
});
}
// 绑定主页面事件
bindMainPageEvents() {
// 连接状态检查
const connectionStatus = document.getElementById('connection-status');
const refreshAll = document.getElementById('refresh-all');
const availableModelsRefresh = document.getElementById('available-models-refresh');
if (connectionStatus) {
connectionStatus.addEventListener('click', () => this.checkConnectionStatus());
}
if (refreshAll) {
refreshAll.addEventListener('click', () => this.refreshAllData());
}
if (availableModelsRefresh) {
availableModelsRefresh.addEventListener('click', () => this.loadAvailableModels({ forceRefresh: true }));
}
// 基础设置
const debugToggle = document.getElementById('debug-toggle');
const updateProxy = document.getElementById('update-proxy');
const clearProxy = document.getElementById('clear-proxy');
const updateRetry = document.getElementById('update-retry');
const switchProjectToggle = document.getElementById('switch-project-toggle');
const switchPreviewToggle = document.getElementById('switch-preview-model-toggle');
const usageStatisticsToggle = document.getElementById('usage-statistics-enabled-toggle');
const requestLogToggle = document.getElementById('request-log-toggle');
const wsAuthToggle = document.getElementById('ws-auth-toggle');
if (debugToggle) {
debugToggle.addEventListener('change', (e) => this.updateDebug(e.target.checked));
}
if (updateProxy) {
updateProxy.addEventListener('click', () => this.updateProxyUrl());
}
if (clearProxy) {
clearProxy.addEventListener('click', () => this.clearProxyUrl());
}
if (updateRetry) {
updateRetry.addEventListener('click', () => this.updateRequestRetry());
}
if (switchProjectToggle) {
switchProjectToggle.addEventListener('change', (e) => this.updateSwitchProject(e.target.checked));
}
if (switchPreviewToggle) {
switchPreviewToggle.addEventListener('change', (e) => this.updateSwitchPreviewModel(e.target.checked));
}
if (usageStatisticsToggle) {
usageStatisticsToggle.addEventListener('change', (e) => this.updateUsageStatisticsEnabled(e.target.checked));
}
if (requestLogToggle) {
requestLogToggle.addEventListener('change', (e) => this.updateRequestLog(e.target.checked));
}
if (wsAuthToggle) {
wsAuthToggle.addEventListener('change', (e) => this.updateWsAuth(e.target.checked));
}
// 日志记录设置
const loggingToFileToggle = document.getElementById('logging-to-file-toggle');
if (loggingToFileToggle) {
loggingToFileToggle.addEventListener('change', (e) => this.updateLoggingToFile(e.target.checked));
}
// 日志查看
const refreshLogs = document.getElementById('refresh-logs');
const selectErrorLog = document.getElementById('select-error-log');
const downloadLogs = document.getElementById('download-logs');
const clearLogs = document.getElementById('clear-logs');
const logsAutoRefreshToggle = document.getElementById('logs-auto-refresh-toggle');
const logsSearchInput = document.getElementById('logs-search-input');
if (refreshLogs) {
refreshLogs.addEventListener('click', () => this.refreshLogs());
}
if (selectErrorLog) {
selectErrorLog.addEventListener('click', () => this.openErrorLogsModal());
}
if (downloadLogs) {
downloadLogs.addEventListener('click', () => this.downloadLogs());
}
if (clearLogs) {
clearLogs.addEventListener('click', () => this.clearLogs());
}
if (logsAutoRefreshToggle) {
logsAutoRefreshToggle.addEventListener('change', (e) => this.toggleLogsAutoRefresh(e.target.checked));
}
if (logsSearchInput) {
const debouncedLogSearch = this.debounce((value) => {
this.updateLogSearchQuery(value);
}, 200);
logsSearchInput.addEventListener('input', (e) => {
debouncedLogSearch(e?.target?.value ?? '');
});
}
// API 密钥管理
const addApiKey = document.getElementById('add-api-key');
const addGeminiKey = document.getElementById('add-gemini-key');
const addCodexKey = document.getElementById('add-codex-key');
const addClaudeKey = document.getElementById('add-claude-key');
const addOpenaiProvider = document.getElementById('add-openai-provider');
if (addApiKey) {
addApiKey.addEventListener('click', () => this.showAddApiKeyModal());
}
if (addGeminiKey) {
addGeminiKey.addEventListener('click', () => this.showAddGeminiKeyModal());
}
if (addCodexKey) {
addCodexKey.addEventListener('click', () => this.showAddCodexKeyModal());
}
if (addClaudeKey) {
addClaudeKey.addEventListener('click', () => this.showAddClaudeKeyModal());
}
if (addOpenaiProvider) {
addOpenaiProvider.addEventListener('click', () => this.showAddOpenAIProviderModal());
}
// 认证文件管理
const uploadAuthFile = document.getElementById('upload-auth-file');
const deleteAllAuthFiles = document.getElementById('delete-all-auth-files');
const authFileInput = document.getElementById('auth-file-input');
if (uploadAuthFile) {
uploadAuthFile.addEventListener('click', () => this.uploadAuthFile());
}
if (deleteAllAuthFiles) {
deleteAllAuthFiles.addEventListener('click', () => this.deleteAllAuthFiles());
}
if (authFileInput) {
authFileInput.addEventListener('change', (e) => this.handleFileUpload(e));
}
this.bindAuthFilesPaginationEvents();
this.bindAuthFilesSearchControl();
this.bindAuthFilesPageSizeControl();
this.syncAuthFileControls();
// OAuth 排除列表
const oauthExcludedAdd = document.getElementById('oauth-excluded-add');
const oauthExcludedRefresh = document.getElementById('oauth-excluded-refresh');
if (oauthExcludedAdd) {
oauthExcludedAdd.addEventListener('click', () => this.openOauthExcludedEditor());
}
if (oauthExcludedRefresh) {
oauthExcludedRefresh.addEventListener('click', () => this.loadOauthExcludedModels(true));
}
// Vertex AI credential import
const vertexSelectFile = document.getElementById('vertex-select-file');
const vertexFileInput = document.getElementById('vertex-file-input');
const vertexImportBtn = document.getElementById('vertex-import-btn');
if (vertexSelectFile) {
vertexSelectFile.addEventListener('click', () => this.openVertexFilePicker());
}
if (vertexFileInput) {
vertexFileInput.addEventListener('change', (e) => this.handleVertexFileSelection(e));
}
if (vertexImportBtn) {
vertexImportBtn.addEventListener('click', () => this.importVertexCredential());
}
this.updateVertexFileDisplay();
this.updateVertexImportButtonState();
this.renderVertexImportResult(this.vertexImportState.result);
// Codex OAuth
const codexOauthBtn = document.getElementById('codex-oauth-btn');
const codexOpenLink = document.getElementById('codex-open-link');
const codexCopyLink = document.getElementById('codex-copy-link');
if (codexOauthBtn) {
codexOauthBtn.addEventListener('click', () => this.startCodexOAuth());
}
if (codexOpenLink) {
codexOpenLink.addEventListener('click', () => this.openCodexLink());
}
if (codexCopyLink) {
codexCopyLink.addEventListener('click', () => this.copyCodexLink());
}
// Anthropic OAuth
const anthropicOauthBtn = document.getElementById('anthropic-oauth-btn');
const anthropicOpenLink = document.getElementById('anthropic-open-link');
const anthropicCopyLink = document.getElementById('anthropic-copy-link');
if (anthropicOauthBtn) {
anthropicOauthBtn.addEventListener('click', () => this.startAnthropicOAuth());
}
if (anthropicOpenLink) {
anthropicOpenLink.addEventListener('click', () => this.openAnthropicLink());
}
if (anthropicCopyLink) {
anthropicCopyLink.addEventListener('click', () => this.copyAnthropicLink());
}
// Antigravity OAuth
const antigravityOauthBtn = document.getElementById('antigravity-oauth-btn');
const antigravityOpenLink = document.getElementById('antigravity-open-link');
const antigravityCopyLink = document.getElementById('antigravity-copy-link');
if (antigravityOauthBtn) {
antigravityOauthBtn.addEventListener('click', () => this.startAntigravityOAuth());
}
if (antigravityOpenLink) {
antigravityOpenLink.addEventListener('click', () => this.openAntigravityLink());
}
if (antigravityCopyLink) {
antigravityCopyLink.addEventListener('click', () => this.copyAntigravityLink());
}
// Gemini CLI OAuth
const geminiCliOauthBtn = document.getElementById('gemini-cli-oauth-btn');
const geminiCliOpenLink = document.getElementById('gemini-cli-open-link');
const geminiCliCopyLink = document.getElementById('gemini-cli-copy-link');
if (geminiCliOauthBtn) {
geminiCliOauthBtn.addEventListener('click', () => this.startGeminiCliOAuth());
}
if (geminiCliOpenLink) {
geminiCliOpenLink.addEventListener('click', () => this.openGeminiCliLink());
}
if (geminiCliCopyLink) {
geminiCliCopyLink.addEventListener('click', () => this.copyGeminiCliLink());
}
// Qwen OAuth
const qwenOauthBtn = document.getElementById('qwen-oauth-btn');
const qwenOpenLink = document.getElementById('qwen-open-link');
const qwenCopyLink = document.getElementById('qwen-copy-link');
if (qwenOauthBtn) {
qwenOauthBtn.addEventListener('click', () => this.startQwenOAuth());
}
if (qwenOpenLink) {
qwenOpenLink.addEventListener('click', () => this.openQwenLink());
}
if (qwenCopyLink) {
qwenCopyLink.addEventListener('click', () => this.copyQwenLink());
}
// iFlow OAuth
const iflowOauthBtn = document.getElementById('iflow-oauth-btn');
const iflowOpenLink = document.getElementById('iflow-open-link');
const iflowCopyLink = document.getElementById('iflow-copy-link');
const iflowCookieSubmit = document.getElementById('iflow-cookie-submit');
if (iflowOauthBtn) {
iflowOauthBtn.addEventListener('click', () => this.startIflowOAuth());
}
if (iflowOpenLink) {
iflowOpenLink.addEventListener('click', () => this.openIflowLink());
}
if (iflowCopyLink) {
iflowCopyLink.addEventListener('click', () => this.copyIflowLink());
}
if (iflowCookieSubmit) {
iflowCookieSubmit.addEventListener('click', () => this.submitIflowCookieLogin());
}
// 使用统计
const refreshUsageStats = document.getElementById('refresh-usage-stats');
const requestsHourBtn = document.getElementById('requests-hour-btn');
const requestsDayBtn = document.getElementById('requests-day-btn');
const tokensHourBtn = document.getElementById('tokens-hour-btn');
const tokensDayBtn = document.getElementById('tokens-day-btn');
const costHourBtn = document.getElementById('cost-hour-btn');
const costDayBtn = document.getElementById('cost-day-btn');
const addChartLineBtn = document.getElementById('add-chart-line');
const chartLineSelects = document.querySelectorAll('.chart-line-select');
const chartLineDeleteButtons = document.querySelectorAll('.chart-line-delete');
const modelPriceForm = document.getElementById('model-price-form');
const resetModelPricesBtn = document.getElementById('reset-model-prices');
const modelPriceSelect = document.getElementById('model-price-model-select');
if (refreshUsageStats) {
refreshUsageStats.addEventListener('click', () => this.loadUsageStats());
}
if (requestsHourBtn) {
requestsHourBtn.addEventListener('click', () => this.switchRequestsPeriod('hour'));
}
if (requestsDayBtn) {
requestsDayBtn.addEventListener('click', () => this.switchRequestsPeriod('day'));
}
if (tokensHourBtn) {
tokensHourBtn.addEventListener('click', () => this.switchTokensPeriod('hour'));
}
if (tokensDayBtn) {
tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day'));
}
if (costHourBtn) {
costHourBtn.addEventListener('click', () => this.switchCostPeriod('hour'));
}
if (costDayBtn) {
costDayBtn.addEventListener('click', () => this.switchCostPeriod('day'));
}
if (addChartLineBtn) {
addChartLineBtn.addEventListener('click', () => this.changeChartLineCount(1));
}
if (chartLineSelects.length) {
chartLineSelects.forEach(select => {
select.addEventListener('change', (event) => {
const index = Number.parseInt(select.getAttribute('data-line-index'), 10);
this.handleChartLineSelectionChange(Number.isNaN(index) ? -1 : index, event.target.value);
});
});
}
if (chartLineDeleteButtons.length) {
chartLineDeleteButtons.forEach(button => {
button.addEventListener('click', () => {
const index = Number.parseInt(button.getAttribute('data-line-index'), 10);
this.removeChartLine(Number.isNaN(index) ? -1 : index);
});
});
}
this.updateChartLineControlsUI();
if (modelPriceForm) {
modelPriceForm.addEventListener('submit', (event) => {
event.preventDefault();
this.handleModelPriceSubmit();
});
}
if (resetModelPricesBtn) {
resetModelPricesBtn.addEventListener('click', () => this.handleModelPriceReset());
}
if (modelPriceSelect) {
modelPriceSelect.addEventListener('change', () => this.prefillModelPriceInputs());
}
// 模态框
const closeBtn = document.querySelector('.close');
if (closeBtn) {
closeBtn.addEventListener('click', () => this.closeModal());
}
// 移动端菜单按钮
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const sidebar = document.getElementById('sidebar');
if (mobileMenuBtn) {
mobileMenuBtn.addEventListener('click', () => this.toggleMobileSidebar());
}
if (sidebarOverlay) {
sidebarOverlay.addEventListener('click', () => this.closeMobileSidebar());
}
// 侧边栏收起/展开按钮(桌面端)
const sidebarToggleBtnDesktop = document.getElementById('sidebar-toggle-btn-desktop');
if (sidebarToggleBtnDesktop) {
sidebarToggleBtnDesktop.addEventListener('click', () => this.toggleSidebar());
}
// 从本地存储恢复侧边栏状态
this.restoreSidebarState();
// 监听窗口大小变化
window.addEventListener('resize', () => {
const sidebar = document.getElementById('sidebar');
const layout = document.getElementById('layout-container');
if (window.innerWidth <= 1024) {
// 移动端:移除收起状态
if (sidebar && layout) {
sidebar.classList.remove('collapsed');
layout.classList.remove('sidebar-collapsed');
}
} else {
// 桌面端:恢复保存的状态
this.restoreSidebarState();
}
});
// 点击侧边栏导航项时在移动端关闭侧边栏
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.addEventListener('click', () => {
if (window.innerWidth <= 1024) {
this.closeMobileSidebar();
}
});
});
}
// 显示通知
showNotification(message, type = 'info') {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = `notification ${type}`;
notification.classList.add('show');
setTimeout(() => {
notification.classList.remove('show');
}, NOTIFICATION_DURATION_MS);
}
// 密钥可见性切换
toggleKeyVisibility() {
const keyInput = document.getElementById('management-key');
const toggleButton = document.getElementById('toggle-key-visibility');
if (keyInput.type === 'password') {
keyInput.type = 'text';
toggleButton.innerHTML = '<i class="fas fa-eye-slash"></i>';
} else {
keyInput.type = 'password';
toggleButton.innerHTML = '<i class="fas fa-eye"></i>';
}
}
// ===== 使用统计相关方法 =====
// 使用统计状态
requestsChart = null;
tokensChart = null;
costChart = null;
currentUsageData = null;
chartLineMaxCount = 9;
chartLineVisibleCount = 3;
chartLineSelections = Array(3).fill('none');
chartLineSelectionsInitialized = false;
chartLineSelectIds = Array.from({ length: 9 }, (_, idx) => `chart-line-select-${idx}`);
chartLineStyles = [
{ borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' },
{ borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.15)' },
{ borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' },
{ borderColor: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.15)' },
{ borderColor: '#ec4899', backgroundColor: 'rgba(236, 72, 153, 0.15)' },
{ borderColor: '#14b8a6', backgroundColor: 'rgba(20, 184, 166, 0.15)' },
{ borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.15)' },
{ borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.15)' },
{ borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.15)' }
];
modelPriceStorageKey = 'cli-proxy-model-prices-v2';
modelPrices = {};
modelPriceInitialized = false;
showModal() {
const modal = document.getElementById('modal');
if (modal) {
modal.style.display = 'block';
}
}
// 关闭模态框
closeModal() {
document.getElementById('modal').style.display = 'none';
if (typeof this.closeOpenAIModelDiscovery === 'function') {
this.closeOpenAIModelDiscovery();
}
}
}
Object.assign(
CLIProxyManager.prototype,
themeModule,
navigationModule,
languageModule,
loginModule,
configEditorModule,
logsModule,
apiKeysModule,
authFilesModule,
oauthModule,
usageModule,
settingsModule,
aiProvidersModule,
connectionModule
);
// 将工具函数绑定到原型上,供模块使用
CLIProxyManager.prototype.escapeHtml = escapeHtml;
CLIProxyManager.prototype.maskApiKey = maskApiKey;
CLIProxyManager.prototype.formatFileSize = formatFileSize;
CLIProxyManager.prototype.normalizeArrayResponse = normalizeArrayResponse;
CLIProxyManager.prototype.debounce = debounce;
// 全局管理器实例
let manager;
// 让内联事件处理器可以访问到 manager 实例
function exposeManagerInstance(instance) {
if (typeof window !== 'undefined') {
window.manager = instance;
} else if (typeof globalThis !== 'undefined') {
globalThis.manager = instance;
}
}
// 尝试自动加载根目录 Logo支持多种常见文件名/扩展名)
function setupSiteLogo() {
const img = document.getElementById('site-logo');
const loginImg = document.getElementById('login-logo');
if (!img && !loginImg) return;
const inlineLogo = typeof window !== 'undefined' ? window.__INLINE_LOGO__ : null;
if (inlineLogo) {
if (img) {
img.src = inlineLogo;
img.style.display = 'inline-block';
}
if (loginImg) {
loginImg.src = inlineLogo;
loginImg.style.display = 'inline-block';
}
return;
}
const candidates = [
'../logo.svg', '../logo.png', '../logo.jpg', '../logo.jpeg', '../logo.webp', '../logo.gif',
'logo.svg', 'logo.png', 'logo.jpg', 'logo.jpeg', 'logo.webp', 'logo.gif',
'/logo.svg', '/logo.png', '/logo.jpg', '/logo.jpeg', '/logo.webp', '/logo.gif'
];
let idx = 0;
const tryNext = () => {
if (idx >= candidates.length) return;
const test = new Image();
test.onload = () => {
if (img) {
img.src = test.src;
img.style.display = 'inline-block';
}
if (loginImg) {
loginImg.src = test.src;
loginImg.style.display = 'inline-block';
}
};
test.onerror = () => {
idx++;
tryNext();
};
test.src = candidates[idx];
};
tryNext();
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
// 初始化国际化
i18n.init();
setupSiteLogo();
manager = new CLIProxyManager();
exposeManagerInstance(manager);
});

View File

@@ -1,30 +0,0 @@
const fs = require('fs');
const path = require('path');
// Read the original index.html
const indexPath = path.resolve(__dirname, '../index.html');
const outputPath = path.resolve(__dirname, '../index.build.html');
let htmlContent = fs.readFileSync(indexPath, 'utf8');
// Remove local CSS reference
htmlContent = htmlContent.replace(
/<link rel="stylesheet" href="styles\.css">\n?/g,
''
);
// Remove local JavaScript references
htmlContent = htmlContent.replace(
/<script src="i18n\.js"><\/script>\n?/g,
''
);
htmlContent = htmlContent.replace(
/<script src="app\.js"><\/script>\n?/g,
''
);
// Write the modified HTML to a temporary build file
fs.writeFileSync(outputPath, htmlContent, 'utf8');
console.log('✓ Generated index.build.html for webpack processing');

225
build.cjs
View File

@@ -1,225 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const projectRoot = __dirname;
const distDir = path.join(projectRoot, 'dist');
const sourceFiles = {
html: path.join(projectRoot, 'index.html'),
css: path.join(projectRoot, 'styles.css'),
i18n: path.join(projectRoot, 'i18n.js'),
app: path.join(projectRoot, 'app.js')
};
const logoCandidates = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.svg', 'logo.webp', 'logo.gif'];
const logoMimeMap = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.gif': 'image/gif'
};
function readFile(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (err) {
console.error(`读取文件失败: ${filePath}`);
throw err;
}
}
function readBinary(filePath) {
try {
return fs.readFileSync(filePath);
} catch (err) {
console.error(`读取文件失败: ${filePath}`);
throw err;
}
}
function escapeForScript(content) {
return content.replace(/<\/(script)/gi, '<\\/$1');
}
function escapeForStyle(content) {
return content.replace(/<\/(style)/gi, '<\\/$1');
}
function getVersion() {
// 1. 优先从环境变量获取GitHub Actions 会设置)
if (process.env.VERSION) {
return process.env.VERSION;
}
// 2. 尝试从 git tag 获取
try {
const { execSync } = require('child_process');
const gitTag = execSync('git describe --tags --exact-match 2>/dev/null || git describe --tags 2>/dev/null || echo ""', { encoding: 'utf8' }).trim();
if (gitTag) {
return gitTag;
}
} catch (err) {
console.warn('无法从 git 获取版本号');
}
// 3. 回退到 package.json
try {
const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
return 'v' + packageJson.version;
} catch (err) {
console.warn('无法从 package.json 读取版本号');
}
// 4. 最后使用默认值
return 'v0.0.0-dev';
}
function ensureDistDir() {
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true });
}
fs.mkdirSync(distDir);
}
// 匹配各种 import 语句
const importRegex = /import\s+(?:{[^}]*}|[\w*\s,{}]+)\s+from\s+['"]([^'"]+)['"];?/gm;
// 匹配 export 关键字(包括 export const, export function, export class, export async function 等)
const exportRegex = /^export\s+(?=const|let|var|function|class|default|async)/gm;
// 匹配单独的 export {} 或 export { ... } from '...'
const exportBraceRegex = /^export\s*{[^}]*}\s*(?:from\s+['"][^'"]+['"];?)?$/gm;
function bundleApp(entryPath) {
const visited = new Set();
const modules = [];
function inlineFile(filePath) {
let content = readFile(filePath);
const dir = path.dirname(filePath);
// 收集所有 import 语句
const imports = [];
content = content.replace(importRegex, (match, specifier) => {
const targetPath = path.resolve(dir, specifier);
const normalized = path.normalize(targetPath);
if (!fs.existsSync(normalized)) {
throw new Error(`无法解析模块: ${specifier} (from ${filePath})`);
}
if (!visited.has(normalized)) {
visited.add(normalized);
imports.push(normalized);
}
return ''; // 移除 import 语句
});
// 移除 export 关键字
content = content.replace(exportRegex, '');
content = content.replace(exportBraceRegex, '');
// 处理依赖的模块
for (const importPath of imports) {
const moduleContent = inlineFile(importPath);
const relativePath = path.relative(projectRoot, importPath);
modules.push(`\n// ============ ${relativePath} ============\n${moduleContent}\n`);
}
return content;
}
const mainContent = inlineFile(entryPath);
// 将所有模块内容组合在一起,模块在前,主文件在后
return modules.join('\n') + '\n// ============ Main ============\n' + mainContent;
}
function loadLogoDataUrl() {
for (const candidate of logoCandidates) {
const filePath = path.join(projectRoot, candidate);
if (!fs.existsSync(filePath)) continue;
const ext = path.extname(candidate).toLowerCase();
const mime = logoMimeMap[ext];
if (!mime) {
console.warn(`未知 Logo 文件类型,跳过内联: ${candidate}`);
continue;
}
const buffer = readBinary(filePath);
const base64 = buffer.toString('base64');
return `data:${mime};base64,${base64}`;
}
return null;
}
function build() {
ensureDistDir();
let html = readFile(sourceFiles.html);
const css = escapeForStyle(readFile(sourceFiles.css));
const i18n = escapeForScript(readFile(sourceFiles.i18n));
const bundledApp = bundleApp(sourceFiles.app);
const app = escapeForScript(bundledApp);
// 获取版本号并替换
const version = getVersion();
console.log(`使用版本号: ${version}`);
html = html.replace(/__VERSION__/g, version);
html = html.replace(
'<link rel="stylesheet" href="styles.css">',
() => `<style>
${css}
</style>`
);
html = html.replace(
'<script src="i18n.js"></script>',
() => `<script>
${i18n}
</script>`
);
const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i;
if (scriptTagRegex.test(html)) {
html = html.replace(
scriptTagRegex,
() => `<script>
${app}
</script>`
);
} else {
console.warn('未找到 app.js 脚本标签,未内联应用代码。');
}
const logoDataUrl = loadLogoDataUrl();
if (logoDataUrl) {
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
const closingBodyTag = '</body>';
const closingBodyIndex = html.lastIndexOf(closingBodyTag);
if (closingBodyIndex !== -1) {
html = `${html.slice(0, closingBodyIndex)}${logoScript}\n${closingBodyTag}${html.slice(closingBodyIndex + closingBodyTag.length)}`;
} else {
html += `\n${logoScript}`;
}
} else {
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。');
}
const outputPath = path.join(distDir, 'index.html');
fs.writeFileSync(outputPath, html, 'utf8');
console.log('构建完成: dist/index.html');
}
try {
build();
} catch (error) {
console.error('构建失败:', error);
process.exit(1);
}

View File

@@ -1,25 +0,0 @@
// Import CSS
import './styles.css';
// Import JavaScript modules
import './i18n.js';
import './app.js';
// Import logo image
import logoImg from './logo.jpg';
// Set logo after DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
const loginLogo = document.getElementById('login-logo');
const siteLogo = document.getElementById('site-logo');
if (loginLogo) {
loginLogo.src = logoImg;
loginLogo.style.display = 'block';
}
if (siteLogo) {
siteLogo.src = logoImg;
siteLogo.style.display = 'block';
}
});

30
eslint.config.js Normal file
View File

@@ -0,0 +1,30 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
);

1391
i18n.js

File diff suppressed because it is too large Load Diff

1363
index.html

File diff suppressed because it is too large Load Diff

4681
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,46 @@
{
"name": "cli-proxy-api-webui",
"version": "1.0.0",
"description": "CLI Proxy API 管理界面",
"main": "index.html",
"name": "cli-proxy-webui-react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "npx serve .",
"dev": "npx serve . -l 3090",
"build": "node build.cjs",
"lint": "echo '使用浏览器开发者工具检查代码'"
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1",
"zustand": "^5.0.9"
},
"keywords": [
"cli-proxy-api",
"webui",
"management",
"api"
],
"author": "CLI Proxy API WebUI",
"license": "MIT",
"devDependencies": {
"serve": "^14.2.1"
},
"engines": {
"node": ">=14.0.0"
},
"repository": {
"type": "git",
"url": "local"
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"sass": "^1.94.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6",
"vite-plugin-singlefile": "^2.3.0"
}
}

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

48
src/App.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { useEffect } from 'react';
import { HashRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage';
import { NotificationContainer } from '@/components/common/NotificationContainer';
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute';
import { useLanguageStore, useThemeStore } from '@/stores';
function App() {
const initializeTheme = useThemeStore((state) => state.initializeTheme);
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
useEffect(() => {
const cleanupTheme = initializeTheme();
return cleanupTheme;
}, [initializeTheme]);
useEffect(() => {
setLanguage(language);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅用于首屏同步 i18n 语言
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
return (
<HashRouter>
<NotificationContainer />
<ConfirmationModal />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
/>
</Routes>
</HashRouter>
);
}
export default App;

6
src/assets/icons/amp.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="400" height="400" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="#F34E3F"/>
<path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="#F34E3F"/>
<path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="#F34E3F"/>
<path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="#F34E3F"/>
</svg>

After

Width:  |  Height:  |  Size: 632 B

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 0.6.4 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="59">
<path d="M0,0 L8,0 L14,4 L19,14 L27,40 L32,50 L36,54 L35,59 L30,59 L22,52 L11,35 L6,33 L-1,34 L-6,39 L-14,52 L-22,59 L-28,59 L-27,53 L-22,47 L-17,34 L-10,12 L-5,3 Z " fill="#3789F9" transform="translate(28,0)"/>
<path d="M0,0 L8,0 L14,4 L19,14 L25,35 L21,34 L16,29 L11,26 L7,20 L7,18 L2,16 L-3,15 L-8,18 L-12,19 L-9,9 L-4,2 Z " fill="#6D80D8" transform="translate(28,0)"/>
<path d="M0,0 L8,0 L14,4 L19,14 L20,19 L13,15 L10,12 L3,10 L-1,8 L-7,7 L-4,2 Z " fill="#D78240" transform="translate(28,0)"/>
<path d="M0,0 L5,1 L10,4 L12,9 L1,8 L-5,13 L-10,21 L-13,26 L-16,26 L-9,5 L-4,2 Z M6,7 Z " fill="#3294CC" transform="translate(25,14)"/>
<path d="M0,0 L5,2 L10,10 L12,18 L5,14 L1,10 L0,4 L-3,3 L0,2 Z " fill="#E45C49" transform="translate(36,1)"/>
<path d="M0,0 L9,1 L12,3 L12,5 L7,6 L4,8 L-1,11 L-5,12 L-2,2 Z " fill="#90AE64" transform="translate(21,7)"/>
<path d="M0,0 L5,1 L5,4 L-2,7 L-7,11 L-11,10 L-9,5 L-4,2 Z " fill="#53A89A" transform="translate(25,14)"/>
<path d="M0,0 L5,0 L16,9 L17,13 L12,12 L8,9 L8,7 L4,5 L0,2 Z " fill="#B5677D" transform="translate(33,11)"/>
<path d="M0,0 L6,0 L14,6 L19,11 L23,12 L22,15 L15,12 L10,8 L10,6 L4,5 Z " fill="#778998" transform="translate(27,12)"/>
<path d="M0,0 L4,2 L-11,17 L-12,14 L-5,4 Z " fill="#3390DF" transform="translate(26,21)"/>
<path d="M0,0 L2,1 L-4,5 L-9,9 L-13,13 L-14,10 L-13,7 L-6,4 L-3,1 Z " fill="#3FA1B7" transform="translate(27,18)"/>
<path d="M0,0 L4,0 L9,5 L13,6 L12,9 L5,6 L0,2 Z " fill="#8277BB" transform="translate(37,18)"/>
<path d="M0,0 L5,1 L7,6 L-2,5 Z M1,4 Z " fill="#4989CF" transform="translate(30,17)"/>
<path d="M0,0 L5,1 L2,3 L-3,6 L-7,7 L-6,3 Z " fill="#71B774" transform="translate(23,12)"/>
<path d="M0,0 L7,1 L9,7 L5,6 L0,1 Z " fill="#6687E9" transform="translate(44,28)"/>
<path d="M0,0 L7,0 L5,1 L5,3 L8,4 L4,5 L-2,4 Z " fill="#C7AF38" transform="translate(23,3)"/>
<path d="M0,0 L8,0 L8,3 L4,4 L-4,3 Z " fill="#EF842A" transform="translate(28,0)"/>
<path d="M0,0 L7,4 L7,6 L10,6 L11,10 L4,6 L0,2 Z " fill="#CD5D67" transform="translate(37,9)"/>
<path d="M0,0 L5,2 L9,8 L8,11 L2,3 L0,2 Z " fill="#F35241" transform="translate(36,1)"/>
<path d="M0,0 L8,2 L9,6 L4,5 L0,2 Z " fill="#A667A2" transform="translate(41,18)"/>
<path d="M0,0 L9,1 L8,3 L-2,3 Z " fill="#A4B34C" transform="translate(21,7)"/>
<path d="M0,0 L2,0 L7,5 L8,7 L3,6 L0,2 Z " fill="#617FCF" transform="translate(35,18)"/>
<path d="M0,0 L5,2 L8,7 L4,5 L0,2 Z " fill="#9D7784" transform="translate(33,11)"/>
<path d="M0,0 L6,2 L6,4 L0,3 Z " fill="#BC7F59" transform="translate(31,7)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 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>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 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="#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

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>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 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 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="32" height="32" viewBox="0 0 32 32"><defs><filter id="master_svg0_278_51503" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB" x="0" y="0" width="1" height="1"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur in="BackgroundImageFix" stdDeviation="1.3333334922790527"/><feComposite in2="SourceAlpha" operator="in" result="effect1_foregroundBlur"/><feBlend mode="normal" in="SourceGraphic" in2="effect1_foregroundBlur" result="shape"/></filter><linearGradient x1="0.07353696972131729" y1="0.12899449467658997" x2="0.9907095821060244" y2="0.9383787344260006" id="master_svg1_93_40276"><stop offset="0%" stop-color="#5C5CFF" stop-opacity="1"/><stop offset="100%" stop-color="#AE5CFF" stop-opacity="1"/></linearGradient></defs><g><g filter="url(#master_svg0_278_51503)"><rect x="0" y="0" width="32" height="32" rx="16" fill="#F0F2F5" fill-opacity="0"/></g><g><g><path d="M31.843111328125,14.751C31.315411328125,7.18121,25.497411328125,1.04691,17.966011328125,0.119698C10.434711328125,-0.807512,3.302541328125,3.73244,0.954596328125,10.9482C0.345662328125,12.8248,1.732821328125,14.751,3.705641328125,14.751C4.950051328125,14.7517,6.055631328125,13.9569,6.451401328125,12.7772C7.497331328125,9.65101,10.504411328125,3.91401,18.482011328125,3.91401Q29.445911328125,3.91401,31.843111328125,14.751ZM9.127681328125,17.3314L9.127681328125,13.0862Q9.127681328125,13.0022,9.144081328125,12.9198Q9.160481328125,12.8373,9.192641328125,12.7597Q9.224801328125,12.682,9.271501328125,12.6122Q9.318191328125,12.5423,9.377621328125,12.4828Q9.437051328125,12.4234,9.506931328125,12.3767Q9.576811328125,12.33,9.654461328125,12.2979Q9.732111328125,12.2657,9.814541328125,12.2493Q9.896971328125,12.2329,9.981021328125,12.2329L11.049211328125,12.2329Q11.133211328125,12.2329,11.215711328125,12.2493Q11.298111328125,12.2657,11.375811328125,12.2979Q11.453411328125,12.33,11.523311328125,12.3767Q11.593211328125,12.4234,11.652611328125,12.4828Q11.712011328125,12.5423,11.758711328125,12.6122Q11.805411328125,12.682,11.837611328125,12.7597Q11.869711328125,12.8373,11.886111328125,12.9198Q11.902511328125,13.0022,11.902511328125,13.0862L11.902511328125,17.3314Q11.902511328125,17.4154,11.886111328125,17.4978Q11.869711328125,17.5803,11.837611328125,17.6579Q11.805411328125,17.7356,11.758711328125,17.8055Q11.712011328125,17.8753,11.652611328125,17.9348Q11.593211328125,17.9942,11.523311328125,18.0409Q11.453411328125,18.0876,11.375811328125,18.1197Q11.298111328125,18.1519,11.215711328125,18.1683Q11.133211328125,18.1847,11.049211328125,18.1847L9.981021328125,18.1847Q9.896971328125,18.1847,9.814541328125,18.1683Q9.732111328125,18.1519,9.654461328125,18.1197Q9.576811328125,18.0876,9.506931328125,18.0409Q9.437051328125,17.9942,9.377621328125,17.9348Q9.318191328125,17.8753,9.271501328125,17.8055Q9.224801328125,17.7356,9.192641328125,17.6579Q9.160481328125,17.5803,9.144081328125,17.4978Q9.127681328125,17.4154,9.127681328125,17.3314ZM17.273611328125,17.3295C17.272611328125,17.8015,17.654911328125,18.1847,18.126911328125,18.1847L19.408411328125,18.1847C19.879011328125,18.1847,20.260711328125,17.8038,20.261811328125,17.3332L20.266411328125,15.2107L20.266411328125,15.2069L20.261811328125,13.0844C20.260711328125,12.6138,19.879011328125,12.2329,19.408411328125,12.2329L18.126911328125,12.2329C17.654911328125,12.2329,17.272611328125,12.6161,17.273611328125,13.0881L17.278211328125,15.2069L17.278211328125,15.2107L17.273611328125,17.3295ZM13.574711328125,28.0523C21.552211328125,28.0523,24.559311328125,22.3153,25.605811328125,19.1897C26.001411328125,18.0098,27.107111328125,17.215,28.351511328125,17.2158C30.323811328125,17.2158,31.711511328125,19.1416,31.102611328125,21.0181C30.552411328125,22.7189,29.716211328125,24.3134,28.629811328125,25.733L30.137611328125,30.2235L24.775211328125,29.3432C14.645911328125,36.0484,1.048779328125,29.3346,0.214111328125,17.2158Q2.611231328125,28.0523,13.574711328125,28.0523Z" fill-rule="evenodd" fill="url(#master_svg1_93_40276)" fill-opacity="1"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

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 @@
<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>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg fill="#000000" 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>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 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>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

1
src/assets/logoInline.ts Normal file

File diff suppressed because one or more lines are too long

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 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

@@ -0,0 +1,94 @@
import { useEffect, useRef, useState } from 'react';
import { useNotificationStore } from '@/stores';
import { IconX } from '@/components/ui/icons';
import type { Notification } from '@/types';
interface AnimatedNotification extends Notification {
isExiting?: boolean;
}
const ANIMATION_DURATION = 300; // ms
export function NotificationContainer() {
const { notifications, removeNotification } = useNotificationStore();
const [animatedNotifications, setAnimatedNotifications] = useState<AnimatedNotification[]>([]);
const prevNotificationsRef = useRef<Notification[]>([]);
// Track notifications and manage animation states
useEffect(() => {
const prevNotifications = prevNotificationsRef.current;
const prevIds = new Set(prevNotifications.map((n) => n.id));
const currentIds = new Set(notifications.map((n) => n.id));
// Find new notifications (for enter animation)
const newNotifications = notifications.filter((n) => !prevIds.has(n.id));
// Find removed notifications (for exit animation)
const removedIds = new Set(
prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id)
);
setAnimatedNotifications((prev) => {
// Mark removed notifications as exiting
let updated = prev.map((n) =>
removedIds.has(n.id) ? { ...n, isExiting: true } : n
);
// Add new notifications
newNotifications.forEach((n) => {
if (!updated.find((an) => an.id === n.id)) {
updated.push({ ...n, isExiting: false });
}
});
// Remove notifications that are not in current and not exiting
// (they've already completed their exit animation)
updated = updated.filter(
(n) => currentIds.has(n.id) || n.isExiting
);
return updated;
});
// Clean up exited notifications after animation
if (removedIds.size > 0) {
setTimeout(() => {
setAnimatedNotifications((prev) =>
prev.filter((n) => !removedIds.has(n.id))
);
}, ANIMATION_DURATION);
}
prevNotificationsRef.current = notifications;
}, [notifications]);
const handleClose = (id: string) => {
// Start exit animation
setAnimatedNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n))
);
// Actually remove after animation
setTimeout(() => {
removeNotification(id);
}, ANIMATION_DURATION);
};
if (!animatedNotifications.length) return null;
return (
<div className="notification-container">
{animatedNotifications.map((notification) => (
<div
key={notification.id}
className={`notification ${notification.type} ${notification.isExiting ? 'exiting' : 'entering'}`}
>
<div className="message">{notification.message}</div>
<button className="close-btn" onClick={() => handleClose(notification.id)} aria-label="Close">
<IconX size={16} />
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,42 @@
@use '@/styles/variables.scss' as *;
.page-transition {
position: relative;
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
&__layer {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
flex: 1;
background: var(--bg-secondary);
backface-visibility: hidden;
transform: translateZ(0);
// During animation, exit layer uses absolute positioning
&--exit {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
will-change: transform, opacity;
}
&--stacked {
display: none;
}
}
&--animating &__layer {
will-change: transform, opacity;
}
&--animating &__layer:not(.page-transition__layer--exit) {
position: relative;
}
}

View File

@@ -0,0 +1,348 @@
import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import './PageTransition.scss';
interface PageTransitionProps {
render: (location: Location) => ReactNode;
getRouteOrder?: (pathname: string) => number | null;
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
const VERTICAL_TRANSITION_DURATION = 0.35;
const VERTICAL_TRAVEL_DISTANCE = 60;
const IOS_TRANSITION_DURATION = 0.42;
const IOS_ENTER_FROM_X_PERCENT = 100;
const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
const IOS_EXIT_DIM_OPACITY = 0.72;
type LayerStatus = 'current' | 'exiting' | 'stacked';
type Layer = {
key: string;
location: Location;
status: LayerStatus;
};
type TransitionDirection = 'forward' | 'backward';
type TransitionVariant = 'vertical' | 'ios';
export function PageTransition({
render,
getRouteOrder,
getTransitionVariant,
scrollContainerRef,
}: PageTransitionProps) {
const location = useLocation();
const currentLayerRef = useRef<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null);
const transitionDirectionRef = useRef<TransitionDirection>('forward');
const transitionVariantRef = useRef<TransitionVariant>('vertical');
const exitScrollOffsetRef = useRef(0);
const enterScrollOffsetRef = useRef(0);
const scrollPositionsRef = useRef(new Map<string, number>());
const nextLayersRef = useRef<Layer[] | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [layers, setLayers] = useState<Layer[]>(() => [
{
key: location.key,
location,
status: 'current',
},
]);
const currentLayer =
layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
const currentLayerKey = currentLayer?.key ?? location.key;
const currentLayerPathname = currentLayer?.location.pathname;
const resolveScrollContainer = useCallback(() => {
if (scrollContainerRef?.current) return scrollContainerRef.current;
if (typeof document === 'undefined') return null;
return document.scrollingElement as HTMLElement | null;
}, [scrollContainerRef]);
useLayoutEffect(() => {
if (isAnimating) return;
if (location.key === currentLayerKey) return;
if (currentLayerPathname === location.pathname) return;
const scrollContainer = resolveScrollContainer();
const exitScrollOffset = scrollContainer?.scrollTop ?? 0;
exitScrollOffsetRef.current = exitScrollOffset;
scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset);
enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 0;
const resolveOrderIndex = (pathname?: string) => {
if (!getRouteOrder || !pathname) return null;
const index = getRouteOrder(pathname);
return typeof index === 'number' && index >= 0 ? index : null;
};
const fromIndex = resolveOrderIndex(currentLayerPathname);
const toIndex = resolveOrderIndex(location.pathname);
const nextDirection: TransitionDirection =
fromIndex === null || toIndex === null || fromIndex === toIndex
? 'forward'
: toIndex > fromIndex
? 'forward'
: 'backward';
transitionDirectionRef.current = nextDirection;
transitionVariantRef.current = getTransitionVariant
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
: 'vertical';
const shouldSkipExitLayer = (() => {
if (transitionVariantRef.current !== 'ios' || nextDirection !== 'backward') return false;
const normalizeSegments = (pathname: string) =>
pathname
.split('/')
.filter(Boolean)
.filter((segment) => segment.length > 0);
const fromSegments = normalizeSegments(currentLayerPathname ?? '');
const toSegments = normalizeSegments(location.pathname);
if (!fromSegments.length || !toSegments.length) return false;
return fromSegments[0] === toSegments[0] && toSegments.length === 1;
})();
setLayers((prev) => {
const variant = transitionVariantRef.current;
const direction = transitionDirectionRef.current;
const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
const resolvedCurrentIndex =
previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
const previousCurrent = prev[resolvedCurrentIndex];
const previousStack: Layer[] = prev
.filter((_, idx) => idx !== resolvedCurrentIndex)
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
if (!previousCurrent) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
if (variant === 'ios') {
if (direction === 'forward') {
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
return [...previousStack, exitingLayer, nextCurrent];
}
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
if (targetIndex !== -1) {
const targetStack: Layer[] = prev
.slice(0, targetIndex + 1)
.map((layer, idx): Layer => {
const isTarget = idx === targetIndex;
return {
...layer,
location: isTarget ? location : layer.location,
status: isTarget ? 'current' : 'stacked',
};
});
if (shouldSkipExitLayer) {
nextLayersRef.current = targetStack;
return targetStack;
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = targetStack;
return [...targetStack, exitingLayer];
}
}
if (shouldSkipExitLayer) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = [nextCurrent];
return [exitingLayer, nextCurrent];
});
setIsAnimating(true);
}, [
isAnimating,
location,
currentLayerKey,
currentLayerPathname,
getRouteOrder,
getTransitionVariant,
resolveScrollContainer,
]);
// Run GSAP animation when animating starts
useLayoutEffect(() => {
if (!isAnimating) return;
if (!currentLayerRef.current) return;
const currentLayerEl = currentLayerRef.current;
const exitingLayerEl = exitingLayerRef.current;
const transitionVariant = transitionVariantRef.current;
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
const scrollContainer = resolveScrollContainer();
const exitScrollOffset = exitScrollOffsetRef.current;
const enterScrollOffset = enterScrollOffsetRef.current;
if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
}
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({
onComplete: () => {
const nextLayers = nextLayersRef.current;
nextLayersRef.current = null;
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false);
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
},
});
if (transitionVariant === 'ios') {
const exitToXPercent = isForward
? IOS_EXIT_TO_X_PERCENT_FORWARD
: IOS_EXIT_TO_X_PERCENT_BACKWARD;
const enterFromXPercent = isForward
? IOS_ENTER_FROM_X_PERCENT
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
if (exitingLayerEl) {
gsap.set(exitingLayerEl, {
y: exitBaseY,
xPercent: 0,
opacity: 1,
});
}
gsap.set(currentLayerEl, {
xPercent: enterFromXPercent,
opacity: 1,
});
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
if (topLayerEl) {
gsap.set(topLayerEl, { boxShadow: shadowValue });
}
if (exitingLayerEl) {
tl.to(
exitingLayerEl,
{
xPercent: exitToXPercent,
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
},
0
);
}
tl.to(
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(
currentLayerEl,
{ y: enterFromY, opacity: 0 },
{
y: 0,
opacity: 1,
duration: VERTICAL_TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
onComplete: () => {
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
}
},
},
0
);
}
return () => {
tl.kill();
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
};
}, [isAnimating, resolveScrollContainer]);
return (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
{layers.map((layer) => (
<div
key={layer.key}
className={[
'page-transition__layer',
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
]
.filter(Boolean)
.join(' ')}
ref={
layer.status === 'exiting'
? exitingLayerRef
: layer.status === 'current'
? currentLayerRef
: undefined
}
>
{render(layer.location)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,84 @@
@use '../../styles/variables' as *;
.container {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
}
.topBar {
position: sticky;
top: 0;
z-index: 5;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: $spacing-md;
padding: $spacing-sm $spacing-md;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
min-height: 44px;
}
.topBarTitle {
min-width: 0;
text-align: center;
font-size: 16px;
font-weight: 650;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
justify-self: center;
}
.backButton {
padding-left: 6px;
padding-right: 10px;
justify-self: start;
gap: 0;
}
.backButton > span:last-child {
display: inline-flex;
align-items: center;
gap: 6px;
}
.backIcon {
display: inline-flex;
align-items: center;
justify-content: center;
svg {
display: block;
}
}
.backText {
font-weight: 600;
line-height: 18px;
}
.rightSlot {
justify-self: end;
display: flex;
justify-content: flex-end;
}
.loadingState {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
padding: $spacing-2xl 0;
color: var(--text-secondary);
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}

View File

@@ -0,0 +1,78 @@
import { forwardRef, type ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconChevronLeft } from '@/components/ui/icons';
import styles from './SecondaryScreenShell.module.scss';
export type SecondaryScreenShellProps = {
title: ReactNode;
onBack?: () => void;
backLabel?: string;
backAriaLabel?: string;
rightAction?: ReactNode;
isLoading?: boolean;
loadingLabel?: ReactNode;
className?: string;
contentClassName?: string;
children?: ReactNode;
};
export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenShellProps>(
function SecondaryScreenShell(
{
title,
onBack,
backLabel = 'Back',
backAriaLabel,
rightAction,
isLoading = false,
loadingLabel = 'Loading...',
className = '',
contentClassName = '',
children,
},
ref
) {
const containerClassName = [styles.container, className].filter(Boolean).join(' ');
const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' ');
const titleTooltip = typeof title === 'string' ? title : undefined;
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
return (
<div className={containerClassName} ref={ref}>
<div className={styles.topBar}>
{onBack ? (
<Button
variant="ghost"
size="sm"
onClick={onBack}
className={styles.backButton}
aria-label={resolvedBackAriaLabel}
>
<span className={styles.backIcon}>
<IconChevronLeft size={18} />
</span>
<span className={styles.backText}>{backLabel}</span>
</Button>
) : (
<div />
)}
<div className={styles.topBarTitle} title={titleTooltip}>
{title}
</div>
<div className={styles.rightSlot}>{rightAction}</div>
</div>
{isLoading ? (
<div className={styles.loadingState}>
<LoadingSpinner size={16} />
<span>{loadingLabel}</span>
</div>
) : (
<div className={contentClasses}>{children}</div>
)}
</div>
);
}
);

View File

@@ -0,0 +1,106 @@
@use 'sass:color';
@use '../../styles/variables.scss' as *;
.splash-screen {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
opacity: 1;
transition: opacity 0.4s ease-out;
&.fade-out {
opacity: 0;
pointer-events: none;
}
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-md;
animation: splash-enter 0.6s ease-out;
}
@keyframes splash-enter {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.splash-logo {
height: 80px;
width: auto;
border-radius: $radius-lg;
box-shadow: $shadow-lg;
animation: splash-logo-pulse 1.5s ease-in-out infinite;
}
@keyframes splash-logo-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.splash-title {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.5px;
}
.splash-subtitle {
font-size: 16px;
font-weight: 500;
color: var(--text-secondary);
margin: 0;
margin-top: -8px;
}
.splash-loader {
width: 120px;
height: 3px;
background: var(--border-color);
border-radius: $radius-full;
overflow: hidden;
margin-top: $spacing-md;
}
.splash-loader-bar {
width: 100%;
height: 100%;
background: var(--primary-color);
border-radius: $radius-full;
animation: splash-loading 1.2s ease-in-out infinite;
transform-origin: left;
}
@keyframes splash-loading {
0% {
transform: scaleX(0);
}
50% {
transform: scaleX(1);
transform-origin: left;
}
50.01% {
transform-origin: right;
}
100% {
transform: scaleX(0);
transform-origin: right;
}
}

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import './SplashScreen.scss';
interface SplashScreenProps {
onFinish: () => void;
fadeOut?: boolean;
}
const FADE_OUT_DURATION = 400;
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
useEffect(() => {
if (!fadeOut) return;
const finishTimer = setTimeout(() => {
onFinish();
}, FADE_OUT_DURATION);
return () => {
clearTimeout(finishTimer);
};
}, [fadeOut, onFinish]);
return (
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
<div className="splash-content">
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
<h1 className="splash-title">CLI Proxy API</h1>
<p className="splash-subtitle">Management Center</p>
<div className="splash-loader">
<div className="splash-loader-bar" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,643 @@
import {
ReactNode,
SVGProps,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { PageTransition } from '@/components/common/PageTransition';
import { MainRoutes } from '@/router/MainRoutes';
import {
IconBot,
IconChartLine,
IconFileText,
IconInfo,
IconKey,
IconLayoutDashboard,
IconScrollText,
IconSettings,
IconShield,
IconSlidersHorizontal,
IconTimer,
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import {
useAuthStore,
useConfigStore,
useLanguageStore,
useNotificationStore,
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
settings: <IconSlidersHorizontal size={18} />,
apiKeys: <IconKey size={18} />,
aiProviders: <IconBot size={18} />,
authFiles: <IconFileText size={18} />,
oauth: <IconShield size={18} />,
quota: <IconTimer size={18} />,
usage: <IconChartLine size={18} />,
config: <IconSettings size={18} />,
logs: <IconScrollText size={18} />,
system: <IconInfo size={18} />,
};
// Header action icons - smaller size for header buttons
const headerIconProps: SVGProps<SVGSVGElement> = {
width: 16,
height: 16,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
focusable: 'false',
};
const headerIcons = {
refresh: (
<svg {...headerIconProps}>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
),
update: (
<svg {...headerIconProps}>
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
</svg>
),
menu: (
<svg {...headerIconProps}>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
</svg>
),
chevronLeft: (
<svg {...headerIconProps}>
<path d="m14 18-6-6 6-6" />
</svg>
),
chevronRight: (
<svg {...headerIconProps}>
<path d="m10 6 6 6-6 6" />
</svg>
),
language: (
<svg {...headerIconProps}>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
),
sun: (
<svg {...headerIconProps}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
),
moon: (
<svg {...headerIconProps}>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
</svg>
),
autoTheme: (
<svg {...headerIconProps}>
<defs>
<clipPath id="mainLayoutAutoThemeSunLeftHalf">
<rect x="0" y="0" width="12" height="24" />
</clipPath>
</defs>
<circle cx="12" cy="12" r="4" />
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="M4.93 4.93l1.41 1.41" />
<path d="M17.66 17.66l1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="M6.34 17.66l-1.41 1.41" />
<path d="M19.07 4.93l-1.41 1.41" />
</svg>
),
logout: (
<svg {...headerIconProps}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
),
};
const parseVersionSegments = (version?: string | null) => {
if (!version) return null;
const cleaned = version.trim().replace(/^v/i, '');
if (!cleaned) return null;
const parts = cleaned
.split(/[^0-9]+/)
.filter(Boolean)
.map((segment) => Number.parseInt(segment, 10))
.filter(Number.isFinite);
return parts.length ? parts : null;
};
const compareVersions = (latest?: string | null, current?: string | null) => {
const latestParts = parseVersionSegments(latest);
const currentParts = parseVersionSegments(current);
if (!latestParts || !currentParts) return null;
const length = Math.max(latestParts.length, currentParts.length);
for (let i = 0; i < length; i++) {
const l = latestParts[i] || 0;
const c = currentParts[i] || 0;
if (l > c) return 1;
if (l < c) return -1;
}
return 0;
};
export function MainLayout() {
const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion);
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme);
const cycleTheme = useThemeStore((state) => state.cycleTheme);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullBrandName = 'CLI Proxy API Management Center';
const abbrBrandName = t('title.abbr');
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
const isLogsPage = location.pathname.startsWith('/logs');
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
useLayoutEffect(() => {
const updateHeaderHeight = () => {
const height = headerRef.current?.offsetHeight;
if (height) {
document.documentElement.style.setProperty('--header-height', `${height}px`);
}
};
updateHeaderHeight();
const resizeObserver =
typeof ResizeObserver !== 'undefined' && headerRef.current
? new ResizeObserver(updateHeaderHeight)
: null;
if (resizeObserver && headerRef.current) {
resizeObserver.observe(headerRef.current);
}
window.addEventListener('resize', updateHeaderHeight);
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
window.removeEventListener('resize', updateHeaderHeight);
};
}, []);
// 5秒后自动收起品牌名称
useEffect(() => {
brandCollapseTimer.current = setTimeout(() => {
setBrandExpanded(false);
}, 5000);
return () => {
if (brandCollapseTimer.current) {
clearTimeout(brandCollapseTimer.current);
}
};
}, []);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
};
}, []);
const handleBrandClick = useCallback(() => {
if (!brandExpanded) {
setBrandExpanded(true);
// 点击展开后5秒后再次收起
if (brandCollapseTimer.current) {
clearTimeout(brandCollapseTimer.current);
}
brandCollapseTimer.current = setTimeout(() => {
setBrandExpanded(false);
}, 5000);
}
}, [brandExpanded]);
const openRequestLogModal = useCallback(() => {
setRequestLogTouched(false);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
}, []);
const handleVersionTap = useCallback(() => {
versionTapCount.current += 1;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
}, 1500);
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
versionTapTimer.current = null;
}
openRequestLogModal();
}
}, [openRequestLogModal]);
const handleRequestLogSave = async () => {
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: any) {
updateConfigValue('request-log', previous);
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
} finally {
setRequestLogSaving(false);
}
};
useEffect(() => {
fetchConfig().catch(() => {
// ignore initial failure; login flow会提示
});
}, [fetchConfig]);
const statusClass =
connectionStatus === 'connected'
? 'success'
: connectionStatus === 'connecting'
? 'warning'
: connectionStatus === 'error'
? 'error'
: 'muted';
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
];
const navOrder = navItems.map((item) => item.path);
const getRouteOrder = (pathname: string) => {
const trimmedPath =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
const aiProvidersIndex = navOrder.indexOf('/ai-providers');
if (aiProvidersIndex !== -1) {
if (normalizedPath === '/ai-providers') return aiProvidersIndex;
if (normalizedPath.startsWith('/ai-providers/')) {
if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1;
if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2;
if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3;
if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4;
if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5;
if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6;
return aiProvidersIndex + 0.05;
}
}
const authFilesIndex = navOrder.indexOf('/auth-files');
if (authFilesIndex !== -1) {
if (normalizedPath === '/auth-files') return authFilesIndex;
if (normalizedPath.startsWith('/auth-files/')) {
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
return authFilesIndex + 0.05;
}
}
const exactIndex = navOrder.indexOf(normalizedPath);
if (exactIndex !== -1) return exactIndex;
const nestedIndex = navOrder.findIndex(
(path) => path !== '/' && normalizedPath.startsWith(`${path}/`)
);
return nestedIndex === -1 ? null : nestedIndex;
};
const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
const normalize = (pathname: string) => {
const trimmed =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
return trimmed === '/dashboard' ? '/' : trimmed;
};
const from = normalize(fromPathname);
const to = normalize(toPathname);
const isAuthFiles = (pathname: string) =>
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
const isAiProviders = (pathname: string) =>
pathname === '/ai-providers' || pathname.startsWith('/ai-providers/');
if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
if (isAiProviders(from) && isAiProviders(to)) return 'ios';
return 'vertical';
}, []);
const handleRefreshAll = async () => {
clearCache();
const results = await Promise.allSettled([
fetchConfig(undefined, true),
triggerHeaderRefresh()
]);
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 () => {
setCheckingVersion(true);
try {
const data = await versionApi.checkLatest();
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const comparison = compareVersions(latest, serverVersion);
if (!latest) {
showNotification(t('system_info.version_check_error'), 'error');
return;
}
if (comparison === null) {
showNotification(t('system_info.version_current_missing'), 'warning');
return;
}
if (comparison > 0) {
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
} else {
showNotification(t('system_info.version_is_latest'), 'success');
}
} catch (error: any) {
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
} finally {
setCheckingVersion(false);
}
};
return (
<div className="app-shell">
<header className="main-header" ref={headerRef}>
<div className="left">
<button
className="sidebar-toggle-header"
onClick={() => setSidebarCollapsed((prev) => !prev)}
title={
sidebarCollapsed
? t('sidebar.expand', { defaultValue: '展开' })
: t('sidebar.collapse', { defaultValue: '收起' })
}
>
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
</button>
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="brand-logo" />
<div
className={`brand-header ${brandExpanded ? 'expanded' : 'collapsed'}`}
onClick={handleBrandClick}
title={brandExpanded ? undefined : fullBrandName}
>
<span className="brand-full">{fullBrandName}</span>
<span className="brand-abbr">{abbrBrandName}</span>
</div>
</div>
<div className="right">
<div className="connection">
<span className={`status-badge ${statusClass}`}>
{t(
connectionStatus === 'connected'
? 'common.connected_status'
: connectionStatus === 'connecting'
? 'common.connecting_status'
: 'common.disconnected_status'
)}
</span>
<span className="base">{apiBase || '-'}</span>
</div>
<div className="header-actions">
<Button
className="mobile-menu-btn"
variant="ghost"
size="sm"
onClick={() => setSidebarOpen((prev) => !prev)}
>
{headerIcons.menu}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRefreshAll}
title={t('header.refresh_all')}
>
{headerIcons.refresh}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleVersionCheck}
loading={checkingVersion}
title={t('system_info.version_check_button')}
>
{headerIcons.update}
</Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
{headerIcons.language}
</Button>
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: headerIcons.sun}
</Button>
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
{headerIcons.logout}
</Button>
</div>
</div>
</header>
<div className="main-body">
<aside
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
>
<div className="nav-section">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
onClick={() => setSidebarOpen(false)}
title={sidebarCollapsed ? item.label : undefined}
>
<span className="nav-icon">{item.icon}</span>
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
</NavLink>
))}
</div>
</aside>
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
<PageTransition
render={(location) => <MainRoutes location={location} />}
getRouteOrder={getRouteOrder}
getTransitionVariant={getTransitionVariant}
scrollContainerRef={contentRef}
/>
</main>
<footer className="footer">
<span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span>
<span className="footer-version" onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span>
<span>
{t('footer.build_date')}:{' '}
{serverBuildDate
? new Date(serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown')}
</span>
</footer>
</div>
</div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,281 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useConfigStore, useNotificationStore } from '@/stores';
import { ampcodeApi } from '@/services/api';
import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
import type { AmpcodeFormState } from '../types';
interface AmpcodeModalProps {
isOpen: boolean;
disableControls: boolean;
onClose: () => void;
onBusyChange?: (busy: boolean) => void;
}
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
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 getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
useEffect(() => {
onBusyChange?.(loading || saving);
}, [loading, saving, onBusyChange]);
useEffect(() => {
if (!isOpen) {
initializedRef.current = false;
setLoading(false);
setSaving(false);
setError('');
setLoaded(false);
setMappingsDirty(false);
setForm(buildAmpcodeFormState(null));
onBusyChange?.(false);
return;
}
if (initializedRef.current) return;
initializedRef.current = true;
setLoading(true);
setLoaded(false);
setMappingsDirty(false);
setError('');
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
void (async () => {
try {
const ampcode = await ampcodeApi.getAmpcode();
setLoaded(true);
updateConfigValue('ampcode', ampcode);
clearCache('ampcode');
setForm(buildAmpcodeFormState(ampcode));
} catch (err: unknown) {
setError(getErrorMessage(err) || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
})();
}, [clearCache, config?.ampcode, isOpen, onBusyChange, 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');
onClose();
} 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', // Not dangerous, just a warning
confirmText: t('common.confirm'),
onConfirm: performSaveAmpcode,
});
return;
}
await performSaveAmpcode();
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={t('ai_providers.ampcode_modal_title')}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
{t('common.save')}
</Button>
</>
}
>
{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}
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}
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={clearAmpcodeUpstreamApiKey}
disabled={loading || saving || !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}
/>
<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}
/>
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,94 @@
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import iconAmp from '@/assets/icons/amp.svg';
import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import styles from '@/pages/AiProvidersPage.module.scss';
import { useTranslation } from 'react-i18next';
interface AmpcodeSectionProps {
config: AmpcodeConfig | null | undefined;
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
onEdit: () => void;
}
export function AmpcodeSection({
config,
loading,
disableControls,
isSwitching,
onEdit,
}: AmpcodeSectionProps) {
const { t } = useTranslation();
const showLoadingPlaceholder = loading && !config;
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img src={iconAmp} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.ampcode_title')}
</span>
}
extra={
<Button
size="sm"
onClick={onEdit}
disabled={disableControls || loading || isSwitching}
>
{t('common.edit')}
</Button>
}
>
{showLoadingPlaceholder ? (
<div className="hint">{t('common.loading')}</div>
) : (
<>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_url_label')}:</span>
<span className={styles.fieldValue}>{config?.upstreamUrl || t('common.not_set')}</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>
{t('ai_providers.ampcode_upstream_api_key_label')}:
</span>
<span className={styles.fieldValue}>
{config?.upstreamApiKey ? maskApiKey(config.upstreamApiKey) : t('common.not_set')}
</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>
{t('ai_providers.ampcode_force_model_mappings_label')}:
</span>
<span className={styles.fieldValue}>
{(config?.forceModelMappings ?? false) ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.fieldRow} style={{ marginTop: 8 }}>
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
<span className={styles.fieldValue}>{config?.modelMappings?.length || 0}</span>
</div>
{config?.modelMappings?.length ? (
<div className={styles.modelTagList}>
{config.modelMappings.slice(0, 5).map((mapping) => (
<span key={`${mapping.from}${mapping.to}`} className={styles.modelTag}>
<span className={styles.modelName}>{mapping.from}</span>
<span className={styles.modelAlias}>{mapping.to}</span>
</span>
))}
{config.modelMappings.length > 5 && (
<span className={styles.modelTag}>
<span className={styles.modelName}>+{config.modelMappings.length - 5}</span>
</span>
)}
</div>
) : null}
</>
)}
</Card>
</>
);
}

View File

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

View File

@@ -0,0 +1,129 @@
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 { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
export function ClaudeModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: ClaudeModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<ProviderFormState>(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),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.claude_edit_modal_title')
: t('ai_providers.claude_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.claude_add_modal_key_label')}
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.claude_add_modal_url_label')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.claude_add_modal_proxy_label')}
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.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={isSaving}
/>
</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}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,191 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconClaude from '@/assets/icons/claude.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, hasDisableAllModelsRule } from '../utils';
interface ClaudeSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void;
}
export function ClaudeSection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSwitching,
onAdd,
onEdit,
onDelete,
onToggle,
}: ClaudeSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = 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={iconClaude} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.claude_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.claude_add_button')}
</Button>
}
>
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.claude_empty_title')}
emptyDescription={t('ai_providers.claude_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
<ToggleSwitch
label={t('ai_providers.config_toggle_label')}
checked={!hasDisableAllModelsRule(item.excludedModels)}
disabled={toggleDisabled}
onChange={(value) => void onToggle(index, value)}
/>
)}
renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
return (
<Fragment>
<div className="item-title">{t('ai_providers.claude_item_title')}</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>
)}
{configDisabled && (
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
{t('ai_providers.config_disabled_badge')}
</div>
)}
{item.models?.length ? (
<div className={styles.modelTagList}>
<span className={styles.modelCountLabel}>
{t('ai_providers.claude_models_count')}: {item.models.length}
</span>
{item.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{excludedModels.length ? (
<div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}>
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
</div>
<div className={styles.modelTagList}>
{excludedModels.map((model) => (
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
<span className={styles.modelName}>{model}</span>
</span>
))}
</div>
</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 { ClaudeSection } from './ClaudeSection';

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
export function CodexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: CodexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<ProviderFormState>(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),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.codex_edit_modal_title')
: t('ai_providers.codex_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.codex_add_modal_key_label')}
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.codex_add_modal_url_label')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.codex_add_modal_proxy_label')}
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.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}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,183 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconCodexDark from '@/assets/icons/codex_drak.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
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, hasDisableAllModelsRule } from '../utils';
interface CodexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
resolvedTheme: string;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void;
}
export function CodexSection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSwitching,
resolvedTheme,
onAdd,
onEdit,
onDelete,
onToggle,
}: CodexSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = 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={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
alt=""
className={styles.cardTitleIcon}
/>
{t('ai_providers.codex_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.codex_add_button')}
</Button>
}
>
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.codex_empty_title')}
emptyDescription={t('ai_providers.codex_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
<ToggleSwitch
label={t('ai_providers.config_toggle_label')}
checked={!hasDisableAllModelsRule(item.excludedModels)}
disabled={toggleDisabled}
onChange={(value) => void onToggle(index, value)}
/>
)}
renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
return (
<Fragment>
<div className="item-title">{t('ai_providers.codex_item_title')}</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>
)}
{configDisabled && (
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
{t('ai_providers.config_disabled_badge')}
</div>
)}
{excludedModels.length ? (
<div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}>
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
</div>
<div className={styles.modelTagList}>
{excludedModels.map((model) => (
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
<span className={styles.modelName}>{model}</span>
</span>
))}
</div>
</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 { CodexSection } from './CodexSection';

View File

@@ -0,0 +1,113 @@
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 type { GeminiKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { GeminiFormState, ProviderModalProps } from '../types';
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): GeminiFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
headers: [],
excludedModels: [],
excludedText: '',
});
export function GeminiModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: GeminiModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
const handleSave = () => {
void onSave(form, editIndex);
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.gemini_edit_modal_title')
: t('ai_providers.gemini_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<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 }))}
/>
<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.gemini_base_url_label')}
placeholder={t('ai_providers.gemini_base_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: 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.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}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,172 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconGemini from '@/assets/icons/gemini.svg';
import type { GeminiKeyConfig } 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, hasDisableAllModelsRule } from '../utils';
interface GeminiSectionProps {
configs: GeminiKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void;
}
export function GeminiSection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSwitching,
onAdd,
onEdit,
onDelete,
onToggle,
}: GeminiSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = 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={iconGemini} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.gemini_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.gemini_add_button')}
</Button>
}
>
<ProviderList<GeminiKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.gemini_empty_title')}
emptyDescription={t('ai_providers.gemini_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
<ToggleSwitch
label={t('ai_providers.config_toggle_label')}
checked={!hasDisableAllModelsRule(item.excludedModels)}
disabled={toggleDisabled}
onChange={(value) => void onToggle(index, value)}
/>
)}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
return (
<Fragment>
<div className="item-title">
{t('ai_providers.gemini_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>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{configDisabled && (
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
{t('ai_providers.config_disabled_badge')}
</div>
)}
{excludedModels.length ? (
<div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}>
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
</div>
<div className={styles.modelTagList}>
{excludedModels.map((model) => (
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
<span className={styles.modelName}>{model}</span>
</span>
))}
</div>
</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 { GeminiSection } from './GeminiSection';

View File

@@ -0,0 +1,194 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { modelsApi } from '@/services/api';
import type { ApiKeyEntry } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
import { buildOpenAIModelsEndpoint } from '../utils';
import styles from '@/pages/AiProvidersPage.module.scss';
interface OpenAIDiscoveryModalProps {
isOpen: boolean;
baseUrl: string;
headers: HeaderEntry[];
apiKeyEntries: ApiKeyEntry[];
onClose: () => void;
onApply: (selected: ModelInfo[]) => void;
}
export function OpenAIDiscoveryModal({
isOpen,
baseUrl,
headers,
apiKeyEntries,
onClose,
onApply,
}: OpenAIDiscoveryModalProps) {
const { t } = useTranslation();
const [endpoint, setEndpoint] = useState('');
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
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 = baseUrl.trim();
if (!trimmedBaseUrl) return;
setLoading(true);
setError('');
try {
const headerObject = buildHeaderObject(headers);
const firstKey = 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 {
setLoading(false);
}
},
[apiKeyEntries, baseUrl, headers, t]
);
useEffect(() => {
if (!isOpen) return;
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
setModels([]);
setSearch('');
setSelected(new Set());
setError('');
void fetchOpenaiModelDiscovery();
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
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));
onApply(selectedModels);
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={t('ai_providers.openai_models_fetch_title')}
width={720}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={loading}>
{t('ai_providers.openai_models_fetch_back')}
</Button>
<Button onClick={handleApply} disabled={loading}>
{t('ai_providers.openai_models_fetch_apply')}
</Button>
</>
}
>
<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={loading}
>
{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)}
/>
{error && <div className="error-box">{error}</div>}
{loading ? (
<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>
)}
</Modal>
);
}

View File

@@ -0,0 +1,433 @@
import { useEffect, useMemo, 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 { useNotificationStore } from '@/stores';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import type { ModelInfo } from '@/utils/models';
import styles from '@/pages/AiProvidersPage.module.scss';
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
const OPENAI_TEST_TIMEOUT_MS = 30_000;
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): OpenAIFormState => ({
name: '',
prefix: '',
baseUrl: '',
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
modelEntries: [{ name: '', alias: '' }],
testModel: undefined,
});
export function OpenAIModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: OpenAIModalProps) {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
const [discoveryOpen, setDiscoveryOpen] = useState(false);
const [testModel, setTestModel] = useState('');
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
const availableModels = useMemo(
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
[form.modelEntries]
);
useEffect(() => {
if (!isOpen) {
setDiscoveryOpen(false);
return;
}
if (initialData) {
const modelEntries = modelsToEntries(initialData.models);
setForm({
name: initialData.name,
prefix: initialData.prefix ?? '',
baseUrl: initialData.baseUrl,
headers: headersToEntries(initialData.headers),
testModel: initialData.testModel,
modelEntries,
apiKeyEntries: initialData.apiKeyEntries?.length
? initialData.apiKeyEntries
: [buildApiKeyEntry()],
});
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
const initialModel =
initialData.testModel && available.includes(initialData.testModel)
? initialData.testModel
: available[0] || '';
setTestModel(initialModel);
} else {
setForm(buildEmptyForm());
setTestModel('');
}
setTestStatus('idle');
setTestMessage('');
setDiscoveryOpen(false);
}, [initialData, isOpen]);
useEffect(() => {
if (!isOpen) return;
if (availableModels.length === 0) {
if (testModel) {
setTestModel('');
setTestStatus('idle');
setTestMessage('');
}
return;
}
if (!testModel || !availableModels.includes(testModel)) {
setTestModel(availableModels[0]);
setTestStatus('idle');
setTestMessage('');
}
}, [availableModels, isOpen, testModel]);
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)}
/>
<Input
label={t('common.proxy_url')}
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
/>
</div>
<div className="item-actions">
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={list.length <= 1 || isSaving}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
{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;
}
setDiscoveryOpen(true);
};
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
if (!selectedModels.length) {
setDiscoveryOpen(false);
return;
}
const mergedMap = new Map<string, ModelEntry>();
form.modelEntries.forEach((entry) => {
const name = entry.name.trim();
if (!name) return;
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
});
let addedCount = 0;
selectedModels.forEach((model) => {
const name = model.name.trim();
if (!name || mergedMap.has(name)) return;
mergedMap.set(name, { name, alias: model.alias ?? '' });
addedCount += 1;
});
const mergedEntries = Array.from(mergedMap.values());
setForm((prev) => ({
...prev,
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
}));
setDiscoveryOpen(false);
if (addedCount > 0) {
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
}
};
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 (
<>
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.openai_edit_modal_title')
: t('ai_providers.openai_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.openai_add_modal_name_label')}
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: 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.openai_add_modal_url_label')}
value={form.baseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: 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>
{editIndex !== null
? 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={isSaving}
/>
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
{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={isSaving || 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={testOpenaiProviderConnection}
loading={testStatus === 'loading'}
disabled={isSaving || 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>
</Modal>
<OpenAIDiscoveryModal
isOpen={discoveryOpen}
baseUrl={form.baseUrl}
headers={form.headers}
apiKeyEntries={form.apiKeyEntries}
onClose={() => setDiscoveryOpen(false)}
onApply={applyOpenaiModelDiscoverySelection}
/>
</>
);
}

View File

@@ -0,0 +1,195 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { IconCheck, IconX } from '@/components/ui/icons';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import type { OpenAIProviderConfig } 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 { getOpenAIProviderStats, getStatsBySource } from '../utils';
interface OpenAISectionProps {
configs: OpenAIProviderConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
resolvedTheme: string;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
}
export function OpenAISection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSwitching,
resolvedTheme,
onAdd,
onEdit,
onDelete,
}: OpenAISectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((provider) => {
const sourceIds = new Set<string>();
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));
});
return cache;
}, [configs, usageDetails]);
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
/>
{t('ai_providers.openai_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.openai_add_button')}
</Button>
}
>
<ProviderList<OpenAIProviderConfig>
items={configs}
loading={loading}
keyField={(item) => item.name}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item) => {
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
const headerEntries = Object.entries(item.headers || {});
const apiKeyEntries = item.apiKeyEntries || [];
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
return (
<Fragment>
<div className="item-title">{item.name}</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</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>
)}
{apiKeyEntries.length > 0 && (
<div className={styles.apiKeyEntriesSection}>
<div className={styles.apiKeyEntriesLabel}>
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsBySource(entry.apiKey, keyStats);
return (
<div key={entryIndex} className={styles.apiKeyEntryCard}>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
{entry.proxyUrl && (
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
)}
<div className={styles.apiKeyEntryStats}>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
>
<IconCheck size={12} /> {entryStats.success}
</span>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
>
<IconX size={12} /> {entryStats.failure}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
<span className={styles.fieldValue}>{item.models?.length || 0}</span>
</div>
{item.models?.length ? (
<div className={styles.modelTagList}>
{item.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{item.testModel && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Test Model:</span>
<span className={styles.fieldValue}>{item.testModel}</span>
</div>
)}
<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 { OpenAISection } from './OpenAISection';

View File

@@ -0,0 +1,80 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
interface ProviderListProps<T> {
items: T[];
loading: boolean;
keyField: (item: T) => string;
renderContent: (item: T, index: number) => ReactNode;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
emptyTitle: string;
emptyDescription: string;
deleteLabel?: string;
actionsDisabled?: boolean;
getRowDisabled?: (item: T, index: number) => boolean;
renderExtraActions?: (item: T, index: number) => ReactNode;
}
export function ProviderList<T>({
items,
loading,
keyField,
renderContent,
onEdit,
onDelete,
emptyTitle,
emptyDescription,
deleteLabel,
actionsDisabled = false,
getRowDisabled,
renderExtraActions,
}: ProviderListProps<T>) {
const { t } = useTranslation();
if (loading && items.length === 0) {
return <div className="hint">{t('common.loading')}</div>;
}
if (!items.length) {
return <EmptyState title={emptyTitle} description={emptyDescription} />;
}
return (
<div className="item-list">
{items.map((item, index) => {
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
return (
<div
key={keyField(item)}
className="item-row"
style={rowDisabled ? { opacity: 0.6 } : undefined}
>
<div className="item-meta">{renderContent(item, index)}</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => onEdit(index)}
disabled={actionsDisabled}
>
{t('common.edit')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(index)}
disabled={actionsDisabled}
>
{deleteLabel || t('common.delete')}
</Button>
{renderExtraActions ? renderExtraActions(item, index) : null}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,83 @@
@use '../../../styles/variables' as *;
.navContainer {
position: fixed;
right: 24px;
top: 50%;
transform: translateY(-50%);
z-index: 50;
pointer-events: auto;
}
.navList {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 8px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
}
.navItem {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
background: transparent;
border-radius: 10px;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.15s ease;
&:hover {
background: rgba(0, 0, 0, 0.06);
transform: scale(1.08);
}
&:active {
transform: scale(0.95);
}
}
.icon {
width: 28px;
height: 28px;
object-fit: contain;
}
.active {
background: rgba(59, 130, 246, 0.15);
box-shadow: inset 0 0 0 2px var(--primary-color);
}
// 暗色主题适配
:global([data-theme='dark']) {
.navList {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.navItem {
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
.active {
background: rgba(59, 130, 246, 0.25);
}
}
// 小屏幕隐藏悬浮导航
@media (max-width: 1200px) {
.navContainer {
display: none;
}
}

View File

@@ -0,0 +1,129 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useThemeStore } from '@/stores';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconCodexDark from '@/assets/icons/codex_drak.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import iconAmp from '@/assets/icons/amp.svg';
import styles from './ProviderNav.module.scss';
export type ProviderId = 'gemini' | 'codex' | 'claude' | 'vertex' | 'ampcode' | 'openai';
interface ProviderNavItem {
id: ProviderId;
label: string;
getIcon: (theme: string) => string;
}
const PROVIDERS: ProviderNavItem[] = [
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
{ id: 'openai', label: 'OpenAI', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) },
];
const HEADER_OFFSET = 24;
export function ProviderNav() {
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const getScrollContainer = useCallback(() => {
if (scrollContainerRef.current) return scrollContainerRef.current;
const container = document.querySelector('.content') as HTMLElement | null;
scrollContainerRef.current = container;
return container;
}, []);
const handleScroll = useCallback(() => {
const container = getScrollContainer();
if (!container) return;
const containerRect = container.getBoundingClientRect();
const activationLine = containerRect.top + HEADER_OFFSET + 1;
let currentActive: ProviderId | null = null;
for (const provider of PROVIDERS) {
const element = document.getElementById(`provider-${provider.id}`);
if (!element) continue;
const rect = element.getBoundingClientRect();
if (rect.top <= activationLine) {
currentActive = provider.id;
continue;
}
if (currentActive) break;
}
if (!currentActive) {
const firstVisible = PROVIDERS.find((provider) =>
document.getElementById(`provider-${provider.id}`)
);
currentActive = firstVisible?.id ?? null;
}
setActiveProvider(currentActive);
}, [getScrollContainer]);
useEffect(() => {
const container = getScrollContainer();
if (!container) return;
container.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll, getScrollContainer]);
const scrollToProvider = (providerId: ProviderId) => {
const container = getScrollContainer();
const element = document.getElementById(`provider-${providerId}`);
if (!element || !container) return;
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
setActiveProvider(providerId);
container.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
};
const navContent = (
<div className={styles.navContainer}>
<div className={styles.navList}>
{PROVIDERS.map((provider) => {
const isActive = activeProvider === provider.id;
return (
<button
key={provider.id}
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
onClick={() => scrollToProvider(provider.id)}
title={provider.label}
type="button"
>
<img
src={provider.getIcon(resolvedTheme)}
alt={provider.label}
className={styles.icon}
/>
</button>
);
})}
</div>
</div>
);
if (typeof document === 'undefined') return null;
return createPortal(navContent, document.body);
}

View File

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

View File

@@ -0,0 +1,38 @@
import { calculateStatusBarData } from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
interface ProviderStatusBarProps {
statusData: ReturnType<typeof calculateStatusBarData>;
}
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
);
}

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

@@ -0,0 +1,37 @@
import { useCallback, useRef, useState } from 'react';
import { useInterval } from '@/hooks/useInterval';
import { usageApi } from '@/services/api';
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
const EMPTY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
export const useProviderStats = () => {
const [keyStats, setKeyStats] = useState<KeyStats>(EMPTY_STATS);
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const [isLoading, setIsLoading] = useState(false);
const loadingRef = useRef(false);
// 加载 key 统计和 usage 明细API 层已有60秒超时
const loadKeyStats = useCallback(async () => {
if (loadingRef.current) return;
loadingRef.current = true;
setIsLoading(true);
try {
const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats);
setUsageDetails(collectUsageDetails(usageData));
} catch {
// 静默失败
} finally {
loadingRef.current = false;
setIsLoading(false);
}
}, []);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
return { keyStats, usageDetails, loadKeyStats, isLoading };
};

View File

@@ -0,0 +1,12 @@
export { AmpcodeSection } from './AmpcodeSection';
export { ClaudeSection } from './ClaudeSection';
export { CodexSection } from './CodexSection';
export { GeminiSection } from './GeminiSection';
export { OpenAISection } from './OpenAISection';
export { VertexSection } from './VertexSection';
export { ProviderList } from './ProviderList';
export { ProviderStatusBar } from './ProviderStatusBar';
export { ProviderNav } from './ProviderNav';
export * from './hooks/useProviderStats';
export * from './types';
export * from './utils';

View File

@@ -0,0 +1,69 @@
import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
import type { HeaderEntry } from '@/utils/headers';
import type { KeyStats, UsageDetail } from '@/utils/usage';
export type ProviderModal =
| { type: 'gemini'; index: number | null }
| { type: 'codex'; index: number | null }
| { type: 'claude'; index: number | null }
| { type: 'vertex'; index: number | null }
| { type: 'ampcode'; index: null }
| { type: 'openai'; index: number | null };
export interface ModelEntry {
name: string;
alias: string;
}
export interface OpenAIFormState {
name: string;
prefix: string;
baseUrl: string;
headers: HeaderEntry[];
testModel?: string;
modelEntries: ModelEntry[];
apiKeyEntries: ApiKeyEntry[];
}
export interface AmpcodeFormState {
upstreamUrl: string;
upstreamApiKey: string;
forceModelMappings: boolean;
mappingEntries: ModelEntry[];
}
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
headers: HeaderEntry[];
excludedText: string;
};
export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[];
excludedText: string;
};
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[];
};
export interface ProviderSectionProps<TConfig> {
configs: TConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
disabled: boolean;
onEdit: (index: number) => void;
onAdd: () => void;
onDelete: (index: number) => void;
onToggle?: (index: number, enabled: boolean) => void;
}
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
isOpen: boolean;
editIndex: number | null;
initialData?: TConfig;
onClose: () => void;
onSave: (data: TPayload, index: number | null) => Promise<void>;
disabled?: boolean;
}

View File

@@ -0,0 +1,149 @@
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
import type { AmpcodeFormState, ModelEntry } from './types';
export const DISABLE_ALL_MODELS_RULE = '*';
export const hasDisableAllModelsRule = (models?: string[]) =>
Array.isArray(models) &&
models.some((model) => String(model ?? '').trim() === DISABLE_ALL_MODELS_RULE);
export const stripDisableAllModelsRule = (models?: string[]) =>
Array.isArray(models)
? models.filter((model) => String(model ?? '').trim() !== DISABLE_ALL_MODELS_RULE)
: [];
export const withDisableAllModelsRule = (models?: string[]) => {
const base = stripDisableAllModelsRule(models);
return [...base, DISABLE_ALL_MODELS_RULE];
};
export const withoutDisableAllModelsRule = (models?: string[]) => {
const base = stripDisableAllModelsRule(models);
return base;
};
export const parseExcludedModels = (text: string): string[] =>
text
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
export const excludedModelsToText = (models?: string[]) =>
Array.isArray(models) ? models.join('\n') : '';
export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
let trimmed = String(baseUrl || '').trim();
if (!trimmed) return '';
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
trimmed = trimmed.replace(/\/+$/g, '');
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `http://${trimmed}`;
}
return trimmed;
};
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return '';
return `${trimmed}/models`;
};
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return '';
if (trimmed.endsWith('/chat/completions')) {
return trimmed;
}
return `${trimmed}/chat/completions`;
};
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
export const getStatsBySource = (
apiKey: string,
keyStats: KeyStats,
prefix?: string
): KeyStatBucket => {
const bySource = keyStats.bySource ?? {};
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
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 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = (
apiKeyEntries: ApiKeyEntry[] | undefined,
keyStats: KeyStats,
providerPrefix?: string
): KeyStatBucket => {
const bySource = keyStats.bySource ?? {};
const sourceIds = new Set<string>();
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
(apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
});
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 => ({
apiKey: input?.apiKey ?? '',
proxyUrl: input?.proxyUrl ?? '',
headers: input?.headers ?? {},
});
export const ampcodeMappingsToEntries = (mappings?: AmpcodeModelMapping[]): ModelEntry[] => {
if (!Array.isArray(mappings) || mappings.length === 0) {
return [{ name: '', alias: '' }];
}
return mappings.map((mapping) => ({
name: mapping.from ?? '',
alias: mapping.to ?? '',
}));
};
export const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[] => {
const seen = new Set<string>();
const mappings: AmpcodeModelMapping[] = [];
entries.forEach((entry) => {
const from = entry.name.trim();
const to = entry.alias.trim();
if (!from || !to) return;
const key = from.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
mappings.push({ from, to });
});
return mappings;
};
export const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
upstreamUrl: ampcode?.upstreamUrl ?? '',
upstreamApiKey: '',
forceModelMappings: ampcode?.forceModelMappings ?? false,
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
});

View File

@@ -0,0 +1,145 @@
/**
* Generic quota card component.
*/
import { useTranslation } from 'react-i18next';
import type { ReactElement, ReactNode } from 'react';
import type { TFunction } from 'i18next';
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
import { TYPE_COLORS } from '@/utils/quota';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
export interface QuotaStatusState {
status: QuotaStatus;
error?: string;
errorStatus?: number;
}
export interface QuotaProgressBarProps {
percent: number | null;
highThreshold: number;
mediumThreshold: number;
}
export function QuotaProgressBar({
percent,
highThreshold,
mediumThreshold
}: QuotaProgressBarProps) {
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const normalized = percent === null ? null : clamp(percent, 0, 100);
const fillClass =
normalized === null
? styles.quotaBarFillMedium
: normalized >= highThreshold
? styles.quotaBarFillHigh
: normalized >= mediumThreshold
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
const widthPercent = Math.round(normalized ?? 0);
return (
<div className={styles.quotaBar}>
<div
className={`${styles.quotaBarFill} ${fillClass}`}
style={{ width: `${widthPercent}%` }}
/>
</div>
);
}
export interface QuotaRenderHelpers {
styles: typeof styles;
QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement;
}
interface QuotaCardProps<TState extends QuotaStatusState> {
item: AuthFileItem;
quota?: TState;
resolvedTheme: ResolvedTheme;
i18nPrefix: string;
cardClassName: string;
defaultType: string;
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
export function QuotaCard<TState extends QuotaStatusState>({
item,
quota,
resolvedTheme,
i18nPrefix,
cardClassName,
defaultType,
renderQuotaItems
}: QuotaCardProps<TState>) {
const { t } = useTranslation();
const displayType = item.type || item.provider || defaultType;
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
const typeColor: ThemeColors =
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
const quotaStatus = quota?.status ?? 'idle';
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const getTypeLabel = (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);
};
return (
<div className={`${styles.fileCard} ${cardClassName}`}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(displayType)}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${i18nPrefix}.load_failed`, {
message: quotaErrorMessage
})}
</div>
) : quota ? (
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
) : (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
)}
</div>
</div>
);
}
const resolveQuotaErrorMessage = (
t: TFunction,
status: number | undefined,
fallback: string
): string => {
if (status === 404) return t('common.quota_update_required');
if (status === 403) return t('common.quota_check_credential');
return fallback;
};

View File

@@ -0,0 +1,321 @@
/**
* Generic quota section component.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types';
import { QuotaCard } from './QuotaCard';
import type { QuotaStatusState } from './QuotaCard';
import { useQuotaLoader } from './useQuotaLoader';
import type { QuotaConfig } from './quotaConfigs';
import { useGridColumns } from './useGridColumns';
import { IconRefreshCw } from '@/components/ui/icons';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
type ViewMode = 'paged' | 'all';
const MAX_ITEMS_PER_PAGE = 14;
const MAX_SHOW_ALL_THRESHOLD = 30;
interface QuotaPaginationState<T> {
pageSize: number;
totalPages: number;
currentPage: number;
pageItems: T[];
setPageSize: (size: number) => void;
goToPrev: () => void;
goToNext: () => void;
loading: boolean;
loadingScope: 'page' | 'all' | null;
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
}
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
const [page, setPage] = useState(1);
const [pageSize, setPageSizeState] = useState(defaultPageSize);
const [loading, setLoadingState] = useState(false);
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(items.length / pageSize)),
[items.length, pageSize]
);
const currentPage = useMemo(() => Math.min(page, totalPages), [page, totalPages]);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return items.slice(start, start + pageSize);
}, [items, currentPage, pageSize]);
const setPageSize = useCallback((size: number) => {
setPageSizeState(size);
setPage(1);
}, []);
const goToPrev = useCallback(() => {
setPage((prev) => Math.max(1, prev - 1));
}, []);
const goToNext = useCallback(() => {
setPage((prev) => Math.min(totalPages, prev + 1));
}, [totalPages]);
const setLoading = useCallback((isLoading: boolean, scope?: 'page' | 'all' | null) => {
setLoadingState(isLoading);
setLoadingScope(isLoading ? (scope ?? null) : null);
}, []);
return {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading,
loadingScope,
setLoading
};
};
interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
config: QuotaConfig<TState, TData>;
files: AuthFileItem[];
loading: boolean;
disabled: boolean;
}
export function QuotaSection<TState extends QuotaStatusState, TData>({
config,
files,
loading,
disabled
}: QuotaSectionProps<TState, TData>) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
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)), [
files,
config
]);
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
const {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading: sectionLoading,
setLoading
} = 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 pendingQuotaRefreshRef = useRef(false);
const prevFilesLoadingRef = useRef(loading);
const handleRefresh = useCallback(() => {
pendingQuotaRefreshRef.current = true;
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(() => {
if (loading) return;
if (filteredFiles.length === 0) {
setQuota({});
return;
}
setQuota((prev) => {
const nextState: Record<string, TState> = {};
filteredFiles.forEach((file) => {
const cached = prev[file.name];
if (cached) {
nextState[file.name] = cached;
}
});
return nextState;
});
}, [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 (
<Card
title={titleNode}
extra={
<div className={styles.headerActions}>
<div className={styles.viewModeToggle}>
<Button
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setViewMode('paged')}
>
{t('auth_files.view_mode_paged')}
</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
variant="secondary"
size="sm"
onClick={handleRefresh}
disabled={disabled || isRefreshing}
loading={isRefreshing}
title={t('quota_management.refresh_files_and_quota')}
aria-label={t('quota_management.refresh_files_and_quota')}
>
{!isRefreshing && <IconRefreshCw size={16} />}
</Button>
</div>
}
>
{filteredFiles.length === 0 ? (
<EmptyState
title={t(`${config.i18nPrefix}.empty_title`)}
description={t(`${config.i18nPrefix}.empty_desc`)}
/>
) : (
<>
<div ref={gridRef} className={config.gridClassName}>
{pageItems.map((item) => (
<QuotaCard
key={item.name}
item={item}
quota={quota[item.name]}
resolvedTheme={resolvedTheme}
i18nPrefix={config.i18nPrefix}
cardClassName={config.cardClassName}
defaultType={config.type}
renderQuotaItems={config.renderQuotaItems}
/>
))}
</div>
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={goToPrev}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filteredFiles.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={goToNext}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</>
)}
{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>
);
}

View File

@@ -0,0 +1,9 @@
/**
* Quota components barrel export.
*/
export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';

View File

@@ -0,0 +1,592 @@
/**
* Quota configuration definitions.
*/
import React from 'react';
import type { ReactNode } from 'react';
import type { TFunction } from 'i18next';
import type {
AntigravityQuotaGroup,
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
CodexQuotaState,
CodexUsageWindow,
CodexQuotaWindow,
CodexUsagePayload,
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
GeminiCliQuotaState
} from '@/types';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS,
normalizeAuthIndexValue,
normalizeNumberValue,
normalizePlanType,
normalizeQuotaFraction,
normalizeStringValue,
parseAntigravityPayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
resolveCodexChatgptAccountId,
resolveCodexPlanType,
resolveGeminiCliProjectId,
formatCodexResetLabel,
formatQuotaResetTime,
buildAntigravityQuotaGroups,
buildGeminiCliQuotaBuckets,
createStatusError,
getStatusFromError,
isAntigravityFile,
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
isRuntimeOnlyAuthFile
} from '@/utils/quota';
import type { QuotaRenderHelpers } from './QuotaCard';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
}
export interface QuotaConfig<TState, TData> {
type: QuotaType;
i18nPrefix: string;
filterFn: (file: AuthFileItem) => boolean;
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
storeSelector: (state: QuotaStore) => Record<string, TState>;
storeSetter: keyof QuotaStore;
buildLoadingState: () => TState;
buildSuccessState: (data: TData) => TState;
buildErrorState: (message: string, status?: number) => TState;
cardClassName: string;
controlsClassName: string;
controlClassName: string;
gridClassName: string;
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 (
file: AuthFileItem,
t: TFunction
): Promise<AntigravityQuotaGroup[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('antigravity_quota.missing_auth_index'));
}
const projectId = await resolveAntigravityProjectId(file);
const requestBody = JSON.stringify({ project: projectId });
let lastError = '';
let lastStatus: number | undefined;
let priorityStatus: number | undefined;
let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: requestBody
});
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
}
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
}
}
}
}
if (hadSuccess) {
return [];
}
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
};
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const windows: CodexQuotaWindow[] = [];
const addWindow = (
id: string,
labelKey: string,
window?: CodexUsageWindow | null,
limitReached?: boolean,
allowed?: boolean
) => {
if (!window) return;
const resetLabel = formatCodexResetLabel(window);
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
const isLimitReached = Boolean(limitReached) || allowed === false;
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel
});
};
addWindow(
'primary',
'codex_quota.primary_window',
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
);
addWindow(
'secondary',
'codex_quota.secondary_window',
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
);
addWindow(
'code-review',
'codex_quota.code_review_window',
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
codeReviewLimit?.allowed
);
return windows;
};
const fetchCodexQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('codex_quota.missing_auth_index'));
}
const planTypeFromFile = resolveCodexPlanType(file);
const accountId = resolveCodexChatgptAccountId(file);
if (!accountId) {
throw new Error(t('codex_quota.missing_account_id'));
}
const requestHeader: Record<string, string> = {
...CODEX_REQUEST_HEADERS,
'Chatgpt-Account-Id': accountId
};
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CODEX_USAGE_URL,
header: requestHeader
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('codex_quota.empty_windows'));
}
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
const windows = buildCodexQuotaWindows(payload, t);
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
};
const fetchGeminiCliQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<GeminiCliQuotaBucketState[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('gemini_cli_quota.missing_auth_index'));
}
const projectId = resolveGeminiCliProjectId(file);
if (!projectId) {
throw new Error(t('gemini_cli_quota.missing_project_id'));
}
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url: GEMINI_CLI_QUOTA_URL,
header: { ...GEMINI_CLI_REQUEST_HEADERS },
data: JSON.stringify({ project: projectId })
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
if (buckets.length === 0) return [];
const parsedBuckets = buckets
.map((bucket) => {
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
const remainingFractionRaw = normalizeQuotaFraction(
bucket.remainingFraction ?? bucket.remaining_fraction
);
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
let fallbackFraction: number | null = null;
if (remainingAmount !== null) {
fallbackFraction = remainingAmount <= 0 ? 0 : null;
} else if (resetTime) {
fallbackFraction = 0;
}
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
return {
modelId,
tokenType,
remainingFraction,
remainingAmount,
resetTime
};
})
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
return buildGeminiCliQuotaBuckets(parsedBuckets);
};
const renderAntigravityItems = (
quota: AntigravityQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const groups = quota.groups ?? [];
if (groups.length === 0) {
return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models'));
}
return groups.map((group) => {
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
const percent = Math.round(clamped * 100);
const resetLabel = formatQuotaResetTime(group.resetTime);
return h(
'div',
{ key: group.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h(
'span',
{ className: styleMap.quotaModel, title: group.models.join(', ') },
group.label
),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, `${percent}%`),
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
);
});
};
const renderCodexItems = (
quota: CodexQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const planType = quota.planType ?? null;
const getPlanLabel = (pt?: string | null): string | null => {
const normalized = normalizePlanType(pt);
if (!normalized) return null;
if (normalized === 'plus') return t('codex_quota.plan_plus');
if (normalized === 'team') return t('codex_quota.plan_team');
if (normalized === 'free') return t('codex_quota.plan_free');
return pt || normalized;
};
const planLabel = getPlanLabel(planType);
const isFreePlan = normalizePlanType(planType) === 'free';
const nodes: ReactNode[] = [];
if (planLabel) {
nodes.push(
h(
'div',
{ key: 'plan', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
h('span', { className: styleMap.codexPlanValue }, planLabel)
)
);
}
if (isFreePlan) {
nodes.push(
h(
'div',
{ key: 'warning', className: styleMap.quotaWarning },
t('codex_quota.no_access')
)
);
return h(Fragment, null, ...nodes);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...windows.map((window) => {
const used = window.usedPercent;
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return h(
'div',
{ key: window.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, windowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
);
})
);
return h(Fragment, null, ...nodes);
};
const renderGeminiCliItems = (
quota: GeminiCliQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const buckets = quota.buckets ?? [];
if (buckets.length === 0) {
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
}
return buckets.map((bucket) => {
const fraction = bucket.remainingFraction;
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
const percent = clamped === null ? null : Math.round(clamped * 100);
const percentLabel = percent === null ? '--' : `${percent}%`;
const remainingAmountLabel =
bucket.remainingAmount === null || bucket.remainingAmount === undefined
? null
: t('gemini_cli_quota.remaining_amount', {
count: bucket.remainingAmount
});
const titleBase =
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
const resetLabel = formatQuotaResetTime(bucket.resetTime);
return h(
'div',
{ key: bucket.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel, title }, bucket.label),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
remainingAmountLabel
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
: null,
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
);
});
};
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',
filterFn: (file) => isAntigravityFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchAntigravityQuota,
storeSelector: (state) => state.antigravityQuota,
storeSetter: 'setAntigravityQuota',
buildLoadingState: () => ({ status: 'loading', groups: [] }),
buildSuccessState: (groups) => ({ status: 'success', groups }),
buildErrorState: (message, status) => ({
status: 'error',
groups: [],
error: message,
errorStatus: status
}),
cardClassName: styles.antigravityCard,
controlsClassName: styles.antigravityControls,
controlClassName: styles.antigravityControl,
gridClassName: styles.antigravityGrid,
renderQuotaItems: renderAntigravityItems
};
export const CODEX_CONFIG: QuotaConfig<
CodexQuotaState,
{ planType: string | null; windows: CodexQuotaWindow[] }
> = {
type: 'codex',
i18nPrefix: 'codex_quota',
filterFn: (file) => isCodexFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchCodexQuota,
storeSelector: (state) => state.codexQuota,
storeSetter: 'setCodexQuota',
buildLoadingState: () => ({ status: 'loading', windows: [] }),
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
planType: data.planType
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status
}),
cardClassName: styles.codexCard,
controlsClassName: styles.codexControls,
controlClassName: styles.codexControl,
gridClassName: styles.codexGrid,
renderQuotaItems: renderCodexItems
};
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
type: 'gemini-cli',
i18nPrefix: 'gemini_cli_quota',
filterFn: (file) =>
isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchGeminiCliQuota,
storeSelector: (state) => state.geminiCliQuota,
storeSetter: 'setGeminiCliQuota',
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
buildErrorState: (message, status) => ({
status: 'error',
buckets: [],
error: message,
errorStatus: status
}),
cardClassName: styles.geminiCliCard,
controlsClassName: styles.geminiCliControls,
controlClassName: styles.geminiCliControl,
gridClassName: styles.geminiCliGrid,
renderQuotaItems: renderGeminiCliItems
};

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,98 @@
/**
* Generic hook for quota data fetching and management.
*/
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthFileItem } from '@/types';
import { useQuotaStore } from '@/stores';
import { getStatusFromError } from '@/utils/quota';
import type { QuotaConfig } from './quotaConfigs';
type QuotaScope = 'page' | 'all';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
interface LoadQuotaResult<TData> {
name: string;
status: 'success' | 'error';
data?: TData;
error?: string;
errorStatus?: number;
}
export function useQuotaLoader<TState, TData>(config: QuotaConfig<TState, TData>) {
const { t } = useTranslation();
const quota = useQuotaStore(config.storeSelector);
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
Record<string, TState>
>;
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const loadQuota = useCallback(
async (
targets: AuthFileItem[],
scope: QuotaScope,
setLoading: (loading: boolean, scope?: QuotaScope | null) => void
) => {
if (loadingRef.current) return;
loadingRef.current = true;
const requestId = ++requestIdRef.current;
setLoading(true, scope);
try {
if (targets.length === 0) return;
setQuota((prev) => {
const nextState = { ...prev };
targets.forEach((file) => {
nextState[file.name] = config.buildLoadingState();
});
return nextState;
});
const results = await Promise.all(
targets.map(async (file): Promise<LoadQuotaResult<TData>> => {
try {
const data = await config.fetchQuota(file, t);
return { name: file.name, status: 'success', data };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const errorStatus = getStatusFromError(err);
return { name: file.name, status: 'error', error: message, errorStatus };
}
})
);
if (requestId !== requestIdRef.current) return;
setQuota((prev) => {
const nextState = { ...prev };
results.forEach((result) => {
if (result.status === 'success') {
nextState[result.name] = config.buildSuccessState(result.data as TData);
} else {
nextState[result.name] = config.buildErrorState(
result.error || t('common.unknown_error'),
result.errorStatus
);
}
});
return nextState;
});
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
loadingRef.current = false;
}
}
},
[config, setQuota, t]
);
return { quota, loadQuota };
}

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

@@ -0,0 +1,40 @@
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'md' | 'sm';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
loading?: boolean;
}
export function Button({
children,
variant = 'primary',
size = 'md',
fullWidth = false,
loading = false,
className = '',
disabled,
...rest
}: PropsWithChildren<ButtonProps>) {
const hasChildren = children !== null && children !== undefined && children !== false;
const classes = [
'btn',
`btn-${variant}`,
size === 'sm' ? 'btn-sm' : '',
fullWidth ? 'btn-full' : '',
className
]
.filter(Boolean)
.join(' ');
return (
<button className={classes} disabled={disabled || loading} {...rest}>
{loading && <span className="loading-spinner" aria-hidden="true" />}
{hasChildren && <span>{children}</span>}
</button>
);
}

View File

@@ -0,0 +1,21 @@
import type { PropsWithChildren, ReactNode } from 'react';
interface CardProps {
title?: ReactNode;
extra?: ReactNode;
className?: string;
}
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
return (
<div className={className ? `card ${className}` : 'card'}>
{(title || extra) && (
<div className="card-header">
<div className="title">{title}</div>
{extra}
</div>
)}
{children}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react';
import { IconInbox } from './icons';
interface EmptyStateProps {
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<div className="empty-state">
<div className="empty-content">
<div className="empty-icon" aria-hidden="true">
<IconInbox size={20} />
</div>
<div>
<div className="empty-title">{title}</div>
{description && <div className="empty-desc">{description}</div>}
</div>
</div>
{action && <div className="empty-action">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { Fragment } from 'react';
import { Button } from './Button';
import { IconX } from './icons';
import type { HeaderEntry } from '@/utils/headers';
interface HeaderInputListProps {
entries: HeaderEntry[];
onChange: (entries: HeaderEntry[]) => void;
addLabel: string;
disabled?: boolean;
keyPlaceholder?: string;
valuePlaceholder?: string;
}
export function HeaderInputList({
entries,
onChange,
addLabel,
disabled = false,
keyPlaceholder = 'X-Custom-Header',
valuePlaceholder = 'value'
}: HeaderInputListProps) {
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
const updateEntry = (index: number, field: 'key' | 'value', value: string) => {
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
onChange(next);
};
const addEntry = () => {
onChange([...currentEntries, { key: '', value: '' }]);
};
const removeEntry = (index: number) => {
const next = currentEntries.filter((_, idx) => idx !== index);
onChange(next.length ? next : [{ key: '', value: '' }]);
};
return (
<div className="header-input-list">
{currentEntries.map((entry, index) => (
<Fragment key={index}>
<div className="header-input-row">
<input
className="input"
placeholder={keyPlaceholder}
value={entry.key}
onChange={(e) => updateEntry(index, 'key', e.target.value)}
disabled={disabled}
/>
<span className="header-separator">:</span>
<input
className="input"
placeholder={valuePlaceholder}
value={entry.value}
onChange={(e) => updateEntry(index, 'value', e.target.value)}
disabled={disabled}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={disabled || currentEntries.length <= 1}
title="Remove"
aria-label="Remove"
>
<IconX size={14} />
</Button>
</div>
</Fragment>
))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
{addLabel}
</Button>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
hint?: string;
error?: string;
rightElement?: ReactNode;
}
export function Input({ label, hint, error, rightElement, className = '', ...rest }: InputProps) {
return (
<div className="form-group">
{label && <label>{label}</label>}
<div style={{ position: 'relative' }}>
<input className={`input ${className}`.trim()} {...rest} />
{rightElement && (
<div style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)' }}>
{rightElement}
</div>
)}
</div>
{hint && <div className="hint">{hint}</div>}
{error && <div className="error-box">{error}</div>}
</div>
);
}

View File

@@ -0,0 +1,16 @@
export function LoadingSpinner({
size = 20,
className = ''
}: {
size?: number;
className?: string;
}) {
return (
<div
className={`loading-spinner${className ? ` ${className}` : ''}`}
style={{ width: size, height: size, borderWidth: size / 7 }}
role="status"
aria-live="polite"
/>
);
}

141
src/components/ui/Modal.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { IconX } from './icons';
interface ModalProps {
open: boolean;
title?: ReactNode;
onClose: () => void;
footer?: ReactNode;
width?: number | string;
closeDisabled?: boolean;
}
const CLOSE_ANIMATION_DURATION = 350;
const MODAL_LOCK_CLASS = 'modal-open';
let activeModalCount = 0;
const lockScroll = () => {
if (typeof document === 'undefined') return;
if (activeModalCount === 0) {
document.body?.classList.add(MODAL_LOCK_CLASS);
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
}
activeModalCount += 1;
};
const unlockScroll = () => {
if (typeof document === 'undefined') return;
activeModalCount = Math.max(0, activeModalCount - 1);
if (activeModalCount === 0) {
document.body?.classList.remove(MODAL_LOCK_CLASS);
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
}
};
export function Modal({
open,
title,
onClose,
footer,
width = 520,
closeDisabled = false,
children
}: PropsWithChildren<ModalProps>) {
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startClose = useCallback(
(notifyParent: boolean) => {
if (closeTimerRef.current !== null) return;
setIsClosing(true);
closeTimerRef.current = window.setTimeout(() => {
setIsVisible(false);
setIsClosing(false);
closeTimerRef.current = null;
if (notifyParent) {
onClose();
}
}, CLOSE_ANIMATION_DURATION);
},
[onClose]
);
useEffect(() => {
let cancelled = false;
if (open) {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
queueMicrotask(() => {
if (cancelled) return;
setIsVisible(true);
setIsClosing(false);
});
} else if (isVisible) {
queueMicrotask(() => {
if (cancelled) return;
startClose(false);
});
}
return () => {
cancelled = true;
};
}, [open, isVisible, startClose]);
const handleClose = useCallback(() => {
startClose(true);
}, [startClose]);
useEffect(() => {
return () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
}
};
}, []);
const shouldLockScroll = open || isVisible;
useEffect(() => {
if (!shouldLockScroll) return;
lockScroll();
return () => unlockScroll();
}, [shouldLockScroll]);
if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
const modalContent = (
<div className={overlayClass}>
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<button
type="button"
className="modal-close-floating"
onClick={closeDisabled ? undefined : handleClose}
aria-label="Close"
disabled={closeDisabled}
>
<IconX size={20} />
</button>
<div className="modal-header">
<div className="modal-title">{title}</div>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
);
if (typeof document === 'undefined') {
return modalContent;
}
return createPortal(modalContent, document.body);
}

View File

@@ -0,0 +1,77 @@
import { Fragment } from 'react';
import { Button } from './Button';
import { IconX } from './icons';
import type { ModelEntry } from './modelInputListUtils';
interface ModelInputListProps {
entries: ModelEntry[];
onChange: (entries: ModelEntry[]) => void;
addLabel: string;
disabled?: boolean;
namePlaceholder?: string;
aliasPlaceholder?: string;
}
export function ModelInputList({
entries,
onChange,
addLabel,
disabled = false,
namePlaceholder = 'model-name',
aliasPlaceholder = 'alias (optional)'
}: ModelInputListProps) {
const currentEntries = entries.length ? entries : [{ name: '', alias: '' }];
const updateEntry = (index: number, field: 'name' | 'alias', value: string) => {
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
onChange(next);
};
const addEntry = () => {
onChange([...currentEntries, { name: '', alias: '' }]);
};
const removeEntry = (index: number) => {
const next = currentEntries.filter((_, idx) => idx !== index);
onChange(next.length ? next : [{ name: '', alias: '' }]);
};
return (
<div className="header-input-list">
{currentEntries.map((entry, index) => (
<Fragment key={index}>
<div className="header-input-row">
<input
className="input"
placeholder={namePlaceholder}
value={entry.name}
onChange={(e) => updateEntry(index, 'name', e.target.value)}
disabled={disabled}
/>
<span className="header-separator"></span>
<input
className="input"
placeholder={aliasPlaceholder}
value={entry.alias}
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
disabled={disabled}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={disabled || currentEntries.length <= 1}
title="Remove"
aria-label="Remove"
>
<IconX size={14} />
</Button>
</div>
</Fragment>
))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
{addLabel}
</Button>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import type { ChangeEvent, ReactNode } from 'react';
interface ToggleSwitchProps {
checked: boolean;
onChange: (value: boolean) => void;
label?: ReactNode;
ariaLabel?: string;
disabled?: boolean;
labelPosition?: 'left' | 'right';
}
export function ToggleSwitch({
checked,
onChange,
label,
ariaLabel,
disabled = false,
labelPosition = 'right'
}: ToggleSwitchProps) {
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.checked);
};
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
.filter(Boolean)
.join(' ');
return (
<label className={className}>
<input
type="checkbox"
checked={checked}
onChange={handleChange}
disabled={disabled}
aria-label={ariaLabel}
/>
<span className="track">
<span className="thumb" />
</span>
{label && <span className="label">{label}</span>}
</label>
);
}

324
src/components/ui/icons.tsx Normal file
View File

@@ -0,0 +1,324 @@
import type { SVGProps } from 'react';
// Inline SVG icons (Lucide, ISC). We embed paths to keep the WebUI single-file/offline friendly.
// Source: https://github.com/lucide-icons/lucide (via lucide-static).
export interface IconProps extends SVGProps<SVGSVGElement> {
size?: number;
}
const baseSvgProps: SVGProps<SVGSVGElement> = {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
focusable: 'false'
};
export function IconSlidersHorizontal({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<line x1="21" x2="14" y1="4" y2="4" />
<line x1="10" x2="3" y1="4" y2="4" />
<line x1="21" x2="12" y1="12" y2="12" />
<line x1="8" x2="3" y1="12" y2="12" />
<line x1="21" x2="16" y1="20" y2="20" />
<line x1="12" x2="3" y1="20" y2="20" />
<line x1="14" x2="14" y1="2" y2="6" />
<line x1="8" x2="8" y1="10" y2="14" />
<line x1="16" x2="16" y1="18" y2="22" />
</svg>
);
}
export function IconKey({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4" />
<path d="m21 2-9.6 9.6" />
<circle cx="7.5" cy="15.5" r="5.5" />
</svg>
);
}
export function IconBot({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M12 8V4H8" />
<rect width="16" height="12" x="4" y="8" rx="2" />
<path d="M2 14h2" />
<path d="M20 14h2" />
<path d="M15 13v2" />
<path d="M9 13v2" />
</svg>
);
}
export function IconFileText({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>
);
}
export function IconShield({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
</svg>
);
}
export function IconChartLine({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<path d="m19 9-5 5-4-4-3 3" />
</svg>
);
}
export function IconSettings({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export function IconScrollText({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M15 12h-5" />
<path d="M15 8h-5" />
<path d="M19 17V5a2 2 0 0 0-2-2H4" />
<path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3" />
</svg>
);
}
export function IconInfo({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
);
}
export function IconRefreshCw({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
);
}
export function IconDownload({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M12 15V3" />
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m7 10 5 5 5-5" />
</svg>
);
}
export function IconTrash2({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>
);
}
export function IconChevronUp({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="m18 15-6-6-6 6" />
</svg>
);
}
export function IconChevronDown({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
export function IconChevronLeft({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="m15 18-6-6 6-6" />
</svg>
);
}
export function IconSearch({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
);
}
export function IconX({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
export function IconCheck({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
export function IconEye({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export function IconEyeOff({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
<path d="m2 2 20 20" />
</svg>
);
}
export function IconInbox({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
);
}
export function IconSatellite({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="m13.5 6.5-3.148-3.148a1.205 1.205 0 0 0-1.704 0L6.352 5.648a1.205 1.205 0 0 0 0 1.704L9.5 10.5" />
<path d="M16.5 7.5 19 5" />
<path d="m17.5 10.5 3.148 3.148a1.205 1.205 0 0 1 0 1.704l-2.296 2.296a1.205 1.205 0 0 1-1.704 0L13.5 14.5" />
<path d="M9 21a6 6 0 0 0-6-6" />
<path d="M9.352 10.648a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l4.296-4.296a1.205 1.205 0 0 0 0-1.704l-2.296-2.296a1.205 1.205 0 0 0-1.704 0z" />
</svg>
);
}
export function IconDiamond({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41l-7.59-7.59a2.41 2.41 0 0 0-3.41 0Z" />
</svg>
);
}
export function IconTimer({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<line x1="10" x2="14" y1="2" y2="2" />
<line x1="12" x2="15" y1="14" y2="11" />
<circle cx="12" cy="14" r="8" />
</svg>
);
}
export function IconTrendingUp({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M16 7h6v6" />
<path d="m22 7-8.5 8.5-5-5L2 17" />
</svg>
);
}
export function IconDollarSign({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<line x1="12" x2="12" y1="2" y2="22" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
);
}
export function IconGithub({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
);
}
export function IconExternalLink({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
);
}
export function IconBookOpen({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>
);
}
export function IconCode({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
);
}
export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<rect width="7" height="9" x="3" y="3" rx="1" />
<rect width="7" height="5" x="14" y="3" rx="1" />
<rect width="7" height="9" x="14" y="12" rx="1" />
<rect width="7" height="5" x="3" y="16" rx="1" />
</svg>
);
}

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

@@ -0,0 +1,79 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ApiDetailsCardProps {
apiStats: ApiStats[];
loading: boolean;
hasPrices: boolean;
}
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
const { t } = useTranslation();
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
const toggleExpand = (endpoint: string) => {
setExpandedApis((prev) => {
const newSet = new Set(prev);
if (newSet.has(endpoint)) {
newSet.delete(endpoint);
} else {
newSet.add(endpoint);
}
return newSet;
});
};
return (
<Card title={t('usage_stats.api_details')}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : apiStats.length > 0 ? (
<div className={styles.apiList}>
{apiStats.map((api) => (
<div key={api.endpoint} className={styles.apiItem}>
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
<div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
{t('usage_stats.requests_count')}: {api.totalRequests}
</span>
<span className={styles.apiBadge}>
Tokens: {formatTokensInMillions(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
</span>
)}
</div>
</div>
<span className={styles.expandIcon}>
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
</span>
</div>
{expandedApis.has(api.endpoint) && (
<div className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
{stats.requests} {t('usage_stats.requests_count')}
</span>
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,92 @@
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import styles from '@/pages/UsagePage.module.scss';
export interface ChartLineSelectorProps {
chartLines: string[];
modelNames: string[];
maxLines?: number;
onChange: (lines: string[]) => void;
}
export function ChartLineSelector({
chartLines,
modelNames,
maxLines = 9,
onChange
}: ChartLineSelectorProps) {
const { t } = useTranslation();
const handleAdd = () => {
if (chartLines.length >= maxLines) return;
const unusedModel = modelNames.find((m) => !chartLines.includes(m));
if (unusedModel) {
onChange([...chartLines, unusedModel]);
} else {
onChange([...chartLines, 'all']);
}
};
const handleRemove = (index: number) => {
if (chartLines.length <= 1) return;
const newLines = [...chartLines];
newLines.splice(index, 1);
onChange(newLines);
};
const handleChange = (index: number, value: string) => {
const newLines = [...chartLines];
newLines[index] = value;
onChange(newLines);
};
return (
<Card
title={t('usage_stats.chart_line_actions_label')}
extra={
<div className={styles.chartLineHeader}>
<span className={styles.chartLineCount}>
{chartLines.length}/{maxLines}
</span>
<Button
variant="secondary"
size="sm"
onClick={handleAdd}
disabled={chartLines.length >= maxLines}
>
{t('usage_stats.chart_line_add')}
</Button>
</div>
}
>
<div className={styles.chartLineList}>
{chartLines.map((line, index) => (
<div key={index} className={styles.chartLineItem}>
<span className={styles.chartLineLabel}>
{t(`usage_stats.chart_line_label_${index + 1}`)}
</span>
<select
value={line}
onChange={(e) => handleChange(index, e.target.value)}
className={styles.select}
>
<option value="all">{t('usage_stats.chart_line_all')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
{chartLines.length > 1 && (
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
{t('usage_stats.chart_line_delete')}
</Button>
)}
</div>
))}
</div>
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
</Card>
);
}

View File

@@ -0,0 +1,64 @@
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat {
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}
export interface ModelStatsCardProps {
modelStats: ModelStat[];
loading: boolean;
hasPrices: boolean;
}
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
const { t } = useTranslation();
return (
<Card title={t('usage_stats.models')}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : modelStats.length > 0 ? (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.model_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.tokens_count')}</th>
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
</tr>
</thead>
<tbody>
{modelStats.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
</span>
</span>
</td>
<td>{formatTokensInMillions(stat.tokens)}</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,164 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import type { ModelPrice } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface PriceSettingsCardProps {
modelNames: string[];
modelPrices: Record<string, ModelPrice>;
onPricesChange: (prices: Record<string, ModelPrice>) => void;
}
export function PriceSettingsCard({
modelNames,
modelPrices,
onPricesChange
}: PriceSettingsCardProps) {
const { t } = useTranslation();
const [selectedModel, setSelectedModel] = useState('');
const [promptPrice, setPromptPrice] = useState('');
const [completionPrice, setCompletionPrice] = useState('');
const [cachePrice, setCachePrice] = useState('');
const handleSavePrice = () => {
if (!selectedModel) return;
const prompt = parseFloat(promptPrice) || 0;
const completion = parseFloat(completionPrice) || 0;
const cache = cachePrice.trim() === '' ? prompt : parseFloat(cachePrice) || 0;
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache } };
onPricesChange(newPrices);
setSelectedModel('');
setPromptPrice('');
setCompletionPrice('');
setCachePrice('');
};
const handleDeletePrice = (model: string) => {
const newPrices = { ...modelPrices };
delete newPrices[model];
onPricesChange(newPrices);
};
const handleEditPrice = (model: string) => {
const price = modelPrices[model];
setSelectedModel(model);
setPromptPrice(price?.prompt?.toString() || '');
setCompletionPrice(price?.completion?.toString() || '');
setCachePrice(price?.cache?.toString() || '');
};
const handleModelSelect = (value: string) => {
setSelectedModel(value);
const price = modelPrices[value];
if (price) {
setPromptPrice(price.prompt.toString());
setCompletionPrice(price.completion.toString());
setCachePrice(price.cache.toString());
} else {
setPromptPrice('');
setCompletionPrice('');
setCachePrice('');
}
};
return (
<Card title={t('usage_stats.model_price_settings')}>
<div className={styles.pricingSection}>
{/* Price Form */}
<div className={styles.priceForm}>
<div className={styles.formRow}>
<div className={styles.formField}>
<label>{t('usage_stats.model_name')}</label>
<select
value={selectedModel}
onChange={(e) => handleModelSelect(e.target.value)}
className={styles.select}
>
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
<Input
type="number"
value={promptPrice}
onChange={(e) => setPromptPrice(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
<Input
type="number"
value={completionPrice}
onChange={(e) => setCompletionPrice(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
<Input
type="number"
value={cachePrice}
onChange={(e) => setCachePrice(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<Button variant="primary" onClick={handleSavePrice} disabled={!selectedModel}>
{t('common.save')}
</Button>
</div>
</div>
{/* Saved Prices List */}
<div className={styles.pricesList}>
<h4 className={styles.pricesTitle}>{t('usage_stats.saved_prices')}</h4>
{Object.keys(modelPrices).length > 0 ? (
<div className={styles.pricesGrid}>
{Object.entries(modelPrices).map(([model, price]) => (
<div key={model} className={styles.priceItem}>
<div className={styles.priceInfo}>
<span className={styles.priceModel}>{model}</span>
<div className={styles.priceMeta}>
<span>
{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M
</span>
<span>
{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M
</span>
<span>
{t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M
</span>
</div>
</div>
<div className={styles.priceActions}>
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
{t('common.delete')}
</Button>
</div>
</div>
))}
</div>
) : (
<div className={styles.hint}>{t('usage_stats.model_price_empty')}</div>
)}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,184 @@
import type { CSSProperties, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import {
formatTokensInMillions,
formatPerMinuteValue,
formatUsd,
calculateTokenBreakdown,
calculateRecentPerMinuteRates,
calculateTotalCost,
type ModelPrice
} from '@/utils/usage';
import { sparklineOptions } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import type { SparklineBundle } from './hooks/useSparklines';
import styles from '@/pages/UsagePage.module.scss';
interface StatCardData {
key: string;
label: string;
icon: ReactNode;
accent: string;
accentSoft: string;
accentBorder: string;
value: string;
meta?: ReactNode;
trend: SparklineBundle | null;
}
export interface StatCardsProps {
usage: UsagePayload | null;
loading: boolean;
modelPrices: Record<string, ModelPrice>;
sparklines: {
requests: SparklineBundle | null;
tokens: SparklineBundle | null;
rpm: SparklineBundle | null;
tpm: SparklineBundle | null;
cost: SparklineBundle | null;
};
}
export function StatCards({ usage, loading, modelPrices, sparklines }: StatCardsProps) {
const { t } = useTranslation();
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
const rateStats = usage
? calculateRecentPerMinuteRates(30, usage)
: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 };
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
const hasPrices = Object.keys(modelPrices).length > 0;
const statsCards: StatCardData[] = [
{
key: 'requests',
label: t('usage_stats.total_requests'),
icon: <IconSatellite size={16} />,
accent: '#3b82f6',
accentSoft: 'rgba(59, 130, 246, 0.18)',
accentBorder: 'rgba(59, 130, 246, 0.35)',
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
meta: (
<>
<span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#10b981' }} />
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
</span>
<span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
</span>
</>
),
trend: sparklines.requests
},
{
key: 'tokens',
label: t('usage_stats.total_tokens'),
icon: <IconDiamond size={16} />,
accent: '#8b5cf6',
accentSoft: 'rgba(139, 92, 246, 0.18)',
accentBorder: 'rgba(139, 92, 246, 0.35)',
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
</span>
<span className={styles.statMetaItem}>
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
</span>
</>
),
trend: sparklines.tokens
},
{
key: 'rpm',
label: t('usage_stats.rpm_30m'),
icon: <IconTimer size={16} />,
accent: '#22c55e',
accentSoft: 'rgba(34, 197, 94, 0.18)',
accentBorder: 'rgba(34, 197, 94, 0.32)',
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
</span>
),
trend: sparklines.rpm
},
{
key: 'tpm',
label: t('usage_stats.tpm_30m'),
icon: <IconTrendingUp size={16} />,
accent: '#f97316',
accentSoft: 'rgba(249, 115, 22, 0.18)',
accentBorder: 'rgba(249, 115, 22, 0.32)',
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
</span>
),
trend: sparklines.tpm
},
{
key: 'cost',
label: t('usage_stats.total_cost'),
icon: <IconDollarSign size={16} />,
accent: '#f59e0b',
accentSoft: 'rgba(245, 158, 11, 0.18)',
accentBorder: 'rgba(245, 158, 11, 0.32)',
value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--',
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
</span>
{!hasPrices && (
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
{t('usage_stats.cost_need_price')}
</span>
)}
</>
),
trend: hasPrices ? sparklines.cost : null
}
];
return (
<div className={styles.statsGrid}>
{statsCards.map((card) => (
<div
key={card.key}
className={styles.statCard}
style={
{
'--accent': card.accent,
'--accent-soft': card.accentSoft,
'--accent-border': card.accentBorder
} as CSSProperties
}
>
<div className={styles.statCardHeader}>
<div className={styles.statLabelGroup}>
<span className={styles.statLabel}>{card.label}</span>
</div>
<span className={styles.statIconBadge}>{card.icon}</span>
</div>
<div className={styles.statValue}>{card.value}</div>
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
<div className={styles.statTrend}>
{card.trend ? (
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
) : (
<div className={styles.statTrendPlaceholder}></div>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useTranslation } from 'react-i18next';
import type { ChartOptions } from 'chart.js';
import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import type { ChartData } from '@/utils/usage';
import { getHourChartMinWidth } from '@/utils/usage/chartConfig';
import styles from '@/pages/UsagePage.module.scss';
export interface UsageChartProps {
title: string;
period: 'hour' | 'day';
onPeriodChange: (period: 'hour' | 'day') => void;
chartData: ChartData;
chartOptions: ChartOptions<'line'>;
loading: boolean;
isMobile: boolean;
emptyText: string;
}
export function UsageChart({
title,
period,
onPeriodChange,
chartData,
chartOptions,
loading,
isMobile,
emptyText
}: UsageChartProps) {
const { t } = useTranslation();
return (
<Card
title={title}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => onPeriodChange('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => onPeriodChange('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : chartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{chartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{emptyText}</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,8 @@
export { useUsageData } from './useUsageData';
export type { UsagePayload, UseUsageDataReturn } from './useUsageData';
export { useSparklines } from './useSparklines';
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './useSparklines';
export { useChartData } from './useChartData';
export type { UseChartDataOptions, UseChartDataReturn } from './useChartData';

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