feat(quota): add zustand store for quota state caching

This commit is contained in:
Supra4E8C
2025-12-30 18:29:28 +08:00
parent 49b9259452
commit 23fb88e5fd
5 changed files with 186 additions and 72 deletions

View File

@@ -3,9 +3,17 @@ import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { useAuthStore, useThemeStore } from '@/stores'; import { useAuthStore, useQuotaStore, useThemeStore } from '@/stores';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import type { AuthFileItem } from '@/types'; import type {
AntigravityQuotaGroup,
AntigravityQuotaState,
AuthFileItem,
CodexQuotaState,
CodexQuotaWindow,
GeminiCliQuotaBucketState,
GeminiCliQuotaState
} from '@/types';
import styles from './QuotaPage.module.scss'; import styles from './QuotaPage.module.scss';
type ThemeColors = { bg: string; text: string; border?: string }; type ThemeColors = { bg: string; text: string; border?: string };
@@ -56,21 +64,6 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
} }
}; };
interface AntigravityQuotaGroup {
id: string;
label: string;
models: string[];
remainingFraction: number;
resetTime?: string;
}
interface AntigravityQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
groups: AntigravityQuotaGroup[];
error?: string;
errorStatus?: number;
}
interface GeminiCliQuotaBucket { interface GeminiCliQuotaBucket {
modelId?: string; modelId?: string;
model_id?: string; model_id?: string;
@@ -88,22 +81,6 @@ interface GeminiCliQuotaPayload {
buckets?: GeminiCliQuotaBucket[]; buckets?: GeminiCliQuotaBucket[];
} }
interface GeminiCliQuotaBucketState {
id: string;
label: string;
remainingFraction: number | null;
remainingAmount: number | null;
resetTime: string | undefined;
tokenType: string | null;
}
interface GeminiCliQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
buckets: GeminiCliQuotaBucketState[];
error?: string;
errorStatus?: number;
}
interface AntigravityQuotaInfo { interface AntigravityQuotaInfo {
displayName?: string; displayName?: string;
quotaInfo?: { quotaInfo?: {
@@ -199,6 +176,7 @@ interface CodexUsageWindow {
interface CodexRateLimitInfo { interface CodexRateLimitInfo {
allowed?: boolean; allowed?: boolean;
limit_reached?: boolean; limit_reached?: boolean;
limitReached?: boolean;
primary_window?: CodexUsageWindow | null; primary_window?: CodexUsageWindow | null;
primaryWindow?: CodexUsageWindow | null; primaryWindow?: CodexUsageWindow | null;
secondary_window?: CodexUsageWindow | null; secondary_window?: CodexUsageWindow | null;
@@ -214,21 +192,6 @@ interface CodexUsagePayload {
codeReviewRateLimit?: CodexRateLimitInfo | null; codeReviewRateLimit?: CodexRateLimitInfo | null;
} }
interface CodexQuotaWindow {
id: string;
label: string;
usedPercent: number | null;
resetLabel: string;
}
interface CodexQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
windows: CodexQuotaWindow[];
planType?: string | null;
error?: string;
errorStatus?: number;
}
const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
const CODEX_REQUEST_HEADERS = { const CODEX_REQUEST_HEADERS = {
@@ -293,6 +256,20 @@ function normalizeNumberValue(value: unknown): number | null {
return null; return null;
} }
function normalizeQuotaFraction(value: unknown): number | null {
const normalized = normalizeNumberValue(value);
if (normalized !== null) return normalized;
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.endsWith('%')) {
const parsed = Number(trimmed.slice(0, -1));
return Number.isFinite(parsed) ? parsed / 100 : null;
}
}
return null;
}
function normalizePlanType(value: unknown): string | null { function normalizePlanType(value: unknown): string | null {
const normalized = normalizeStringValue(value); const normalized = normalizeStringValue(value);
return normalized ? normalized.toLowerCase() : null; return normalized ? normalized.toLowerCase() : null;
@@ -506,13 +483,13 @@ function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
const quotaInfo = entry.quotaInfo ?? entry.quota_info ?? {}; const quotaInfo = entry.quotaInfo ?? entry.quota_info ?? {};
const remainingValue = const remainingValue =
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining; quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
const remainingFraction = Number(remainingValue); const remainingFraction = normalizeQuotaFraction(remainingValue);
const resetValue = quotaInfo.resetTime ?? quotaInfo.reset_time; const resetValue = quotaInfo.resetTime ?? quotaInfo.reset_time;
const resetTime = typeof resetValue === 'string' ? resetValue : undefined; const resetTime = typeof resetValue === 'string' ? resetValue : undefined;
const displayName = typeof entry.displayName === 'string' ? entry.displayName : undefined; const displayName = typeof entry.displayName === 'string' ? entry.displayName : undefined;
return { return {
remainingFraction: Number.isFinite(remainingFraction) ? remainingFraction : null, remainingFraction,
resetTime, resetTime,
displayName displayName
}; };
@@ -554,10 +531,12 @@ function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): Antigrav
const quotaEntries = matches const quotaEntries = matches
.map(({ id, entry }) => { .map(({ id, entry }) => {
const info = getAntigravityQuotaInfo(entry); const info = getAntigravityQuotaInfo(entry);
if (info.remainingFraction === null) return null; const remainingFraction =
info.remainingFraction ?? (info.resetTime ? 0 : null);
if (remainingFraction === null) return null;
return { return {
id, id,
remainingFraction: info.remainingFraction, remainingFraction,
resetTime: info.resetTime, resetTime: info.resetTime,
displayName: info.displayName displayName: info.displayName
}; };
@@ -683,22 +662,24 @@ export function QuotaPage() {
const [codexPageSize, setCodexPageSize] = useState(6); const [codexPageSize, setCodexPageSize] = useState(6);
const [geminiCliPage, setGeminiCliPage] = useState(1); const [geminiCliPage, setGeminiCliPage] = useState(1);
const [geminiCliPageSize, setGeminiCliPageSize] = useState(6); const [geminiCliPageSize, setGeminiCliPageSize] = useState(6);
const [antigravityQuota, setAntigravityQuota] = useState<Record<string, AntigravityQuotaState>>(
{}
);
const [antigravityLoading, setAntigravityLoading] = useState(false); const [antigravityLoading, setAntigravityLoading] = useState(false);
const [antigravityLoadingScope, setAntigravityLoadingScope] = useState< const [antigravityLoadingScope, setAntigravityLoadingScope] = useState<
'page' | 'all' | null 'page' | 'all' | null
>(null); >(null);
const [codexQuota, setCodexQuota] = useState<Record<string, CodexQuotaState>>({});
const [codexLoading, setCodexLoading] = useState(false); const [codexLoading, setCodexLoading] = useState(false);
const [codexLoadingScope, setCodexLoadingScope] = useState<'page' | 'all' | null>(null); const [codexLoadingScope, setCodexLoadingScope] = useState<'page' | 'all' | null>(null);
const [geminiCliQuota, setGeminiCliQuota] = useState<Record<string, GeminiCliQuotaState>>({});
const [geminiCliLoading, setGeminiCliLoading] = useState(false); const [geminiCliLoading, setGeminiCliLoading] = useState(false);
const [geminiCliLoadingScope, setGeminiCliLoadingScope] = useState< const [geminiCliLoadingScope, setGeminiCliLoadingScope] = useState<
'page' | 'all' | null 'page' | 'all' | null
>(null); >(null);
const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
const codexQuota = useQuotaStore((state) => state.codexQuota);
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota);
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
const antigravityLoadingRef = useRef(false); const antigravityLoadingRef = useRef(false);
const antigravityRequestIdRef = useRef(0); const antigravityRequestIdRef = useRef(0);
const codexLoadingRef = useRef(false); const codexLoadingRef = useRef(false);
@@ -888,35 +869,56 @@ export function QuotaPage() {
} }
} }
}, },
[fetchAntigravityQuota, t] [fetchAntigravityQuota, setAntigravityQuota, t]
); );
const buildCodexQuotaWindows = useCallback( const buildCodexQuotaWindows = useCallback(
(payload: CodexUsagePayload): CodexQuotaWindow[] => { (payload: CodexUsagePayload): CodexQuotaWindow[] => {
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined; const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined; const codeReviewLimit =
payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const windows: CodexQuotaWindow[] = []; const windows: CodexQuotaWindow[] = [];
const addWindow = (id: string, label: string, window?: CodexUsageWindow | null) => { const addWindow = (
id: string,
label: string,
window?: CodexUsageWindow | null,
limitReached?: boolean,
allowed?: boolean
) => {
if (!window) return; if (!window) return;
const usedPercent = normalizeNumberValue(window.used_percent ?? window.usedPercent); const resetLabel = formatCodexResetLabel(window);
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
const isLimitReached = Boolean(limitReached) || allowed === false;
const usedPercent =
usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
windows.push({ windows.push({
id, id,
label, label,
usedPercent, usedPercent,
resetLabel: formatCodexResetLabel(window) resetLabel
}); });
}; };
addWindow('primary', t('codex_quota.primary_window'), rateLimit?.primary_window ?? rateLimit?.primaryWindow); addWindow(
'primary',
t('codex_quota.primary_window'),
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
);
addWindow( addWindow(
'secondary', 'secondary',
t('codex_quota.secondary_window'), t('codex_quota.secondary_window'),
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
); );
addWindow( addWindow(
'code-review', 'code-review',
t('codex_quota.code_review_window'), t('codex_quota.code_review_window'),
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
codeReviewLimit?.allowed
); );
return windows; return windows;
@@ -925,7 +927,9 @@ export function QuotaPage() {
); );
const fetchCodexQuota = useCallback( const fetchCodexQuota = useCallback(
async (file: AuthFileItem): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => { async (
file: AuthFileItem
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex; const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex); const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) { if (!authIndex) {
@@ -1030,7 +1034,7 @@ export function QuotaPage() {
} }
} }
}, },
[fetchCodexQuota, t] [fetchCodexQuota, setCodexQuota, t]
); );
const fetchGeminiCliQuota = useCallback( const fetchGeminiCliQuota = useCallback(
@@ -1067,13 +1071,20 @@ export function QuotaPage() {
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id); const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
if (!modelId) return null; if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type); const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
const remainingFraction = normalizeNumberValue( const remainingFractionRaw = normalizeQuotaFraction(
bucket.remainingFraction ?? bucket.remaining_fraction bucket.remainingFraction ?? bucket.remaining_fraction
); );
const remainingAmount = normalizeNumberValue( const remainingAmount = normalizeNumberValue(
bucket.remainingAmount ?? bucket.remaining_amount bucket.remainingAmount ?? bucket.remaining_amount
); );
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined; const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
let fallbackFraction: number | null = null;
if (remainingAmount !== null) {
fallbackFraction = remainingAmount <= 0 ? 0 : null;
} else if (resetTime) {
fallbackFraction = 0;
}
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
return { return {
id: `${modelId}-${tokenType ?? index}`, id: `${modelId}-${tokenType ?? index}`,
label: modelId, label: modelId,
@@ -1149,7 +1160,7 @@ export function QuotaPage() {
} }
} }
}, },
[fetchGeminiCliQuota, t] [fetchGeminiCliQuota, setGeminiCliQuota, t]
); );
useEffect(() => { useEffect(() => {
@@ -1157,6 +1168,7 @@ export function QuotaPage() {
}, [loadFiles]); }, [loadFiles]);
useEffect(() => { useEffect(() => {
if (loading) return;
if (antigravityFiles.length === 0) { if (antigravityFiles.length === 0) {
setAntigravityQuota({}); setAntigravityQuota({});
return; return;
@@ -1171,9 +1183,10 @@ export function QuotaPage() {
}); });
return nextState; return nextState;
}); });
}, [antigravityFiles]); }, [antigravityFiles, loading, setAntigravityQuota]);
useEffect(() => { useEffect(() => {
if (loading) return;
if (codexFiles.length === 0) { if (codexFiles.length === 0) {
setCodexQuota({}); setCodexQuota({});
return; return;
@@ -1188,9 +1201,10 @@ export function QuotaPage() {
}); });
return nextState; return nextState;
}); });
}, [codexFiles]); }, [codexFiles, loading, setCodexQuota]);
useEffect(() => { useEffect(() => {
if (loading) return;
if (geminiCliFiles.length === 0) { if (geminiCliFiles.length === 0) {
setGeminiCliQuota({}); setGeminiCliQuota({});
return; return;
@@ -1205,7 +1219,7 @@ export function QuotaPage() {
}); });
return nextState; return nextState;
}); });
}, [geminiCliFiles]); }, [geminiCliFiles, loading, setGeminiCliQuota]);
// Resolve type label text for badges. // Resolve type label text for badges.
const getTypeLabel = (type: string): string => { const getTypeLabel = (type: string): string => {

View File

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

View File

@@ -0,0 +1,49 @@
/**
* Quota cache that survives route switches.
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
interface QuotaStoreState {
antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
}
const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
if (typeof updater === 'function') {
return (updater as (value: T) => T)(prev);
}
return updater;
};
export const useQuotaStore = create<QuotaStoreState>((set) => ({
antigravityQuota: {},
codexQuota: {},
geminiCliQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
})),
setCodexQuota: (updater) =>
set((state) => ({
codexQuota: resolveUpdater(updater, state.codexQuota)
})),
setGeminiCliQuota: (updater) =>
set((state) => ({
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota)
})),
clearQuotaCache: () =>
set({
antigravityQuota: {},
codexQuota: {},
geminiCliQuota: {}
})
}));

View File

@@ -12,3 +12,4 @@ export * from './authFile';
export * from './oauth'; export * from './oauth';
export * from './usage'; export * from './usage';
export * from './log'; export * from './log';
export * from './quota';

49
src/types/quota.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Quota management types.
*/
export interface AntigravityQuotaGroup {
id: string;
label: string;
models: string[];
remainingFraction: number;
resetTime?: string;
}
export interface AntigravityQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
groups: AntigravityQuotaGroup[];
error?: string;
errorStatus?: number;
}
export interface GeminiCliQuotaBucketState {
id: string;
label: string;
remainingFraction: number | null;
remainingAmount: number | null;
resetTime: string | undefined;
tokenType: string | null;
}
export interface GeminiCliQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
buckets: GeminiCliQuotaBucketState[];
error?: string;
errorStatus?: number;
}
export interface CodexQuotaWindow {
id: string;
label: string;
usedPercent: number | null;
resetLabel: string;
}
export interface CodexQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
windows: CodexQuotaWindow[];
planType?: string | null;
error?: string;
errorStatus?: number;
}