diff --git a/src/features/authFiles/components/AuthFileCard.tsx b/src/features/authFiles/components/AuthFileCard.tsx index 4736300d..de8ed5b6 100644 --- a/src/features/authFiles/components/AuthFileCard.tsx +++ b/src/features/authFiles/components/AuthFileCard.tsx @@ -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; quotaFilterType: QuotaProviderType | null; statusBarCache: Map; + 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} {stateLabel} + {subscriptionBadgeLabel && ( + + {subscriptionBadgeLabel} + + )} {file.name} + {!compact && subscriptionData?.tierName && ( +
+ + {t('antigravity_subscription.subscription_label')} + + + {subscriptionData.tierName} + +
+ )} {!compact && noteValue && (
{t('auth_files.note_display')} @@ -232,6 +301,17 @@ export function AuthFileCard(props: AuthFileCardProps) {
)} + {subscriptionErrorMessage && ( +
+ + + {t('antigravity_subscription.load_failed', { + message: subscriptionErrorMessage, + })} + +
+ )} +
diff --git a/src/features/authFiles/hooks/useAntigravitySubscriptions.ts b/src/features/authFiles/hooks/useAntigravitySubscriptions.ts new file mode 100644 index 00000000..8e7c1c2e --- /dev/null +++ b/src/features/authFiles/hooks/useAntigravitySubscriptions.ts @@ -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>( + {} + ); + const cacheKeysRef = useRef(new Map()); + + const targets = useMemo( + () => + files.reduce((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 => { + 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; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 76236b63..af1de698 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c09798fc..23e9a0d1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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 отсутствуют", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a922ec0b..d089b79c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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 认证", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index d8e0e11e..9d33f958 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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 驗證", diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index 12a8aa68..48316943 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -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; diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 0595b96f..b94c99b7 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -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} diff --git a/src/services/api/antigravitySubscription.ts b/src/services/api/antigravitySubscription.ts new file mode 100644 index 00000000..c7ea3e08 --- /dev/null +++ b/src/services/api/antigravitySubscription.ts @@ -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([ + ['free-tier', 'free'], + ['g1-pro-tier', 'pro'], + ['g1-ultra-tier', 'ultra'], + ['g1-ultra-lite-tier', 'ultra-lite'], +]); + +const isRecord = (value: unknown): value is Record => + 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 { + 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); + }, +}; diff --git a/src/services/api/index.ts b/src/services/api/index.ts index c5cf4241..ab7ef359 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -1,5 +1,6 @@ export * from './client'; export * from './apiCall'; +export * from './antigravitySubscription'; export * from './apiKeyUsage'; export * from './config'; export * from './configFile'; diff --git a/src/utils/quota/constants.ts b/src/utils/quota/constants.ts index 3663c19e..812603f8 100644 --- a/src/utils/quota/constants.ts +++ b/src/utils/quota/constants.ts @@ -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',