feat(antigravity): add subscription management with UI integration and localization support

This commit is contained in:
LTbinglingfeng
2026-06-17 13:14:04 +08:00
parent e95cc2b621
commit c595ada80d
11 changed files with 509 additions and 3 deletions
@@ -34,6 +34,7 @@ import {
type ResolvedTheme,
} from '@/features/authFiles/constants';
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
import type { AntigravitySubscriptionState } from '@/features/authFiles/hooks/useAntigravitySubscriptions';
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
import styles from '@/pages/AuthFilesPage.module.scss';
@@ -49,6 +50,7 @@ export type AuthFileCardProps = {
statusUpdating: Record<string, boolean>;
quotaFilterType: QuotaProviderType | null;
statusBarCache: Map<string, AuthFileStatusBarData>;
antigravitySubscription?: AntigravitySubscriptionState;
onShowModels: (file: AuthFileItem) => void;
onDownload: (name: string) => void;
onOpenPrefixProxyEditor: (file: AuthFileItem) => void;
@@ -75,6 +77,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
statusUpdating,
quotaFilterType,
statusBarCache,
antigravitySubscription,
onShowModels,
onDownload,
onOpenPrefixProxyEditor,
@@ -90,6 +93,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
};
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
const providerKey = normalizeProviderKey(String(file.type ?? file.provider ?? 'unknown'));
const isAntigravity = providerKey === 'antigravity';
const isAistudio = providerKey === 'aistudio';
const showModelsButton = !isRuntimeOnly || isAistudio;
const typeColor = getTypeColor(providerKey, resolvedTheme);
@@ -127,6 +131,53 @@ export function AuthFileCard(props: AuthFileCardProps) {
const priorityValue = parsePriorityValue(file.priority ?? file['priority']);
const noteValue = typeof file.note === 'string' ? file.note.trim() : '';
const subscription =
isAntigravity && !isRuntimeOnly ? antigravitySubscription : undefined;
const subscriptionData = subscription?.status === 'success' ? subscription.data : undefined;
const subscriptionPlanLabel =
subscriptionData?.plan === 'free'
? t('antigravity_subscription.plan_free')
: subscriptionData?.plan === 'pro'
? t('antigravity_subscription.plan_pro')
: subscriptionData?.plan === 'ultra'
? t('antigravity_subscription.plan_ultra')
: subscriptionData?.plan === 'ultra-lite'
? t('antigravity_subscription.plan_ultra_lite')
: subscriptionData
? subscriptionData.tierName ||
subscriptionData.tierId ||
t('antigravity_subscription.plan_unknown')
: '';
const subscriptionBadgeLabel =
subscription?.status === 'loading'
? t('antigravity_subscription.loading_short')
: subscription?.status === 'error'
? t('antigravity_subscription.error_badge')
: subscriptionData
? t('antigravity_subscription.plan_badge', {
plan: subscriptionPlanLabel,
})
: '';
const subscriptionTitle =
subscription?.status === 'error'
? subscription.error || t('common.unknown_error')
: subscriptionData?.tierName && subscriptionData.tierId
? `${subscriptionData.tierName} (${subscriptionData.tierId})`
: subscriptionData?.tierName || subscriptionData?.tierId || subscriptionBadgeLabel;
const subscriptionBadgeClass =
subscription?.status === 'loading'
? styles.subscriptionBadgeLoading
: subscription?.status === 'error'
? styles.subscriptionBadgeError
: subscriptionData?.plan === 'free'
? styles.subscriptionBadgeFree
: subscriptionData?.plan === 'unknown'
? styles.subscriptionBadgeUnknown
: styles.subscriptionBadgePaid;
const subscriptionErrorMessage =
subscription?.status === 'error'
? subscription.error || t('common.unknown_error')
: '';
const stateLabel = isRuntimeOnly
? t('auth_files.type_virtual') || '虚拟认证文件'
: file.disabled
@@ -191,10 +242,28 @@ export function AuthFileCard(props: AuthFileCardProps) {
{typeLabel}
</span>
<span className={`${styles.stateBadge} ${stateBadgeClass}`}>{stateLabel}</span>
{subscriptionBadgeLabel && (
<span
className={`${styles.subscriptionBadge} ${subscriptionBadgeClass}`}
title={subscriptionTitle}
>
{subscriptionBadgeLabel}
</span>
)}
</div>
<span className={styles.fileName} title={file.name}>
{file.name}
</span>
{!compact && subscriptionData?.tierName && (
<div className={styles.subscriptionSubtitle} title={subscriptionTitle}>
<span className={styles.subscriptionSubtitleLabel}>
{t('antigravity_subscription.subscription_label')}
</span>
<span className={styles.subscriptionSubtitleValue}>
{subscriptionData.tierName}
</span>
</div>
)}
{!compact && noteValue && (
<div className={styles.noteText} title={noteValue}>
<span className={styles.noteLabel}>{t('auth_files.note_display')}</span>
@@ -232,6 +301,17 @@ export function AuthFileCard(props: AuthFileCardProps) {
</div>
)}
{subscriptionErrorMessage && (
<div className={styles.subscriptionError} title={subscriptionErrorMessage}>
<IconInfo className={styles.messageIcon} size={14} />
<span>
{t('antigravity_subscription.load_failed', {
message: subscriptionErrorMessage,
})}
</span>
</div>
)}
<div className={`${styles.cardInsights} ${compact ? styles.cardInsightsCompact : ''}`}>
<div className={`${styles.cardStats} ${compact ? styles.cardStatsCompact : ''}`}>
<div className={`${styles.statPill} ${styles.statSuccess}`}>
@@ -0,0 +1,146 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
antigravitySubscriptionApi,
type AntigravitySubscriptionSummary,
} from '@/services/api';
import type { AuthFileItem } from '@/types';
import { normalizeAuthIndex } from '@/utils/authIndex';
import { getStatusFromError, isAntigravityFile, isRuntimeOnlyAuthFile } from '@/utils/quota';
export type AntigravitySubscriptionState = {
status: 'idle' | 'loading' | 'success' | 'error';
data?: AntigravitySubscriptionSummary;
error?: string;
errorStatus?: number;
};
type SubscriptionTarget = {
file: AuthFileItem;
authIndex: string | null;
cacheKey: string;
};
type SubscriptionResult =
| {
name: string;
cacheKey: string;
status: 'success';
data: AntigravitySubscriptionSummary;
}
| {
name: string;
cacheKey: string;
status: 'error';
error: string;
errorStatus?: number;
};
const buildCacheKey = (file: AuthFileItem, authIndex: string | null): string =>
`${file.name}\n${authIndex ?? ''}`;
export function useAntigravitySubscriptions(files: AuthFileItem[]) {
const { t } = useTranslation();
const [subscriptions, setSubscriptions] = useState<Record<string, AntigravitySubscriptionState>>(
{}
);
const cacheKeysRef = useRef(new Map<string, string>());
const targets = useMemo(
() =>
files.reduce<SubscriptionTarget[]>((result, file) => {
if (!isAntigravityFile(file) || isRuntimeOnlyAuthFile(file)) return result;
const authIndex = normalizeAuthIndex(file['auth_index'] ?? file.authIndex);
result.push({
file,
authIndex,
cacheKey: buildCacheKey(file, authIndex),
});
return result;
}, []),
[files]
);
useEffect(() => {
const targetsToLoad = targets.filter(
(target) => cacheKeysRef.current.get(target.file.name) !== target.cacheKey
);
if (targetsToLoad.length === 0) return;
let cancelled = false;
const requestTargets = targetsToLoad.filter((target) => target.authIndex);
setSubscriptions((prev) => {
const next = { ...prev };
targetsToLoad.forEach((target) => {
cacheKeysRef.current.set(target.file.name, target.cacheKey);
next[target.file.name] = target.authIndex
? { status: 'loading' }
: {
status: 'error',
error: t('antigravity_subscription.missing_auth_index'),
};
});
return next;
});
if (requestTargets.length === 0) return;
void (async () => {
const results = await Promise.all(
requestTargets.map(async (target): Promise<SubscriptionResult> => {
try {
const data = await antigravitySubscriptionApi.get(target.authIndex as string);
if (!data) {
return {
name: target.file.name,
cacheKey: target.cacheKey,
status: 'error',
error: t('antigravity_subscription.empty_data'),
};
}
return {
name: target.file.name,
cacheKey: target.cacheKey,
status: 'success',
data,
};
} catch (err: unknown) {
return {
name: target.file.name,
cacheKey: target.cacheKey,
status: 'error',
error: err instanceof Error ? err.message : t('common.unknown_error'),
errorStatus: getStatusFromError(err),
};
}
})
);
if (cancelled) return;
setSubscriptions((prev) => {
const next = { ...prev };
results.forEach((result) => {
if (cacheKeysRef.current.get(result.name) !== result.cacheKey) return;
if (result.status === 'success') {
next[result.name] = { status: 'success', data: result.data };
} else {
next[result.name] = {
status: 'error',
error: result.error,
errorStatus: result.errorStatus,
};
}
});
return next;
});
})();
return () => {
cancelled = true;
};
}, [t, targets]);
return subscriptions;
}
+14
View File
@@ -378,6 +378,20 @@
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
},
"antigravity_subscription": {
"subscription_label": "Subscription",
"plan_badge": "Plan {{plan}}",
"loading_short": "Plan...",
"error_badge": "Plan error",
"load_failed": "Subscription check failed: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_data": "No subscription data available",
"plan_unknown": "Unknown",
"plan_free": "Free",
"plan_pro": "Pro",
"plan_ultra": "Ultra",
"plan_ultra_lite": "Ultra Lite"
},
"antigravity_quota": {
"title": "Antigravity Quota",
"empty_title": "No Antigravity Auth Files",
+14
View File
@@ -372,6 +372,20 @@
"quota_refresh_success": "Квота для \"{{name}}\" обновлена",
"quota_refresh_failed": "Не удалось обновить квоту для \"{{name}}\": {{message}}"
},
"antigravity_subscription": {
"subscription_label": "Подписка",
"plan_badge": "План {{plan}}",
"loading_short": "План...",
"error_badge": "Ошибка плана",
"load_failed": "Не удалось проверить подписку: {{message}}",
"missing_auth_index": "В файле авторизации отсутствует auth_index",
"empty_data": "Данные подписки отсутствуют",
"plan_unknown": "Неизвестно",
"plan_free": "Free",
"plan_pro": "Pro",
"plan_ultra": "Ultra",
"plan_ultra_lite": "Ultra Lite"
},
"antigravity_quota": {
"title": "Квота Antigravity",
"empty_title": "Файлы авторизации Antigravity отсутствуют",
+14
View File
@@ -378,6 +378,20 @@
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
},
"antigravity_subscription": {
"subscription_label": "订阅",
"plan_badge": "套餐 {{plan}}",
"loading_short": "套餐...",
"error_badge": "套餐错误",
"load_failed": "订阅状态获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_data": "暂无订阅数据",
"plan_unknown": "未知",
"plan_free": "免费版",
"plan_pro": "Pro",
"plan_ultra": "Ultra",
"plan_ultra_lite": "Ultra Lite"
},
"antigravity_quota": {
"title": "Antigravity 额度",
"empty_title": "暂无 Antigravity 认证",
+14
View File
@@ -378,6 +378,20 @@
"quota_refresh_success": "已重新整理「{{name}}」的配額",
"quota_refresh_failed": "重新整理「{{name}}」的配額失敗:{{message}}"
},
"antigravity_subscription": {
"subscription_label": "訂閱",
"plan_badge": "方案 {{plan}}",
"loading_short": "方案...",
"error_badge": "方案錯誤",
"load_failed": "訂閱狀態取得失敗:{{message}}",
"missing_auth_index": "驗證檔案缺少 auth_index",
"empty_data": "暫無訂閱資料",
"plan_unknown": "未知",
"plan_free": "免費版",
"plan_pro": "Pro",
"plan_ultra": "Ultra",
"plan_ultra_lite": "Ultra Lite"
},
"antigravity_quota": {
"title": "Antigravity 配額",
"empty_title": "暫無 Antigravity 驗證",
+90 -2
View File
@@ -795,6 +795,7 @@
.cardHeaderContent,
.cardMeta,
.healthStatusMessage,
.subscriptionError,
.cardInsights {
opacity: 0.72;
}
@@ -851,7 +852,8 @@
}
.typeBadge,
.stateBadge {
.stateBadge,
.subscriptionBadge {
padding: 3px 7px;
font-size: 10px;
}
@@ -862,7 +864,8 @@
-webkit-line-clamp: 1;
}
.healthStatusMessage {
.healthStatusMessage,
.subscriptionError {
padding: 6px 8px;
border-radius: 8px;
font-size: 11px;
@@ -1022,6 +1025,56 @@
border: 1px dashed color-mix(in srgb, var(--border-primary) 90%, transparent);
}
.subscriptionBadge {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
padding: 3px 8px;
border-radius: 6px;
font-size: 10px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
border: 1px solid transparent;
}
.subscriptionBadgeLoading {
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-tertiary) 86%, transparent);
border-color: var(--border-color);
}
.subscriptionBadgeFree {
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
border-color: color-mix(in srgb, var(--border-color) 86%, transparent);
}
.subscriptionBadgePaid {
color: #8a5b00;
background: rgba(217, 165, 22, 0.14);
border-color: rgba(217, 165, 22, 0.34);
}
:global([data-theme='dark']) .subscriptionBadgePaid {
color: #ffd862;
background: rgba(226, 180, 50, 0.12);
border-color: rgba(226, 180, 50, 0.28);
}
.subscriptionBadgeUnknown {
color: var(--text-secondary);
background: color-mix(in srgb, var(--bg-tertiary) 80%, transparent);
border-color: color-mix(in srgb, var(--border-color) 72%, transparent);
}
.subscriptionBadgeError {
color: var(--danger-color);
background: rgba($error-color, 0.08);
border-color: color-mix(in srgb, var(--danger-color) 70%, transparent);
}
.fileName {
font-size: 14px;
font-weight: 800;
@@ -1116,6 +1169,28 @@
word-break: break-word;
}
.subscriptionSubtitle {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
display: flex;
gap: 6px;
align-items: flex-start;
min-width: 0;
}
.subscriptionSubtitleLabel {
flex: 0 0 auto;
color: var(--text-tertiary);
font-weight: 600;
}
.subscriptionSubtitleValue {
@include text-ellipsis-multiline(2);
min-width: 0;
word-break: break-word;
}
.sortSelect {
width: 100%;
min-width: 0;
@@ -1134,6 +1209,19 @@
word-break: break-word;
}
.subscriptionError {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 11px;
color: var(--danger-color);
background-color: rgba($error-color, 0.08);
border: 1px solid color-mix(in srgb, var(--danger-color) 58%, transparent);
border-radius: 8px;
padding: 8px 10px;
word-break: break-word;
}
.messageIcon {
flex-shrink: 0;
margin-top: 1px;
+4 -1
View File
@@ -50,6 +50,7 @@ import { useAuthFilesModels } from '@/features/authFiles/hooks/useAuthFilesModel
import { useAuthFilesOauth } from '@/features/authFiles/hooks/useAuthFilesOauth';
import { useAuthFilesPrefixProxyEditor } from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
import { useAntigravitySubscriptions } from '@/features/authFiles/hooks/useAntigravitySubscriptions';
import {
isAuthFilesSortMode,
readAuthFilesUiState,
@@ -430,7 +431,8 @@ export function AuthFilesPage() {
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * pageSize;
const pageItems = sorted.slice(start, start + pageSize);
const pageItems = useMemo(() => sorted.slice(start, start + pageSize), [pageSize, sorted, start]);
const antigravitySubscriptions = useAntigravitySubscriptions(pageItems);
const selectablePageItems = useMemo(
() => pageItems.filter((file) => !isRuntimeOnlyAuthFile(file)),
[pageItems]
@@ -811,6 +813,7 @@ export function AuthFilesPage() {
statusUpdating={statusUpdating}
quotaFilterType={quotaFilterType}
statusBarCache={statusBarCache}
antigravitySubscription={antigravitySubscriptions[file.name]}
onShowModels={showModels}
onDownload={handleDownload}
onOpenPrefixProxyEditor={openPrefixProxyEditor}
+129
View File
@@ -0,0 +1,129 @@
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
import {
ANTIGRAVITY_CODE_ASSIST_URL,
ANTIGRAVITY_REQUEST_HEADERS,
createStatusError,
normalizeStringValue,
parseAntigravityPayload,
} from '@/utils/quota';
export type AntigravitySubscriptionPlan = 'free' | 'pro' | 'ultra' | 'ultra-lite' | 'unknown';
export type AntigravitySubscriptionTier = {
id: string | null;
name: string | null;
};
export type AntigravitySubscriptionCredit = {
creditType: string | null;
creditAmount: number | string | null;
minimumCreditAmountForUsage: number | string | null;
};
export type AntigravitySubscriptionSummary = {
plan: AntigravitySubscriptionPlan;
tierId: string | null;
tierName: string | null;
source: 'paid' | 'current';
currentTier: AntigravitySubscriptionTier | null;
paidTier: AntigravitySubscriptionTier | null;
availableCredits: AntigravitySubscriptionCredit[];
};
type RawTierPayload = {
id?: unknown;
name?: unknown;
availableCredits?: unknown;
available_credits?: unknown;
};
const CODE_ASSIST_REQUEST_BODY = JSON.stringify({ metadata: { ideType: 'ANTIGRAVITY' } });
const PLAN_BY_TIER_ID = new Map<string, AntigravitySubscriptionPlan>([
['free-tier', 'free'],
['g1-pro-tier', 'pro'],
['g1-ultra-tier', 'ultra'],
['g1-ultra-lite-tier', 'ultra-lite'],
]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const normalizeTier = (value: unknown): AntigravitySubscriptionTier | null => {
if (!isRecord(value)) return null;
const rawTier = value as RawTierPayload;
return {
id: normalizeStringValue(rawTier.id),
name: normalizeStringValue(rawTier.name),
};
};
const normalizeCreditValue = (value: unknown): number | string | null => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
return normalizeStringValue(value);
};
const normalizeCredits = (value: unknown): AntigravitySubscriptionCredit[] => {
if (!Array.isArray(value)) return [];
return value
.filter(isRecord)
.map((credit) => ({
creditType: normalizeStringValue(credit.creditType ?? credit.credit_type),
creditAmount: normalizeCreditValue(credit.creditAmount ?? credit.credit_amount),
minimumCreditAmountForUsage: normalizeCreditValue(
credit.minimumCreditAmountForUsage ?? credit.minimum_credit_amount_for_usage
),
}))
.filter((credit) => credit.creditType || credit.creditAmount !== null);
};
const resolvePlan = (tierId: string | null): AntigravitySubscriptionPlan => {
if (!tierId) return 'unknown';
return PLAN_BY_TIER_ID.get(tierId) ?? 'unknown';
};
export const parseAntigravitySubscriptionSummary = (
payload: unknown
): AntigravitySubscriptionSummary | null => {
const parsed = parseAntigravityPayload(payload);
if (!parsed) return null;
const currentTierPayload = parsed.currentTier ?? parsed.current_tier;
const paidTierPayload = parsed.paidTier ?? parsed.paid_tier;
const currentTier = normalizeTier(currentTierPayload);
const paidTier = normalizeTier(paidTierPayload);
const effectiveTier = paidTier?.id ? paidTier : currentTier;
if (!effectiveTier?.id && !effectiveTier?.name) return null;
const rawCredits = isRecord(paidTierPayload)
? paidTierPayload.availableCredits ?? paidTierPayload.available_credits
: undefined;
return {
plan: resolvePlan(effectiveTier.id),
tierId: effectiveTier.id,
tierName: effectiveTier.name,
source: paidTier?.id ? 'paid' : 'current',
currentTier,
paidTier,
availableCredits: normalizeCredits(rawCredits),
};
};
export const antigravitySubscriptionApi = {
async get(authIndex: string): Promise<AntigravitySubscriptionSummary | null> {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url: ANTIGRAVITY_CODE_ASSIST_URL,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: CODE_ASSIST_REQUEST_BODY,
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
return parseAntigravitySubscriptionSummary(result.body ?? result.bodyText);
},
};
+1
View File
@@ -1,5 +1,6 @@
export * from './client';
export * from './apiCall';
export * from './antigravitySubscription';
export * from './apiKeyUsage';
export * from './config';
export * from './configFile';
+3
View File
@@ -67,6 +67,9 @@ export const ANTIGRAVITY_QUOTA_URLS = [
'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary',
];
export const ANTIGRAVITY_CODE_ASSIST_URL =
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
export const ANTIGRAVITY_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json',