Compare commits

...

25 Commits

38 changed files with 1530 additions and 577 deletions
+1
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>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-fill)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-fill" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

-25
View File
@@ -1,25 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-25
View File
@@ -1,25 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

+1 -1
View File
@@ -50,7 +50,7 @@ function computeUnifiedDiff(original: string, modified: string): DiffResult {
let totalAdditions = 0;
let totalDeletions = 0;
const hunks: Hunk[] = chunks.map((chunk) => {
const hunks: Hunk[] = chunks.map((chunk: Chunk) => {
const lines: UnifiedLine[] = [];
const hasDel = chunk.fromA < chunk.toA;
+73 -104
View File
@@ -13,15 +13,15 @@ import { Button } from '@/components/ui/Button';
import { PageTransition } from '@/components/common/PageTransition';
import { MainRoutes } from '@/router/MainRoutes';
import {
IconBot,
IconChartLine,
IconFileText,
IconInfo,
IconLayoutDashboard,
IconScrollText,
IconSettings,
IconShield,
IconTimer,
IconSidebarAuthFiles,
IconSidebarConfig,
IconSidebarDashboard,
IconSidebarLogs,
IconSidebarOauth,
IconSidebarProviders,
IconSidebarQuota,
IconSidebarSystem,
IconSidebarUsage,
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import {
@@ -31,22 +31,21 @@ import {
useNotificationStore,
useThemeStore,
} from '@/stores';
import { versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
import { isSupportedLanguage } from '@/utils/language';
import type { Theme } from '@/types';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard 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} />,
dashboard: <IconSidebarDashboard size={18} />,
aiProviders: <IconSidebarProviders size={18} />,
authFiles: <IconSidebarAuthFiles size={18} />,
oauth: <IconSidebarOauth size={18} />,
quota: <IconSidebarQuota size={18} />,
usage: <IconSidebarUsage size={18} />,
config: <IconSidebarConfig size={18} />,
logs: <IconSidebarLogs size={18} />,
system: <IconSidebarSystem size={18} />,
};
// Header action icons - smaller size for header buttons
@@ -70,12 +69,6 @@ const headerIcons = {
<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" />
@@ -132,7 +125,13 @@ const headerIcons = {
</clipPath>
</defs>
<circle cx="12" cy="12" r="4" />
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
<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" />
@@ -171,53 +170,44 @@ const THEME_CARDS: Array<{
{
key: 'white',
labelKey: 'theme.white',
colors: { bg: '#ffffff', card: '#ffffff', border: '#e5e5e5', text: '#2d2a26', textMuted: '#a29c95' },
colors: {
bg: '#ffffff',
card: '#ffffff',
border: '#e5e5e5',
text: '#2d2a26',
textMuted: '#a29c95',
},
},
{
key: 'light',
labelKey: 'theme.light',
colors: { bg: '#faf9f5', card: '#f0eee8', border: '#e3e1db', text: '#2d2a26', textMuted: '#a29c95' },
colors: {
bg: '#faf9f5',
card: '#f0eee8',
border: '#e3e1db',
text: '#2d2a26',
textMuted: '#a29c95',
},
},
{
key: 'dark',
labelKey: 'theme.dark',
colors: { bg: '#151412', card: '#1d1b18', border: '#3a3530', text: '#f6f4f1', textMuted: '#9c958d' },
colors: {
bg: '#151412',
card: '#1d1b18',
border: '#3a3530',
text: '#f6f4f1',
textMuted: '#9c958d',
},
},
];
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 } = useTranslation();
const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const logout = useAuthStore((state) => state.logout);
@@ -232,7 +222,6 @@ export function MainLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
@@ -420,7 +409,6 @@ export function MainLayout() {
});
}, [fetchConfig]);
const statusClass =
connectionStatus === 'connected'
? 'success'
@@ -503,7 +491,7 @@ export function MainLayout() {
clearCache();
const results = await Promise.allSettled([
fetchConfig(undefined, true),
triggerHeaderRefresh()
triggerHeaderRefresh(),
]);
const rejected = results.find((result) => result.status === 'rejected');
if (rejected && rejected.status === 'rejected') {
@@ -519,39 +507,6 @@ export function MainLayout() {
showNotification(t('notification.data_refreshed'), 'success');
};
const handleVersionCheck = async () => {
setCheckingVersion(true);
try {
const data = await versionApi.checkLatest();
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
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: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
const suffix = message ? `: ${message}` : '';
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
} finally {
setCheckingVersion(false);
}
};
return (
<div className="app-shell">
<header className="main-header" ref={headerRef}>
@@ -609,16 +564,10 @@ export function MainLayout() {
>
{headerIcons.refresh}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleVersionCheck}
loading={checkingVersion}
title={t('system_info.version_check_button')}
<div
className={`language-menu ${languageMenuOpen ? 'open' : ''}`}
ref={languageMenuRef}
>
{headerIcons.update}
</Button>
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
<Button
variant="ghost"
size="sm"
@@ -631,7 +580,11 @@ export function MainLayout() {
{headerIcons.language}
</Button>
{languageMenuOpen && (
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
<div
className="notification entering language-menu-popover"
role="menu"
aria-label={t('language.switch')}
>
{LANGUAGE_ORDER.map((lang) => (
<button
key={lang}
@@ -667,7 +620,11 @@ export function MainLayout() {
: headerIcons.sun}
</Button>
{themeMenuOpen && (
<div className="notification entering theme-menu-popover" role="menu" aria-label={t('theme.switch')}>
<div
className="notification entering theme-menu-popover"
role="menu"
aria-label={t('theme.switch')}
>
{THEME_CARDS.map((tc) => (
<button
key={tc.key}
@@ -679,7 +636,10 @@ export function MainLayout() {
>
<div
className="theme-card-preview"
style={{ background: tc.colors.bg, border: `1px solid ${tc.colors.border}` }}
style={{
background: tc.colors.bg,
border: `1px solid ${tc.colors.border}`,
}}
>
<div
className="theme-card-header"
@@ -722,6 +682,15 @@ export function MainLayout() {
</header>
<div className="main-body">
<button
type="button"
className={`sidebar-backdrop ${sidebarOpen ? 'visible' : ''}`}
onClick={() => setSidebarOpen(false)}
aria-label={t('common.close')}
aria-hidden={!sidebarOpen}
tabIndex={sidebarOpen ? 0 : -1}
/>
<aside
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
>
@@ -3,8 +3,7 @@ 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 iconCodex from '@/assets/icons/codex.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import {
@@ -25,7 +24,6 @@ interface CodexSectionProps {
loading: boolean;
disableControls: boolean;
isSwitching: boolean;
resolvedTheme: string;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
@@ -39,7 +37,6 @@ export function CodexSection({
loading,
disableControls,
isSwitching,
resolvedTheme,
onAdd,
onEdit,
onDelete,
@@ -72,11 +69,7 @@ export function CodexSection({
<Card
title={
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
alt=""
className={styles.cardTitleIcon}
/>
<img src={iconCodex} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.codex_title')}
</span>
}
@@ -6,8 +6,7 @@ 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 iconCodex from '@/assets/icons/codex.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import iconAmp from '@/assets/icons/amp.svg';
@@ -23,7 +22,7 @@ interface ProviderNavItem {
const PROVIDERS: ProviderNavItem[] = [
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
{ id: 'codex', label: 'Codex', getIcon: () => iconCodex },
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
@@ -2,6 +2,7 @@ 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 iconVertex from '@/assets/icons/vertex.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
@@ -14,7 +15,7 @@ import {
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource } from '../utils';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
interface VertexSectionProps {
configs: ProviderKeyConfig[];
@@ -26,6 +27,7 @@ interface VertexSectionProps {
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void;
}
export function VertexSection({
@@ -38,9 +40,11 @@ export function VertexSection({
onAdd,
onEdit,
onDelete,
onToggle,
}: VertexSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || loading || isSwitching;
const toggleDisabled = disableControls || loading || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
@@ -84,9 +88,19 @@ export function VertexSection({
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([]);
@@ -126,6 +140,11 @@ export function VertexSection({
))}
</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}>
+334 -58
View File
@@ -20,13 +20,17 @@ import type {
CodexUsageWindow,
CodexQuotaWindow,
CodexUsagePayload,
GeminiCliCodeAssistPayload,
GeminiCliCredits,
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
GeminiCliQuotaState,
GeminiCliUserTier,
KimiQuotaRow,
KimiQuotaState,
} from '@/types';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import { useQuotaStore } from '@/stores';
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
@@ -37,6 +41,7 @@ import {
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_CODE_ASSIST_URL,
GEMINI_CLI_REQUEST_HEADERS,
KIMI_USAGE_URL,
KIMI_REQUEST_HEADERS,
@@ -49,6 +54,7 @@ import {
parseClaudeUsagePayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
parseGeminiCliCodeAssistPayload,
parseKimiUsagePayload,
resolveCodexChatgptAccountId,
resolveCodexPlanType,
@@ -78,6 +84,11 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
const geminiCliSupplementaryRequestIds = new Map<string, number>();
const geminiCliSupplementaryCache = new Map<
string,
{ requestId: number; tierLabel: string | null; tierId: string | null; creditBalance: number | null }
>();
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
@@ -427,10 +438,181 @@ const fetchCodexQuota = async (
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
};
const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
const GEMINI_CLI_TIER_LABELS: Record<string, string> = {
'free-tier': 'tier_free',
'legacy-tier': 'tier_legacy',
'standard-tier': 'tier_standard',
'g1-pro-tier': 'tier_pro',
'g1-ultra-tier': 'tier_ultra',
};
const resolveGeminiCliTierLabel = (
payload: GeminiCliCodeAssistPayload | null,
t: TFunction
): string | null => {
if (!payload) return null;
const currentTier: GeminiCliUserTier | null | undefined =
payload.currentTier ?? payload.current_tier;
const paidTier: GeminiCliUserTier | null | undefined =
payload.paidTier ?? payload.paid_tier;
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
if (!rawId) return null;
const tierId = rawId.toLowerCase();
const labelKey = GEMINI_CLI_TIER_LABELS[tierId];
return labelKey ? t(`gemini_cli_quota.${labelKey}`) : rawId;
};
const resolveGeminiCliTierId = (
payload: GeminiCliCodeAssistPayload | null
): string | null => {
if (!payload) return null;
const currentTier: GeminiCliUserTier | null | undefined =
payload.currentTier ?? payload.current_tier;
const paidTier: GeminiCliUserTier | null | undefined =
payload.paidTier ?? payload.paid_tier;
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
return rawId ? rawId.toLowerCase() : null;
};
const resolveGeminiCliCreditBalance = (
payload: GeminiCliCodeAssistPayload | null
): number | null => {
if (!payload) return null;
const paidTier: GeminiCliUserTier | null | undefined =
payload.paidTier ?? payload.paid_tier;
const currentTier: GeminiCliUserTier | null | undefined =
payload.currentTier ?? payload.current_tier;
const tier = paidTier ?? currentTier;
if (!tier) return null;
const credits: GeminiCliCredits[] =
tier.availableCredits ?? tier.available_credits ?? [];
let total = 0;
let found = false;
for (const credit of credits) {
const creditType = normalizeStringValue(credit.creditType ?? credit.credit_type);
if (creditType !== GEMINI_CLI_G1_CREDIT_TYPE) continue;
const amount = normalizeNumberValue(credit.creditAmount ?? credit.credit_amount);
if (amount !== null) {
total += amount;
found = true;
}
}
return found ? total : null;
};
const fetchGeminiCliCodeAssist = async (
authIndex: string,
projectId: string,
t: TFunction
): Promise<{ tierLabel: string | null; tierId: string | null; creditBalance: number | null }> => {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url: GEMINI_CLI_CODE_ASSIST_URL,
header: { ...GEMINI_CLI_REQUEST_HEADERS },
data: JSON.stringify({
cloudaicompanionProject: projectId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: projectId,
},
}),
});
if (result.statusCode < 200 || result.statusCode >= 300) {
return { tierLabel: null, tierId: null, creditBalance: null };
}
const payload = parseGeminiCliCodeAssistPayload(result.body ?? result.bodyText);
return {
tierLabel: resolveGeminiCliTierLabel(payload, t),
tierId: resolveGeminiCliTierId(payload),
creditBalance: resolveGeminiCliCreditBalance(payload),
};
} catch {
return { tierLabel: null, tierId: null, creditBalance: null };
}
};
const readGeminiCliSupplementarySnapshot = (
fileName: string,
requestId: number
): { tierLabel: string | null; tierId: string | null; creditBalance: number | null } => {
const cached = geminiCliSupplementaryCache.get(fileName);
if (!cached || cached.requestId !== requestId) {
return { tierLabel: null, tierId: null, creditBalance: null };
}
return {
tierLabel: cached.tierLabel,
tierId: cached.tierId,
creditBalance: cached.creditBalance,
};
};
const scheduleGeminiCliSupplementaryRefresh = (
fileName: string,
authIndex: string,
projectId: string,
t: TFunction
): number => {
const requestId = (geminiCliSupplementaryRequestIds.get(fileName) ?? 0) + 1;
geminiCliSupplementaryRequestIds.set(fileName, requestId);
geminiCliSupplementaryCache.delete(fileName);
void (async () => {
const supplementary = await fetchGeminiCliCodeAssist(authIndex, projectId, t);
if (geminiCliSupplementaryRequestIds.get(fileName) !== requestId) {
return;
}
geminiCliSupplementaryCache.set(fileName, { requestId, ...supplementary });
useQuotaStore.getState().setGeminiCliQuota((prev) => {
const current = prev[fileName];
if (!current || current.status !== 'success') {
return prev;
}
if (
current.tierLabel === supplementary.tierLabel &&
current.tierId === supplementary.tierId &&
current.creditBalance === supplementary.creditBalance
) {
return prev;
}
return {
...prev,
[fileName]: {
...current,
tierLabel: supplementary.tierLabel,
tierId: supplementary.tierId,
creditBalance: supplementary.creditBalance,
},
};
});
})();
return requestId;
};
const fetchGeminiCliQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<GeminiCliQuotaBucketState[]> => {
): Promise<{
fileName: string;
supplementaryRequestId: number;
buckets: GeminiCliQuotaBucketState[];
tierLabel: string | null;
tierId: string | null;
creditBalance: number | null;
}> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
@@ -442,21 +624,19 @@ const fetchGeminiCliQuota = async (
throw new Error(t('gemini_cli_quota.missing_project_id'));
}
const result = await apiCallApi.request({
const quotaResponse = 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);
if (quotaResponse.statusCode < 200 || quotaResponse.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(quotaResponse), quotaResponse.statusCode);
}
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
const payload = parseGeminiCliQuotaPayload(quotaResponse.body ?? quotaResponse.bodyText);
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
if (buckets.length === 0) return [];
const parsedBuckets = buckets
.map((bucket) => {
@@ -487,7 +667,26 @@ const fetchGeminiCliQuota = async (
})
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
return buildGeminiCliQuotaBuckets(parsedBuckets);
const builtBuckets = buildGeminiCliQuotaBuckets(parsedBuckets);
const supplementaryRequestId = scheduleGeminiCliSupplementaryRefresh(
file.name,
authIndex,
projectId,
t
);
const supplementarySnapshot = readGeminiCliSupplementarySnapshot(
file.name,
supplementaryRequestId
);
return {
fileName: file.name,
supplementaryRequestId,
buckets: builtBuckets,
tierLabel: supplementarySnapshot.tierLabel,
tierId: supplementarySnapshot.tierId,
creditBalance: supplementarySnapshot.creditBalance,
};
};
const renderAntigravityItems = (
@@ -527,6 +726,8 @@ const renderAntigravityItems = (
});
};
const PREMIUM_GEMINI_CLI_TIER_IDS = new Set(['g1-ultra-tier']);
const renderCodexItems = (
quota: CodexQuotaState,
t: TFunction,
@@ -540,6 +741,7 @@ const renderCodexItems = (
const getPlanLabel = (pt?: string | null): string | null => {
const normalized = normalizePlanType(pt);
if (!normalized) return null;
if (normalized === 'pro') return t('codex_quota.plan_pro');
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');
@@ -547,15 +749,17 @@ const renderCodexItems = (
};
const planLabel = getPlanLabel(planType);
const isPremiumPlan = normalizePlanType(planType) === 'pro';
const nodes: ReactNode[] = [];
if (planLabel) {
const valueClass = isPremiumPlan ? styleMap.premiumPlanValue : styleMap.codexPlanValue;
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)
h('span', { className: valueClass }, planLabel)
)
);
}
@@ -605,50 +809,89 @@ const renderGeminiCliItems = (
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const { createElement: h, Fragment } = React;
const buckets = quota.buckets ?? [];
const tierLabel = quota.tierLabel ?? null;
const tierId = quota.tierId ?? null;
const creditBalance = quota.creditBalance ?? null;
const isPremiumTier = tierId !== null && PREMIUM_GEMINI_CLI_TIER_IDS.has(tierId);
const nodes: ReactNode[] = [];
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 },
if (tierLabel) {
const valueClass = isPremiumTier ? styleMap.premiumPlanValue : styleMap.codexPlanValue;
nodes.push(
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel, title }, bucket.label),
{ key: 'tier', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('gemini_cli_quota.tier_label')),
h('span', { className: valueClass }, tierLabel)
)
);
}
if (creditBalance !== null) {
nodes.push(
h(
'div',
{ key: 'credits', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('gemini_cli_quota.credit_label')),
h(
'span',
{ className: styleMap.codexPlanValue },
t('gemini_cli_quota.credit_amount', { count: creditBalance })
)
)
);
}
if (buckets.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...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.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 })
);
});
{ 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 })
);
})
);
return h(Fragment, null, ...nodes);
};
const buildClaudeQuotaWindows = (
@@ -675,11 +918,16 @@ const buildClaudeQuotaWindows = (
return windows;
};
const CLAUDE_PLAN_TYPE_MAP: Record<string, string> = {
default_claude_max_5x: 'plan_max5',
default_claude_max_20x: 'plan_max20',
default_claude_pro: 'plan_pro',
default_claude_ai: 'plan_free',
const normalizeFlagValue = (value: unknown): boolean | undefined => {
if (value === undefined || value === null) return undefined;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const trimmed = value.trim().toLowerCase();
if (['true', '1', 'yes', 'y', 'on'].includes(trimmed)) return true;
if (['false', '0', 'no', 'n', 'off'].includes(trimmed)) return false;
}
return undefined;
};
const parseClaudeProfilePayload = (payload: unknown): ClaudeProfileResponse | null => {
@@ -702,10 +950,15 @@ const parseClaudeProfilePayload = (payload: unknown): ClaudeProfileResponse | nu
const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string | null => {
if (!profile) return null;
const tier = normalizeStringValue(profile.organization?.rate_limit_tier);
if (!tier) return null;
const hasClaudeMax = normalizeFlagValue(profile.account?.has_claude_max);
if (hasClaudeMax) return 'plan_max';
return CLAUDE_PLAN_TYPE_MAP[tier] ?? 'plan_unknown';
const hasClaudePro = normalizeFlagValue(profile.account?.has_claude_pro);
if (hasClaudePro) return 'plan_pro';
if (hasClaudeMax === false && hasClaudePro === false) return 'plan_free';
return null;
};
const fetchClaudeQuota = async (
@@ -917,7 +1170,17 @@ export const CODEX_CONFIG: QuotaConfig<
renderQuotaItems: renderCodexItems,
};
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
export const GEMINI_CLI_CONFIG: QuotaConfig<
GeminiCliQuotaState,
{
fileName: string;
supplementaryRequestId: number;
buckets: GeminiCliQuotaBucketState[];
tierLabel: string | null;
tierId: string | null;
creditBalance: number | null;
}
> = {
type: 'gemini-cli',
i18nPrefix: 'gemini_cli_quota',
cardIdleMessageKey: 'quota_management.card_idle_hint',
@@ -926,8 +1189,21 @@ export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaB
fetchQuota: fetchGeminiCliQuota,
storeSelector: (state) => state.geminiCliQuota,
storeSetter: 'setGeminiCliQuota',
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
buildLoadingState: () => ({ status: 'loading', buckets: [], tierLabel: null, tierId: null, creditBalance: null }),
buildSuccessState: (data) => {
const supplementarySnapshot = readGeminiCliSupplementarySnapshot(
data.fileName,
data.supplementaryRequestId
);
return {
status: 'success',
buckets: data.buckets,
tierLabel: supplementarySnapshot.tierLabel ?? data.tierLabel,
tierId: supplementarySnapshot.tierId ?? data.tierId,
creditBalance: supplementarySnapshot.creditBalance ?? data.creditBalance,
};
},
buildErrorState: (message, status) => ({
status: 'error',
buckets: [],
@@ -0,0 +1,87 @@
@use '../../styles/variables' as *;
.root {
position: relative;
display: inline-flex;
align-items: center;
gap: $spacing-sm;
cursor: pointer;
user-select: none;
}
.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.box {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 7px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
color: var(--primary-contrast, #fff);
display: inline-flex;
align-items: center;
justify-content: center;
transition:
border-color $transition-fast,
background-color $transition-fast,
box-shadow $transition-fast,
transform $transition-fast;
}
.root:hover .box {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
}
.root:active .box {
transform: scale(0.95);
}
.disabled:hover .box {
border-color: var(--border-color);
box-shadow: none;
}
.disabled:active .box {
transform: none;
}
.input:focus-visible + .box {
border-color: var(--primary-color);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent),
0 0 0 1px color-mix(in srgb, var(--primary-color) 50%, transparent);
}
.boxChecked {
border-color: var(--primary-color);
background: var(--primary-color);
}
.boxChecked svg {
display: block;
stroke-width: 2.4;
}
.label {
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
}
+50
View File
@@ -0,0 +1,50 @@
import type { ChangeEvent, ReactNode } from 'react';
import { IconCheck } from './icons';
import styles from './SelectionCheckbox.module.scss';
interface SelectionCheckboxProps {
checked: boolean;
onChange: (value: boolean) => void;
label?: ReactNode;
ariaLabel?: string;
title?: string;
disabled?: boolean;
className?: string;
labelClassName?: string;
}
export function SelectionCheckbox({
checked,
onChange,
label,
ariaLabel,
title,
disabled = false,
className,
labelClassName,
}: SelectionCheckboxProps) {
const rootClassName = [styles.root, disabled ? styles.disabled : '', className]
.filter(Boolean)
.join(' ');
const boxClassName = [styles.box, checked ? styles.boxChecked : ''].filter(Boolean).join(' ');
const textClassName = [styles.label, labelClassName].filter(Boolean).join(' ');
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.checked);
};
return (
<label className={rootClassName} title={title}>
<input
className={styles.input}
type="checkbox"
checked={checked}
onChange={handleChange}
aria-label={ariaLabel}
disabled={disabled}
/>
<span className={boxClassName}>{checked ? <IconCheck size={12} /> : null}</span>
{label ? <span className={textClassName}>{label}</span> : null}
</label>
);
}
+118 -1
View File
@@ -16,7 +16,15 @@ const baseSvgProps: SVGProps<SVGSVGElement> = {
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
focusable: 'false'
focusable: 'false',
};
const sidebarSvgProps: SVGProps<SVGSVGElement> = {
...baseSvgProps,
strokeWidth: 1.72,
strokeLinecap: 'square',
strokeLinejoin: 'miter',
strokeMiterlimit: 10,
};
export function IconSlidersHorizontal({ size = 20, ...props }: IconProps) {
@@ -322,3 +330,112 @@ export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
</svg>
);
}
export function IconSidebarDashboard({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<rect x="3" y="3" width="7.5" height="8" rx="1.5" />
<rect x="13.5" y="3" width="7.5" height="5" rx="1.5" fill="currentColor" fillOpacity="0.12" />
<rect x="3" y="14" width="7.5" height="7" rx="1.5" fill="currentColor" fillOpacity="0.12" />
<rect x="13.5" y="11" width="7.5" height="10" rx="1.5" />
</svg>
);
}
export function IconSidebarConfig({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<path d="M4 8h16" />
<path d="M4 16h16" />
<circle cx="9.5" cy="8" r="2.8" fill="currentColor" fillOpacity="0.12" />
<circle cx="15" cy="16" r="2.8" fill="currentColor" fillOpacity="0.12" />
</svg>
);
}
export function IconSidebarProviders({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<circle cx="12" cy="5.5" r="2.8" fill="currentColor" fillOpacity="0.12" />
<circle cx="5.5" cy="18.5" r="2.8" />
<circle cx="18.5" cy="18.5" r="2.8" />
<path d="M10.2 7.8 7 16.2" />
<path d="M13.8 7.8 17 16.2" />
<path d="M8.3 18.5h7.4" />
</svg>
);
}
export function IconSidebarAuthFiles({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<path d="M7 3h7l4 4v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" />
<path d="M14 3v4h4" fill="currentColor" fillOpacity="0.12" />
<path d="M9 13l2 2 4-4" />
</svg>
);
}
export function IconSidebarOauth({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<path
d="M12 3l8 4v5c0 5.25-3.4 8.25-8 10-4.6-1.75-8-4.75-8-10V7Z"
fill="currentColor"
fillOpacity="0.08"
/>
<circle cx="12" cy="11" r="1.5" fill="currentColor" stroke="none" />
<path d="M12 12.5v2.5" />
</svg>
);
}
export function IconSidebarQuota({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<circle cx="12" cy="12" r="8" />
<path d="M12 12V4a8 8 0 0 1 8 8Z" fill="currentColor" fillOpacity="0.12" />
</svg>
);
}
export function IconSidebarUsage({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<path d="M3.5 20h17" />
<rect x="5" y="13" width="3.5" height="7" rx="0.5" />
<rect x="10.25" y="7" width="3.5" height="13" rx="0.5" fill="currentColor" fillOpacity="0.12" />
<rect x="15.5" y="10" width="3.5" height="10" rx="0.5" />
</svg>
);
}
export function IconSidebarLogs({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 8.5h18" />
<circle cx="5.5" cy="6.2" r="0.8" fill="currentColor" stroke="none" />
<circle cx="7.8" cy="6.2" r="0.8" fill="currentColor" fillOpacity="0.4" stroke="none" />
<path d="M7 12l3 2.5-3 2.5" />
<path d="M13 17h4" />
</svg>
);
}
export function IconSidebarSystem({ size = 20, ...props }: IconProps) {
return (
<svg {...sidebarSvgProps} width={size} height={size} {...props}>
<rect x="6" y="6" width="12" height="12" rx="2" />
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" fillOpacity="0.12" />
<path d="M6 10H3" />
<path d="M6 14H3" />
<path d="M21 10h-3" />
<path d="M21 14h-3" />
<path d="M10 6V3" />
<path d="M14 6V3" />
<path d="M10 21v-3" />
<path d="M14 21v-3" />
</svg>
);
}
@@ -1,10 +1,10 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import {
IconBot,
IconCheck,
IconCode,
IconDownload,
IconInfo,
@@ -22,6 +22,7 @@ import {
getTypeColor,
getTypeLabel,
isRuntimeOnlyAuthFile,
parsePriorityValue,
resolveAuthFileStats,
type QuotaProviderType,
type ResolvedTheme,
@@ -110,6 +111,9 @@ export function AuthFileCard(props: AuthFileCardProps) {
const hasStatusWarning =
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(rawStatusMessage.toLowerCase());
const priorityValue = parsePriorityValue(file.priority ?? file['priority']);
const noteValue = typeof file.note === 'string' ? file.note.trim() : '';
return (
<div
className={`${styles.fileCard} ${providerCardClass} ${selected ? styles.fileCardSelected : ''} ${file.disabled ? styles.fileCardDisabled : ''}`}
@@ -118,18 +122,14 @@ export function AuthFileCard(props: AuthFileCardProps) {
<div className={styles.fileCardMain}>
<div className={styles.cardHeader}>
{!isRuntimeOnly && (
<button
type="button"
className={`${styles.selectionToggle} ${selected ? styles.selectionToggleActive : ''}`}
onClick={() => onToggleSelect(file.name)}
<SelectionCheckbox
checked={selected}
onChange={() => onToggleSelect(file.name)}
aria-label={
selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')
}
aria-pressed={selected}
title={selected ? t('auth_files.batch_deselect') : t('auth_files.batch_select_all')}
>
{selected && <IconCheck size={12} />}
</button>
/>
)}
<span
className={styles.typeBadge}
@@ -151,8 +151,20 @@ export function AuthFileCard(props: AuthFileCardProps) {
<span>
{t('auth_files.file_modified')}: {formatModified(file)}
</span>
{priorityValue !== undefined && (
<span className={styles.priorityBadge}>
{t('auth_files.priority_display')}: <span className={styles.priorityValue}>{priorityValue}</span>
</span>
)}
</div>
{noteValue && (
<div className={styles.noteText} title={noteValue}>
<span className={styles.noteLabel}>{t('auth_files.note_display')}: </span>
{noteValue}
</div>
)}
{rawStatusMessage && hasStatusWarning && (
<div className={styles.healthStatusMessage} title={rawStatusMessage}>
{rawStatusMessage}
@@ -114,14 +114,22 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('disableCooling', e.target.value)}
/>
<Input
label={t('auth_files.note_label')}
value={editor.note}
placeholder={t('auth_files.note_placeholder')}
hint={t('auth_files.note_hint')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('note', e.target.value)}
/>
{editor.isCodexFile && (
<div className="form-group">
<label>{t('ai_providers.codex_websockets_label')}</label>
<ToggleSwitch
checked={Boolean(editor.websocket)}
checked={Boolean(editor.websockets)}
disabled={disableControls || editor.saving || !editor.json}
ariaLabel={t('ai_providers.codex_websockets_label')}
onChange={(value) => onChange('websocket', value)}
onChange={(value) => onChange('websockets', value)}
/>
<div className="hint">{t('ai_providers.codex_websockets_hint')}</div>
</div>
+13
View File
@@ -160,6 +160,19 @@ export const parseDisableCoolingValue = (value: unknown): boolean | undefined =>
return undefined;
};
export const readCodexAuthFileWebsockets = (value: Record<string, unknown>): boolean =>
parseDisableCoolingValue(value.websockets) ?? false;
export const applyCodexAuthFileWebsockets = (
value: Record<string, unknown>,
websockets: boolean
): Record<string, unknown> => {
const next = { ...value };
delete next.websocket;
next.websockets = websockets;
return next;
};
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw;
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi, isAuthFileInvalidJsonObjectError } from '@/services/api';
import { authFilesApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
@@ -65,17 +65,6 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
const fileInputRef = useRef<HTMLInputElement | null>(null);
const selectionCount = selectedFiles.size;
const resolveStatusUpdateErrorMessage = useCallback(
(err: unknown) => {
if (isAuthFileInvalidJsonObjectError(err)) {
return t('auth_files.prefix_proxy_invalid_json');
}
return err instanceof Error ? err.message : '';
},
[t]
);
const toggleSelect = useCallback((name: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
@@ -393,9 +382,10 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
try {
await authFilesApi.setStatus(name, nextDisabled);
await loadFiles();
void refreshKeyStats().catch(() => {});
const res = await authFilesApi.setStatus(name, nextDisabled);
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
);
showNotification(
enabled
? t('auth_files.status_enabled_success', { name })
@@ -403,7 +393,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
'success'
);
} catch (err: unknown) {
const errorMessage = resolveStatusUpdateErrorMessage(err);
const errorMessage = err instanceof Error ? err.message : '';
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
);
@@ -417,7 +407,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
});
}
},
[loadFiles, refreshKeyStats, resolveStatusUpdateErrorMessage, showNotification, t]
[showNotification, t]
);
const batchSetStatus = useCallback(
@@ -427,9 +417,6 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
const targetNames = new Set(uniqueNames);
const nextDisabled = !enabled;
const previousDisabled = new Map(
files.map((file) => [file.name, file.disabled === true] as const)
);
setFiles((prev) =>
prev.map((file) =>
@@ -443,26 +430,31 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
let successCount = 0;
let failCount = 0;
const failedNames = new Set<string>();
const confirmedDisabled = new Map<string, boolean>();
results.forEach((result) => {
results.forEach((result, index) => {
const name = uniqueNames[index];
if (result.status === 'fulfilled') {
successCount++;
confirmedDisabled.set(name, result.value.disabled);
} else {
failCount++;
failedNames.add(name);
}
});
if (successCount > 0) {
await loadFiles();
void refreshKeyStats().catch(() => {});
} else {
setFiles((prev) =>
prev.map((file) => {
if (!targetNames.has(file.name)) return file;
return { ...file, disabled: previousDisabled.get(file.name) === true };
})
);
}
setFiles((prev) =>
prev.map((file) => {
if (failedNames.has(file.name)) {
return { ...file, disabled: !nextDisabled };
}
if (confirmedDisabled.has(file.name)) {
return { ...file, disabled: confirmedDisabled.get(file.name) };
}
return file;
})
);
if (failCount === 0) {
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
@@ -475,7 +467,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles
deselectAll();
},
[deselectAll, files, loadFiles, refreshKeyStats, showNotification, t]
[deselectAll, showNotification, t]
);
const batchDelete = useCallback(
@@ -6,10 +6,12 @@ import { useNotificationStore } from '@/stores';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
import {
applyCodexAuthFileWebsockets,
normalizeExcludedModels,
parseDisableCoolingValue,
parseExcludedModelsText,
parsePriorityValue,
readCodexAuthFileWebsockets,
} from '@/features/authFiles/constants';
export type PrefixProxyEditorField =
@@ -18,7 +20,8 @@ export type PrefixProxyEditorField =
| 'priority'
| 'excludedModelsText'
| 'disableCooling'
| 'websocket';
| 'websockets'
| 'note';
export type PrefixProxyEditorFieldValue = string | boolean;
@@ -36,7 +39,9 @@ export type PrefixProxyEditorState = {
priority: string;
excludedModelsText: string;
disableCooling: string;
websocket: boolean;
websockets: boolean;
note: string;
noteTouched: boolean;
};
export type UseAuthFilesPrefixProxyEditorOptions = {
@@ -89,11 +94,18 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
delete next.disable_cooling;
}
if (editor.isCodexFile) {
next.websocket = editor.websocket;
if (editor.noteTouched) {
const noteValue = editor.note.trim();
if (noteValue) {
next.note = editor.note;
} else if ('note' in next) {
delete next.note;
}
}
return JSON.stringify(next);
return JSON.stringify(
editor.isCodexFile ? applyCodexAuthFileWebsockets(next, editor.websockets) : next
);
};
export function useAuthFilesPrefixProxyEditor(
@@ -145,7 +157,9 @@ export function useAuthFilesPrefixProxyEditor(
priority: '',
excludedModelsText: '',
disableCooling: '',
websocket: false,
websockets: false,
note: '',
noteTouched: false,
});
try {
@@ -185,8 +199,9 @@ export function useAuthFilesPrefixProxyEditor(
const json = { ...(parsed as Record<string, unknown>) };
if (isCodexFile) {
const websocketValue = parseDisableCoolingValue(json.websocket);
json.websocket = websocketValue ?? false;
const normalizedWebsockets = readCodexAuthFileWebsockets(json);
delete json.websocket;
json.websockets = normalizedWebsockets;
}
const originalText = JSON.stringify(json);
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
@@ -194,7 +209,8 @@ export function useAuthFilesPrefixProxyEditor(
const priority = parsePriorityValue(json.priority);
const excludedModels = normalizeExcludedModels(json.excluded_models);
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
const websocketValue = parseDisableCoolingValue(json.websocket);
const websocketsValue = readCodexAuthFileWebsockets(json);
const note = typeof json.note === 'string' ? json.note : '';
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
@@ -210,7 +226,9 @@ export function useAuthFilesPrefixProxyEditor(
excludedModelsText: excludedModels.join('\n'),
disableCooling:
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
websocket: websocketValue ?? false,
websockets: websocketsValue,
note,
noteTouched: false,
error: null,
};
});
@@ -235,7 +253,8 @@ export function useAuthFilesPrefixProxyEditor(
if (field === 'priority') return { ...prev, priority: String(value) };
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
return { ...prev, websocket: Boolean(value) };
if (field === 'note') return { ...prev, note: String(value), noteTouched: true };
return { ...prev, websockets: Boolean(value) };
});
};
+9
View File
@@ -1,12 +1,21 @@
export const AUTH_FILES_SORT_MODES = ['default', 'az', 'priority'] as const;
export type AuthFilesSortMode = (typeof AUTH_FILES_SORT_MODES)[number];
export type AuthFilesUiState = {
filter?: string;
problemOnly?: boolean;
search?: string;
page?: number;
pageSize?: number;
sortMode?: AuthFilesSortMode;
};
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
const AUTH_FILES_SORT_MODE_SET = new Set<AuthFilesSortMode>(AUTH_FILES_SORT_MODES);
export const isAuthFilesSortMode = (value: unknown): value is AuthFilesSortMode =>
typeof value === 'string' && AUTH_FILES_SORT_MODE_SET.has(value as AuthFilesSortMode);
export const readAuthFilesUiState = (): AuthFilesUiState | null => {
if (typeof window === 'undefined') return null;
+24 -7
View File
@@ -342,18 +342,17 @@
"vertex_add_modal_title": "Add Vertex API Configuration",
"vertex_add_modal_key_label": "API Key:",
"vertex_add_modal_key_placeholder": "Please enter Vertex API key",
"vertex_add_modal_url_label": "Base URL (Required):",
"vertex_add_modal_url_label": "Base URL:",
"vertex_add_modal_url_placeholder": "e.g.: https://example.com/api",
"vertex_add_modal_proxy_label": "Proxy URL (Optional):",
"vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "Edit Vertex API Configuration",
"vertex_edit_modal_key_label": "API Key:",
"vertex_edit_modal_url_label": "Base URL (Required):",
"vertex_edit_modal_url_label": "Base URL:",
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
"vertex_models_label": "Model aliases (alias required):",
"vertex_models_label": "Model aliases:",
"vertex_models_add_btn": "Add Mapping",
"vertex_models_hint": "Each alias needs both the original model and the alias.",
"vertex_models_count": "Alias count",
"ampcode_title": "Amp CLI Integration (ampcode)",
"ampcode_modal_title": "Configure Ampcode",
@@ -496,6 +495,11 @@
"search_placeholder": "Filter by name, type, or provider",
"problem_filter_label": "Problem Filter",
"problem_filter_only": "Only show problematic credentials",
"sort_label": "Sort",
"sort_default": "Default",
"sort_az": "A-Z Name",
"sort_priority": "Priority",
"priority_display": "Priority",
"page_size_label": "Per page",
"page_size_unit": "items",
"view_mode_paged": "Paged",
@@ -565,6 +569,10 @@
"disable_cooling_label": "Disable cooling (disable_cooling)",
"disable_cooling_placeholder": "e.g. true / false / 1 / 0",
"disable_cooling_hint": "Supports booleans, numeric 0/non-0, and strings like true/false/1/0; unparseable values are ignored.",
"note_label": "Note",
"note_placeholder": "Enter a note, e.g.: John's account",
"note_hint": "Optional. Used to describe the purpose or owner of this credential; leave empty to omit.",
"note_display": "Note",
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
@@ -605,6 +613,7 @@
"plan_unknown": "Unknown",
"plan_free": "Free",
"plan_pro": "Pro",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
},
@@ -630,7 +639,8 @@
"plan_label": "Plan",
"plan_plus": "Plus",
"plan_team": "Team",
"plan_free": "Free"
"plan_free": "Free",
"plan_pro": "Pro"
},
"gemini_cli_quota": {
"title": "Gemini CLI Quota",
@@ -644,7 +654,15 @@
"empty_buckets": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"remaining_amount": "Remaining {{count}}"
"remaining_amount": "Remaining {{count}}",
"tier_label": "Tier",
"tier_free": "Free",
"tier_legacy": "Legacy",
"tier_standard": "Standard",
"tier_pro": "Pro",
"tier_ultra": "Ultra",
"credit_label": "Google One AI Credits",
"credit_amount": "{{count}} credits"
},
"kimi_quota": {
"title": "Kimi Quota",
@@ -1373,7 +1391,6 @@
"vertex_config_added": "Vertex configuration added successfully",
"vertex_config_updated": "Vertex configuration updated successfully",
"vertex_config_deleted": "Vertex configuration deleted successfully",
"vertex_base_url_required": "Please enter the Vertex Base URL",
"config_enabled": "Configuration enabled",
"config_disabled": "Configuration disabled",
"field_required": "Required fields cannot be empty",
+24 -7
View File
@@ -342,18 +342,17 @@
"vertex_add_modal_title": "Добавление конфигурации Vertex API",
"vertex_add_modal_key_label": "API-ключ:",
"vertex_add_modal_key_placeholder": "Введите API-ключ Vertex",
"vertex_add_modal_url_label": "Базовый URL (обязательно):",
"vertex_add_modal_url_label": "Базовый URL:",
"vertex_add_modal_url_placeholder": "например: https://example.com/api",
"vertex_add_modal_proxy_label": "URL прокси (необязательно):",
"vertex_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "Редактирование конфигурации Vertex API",
"vertex_edit_modal_key_label": "API-ключ:",
"vertex_edit_modal_url_label": "Базовый URL (обязательно):",
"vertex_edit_modal_url_label": "Базовый URL:",
"vertex_edit_modal_proxy_label": "URL прокси (необязательно):",
"vertex_delete_confirm": "Удалить эту конфигурацию Vertex?",
"vertex_models_label": "Псевдонимы моделей (требуется псевдоним):",
"vertex_models_label": "Псевдонимы моделей:",
"vertex_models_add_btn": "Добавить сопоставление",
"vertex_models_hint": "Каждому псевдониму требуются исходная модель и псевдоним.",
"vertex_models_count": "Количество псевдонимов",
"ampcode_title": "Интеграция Amp CLI (ampcode)",
"ampcode_modal_title": "Настройка Ampcode",
@@ -496,6 +495,11 @@
"search_placeholder": "Фильтр по имени, типу или провайдеру",
"problem_filter_label": "Фильтр проблем",
"problem_filter_only": "Показывать только проблемные учётные данные",
"sort_label": "Сортировка",
"sort_default": "По умолчанию",
"sort_az": "A-Z Имя",
"sort_priority": "Приоритет",
"priority_display": "Приоритет",
"page_size_label": "На странице",
"page_size_unit": "элементов",
"view_mode_paged": "Постранично",
@@ -565,6 +569,10 @@
"disable_cooling_label": "Отключение охлаждения (disable_cooling)",
"disable_cooling_placeholder": "например: true / false / 1 / 0",
"disable_cooling_hint": "Поддерживает boolean, числа 0/не 0 и строки true/false/1/0; непарсируемые значения игнорируются.",
"note_label": "Заметка (note)",
"note_placeholder": "Введите заметку, например: аккаунт Ивана",
"note_hint": "Необязательно. Используется для описания назначения или владельца учётных данных; оставьте пустым, чтобы не записывать.",
"note_display": "Заметка",
"prefix_proxy_invalid_json": "Этот файл авторизации не является JSON-объектом, поэтому поля нельзя редактировать.",
"prefix_proxy_saved_success": "Файл авторизации \"{{name}}\" успешно обновлён",
"card_tools_title": "Инструменты",
@@ -608,6 +616,7 @@
"plan_unknown": "Неизвестно",
"plan_free": "Free",
"plan_pro": "Pro",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
},
@@ -633,7 +642,8 @@
"plan_label": "Тариф",
"plan_plus": "Plus",
"plan_team": "Team",
"plan_free": "Free"
"plan_free": "Free",
"plan_pro": "Pro"
},
"gemini_cli_quota": {
"title": "Квота Gemini CLI",
@@ -647,7 +657,15 @@
"empty_buckets": "Данные по квоте отсутствуют",
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все",
"remaining_amount": "Осталось {{count}}"
"remaining_amount": "Осталось {{count}}",
"tier_label": "Уровень",
"tier_free": "Бесплатный",
"tier_legacy": "Устаревший",
"tier_standard": "Стандартный",
"tier_pro": "Pro",
"tier_ultra": "Ultra",
"credit_label": "Google One AI кредиты",
"credit_amount": "{{count}} кредитов"
},
"kimi_quota": {
"title": "Квота Kimi",
@@ -1378,7 +1396,6 @@
"vertex_config_added": "Конфигурация Vertex успешно добавлена",
"vertex_config_updated": "Конфигурация Vertex успешно обновлена",
"vertex_config_deleted": "Конфигурация Vertex успешно удалена",
"vertex_base_url_required": "Введите базовый URL Vertex",
"config_enabled": "Конфигурация включена",
"config_disabled": "Конфигурация выключена",
"field_required": "Обязательные поля не могут быть пустыми",
+24 -7
View File
@@ -342,18 +342,17 @@
"vertex_add_modal_title": "添加Vertex API配置",
"vertex_add_modal_key_label": "API密钥:",
"vertex_add_modal_key_placeholder": "请输入Vertex API密钥",
"vertex_add_modal_url_label": "Base URL (必填):",
"vertex_add_modal_url_label": "Base URL:",
"vertex_add_modal_url_placeholder": "例如: https://example.com/api",
"vertex_add_modal_proxy_label": "代理 URL (可选):",
"vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "编辑Vertex API配置",
"vertex_edit_modal_key_label": "API密钥:",
"vertex_edit_modal_url_label": "Base URL (必填):",
"vertex_edit_modal_url_label": "Base URL:",
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
"vertex_delete_confirm": "确定要删除这个Vertex配置吗?",
"vertex_models_label": "模型别名 (别名必填):",
"vertex_models_label": "模型别名:",
"vertex_models_add_btn": "添加映射",
"vertex_models_hint": "每条别名需要填写原模型与别名。",
"vertex_models_count": "别名数量",
"ampcode_title": "Amp CLI 集成 (ampcode)",
"ampcode_modal_title": "配置 Ampcode",
@@ -496,6 +495,11 @@
"search_placeholder": "输入名称、类型或提供方关键字",
"problem_filter_label": "问题筛选",
"problem_filter_only": "仅显示有问题凭证",
"sort_label": "排序",
"sort_default": "默认",
"sort_az": "A-Z 名称",
"sort_priority": "优先级",
"priority_display": "优先级",
"page_size_label": "单页数量",
"page_size_unit": "个/页",
"view_mode_paged": "按页显示",
@@ -565,6 +569,10 @@
"disable_cooling_label": "禁用冷却(disable_cooling",
"disable_cooling_placeholder": "例如: true / false / 1 / 0",
"disable_cooling_hint": "支持布尔值、0/非0 数字或字符串 true/false/1/0;无法解析时忽略。",
"note_label": "备注(note",
"note_placeholder": "输入备注信息,例如:张三的账号",
"note_hint": "可选,用于标记凭证用途或归属;留空则不写入。",
"note_display": "备注",
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
@@ -605,6 +613,7 @@
"plan_unknown": "未知",
"plan_free": "免费版",
"plan_pro": "专业版",
"plan_max": "Max",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
},
@@ -630,7 +639,8 @@
"plan_label": "套餐",
"plan_plus": "Plus",
"plan_team": "Team",
"plan_free": "Free"
"plan_free": "Free",
"plan_pro": "Pro"
},
"gemini_cli_quota": {
"title": "Gemini CLI 额度",
@@ -644,7 +654,15 @@
"empty_buckets": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"remaining_amount": "剩余 {{count}}"
"remaining_amount": "剩余 {{count}}",
"tier_label": "层级",
"tier_free": "免费版",
"tier_legacy": "旧版",
"tier_standard": "标准版",
"tier_pro": "Pro",
"tier_ultra": "Ultra",
"credit_label": "Google One AI 积分",
"credit_amount": "{{count}} 积分"
},
"kimi_quota": {
"title": "Kimi 额度",
@@ -1373,7 +1391,6 @@
"vertex_config_added": "Vertex配置添加成功",
"vertex_config_updated": "Vertex配置更新成功",
"vertex_config_deleted": "Vertex配置删除成功",
"vertex_base_url_required": "请填写Vertex Base URL",
"config_enabled": "配置已启用",
"config_disabled": "配置已停用",
"field_required": "必填字段不能为空",
+21 -6
View File
@@ -164,7 +164,7 @@ export function AiProvidersPage() {
};
const setConfigEnabled = async (
provider: 'gemini' | 'codex' | 'claude',
provider: 'gemini' | 'codex' | 'claude' | 'vertex',
index: number,
enabled: boolean
) => {
@@ -204,7 +204,12 @@ export function AiProvidersPage() {
return;
}
const source = provider === 'codex' ? codexConfigs : claudeConfigs;
const source =
provider === 'codex'
? codexConfigs
: provider === 'claude'
? claudeConfigs
: vertexConfigs;
const current = source[index];
if (!current) return;
@@ -222,17 +227,23 @@ export function AiProvidersPage() {
setCodexConfigs(nextList);
updateConfigValue('codex-api-key', nextList);
clearCache('codex-api-key');
} else {
} else if (provider === 'claude') {
setClaudeConfigs(nextList);
updateConfigValue('claude-api-key', nextList);
clearCache('claude-api-key');
} else {
setVertexConfigs(nextList);
updateConfigValue('vertex-api-key', nextList);
clearCache('vertex-api-key');
}
try {
if (provider === 'codex') {
await providersApi.saveCodexConfigs(nextList);
} else {
} else if (provider === 'claude') {
await providersApi.saveClaudeConfigs(nextList);
} else {
await providersApi.saveVertexConfigs(nextList);
}
showNotification(
enabled ? t('notification.config_enabled') : t('notification.config_disabled'),
@@ -244,10 +255,14 @@ export function AiProvidersPage() {
setCodexConfigs(previousList);
updateConfigValue('codex-api-key', previousList);
clearCache('codex-api-key');
} else {
} else if (provider === 'claude') {
setClaudeConfigs(previousList);
updateConfigValue('claude-api-key', previousList);
clearCache('claude-api-key');
} else {
setVertexConfigs(previousList);
updateConfigValue('vertex-api-key', previousList);
clearCache('vertex-api-key');
}
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
@@ -366,7 +381,6 @@ export function AiProvidersPage() {
loading={loading}
disableControls={disableControls}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
onAdd={() => openEditor('/ai-providers/codex/new')}
onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)}
onDelete={(index) => void deleteProviderEntry('codex', index)}
@@ -400,6 +414,7 @@ export function AiProvidersPage() {
onAdd={() => openEditor('/ai-providers/vertex/new')}
onEdit={(index) => openEditor(`/ai-providers/vertex/${index}`)}
onDelete={deleteVertex}
onToggle={(index, enabled) => void setConfigEnabled('vertex', index, enabled)}
/>
</div>
-5
View File
@@ -192,10 +192,6 @@ export function AiProvidersVertexEditPage() {
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
const baseUrl = trimmedBaseUrl || undefined;
if (!baseUrl) {
showNotification(t('notification.vertex_base_url_required'), 'error');
return;
}
setSaving(true);
setError('');
@@ -347,7 +343,6 @@ export function AiProvidersVertexEditPage() {
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
</div>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
@@ -170,19 +170,19 @@
}
.modelItem {
display: flex;
align-items: center;
gap: $spacing-sm;
width: 100%;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
border-radius: $radius-sm;
transition: background-color $transition-fast;
&:last-child {
border-bottom: none;
}
input {
width: 16px;
height: 16px;
&:hover {
background-color: var(--bg-hover);
}
}
@@ -191,6 +191,7 @@
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.modelId {
+17 -14
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { EmptyState } from '@/components/ui/EmptyState';
import { IconInfo } from '@/components/ui/icons';
@@ -400,20 +401,22 @@ export function AuthFilesOAuthExcludedEditPage() {
{modelsList.map((model) => {
const checked = selectedModels.has(model.id);
return (
<label key={model.id} className={styles.modelItem}>
<input
type="checkbox"
checked={checked}
disabled={disableControls || saving}
onChange={(event) => toggleModel(model.id, event.target.checked)}
/>
<span className={styles.modelText}>
<span className={styles.modelId}>{model.id}</span>
{model.display_name && model.display_name !== model.id && (
<span className={styles.modelDisplayName}>{model.display_name}</span>
)}
</span>
</label>
<SelectionCheckbox
key={model.id}
checked={checked}
disabled={disableControls || saving}
onChange={(value) => toggleModel(model.id, value)}
className={styles.modelItem}
labelClassName={styles.modelText}
label={
<>
<span className={styles.modelId}>{model.id}</span>
{model.display_name && model.display_name !== model.id && (
<span className={styles.modelDisplayName}>{model.display_name}</span>
)}
</>
}
/>
);
})}
</div>
+83 -42
View File
@@ -81,7 +81,7 @@
.filterTag {
display: inline-flex;
align-items: baseline;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
@@ -104,13 +104,21 @@
.filterTagLabel {
display: inline-flex;
align-items: baseline;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.filterTagIcon {
width: 15px;
height: 15px;
flex: 0 0 auto;
object-fit: contain;
}
.filterTagCount {
display: inline-flex;
align-items: baseline;
align-items: center;
justify-content: flex-end;
min-width: 2ch;
font-size: 12px;
@@ -504,6 +512,45 @@
text-transform: capitalize;
}
.premiumPlanValue {
display: inline-flex;
align-items: center;
font-weight: 700;
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
background: linear-gradient(135deg, #fdf3d7, #f0d060);
border: 1px solid #e0b830;
box-shadow: 0 1px 4px rgba(200, 160, 0, 0.18);
color: #7a5c00;
text-transform: capitalize;
@media (dynamic-range: high) {
background: linear-gradient(135deg,
color(display-p3 0.99 0.95 0.82),
color(display-p3 0.97 0.82 0.28));
border-color: color(display-p3 0.90 0.73 0.12);
box-shadow: 0 1px 8px color(display-p3 1.0 0.84 0.0 / 0.25);
color: color(display-p3 0.50 0.38 0.0);
}
}
:global([data-theme='dark']) .premiumPlanValue {
background: linear-gradient(135deg, #3d3210, #5a4a18);
border-color: #8b7030;
box-shadow: 0 1px 8px rgba(180, 140, 0, 0.2);
color: #e8c84c;
@media (dynamic-range: high) {
background: linear-gradient(135deg,
color(display-p3 0.24 0.20 0.06),
color(display-p3 0.38 0.30 0.08));
border-color: color(display-p3 0.58 0.46 0.15);
box-shadow: 0 1px 12px color(display-p3 0.80 0.65 0.0 / 0.2);
color: color(display-p3 0.95 0.82 0.25);
}
}
// 单个认证文件卡片
.fileCard {
background-color: var(--bg-primary);
@@ -565,45 +612,6 @@
min-height: 28px;
}
.selectionToggle {
width: 22px;
height: 22px;
margin: 0;
flex-shrink: 0;
border-radius: 7px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-secondary) 92%, transparent);
color: var(--primary-contrast, #fff);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
border-color $transition-fast,
background-color $transition-fast,
box-shadow $transition-fast,
transform $transition-fast;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
}
&:active {
transform: scale(0.95);
}
}
.selectionToggleActive {
border-color: var(--primary-color);
background: var(--primary-color);
}
.selectionToggleActive svg {
display: block;
stroke-width: 2.4;
}
.typeBadge {
padding: 4px 10px;
border-radius: 12px;
@@ -632,6 +640,39 @@
border-bottom: 1px solid var(--border-color);
}
.priorityBadge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
.priorityValue {
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
}
.noteText {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
.noteLabel {
color: var(--text-tertiary);
}
}
.sortSelect {
min-width: 140px;
}
.healthStatusMessage {
font-size: 12px;
color: var(--warning-text);
+106 -8
View File
@@ -18,6 +18,7 @@ import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { copyToClipboard } from '@/utils/clipboard';
@@ -31,6 +32,7 @@ import {
hasAuthFileStatusMessage,
isRuntimeOnlyAuthFile,
normalizeProviderKey,
parsePriorityValue,
type QuotaProviderType,
type ResolvedTheme,
} from '@/features/authFiles/constants';
@@ -40,13 +42,27 @@ import { AuthFileModelsModal } from '@/features/authFiles/components/AuthFileMod
import { AuthFilesPrefixProxyEditorModal } from '@/features/authFiles/components/AuthFilesPrefixProxyEditorModal';
import { OAuthExcludedCard } from '@/features/authFiles/components/OAuthExcludedCard';
import { OAuthModelAliasCard } from '@/features/authFiles/components/OAuthModelAliasCard';
import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconCodex from '@/assets/icons/codex.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import { useAuthFilesData } from '@/features/authFiles/hooks/useAuthFilesData';
import { useAuthFilesModels } from '@/features/authFiles/hooks/useAuthFilesModels';
import { useAuthFilesOauth } from '@/features/authFiles/hooks/useAuthFilesOauth';
import { useAuthFilesPrefixProxyEditor } from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
import { useAuthFilesStats } from '@/features/authFiles/hooks/useAuthFilesStats';
import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
import { readAuthFilesUiState, writeAuthFilesUiState } from '@/features/authFiles/uiState';
import {
isAuthFilesSortMode,
readAuthFilesUiState,
writeAuthFilesUiState,
type AuthFilesSortMode,
} from '@/features/authFiles/uiState';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import styles from './AuthFilesPage.module.scss';
@@ -55,6 +71,28 @@ const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4;
const easePower2In = (progress: number) => progress ** 3;
const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
const AUTH_FILE_FILTER_ICONS: Record<string, string | { light: string; dark: string }> = {
antigravity: iconAntigravity,
aistudio: iconGemini,
claude: iconClaude,
codex: iconCodex,
gemini: iconGemini,
'gemini-cli': iconGemini,
iflow: iconIflow,
kimi: { light: iconKimiLight, dark: iconKimiDark },
qwen: iconQwen,
vertex: iconVertex,
};
const getFilterTagIcon = (type: string, resolvedTheme: ResolvedTheme): string | null => {
const iconEntry = AUTH_FILE_FILTER_ICONS[normalizeProviderKey(type)];
if (!iconEntry) return null;
return typeof iconEntry === 'string'
? iconEntry
: resolvedTheme === 'dark'
? iconEntry.dark
: iconEntry.light;
};
export function AuthFilesPage() {
const { t } = useTranslation();
@@ -74,6 +112,7 @@ export function AuthFilesPage() {
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [sortMode, setSortMode] = useState<AuthFilesSortMode>('default');
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
@@ -177,11 +216,14 @@ export function AuthFilesPage() {
if (typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)) {
setPageSize(clampCardPageSize(persisted.pageSize));
}
if (isAuthFilesSortMode(persisted.sortMode)) {
setSortMode(persisted.sortMode);
}
}, []);
useEffect(() => {
writeAuthFilesUiState({ filter, problemOnly, search, page, pageSize });
}, [filter, problemOnly, search, page, pageSize]);
writeAuthFilesUiState({ filter, problemOnly, search, page, pageSize, sortMode });
}, [filter, problemOnly, search, page, pageSize, sortMode]);
useEffect(() => {
setPageSizeInput(String(pageSize));
@@ -223,6 +265,16 @@ export function AuthFilesPage() {
setPage(1);
};
const handleSortModeChange = useCallback(
(value: string) => {
if (!isAuthFilesSortMode(value) || value === sortMode) return;
setSortMode(value);
setPage(1);
void loadFiles().catch(() => {});
},
[loadFiles, sortMode]
);
const handleHeaderRefresh = useCallback(async () => {
await Promise.all([loadFiles(), refreshKeyStats(), loadExcluded(), loadModelAlias()]);
}, [loadFiles, refreshKeyStats, loadExcluded, loadModelAlias]);
@@ -259,6 +311,15 @@ export function AuthFilesPage() {
[files, problemOnly]
);
const sortOptions = useMemo(
() => [
{ value: 'default', label: t('auth_files.sort_default') },
{ value: 'az', label: t('auth_files.sort_az') },
{ value: 'priority', label: t('auth_files.sort_priority') },
],
[t]
);
const typeCounts = useMemo(() => {
const counts: Record<string, number> = { all: filesMatchingProblemFilter.length };
filesMatchingProblemFilter.forEach((file) => {
@@ -281,10 +342,32 @@ export function AuthFilesPage() {
});
}, [filesMatchingProblemFilter, filter, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const sorted = useMemo(() => {
const copy = [...filtered];
if (sortMode === 'default') {
copy.sort((a, b) => {
const providerA = normalizeProviderKey(String(a.provider ?? a.type ?? 'unknown'));
const providerB = normalizeProviderKey(String(b.provider ?? b.type ?? 'unknown'));
const providerCompare = providerA.localeCompare(providerB);
if (providerCompare !== 0) return providerCompare;
return a.name.localeCompare(b.name);
});
} else if (sortMode === 'az') {
copy.sort((a, b) => a.name.localeCompare(b.name));
} else if (sortMode === 'priority') {
copy.sort((a, b) => {
const pa = parsePriorityValue(a.priority ?? a['priority']) ?? 0;
const pb = parsePriorityValue(b.priority ?? b['priority']) ?? 0;
return pb - pa; // 高优先级排前面
});
}
return copy;
}, [filtered, sortMode]);
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * pageSize;
const pageItems = filtered.slice(start, start + pageSize);
const pageItems = sorted.slice(start, start + pageSize);
const selectablePageItems = useMemo(
() => pageItems.filter((file) => !isRuntimeOnlyAuthFile(file)),
[pageItems]
@@ -433,6 +516,7 @@ export function AuthFilesPage() {
<div className={styles.filterTags}>
{existingTypes.map((type) => {
const isActive = filter === type;
const iconSrc = getFilterTagIcon(type, resolvedTheme);
const color =
type === 'all'
? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' }
@@ -452,7 +536,10 @@ export function AuthFilesPage() {
setPage(1);
}}
>
<span className={styles.filterTagLabel}>{getTypeLabel(t, type)}</span>
<span className={styles.filterTagLabel}>
{iconSrc && <img src={iconSrc} alt="" className={styles.filterTagIcon} />}
<span>{getTypeLabel(t, type)}</span>
</span>
<span className={styles.filterTagCount}>{typeCounts[type] ?? 0}</span>
</button>
);
@@ -559,6 +646,17 @@ export function AuthFilesPage() {
}}
/>
</div>
<div className={styles.filterItem}>
<label>{t('auth_files.sort_label')}</label>
<Select
className={styles.sortSelect}
value={sortMode}
options={sortOptions}
onChange={handleSortModeChange}
ariaLabel={t('auth_files.sort_label')}
fullWidth={false}
/>
</div>
<div className={`${styles.filterItem} ${styles.filterToggleItem}`}>
<label>{t('auth_files.problem_filter_label')}</label>
<div className={styles.filterToggle}>
@@ -615,7 +713,7 @@ export function AuthFilesPage() {
</div>
)}
{!loading && filtered.length > pageSize && (
{!loading && sorted.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
@@ -629,7 +727,7 @@ export function AuthFilesPage() {
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filtered.length,
count: sorted.length,
})}
</div>
<Button
+7 -5
View File
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox';
import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
@@ -233,11 +233,12 @@ export function LoginPage() {
</div>
<div className={styles.toggleAdvanced}>
<ToggleSwitch
<SelectionCheckbox
checked={showCustomBase}
onChange={setShowCustomBase}
ariaLabel={t('login.custom_connection_label')}
label={<span className={styles.toggleLabel}>{t('login.custom_connection_label')}</span>}
label={t('login.custom_connection_label')}
labelClassName={styles.toggleLabel}
/>
</div>
@@ -281,11 +282,12 @@ export function LoginPage() {
/>
<div className={styles.toggleAdvanced}>
<ToggleSwitch
<SelectionCheckbox
checked={rememberPassword}
onChange={setRememberPassword}
ariaLabel={t('login.remember_password_label')}
label={<span className={styles.toggleLabel}>{t('login.remember_password_label')}</span>}
label={t('login.remember_password_label')}
labelClassName={styles.toggleLabel}
/>
</div>
+2 -3
View File
@@ -8,8 +8,7 @@ import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/se
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import { copyToClipboard } from '@/utils/clipboard';
import styles from './OAuthPage.module.scss';
import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconCodexDark from '@/assets/icons/codex_drak.svg';
import iconCodex from '@/assets/icons/codex.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg';
@@ -73,7 +72,7 @@ function getErrorStatus(error: unknown): number | undefined {
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconCodexLight, dark: iconCodexDark } },
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: iconCodex },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
+39
View File
@@ -318,6 +318,45 @@
text-transform: capitalize;
}
.premiumPlanValue {
display: inline-flex;
align-items: center;
font-weight: 700;
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
background: linear-gradient(135deg, #fdf3d7, #f0d060);
border: 1px solid #e0b830;
box-shadow: 0 1px 4px rgba(200, 160, 0, 0.18);
color: #7a5c00;
text-transform: capitalize;
@media (dynamic-range: high) {
background: linear-gradient(135deg,
color(display-p3 0.99 0.95 0.82),
color(display-p3 0.97 0.82 0.28));
border-color: color(display-p3 0.90 0.73 0.12);
box-shadow: 0 1px 8px color(display-p3 1.0 0.84 0.0 / 0.25);
color: color(display-p3 0.50 0.38 0.0);
}
}
:global([data-theme='dark']) .premiumPlanValue {
background: linear-gradient(135deg, #3d3210, #5a4a18);
border-color: #8b7030;
box-shadow: 0 1px 8px rgba(180, 140, 0, 0.2);
color: #e8c84c;
@media (dynamic-range: high) {
background: linear-gradient(135deg,
color(display-p3 0.24 0.20 0.06),
color(display-p3 0.38 0.30 0.08));
border-color: color(display-p3 0.58 0.46 0.15);
box-shadow: 0 1px 12px color(display-p3 0.80 0.65 0.0 / 0.2);
color: color(display-p3 0.95 0.82 0.25);
}
}
.fileCard {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
+20 -8
View File
@@ -78,7 +78,10 @@
color: inherit;
padding: $spacing-md $spacing-lg;
cursor: pointer;
transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.18s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-1px);
@@ -97,6 +100,19 @@
color: var(--text-secondary);
}
.tileHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-sm;
}
.tileAction {
flex-shrink: 0;
white-space: nowrap;
margin: -4px -8px 0 0;
}
.tileValue {
font-size: 22px;
font-weight: 700;
@@ -111,12 +127,6 @@
line-height: 1.4;
}
.aboutActions {
display: flex;
justify-content: flex-end;
margin-top: $spacing-lg;
}
.section {
display: flex;
flex-direction: column;
@@ -212,7 +222,9 @@
border-radius: $radius-full;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
}
.modelName {
+241 -154
View File
@@ -5,8 +5,14 @@ import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore, useThemeStore } from '@/stores';
import { configApi } from '@/services/api';
import {
useAuthStore,
useConfigStore,
useNotificationStore,
useModelsStore,
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
@@ -36,6 +42,32 @@ const MODEL_CATEGORY_ICONS: Record<string, string | { light: string; dark: strin
minimax: iconMinimax,
};
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 SystemPage() {
const { t, i18n } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
@@ -51,11 +83,15 @@ export function SystemPage() {
const modelsError = useModelsStore((state) => state.error);
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
const [modelStatus, setModelStatus] = useState<{ type: 'success' | 'warning' | 'error' | 'muted'; message: string }>();
const [modelStatus, setModelStatus] = useState<{
type: 'success' | 'warning' | 'error' | 'muted';
message: string;
}>();
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const apiKeysCache = useRef<string[]>([]);
const versionTapCount = useRef(0);
@@ -136,7 +172,7 @@ export function SystemPage() {
if (auth.connectionStatus !== 'connected') {
setModelStatus({
type: 'warning',
message: t('notification.connection_required')
message: t('notification.connection_required'),
});
return;
}
@@ -158,11 +194,12 @@ export function SystemPage() {
const hasModels = list.length > 0;
setModelStatus({
type: hasModels ? 'success' : 'warning',
message: hasModels ? t('system_info.models_count', { count: list.length }) : t('system_info.models_empty')
message: hasModels
? t('system_info.models_count', { count: list.length })
: t('system_info.models_empty'),
});
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : '';
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
const suffix = message ? `: ${message}` : '';
const text = `${t('system_info.models_error')}${suffix}`;
setModelStatus({ type: 'error', message: text });
@@ -244,6 +281,39 @@ export function SystemPage() {
}
};
const handleVersionCheck = useCallback(async () => {
setCheckingVersion(true);
try {
const data = await versionApi.checkLatest();
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
const comparison = compareVersions(latest, auth.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: unknown) {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
const suffix = message ? `: ${message}` : '';
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
} finally {
setCheckingVersion(false);
}
}, [auth.serverVersion, showNotification, t]);
useEffect(() => {
fetchConfig().catch(() => {
// ignore
@@ -273,160 +343,177 @@ export function SystemPage() {
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('system_info.title')}</h1>
<div className={styles.content}>
<Card className={styles.aboutCard}>
<div className={styles.aboutHeader}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
</div>
<div className={styles.aboutInfoGrid}>
<button
type="button"
className={`${styles.infoTile} ${styles.tapTile}`}
onClick={handleInfoVersionTap}
>
<div className={styles.tileLabel}>{t('footer.version')}</div>
<div className={styles.tileValue}>{appVersion}</div>
</button>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
<div className={styles.tileValue}>{apiVersion}</div>
<Card className={styles.aboutCard}>
<div className={styles.aboutHeader}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.aboutLogo} />
<div className={styles.aboutTitle}>{t('system_info.about_title')}</div>
</div>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
<div className={styles.tileValue}>{buildTime}</div>
<div className={styles.aboutInfoGrid}>
<button
type="button"
className={`${styles.infoTile} ${styles.tapTile}`}
onClick={handleInfoVersionTap}
>
<div className={styles.tileLabel}>{t('footer.version')}</div>
<div className={styles.tileValue}>{appVersion}</div>
</button>
<div className={styles.infoTile}>
<div className={styles.tileHeader}>
<div className={styles.tileLabel}>{t('footer.api_version')}</div>
<Button
type="button"
variant="ghost"
size="sm"
className={styles.tileAction}
onClick={() => void handleVersionCheck()}
loading={checkingVersion}
title={t('system_info.version_check_button')}
aria-label={t('system_info.version_check_button')}
>
{t('system_info.version_check_button')}
</Button>
</div>
<div className={styles.tileValue}>{apiVersion}</div>
</div>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('footer.build_date')}</div>
<div className={styles.tileValue}>{buildTime}</div>
</div>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('connection.status')}</div>
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status`)}</div>
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
</div>
</div>
</Card>
<div className={styles.infoTile}>
<div className={styles.tileLabel}>{t('connection.status')}</div>
<div className={styles.tileValue}>{t(`common.${auth.connectionStatus}_status`)}</div>
<div className={styles.tileSub}>{auth.apiBase || '-'}</div>
</div>
</div>
<div className={styles.aboutActions}>
<Button variant="secondary" size="sm" onClick={() => fetchConfig(undefined, true)}>
{t('common.refresh')}
</Button>
</div>
</Card>
<Card title={t('system_info.quick_links_title')}>
<p className={styles.sectionDescription}>{t('system_info.quick_links_desc')}</p>
<div className={styles.quickLinks}>
<a
href="https://github.com/router-for-me/CLIProxyAPI"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.github}`}>
<IconGithub size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_main_repo')}
<IconExternalLink size={14} />
<Card title={t('system_info.quick_links_title')}>
<p className={styles.sectionDescription}>{t('system_info.quick_links_desc')}</p>
<div className={styles.quickLinks}>
<a
href="https://github.com/router-for-me/CLIProxyAPI"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.github}`}>
<IconGithub size={22} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_main_repo_desc')}</div>
</div>
</a>
<a
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.github}`}>
<IconCode size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_webui_repo')}
<IconExternalLink size={14} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_webui_repo_desc')}</div>
</div>
</a>
<a
href="https://help.router-for.me/"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.docs}`}>
<IconBookOpen size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_docs')}
<IconExternalLink size={14} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_docs_desc')}</div>
</div>
</a>
</div>
</Card>
<Card
title={t('system_info.models_title')}
extra={
<Button variant="secondary" size="sm" onClick={() => fetchModels({ forceRefresh: true })} loading={modelsLoading}>
{t('common.refresh')}
</Button>
}
>
<p className={styles.sectionDescription}>{t('system_info.models_desc')}</p>
{modelStatus && <div className={`status-badge ${modelStatus.type}`}>{modelStatus.message}</div>}
{modelsError && <div className="error-box">{modelsError}</div>}
{modelsLoading ? (
<div className="hint">{t('common.loading')}</div>
) : models.length === 0 ? (
<div className="hint">{t('system_info.models_empty')}</div>
) : (
<div className="item-list">
{groupedModels.map((group) => {
const iconSrc = getIconForCategory(group.id);
return (
<div key={group.id} className="item-row">
<div className="item-meta">
<div className={styles.groupTitle}>
{iconSrc && <img src={iconSrc} alt="" className={styles.groupIcon} />}
<span className="item-title">{group.label}</span>
</div>
<div className="item-subtitle">{t('system_info.models_count', { count: group.items.length })}</div>
</div>
<div className={styles.modelTags}>
{group.items.map((model) => (
<span
key={`${model.name}-${model.alias ?? 'default'}`}
className={styles.modelTag}
title={model.description || ''}
>
<span className={styles.modelName}>{model.name}</span>
{model.alias && <span className={styles.modelAlias}>{model.alias}</span>}
</span>
))}
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_main_repo')}
<IconExternalLink size={14} />
</div>
);
})}
</div>
)}
</Card>
<div className={styles.linkDesc}>{t('system_info.link_main_repo_desc')}</div>
</div>
</a>
<Card title={t('system_info.clear_login_title')}>
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
<div className={styles.clearLoginActions}>
<Button variant="danger" onClick={handleClearLoginStorage}>
{t('system_info.clear_login_button')}
</Button>
</div>
</Card>
<a
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.github}`}>
<IconCode size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_webui_repo')}
<IconExternalLink size={14} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_webui_repo_desc')}</div>
</div>
</a>
<a
href="https://help.router-for.me/"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.docs}`}>
<IconBookOpen size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_docs')}
<IconExternalLink size={14} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_docs_desc')}</div>
</div>
</a>
</div>
</Card>
<Card
title={t('system_info.models_title')}
extra={
<Button
variant="secondary"
size="sm"
onClick={() => fetchModels({ forceRefresh: true })}
loading={modelsLoading}
>
{t('common.refresh')}
</Button>
}
>
<p className={styles.sectionDescription}>{t('system_info.models_desc')}</p>
{modelStatus && (
<div className={`status-badge ${modelStatus.type}`}>{modelStatus.message}</div>
)}
{modelsError && <div className="error-box">{modelsError}</div>}
{modelsLoading ? (
<div className="hint">{t('common.loading')}</div>
) : models.length === 0 ? (
<div className="hint">{t('system_info.models_empty')}</div>
) : (
<div className="item-list">
{groupedModels.map((group) => {
const iconSrc = getIconForCategory(group.id);
return (
<div key={group.id} className="item-row">
<div className="item-meta">
<div className={styles.groupTitle}>
{iconSrc && <img src={iconSrc} alt="" className={styles.groupIcon} />}
<span className="item-title">{group.label}</span>
</div>
<div className="item-subtitle">
{t('system_info.models_count', { count: group.items.length })}
</div>
</div>
<div className={styles.modelTags}>
{group.items.map((model) => (
<span
key={`${model.name}-${model.alias ?? 'default'}`}
className={styles.modelTag}
title={model.description || ''}
>
<span className={styles.modelName}>{model.name}</span>
{model.alias && <span className={styles.modelAlias}>{model.alias}</span>}
</span>
))}
</div>
</div>
);
})}
</div>
)}
</Card>
<Card title={t('system_info.clear_login_title')}>
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
<div className={styles.clearLoginActions}>
<Button variant="danger" onClick={handleClearLoginStorage}>
{t('system_info.clear_login_button')}
</Button>
</div>
</Card>
</div>
<Modal
+2 -12
View File
@@ -132,18 +132,8 @@ const OAUTH_MODEL_ALIAS_ENDPOINT = '/oauth-model-alias';
export const authFilesApi = {
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
async setStatus(name: string, disabled: boolean): Promise<AuthFileStatusResponse> {
const json = await authFilesApi.downloadJsonObject(name);
if (disabled) {
json.disabled = true;
} else {
delete json.disabled;
}
await authFilesApi.saveJsonObject(name, json);
return { status: disabled ? 'disabled' : 'enabled', disabled };
},
setStatus: (name: string, disabled: boolean) =>
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
upload: (file: File) => {
const formData = new FormData();
+2
View File
@@ -10,6 +10,7 @@ import { STORAGE_KEY_AUTH } from '@/utils/constants';
import { secureStorage } from '@/services/storage/secureStorage';
import { apiClient } from '@/services/api/client';
import { useConfigStore } from './useConfigStore';
import { useUsageStatsStore } from './useUsageStatsStore';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
interface AuthStoreState extends AuthState {
@@ -136,6 +137,7 @@ export const useAuthStore = create<AuthStoreState>()(
logout: () => {
restoreSessionPromise = null;
useConfigStore.getState().clearCache();
useUsageStatsStore.getState().clearUsageStats();
set({
isAuthenticated: false,
apiBase: '',
+69 -10
View File
@@ -81,7 +81,9 @@
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: background $transition-fast, color $transition-fast;
transition:
background $transition-fast,
color $transition-fast;
display: flex;
align-items: center;
justify-content: center;
@@ -118,7 +120,10 @@
max-width: 320px;
opacity: 1;
transform: translateX(0);
transition: max-width 0.4s ease, opacity 0.4s ease, transform 0.4s ease;
transition:
max-width 0.4s ease,
opacity 0.4s ease,
transform 0.4s ease;
}
.brand-abbr {
@@ -126,7 +131,9 @@
transform: translateX(12px);
opacity: 0;
pointer-events: none;
transition: opacity 0.4s ease, transform 0.4s ease;
transition:
opacity 0.4s ease,
transform 0.4s ease;
}
&.collapsed {
@@ -220,7 +227,9 @@
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color $transition-fast, color $transition-fast;
transition:
background-color $transition-fast,
color $transition-fast;
&:hover {
background: var(--bg-secondary);
@@ -278,7 +287,9 @@
flex-direction: column;
align-items: center;
gap: 4px;
transition: border-color $transition-fast, background-color $transition-fast;
transition:
border-color $transition-fast,
background-color $transition-fast;
&:hover {
background: var(--bg-secondary);
@@ -431,6 +442,29 @@
}
}
.sidebar-backdrop {
display: none;
border: 0;
padding: 0;
margin: 0;
background: rgb(15 23 42 / 0.18);
opacity: 0;
pointer-events: none;
transition: opacity $transition-fast;
@media (max-width: $breakpoint-mobile) {
display: block;
position: fixed;
inset: var(--header-height) 0 0;
z-index: $z-dropdown - 1;
&.visible {
opacity: 1;
pointer-events: auto;
}
}
}
.sidebar {
width: 240px;
background: var(--bg-primary);
@@ -439,7 +473,9 @@
display: flex;
flex-direction: column;
gap: $spacing-lg;
transition: width $transition-normal, transform $transition-normal;
transition:
width $transition-normal,
transform $transition-normal;
overflow-y: auto;
flex-shrink: 0;
height: 100%;
@@ -464,22 +500,33 @@
.nav-item {
padding: 10px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
color: var(--text-primary);
font-weight: 600;
display: flex;
align-items: center;
gap: $spacing-sm;
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
transition:
background $transition-fast,
color $transition-fast;
.nav-icon {
width: 20px;
height: 20px;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.9;
opacity: 0.96;
border-radius: 7px;
background: linear-gradient(180deg, rgb(255 255 255 / 0.18), rgb(255 255 255 / 0))
var(--bg-secondary);
box-shadow: inset 0 0 0 1px var(--border-primary);
transition:
background $transition-fast,
box-shadow $transition-fast,
color $transition-fast;
svg {
width: 18px;
@@ -496,12 +543,24 @@
&:hover {
background: var(--bg-secondary);
.nav-icon {
background: linear-gradient(180deg, rgb(255 255 255 / 0.24), rgb(255 255 255 / 0))
var(--bg-primary);
box-shadow: inset 0 0 0 1px var(--border-hover);
}
}
&.active {
background: rgba($primary-color, 0.14);
color: var(--primary-color);
border: 1px solid rgba($primary-color, 0.35);
.nav-icon {
background: linear-gradient(180deg, rgb(255 255 255 / 0.22), rgb(255 255 255 / 0))
rgba($primary-color, 0.1);
box-shadow: inset 0 0 0 1px rgba($primary-color, 0.26);
}
}
}
+25
View File
@@ -25,6 +25,28 @@ export interface GeminiCliQuotaPayload {
buckets?: GeminiCliQuotaBucket[];
}
export interface GeminiCliCredits {
creditType?: string;
credit_type?: string;
creditAmount?: string | number;
credit_amount?: string | number;
}
export interface GeminiCliUserTier {
id?: string;
name?: string;
description?: string;
availableCredits?: GeminiCliCredits[];
available_credits?: GeminiCliCredits[];
}
export interface GeminiCliCodeAssistPayload {
currentTier?: GeminiCliUserTier | null;
current_tier?: GeminiCliUserTier | null;
paidTier?: GeminiCliUserTier | null;
paid_tier?: GeminiCliUserTier | null;
}
export interface AntigravityQuotaInfo {
displayName?: string;
quotaInfo?: {
@@ -200,6 +222,9 @@ export interface GeminiCliQuotaBucketState {
export interface GeminiCliQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
buckets: GeminiCliQuotaBucketState[];
tierLabel?: string | null;
tierId?: string | null;
creditBalance?: number | null;
error?: string;
errorStatus?: number;
}
+3
View File
@@ -117,6 +117,9 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
export const GEMINI_CLI_QUOTA_URL =
'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
export const GEMINI_CLI_CODE_ASSIST_URL =
'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
export const GEMINI_CLI_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json',
+18 -1
View File
@@ -2,7 +2,7 @@
* Normalization and parsing functions for quota data.
*/
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliCodeAssistPayload, GeminiCliQuotaPayload, KimiUsagePayload } from '@/types';
import { normalizeAuthIndex } from '@/utils/usage';
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
@@ -191,6 +191,23 @@ export function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayl
return null;
}
export function parseGeminiCliCodeAssistPayload(payload: unknown): GeminiCliCodeAssistPayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
const trimmed = payload.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as GeminiCliCodeAssistPayload;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as GeminiCliCodeAssistPayload;
}
return null;
}
export function parseKimiUsagePayload(payload: unknown): KimiUsagePayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {