Refactor Gemini model display: filter old versions, group low/high pкo, and improve naming (#114)

* Refactor Gemini model display: filter old versions, group low/high pro, and improve naming

* Fix potential negative minutes and refine Gemini 3 Pro grouping logic based on PR feedback

* The account change process has been sped up

* Address PR review: enhance error message and parameterize polling interval in process handler

* Address PR review: fix potential race condition in database backup logic

* fix: prevent renderer white screen and harden sentry gating

* feat:  localize model labels

* chore: enable hoisted node-linker

* chore: ignore .agent/.claude/.codex in prettier

---------

Co-authored-by: Draculabo <[email protected]>
This commit is contained in:
AngeloooooandDraculabo authored and GitHub committed 2026-02-19 19:37:30 +08:00
1 parent b9497648c6
commit 8ed4593f73
162 files changed
+2981 -2541

No files matched your search

+12 -3
View File
@@ -53,7 +53,10 @@ export function previewGenerateCloudIdentityProfile() {
return ipc.client.cloud.previewIdentityProfile();
}
export function bindCloudIdentityProfile(input: { accountId: string; mode: 'capture' | 'generate' }) {
export function bindCloudIdentityProfile(input: {
accountId: string;
mode: 'capture' | 'generate';
}) {
return ipc.client.cloud.bindIdentityProfile(input);
}
@@ -64,7 +67,10 @@ export function bindCloudIdentityProfileWithPayload(input: {
return ipc.client.cloud.bindIdentityProfileWithPayload(input);
}
export function restoreCloudIdentityProfileRevision(input: { accountId: string; versionId: string }) {
export function restoreCloudIdentityProfileRevision(input: {
accountId: string;
versionId: string;
}) {
return ipc.client.cloud.restoreIdentityProfileRevision(input);
}
@@ -72,7 +78,10 @@ export function restoreCloudBaselineProfile(input: { accountId: string }) {
return ipc.client.cloud.restoreBaselineProfile(input);
}
export function deleteCloudIdentityProfileRevision(input: { accountId: string; versionId: string }) {
export function deleteCloudIdentityProfileRevision(input: {
accountId: string;
versionId: string;
}) {
return ipc.client.cloud.deleteIdentityProfileRevision(input);
}
+132 -80
View File
@@ -26,6 +26,10 @@ import { useAppConfig } from '@/hooks/useAppConfig';
import { useProviderGrouping } from '@/hooks/useProviderGrouping';
import { ProviderGroup } from '@/components/ProviderGroup';
interface ModelQuotaInfo {
percentage: number;
resetTime: string;
}
interface CloudAccountCardProps {
account: CloudAccount;
onRefresh: (id: string) => void;
@@ -62,22 +66,14 @@ export function CloudAccountCard({
// Helpers to get quota color
const getQuotaColor = (percentage: number) => {
if (percentage > 80) {
return 'text-green-500';
}
if (percentage > 20) {
return 'text-yellow-500';
}
if (percentage > 80) return 'text-green-500';
if (percentage > 20) return 'text-yellow-500';
return 'text-red-500';
};
const getQuotaBarColor = (percentage: number) => {
if (percentage > 80) {
return 'bg-emerald-500';
}
if (percentage > 20) {
return 'bg-amber-500';
}
if (percentage > 80) return 'bg-emerald-500';
if (percentage > 20) return 'bg-amber-500';
return 'bg-rose-500';
};
@@ -90,63 +86,144 @@ export function CloudAccountCard({
const formatTimeRemaining = (dateStr: string) => {
const targetDate = new Date(dateStr);
if (Number.isNaN(targetDate.getTime())) {
return null;
}
if (Number.isNaN(targetDate.getTime())) return null;
const now = new Date();
if (isBefore(targetDate, now)) {
return '0h 0m';
}
if (isBefore(targetDate, now)) return '0h 0m';
// Calculate difference in hours and minutes
const diffHrs = differenceInHours(targetDate, now);
const diffMins = differenceInMinutes(targetDate, now) - diffHrs * 60;
const diffHrs = Math.max(0, differenceInHours(targetDate, now));
const diffMins = Math.max(0, differenceInMinutes(targetDate, now) - diffHrs * 60);
if (diffHrs >= 24) {
const diffDays = Math.floor(diffHrs / 24);
const remainingHrs = diffHrs % 24;
return `${diffDays}d ${remainingHrs}h`;
}
return `${diffHrs}h ${diffMins}m`;
};
const getResetTimeLabel = (resetTime?: string) => {
if (!resetTime) {
return t('cloud.card.resetUnknown');
}
if (!resetTime) return t('cloud.card.resetUnknown');
const remaining = formatTimeRemaining(resetTime);
if (!remaining) {
return t('cloud.card.resetUnknown');
}
if (!remaining) return t('cloud.card.resetUnknown');
return `${t('cloud.card.resetPrefix')}: ${remaining}`;
};
const getResetTimeTitle = (resetTime?: string) => {
if (!resetTime) {
return undefined;
}
if (!resetTime) return undefined;
const resetDate = new Date(resetTime);
if (Number.isNaN(resetDate.getTime())) {
return undefined;
}
if (Number.isNaN(resetDate.getTime())) return undefined;
return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`;
};
const modelQuotas = Object.entries(account.quota?.models || {}).filter(
// --- Logic for model groups ---
const rawModels = Object.entries(account.quota?.models || {}).filter(
([modelName]) => config?.model_visibility?.[modelName] !== false,
);
// Group Gemini 3 Pro Low/High if both exist
const processedModels: Record<string, ModelQuotaInfo> = {};
const hasLow = rawModels.some(([name]) => name.includes('gemini-3-pro-low'));
const hasHigh = rawModels.some(([name]) => name.includes('gemini-3-pro-high'));
for (const [name, info] of rawModels) {
if (name.includes('gemini-3-pro-low') && hasHigh) continue;
if (name.includes('gemini-3-pro-high') && hasLow) {
// Use the lower percentage if both exist, to be safe
const lowInfo = rawModels.find(([n]) => n.includes('gemini-3-pro-low'))?.[1];
const mergedPercentage = lowInfo
? Math.min(info.percentage, lowInfo.percentage)
: info.percentage;
processedModels['gemini-3-pro-low/high'] = { ...info, percentage: mergedPercentage };
continue;
}
processedModels[name] = info;
}
const geminiModels = Object.entries(processedModels)
.filter(([name]) => name.includes('gemini') && !/gemini-[12](\.|$|-)/i.test(name))
.sort((a, b) => b[1].percentage - a[1].percentage);
const claudeModels = Object.entries(processedModels)
.filter(([name]) => name.includes('claude'))
.sort((a, b) => b[1].percentage - a[1].percentage);
const hasHighTier = geminiModels.some(
([name, info]) => name.includes('gemini-3-pro') && info.percentage > 50,
);
const hasRenderableModels = geminiModels.length > 0 || claudeModels.length > 0;
const formatModelName = (name: string) => {
return name
.replace('models/', '')
.replace('gemini-3-pro-low/high', 'Gemini 3 Pro (Low/High)')
.replace('gemini-3-pro-preview', 'Gemini 3 Pro Preview')
.replace('gemini-3-pro-image', 'Gemini 3 Pro Image')
.replace('gemini-3-pro', 'Gemini 3 Pro')
.replace('gemini-3-flash', 'Gemini 3 Flash')
.replace('claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)')
.replace('claude-sonnet-4-5', 'Claude 4.5 Sonnet')
.replace('claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)')
.replace('claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)')
.replace('claude-3-5-sonnet', 'Claude 3.5 Sonnet')
.replace(/-/g, ' ')
.split(' ')
.map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
.join(' ');
};
const renderModelGroup = (title: string, models: [string, ModelQuotaInfo][]) => {
if (models.length === 0) return null;
return (
<div key={title} className="space-y-1">
<div className="flex items-center gap-1.5 px-2 py-1">
<span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase">
{title}
</span>
<div className="bg-border/50 h-[1px] flex-1" />
</div>
{models.map(([modelName, info]) => (
<div
key={modelName}
className="group/item hover:bg-muted/60 hover:border-border/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-sm transition-all"
>
<span
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
title={modelName}
>
{formatModelName(modelName)}
</span>
<div className="flex flex-col items-end gap-0.5">
<span
className="text-muted-foreground text-[9px] leading-none opacity-80"
title={getResetTimeTitle(info.resetTime)}
>
{getResetTimeLabel(info.resetTime)}
</span>
<div className="flex items-baseline gap-1">
<span
className={`font-mono text-xs leading-none font-bold ${getQuotaColor(info.percentage)}`}
>
{info.percentage}%
</span>
<div className="bg-muted h-1 w-16 overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(info.percentage)}`}
style={{ width: `${Math.max(0, Math.min(100, info.percentage))}%` }}
/>
</div>
</div>
</div>
</div>
))}
</div>
);
};
return (
<Card
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
>
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
{/* Selection Checkbox - Visible on hover or selected */}
{onToggleSelection && (
<div
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} bg-background/90 rounded-full p-2 transition-opacity`}
@@ -233,6 +310,15 @@ export function CloudAccountCard({
)}
</div>
{hasHighTier && (
<Badge
variant="secondary"
className="animate-pulse border-blue-500/20 bg-blue-500/10 text-[10px] text-blue-500"
>
{t('cloud.card.gemini3Ready')}
</Badge>
)}
{account.is_active ? (
<Button variant="ghost" size="sm" disabled className="text-green-600 opacity-100">
<Power className="mr-1 h-3 w-3" />
@@ -258,7 +344,6 @@ export function CloudAccountCard({
<div className="space-y-2">
{providerGroupingsEnabled ? (
// Grouped view with provider sections
(() => {
const accountStats = getAccountStats(account);
if (accountStats.visibleModels === 0) {
@@ -271,7 +356,6 @@ export function CloudAccountCard({
}
return (
<>
{/* Account-level overall stats */}
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
<div className="flex items-center gap-2">
@@ -290,7 +374,6 @@ export function CloudAccountCard({
</div>
</div>
</div>
{/* Provider groups */}
{accountStats.providers.map((providerStats) => (
<ProviderGroup
key={providerStats.providerKey}
@@ -310,43 +393,12 @@ export function CloudAccountCard({
</>
);
})()
) : modelQuotas.length > 0 ? (
modelQuotas.map(([modelName, info]) => (
<div
key={modelName}
className="hover:bg-muted/60 hover:border-border/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-2 text-sm transition-colors"
>
<span className="text-muted-foreground min-w-0 truncate" title={modelName}>
{modelName.replace('models/', '')}
</span>
<div className="flex flex-col items-end gap-1">
<span
className="text-muted-foreground text-[10px] leading-none"
title={getResetTimeTitle(info.resetTime)}
>
{getResetTimeLabel(info.resetTime)}
</span>
<div className="flex items-baseline gap-1.5">
<span
className={`font-mono leading-none font-bold ${getQuotaColor(info.percentage)}`}
>
{getQuotaLabel(info.percentage)}
</span>
{info.percentage > 0 && (
<span className="text-muted-foreground text-[10px]">
{t('cloud.card.left')}
</span>
)}
</div>
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(info.percentage)}`}
style={{ width: `${Math.max(0, Math.min(100, info.percentage))}%` }}
/>
</div>
</div>
</div>
))
) : hasRenderableModels ? (
<div className="space-y-3">
{renderModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
<div className="pt-1" />
{renderModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
</div>
) : (
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
<Box className="mb-2 h-8 w-8 opacity-20" />
+29 -28
View File
@@ -197,20 +197,25 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
account.device_profile = generated;
}
// 1. Ensure token is fresh before injecting
const now = Math.floor(Date.now() / 1000);
if (account.token.expiry_timestamp < now + 300) {
logger.info(`Token for ${account.email} near expiry, refreshing before switch...`);
try {
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
account.token.access_token = newTokenData.access_token;
account.token.expires_in = newTokenData.expires_in;
account.token.expiry_timestamp = now + newTokenData.expires_in;
await CloudAccountRepo.updateToken(account.id, account.token);
} catch (e) {
logger.warn('Failed to refresh token before switch, trying with existing token', e);
// 1. Prepare token refresh promise (start it in parallel with process exit)
const tokenRefreshPromise = (async () => {
const now = Math.floor(Date.now() / 1000);
if (account.token.expiry_timestamp < now + 1200) { // Increased buffer to 20m
logger.info(`Token for ${account.email} near expiry, refreshing in parallel...`);
try {
const newTokenData = await GoogleAPIService.refreshAccessToken(
account.token.refresh_token,
);
account.token.access_token = newTokenData.access_token;
account.token.expires_in = newTokenData.expires_in;
account.token.expiry_timestamp = now + newTokenData.expires_in;
await CloudAccountRepo.updateToken(account.id, account.token);
logger.info(`Token refreshed for ${account.email}`);
} catch (e) {
logger.warn('Failed to refresh token in parallel, will try to use existing', e);
}
}
}
})();
await executeSwitchFlow({
scope: 'cloud',
@@ -218,29 +223,25 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
applyFingerprint: isIdentityProfileApplyEnabled(),
processExitTimeoutMs: 10000,
performSwitch: async () => {
// 3. Backup Database (New Logic)
const dbPaths = getAntigravityDbPaths();
// Find the valid DB path
let dbPath: string | null = null;
for (const p of dbPaths) {
if (fs.existsSync(p)) {
dbPath = p;
break;
}
}
// Wait for token refresh to complete before injection if it was started
await tokenRefreshPromise;
if (dbPath) {
// 3. Backup Database (Optimized to avoid race conditions)
const dbPaths = getAntigravityDbPaths();
for (const dbPath of dbPaths) {
try {
const backupPath = `${dbPath}.backup`;
fs.copyFileSync(dbPath, backupPath);
await fs.promises.copyFile(dbPath, backupPath);
logger.info(`Backed up database to ${backupPath}`);
} catch (e) {
logger.error('Failed to backup database', e);
break; // Success, stop trying other paths
} catch (e: any) {
// If file not found, just try the next path
if (e.code === 'ENOENT') continue;
logger.error(`Failed to backup database at ${dbPath}`, e);
}
}
// 4. Inject Token
// injectedCloudToken uses direct DB/FS access which is sync better-sqlite3.
CloudAccountRepo.injectCloudToken(account);
// 5. Update usage and active status
+1 -4
View File
@@ -20,10 +20,7 @@ import {
startAuthFlow,
} from './handler';
import { CloudAccountSchema } from '../../types/cloudAccount';
import {
DeviceProfileSchema,
DeviceProfilesSnapshotSchema,
} from '../../types/account';
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
import { CloudAccountRepo } from '../database/cloudHandler';
import { logger } from '../../utils/logger';
import { getSwitchMetricsSnapshot } from '../switchMetrics';
+25 -20
View File
@@ -34,10 +34,8 @@ export interface DeviceHardeningSnapshot {
lastFailureAt: number | null;
}
const UUID_V4_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const SQM_ID_REGEX =
/^\{[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\}$/;
const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const SQM_ID_REGEX = /^\{[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}\}$/;
interface DeviceProfileTemplate {
machineIdPrefix: string;
@@ -183,7 +181,11 @@ function getTelemetryField(storage: Record<string, unknown>, key: string): strin
}
function ensureTelemetryObject(storage: Record<string, unknown>): Record<string, unknown> {
if (!storage.telemetry || typeof storage.telemetry !== 'object' || Array.isArray(storage.telemetry)) {
if (
!storage.telemetry ||
typeof storage.telemetry !== 'object' ||
Array.isArray(storage.telemetry)
) {
storage.telemetry = {};
}
return storage.telemetry as Record<string, unknown>;
@@ -272,9 +274,7 @@ function pickDeviceProfileTemplate(): DeviceProfileTemplate {
}
function deterministicUuidFromSeed(seed: string, namespace: string): string {
const hash = createHash('sha256')
.update(`${namespace}:${seed}`)
.digest();
const hash = createHash('sha256').update(`${namespace}:${seed}`).digest();
const bytes = Buffer.from(hash.subarray(0, 16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
@@ -463,10 +463,18 @@ function buildApplyError(
): Error {
const message = original instanceof Error ? original.message : String(original);
const wrapped = new Error(`device_apply_failed:${reason}:${stage}:${message}`);
(wrapped as Error & { deviceFailureReason?: DeviceApplyFailureReason; deviceFailureStage?: string })
.deviceFailureReason = reason;
(wrapped as Error & { deviceFailureReason?: DeviceApplyFailureReason; deviceFailureStage?: string })
.deviceFailureStage = stage;
(
wrapped as Error & {
deviceFailureReason?: DeviceApplyFailureReason;
deviceFailureStage?: string;
}
).deviceFailureReason = reason;
(
wrapped as Error & {
deviceFailureReason?: DeviceApplyFailureReason;
deviceFailureStage?: string;
}
).deviceFailureStage = stage;
return wrapped;
}
@@ -490,12 +498,8 @@ export function isIdentityProfileApplyEnabled(): boolean {
return !isSafeModeActiveNow();
}
const normalized = raw.trim().toLowerCase();
const envEnabled = (
normalized !== '0' &&
normalized !== 'false' &&
normalized !== 'off' &&
normalized !== 'no'
);
const envEnabled =
normalized !== '0' && normalized !== 'false' && normalized !== 'off' && normalized !== 'no';
if (!envEnabled) {
return false;
}
@@ -605,8 +609,9 @@ export function syncStateServiceMachineIdValue(serviceMachineId: string, dbPath?
db = new Database(targetDbPath);
db.pragma('busy_timeout = 3000');
db.exec('CREATE TABLE IF NOT EXISTS ItemTable (key TEXT PRIMARY KEY, value TEXT);');
db.prepare("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('storage.serviceMachineId', ?)")
.run(serviceMachineId);
db.prepare(
"INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('storage.serviceMachineId', ?)",
).run(serviceMachineId);
return;
} catch (error) {
if (isSqliteBusyError(error) && attempt < SQLITE_RETRY_COUNT) {
+6 -3
View File
@@ -345,15 +345,18 @@ export async function closeAntigravity(): Promise<void> {
* @param timeoutMs {number} The timeout in milliseconds.
* @returns {Promise<void>} A promise that resolves when the process exits.
*/
export async function _waitForProcessExit(timeoutMs: number): Promise<void> {
export async function _waitForProcessExit(
timeoutMs: number,
pollInterval = 100, // Make it configurable, but keep fast 100ms default
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
if (!(await isProcessRunning())) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
throw new Error('Process did not exit within timeout');
throw new Error(`Antigravity process did not exit within ${timeoutMs}ms`);
}
/**
+6 -8
View File
@@ -2,7 +2,11 @@ import { type DeviceProfile } from '../types/account';
import { logger } from '../utils/logger';
import { closeAntigravity, startAntigravity, _waitForProcessExit } from './process/handler';
import { applyDeviceProfile } from './device/handler';
import { type SwitchFailureReason, recordSwitchFailure, recordSwitchSuccess } from './switchMetrics';
import {
type SwitchFailureReason,
recordSwitchFailure,
recordSwitchSuccess,
} from './switchMetrics';
export interface SwitchFlowOptions {
scope: 'local' | 'cloud';
@@ -47,13 +51,7 @@ function toSwitchFailureReason(stage: string, error: unknown): SwitchFailureReas
}
export async function executeSwitchFlow(options: SwitchFlowOptions): Promise<void> {
const {
scope,
targetProfile,
applyFingerprint,
processExitTimeoutMs,
performSwitch,
} = options;
const { scope, targetProfile, applyFingerprint, processExitTimeoutMs, performSwitch } = options;
let stage = 'close';
try {
+21 -28
View File
@@ -20,49 +20,42 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
'claude-haiku-4-5-20251001': 'claude-sonnet-4-5',
// OpenAI Protocol Mapping
'gpt-4': 'gemini-2.5-flash',
'gpt-4-turbo': 'gemini-2.5-flash',
'gpt-4-turbo-preview': 'gemini-2.5-flash',
'gpt-4-0125-preview': 'gemini-2.5-flash',
'gpt-4-1106-preview': 'gemini-2.5-flash',
'gpt-4-0613': 'gemini-2.5-flash',
'gpt-4': 'gemini-3-flash',
'gpt-4-turbo': 'gemini-3-flash',
'gpt-4-turbo-preview': 'gemini-3-flash',
'gpt-4-0125-preview': 'gemini-3-flash',
'gpt-4-1106-preview': 'gemini-3-flash',
'gpt-4-0613': 'gemini-3-flash',
'gpt-4o': 'gemini-2.5-flash',
'gpt-4o-2024-05-13': 'gemini-2.5-flash',
'gpt-4o-2024-08-06': 'gemini-2.5-flash',
'gpt-4o': 'gemini-3-flash',
'gpt-4o-2024-05-13': 'gemini-3-flash',
'gpt-4o-2024-08-06': 'gemini-3-flash',
'gpt-4o-mini': 'gemini-2.5-flash',
'gpt-4o-mini-2024-07-18': 'gemini-2.5-flash',
'gpt-4o-mini': 'gemini-3-flash',
'gpt-4o-mini-2024-07-18': 'gemini-3-flash',
'gpt-3.5-turbo': 'gemini-2.5-flash',
'gpt-3.5-turbo-16k': 'gemini-2.5-flash',
'gpt-3.5-turbo-0125': 'gemini-2.5-flash',
'gpt-3.5-turbo-1106': 'gemini-2.5-flash',
'gpt-3.5-turbo-0613': 'gemini-2.5-flash',
'gpt-3.5-turbo': 'gemini-3-flash',
'gpt-3.5-turbo-16k': 'gemini-3-flash',
'gpt-3.5-turbo-0125': 'gemini-3-flash',
'gpt-3.5-turbo-1106': 'gemini-3-flash',
'gpt-3.5-turbo-0613': 'gemini-3-flash',
// Gemini Protocol Mapping
'gemini-2.5-flash-lite': 'gemini-2.5-flash',
'gemini-2.5-flash-thinking': 'gemini-2.5-flash-thinking',
'gemini-2.5-flash-lite': 'gemini-3-flash',
'gemini-3-pro-low': 'gemini-3-pro-preview',
'gemini-3-pro-high': 'gemini-3-pro-preview',
'gemini-3-pro-preview': 'gemini-3-pro-preview',
'gemini-3-pro': 'gemini-3-pro-preview',
'gemini-2.5-flash': 'gemini-2.5-flash',
'gemini-2.5-flash': 'gemini-3-flash',
'gemini-3-flash': 'gemini-3-flash',
'gemini-3-pro-image': 'gemini-3-pro-image',
'internal-background-task': 'gemini-2.5-flash',
'internal-background-task': 'gemini-3-flash',
};
const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image';
const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k'];
const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9'];
const EXTRA_DYNAMIC_MODELS = [
'gemini-2.0-flash-exp',
'gemini-2.5-flash',
'gemini-3-flash',
'gemini-3-pro-high',
'gemini-3-pro-low',
];
const EXTRA_DYNAMIC_MODELS = ['gemini-3-flash', 'gemini-3-pro-high', 'gemini-3-pro-low'];
export const MODEL_LIST_CREATED_AT = 1770652800;
@@ -93,7 +86,7 @@ export function getAllDynamicModels(customMapping: Record<string, string> = {}):
modelIds.add(modelId);
}
return [...modelIds].sort();
return [...modelIds].filter((id) => !/gemini-[12](\.|$|-)/.test(id)).sort();
}
export function mapClaudeModelToGemini(input: string): string {
+6 -2
View File
@@ -115,8 +115,12 @@ export class QuotaService {
const percentage = Math.floor(fraction * 100);
const resetTime = info.quotaInfo.resetTime || '';
// Only save models we care about
if (name.includes('gemini') || name.includes('claude')) {
// Only save models we care about, filtering out old versions (< 3.0)
const isGemini = name.includes('gemini');
const isClaude = name.includes('claude');
const isOldGemini = /gemini-[12](\.|$|-)/.test(name);
if ((isGemini || isClaude) && !isOldGemini) {
quotaData.models[name] = { percentage, resetTime };
}
}
+9
View File
@@ -286,6 +286,9 @@ i18n
resetPrefix: 'reset',
resetTime: 'Reset time',
resetUnknown: 'Unknown',
gemini3Ready: 'Gemini 3 Ready',
groupGoogleGemini: 'Google Gemini',
groupAnthropicClaude: 'Anthropic Claude',
},
identity: {
title: 'Identity Profile',
@@ -666,6 +669,9 @@ i18n
resetPrefix: '重置',
resetTime: '重置时间',
resetUnknown: '未知',
gemini3Ready: 'Gemini 3 就绪',
groupGoogleGemini: 'Google Gemini',
groupAnthropicClaude: 'Anthropic Claude',
},
identity: {
title: '身份配置',
@@ -1050,6 +1056,9 @@ i18n
resetPrefix: 'Сброс',
resetTime: 'Время сброса',
resetUnknown: 'Неизвестно',
gemini3Ready: 'Gemini 3 готов',
groupGoogleGemini: 'Google Gemini',
groupAnthropicClaude: 'Anthropic Claude',
},
identity: {
title: 'Профиль идентичности',
+35 -2
View File
@@ -200,8 +200,41 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
logger.info('createWindow: setMainWindow done');
if (inDevelopment && MAIN_WINDOW_VITE_DEV_SERVER_URL) {
logger.info(`createWindow: loading URL ${MAIN_WINDOW_VITE_DEV_SERVER_URL}`);
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
const devUrl = MAIN_WINDOW_VITE_DEV_SERVER_URL;
logger.info(`createWindow: waiting for Vite dev server at ${devUrl}`);
// Wait for Vite to be ready before loading
const waitForVite = async (url: string, maxRetries = 30, delay = 500) => {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url);
if (response.ok) {
logger.info(`createWindow: Vite server ready after ${i * delay}ms`);
return true;
}
} catch (e) {
// Server not ready yet
}
await new Promise((resolve) => setTimeout(resolve, delay));
}
logger.error('createWindow: Vite server did not start in time');
return false;
};
waitForVite(devUrl).then((ready) => {
if (mainWindow.isDestroyed()) {
logger.warn('createWindow: BrowserWindow destroyed before Vite URL load');
return;
}
if (ready) {
logger.info(`createWindow: loading URL ${devUrl}`);
mainWindow.loadURL(devUrl);
} else {
logger.error('createWindow: Failed to connect to Vite server, loading anyway');
mainWindow.loadURL(devUrl);
}
});
} else {
logger.info('createWindow: loading file index.html');
mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
+1 -1
View File
@@ -32,7 +32,7 @@ try {
// console.error('Preload: Failed to read config', e);
}
if (sentryEnabled) {
if (sentryEnabled && process.env.NODE_ENV === 'production') {
// Defer Sentry init to avoid blocking main thread during startup (white screen fix)
setTimeout(() => {
// console.log('[Preload] Initializing Sentry (Deferred)');
+11 -8
View File
@@ -1,12 +1,15 @@
import * as Sentry from '@sentry/electron/renderer';
if (window.electron.SENTRY_ENABLED) {
// Defer initialization to prioritize initial render
setTimeout(() => {
Sentry.init({
dsn: process.env.SENTRY_DSN,
if (window.electron?.SENTRY_ENABLED && process.env.NODE_ENV === 'production') {
import('@sentry/electron/renderer')
.then((Sentry) => {
setTimeout(() => {
Sentry.init({
dsn: process.env.SENTRY_DSN,
});
}, 2000);
})
.catch((error) => {
console.warn('Sentry initialization failed:', error);
});
}, 2000);
}
import '@/App';
+1 -7
View File
@@ -15,13 +15,7 @@ function HomePage() {
);
}
function HomePageErrorBoundary({
error,
reset,
}: {
error: unknown;
reset: () => void;
}) {
function HomePageErrorBoundary({ error, reset }: { error: unknown; reset: () => void }) {
const { t } = useTranslation();
const { toast } = useToast();
const hasShownToastRef = useRef(false);
+12 -5
View File
@@ -1,4 +1,14 @@
import { Body, Controller, Get, HttpStatus, Inject, Param, Post, Res, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Get,
HttpStatus,
Inject,
Param,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { FastifyReply } from 'fastify';
import { isEmpty } from 'lodash-es';
import { Observable } from 'rxjs';
@@ -131,9 +141,7 @@ export class GeminiController {
}
}
private parseCombinedModelAction(
modelAction: string,
): {
private parseCombinedModelAction(modelAction: string): {
model: string;
action: string;
} | null {
@@ -288,5 +296,4 @@ export class GeminiController {
private hasHijack(reply: FastifyReply): reply is FastifyReply & { hijack: () => void } {
return typeof (reply as { hijack?: unknown }).hijack === 'function';
}
}
+12 -10
View File
@@ -30,14 +30,12 @@ import {
} from '../../../lib/antigravity/ModelMapping';
import { getServerConfig } from '../../server-config';
@Controller('v1')
@UseGuards(ProxyGuard)
export class ProxyController {
private readonly logger = new Logger(ProxyController.name);
constructor(@Inject(ProxyService) private readonly proxyService: ProxyService) { }
constructor(@Inject(ProxyService) private readonly proxyService: ProxyService) {}
@Get('models')
listModels(@Res() res: FastifyReply) {
@@ -223,12 +221,13 @@ export class ProxyController {
content:
imageParts.length > 0
? [
{
type: 'text',
text: body.prompt ?? 'Please edit this image based on the provided instruction.',
},
...imageParts,
]
{
type: 'text',
text:
body.prompt ?? 'Please edit this image based on the provided instruction.',
},
...imageParts,
]
: (body.prompt ?? 'Please edit this image based on the provided instruction.'),
},
],
@@ -951,7 +950,10 @@ export class ProxyController {
parts.push({ text: fallbackPrompt || 'Please generate an image based on this request.' });
} else if (typeof userMessage.content === 'string') {
parts.push({
text: userMessage.content || fallbackPrompt || 'Please generate an image based on this request.',
text:
userMessage.content ||
fallbackPrompt ||
'Please generate an image based on this request.',
});
} else {
for (const block of userMessage.content) {
+5 -1
View File
@@ -6,7 +6,11 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { getServerConfig } from '../../server-config';
import { extractApiKeyToken, hasConfiguredApiKey, RequestHeaders } from '../../guards/api-key-auth.util';
import {
extractApiKeyToken,
hasConfiguredApiKey,
RequestHeaders,
} from '../../guards/api-key-auth.util';
@Injectable()
export class ProxyGuard implements CanActivate {
+37 -37
View File
@@ -273,38 +273,38 @@
}
:root {
--background: oklch(0.9900 0 0);
--background: oklch(0.99 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(0.9900 0 0);
--popover: oklch(0.99 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9400 0 0);
--secondary: oklch(0.94 0 0);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9700 0 0);
--muted-foreground: oklch(0.4400 0 0);
--accent: oklch(0.9400 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.44 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.6300 0.1900 23.0300);
--destructive: oklch(0.63 0.19 23.03);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9200 0 0);
--input: oklch(0.9400 0 0);
--border: oklch(0.92 0 0);
--input: oklch(0.94 0 0);
--ring: oklch(0 0 0);
--chart-1: oklch(0.8100 0.1700 75.3500);
--chart-2: oklch(0.5500 0.2200 264.5300);
--chart-3: oklch(0.7200 0 0);
--chart-4: oklch(0.9200 0 0);
--chart-5: oklch(0.5600 0 0);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.55 0.22 264.53);
--chart-3: oklch(0.72 0 0);
--chart-4: oklch(0.92 0 0);
--chart-5: oklch(0.56 0 0);
--radius: 0.5rem;
--sidebar: oklch(0.9900 0 0);
--sidebar: oklch(0.99 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9400 0 0);
--sidebar-accent: oklch(0.94 0 0);
--sidebar-accent-foreground: oklch(0 0 0);
--sidebar-border: oklch(0.9400 0 0);
--sidebar-border: oklch(0.94 0 0);
--sidebar-ring: oklch(0 0 0);
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
@@ -331,37 +331,37 @@
.dark {
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0.1400 0 0);
--card: oklch(0.14 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.1800 0 0);
--popover: oklch(0.18 0 0);
--popover-foreground: oklch(1 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2500 0 0);
--secondary: oklch(0.25 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.2300 0 0);
--muted-foreground: oklch(0.7200 0 0);
--accent: oklch(0.3200 0 0);
--muted: oklch(0.23 0 0);
--muted-foreground: oklch(0.72 0 0);
--accent: oklch(0.32 0 0);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.6900 0.2000 23.9100);
--destructive: oklch(0.69 0.2 23.91);
--destructive-foreground: oklch(0 0 0);
--border: oklch(0.2600 0 0);
--input: oklch(0.3200 0 0);
--ring: oklch(0.7200 0 0);
--chart-1: oklch(0.8100 0.1700 75.3500);
--chart-2: oklch(0.5800 0.2100 260.8400);
--chart-3: oklch(0.5600 0 0);
--chart-4: oklch(0.4400 0 0);
--chart-5: oklch(0.9200 0 0);
--border: oklch(0.26 0 0);
--input: oklch(0.32 0 0);
--ring: oklch(0.72 0 0);
--chart-1: oklch(0.81 0.17 75.35);
--chart-2: oklch(0.58 0.21 260.84);
--chart-3: oklch(0.56 0 0);
--chart-4: oklch(0.44 0 0);
--chart-5: oklch(0.92 0 0);
--radius: 0.5rem;
--sidebar: oklch(0.1800 0 0);
--sidebar: oklch(0.18 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(1 0 0);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.3200 0 0);
--sidebar-accent: oklch(0.32 0 0);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.3200 0 0);
--sidebar-ring: oklch(0.7200 0 0);
--sidebar-border: oklch(0.32 0 0);
--sidebar-ring: oklch(0.72 0 0);
--font-sans: Geist, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Geist Mono, monospace;
@@ -381,4 +381,4 @@
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
}
}
@@ -232,7 +232,8 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
},
markAsRateLimited: (accountIdOrEmail: string) =>
realTokenManager.markAsRateLimited(accountIdOrEmail),
markAsForbidden: (accountIdOrEmail: string) => realTokenManager.markAsForbidden(accountIdOrEmail),
markAsForbidden: (accountIdOrEmail: string) =>
realTokenManager.markAsForbidden(accountIdOrEmail),
};
try {
@@ -256,7 +257,9 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
const captured = capturedBody as Record<string, unknown>;
const tokenProjectId = selectedToken?.token?.project_id;
const normalizedTokenProjectId =
typeof tokenProjectId === 'string' && tokenProjectId.trim() !== '' ? tokenProjectId.trim() : undefined;
typeof tokenProjectId === 'string' && tokenProjectId.trim() !== ''
? tokenProjectId.trim()
: undefined;
if (normalizedTokenProjectId) {
assert.equal(
@@ -275,7 +278,10 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
'[DEBUG] Observed getNextToken options from ProxyService:',
JSON.stringify(observedGetNextTokenOptions, null, 2),
);
console.log('[DEBUG] Selected token project_id from TokenManagerService:', tokenProjectId ?? null);
console.log(
'[DEBUG] Selected token project_id from TokenManagerService:',
tokenProjectId ?? null,
);
console.log(
'[DEBUG] Runtime captured internal request (from handleAnthropicMessages):',
JSON.stringify(captured, null, 2),
+4 -1
View File
@@ -99,7 +99,10 @@ describe('Account Handler', () => {
it('should reuse existing device profile on switch', async () => {
const account = await addAccountSnapshot();
const accountFilePath = path.join(testAgentDir, 'accounts.json');
const allAccounts = JSON.parse(fs.readFileSync(accountFilePath, 'utf-8')) as Record<string, any>;
const allAccounts = JSON.parse(fs.readFileSync(accountFilePath, 'utf-8')) as Record<
string,
any
>;
allAccounts[account.id].deviceProfile = {
machineId: 'existing-machine',
macMachineId: 'existing-mac',
-1
View File
@@ -266,7 +266,6 @@ describe('CloudAccountRepo.syncFromIDE', () => {
expect(wroteUnifiedKey).toBe(true);
expect(updatedOldKey).toBe(false);
});
});
describe('cloud switch fail-fast path', () => {
+1 -5
View File
@@ -21,11 +21,7 @@ describe('ProtobufUtils Unified OAuth', () => {
const refreshToken = 'refresh-token-def';
const expiry = 1700001234;
const unifiedB64 = ProtobufUtils.createUnifiedOAuthToken(
accessToken,
refreshToken,
expiry,
);
const unifiedB64 = ProtobufUtils.createUnifiedOAuthToken(accessToken, refreshToken, expiry);
const unifiedBytes = new Uint8Array(Buffer.from(unifiedB64, 'base64'));
const parsed = ProtobufUtils.extractOAuthTokenInfoFromUnifiedState(unifiedBytes);
+5 -1
View File
@@ -6,7 +6,11 @@ import { Observable } from 'rxjs';
import { ProxyService } from '../../server/modules/proxy/proxy.service';
const mockTokenManager = { getNextToken: vi.fn(), markAsRateLimited: vi.fn(), markAsForbidden: vi.fn() };
const mockTokenManager = {
getNextToken: vi.fn(),
markAsRateLimited: vi.fn(),
markAsForbidden: vi.fn(),
};
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
class TestableProxyService extends ProxyService {
+6 -1
View File
@@ -1,5 +1,10 @@
import { z } from 'zod';
import { DeviceProfileSchema, DeviceProfileVersionSchema, type DeviceProfile, type DeviceProfileVersion } from './account';
import {
DeviceProfileSchema,
DeviceProfileVersionSchema,
type DeviceProfile,
type DeviceProfileVersion,
} from './account';
export interface CloudTokenData {
access_token: string;
+1 -3
View File
@@ -231,9 +231,7 @@ export class ProtobufUtils {
const field4 = this.encodeLenDelimField(4, timestampMsg);
const combined = new Uint8Array(
field1.length + field2.length + field3.length + field4.length,
);
const combined = new Uint8Array(field1.length + field2.length + field3.length + field4.length);
combined.set(field1, 0);
combined.set(field2, field1.length);
combined.set(field3, field1.length + field2.length);