Compare commits

..
9 Commits
Author SHA1 Message Date
semantic-release-bot e524a340cb chore(release): 0.11.1
## [0.11.1](https://github.com/Draculabo/AntigravityManager/compare/v0.11.0...v0.11.1) (2026-04-18)

### 🐛 Bug Fixes

* add unit label to AI credit display ([6577f1c](https://github.com/Draculabo/AntigravityManager/commit/6577f1c51fd155004b81f6171eb5feffa2448128))
* remaining localization gaps ([#156](https://github.com/Draculabo/AntigravityManager/issues/156)) ([7093c98](https://github.com/Draculabo/AntigravityManager/commit/7093c982ba790380cda7d1e8a53891970df6b3cf))
* remove AI credit refresh time scraping ([3e1e10b](https://github.com/Draculabo/AntigravityManager/commit/3e1e10b2a580ebf5a64b9a8982bd812a6a1f2924))

### 📝 Documentation

* **repo:** update project docs ([440a934](https://github.com/Draculabo/AntigravityManager/commit/440a9342800f244287c758b1a77a05fac9096df7))

### ♻️ Code Refactoring

* **ipc:** migrate renderer IPC client to official oRPC client ([d0c282d](https://github.com/Draculabo/AntigravityManager/commit/d0c282dffff24bb74a81cfa523f2f72f949bf8f9))
* standardize runtime guard patterns with lodash-es ([#154](https://github.com/Draculabo/AntigravityManager/issues/154)) ([bc35b96](https://github.com/Draculabo/AntigravityManager/commit/bc35b96b5d2d873cf0f187a57de695aedc32108f))
2026-04-18 17:26:35 +00:00
Draculabo 6577f1c51f fix: add unit label to AI credit display 2026-04-19 01:24:11 +08:00
Draculabo 3e1e10b2a5 fix: remove AI credit refresh time scraping 2026-04-19 01:10:51 +08:00
Lê Trung HiếuandLê Trung Hiếu 7093c982ba fix: remaining localization gaps (#156)
Co-authored-by: Lê Trung Hiếu <[email protected]>
2026-04-14 12:59:57 +08:00
Draculabo 440a934280 docs(repo): update project docs 2026-04-12 15:20:41 +08:00
Draculabo 87887dc4b9 chore(repo): remove tracked local database 2026-04-12 15:20:35 +08:00
Draculabo 00ff50055f chore(security): bump runtime/server dependencies 2026-04-12 15:20:30 +08:00
Draculabo d0c282dfff refactor(ipc): migrate renderer IPC client to official oRPC client 2026-04-12 15:20:22 +08:00
vin bc35b96b5d refactor: standardize runtime guard patterns with lodash-es (#154)
* refactor(ui): replace runtime type guards with lodash-es predicates

* refactor(runtime): migrate typeof checks to lodash-es in core modules

* refactor(ipc): standardize runtime guards with lodash-es

* test(refactor): align runtime guard checks with lodash-es

* docs(agents): add lodash-es runtime guard skill summary
2026-04-12 11:41:56 +08:00
53 changed files with 1566 additions and 1385 deletions

No files matched your search

+1 -1
View File
@@ -69,7 +69,7 @@ The project uses `orpc` for type-safe communication.
### Database Access
Data is stored in a local SQLite file (`test.db` in dev, user data in prod).
Data is stored in a local SQLite file.
- Use `Better-SQLite3` for direct access.
- Logic should be encapsulated in `src/services` or `src/ipc`.
@@ -0,0 +1,74 @@
# lodash-es-runtime-guards
Replace runtime `typeof` checks and ad-hoc empty-string checks with `lodash-es` predicates.
## When to Use
- You see runtime checks like `typeof value === 'string'`.
- You see object guards like `value && typeof value === 'object'`.
- You see emptiness checks like `value.trim() !== ''` or `value.trim() === ''`.
- You want consistent guard style across app, server, IPC, and tests.
## Do Not Replace
- Type-level `typeof` (TypeScript only), for example:
- `ReturnType<typeof fn>`
- `keyof typeof CONST_MAP`
- `z.infer<typeof Schema>`
These are compile-time types and must remain as-is.
## Guard Mapping
- `typeof x === 'string'` -> `isString(x)`
- `typeof x !== 'string'` -> `!isString(x)`
- `typeof x === 'number'` -> `isNumber(x)`
- `typeof x === 'boolean'` -> `isBoolean(x)`
- `typeof x === 'function'` -> `isFunction(x)`
- `typeof x === 'undefined'` -> `isUndefined(x)`
- `x && typeof x === 'object'` -> `isObjectLike(x)`
- `typeof x === 'object' && x !== null` -> `isObjectLike(x)`
- `typeof x === 'object' && !Array.isArray(x)` -> `isObjectLike(x) && !isArray(x)`
## String Emptiness Pattern
- Prefer:
- `isEmpty(value.trim())`
- Instead of:
- `value.trim() === ''`
- `value.trim() !== ''`
- `value.trim().length === 0`
## Import Style
- Use named imports from `lodash-es`:
```ts
import { isString, isNumber, isObjectLike, isEmpty } from 'lodash-es';
```
- Do not use full-package imports.
- Reuse existing imports in file; avoid duplicate imports.
## Refactor Checklist
1. Replace runtime checks first, preserving behavior.
2. Keep array/object branching semantics unchanged.
3. Re-scan:
```bash
rg -n "typeof\s+[^\n]+(?:===|!==)\s*'" src
```
4. Confirm only type-level `typeof` remains:
```bash
rg -n "\btypeof\b" src
```
5. Run verification:
```bash
npm run type-check
```
-130
View File
@@ -345,11 +345,6 @@ Documentation/usage lookup:
- Use `context7` for latest dependency usage.
VSCode MCP (if available):
- `mcp__vscode-mcp__get_references` for refactor impact analysis
- `mcp__vscode-mcp__rename_symbol` for safe renaming
- `mcp__vscode-mcp__get_symbol_lsp_info` for types/signatures/definitions
## 🚨 Local Quality Checks (Optional Flow)
@@ -371,128 +366,3 @@ Task(subagent_type: "run-related-tests", description: "Run tests", prompt: "[sam
Flow: initial check -> fix key issues -> re-check -> iterate until key issues are resolved.
Note: these tools are read-only analyzers; you still need to apply fixes manually. Pass precise file paths, not broad directories.
<skills_system priority="1">
## Available Skills
<!-- SKILLS_TABLE_START -->
<usage>
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
How to use skills:
- Invoke: Bash("openskills read <skill-name>")
- The skill content will load with detailed instructions on how to complete the task
- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
Usage notes:
- Only use skills listed in <available_skills> below
- Do not invoke a skill that is already loaded in your context
- Each skill invocation is stateless
</usage>
<available_skills>
<skill>
<name>algorithmic-art</name>
<description>Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.</description>
<location>project</location>
</skill>
<skill>
<name>brand-guidelines</name>
<description>Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.</description>
<location>project</location>
</skill>
<skill>
<name>canvas-design</name>
<description>Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.</description>
<location>project</location>
</skill>
<skill>
<name>doc-coauthoring</name>
<description>Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks.</description>
<location>project</location>
</skill>
<skill>
<name>docx</name>
<description>"Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. When Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"</description>
<location>project</location>
</skill>
<skill>
<name>frontend-design</name>
<description>Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.</description>
<location>project</location>
</skill>
<skill>
<name>internal-comms</name>
<description>A set of resources to help me write all kinds of internal communications, using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.).</description>
<location>project</location>
</skill>
<skill>
<name>mcp-builder</name>
<description>Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).</description>
<location>project</location>
</skill>
<skill>
<name>pdf</name>
<description>Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale.</description>
<location>project</location>
</skill>
<skill>
<name>pptx</name>
<description>"Presentation creation, editing, and analysis. When Claude needs to work with presentations (.pptx files) for: (1) Creating new presentations, (2) Modifying or editing content, (3) Working with layouts, (4) Adding comments or speaker notes, or any other presentation tasks"</description>
<location>project</location>
</skill>
<skill>
<name>skill-creator</name>
<description>Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.</description>
<location>project</location>
</skill>
<skill>
<name>slack-gif-creator</name>
<description>Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."</description>
<location>project</location>
</skill>
<skill>
<name>theme-factory</name>
<description>Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.</description>
<location>project</location>
</skill>
<skill>
<name>web-artifacts-builder</name>
<description>Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.</description>
<location>project</location>
</skill>
<skill>
<name>webapp-testing</name>
<description>Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.</description>
<location>project</location>
</skill>
<skill>
<name>xlsx</name>
<description>"Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When Claude needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas"</description>
<location>project</location>
</skill>
</available_skills>
<!-- SKILLS_TABLE_END -->
</skills_system>
+17
View File
@@ -2,6 +2,23 @@
# Changelog
## [0.11.1](https://github.com/Draculabo/AntigravityManager/compare/v0.11.0...v0.11.1) (2026-04-18)
### 🐛 Bug Fixes
* add unit label to AI credit display ([6577f1c](https://github.com/Draculabo/AntigravityManager/commit/6577f1c51fd155004b81f6171eb5feffa2448128))
* remaining localization gaps ([#156](https://github.com/Draculabo/AntigravityManager/issues/156)) ([7093c98](https://github.com/Draculabo/AntigravityManager/commit/7093c982ba790380cda7d1e8a53891970df6b3cf))
* remove AI credit refresh time scraping ([3e1e10b](https://github.com/Draculabo/AntigravityManager/commit/3e1e10b2a580ebf5a64b9a8982bd812a6a1f2924))
### 📝 Documentation
* **repo:** update project docs ([440a934](https://github.com/Draculabo/AntigravityManager/commit/440a9342800f244287c758b1a77a05fac9096df7))
### ♻️ Code Refactoring
* **ipc:** migrate renderer IPC client to official oRPC client ([d0c282d](https://github.com/Draculabo/AntigravityManager/commit/d0c282dffff24bb74a81cfa523f2f72f949bf8f9))
* standardize runtime guard patterns with lodash-es ([#154](https://github.com/Draculabo/AntigravityManager/issues/154)) ([bc35b96](https://github.com/Draculabo/AntigravityManager/commit/bc35b96b5d2d873cf0f187a57de695aedc32108f))
## [0.11.0](https://github.com/Draculabo/AntigravityManager/compare/v0.10.0...v0.11.0) (2026-04-11)
### ✨ Features
+308 -671
View File
File diff suppressed because it is too large. Load diff
+27 -27
View File
@@ -1,7 +1,7 @@
{
"name": "antigravity-manager",
"productName": "Antigravity Manager",
"version": "0.11.0",
"version": "0.11.1",
"description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js",
"private": true,
@@ -23,18 +23,18 @@
"author": "Draculabo",
"license": "CC-BY-NC-SA-4.0",
"devDependencies": {
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
"@electron-forge/maker-dmg": "^7.10.2",
"@electron-forge/maker-rpm": "^7.10.2",
"@electron-forge/maker-squirrel": "^7.10.2",
"@electron-forge/maker-wix": "^7.10.2",
"@electron-forge/maker-zip": "^7.10.2",
"@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
"@electron-forge/plugin-fuses": "^7.10.2",
"@electron-forge/plugin-vite": "^7.10.2",
"@electron-forge/publisher-github": "^7.10.2",
"@electron-forge/shared-types": "^7.8.1",
"@electron-forge/cli": "^7.11.1",
"@electron-forge/maker-deb": "^7.11.1",
"@electron-forge/maker-dmg": "^7.11.1",
"@electron-forge/maker-rpm": "^7.11.1",
"@electron-forge/maker-squirrel": "^7.11.1",
"@electron-forge/maker-wix": "^7.11.1",
"@electron-forge/maker-zip": "^7.11.1",
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
"@electron-forge/plugin-fuses": "^7.11.1",
"@electron-forge/plugin-vite": "^7.11.1",
"@electron-forge/publisher-github": "^7.11.1",
"@electron-forge/shared-types": "^7.11.1",
"@electron/fuses": "~1.8.0",
"@electron/rebuild": "^3.7.1",
"@eslint/compat": "^1.4.1",
@@ -43,7 +43,7 @@
"@playwright/test": "^1.56.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.2",
"@semantic-release/github": "^12.0.6",
"@sentry/vite-plugin": "^4.8.0",
"@swc/core": "^1.15.8",
"@tailwindcss/vite": "^4.1.17",
@@ -73,12 +73,13 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^16.5.0",
"happy-dom": "^20.8.9",
"icon-gen": "^5.0.0",
"jsdom": "^27.1.0",
"node-addon-api": "^8.3.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"semantic-release": "^25.0.2",
"semantic-release": "^25.0.3",
"semantic-release-config-gitmoji": "^1.5.3",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.16",
@@ -93,13 +94,13 @@
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@icons-pack/react-simple-icons": "^13.8.0",
"@nestjs/common": "^11.1.10",
"@nestjs/core": "^11.1.10",
"@nestjs/microservices": "^11.1.11",
"@nestjs/platform-express": "^11.1.11",
"@nestjs/platform-fastify": "^11.1.10",
"@orpc/client": "^1.10.4",
"@orpc/server": "^1.11.3",
"@nestjs/common": "^11.1.18",
"@nestjs/core": "^11.1.18",
"@nestjs/microservices": "^11.1.18",
"@nestjs/platform-express": "^11.1.18",
"@nestjs/platform-fastify": "^11.1.18",
"@orpc/client": "^1.13.14",
"@orpc/server": "^1.13.14",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -121,22 +122,21 @@
"@tanstack/react-router": "^1.139.3",
"@tanstack/react-router-devtools": "^1.135.0",
"@types/uuid": "^11.0.0",
"axios": "^1.13.2",
"axios": "^1.15.0",
"better-sqlite3": "^12.4.6",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.39.1",
"drizzle-orm": "^0.45.2",
"electron-squirrel-startup": "^1.0.1",
"fastify": "^5.6.2",
"fastify": "^5.8.4",
"find-process": "^2.0.0",
"happy-dom": "^20.0.10",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"keytar": "^7.9.0",
"lodash-es": "^4.17.22",
"lodash-es": "^4.18.1",
"lucide-react": "^0.553.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+3 -2
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
import { formatDistanceToNow, type Locale } from 'date-fns';
import { enUS, zhCN, ru } from 'date-fns/locale';
import { enUS, zhCN, ru, vi } from 'date-fns/locale';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
@@ -22,6 +22,7 @@ const DATE_LOCALE_MAP: Record<string, Locale> = {
en: enUS,
'zh-CN': zhCN,
ru: ru,
vi: vi,
};
interface AccountCardProps {
@@ -162,7 +163,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open menu</span>
<span className="sr-only">{t('common.openMenu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
+31 -27
View File
@@ -29,6 +29,7 @@ import { useProviderGrouping } from '@/hooks/useProviderGrouping';
import { ProviderGroup } from '@/components/ProviderGroup';
import {
clampQuotaPercentage,
formatAiCreditsAmount,
formatResetTimeLabel,
formatResetTimeTitle,
getQuotaStatus,
@@ -82,6 +83,19 @@ function isGeminiProHighModel(modelName: string): boolean {
return normalizedModelName.includes('gemini-3.1-pro-high');
}
function formatCreditsExpiry(expiryDate: string): string {
if (!expiryDate) {
return '';
}
try {
const date = new Date(expiryDate);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch {
return expiryDate;
}
}
function isGeminiProFamilyModel(modelName: string): boolean {
const normalizedModelName = modelName.toLowerCase();
return normalizedModelName.includes('gemini-3.1-pro');
@@ -325,17 +339,8 @@ export function CloudAccountCard({
);
const aiCredits = account.quota?.ai_credits;
const shouldShowAiCredits = !!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
const formatCreditsExpiry = (expiryDate: string) => {
if (!expiryDate) return '';
try {
const date = new Date(expiryDate);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch {
return expiryDate;
}
};
const shouldShowAiCredits =
!!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
account.status,
@@ -378,8 +383,12 @@ export function CloudAccountCard({
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
{shouldShowAiCredits && aiCredits && (
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-amber-500">
<span>${aiCredits.credits.toFixed(2)}</span>
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-blue-500">
<span>
{t('cloud.card.aiCreditsValue', {
amount: formatAiCreditsAmount(aiCredits.credits),
})}
</span>
{aiCredits.expiryDate && (
<span className="text-muted-foreground opacity-70">
·{' '}
@@ -635,17 +644,8 @@ export function CompactCloudAccountCard({
);
const aiCredits = account.quota?.ai_credits;
const shouldShowAiCredits = !!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
const formatCreditsExpiry = (expiryDate: string) => {
if (!expiryDate) return '';
try {
const date = new Date(expiryDate);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch {
return expiryDate;
}
};
const shouldShowAiCredits =
!!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
account.status,
@@ -695,12 +695,16 @@ export function CompactCloudAccountCard({
<div className="text-muted-foreground flex items-center gap-3 text-xs">
<span className="truncate">{account.email}</span>
{validationBlockedStatusLabel && (
<span className="text-destructive shrink-0 font-medium">{validationBlockedStatusLabel}</span>
<span className="text-destructive shrink-0 font-medium">
{validationBlockedStatusLabel}
</span>
)}
{shouldShowAiCredits && aiCredits && (
<span className="shrink-0 text-amber-500">
${aiCredits.credits.toFixed(2)}
<span className="shrink-0 text-blue-500">
{t('cloud.card.aiCreditsValue', {
amount: formatAiCreditsAmount(aiCredits.credits),
})}
{aiCredits.expiryDate && (
<span className="text-muted-foreground">
{' '}
+23 -14
View File
@@ -71,9 +71,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es';
import { filter, flatMap, isEmpty, isNumber, size, sumBy } from 'lodash-es';
import {
clampQuotaPercentage,
formatAiCreditsAmount,
getQuotaStatus,
roundQuotaPercentage,
getAccountSortValue,
@@ -313,17 +314,19 @@ export function CloudAccountList() {
{
onSuccess: (updatedAccount) => {
const credits = updatedAccount.quota?.ai_credits?.credits;
if (typeof credits === 'number') {
if (isNumber(credits)) {
toast({
title: t('cloud.toast.quotaRefreshed'),
description: `AI credits: $${credits.toFixed(2)}`,
description: t('cloud.toast.refreshCreditsAvailable', {
amount: formatAiCreditsAmount(credits),
}),
});
return;
}
toast({
title: t('cloud.toast.quotaRefreshed'),
description: 'AI credits not available for this refresh.',
description: t('cloud.toast.refreshCreditsUnavailable'),
});
},
onError: () => toast({ title: t('cloud.toast.refreshFailed'), variant: 'destructive' }),
@@ -483,7 +486,7 @@ export function CloudAccountList() {
if (file.size > 5 * 1024 * 1024) {
toast({
title: t('cloud.error.loadFailed'),
description: 'File size exceeds 5MB limit',
description: t('cloud.exportImport.fileTooLarge'),
variant: 'destructive',
});
return;
@@ -496,10 +499,10 @@ export function CloudAccountList() {
const content = event.target?.result as string;
JSON.parse(content);
setImportFileContent(content);
} catch (err) {
} catch {
toast({
title: t('cloud.error.loadFailed'),
description: 'Invalid JSON file format',
description: t('cloud.exportImport.invalidJson'),
variant: 'destructive',
});
setImportFileName('');
@@ -509,7 +512,7 @@ export function CloudAccountList() {
reader.onerror = () => {
toast({
title: t('cloud.error.loadFailed'),
description: 'Failed to read file',
description: t('cloud.exportImport.readFileFailed'),
variant: 'destructive',
});
};
@@ -588,12 +591,15 @@ export function CloudAccountList() {
if (failed === 0) {
toast({
title: t('cloud.toast.quotaRefreshed'),
description: `Successfully refreshed ${successful} accounts.`,
description: t('cloud.toast.batchRefreshSuccess', { count: successful }),
});
} else {
toast({
title: `Refresh completed with issues`,
description: `Refreshed ${successful} accounts, ${failed} failed.`,
title: t('cloud.toast.batchRefreshPartial.title'),
description: t('cloud.toast.batchRefreshPartial.description', {
successful,
failed,
}),
variant: 'destructive',
});
}
@@ -614,12 +620,15 @@ export function CloudAccountList() {
if (failed === 0) {
toast({
title: t('cloud.toast.deleted'),
description: `Successfully deleted ${successful} accounts.`,
description: t('cloud.toast.batchDeleteSuccess', { count: successful }),
});
} else {
toast({
title: `Deletion completed with issues`,
description: `Deleted ${successful} accounts, ${failed} failed.`,
title: t('cloud.toast.batchDeletePartial.title'),
description: t('cloud.toast.batchDeletePartial.description', {
successful,
failed,
}),
variant: 'destructive',
});
}
+45 -41
View File
@@ -1,4 +1,4 @@
import { type JSX, useEffect, useMemo, useRef, useState } from 'react';
import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Fingerprint,
@@ -48,11 +48,14 @@ function formatError(error: unknown): string {
return String(error);
}
function renderProfile(profile?: DeviceProfile): JSX.Element {
function renderProfile(
profile: DeviceProfile | undefined,
t: ReturnType<typeof useTranslation>['t'],
): JSX.Element {
if (!profile) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
N/A
{t('common.notAvailable')}
</div>
);
}
@@ -95,41 +98,44 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
const history = useMemo(() => sortHistory(snapshot?.history || []), [snapshot?.history]);
const showLoadingPlaceholder = initialLoading && !snapshot;
const refreshProfiles = async (options?: { silent?: boolean }) => {
if (!account) {
setSnapshot(null);
return;
}
const silent = options?.silent === true;
if (silent) {
setRefreshing(true);
} else {
setInitialLoading(true);
}
try {
const result = await getCloudIdentityProfiles({ accountId: account.id });
setSnapshot(result);
} catch (error) {
toast({
title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }),
description: formatError(error),
variant: 'destructive',
});
} finally {
if (silent) {
setRefreshing(false);
} else {
setInitialLoading(false);
const refreshProfiles = useCallback(
async (options?: { silent?: boolean }) => {
if (!account) {
setSnapshot(null);
return;
}
}
};
const silent = options?.silent === true;
if (silent) {
setRefreshing(true);
} else {
setInitialLoading(true);
}
try {
const result = await getCloudIdentityProfiles({ accountId: account.id });
setSnapshot(result);
} catch (error) {
toast({
title: t('cloud.toast.actionFailed'),
description: formatError(error),
variant: 'destructive',
});
} finally {
if (silent) {
setRefreshing(false);
} else {
setInitialLoading(false);
}
}
},
[account, t, toast],
);
useEffect(() => {
if (!open) {
return;
}
refreshProfiles();
}, [open, account?.id]);
void refreshProfiles();
}, [open, refreshProfiles]);
const runAction = async (key: string, action: () => Promise<void>) => {
if (actionLockRef.current) {
@@ -142,7 +148,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
await refreshProfiles({ silent: true });
} catch (error) {
toast({
title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }),
title: t('cloud.toast.actionFailed'),
description: formatError(error),
variant: 'destructive',
});
@@ -247,7 +253,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
<CardTitle className="text-sm">{t('cloud.identity.previewTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{renderProfile(previewProfile)}
{renderProfile(previewProfile, t)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
@@ -295,7 +301,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
{t('cloud.identity.loading')}
</div>
) : (
renderProfile(snapshot?.currentStorage)
renderProfile(snapshot?.currentStorage, t)
)}
</CardContent>
</Card>
@@ -313,7 +319,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
{t('cloud.identity.loading')}
</div>
) : (
renderProfile(snapshot?.boundProfile)
renderProfile(snapshot?.boundProfile, t)
)}
</CardContent>
</Card>
@@ -353,7 +359,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
) : null}
</div>
{renderProfile(version.profile)}
{renderProfile(version.profile, t)}
<div className="mt-3 flex flex-wrap gap-2">
<Button
@@ -402,15 +408,13 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
<Card className="shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-sm">
{t('cloud.identity.baseline', { defaultValue: 'Reference Profile' })}
</CardTitle>
<CardTitle className="text-sm">{t('cloud.identity.baseline')}</CardTitle>
</CardHeader>
<CardContent>
{showLoadingPlaceholder ? (
<div className="text-muted-foreground text-xs">{t('cloud.identity.loading')}</div>
) : (
renderProfile(snapshot?.baseline)
renderProfile(snapshot?.baseline, t)
)}
</CardContent>
</Card>
+3 -1
View File
@@ -130,7 +130,9 @@ export function ModelVisibilitySettings() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>{t('settings.modelVisibility.title')}</span>
<Badge variant="secondary">{filteredModelIds.length} models</Badge>
<Badge variant="secondary">
{t('settings.providerGroupings.models', { count: filteredModelIds.length })}
</Badge>
</CardTitle>
<CardDescription>{t('settings.modelVisibility.description')}</CardDescription>
</CardHeader>
+3 -2
View File
@@ -1,5 +1,6 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { isNumber, isObjectLike } from 'lodash-es';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -52,11 +53,11 @@ const DropdownMenuContent = React.forwardRef<
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
}
>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => {
if (typeof collisionPadding === 'number') {
if (isNumber(collisionPadding)) {
if (collisionPadding < 0) {
throw new Error('collisionPadding must be a non-negative number');
}
} else if (typeof collisionPadding === 'object' && collisionPadding !== null) {
} else if (isObjectLike(collisionPadding)) {
for (const key of Object.keys(collisionPadding)) {
if ((collisionPadding as any)[key] < 0) {
throw new Error(`collisionPadding.${key} must be a non-negative number`);
+6 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from '@/components/ui/use-toast';
import { ipc } from '@/ipc/manager';
@@ -9,6 +10,7 @@ import { AppConfig } from '@/types/config';
const SAVE_DEBOUNCE_MS = 400;
export function useAppConfig() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const {
@@ -57,8 +59,8 @@ export function useAppConfig() {
queryClient.setQueryData(['appConfig'], savedConfig);
lastStableConfigRef.current = savedConfig;
toast({
title: 'Settings saved',
description: 'Your configuration has been updated.',
title: t('settings.toast.saved.title'),
description: t('settings.toast.saved.description'),
});
for (const item of pendingBatch) {
item.resolve();
@@ -72,7 +74,7 @@ export function useAppConfig() {
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
}
toast({
title: 'Error saving settings',
title: t('settings.toast.saveFailed.title'),
description: error.message,
variant: 'destructive',
});
@@ -80,7 +82,7 @@ export function useAppConfig() {
item.reject(error);
}
}
}, [queryClient, updateConfig]);
}, [queryClient, t, updateConfig]);
const saveConfig = useCallback(
(newConfig: AppConfig) => {
+26 -10
View File
@@ -6,6 +6,7 @@ import { logger } from '../../utils/logger';
import { shell } from 'electron';
import fs from 'fs';
import { isEmpty, isString } from 'lodash-es';
import { updateTrayMenu } from '../../ipc/tray/handler';
import {
ensureGlobalOriginalFromCurrentStorage,
@@ -46,14 +47,17 @@ function isEnterpriseClient(clientKey?: string): boolean {
}
function normalizeProjectId(projectId?: string): string | null {
if (typeof projectId !== 'string') {
if (!isString(projectId)) {
return null;
}
const normalized = projectId.trim();
return normalized === '' ? null : normalized;
}
function recoverCachedQuotaOnRateLimit(account: CloudAccount, error: unknown): CloudAccount['quota'] | null {
function recoverCachedQuotaOnRateLimit(
account: CloudAccount,
error: unknown,
): CloudAccount['quota'] | null {
const classified = classifyAccountStatusFromError(error);
if (!classified || classified.status !== 'rate_limited') {
return null;
@@ -147,8 +151,11 @@ async function clearAccountStatus(account: CloudAccount): Promise<void> {
}
function hydrateActiveOAuthClientFromSettings(): void {
const preferredClientKey = CloudAccountRepo.getSetting<string>(ACTIVE_OAUTH_CLIENT_KEY_SETTING, '');
if (preferredClientKey && preferredClientKey.trim() !== '') {
const preferredClientKey = CloudAccountRepo.getSetting<string>(
ACTIVE_OAUTH_CLIENT_KEY_SETTING,
'',
);
if (isString(preferredClientKey) && !isEmpty(preferredClientKey.trim())) {
try {
GoogleAPIService.setActiveOAuthClientKey(preferredClientKey);
} catch (error) {
@@ -164,7 +171,10 @@ function hydrateActiveOAuthClientFromSettings(): void {
async function backfillMissingOAuthClientKeyForLegacyAccounts(
accounts: CloudAccount[],
): Promise<boolean> {
const backfillDone = CloudAccountRepo.getSetting<boolean>(OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING, false);
const backfillDone = CloudAccountRepo.getSetting<boolean>(
OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING,
false,
);
if (backfillDone) {
return false;
}
@@ -195,7 +205,7 @@ async function backfillMissingOAuthClientKeyForLegacyAccounts(
}
const projectMissing =
typeof account.token.project_id !== 'string' || account.token.project_id.trim() === '';
!isString(account.token.project_id) || isEmpty(account.token.project_id.trim());
if (activeClientKey === ENTERPRISE_OAUTH_CLIENT_KEY && projectMissing) {
skippedEnterpriseGuardCount += 1;
continue;
@@ -232,7 +242,7 @@ export async function addGoogleAccount(
oauthClientKey?: string,
): Promise<CloudAccount> {
try {
if (oauthClientKey && oauthClientKey.trim() !== '') {
if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) {
setActiveOAuthClient(oauthClientKey);
} else {
hydrateActiveOAuthClientFromSettings();
@@ -281,7 +291,10 @@ export async function addGoogleAccount(
try {
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
try {
const aiCredits = await GoogleAPIService.fetchAICredits(account.token.access_token);
const aiCredits = await GoogleAPIService.fetchAICredits(
account.token.access_token,
undefined,
);
if (aiCredits) {
quota.ai_credits = aiCredits;
} else {
@@ -684,7 +697,7 @@ export async function forcePollCloudMonitor(): Promise<void> {
}
export async function startAuthFlow(oauthClientKey?: string): Promise<void> {
if (oauthClientKey && oauthClientKey.trim() !== '') {
if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) {
setActiveOAuthClient(oauthClientKey);
} else {
hydrateActiveOAuthClientFromSettings();
@@ -707,7 +720,10 @@ export function getActiveOAuthClient(): string {
export function setActiveOAuthClient(clientKey: string): void {
GoogleAPIService.setActiveOAuthClientKey(clientKey);
CloudAccountRepo.setSetting(ACTIVE_OAUTH_CLIENT_KEY_SETTING, GoogleAPIService.getActiveOAuthClientKey());
CloudAccountRepo.setSetting(
ACTIVE_OAUTH_CLIENT_KEY_SETTING,
GoogleAPIService.getActiveOAuthClientKey(),
);
}
export async function exportCloudAccounts(stripTokens = false): Promise<string> {
+33 -31
View File
@@ -4,6 +4,7 @@ import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { desc, eq } from 'drizzle-orm';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { isNumber, isObjectLike, isPlainObject, isString } from 'lodash-es';
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
import { logger } from '../../utils/logger';
import {
@@ -35,14 +36,14 @@ type DrizzleExecutor = Pick<
>;
function isSqliteBusyError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
if (!isObjectLike(error)) {
return false;
}
const err = error as { code?: string; message?: string };
if (err.code && SQLITE_BUSY_CODES.has(err.code)) {
return true;
}
if (typeof err.message === 'string') {
if (isString(err.message)) {
return err.message.includes('SQLITE_BUSY') || err.message.includes('SQLITE_LOCKED');
}
return false;
@@ -168,17 +169,13 @@ interface MigrationStats {
failedFields: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function readStringCandidate(
source: Record<string, unknown>,
...keys: string[]
): string | undefined {
for (const key of keys) {
const candidate = source[key];
if (typeof candidate === 'string' && candidate.length > 0) {
if (isString(candidate) && candidate.length > 0) {
return candidate;
}
}
@@ -186,14 +183,15 @@ function readStringCandidate(
}
function normalizeDeviceProfile(value: unknown): DeviceProfile | undefined {
if (!isRecord(value)) {
if (!isPlainObject(value)) {
return undefined;
}
const valueRecord = value as Record<string, unknown>;
const machineId = readStringCandidate(value, 'machineId', 'machine_id');
const macMachineId = readStringCandidate(value, 'macMachineId', 'mac_machine_id');
const devDeviceId = readStringCandidate(value, 'devDeviceId', 'dev_device_id');
const sqmId = readStringCandidate(value, 'sqmId', 'sqm_id');
const machineId = readStringCandidate(valueRecord, 'machineId', 'machine_id');
const macMachineId = readStringCandidate(valueRecord, 'macMachineId', 'mac_machine_id');
const devDeviceId = readStringCandidate(valueRecord, 'devDeviceId', 'dev_device_id');
const sqmId = readStringCandidate(valueRecord, 'sqmId', 'sqm_id');
if (!machineId || !macMachineId || !devDeviceId || !sqmId) {
return undefined;
@@ -217,45 +215,47 @@ function areDeviceProfilesEqual(left: DeviceProfile, right: DeviceProfile): bool
}
function readVersionedProfilePayload(value: unknown): unknown {
if (!isRecord(value)) {
if (!isPlainObject(value)) {
return value;
}
if (!('schemaVersion' in value)) {
const valueRecord = value as Record<string, unknown>;
if (!('schemaVersion' in valueRecord)) {
return value;
}
const schemaVersion = value.schemaVersion;
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
const schemaVersion = valueRecord.schemaVersion;
if (!isNumber(schemaVersion) || !Number.isFinite(schemaVersion)) {
throw new Error('invalid_device_profile_schema_version');
}
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
throw new Error(`unsupported_device_profile_schema_version:${schemaVersion}`);
}
if (!('profile' in value)) {
if (!('profile' in valueRecord)) {
throw new Error('invalid_device_profile_payload');
}
return value.profile;
return valueRecord.profile;
}
function readVersionedHistoryPayload(value: unknown): unknown {
if (!isRecord(value)) {
if (!isPlainObject(value)) {
return value;
}
if (!('schemaVersion' in value)) {
const valueRecord = value as Record<string, unknown>;
if (!('schemaVersion' in valueRecord)) {
return value;
}
const schemaVersion = value.schemaVersion;
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
const schemaVersion = valueRecord.schemaVersion;
if (!isNumber(schemaVersion) || !Number.isFinite(schemaVersion)) {
throw new Error('invalid_device_history_schema_version');
}
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
throw new Error(`unsupported_device_history_schema_version:${schemaVersion}`);
}
if (!('history' in value)) {
if (!('history' in valueRecord)) {
throw new Error('invalid_device_history_payload');
}
return value.history;
return valueRecord.history;
}
function serializeDeviceProfile(profile: DeviceProfile | undefined): string | null {
@@ -285,23 +285,25 @@ function normalizeDeviceHistory(value: unknown): DeviceProfileVersion[] | undefi
const normalized: DeviceProfileVersion[] = [];
for (const item of value) {
if (!isRecord(item)) {
if (!isPlainObject(item)) {
continue;
}
const itemRecord = item as Record<string, unknown>;
const profile = normalizeDeviceProfile(item.profile);
const profile = normalizeDeviceProfile(itemRecord.profile);
if (!profile) {
continue;
}
const id = typeof item.id === 'string' && item.id.length > 0 ? item.id : uuidv4();
const createdAtCandidate = item.createdAt;
const id = isString(itemRecord.id) && itemRecord.id.length > 0 ? itemRecord.id : uuidv4();
const createdAtCandidate = itemRecord.createdAt;
const createdAt =
typeof createdAtCandidate === 'number' && Number.isFinite(createdAtCandidate)
isNumber(createdAtCandidate) && Number.isFinite(createdAtCandidate)
? Math.floor(createdAtCandidate)
: Math.floor(Date.now() / 1000);
const label = typeof item.label === 'string' && item.label.length > 0 ? item.label : 'legacy';
const isCurrent = item.isCurrent === true;
const label =
isString(itemRecord.label) && itemRecord.label.length > 0 ? itemRecord.label : 'legacy';
const isCurrent = itemRecord.isCurrent === true;
normalized.push({
id,
+4 -3
View File
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { eq } from 'drizzle-orm';
import { isString } from 'lodash-es';
import { AccountBackupData, AccountInfo } from '../../types/account';
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
import { logger } from '../../utils/logger';
@@ -192,8 +193,8 @@ export function getCurrentAccountInfo(): AccountInfo {
// Helper to find email in object
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
if (!obj) return '';
if (typeof obj.email === 'string') return obj.email;
if (obj.user && typeof obj.user.email === 'string') return obj.user.email;
if (isString(obj.email)) return obj.email;
if (obj.user && isString(obj.user.email)) return obj.user.email;
return '';
};
@@ -323,7 +324,7 @@ function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
for (const key of KEYS_TO_BACKUP) {
if (key in backup.data) {
const value = backup.data[key];
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
const stringValue = isString(value) ? value : JSON.stringify(value);
tx.insert(itemTable)
.values({ key, value: stringValue })
.onConflictDoUpdate({
+11 -10
View File
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { createHash, randomBytes } from 'crypto';
import { isBoolean, isNumber, isObjectLike, isString } from 'lodash-es';
import { type DeviceProfile } from '../../types/account';
import { logger } from '../../utils/logger';
import { getAgentDir, getAntigravityDbPaths, getAntigravityStoragePaths } from '../../utils/paths';
@@ -76,14 +77,14 @@ const deviceHardeningState: DeviceHardeningState = {
};
function isSqliteBusyError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
if (!isObjectLike(error)) {
return false;
}
const candidate = error as { code?: string; message?: string };
if (candidate.code === 'SQLITE_BUSY' || candidate.code === 'SQLITE_LOCKED') {
return true;
}
if (typeof candidate.message === 'string') {
if (isString(candidate.message)) {
return candidate.message.includes('SQLITE_BUSY') || candidate.message.includes('SQLITE_LOCKED');
}
return false;
@@ -139,8 +140,8 @@ function readLastKnownGoodMarker(): LastKnownGoodMarker | null {
if (
parsed &&
parsed.version === 1 &&
typeof parsed.savedAt === 'number' &&
typeof parsed.hasStateDb === 'boolean'
isNumber(parsed.savedAt) &&
isBoolean(parsed.hasStateDb)
) {
return {
version: 1,
@@ -157,7 +158,7 @@ function readLastKnownGoodMarker(): LastKnownGoodMarker | null {
function parseStorageJson(storagePath: string): Record<string, unknown> {
const content = fs.readFileSync(storagePath, 'utf-8');
const parsed = JSON.parse(content);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
if (!isObjectLike(parsed) || Array.isArray(parsed)) {
throw new Error('storage.json top-level value must be an object');
}
return parsed as Record<string, unknown>;
@@ -165,15 +166,15 @@ function parseStorageJson(storagePath: string): Record<string, unknown> {
function getTelemetryField(storage: Record<string, unknown>, key: string): string | null {
const telemetry = storage.telemetry;
if (telemetry && typeof telemetry === 'object' && !Array.isArray(telemetry)) {
if (isObjectLike(telemetry) && !Array.isArray(telemetry)) {
const nestedValue = (telemetry as Record<string, unknown>)[key];
if (typeof nestedValue === 'string' && nestedValue.length > 0) {
if (isString(nestedValue) && nestedValue.length > 0) {
return nestedValue;
}
}
const flatValue = storage[`telemetry.${key}`];
if (typeof flatValue === 'string' && flatValue.length > 0) {
if (isString(flatValue) && flatValue.length > 0) {
return flatValue;
}
@@ -183,7 +184,7 @@ 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' ||
!isObjectLike(storage.telemetry) ||
Array.isArray(storage.telemetry)
) {
storage.telemetry = {};
@@ -223,7 +224,7 @@ function readStateServiceMachineIdValue(dbPath: string): string | null {
const row = db
.prepare("SELECT value FROM ItemTable WHERE key = 'storage.serviceMachineId' LIMIT 1")
.get() as { value?: unknown } | undefined;
if (!row || typeof row.value !== 'string' || row.value.length === 0) {
if (!row || !isString(row.value) || row.value.length === 0) {
return null;
}
return row.value;
+11 -105
View File
@@ -1,114 +1,16 @@
import { IPC_CHANNELS } from '@/constants';
import { createORPCClient } from '@orpc/client';
import { RPCLink } from '@orpc/client/message-port';
import type { RouterClient } from '@orpc/server';
import type { router } from './router';
// Custom IPC Client that correctly builds method paths
function createIPCClient(port: MessagePort) {
let requestId = 0;
const pendingRequests = new Map<
string,
{ resolve: (v: any) => void; reject: (e: any) => void }
>();
port.onmessage = (event) => {
try {
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
const id = data.i || data.id;
const pending = pendingRequests.get(id);
if (pending) {
pendingRequests.delete(id);
if (data.e || data.error) {
const errorMsg = data.e?.m || data.error?.message || JSON.stringify(data.e || data.error);
console.error('[IPCClient] Error response:', errorMsg);
pending.reject(new Error(errorMsg));
} else {
// ORPC response format: { i, p: { b: { json: value }, s?: statusCode } }
// Or error: { i, p: { s: 500, b: { json: { code, message } } } }
let result;
const payload = data.p;
// Check for error status
if (payload?.s && payload.s >= 400) {
const errorData = payload.b?.json;
const errorMsg = errorData?.message || errorData?.code || 'Server error';
console.error('[IPCClient] Server error:', errorData);
pending.reject(new Error(errorMsg));
return;
}
// Extract successful result
if (payload?.b?.json !== undefined) {
result = payload.b.json;
} else if (data.r !== undefined) {
// Fallback to 'r' field if present
result = data.r?.b?.json ?? data.r;
} else if ('result' in data) {
result = data.result;
}
pending.resolve(result);
}
} else {
console.warn('[IPCClient] No pending request for id:', id);
}
} catch (e) {
console.error('[IPCClient] Error parsing response:', e, event.data);
}
};
async function callMethod(path: string[], input?: unknown): Promise<any> {
const id = String(++requestId);
const methodPath = path.join('/');
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
// ORPC MessagePort protocol format
const payload = {
i: id,
p: {
u: `orpc://localhost/${methodPath}`,
b: { json: input ?? null },
},
};
port.postMessage(JSON.stringify(payload));
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error(`Request /${methodPath} timed out`));
}
}, 60000);
});
}
// Create a Proxy that builds the path chain
function createProxyChain(pathSoFar: string[] = []): any {
return new Proxy(() => {}, {
get(_target, prop: string) {
if (prop === 'then' || prop === 'catch' || prop === 'finally') {
// Don't intercept promise methods
return undefined;
}
// Build deeper path
return createProxyChain([...pathSoFar, prop]);
},
apply(_target, _thisArg, args) {
// Called as function - execute RPC
const input = args[0];
return callMethod(pathSoFar, input);
},
});
}
return createProxyChain();
}
type IPCClient = RouterClient<typeof router>;
class IPCManager {
private readonly clientPort: MessagePort;
private readonly serverPort: MessagePort;
public readonly client: any; // Will be typed via usage
public readonly client: IPCClient;
private initialized: boolean = false;
@@ -117,7 +19,11 @@ class IPCManager {
this.clientPort = port1;
this.serverPort = port2;
this.client = createIPCClient(this.clientPort);
this.client = createORPCClient<IPCClient>(
new RPCLink({
port: this.clientPort,
}),
);
}
public initialize() {
+2 -1
View File
@@ -1,6 +1,7 @@
import { exec, execSync, spawn } from 'child_process';
import { promisify } from 'util';
import findProcess, { ProcessInfo } from 'find-process';
import { isNumber } from 'lodash-es';
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
import { logger } from '../../utils/logger';
@@ -98,7 +99,7 @@ export async function isProcessRunning(): Promise<boolean> {
const processMap = new Map<number, ProcessInfo>();
for (const proc of allMatches) {
if (typeof proc.pid === 'number') {
if (isNumber(proc.pid)) {
processMap.set(proc.pid, proc);
}
}
+33 -3
View File
@@ -25,18 +25,48 @@ const en: TrayTexts = {
const zh: TrayTexts = {
current: '当前账号',
quota: '当前额度',
switch_next: '切换下一个账号',
switch_next: '切换到下一个账号',
refresh_current: '刷新当前额度',
show_window: '显示主窗口',
quit: '退出程序',
quit: '退出应用',
no_account: '无账号',
unknown_quota: '未知',
forbidden: '账号无权限',
forbidden: '账号已被禁用',
};
const ru: TrayTexts = {
current: 'Текущий',
quota: 'Квота',
switch_next: 'Переключить на следующий аккаунт',
refresh_current: 'Обновить текущую квоту',
show_window: 'Показать главное окно',
quit: 'Выйти из приложения',
no_account: 'Нет аккаунта',
unknown_quota: 'Неизвестно',
forbidden: 'Аккаунт заблокирован',
};
const vi: TrayTexts = {
current: 'Hiện tại',
quota: 'Quota',
switch_next: 'Chuyển sang tài khoản tiếp theo',
refresh_current: 'Làm mới quota hiện tại',
show_window: 'Hiện cửa sổ chính',
quit: 'Thoát ứng dụng',
no_account: 'Không có tài khoản',
unknown_quota: 'Không rõ',
forbidden: 'Tài khoản bị cấm',
};
export function getTrayTexts(lang: string = 'en'): TrayTexts {
if (lang.startsWith('zh')) {
return zh;
}
if (lang.startsWith('ru')) {
return ru;
}
if (lang.startsWith('vi')) {
return vi;
}
return en;
}
+1 -1
View File
@@ -44,7 +44,7 @@ export const MainLayout: React.FC = () => {
{
to: '/proxy',
icon: Network,
label: t('nav.proxy', 'API Proxy'),
label: t('nav.proxy'),
},
{
to: '/settings',
+3 -3
View File
@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from 'uuid';
import { isPlainObject, isString } from 'lodash-es';
import { isEmpty, isPlainObject, isString } from 'lodash-es';
import { mapClaudeModelToGemini, normalizeGeminiModelAlias } from './ModelMapping';
import { getMaxOutputTokens, getThinkingBudget } from './ModelSpecs';
import { cleanJsonSchema, normalizeObjectJsonSchema } from './JsonSchemaUtils';
@@ -518,7 +518,7 @@ function buildContents(
for (const block of contentBlocks) {
if (block.type === 'text') {
if (block.text && block.text !== '(no content)' && block.text.trim() !== '')
if (block.text && block.text !== '(no content)' && !isEmpty(block.text.trim()))
parts.push({ text: block.text.trim() });
} else if (block.type === 'thinking') {
const part: any = { text: block.thinking, thought: true };
@@ -553,7 +553,7 @@ function buildContents(
.filter((b: any) => b.type === 'text')
.map((b: any) => b.text)
.join('\n');
if (mergedContent.trim().length === 0)
if (isEmpty(mergedContent.trim()))
mergedContent = block.is_error
? 'Tool execution failed with no output.'
: 'Command executed successfully.';
+17 -15
View File
@@ -1,3 +1,5 @@
import { isArray, isBoolean, isNumber, isObjectLike, isString } from 'lodash-es';
/**
* Recursively cleans JSON Schema to meet Gemini interface requirements
*
@@ -9,7 +11,7 @@
*/
export function cleanJsonSchema(value: any) {
// 0. Preprocessing: Expand $ref (Schema Flattening)
if (value && typeof value === 'object' && !Array.isArray(value)) {
if (isObjectLike(value) && !isArray(value)) {
const defs: Record<string, any> = {};
// Extract $defs or definitions
@@ -34,21 +36,21 @@ export function cleanJsonSchema(value: any) {
export function normalizeObjectJsonSchema(schema: unknown): Record<string, unknown> {
const fallbackSchema: Record<string, unknown> = { type: 'object', properties: {} };
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
if (!isObjectLike(schema) || isArray(schema)) {
return fallbackSchema;
}
const normalizedSchema = JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
cleanJsonSchema(normalizedSchema);
if (typeof normalizedSchema.type !== 'string') {
if (!isString(normalizedSchema.type)) {
normalizedSchema.type = 'object';
}
if (
normalizedSchema.type === 'object' &&
(!normalizedSchema.properties ||
typeof normalizedSchema.properties !== 'object' ||
Array.isArray(normalizedSchema.properties))
!isObjectLike(normalizedSchema.properties) ||
isArray(normalizedSchema.properties))
) {
normalizedSchema.properties = {};
}
@@ -60,10 +62,10 @@ export function normalizeObjectJsonSchema(schema: unknown): Record<string, unkno
* Recursively expand $ref
*/
function flattenRefs(map: any, defs: Record<string, any>) {
if (!map || typeof map !== 'object') return;
if (!isObjectLike(map)) return;
// Check and replace $ref
if (typeof map['$ref'] === 'string') {
if (isString(map['$ref'])) {
const refPath = map['$ref'];
// Parse reference name (e.g. #/$defs/MyType -> MyType)
const parts = refPath.split('/');
@@ -74,7 +76,7 @@ function flattenRefs(map: any, defs: Record<string, any>) {
// $ref nodes should not have other properties, remove $ref directly
delete map['$ref'];
if (defSchema && typeof defSchema === 'object') {
if (isObjectLike(defSchema)) {
for (const [k, v] of Object.entries(defSchema)) {
// Only insert if the key does not exist in current map (avoid overwrite)
if (map[k] === undefined) {
@@ -93,7 +95,7 @@ function flattenRefs(map: any, defs: Record<string, any>) {
for (const k in map) {
if (Object.prototype.hasOwnProperty.call(map, k)) {
const v = map[k];
if (typeof v === 'object' && v !== null) {
if (isObjectLike(v)) {
flattenRefs(v, defs);
}
}
@@ -101,11 +103,11 @@ function flattenRefs(map: any, defs: Record<string, any>) {
}
function cleanJsonSchemaRecursive(value: any) {
if (!value || typeof value !== 'object') {
if (!isObjectLike(value)) {
return;
}
if (Array.isArray(value)) {
if (isArray(value)) {
// Array: Recursively process each element
for (const v of value) {
cleanJsonSchemaRecursive(v);
@@ -142,7 +144,7 @@ function cleanJsonSchemaRecursive(value: any) {
if (map[field] !== undefined) {
const val = map[field];
// Only migrate if value is primitive type
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
if (isString(val) || isNumber(val) || isBoolean(val)) {
constraints.push(`${label}: ${val}`);
delete map[field];
} else {
@@ -188,13 +190,13 @@ function cleanJsonSchemaRecursive(value: any) {
// 5. Handle type field (Gemini requires single lowercase string)
if (map['type']) {
const typeVal = map['type'];
if (typeof typeVal === 'string') {
if (isString(typeVal)) {
map['type'] = typeVal.toLowerCase();
} else if (Array.isArray(typeVal)) {
} else if (isArray(typeVal)) {
// Union type downgrade: take the first non-null type
let selectedType = 'string';
for (const item of typeVal) {
if (typeof item === 'string' && item !== 'null') {
if (isString(item) && item !== 'null') {
selectedType = item.toLowerCase();
break;
}
+4 -3
View File
@@ -1,3 +1,4 @@
import { isEmpty, isString } from 'lodash-es';
import { logger } from '../../utils/logger';
const PUBLIC_SUPPORTED_MODELS = [
@@ -94,7 +95,7 @@ export function getSupportedModels(): string[] {
}
export function updateDynamicForwardingRules(oldModel: string, newModel: string): void {
if (typeof oldModel !== 'string' || typeof newModel !== 'string') {
if (!isString(oldModel) || !isString(newModel)) {
return;
}
const normalizedOld = oldModel.trim();
@@ -140,7 +141,7 @@ export function getAllDynamicModels(
if (dynamicModelIds) {
for (const dynamicModelId of dynamicModelIds) {
if (typeof dynamicModelId === 'string' && dynamicModelId.trim() !== '') {
if (isString(dynamicModelId) && !isEmpty(dynamicModelId.trim())) {
modelIds.add(dynamicModelId.trim());
}
}
@@ -150,7 +151,7 @@ export function getAllDynamicModels(
}
export function mapClaudeModelToGemini(input: string): string {
if (!input || typeof input !== 'string') {
if (!isString(input) || isEmpty(input)) {
return '';
}
const mappedModel = CLAUDE_TO_GEMINI[input];
+3 -2
View File
@@ -1,4 +1,5 @@
import modelSpecsJson from './model-specs';
import { isNumber } from 'lodash-es';
type ModelSpec = {
max_output_tokens?: number;
@@ -24,7 +25,7 @@ export function resolveModelAlias(modelId: string): string {
export function getMaxOutputTokens(modelId: string): number {
const resolved = resolveModelAlias(modelId);
const fromSpec = SPECS.models[resolved]?.max_output_tokens;
if (typeof fromSpec === 'number' && Number.isFinite(fromSpec) && fromSpec > 0) {
if (isNumber(fromSpec) && Number.isFinite(fromSpec) && fromSpec > 0) {
return Math.floor(fromSpec);
}
return DEFAULT_MAX_OUTPUT_TOKENS;
@@ -33,7 +34,7 @@ export function getMaxOutputTokens(modelId: string): number {
export function getThinkingBudget(modelId: string): number {
const resolved = resolveModelAlias(modelId);
const fromSpec = SPECS.models[resolved]?.thinking_budget;
if (typeof fromSpec === 'number' && Number.isFinite(fromSpec) && fromSpec >= 0) {
if (isNumber(fromSpec) && Number.isFinite(fromSpec) && fromSpec >= 0) {
return Math.floor(fromSpec);
}
return DEFAULT_THINKING_BUDGET;
+3 -2
View File
@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from 'axios';
import { isNumber } from 'lodash-es';
import { QuotaData, LoadProjectResponse, QuotaApiResponse } from './types';
import { logger } from '../../utils/logger';
@@ -162,7 +163,7 @@ export class QuotaService {
};
}
if (hasNextEndpoint && (status === 429 || (typeof status === 'number' && status >= 500))) {
if (hasNextEndpoint && (status === 429 || (isNumber(status) && status >= 500))) {
logger.warn(
`Quota API ${endpoint} returned ${status}, falling back to next endpoint`,
);
@@ -173,7 +174,7 @@ export class QuotaService {
logger.warn(`API Error: ${status} - ${text}`);
lastError = new Error(`HTTP ${status} - ${text}`);
shouldFallback = typeof status !== 'number';
shouldFallback = !isNumber(status);
} else {
logger.warn(`Request Failed at ${endpoint}: ${error.message}`);
lastError = error instanceof Error ? error : new Error(String(error));
+122 -1
View File
@@ -1,6 +1,7 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import vi from './vi';
i18n
.use(LanguageDetector)
@@ -12,12 +13,19 @@ i18n
caches: ['localStorage'],
lookupLocalStorage: 'lang',
},
supportedLngs: ['en', 'zh-CN', 'ru'],
supportedLngs: ['en', 'zh-CN', 'ru', 'vi'],
load: 'currentOnly', // Only load the exact language code, not variants
resources: {
en: {
translation: {
appName: 'Antigravity Manager',
common: {
loading: 'Loading...',
error: 'Error',
unknown: 'Unknown',
notAvailable: 'N/A',
openMenu: 'Open menu',
},
status: {
checking: 'Checking status...',
running: 'Antigravity is running in background',
@@ -84,6 +92,7 @@ i18n
english: 'English',
chinese: 'Chinese (Simplified)',
russian: 'Russian',
vietnamese: 'Vietnamese',
},
about: {
title: 'About',
@@ -93,6 +102,15 @@ i18n
platform: 'Platform',
license: 'License',
openLogDir: 'Open',
toast: {
saved: {
title: 'Settings saved',
description: 'Your configuration has been updated.',
},
saveFailed: {
title: 'Error saving settings',
},
},
account: {
title: 'Account Settings',
description: 'Configure automatic account refresh and sync.',
@@ -125,6 +143,8 @@ i18n
quotaAlertDesc: 'Get notified when a model quota drops below the set threshold',
quotaThreshold: 'Alert Threshold',
quotaThresholdDesc: 'Percentage below which to trigger an alert',
saveFailed: 'Failed to save notification settings',
thresholdSaveFailed: 'Failed to save threshold setting',
},
proxy: {
title: 'Upstream Proxy',
@@ -308,6 +328,7 @@ i18n
proxySaved: 'Proxy saved',
noProxy: 'No proxy',
aiCredits: 'AI Credits',
aiCreditsValue: '{{amount}} credits',
creditsExpiry: 'expires {{date}}',
modelVisibility: 'Model Visibility',
},
@@ -369,6 +390,20 @@ i18n
autoSwitchOn: 'Auto-Switch Enabled',
autoSwitchOff: 'Auto-Switch Disabled',
updateSettingsFailed: 'Failed to update settings',
actionFailed: 'Action failed',
startAuthFailed: 'Failed to start sign-in flow',
refreshCreditsAvailable: 'AI credits: {{amount}}',
refreshCreditsUnavailable: 'AI credits not available for this refresh.',
batchRefreshSuccess: 'Successfully refreshed {{count}} accounts.',
batchRefreshPartial: {
title: 'Refresh completed with issues',
description: 'Refreshed {{successful}} accounts, {{failed}} failed.',
},
batchDeleteSuccess: 'Successfully deleted {{count}} accounts.',
batchDeletePartial: {
title: 'Deletion completed with issues',
description: 'Deleted {{successful}} accounts, {{failed}} failed.',
},
},
batch: {
selected: 'Selected {{count}}',
@@ -403,6 +438,9 @@ i18n
importErrors: 'Import completed with {{count}} error(s)',
selectFile: 'Select File',
importing: 'Importing...',
fileTooLarge: 'File size exceeds 5MB limit',
invalidJson: 'Invalid JSON file format',
readFileFailed: 'Failed to read file',
},
},
proxy: {
@@ -410,6 +448,7 @@ i18n
description: 'Manage the local API proxy service.',
save: 'Save Settings',
copy: 'Copy',
copied: 'Copied!',
regenerate: 'Regenerate',
regenerateConfirm: {
title: 'Regenerate API Key?',
@@ -433,6 +472,7 @@ i18n
auto_start: 'Auto Start with App',
auto_start_desc: 'Start proxy service when application launches',
local_access: 'Local network access:',
select_ip: 'Select IP',
no_token_warning: '⚠️ API key is not set. Access is open to everyone!',
show_key: 'Show',
hide_key: 'Hide',
@@ -450,9 +490,19 @@ i18n
},
},
},
vi: {
translation: vi,
},
'zh-CN': {
translation: {
appName: 'Antigravity 管理器',
common: {
loading: '加载中...',
error: '错误',
unknown: '未知',
notAvailable: '不适用',
openMenu: '打开菜单',
},
status: {
checking: '正在检查状态...',
running: 'Antigravity 正在后台运行',
@@ -519,6 +569,7 @@ i18n
english: '英语',
chinese: '中文 (简体)',
russian: '俄语',
vietnamese: '越南语',
},
about: {
title: '关于',
@@ -528,6 +579,15 @@ i18n
platform: '平台',
license: '许可证',
openLogDir: '打开',
toast: {
saved: {
title: '设置已保存',
description: '配置已更新。',
},
saveFailed: {
title: '保存设置失败',
},
},
account: {
title: '账号设置',
description: '配置自动刷新和同步账号。',
@@ -558,6 +618,8 @@ i18n
quotaAlertDesc: '当模型配额低于设定阈值时收到提醒',
quotaThreshold: '提醒阈值',
quotaThresholdDesc: '触发提醒的百分比阈值',
saveFailed: '保存通知设置失败',
thresholdSaveFailed: '保存阈值设置失败',
},
proxy: {
title: '上游代理',
@@ -738,6 +800,7 @@ i18n
proxySaved: '代理已保存',
noProxy: '无代理',
aiCredits: 'AI 积分',
aiCreditsValue: '{{amount}} 积分',
creditsExpiry: '{{date}} 过期',
modelVisibility: '模型可见性',
},
@@ -799,6 +862,20 @@ i18n
autoSwitchOn: '自动切换已启用',
autoSwitchOff: '自动切换已禁用',
updateSettingsFailed: '更新设置失败',
actionFailed: '操作失败',
startAuthFailed: '启动登录流程失败',
refreshCreditsAvailable: 'AI 积分:{{amount}}',
refreshCreditsUnavailable: '本次刷新没有可用的 AI 积分信息。',
batchRefreshSuccess: '成功刷新 {{count}} 个账号。',
batchRefreshPartial: {
title: '刷新已完成,但存在问题',
description: '已刷新 {{successful}} 个账号,{{failed}} 个失败。',
},
batchDeleteSuccess: '成功删除 {{count}} 个账号。',
batchDeletePartial: {
title: '删除已完成,但存在问题',
description: '已删除 {{successful}} 个账号,{{failed}} 个失败。',
},
},
batch: {
selected: '已选 {{count}} 项',
@@ -833,6 +910,9 @@ i18n
importErrors: '导入完成,但有 {{count}} 个错误',
selectFile: '选择文件',
importing: '导入中...',
fileTooLarge: '文件大小超过 5MB 限制',
invalidJson: 'JSON 文件格式无效',
readFileFailed: '读取文件失败',
},
},
proxy: {
@@ -840,6 +920,7 @@ i18n
description: '管理本地 API 代理服务。',
save: '保存设置',
copy: '复制',
copied: '已复制!',
regenerate: '重新生成',
regenerateConfirm: {
title: '重新生成 API 密钥?',
@@ -862,6 +943,7 @@ i18n
auto_start: '随应用启动',
auto_start_desc: '应用启动时自动启动代理服务',
local_access: '局域网访问:',
select_ip: '选择 IP',
no_token_warning: '⚠️ 未设置 API 密钥。服务对公网开放!',
show_key: '显示密钥',
hide_key: '隐藏密钥',
@@ -882,6 +964,13 @@ i18n
ru: {
translation: {
appName: 'Antigravity Manager',
common: {
loading: 'Загрузка...',
error: 'Ошибка',
unknown: 'Неизвестно',
notAvailable: 'Н/Д',
openMenu: 'Открыть меню',
},
status: {
checking: 'Проверка статуса...',
running: 'Antigravity работает в фоне',
@@ -949,6 +1038,7 @@ i18n
english: 'English',
chinese: '中文 (简体)',
russian: 'Русский',
vietnamese: 'Вьетнамский',
},
about: {
title: 'О программе',
@@ -958,6 +1048,15 @@ i18n
platform: 'Платформа',
license: 'Лицензия',
openLogDir: 'Открыть',
toast: {
saved: {
title: 'Настройки сохранены',
description: 'Конфигурация обновлена.',
},
saveFailed: {
title: 'Ошибка сохранения настроек',
},
},
account: {
title: 'Настройки аккаунта',
description: 'Настройка автоматического обновления и синхронизации.',
@@ -991,6 +1090,8 @@ i18n
'Получайте уведомления, когда квота модели падает ниже установленного порога',
quotaThreshold: 'Порог уведомления',
quotaThresholdDesc: 'Процент, ниже которого срабатывает уведомление',
saveFailed: 'Не удалось сохранить настройки уведомлений',
thresholdSaveFailed: 'Не удалось сохранить порог уведомления',
},
proxy: {
title: 'Верхнеуровневый прокси',
@@ -1173,6 +1274,7 @@ i18n
proxySaved: 'Прокси сохранён',
noProxy: 'Без прокси',
aiCredits: 'AI Кредиты',
aiCreditsValue: '{{amount}} кредитов',
creditsExpiry: 'истекает {{date}}',
modelVisibility: 'Видимость моделей',
},
@@ -1234,6 +1336,20 @@ i18n
autoSwitchOn: 'Авто-переключение включено',
autoSwitchOff: 'Авто-переключение выключено',
updateSettingsFailed: 'Не удалось обновить настройки',
actionFailed: 'Не удалось выполнить действие',
startAuthFailed: 'Не удалось запустить процесс входа',
refreshCreditsAvailable: 'AI кредиты: {{amount}}',
refreshCreditsUnavailable: 'AI кредиты недоступны для этого обновления.',
batchRefreshSuccess: 'Успешно обновлено {{count}} аккаунтов.',
batchRefreshPartial: {
title: 'Обновление завершено с проблемами',
description: 'Обновлено {{successful}} аккаунтов, {{failed}} с ошибкой.',
},
batchDeleteSuccess: 'Успешно удалено {{count}} аккаунтов.',
batchDeletePartial: {
title: 'Удаление завершено с проблемами',
description: 'Удалено {{successful}} аккаунтов, {{failed}} с ошибкой.',
},
},
batch: {
selected: 'Выбрано {{count}}',
@@ -1269,6 +1385,9 @@ i18n
importErrors: 'Импорт завершён с {{count}} ошибкой(ами)',
selectFile: 'Выбрать файл',
importing: 'Импорт...',
fileTooLarge: 'Размер файла превышает лимит в 5 МБ',
invalidJson: 'Некорректный формат JSON-файла',
readFileFailed: 'Не удалось прочитать файл',
},
},
proxy: {
@@ -1276,6 +1395,7 @@ i18n
description: 'Управление локальным прокси-сервером API.',
save: 'Сохранить',
copy: 'Копировать',
copied: 'Скопировано!',
regenerate: 'Перегенерировать',
regenerateConfirm: {
title: 'Перегенерировать API ключ?',
@@ -1299,6 +1419,7 @@ i18n
auto_start: 'Автозапуск',
auto_start_desc: 'Запускать прокси при старте приложения',
local_access: 'Доступ из локальной сети:',
select_ip: 'Выбрать IP',
no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!',
show_key: 'Показать',
hide_key: 'Скрыть',
+472
View File
@@ -0,0 +1,472 @@
const vi = {
appName: 'Antigravity Manager',
common: {
loading: 'Đang tải...',
error: 'Lỗi',
unknown: 'Không rõ',
notAvailable: 'Không có',
openMenu: 'Mở menu',
},
status: {
checking: 'Đang kiểm tra trạng thái...',
running: 'Antigravity đang chạy nền',
stopped: 'Dịch vụ Antigravity đã dừng',
},
action: {
stop: 'Dừng',
start: 'Bắt đầu',
switch: 'Chuyển',
deleteBackup: 'Xóa bản sao lưu',
backupCurrent: 'Sao lưu tài khoản hiện tại',
retry: 'Thử lại',
openLogs: 'Mở thư mục log',
},
error: {
generic: 'Đã xảy ra lỗi không mong đợi.',
keychainUnavailable: 'Keychain không khả dụng.',
keychainHint: {
translocation:
'Đã phát hiện macOS App Translocation. Hãy chuyển ứng dụng vào /Applications rồi mở lại.',
keychainDenied:
'Quyền truy cập Keychain đã bị từ chối. Ứng dụng có thể chưa được ký; hãy xem README để biết cách tự ký.',
signNotarize: 'Hãy ưu tiên dùng bản dựng đã được ký và công chứng khi có thể.',
},
dataMigrationFailed: 'Không thể giải mã dữ liệu tài khoản cũ.',
dataMigrationHint: {
relogin: 'Hãy đăng nhập lại hoặc thêm lại tài khoản của bạn.',
clearData: 'Nếu lỗi vẫn tiếp diễn, hãy xóa dữ liệu tài khoản cục bộ rồi đăng nhập lại.',
},
},
nav: {
accounts: 'Tài khoản',
proxy: 'API Proxy',
settings: 'Cài đặt',
},
account: {
current: 'Hiện tại',
lastUsed: 'Đã dùng {{time}}',
},
home: {
title: 'Tài khoản',
description: 'Quản lý các tài khoản Antigravity Google Gemini.',
noBackups: {
title: 'Chưa tìm thấy bản sao lưu',
description: 'Hãy tạo bản sao lưu cho tài khoản Antigravity hiện tại để bắt đầu.',
action: 'Sao lưu tài khoản hiện tại',
},
},
settings: {
title: 'Cài đặt',
description: 'Quản lý tùy chọn ứng dụng.',
general: 'Chung',
connection: 'Kết nối',
models: 'Mô hình',
appearance: {
title: 'Giao diện',
description: 'Tùy chỉnh giao diện Antigravity Manager trên thiết bị của bạn.',
},
darkMode: 'Chế độ tối',
darkModeDescription: 'Bật chế độ tối để dễ nhìn hơn vào ban đêm.',
language: {
title: 'Ngôn ngữ',
description: 'Chọn ngôn ngữ bạn muốn sử dụng.',
english: 'Tiếng Anh',
chinese: 'Tiếng Trung Giản thể',
russian: 'Tiếng Nga',
vietnamese: 'Tiếng Việt',
},
about: {
title: 'Thông tin',
description: 'Thông tin ứng dụng.',
},
version: 'Phiên bản',
platform: 'Nền tảng',
license: 'Giấy phép',
openLogDir: 'Mở',
toast: {
saved: {
title: 'Đã lưu cài đặt',
description: 'Cấu hình của bạn đã được cập nhật.',
},
saveFailed: {
title: 'Không thể lưu cài đặt',
},
},
account: {
title: 'Cài đặt tài khoản',
description: 'Cấu hình tự động làm mới quota và đồng bộ.',
auto_refresh: 'Tự động làm mới quota',
auto_refresh_desc: 'Định kỳ cập nhật thông tin quota cho tất cả tài khoản',
auto_sync: 'Tự động đồng bộ tài khoản hiện tại',
auto_sync_desc: 'Định kỳ đồng bộ thông tin tài khoản đang hoạt động',
},
startup: {
title: 'Khởi động',
description: 'Điều khiển hành vi khởi động cùng hệ thống.',
auto_startup: 'Khởi động cùng hệ thống',
auto_startup_desc: 'Chạy khi đăng nhập và ẩn vào khay hệ thống',
macos_hint:
'macOS cần bản ký ứng dụng để Login Items hoạt động. Nếu tự động khởi động thất bại, hãy ký ứng dụng hoặc bật thủ công trong System Settings.',
},
privacy: {
title: 'Quyền riêng tư',
description: 'Quản lý cách dữ liệu được sử dụng để cải thiện ứng dụng.',
error_reporting: 'Báo cáo lỗi',
error_reporting_desc:
'Gửi báo cáo lỗi ẩn danh để giúp cải thiện ứng dụng. Không thu thập dữ liệu cá nhân.',
restart_note: 'Thay đổi báo cáo lỗi sẽ có hiệu lực sau khi khởi động lại ứng dụng.',
},
notifications: {
title: 'Thông báo',
description: 'Cấu hình cảnh báo trên desktop cho các sự kiện tài khoản.',
quotaAlert: 'Cảnh báo quota thấp',
quotaAlertDesc: 'Nhận thông báo khi quota của model giảm xuống dưới ngưỡng đã đặt',
quotaThreshold: 'Ngưỡng cảnh báo',
quotaThresholdDesc: 'Phần trăm dùng để kích hoạt cảnh báo',
saveFailed: 'Không thể lưu cài đặt thông báo',
thresholdSaveFailed: 'Không thể lưu ngưỡng cảnh báo',
},
proxy: {
title: 'Proxy trung gian',
description: 'Cấu hình proxy cho các request đi ra Google/Gemini APIs.',
enable: 'Bật proxy trung gian',
url: 'Địa chỉ proxy',
timeout: 'Thời gian chờ request (giây)',
},
modelMapping: {
title: 'Ánh xạ mô hình',
description:
'Ánh xạ các model Claude Code sang model Antigravity để tối ưu chi phí và tốc độ.',
claudeKeyword: 'Từ khóa model Claude',
targetGemini: 'Model Gemini đích',
addPlaceholderKey: 'ví dụ: op-3',
addPlaceholderValue: 'ví dụ: gemini-3-flash',
noMappings: 'Chưa có ánh xạ tùy chỉnh.',
mapsTo: 'Ánh xạ tới',
default: 'Mặc định',
restoreDefaults: 'Khôi phục mặc định',
},
modelVisibility: {
title: 'Hiển thị mô hình',
description:
'Điều khiển model nào được hiện trong thẻ tài khoản. Model bị ẩn sẽ không xuất hiện ở phần quota.',
searchPlaceholder: 'Tìm mô hình...',
showAll: 'Hiện tất cả',
hideAll: 'Ẩn tất cả',
reset: 'Đặt lại mặc định',
save: 'Lưu thay đổi',
noModels: 'Không tìm thấy mô hình',
modelsShown: 'Đang hiện {{visible}} / {{total}} mô hình',
quotaManagement: 'Quản lý quota',
hidden: 'Đã ẩn',
noModelsFound: 'Không tìm thấy mô hình',
totalModels: 'Tổng',
visibleModels: 'Đang hiện',
hiddenModels: 'Đã ẩn',
saving: 'Đang lưu...',
},
providerGroupings: {
title: 'Nhóm theo nhà cung cấp',
description: 'Nhóm model theo nhà cung cấp để dễ quản lý hơn',
enabled: 'Bật nhóm theo nhà cung cấp',
models: '{{count}} mô hình',
avgLabel: 'tb',
resetLabel: 'reset',
overall: 'Tổng thể',
healthy: 'Tốt',
degraded: 'Giảm',
limited: 'Giới hạn',
critical: 'Nghiêm trọng',
},
examples: {
title: 'Ví dụ sử dụng',
description: 'Ví dụ lệnh để gọi local API proxy.',
curl: 'cURL',
python: 'Python',
copy: 'Sao chép',
copied: 'Đã sao chép!',
openai_protocol: 'Giao thức OpenAI',
anthropic_protocol: 'Giao thức Anthropic',
openai_tools: 'Cursor, Windsurf, NextChat',
anthropic_tools: 'Claude Code CLI',
flash: 'Nhanh',
pro: 'Pro',
flash_preview: 'Xem trước',
pro_high: 'Tốt nhất',
sonnet: 'Thinking',
opus: 'Opus',
},
gateway: {
title: 'Dịch vụ API Proxy',
description: 'Điều khiển local API proxy server.',
status_running: 'Đang chạy',
status_stopped: 'Đã dừng',
action_start: 'Bắt đầu dịch vụ',
action_stop: 'Dừng dịch vụ',
accounts_info: '{{count}} tài khoản khả dụng',
port: 'Cổng lắng nghe',
port_hint: 'Mặc định 8045, cần khởi động lại để áp dụng',
timeout: 'Thời gian chờ request',
timeout_hint: 'Mặc định 120 giây, khoảng 30-600 giây',
api_key: 'API Key',
regenerate_key: 'Tạo lại',
regenerateConfirm: {
title: 'Tạo lại API Key?',
description:
'API key hiện tại sẽ hết hiệu lực ngay lập tức. Các ứng dụng đang dùng key cũ sẽ ngừng hoạt động.',
cancel: 'Hủy',
confirm: 'Tạo lại',
},
key_warning: 'Hãy giữ kín API key của bạn. Không chia sẻ với người khác.',
auto_start: 'Tự động chạy cùng app',
auto_start_desc: 'Khởi động proxy khi ứng dụng mở lên',
},
proxy_tab: 'Proxy',
save: 'Lưu cài đặt',
},
toast: {
backupSuccess: {
title: 'Thành công',
description: 'Đã tạo bản sao lưu tài khoản.',
},
backupError: {
title: 'Lỗi',
description: 'Không thể tạo bản sao lưu: {{error}}',
},
switchSuccess: {
title: 'Thành công',
description: 'Chuyển tài khoản thành công.',
},
switchError: {
title: 'Lỗi',
description: 'Không thể chuyển tài khoản: {{error}}',
},
deleteSuccess: {
title: 'Thành công',
description: 'Đã xóa bản sao lưu tài khoản.',
},
deleteError: {
title: 'Lỗi',
description: 'Không thể xóa bản sao lưu: {{error}}',
},
},
cloud: {
title: 'Tài khoản',
description: 'Quản lý pool tài khoản Google Gemini của bạn.',
autoSwitch: 'Tự động chuyển',
providerGroupings: 'Nhóm theo nhà cung cấp',
addAccount: 'Thêm tài khoản',
syncFromIDE: 'Đồng bộ từ IDE',
checkQuota: 'Kiểm tra quota ngay',
polling: 'Đã kích hoạt polling',
globalQuota: 'Quota tổng',
layout: {
auto: 'Tự động',
twoCol: '2 cột',
threeCol: '3 cột',
list: 'Danh sách',
compact: 'Thu gọn',
},
authDialog: {
title: 'Thêm tài khoản Google',
description: 'Bạn cần cấp quyền ứng dụng để thêm tài khoản.',
oauthClient: 'OAuth Client',
oauthClientPlaceholder: 'Chọn OAuth client',
openLogin: 'Mở trang đăng nhập',
authCode: 'Mã ủy quyền',
placeholder: 'Dán mã bắt đầu bằng 4/...',
instruction:
'Ứng dụng sẽ mở trình duyệt mặc định để đăng nhập Google. Sao chép mã từ trang localhost và dán vào đây.',
verify: 'Xác thực và thêm',
},
card: {
active: 'Đang hoạt động',
use: 'Sử dụng',
rateLimited: 'Bị giới hạn tốc độ',
validationRiskControlled: 'Rủi ro / Bị giới hạn',
validationOAuthReauthRequired: 'Cần xác thực lại OAuth',
validationRequired: 'Cần xác minh',
left: 'còn lại',
used: 'Đã dùng',
unknown: 'Người dùng không rõ',
actions: 'Hành động',
useAccount: 'Dùng tài khoản này',
identityProfile: 'Hồ sơ định danh',
refresh: 'Làm mới quota',
delete: 'Xóa tài khoản',
noQuota: 'Chưa có dữ liệu quota',
rateLimitedQuota: 'Bị giới hạn',
resetPrefix: 'reset',
resetTime: 'Thời điểm reset',
resetUnknown: 'Không rõ',
gemini3Ready: 'Sẵn sàng cho Gemini 3',
groupGoogleGemini: 'Google Gemini',
groupAnthropicClaude: 'Anthropic Claude',
proxy: 'Proxy',
proxyPlaceholder: 'ví dụ: http://127.0.0.1:7890',
proxySaved: 'Đã lưu proxy',
noProxy: 'Không dùng proxy',
aiCredits: 'AI Credits',
aiCreditsValue: '{{amount}} credits',
creditsExpiry: 'hết hạn {{date}}',
modelVisibility: 'Hiển thị model',
},
identity: {
title: 'Hồ sơ định danh',
loading: 'Đang tải...',
generateAndBind: 'Tạo và gán',
captureAndBind: 'Lấy và gán hồ sơ hiện tại',
restoreOriginal: 'Khôi phục baseline',
openFolder: 'Mở thư mục lưu hồ sơ',
previewTitle: 'Xem trước hồ sơ đã tạo',
confirm: 'Xác nhận',
cancel: 'Hủy',
close: 'Đóng',
currentStorage: 'Hồ sơ runtime hiện tại',
accountBinding: 'Hồ sơ đang gán với tài khoản',
history: 'Lịch sử hồ sơ',
noHistory: 'Chưa có lịch sử hồ sơ',
current: 'Đang dùng',
restore: 'Khôi phục',
generateSuccess: 'Đã tạo và gán hồ sơ',
captureSuccess: 'Đã lấy và gán hồ sơ hiện tại',
restoreOriginalSuccess: 'Đã khôi phục baseline',
restoreVersionSuccess: 'Đã khôi phục bản lịch sử',
deleteVersionSuccess: 'Đã xóa bản lịch sử',
openFolderSuccess: 'Đã mở thư mục lưu hồ sơ',
baseline: 'Hồ sơ baseline',
},
list: {
noAccounts: 'Chưa có tài khoản cloud nào được thêm.',
},
error: {
loadFailed: 'Không thể tải danh sách tài khoản cloud.',
},
toast: {
syncSuccess: {
title: 'Đồng bộ thành công',
description: 'Đã nhập {{email}} từ IDE.',
},
syncFailed: {
title: 'Đồng bộ thất bại',
description: 'Không tìm thấy tài khoản đang hoạt động trong database của IDE.',
},
addSuccess: 'Đã thêm tài khoản thành công!',
addFailed: {
title: 'Không thể thêm tài khoản',
},
quotaRefreshed: 'Đã làm mới quota',
refreshFailed: 'Không thể làm mới quota',
pollFailed: 'Không thể polling quota của tất cả tài khoản',
switched: {
title: 'Đã chuyển tài khoản!',
description: 'Đang khởi động lại Antigravity...',
},
switchFailed: 'Không thể chuyển tài khoản',
deleted: 'Đã xóa tài khoản',
deleteFailed: 'Không thể xóa tài khoản',
deleteConfirm: 'Bạn có chắc chắn muốn xóa tài khoản này không?',
autoSwitchOn: 'Đã bật tự động chuyển',
autoSwitchOff: 'Đã tắt tự động chuyển',
updateSettingsFailed: 'Không thể cập nhật cài đặt',
actionFailed: 'Không thể thực hiện thao tác',
startAuthFailed: 'Không thể bắt đầu luồng đăng nhập',
refreshCreditsAvailable: 'AI credits: {{amount}}',
refreshCreditsUnavailable: 'Không có thông tin AI credits cho lần làm mới này.',
batchRefreshSuccess: 'Đã làm mới thành công {{count}} tài khoản.',
batchRefreshPartial: {
title: 'Làm mới hoàn tất nhưng có lỗi',
description: 'Đã làm mới {{successful}} tài khoản, {{failed}} tài khoản thất bại.',
},
batchDeleteSuccess: 'Đã xóa thành công {{count}} tài khoản.',
batchDeletePartial: {
title: 'Xóa hoàn tất nhưng có lỗi',
description: 'Đã xóa {{successful}} tài khoản, {{failed}} tài khoản thất bại.',
},
},
batch: {
selected: 'Đã chọn {{count}}',
delete: 'Xóa mục đã chọn',
refresh: 'Làm mới mục đã chọn',
selectAll: 'Chọn tất cả',
clear: 'Bỏ chọn',
confirmDelete: 'Bạn có chắc chắn muốn xóa {{count}} tài khoản không?',
},
sort: {
recentlyUsed: 'Dùng gần đây',
quotaOverall: 'Quota tổng',
quotaClaude: 'Quota Claude',
quotaPro3: 'Quota Pro3',
quotaFlash: 'Quota Flash',
},
exportImport: {
export: 'Xuất',
import: 'Nhập',
exportTitle: 'Xuất tài khoản',
exportDesc: 'Chọn có bao gồm token xác thực trong tệp xuất hay không.',
includeTokens: 'Bao gồm token (kém an toàn hơn)',
stripTokens: 'Loại bỏ token (an toàn hơn khi chia sẻ)',
exportSuccess: 'Đã xuất tài khoản thành công',
importTitle: 'Nhập tài khoản',
importDesc: 'Chọn tệp JSON đã được xuất trước đó.',
importStrategy: 'Chiến lược nhập',
strategyMerge: 'Gộp - Cập nhật tài khoản cũ, thêm tài khoản mới',
strategyOverwrite: 'Ghi đè - Thay thế toàn bộ dữ liệu hiện có',
strategySkip: 'Bỏ qua - Chỉ thêm tài khoản mới',
importSuccess: 'Đã nhập {{imported}}, cập nhật {{updated}}, bỏ qua {{skipped}}',
importErrors: 'Đã nhập xong với {{count}} lỗi',
selectFile: 'Chọn tệp',
importing: 'Đang nhập...',
fileTooLarge: 'Kích thước tệp vượt quá giới hạn 5MB',
invalidJson: 'Định dạng tệp JSON không hợp lệ',
readFileFailed: 'Không thể đọc tệp',
},
},
proxy: {
title: 'API Proxy',
description: 'Quản lý local API proxy service.',
save: 'Lưu cài đặt',
copy: 'Sao chép',
copied: 'Đã sao chép!',
regenerate: 'Tạo lại',
regenerateConfirm: {
title: 'Tạo lại API Key?',
description:
'API key hiện tại sẽ hết hiệu lực ngay lập tức. Các ứng dụng đang dùng key cũ sẽ ngừng hoạt động.',
cancel: 'Hủy',
confirm: 'Tạo lại',
},
service: {
title: 'Trạng thái dịch vụ',
description: 'Điều khiển local API proxy server.',
running: 'Đang chạy',
stopped: 'Đã dừng',
start: 'Bắt đầu dịch vụ',
stop: 'Dừng dịch vụ',
},
config: {
port: 'Cổng lắng nghe',
timeout: 'Thời gian chờ request',
api_key: 'API Key',
auto_start: 'Tự động chạy cùng app',
auto_start_desc: 'Khởi động proxy khi ứng dụng mở lên',
local_access: 'Truy cập trong mạng nội bộ:',
select_ip: 'Chọn IP',
no_token_warning: 'API key chưa được đặt. Bất kỳ ai trong mạng đều có thể truy cập!',
show_key: 'Hiện',
hide_key: 'Ẩn',
},
mapping: {
title: 'Ánh xạ mô hình',
description: 'Ánh xạ model Claude sang Gemini để định tuyến.',
maps_to: 'Ánh xạ tới',
restore: 'Khôi phục mặc định',
},
examples: {
title: 'Ví dụ sử dụng',
description: 'Ví dụ lệnh để gọi local API proxy.',
},
},
};
export default vi;
+35 -59
View File
@@ -257,16 +257,14 @@ print(response.choices[0].message.content)`;
return (
<div className="container mx-auto max-w-4xl space-y-5 p-6">
<div>
<h2 className="text-3xl font-bold tracking-tight">{t('proxy.title', 'API Proxy')}</h2>
<p className="text-muted-foreground mt-1">
{t('proxy.description', 'Manage the local API proxy service.')}
</p>
<h2 className="text-3xl font-bold tracking-tight">{t('proxy.title')}</h2>
<p className="text-muted-foreground mt-1">{t('proxy.description')}</p>
{/* Local Access Info Banner */}
{proxyConfig?.enabled && (
<div className="mt-4 flex flex-col gap-2 rounded-md border border-blue-100 bg-blue-50 p-3 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-200">
<div className="flex items-center gap-2">
<div className="font-semibold">{t('proxy.config.local_access', 'Local Access:')}</div>
<div className="font-semibold">{t('proxy.config.local_access')}</div>
<code className="rounded bg-blue-100 px-1.5 py-0.5 font-mono select-all dark:bg-blue-900/50">
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
</code>
@@ -274,7 +272,7 @@ print(response.choices[0].message.content)`;
{localIps && localIps.length > 1 && (
<Select value={selectedIp} onValueChange={setSelectedIp}>
<SelectTrigger className="ml-2 h-7 w-auto min-w-[180px] text-xs">
<SelectValue placeholder="Select IP" />
<SelectValue placeholder={t('proxy.config.select_ip')} />
</SelectTrigger>
<SelectContent>
{localIps.map((ip) => (
@@ -288,10 +286,7 @@ print(response.choices[0].message.content)`;
</div>
{!proxyConfig.api_key && (
<div className="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400">
{t(
'proxy.config.no_token_warning',
'⚠️ No API Key set. Service is open to the public network!',
)}
{t('proxy.config.no_token_warning')}
</div>
)}
</div>
@@ -303,19 +298,15 @@ print(response.choices[0].message.content)`;
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>{t('proxy.service.title', 'Service Status')}</CardTitle>
<CardDescription>
{t('proxy.service.description', 'Control the local API proxy server.')}
</CardDescription>
<CardTitle>{t('proxy.service.title')}</CardTitle>
<CardDescription>{t('proxy.service.description')}</CardDescription>
</div>
<div className="flex items-center gap-2">
<div
className={`h-3 w-3 rounded-full ${proxyConfig.enabled ? 'animate-pulse bg-green-500' : 'bg-gray-400'}`}
></div>
<span className="text-sm font-medium">
{proxyConfig.enabled
? t('proxy.service.running', 'Running')
: t('proxy.service.stopped', 'Stopped')}
{proxyConfig.enabled ? t('proxy.service.running') : t('proxy.service.stopped')}
</span>
</div>
</div>
@@ -336,16 +327,14 @@ print(response.choices[0].message.content)`;
}
}}
>
{proxyConfig.enabled
? t('proxy.service.stop', 'Stop Service')
: t('proxy.service.start', 'Start Service')}
{proxyConfig.enabled ? t('proxy.service.stop') : t('proxy.service.start')}
</Button>
</div>
{/* Port & Timeout Configuration */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="gateway-port">{t('proxy.config.port', 'Listen Port')}</Label>
<Label htmlFor="gateway-port">{t('proxy.config.port')}</Label>
<Input
id="gateway-port"
type="number"
@@ -357,9 +346,7 @@ print(response.choices[0].message.content)`;
/>
</div>
<div className="space-y-2">
<Label htmlFor="gateway-timeout">
{t('proxy.config.timeout', 'Request Timeout')}
</Label>
<Label htmlFor="gateway-timeout">{t('proxy.config.timeout')}</Label>
<Input
id="gateway-timeout"
type="number"
@@ -376,7 +363,7 @@ print(response.choices[0].message.content)`;
{/* API Key */}
<div className="space-y-2">
<Label>{t('proxy.config.api_key', 'API Key')}</Label>
<Label>{t('proxy.config.api_key')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
@@ -405,28 +392,21 @@ print(response.choices[0].message.content)`;
onClick={() => navigator.clipboard.writeText(proxyConfig.api_key || '')}
>
<Copy size={14} className="mr-1" />
{t('proxy.copy', 'Copy')}
{t('proxy.copy')}
</Button>
<Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}>
{t('proxy.regenerate', 'Regenerate')}
{t('proxy.regenerate')}
</Button>
</div>
<Dialog open={isRegenerateDialogOpen} onOpenChange={setIsRegenerateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('proxy.regenerateConfirm.title', 'Regenerate API Key?')}
</DialogTitle>
<DialogDescription>
{t(
'proxy.regenerateConfirm.description',
'This will invalidate the current API key immediately. Any applications using the old key will stop working.',
)}
</DialogDescription>
<DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
<DialogDescription>{t('proxy.regenerateConfirm.description')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
{t('proxy.regenerateConfirm.cancel', 'Cancel')}
{t('proxy.regenerateConfirm.cancel')}
</Button>
<Button
variant="destructive"
@@ -437,7 +417,7 @@ print(response.choices[0].message.content)`;
setIsRegenerateDialogOpen(false);
}}
>
{t('proxy.regenerateConfirm.confirm', 'Regenerate')}
{t('proxy.regenerateConfirm.confirm')}
</Button>
</DialogFooter>
</DialogContent>
@@ -447,10 +427,8 @@ print(response.choices[0].message.content)`;
{/* Auto Start Toggle */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('proxy.config.auto_start', 'Auto Start with App')}</Label>
<p className="text-xs text-gray-500">
{t('proxy.config.auto_start_desc', 'Start proxy service when application launches')}
</p>
<Label>{t('proxy.config.auto_start')}</Label>
<p className="text-xs text-gray-500">{t('proxy.config.auto_start_desc')}</p>
</div>
<Switch
checked={proxyConfig.auto_start}
@@ -465,10 +443,8 @@ print(response.choices[0].message.content)`;
{/* Model Mapping Card */}
<Card>
<CardHeader>
<CardTitle>{t('proxy.mapping.title', 'Model Mapping')}</CardTitle>
<CardDescription>
{t('proxy.mapping.description', 'Map Claude models to Gemini models for routing.')}
</CardDescription>
<CardTitle>{t('proxy.mapping.title')}</CardTitle>
<CardDescription>{t('proxy.mapping.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -481,7 +457,7 @@ print(response.choices[0].message.content)`;
</h3>
</div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
{t('proxy.mapping.maps_to', 'Maps to')}
{t('proxy.mapping.maps_to')}
</p>
<Select
value={resolveAnthropicMappingValue(
@@ -518,7 +494,7 @@ print(response.choices[0].message.content)`;
</h3>
</div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
{t('proxy.mapping.maps_to', 'Maps to')}
{t('proxy.mapping.maps_to')}
</p>
<Select
value={resolveAnthropicMappingValue(
@@ -558,7 +534,7 @@ print(response.choices[0].message.content)`;
})
}
>
{t('proxy.mapping.restore', 'Restore Defaults')}
{t('proxy.mapping.restore')}
</Button>
</div>
</CardContent>
@@ -569,11 +545,9 @@ print(response.choices[0].message.content)`;
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code size={20} />
{t('proxy.examples.title', 'Usage Examples')}
{t('proxy.examples.title')}
</CardTitle>
<CardDescription>
{t('proxy.examples.description', 'Example commands to call the local API proxy.')}
</CardDescription>
<CardDescription>{t('proxy.examples.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Protocol Selector Cards */}
@@ -588,7 +562,7 @@ print(response.choices[0].message.content)`;
className={`h-2 w-2 rounded-full ${selectedProtocol === 'openai' ? 'animate-pulse bg-blue-500' : 'bg-blue-400'}`}
></div>
<span className="text-sm font-bold text-blue-700 dark:text-blue-400">
OpenAI Protocol
{t('settings.examples.openai_protocol')}
</span>
</div>
<div className="mb-2 rounded border border-blue-200/50 bg-white/60 px-3 py-2 dark:border-blue-700/30 dark:bg-gray-800/40">
@@ -597,7 +571,7 @@ print(response.choices[0].message.content)`;
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
💡 Cursor, Windsurf, NextChat
{t('settings.examples.openai_tools')}
</p>
</div>
@@ -611,7 +585,7 @@ print(response.choices[0].message.content)`;
className={`h-2 w-2 rounded-full ${selectedProtocol === 'anthropic' ? 'animate-pulse bg-purple-500' : 'bg-purple-400'}`}
></div>
<span className="text-sm font-bold text-purple-700 dark:text-purple-400">
Anthropic Protocol
{t('settings.examples.anthropic_protocol')}
</span>
</div>
<div className="mb-2 rounded border border-purple-200/50 bg-white/60 px-3 py-2 dark:border-purple-700/30 dark:bg-gray-800/40">
@@ -619,7 +593,9 @@ print(response.choices[0].message.content)`;
POST /v1/messages
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">💡 Claude Code CLI</p>
<p className="text-xs text-gray-600 dark:text-gray-400">
{t('settings.examples.anthropic_tools')}
</p>
</div>
</div>
@@ -649,7 +625,7 @@ print(response.choices[0].message.content)`;
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700"
>
{copied === 'curl' ? <CheckCircle size={14} /> : <Copy size={14} />}
{copied === 'curl' ? 'Copied!' : 'Copy'}
{copied === 'curl' ? t('proxy.copied') : t('proxy.copy')}
</button>
</div>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
@@ -669,7 +645,7 @@ print(response.choices[0].message.content)`;
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700"
>
{copied === 'python' ? <CheckCircle size={14} /> : <Copy size={14} />}
{copied === 'python' ? 'Copied!' : 'Copy'}
{copied === 'python' ? t('proxy.copied') : t('proxy.copy')}
</button>
</div>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
+33 -79
View File
@@ -84,9 +84,9 @@ function SettingsPage() {
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="general">{t('settings.general', 'General')}</TabsTrigger>
<TabsTrigger value="models">{t('settings.models', 'Models')}</TabsTrigger>
<TabsTrigger value="proxy">{t('settings.proxy_tab', 'Proxy')}</TabsTrigger>
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="models">{t('settings.models')}</TabsTrigger>
<TabsTrigger value="proxy">{t('settings.proxy_tab')}</TabsTrigger>
</TabsList>
{/* --- GENERAL TAB --- */}
@@ -130,6 +130,7 @@ function SettingsPage() {
<SelectItem value="en">{t('settings.language.english')}</SelectItem>
<SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem>
<SelectItem value="ru">{t('settings.language.russian')}</SelectItem>
<SelectItem value="vi">{t('settings.language.vietnamese')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -139,22 +140,15 @@ function SettingsPage() {
{/* Account Settings Card */}
<Card>
<CardHeader>
<CardTitle>{t('settings.account.title', 'Account Settings')}</CardTitle>
<CardDescription>
{t('settings.account.description', 'Configure automatic account refresh and sync.')}
</CardDescription>
<CardTitle>{t('settings.account.title')}</CardTitle>
<CardDescription>{t('settings.account.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Auto Refresh Quota */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.account.auto_refresh', 'Auto Refresh Quota')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.account.auto_refresh_desc',
'Periodically refresh quota info for all accounts',
)}
</p>
<Label>{t('settings.account.auto_refresh')}</Label>
<p className="text-xs text-gray-500">{t('settings.account.auto_refresh_desc')}</p>
</div>
<Switch
checked={config?.auto_refresh || false}
@@ -169,13 +163,8 @@ function SettingsPage() {
{/* Auto Sync Account */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.account.auto_sync', 'Auto Sync Current Account')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.account.auto_sync_desc',
'Periodically sync active account information',
)}
</p>
<Label>{t('settings.account.auto_sync')}</Label>
<p className="text-xs text-gray-500">{t('settings.account.auto_sync_desc')}</p>
</div>
<Switch
checked={config?.auto_sync || false}
@@ -192,23 +181,15 @@ function SettingsPage() {
{isAutoStartSupported && (
<Card>
<CardHeader>
<CardTitle>{t('settings.startup.title', 'Startup')}</CardTitle>
<CardDescription>
{t(
'settings.startup.description',
'Control application launch behavior at system startup.',
)}
</CardDescription>
<CardTitle>{t('settings.startup.title')}</CardTitle>
<CardDescription>{t('settings.startup.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.startup.auto_startup', 'Start with system')}</Label>
<Label>{t('settings.startup.auto_startup')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.startup.auto_startup_desc',
'Launch at sign-in and keep the app in the system tray',
)}
{t('settings.startup.auto_startup_desc')}
</p>
</div>
<Switch
@@ -222,10 +203,7 @@ function SettingsPage() {
</div>
{isMac && (
<p className="text-muted-foreground text-xs">
{t(
'settings.startup.macos_hint',
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
)}
{t('settings.startup.macos_hint')}
</p>
)}
</CardContent>
@@ -240,10 +218,10 @@ function SettingsPage() {
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="text-muted-foreground">{t('settings.version')}</div>
<div className="font-medium">{appVersion || 'Unknown'}</div>
<div className="font-medium">{appVersion || t('common.unknown')}</div>
<div className="text-muted-foreground">{t('settings.platform')}</div>
<div className="font-medium capitalize">{platform || 'Unknown'}</div>
<div className="font-medium capitalize">{platform || t('common.unknown')}</div>
<div className="text-muted-foreground">{t('settings.license')}</div>
<div className="font-medium">CC BY-NC-SA 4.0</div>
@@ -254,7 +232,7 @@ function SettingsPage() {
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
<FolderOpen className="h-4 w-4" />
<span>{t('settings.openLogDir', 'Open')}</span>
<span>{t('settings.openLogDir')}</span>
</button>
</div>
</CardContent>
@@ -263,23 +241,15 @@ function SettingsPage() {
{/* Privacy & Error Reporting Card */}
<Card>
<CardHeader>
<CardTitle>{t('settings.privacy.title', 'Privacy')}</CardTitle>
<CardDescription>
{t(
'settings.privacy.description',
'Control how your data is used to improve the application.',
)}
</CardDescription>
<CardTitle>{t('settings.privacy.title')}</CardTitle>
<CardDescription>{t('settings.privacy.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.privacy.error_reporting', 'Error Reporting')}</Label>
<Label>{t('settings.privacy.error_reporting')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.privacy.error_reporting_desc',
'Send anonymous error reports to help us improve the app. No personal data is collected.',
)}
{t('settings.privacy.error_reporting_desc')}
</p>
</div>
<Switch
@@ -291,35 +261,22 @@ function SettingsPage() {
}}
/>
</div>
<p className="text-muted-foreground text-xs">
{t(
'settings.privacy.restart_note',
'Changes to error reporting will take effect after restarting the application.',
)}
</p>
<p className="text-muted-foreground text-xs">{t('settings.privacy.restart_note')}</p>
</CardContent>
</Card>
{/* Notifications Card */}
<Card>
<CardHeader>
<CardTitle>{t('settings.notifications.title', 'Notifications')}</CardTitle>
<CardDescription>
{t(
'settings.notifications.description',
'Configure desktop alerts for account events.',
)}
</CardDescription>
<CardTitle>{t('settings.notifications.title')}</CardTitle>
<CardDescription>{t('settings.notifications.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.notifications.quotaAlert', 'Low Quota Alerts')}</Label>
<Label>{t('settings.notifications.quotaAlert')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.notifications.quotaAlertDesc',
'Get notified when a model quota drops below the set threshold',
)}
{t('settings.notifications.quotaAlertDesc')}
</p>
</div>
<Switch
@@ -328,10 +285,10 @@ function SettingsPage() {
if (config) {
try {
await saveConfig({ ...config, quota_alert_enabled: checked });
} catch (err) {
} catch {
toast({
title: t('common.error'),
description: 'Failed to save notification settings',
description: t('settings.notifications.saveFailed'),
variant: 'destructive',
});
}
@@ -341,12 +298,9 @@ function SettingsPage() {
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.notifications.quotaThreshold', 'Alert Threshold')}</Label>
<Label>{t('settings.notifications.quotaThreshold')}</Label>
<p className="text-xs text-gray-500">
{t(
'settings.notifications.quotaThresholdDesc',
'Percentage below which to trigger an alert',
)}
{t('settings.notifications.quotaThresholdDesc')}
</p>
</div>
<div className="flex items-center gap-2">
@@ -363,10 +317,10 @@ function SettingsPage() {
if (config) {
try {
await saveConfig({ ...config, quota_alert_threshold: parsed });
} catch (err) {
} catch {
toast({
title: t('common.error'),
description: 'Failed to save threshold setting',
description: t('settings.notifications.thresholdSaveFailed'),
variant: 'destructive',
});
}
+7 -5
View File
@@ -1,9 +1,11 @@
import { isEmpty, isString } from 'lodash-es';
export type RequestHeaderValue = string | string[] | undefined;
export type RequestHeaders = Record<string, RequestHeaderValue>;
export function hasConfiguredApiKey(apiKey: string | undefined): apiKey is string {
return typeof apiKey === 'string' && apiKey.trim() !== '';
return isString(apiKey) && !isEmpty(apiKey.trim());
}
export function extractApiKeyToken(headers: RequestHeaders): string | null {
@@ -29,16 +31,16 @@ export function extractApiKeyToken(headers: RequestHeaders): string | null {
}
function readHeaderValue(headerValue: RequestHeaderValue): string | null {
if (typeof headerValue === 'string') {
if (isString(headerValue)) {
const trimmedValue = headerValue.trim();
return trimmedValue !== '' ? trimmedValue : null;
return !isEmpty(trimmedValue) ? trimmedValue : null;
}
if (Array.isArray(headerValue)) {
for (const value of headerValue) {
if (typeof value === 'string') {
if (isString(value)) {
const trimmedValue = value.trim();
if (trimmedValue !== '') {
if (!isEmpty(trimmedValue)) {
return trimmedValue;
}
}
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { AxiosProxyConfig, AxiosRequestConfig, AxiosResponse } from 'axios';
import { isNil } from 'lodash-es';
import { isEmpty, isFunction, isNil, isObjectLike, isString } from 'lodash-es';
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
import { GeminiInternalRequest } from '../../../../lib/antigravity/types';
import { getServerConfig } from '../../../server-config';
@@ -131,10 +131,10 @@ export class GeminiClient {
extraHeaders,
);
const payload = response.data;
if (payload && typeof payload === 'object' && 'response' in payload) {
return payload.response;
if (isObjectLike(payload) && 'response' in payload) {
return (payload as { response: GeminiResponse }).response;
}
return payload;
return payload as GeminiResponse;
}
private getInternalBaseUrls(): string[] {
@@ -255,7 +255,7 @@ export class GeminiClient {
return fromObject;
}
if (typeof responseData === 'string') {
if (isString(responseData)) {
return this.extractAxiosErrorMessageFromText(responseData);
}
@@ -272,20 +272,20 @@ export class GeminiClient {
}
private extractAxiosErrorMessageFromObject(responseData: unknown): string | null {
if (!responseData || typeof responseData !== 'object' || this.isReadableStream(responseData)) {
if (!isObjectLike(responseData) || this.isReadableStream(responseData)) {
return null;
}
const errorRecord = (responseData as { error?: unknown }).error;
if (errorRecord && typeof errorRecord === 'object') {
if (isObjectLike(errorRecord)) {
const message = (errorRecord as { message?: unknown }).message;
if (typeof message === 'string' && message.trim() !== '') {
if (isString(message) && !isEmpty(message.trim())) {
return message.trim();
}
}
const message = (responseData as { message?: unknown }).message;
if (typeof message === 'string' && message.trim() !== '') {
if (isString(message) && !isEmpty(message.trim())) {
return message.trim();
}
@@ -333,11 +333,7 @@ export class GeminiClient {
}
private isReadableStream(value: unknown): value is NodeJS.ReadableStream {
return (
Boolean(value) &&
typeof value === 'object' &&
typeof (value as { pipe?: unknown }).pipe === 'function'
);
return isObjectLike(value) && isFunction((value as { pipe?: unknown }).pipe);
}
private async readStreamAsText(stream: NodeJS.ReadableStream): Promise<string | null> {
@@ -407,17 +403,17 @@ export class GeminiClient {
}
private extractRetryAfterHeader(headers: unknown): string | undefined {
if (!headers || typeof headers !== 'object') {
if (!isObjectLike(headers)) {
return undefined;
}
const retryAfter = (headers as Record<string, unknown>)['retry-after'];
if (typeof retryAfter === 'string' && retryAfter.trim() !== '') {
if (isString(retryAfter) && !isEmpty(retryAfter.trim())) {
return retryAfter.trim();
}
if (Array.isArray(retryAfter) && retryAfter.length > 0) {
const first = retryAfter[0];
if (typeof first === 'string' && first.trim() !== '') {
if (isString(first) && !isEmpty(first.trim())) {
return first.trim();
}
}
@@ -439,7 +435,7 @@ export class GeminiClient {
try {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (isObjectLike(value)) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
@@ -1,3 +1,5 @@
import { isNumber, isObjectLike, isString } from 'lodash-es';
export interface UpstreamErrorHeaders {
retryAfter?: string;
}
@@ -16,10 +18,10 @@ export class UpstreamRequestError extends Error {
super(params.message);
this.name = 'UpstreamRequestError';
if (params.status && typeof params.status !== 'number') {
if (params.status && !isNumber(params.status)) {
throw new TypeError('status must be a number');
}
if (params.headers && typeof params.headers !== 'object') {
if (params.headers && !isObjectLike(params.headers)) {
throw new TypeError('headers must be an object');
}
@@ -27,7 +29,7 @@ export class UpstreamRequestError extends Error {
this.headers = params.headers;
// Sanitize and limit body size
if (typeof params.body === 'string') {
if (isString(params.body)) {
const sanitized = params.body.replace(/<[^>]*>?/gm, '');
this.body = sanitized.substring(0, 1000);
} else {
@@ -11,7 +11,7 @@ import {
Optional,
} from '@nestjs/common';
import { FastifyReply } from 'fastify';
import { isEmpty } from 'lodash-es';
import { isEmpty, isFunction, isNumber, isString } from 'lodash-es';
import { Observable } from 'rxjs';
import { ProxyGuard } from './proxy.guard';
@@ -198,7 +198,7 @@ export class GeminiController {
const candidates = (response.candidates ?? []).map((candidate, index) => ({
content: candidate.content,
finishReason: candidate.finishReason,
index: typeof candidate.index === 'number' ? candidate.index : index,
index: isNumber(candidate.index) ? candidate.index : index,
}));
const normalized: GeminiResponse = {
@@ -246,8 +246,8 @@ export class GeminiController {
private writeObservableSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
if (
!res.raw ||
typeof res.raw.writeHead !== 'function' ||
typeof res.raw.write !== 'function'
!isFunction(res.raw.writeHead) ||
!isFunction(res.raw.write)
) {
res.header('Content-Type', 'text/event-stream');
res.header('Cache-Control', 'no-cache');
@@ -271,7 +271,7 @@ export class GeminiController {
if (res.raw.writableEnded) {
return;
}
const payload = typeof chunk === 'string' ? chunk : String(chunk ?? '');
const payload = isString(chunk) ? chunk : String(chunk ?? '');
res.raw.write(payload);
},
error: (error) => {
@@ -302,6 +302,6 @@ export class GeminiController {
}
private supportsReplyHijack(reply: FastifyReply): reply is FastifyReply & { hijack: () => void } {
return typeof (reply as { hijack?: unknown }).hijack === 'function';
return isFunction((reply as { hijack?: unknown }).hijack);
}
}
+13 -17
View File
@@ -12,7 +12,7 @@ import {
Optional,
} from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
import { isNil, isPlainObject, isString } from 'lodash-es';
import { isEmpty, isFunction, isNil, isObjectLike, isPlainObject, isString } from 'lodash-es';
import { ProxyService } from './proxy.service';
import { Observable } from 'rxjs';
import {
@@ -339,7 +339,7 @@ export class ProxyController {
private toLegacyTextCompletionsResponse(response: OpenAIChatResponse): Record<string, unknown> {
const choice = response.choices?.[0];
const content = choice?.message?.content;
const text = typeof content === 'string' ? content : '';
const text = isString(content) ? content : '';
return {
id: response.id,
@@ -397,7 +397,7 @@ export class ProxyController {
stream?: boolean;
}): OpenAIChatRequest {
const messages: OpenAIChatRequest['messages'] = [];
if (body.instructions && body.instructions.trim() !== '') {
if (isString(body.instructions) && !isEmpty(body.instructions.trim())) {
messages.push({
role: 'system',
content: body.instructions,
@@ -484,7 +484,7 @@ export class ProxyController {
continue;
}
}
} else if (typeof body.input === 'string') {
} else if (isString(body.input)) {
messages.push({
role: 'user',
content: body.input,
@@ -716,18 +716,14 @@ export class ProxyController {
}
private isObservableLike(value: unknown): value is Observable<unknown> {
return (
Boolean(value) &&
typeof value === 'object' &&
typeof (value as { subscribe?: unknown }).subscribe === 'function'
);
return isObjectLike(value) && isFunction((value as { subscribe?: unknown }).subscribe);
}
private writeSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
if (
!res.raw ||
typeof res.raw.writeHead !== 'function' ||
typeof res.raw.write !== 'function'
!isFunction(res.raw.writeHead) ||
!isFunction(res.raw.write)
) {
res.header('Content-Type', 'text/event-stream');
res.header('Cache-Control', 'no-cache');
@@ -736,7 +732,7 @@ export class ProxyController {
return;
}
if (typeof (res as { hijack?: () => void }).hijack === 'function') {
if (isFunction((res as { hijack?: () => void }).hijack)) {
(res as { hijack: () => void }).hijack();
}
@@ -751,7 +747,7 @@ export class ProxyController {
if (res.raw.writableEnded) {
return;
}
const payload = typeof chunk === 'string' ? chunk : String(chunk ?? '');
const payload = isString(chunk) ? chunk : String(chunk ?? '');
res.raw.write(payload);
},
error: (error) => {
@@ -804,7 +800,7 @@ export class ProxyController {
}
const content = result.choices?.[0]?.message?.content;
const image = this.extractInlineBase64Image(typeof content === 'string' ? content : '');
const image = this.extractInlineBase64Image(isString(content) ? content : '');
if (!image) {
this.logProxyEndpointError(
'/v1/images/generations',
@@ -908,7 +904,7 @@ export class ProxyController {
if (!userMessage) {
parts.push({ text: fallbackPrompt || 'Please generate an image based on this request.' });
} else if (typeof userMessage.content === 'string') {
} else if (isString(userMessage.content)) {
parts.push({
text:
userMessage.content ||
@@ -917,7 +913,7 @@ export class ProxyController {
});
} else {
for (const block of userMessage.content) {
if (block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
if (block.type === 'text' && isString(block.text) && !isEmpty(block.text.trim())) {
textParts.push(block.text);
}
if (block.type === 'image_url') {
@@ -964,7 +960,7 @@ export class ProxyController {
private hasMultipartBoundary(req: FastifyRequest): boolean {
const contentType = req.headers['content-type'];
if (typeof contentType !== 'string') {
if (!isString(contentType)) {
return false;
}
+20 -24
View File
@@ -1,5 +1,5 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { isEmpty, isNil, isPlainObject, isString } from 'lodash-es';
import { isEmpty, isNil, isNumber, isPlainObject, isString } from 'lodash-es';
import { TokenManagerService } from './token-manager.service';
import { GeminiClient } from './clients/gemini.client';
import { v4 as uuidv4 } from 'uuid';
@@ -511,7 +511,7 @@ export class ProxyService {
private getModelOutputCap(accountId: string, model: string): number {
const normalizedModel = this.normalizeModelIdentifier(model);
const dynamicCap = this.tokenManager.getModelOutputLimitForAccount(accountId, normalizedModel);
if (typeof dynamicCap === 'number' && Number.isFinite(dynamicCap) && dynamicCap > 0) {
if (isNumber(dynamicCap) && Number.isFinite(dynamicCap) && dynamicCap > 0) {
return Math.floor(dynamicCap);
}
return getMaxOutputTokens(normalizedModel);
@@ -523,7 +523,7 @@ export class ProxyService {
accountId,
normalizedModel,
);
if (typeof dynamicBudget === 'number' && Number.isFinite(dynamicBudget) && dynamicBudget >= 0) {
if (isNumber(dynamicBudget) && Number.isFinite(dynamicBudget) && dynamicBudget >= 0) {
return Math.floor(dynamicBudget);
}
return getThinkingBudget(normalizedModel);
@@ -548,12 +548,12 @@ export class ProxyService {
| undefined;
const adaptiveSentinel =
thinkingConfig &&
(typeof thinkingConfig.thinkingLevel === 'string' ||
(isString(thinkingConfig.thinkingLevel) ||
thinkingConfig.thinkingBudget === -1 ||
thinkingConfig.thinkingBudget === 32768);
if (thinkingConfig) {
if (!isClaudeModel && typeof thinkingConfig.thinkingLevel === 'string') {
if (!isClaudeModel && isString(thinkingConfig.thinkingLevel)) {
const converted = this.resolveThinkingLevelBudget(thinkingConfig.thinkingLevel);
if (converted !== undefined) {
thinkingConfig.thinkingBudget = converted;
@@ -561,12 +561,12 @@ export class ProxyService {
delete thinkingConfig.thinkingLevel;
}
if (typeof thinkingConfig.thinkingBudget === 'number' && thinkingConfig.thinkingBudget < 0) {
if (isNumber(thinkingConfig.thinkingBudget) && thinkingConfig.thinkingBudget < 0) {
thinkingConfig.thinkingBudget = Math.min(thinkingBudgetCap, 24576);
}
if (
typeof thinkingConfig.thinkingBudget === 'number' &&
isNumber(thinkingConfig.thinkingBudget) &&
Number.isFinite(thinkingConfig.thinkingBudget)
) {
thinkingConfig.thinkingBudget = Math.min(
@@ -595,7 +595,7 @@ export class ProxyService {
}
if (
typeof generationConfig.maxOutputTokens === 'number' &&
isNumber(generationConfig.maxOutputTokens) &&
Number.isFinite(generationConfig.maxOutputTokens)
) {
generationConfig.maxOutputTokens = Math.min(
@@ -634,7 +634,7 @@ export class ProxyService {
? response.candidates.map((candidate, index) => ({
content: candidate?.content,
finishReason: candidate?.finishReason,
index: typeof candidate?.index === 'number' ? candidate.index : index,
index: isNumber(candidate?.index) ? candidate.index : index,
}))
: [];
@@ -1115,7 +1115,7 @@ export class ProxyService {
const choice = response.choices?.[0];
const finishReason = choice?.finish_reason ?? 'stop';
const content =
choice?.message && typeof choice.message.content === 'string' ? choice.message.content : '';
choice?.message && isString(choice.message.content) ? choice.message.content : '';
const chunkSize = 80;
if (content.length === 0) {
@@ -1216,7 +1216,7 @@ export class ProxyService {
systemInstruction: request.systemInstruction
? {
parts: request.systemInstruction.parts
.filter((part): part is { text: string } => typeof part.text === 'string')
.filter((part): part is { text: string } => isString(part.text))
.map((part) => ({ text: part.text })),
}
: undefined,
@@ -1294,7 +1294,7 @@ export class ProxyService {
private convertOpenAIPartsToAnthropicContent(
content: OpenAIChatRequest['messages'][number]['content'],
): AnthropicContent[] {
if (typeof content === 'string') {
if (isString(content)) {
return content.trim() ? [{ type: 'text', text: content }] : [];
}
@@ -1328,7 +1328,7 @@ export class ProxyService {
private extractOpenAITextContent(
content: OpenAIChatRequest['messages'][number]['content'],
): string {
if (typeof content === 'string') {
if (isString(content)) {
return content;
}
@@ -1339,14 +1339,14 @@ export class ProxyService {
}
private parseOpenAIFunctionArguments(argumentsString: string): Record<string, unknown> {
if (!argumentsString || argumentsString.trim() === '') {
if (isEmpty(argumentsString.trim())) {
return {};
}
try {
const parsed = JSON.parse(argumentsString);
if (this.isRecord(parsed)) {
return parsed;
if (isPlainObject(parsed)) {
return parsed as Record<string, unknown>;
}
return { value: parsed };
} catch {
@@ -1446,7 +1446,7 @@ export class ProxyService {
}
private normalizeToolCallArguments(input: unknown): string {
if (typeof input === 'string') {
if (isString(input)) {
return input;
}
if (isNil(input)) {
@@ -1730,7 +1730,7 @@ export class ProxyService {
const metadata = request.metadata;
const sessionCandidate =
metadata?.session_id ?? metadata?.sessionId ?? metadata?.user_id ?? metadata?.userId;
if (!isString(sessionCandidate) || sessionCandidate.trim() === '') {
if (!isString(sessionCandidate) || isEmpty(sessionCandidate.trim())) {
return undefined;
}
return `anthropic:${sessionCandidate.trim()}`;
@@ -1740,17 +1740,13 @@ export class ProxyService {
const extra = request.extra;
const sessionCandidate =
extra?.session_id ?? extra?.sessionId ?? extra?.user_id ?? extra?.userId;
if (!isString(sessionCandidate) || sessionCandidate.trim() === '') {
if (!isString(sessionCandidate) || isEmpty(sessionCandidate.trim())) {
return undefined;
}
return `openai:${sessionCandidate.trim()}`;
}
private isRecord(value: unknown): value is Record<string, unknown> {
private isGeminiPart(value: unknown): value is InternalGeminiPart {
return isPlainObject(value);
}
private isGeminiPart(value: unknown): value is InternalGeminiPart {
return this.isRecord(value);
}
}
+12 -10
View File
@@ -1,3 +1,5 @@
import { isEmpty, isNumber, isObjectLike, isString } from 'lodash-es';
export enum RateLimitReason {
QuotaExhausted = 'quota_exhausted',
RateLimitExceeded = 'rate_limit_exceeded',
@@ -68,7 +70,7 @@ function tryParseGoogleErrorBody(body: string | undefined): ParsedGoogleErrorBod
try {
const parsed = JSON.parse(trimmed) as ParsedGoogleErrorBody;
if (!parsed || typeof parsed !== 'object') {
if (!isObjectLike(parsed)) {
return null;
}
return parsed;
@@ -96,7 +98,7 @@ export class RateLimitTracker {
private readonly failureCounts = new Map<string, FailureCountEntry>();
private buildLockoutKey(accountId: string, model?: string): string {
if (model && model.trim() !== '') {
if (!isEmpty(model?.trim() ?? '')) {
return `${accountId}:${model}`;
}
return accountId;
@@ -150,7 +152,7 @@ export class RateLimitTracker {
model?: string,
): void {
const retryAfterSec = Math.max(2, Math.ceil((resetTimeMs - Date.now()) / 1000));
const key = model && model.trim() !== '' ? this.buildLockoutKey(accountId, model) : accountId;
const key = !isEmpty(model?.trim() ?? '') ? this.buildLockoutKey(accountId, model) : accountId;
this.lockoutByKey.set(key, {
resetTimeMs,
retryAfterSec,
@@ -240,7 +242,7 @@ export class RateLimitTracker {
const parsedBody = tryParseGoogleErrorBody(body);
const details = Array.isArray(parsedBody?.error?.details) ? parsedBody?.error?.details : [];
for (const detail of details) {
if (typeof detail.reason !== 'string' || detail.reason.trim() === '') {
if (!isString(detail.reason) || isEmpty(detail.reason.trim())) {
continue;
}
const mappedReason = mapGoogleReasonToTrackerReason(detail.reason);
@@ -354,7 +356,7 @@ export class RateLimitTracker {
const parsedBody = tryParseGoogleErrorBody(body);
const details = Array.isArray(parsedBody?.error?.details) ? parsedBody.error.details : [];
for (const detail of details) {
if (typeof detail.retryDelay === 'string' && detail.retryDelay.trim() !== '') {
if (isString(detail.retryDelay) && !isEmpty(detail.retryDelay.trim())) {
const parsedDelay = parseDurationToSeconds(detail.retryDelay);
if (parsedDelay !== null) {
return parsedDelay;
@@ -362,8 +364,8 @@ export class RateLimitTracker {
}
if (
typeof detail.metadata?.retryDelay === 'string' &&
detail.metadata.retryDelay.trim() !== ''
isString(detail.metadata?.retryDelay) &&
!isEmpty(detail.metadata.retryDelay.trim())
) {
const parsedDelay = parseDurationToSeconds(detail.metadata.retryDelay);
if (parsedDelay !== null) {
@@ -372,8 +374,8 @@ export class RateLimitTracker {
}
if (
typeof detail.metadata?.quotaResetDelay === 'string' &&
detail.metadata.quotaResetDelay.trim() !== ''
isString(detail.metadata?.quotaResetDelay) &&
!isEmpty(detail.metadata.quotaResetDelay.trim())
) {
const parsedDelay = parseDurationToSeconds(detail.metadata.quotaResetDelay);
if (parsedDelay !== null) {
@@ -383,7 +385,7 @@ export class RateLimitTracker {
}
const retryAfter = parsedBody?.error?.retry_after;
if (typeof retryAfter === 'number' && retryAfter > 0) {
if (isNumber(retryAfter) && retryAfter > 0) {
return Math.ceil(retryAfter);
}
@@ -1,4 +1,5 @@
import axios from 'axios';
import { isString } from 'lodash-es';
import { getAntigravityVersion } from '../../../utils/antigravityVersion';
import { logger } from '../../../utils/logger';
@@ -20,7 +21,7 @@ let cachedUserAgentResolution: UserAgentResolution | null = null;
let pendingUserAgentResolution: Promise<UserAgentResolution> | null = null;
function normalizeNonEmptyString(value: string | null | undefined): string | null {
if (typeof value !== 'string') {
if (!isString(value)) {
return null;
}
@@ -125,7 +126,7 @@ async function fetchTextPayload(url: string): Promise<string | null> {
},
});
if (typeof response.data === 'string') {
if (isString(response.data)) {
return response.data;
}
@@ -1,4 +1,5 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { isEmpty, isNumber, isString } from 'lodash-es';
import { CloudAccountRepo } from '../../../ipc/database/cloudHandler';
import { CloudAccount, CloudQuotaData } from '../../../types/cloudAccount';
import { GoogleAPIService } from '../../../services/GoogleAPIService';
@@ -36,7 +37,7 @@ interface GetNextTokenOptions {
type TokenEntry = [string, TokenData];
function normalizeProjectId(projectId: string | null | undefined): string | undefined {
if (typeof projectId !== 'string') {
if (!isString(projectId)) {
return undefined;
}
@@ -53,7 +54,7 @@ function normalizeProjectId(projectId: string | null | undefined): string | unde
}
function normalizeModelId(modelId: string | null | undefined): string | undefined {
if (typeof modelId !== 'string') {
if (!isString(modelId)) {
return undefined;
}
const normalized = modelId.replace(/^models\//i, '').trim();
@@ -61,7 +62,7 @@ function normalizeModelId(modelId: string | null | undefined): string | undefine
}
function normalizeClientKey(clientKey: string | undefined): string | undefined {
if (typeof clientKey !== 'string') {
if (!isString(clientKey)) {
return undefined;
}
const normalized = clientKey.trim().toLowerCase();
@@ -190,7 +191,7 @@ export class TokenManagerService implements OnModuleInit {
const accountId = this.resolveAccountId(params.accountIdOrEmail) ?? params.accountIdOrEmail;
const normalizedModel = normalizeModelId(params.model);
const hasExplicitRetryWindow =
Boolean(params.retryAfter && params.retryAfter.trim() !== '') ||
Boolean(isString(params.retryAfter) && !isEmpty(params.retryAfter.trim())) ||
Boolean(params.body && params.body.includes('quotaResetDelay'));
if (!hasExplicitRetryWindow && (params.status ?? 0) === 429) {
@@ -215,7 +216,7 @@ export class TokenManagerService implements OnModuleInit {
if (
parsed.reason !== RateLimitReason.QuotaExhausted ||
!parsed.model ||
parsed.model.trim() === ''
isEmpty(parsed.model.trim())
) {
this.accountCooldowns.set(accountId, Date.now() + parsed.retryAfterSec * 1000);
}
@@ -258,7 +259,7 @@ export class TokenManagerService implements OnModuleInit {
if (
parsed.reason !== RateLimitReason.QuotaExhausted ||
!parsed.model ||
parsed.model.trim() === ''
isEmpty(parsed.model.trim())
) {
this.accountCooldowns.set(accountId, Date.now() + parsed.retryAfterSec * 1000);
}
@@ -661,14 +662,14 @@ export class TokenManagerService implements OnModuleInit {
const limitCandidate = modelInfo.max_output_tokens ?? modelInfo.max_tokens;
if (
typeof limitCandidate === 'number' &&
isNumber(limitCandidate) &&
Number.isFinite(limitCandidate) &&
limitCandidate > 0
) {
modelLimits[normalizedModel] = Math.floor(limitCandidate);
}
if (typeof modelInfo.resetTime === 'string' && modelInfo.resetTime.trim() !== '') {
if (isString(modelInfo.resetTime) && !isEmpty(modelInfo.resetTime.trim())) {
modelResetTimes[normalizedModel] = modelInfo.resetTime;
}
}
@@ -692,7 +693,7 @@ export class TokenManagerService implements OnModuleInit {
}
private findEarliestQuotaResetTime(modelResetTimes: Record<string, string>): string | null {
const validTimes = Object.values(modelResetTimes).filter((value) => value.trim() !== '');
const validTimes = Object.values(modelResetTimes).filter((value) => !isEmpty(value.trim()));
if (validTimes.length === 0) {
return null;
}
@@ -1084,7 +1085,7 @@ export class TokenManagerService implements OnModuleInit {
continue;
}
const budget = modelInfo?.thinking_budget;
if (typeof budget === 'number' && Number.isFinite(budget) && budget >= 0) {
if (isNumber(budget) && Number.isFinite(budget) && budget >= 0) {
return Math.floor(budget);
}
}
+57 -4
View File
@@ -5,6 +5,47 @@ import { AutoSwitchService } from './AutoSwitchService';
import { logger } from '../utils/logger';
import { classifyAccountStatusFromError } from '../utils/account-status';
type CloudMonitorLanguage = 'en' | 'zh-CN' | 'ru' | 'vi';
const CLOUD_MONITOR_NOTIFICATION_TEXT: Record<
CloudMonitorLanguage,
{
lowQuotaTitle: string;
lowQuotaBody: (email: string, models: string) => string;
}
> = {
en: {
lowQuotaTitle: 'Low Quota Alert',
lowQuotaBody: (email, models) => `${email}: ${models} are low on quota`,
},
'zh-CN': {
lowQuotaTitle: '额度不足提醒',
lowQuotaBody: (email, models) => `${email}:${models} 的额度较低`,
},
ru: {
lowQuotaTitle: 'Предупреждение о низкой квоте',
lowQuotaBody: (email, models) => `${email}: низкая квота у ${models}`,
},
vi: {
lowQuotaTitle: 'Cảnh báo quota thấp',
lowQuotaBody: (email, models) => `${email}: ${models} đang có quota thấp`,
},
};
function getCloudMonitorLanguage(language: string | null | undefined): CloudMonitorLanguage {
const normalizedLanguage = language?.toLowerCase() ?? 'en';
if (normalizedLanguage.startsWith('zh')) {
return 'zh-CN';
}
if (normalizedLanguage.startsWith('ru')) {
return 'ru';
}
if (normalizedLanguage.startsWith('vi')) {
return 'vi';
}
return 'en';
}
function hasReusableCachedQuota(account: {
quota?: { models?: Record<string, unknown> };
}): boolean {
@@ -131,7 +172,11 @@ export class CloudMonitorService {
logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError);
const classified = classifyAccountStatusFromError(refreshError);
if (classified) {
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
await CloudAccountRepo.setAccountStatus(
account.id,
classified.status,
classified.reason,
);
}
continue;
}
@@ -163,7 +208,11 @@ export class CloudMonitorService {
logger.error(`Monitor: Failed to update ${account.email}`, error);
const classified = classifyAccountStatusFromError(error);
if (classified) {
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
await CloudAccountRepo.setAccountStatus(
account.id,
classified.status,
classified.reason,
);
if (classified.status === 'rate_limited' && hasReusableCachedQuota(account)) {
logger.warn(
`Monitor: Quota request rate-limited for ${account.email}, keeping cached quota as fallback.`,
@@ -176,6 +225,10 @@ export class CloudMonitorService {
// 4. Check for Quota Alerts
const alertEnabled = CloudAccountRepo.getSetting<boolean>('quota_alert_enabled', false);
const alertThreshold = CloudAccountRepo.getSetting<number>('quota_alert_threshold', 20);
const notificationLanguage = getCloudMonitorLanguage(
CloudAccountRepo.getSetting<string>('language', 'en'),
);
const notificationText = CLOUD_MONITOR_NOTIFICATION_TEXT[notificationLanguage];
if (alertEnabled) {
for (const account of accounts) {
@@ -188,8 +241,8 @@ export class CloudMonitorService {
if (lowQuotaModels.length > 0) {
new Notification({
title: 'Low Quota Alert',
body: `${account.email}: ${lowQuotaModels.join(', ')} are low on quota`,
title: notificationText.lowQuotaTitle,
body: notificationText.lowQuotaBody(account.email, lowQuotaModels.join(', ')),
silent: false,
}).show();
}
+16 -19
View File
@@ -8,7 +8,7 @@ import {
FALLBACK_VERSION,
resolveLocalInstalledVersion,
} from '@/server/modules/proxy/request-user-agent';
import { isNumber, isString } from 'lodash-es';
import { isEmpty, isNumber, isString, isUndefined } from 'lodash-es';
// --- Constants & Config ---
@@ -33,9 +33,6 @@ const QUOTA_API_ENDPOINTS = [
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
] as const;
// Internal API masquerading
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
// Request timeout in milliseconds (30 seconds)
const REQUEST_TIMEOUT_MS = 30000;
@@ -81,7 +78,7 @@ function buildOAuthClientRegistry(): OAuthClientRegistry {
];
const rawExtraClients = process.env[OAUTH_CLIENTS_ENV];
if (isString(rawExtraClients) && rawExtraClients.trim() !== '') {
if (isString(rawExtraClients) && !isEmpty(rawExtraClients.trim())) {
for (const entry of rawExtraClients.split(';')) {
const trimmed = entry.trim();
if (trimmed === '') {
@@ -180,7 +177,7 @@ function selectAuthClient(clientKey?: string): OAuthClientConfig {
throw new Error('No OAuth clients configured');
}
if (clientKey && clientKey.trim() !== '') {
if (isString(clientKey) && !isEmpty(clientKey.trim())) {
const selected = getClientByKey(registry.clients, clientKey);
if (!selected) {
throw new Error(`Unknown OAuth client key: ${clientKey}`);
@@ -345,20 +342,20 @@ function buildInternalApiHeaders(accessToken: string): Record<string, string> {
function resolveSubscriptionTier(payload: LoadProjectResponse): string | undefined {
const paidTier = payload.paidTier;
if (paidTier?.name && paidTier.name.trim() !== '') {
if (isString(paidTier?.name) && !isEmpty(paidTier.name.trim())) {
return paidTier.name;
}
if (paidTier?.id && paidTier.id.trim() !== '') {
if (isString(paidTier?.id) && !isEmpty(paidTier.id.trim())) {
return paidTier.id;
}
const ineligible = Array.isArray(payload.ineligibleTiers) && payload.ineligibleTiers.length > 0;
if (!ineligible) {
const currentTier = payload.currentTier;
if (currentTier?.name && currentTier.name.trim() !== '') {
if (isString(currentTier?.name) && !isEmpty(currentTier.name.trim())) {
return currentTier.name;
}
if (currentTier?.id && currentTier.id.trim() !== '') {
if (isString(currentTier?.id) && !isEmpty(currentTier.id.trim())) {
return currentTier.id;
}
}
@@ -366,10 +363,10 @@ function resolveSubscriptionTier(payload: LoadProjectResponse): string | undefin
if (Array.isArray(payload.allowedTiers)) {
const preferredAllowedTier =
payload.allowedTiers.find((tier) => tier.is_default === true) ?? payload.allowedTiers[0];
if (preferredAllowedTier?.name && preferredAllowedTier.name.trim() !== '') {
if (isString(preferredAllowedTier?.name) && !isEmpty(preferredAllowedTier.name.trim())) {
return ineligible ? `${preferredAllowedTier.name} (Restricted)` : preferredAllowedTier.name;
}
if (preferredAllowedTier?.id && preferredAllowedTier.id.trim() !== '') {
if (isString(preferredAllowedTier?.id) && !isEmpty(preferredAllowedTier.id.trim())) {
return ineligible ? `${preferredAllowedTier.id} (Restricted)` : preferredAllowedTier.id;
}
}
@@ -410,7 +407,7 @@ function toModelForwardingRules(
const forwardingRules: Record<string, string> = {};
for (const [oldModelId, deprecatedInfo] of Object.entries(deprecatedModelIds)) {
if (typeof deprecatedInfo.newModelId === 'string' && deprecatedInfo.newModelId !== '') {
if (isString(deprecatedInfo.newModelId) && deprecatedInfo.newModelId !== '') {
forwardingRules[oldModelId] = deprecatedInfo.newModelId;
}
}
@@ -423,7 +420,7 @@ function parseCreditAmount(value: string | number | undefined): number {
return value;
}
if (typeof value === 'string') {
if (isString(value)) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return parsed;
@@ -448,7 +445,7 @@ function toAiCredits(
? payload.remainingCredits
: undefined;
if (typeof creditsValue === 'undefined') {
if (isUndefined(creditsValue)) {
return null;
}
@@ -517,10 +514,10 @@ export class GoogleAPIService {
): string | undefined {
const resolved = refreshedClientKey ?? currentToken.oauth_client_key;
const projectMissing =
typeof currentToken.project_id !== 'string' || currentToken.project_id.trim() === '';
!isString(currentToken.project_id) || isEmpty(currentToken.project_id.trim());
if (
typeof currentToken.oauth_client_key !== 'string' &&
!isString(currentToken.oauth_client_key) &&
projectMissing &&
resolved &&
normalizeClientKey(resolved) === DEFAULT_OAUTH_CLIENT_KEY
@@ -812,7 +809,7 @@ export class GoogleAPIService {
if (response.ok) {
const data = (await response.json()) as LoadProjectResponse;
if (typeof data.cloudaicompanionProject === 'string') {
if (isString(data.cloudaicompanionProject)) {
projectId = data.cloudaicompanionProject;
}
subscriptionTier = resolveSubscriptionTier(data);
@@ -898,7 +895,7 @@ export class GoogleAPIService {
const fallbackData = (await fallbackResponse.json()) as LoadProjectResponse;
return extractAiCreditsFromProjectContext(fallbackData);
} catch (e) {
} catch {
return null;
}
}
+3 -2
View File
@@ -6,17 +6,18 @@ const injectCloudAccountsFailureScript = `
(() => {
const START_ORPC_SERVER = 'start-orpc-server';
const originalPostMessage = window.postMessage.bind(window);
const isStringValue = (value) => Object.prototype.toString.call(value) === '[object String]';
window.postMessage = function (message, targetOrigin, transfer) {
if (message === START_ORPC_SERVER && Array.isArray(transfer) && transfer[0]) {
const serverPort = transfer[0];
if (typeof serverPort.start === 'function') {
if (serverPort.start instanceof Function) {
serverPort.start();
}
serverPort.onmessage = (event) => {
try {
const request = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
const request = isStringValue(event.data) ? JSON.parse(event.data) : event.data;
const requestId = request && (request.i || request.id);
const requestUrl = (request && request.p && request.p.u) || '';
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { isEmpty, isString } from 'lodash-es';
import { transformClaudeRequestIn } from '../../lib/antigravity/ClaudeRequestMapper';
import { ProxyService } from '../../server/modules/proxy/proxy.service';
@@ -34,7 +35,7 @@ class TestableProxyService extends ProxyService {
function getEnv(name: string, fallback?: string): string {
const value = process.env[name];
if (value && value.trim() !== '') {
if (isString(value) && !isEmpty(value.trim())) {
return value;
}
if (fallback !== undefined) {
@@ -46,7 +47,7 @@ function getEnv(name: string, fallback?: string): string {
function getEnvFromList(names: string[], fallback?: string): string {
for (const name of names) {
const value = process.env[name];
if (value && value.trim() !== '') {
if (isString(value) && !isEmpty(value.trim())) {
return value;
}
}
@@ -60,7 +61,7 @@ function getLiveHeaders(apiKey?: string): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey && apiKey.trim() !== '') {
if (isString(apiKey) && !isEmpty(apiKey.trim())) {
headers.Authorization = apiKey.startsWith('Bearer ') ? apiKey : `Bearer ${apiKey}`;
}
return headers;
@@ -271,7 +272,7 @@ 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() !== ''
isString(tokenProjectId) && !isEmpty(tokenProjectId.trim())
? tokenProjectId.trim()
: undefined;
+3 -2
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { isString } from 'lodash-es';
// Mock module to test timeout behavior
describe('GoogleAPIService Timeout', () => {
@@ -92,13 +93,13 @@ describe('GoogleAPIService OAuth clients', () => {
});
afterEach(() => {
if (typeof originalOauthClientsEnv === 'string') {
if (isString(originalOauthClientsEnv)) {
process.env.ANTIGRAVITY_OAUTH_CLIENTS = originalOauthClientsEnv;
} else {
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
}
if (typeof originalActiveOauthClientEnv === 'string') {
if (isString(originalActiveOauthClientEnv)) {
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = originalActiveOauthClientEnv;
} else {
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
+2 -1
View File
@@ -1,11 +1,12 @@
import '@testing-library/jest-dom';
import * as matchers from '@testing-library/jest-dom/matchers';
import { isUndefined } from 'lodash-es';
import { expect, vi } from 'vitest';
expect.extend(matchers);
// Mock window and localStorage for Node environment
if (typeof window === 'undefined') {
if (isUndefined((globalThis as { window?: unknown }).window)) {
global.window = {
matchMedia: vi.fn().mockImplementation((query) => ({
matches: false,
+3 -1
View File
@@ -1,3 +1,5 @@
import { isString } from 'lodash-es';
export const RATE_LIMIT_HINTS = [
'resource_exhausted',
'too many requests',
@@ -40,7 +42,7 @@ export function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
if (isString(error)) {
return error;
}
return String(error);
+2 -1
View File
@@ -1,4 +1,5 @@
import type { TFunction } from 'i18next';
import { isObjectLike } from 'lodash-es';
const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE';
const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION';
@@ -60,7 +61,7 @@ export function getLocalizedErrorMessage(error: unknown, t: TFunction): string {
return rawMessage;
}
if (error && typeof error === 'object' && 'message' in error) {
if (isObjectLike(error)) {
const rawMessage = String((error as { message?: unknown }).message ?? '');
const [code, hint] = rawMessage.split('|');
if (code === KEYCHAIN_ERROR_CODE) {
+3 -1
View File
@@ -1,3 +1,5 @@
import { isEmpty } from 'lodash-es';
export function shouldAutoSubmitGoogleAuthCode(params: {
authCode: string;
isAddDialogOpen: boolean;
@@ -5,7 +7,7 @@ export function shouldAutoSubmitGoogleAuthCode(params: {
lastSubmittedAuthCode: string | null;
}): boolean {
return (
params.authCode.trim() !== '' &&
!isEmpty(params.authCode.trim()) &&
params.isAddDialogOpen &&
!params.isPending &&
params.authCode !== params.lastSubmittedAuthCode
+12 -1
View File
@@ -1,6 +1,6 @@
import path from 'path';
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru';
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru' | 'vi';
const installNoticeText: Record<
InstallNoticeLanguage,
@@ -32,6 +32,13 @@ const installNoticeText: Record<
detailPrefix: 'Папка установки: ',
buttons: ['Открыть папку', 'Понятно'],
},
vi: {
title: 'Hãy mở ứng dụng từ menu Start',
message:
'Ứng dụng đang được chạy từ vị trí không phải thư mục cài đặt. Để tự động cập nhật hoạt động đúng, hãy mở ứng dụng từ menu Start hoặc biểu tượng ngoài màn hình. Nếu chưa có lối tắt, hãy chạy lại bộ cài.',
detailPrefix: 'Thư mục cài đặt: ',
buttons: ['Mở thư mục cài đặt', 'Đã hiểu'],
},
};
export function resolveInstallNoticeLanguage({
@@ -55,6 +62,10 @@ export function resolveInstallNoticeLanguage({
return 'ru';
}
if (normalized.startsWith('vi')) {
return 'vi';
}
return 'en';
}
+3 -2
View File
@@ -1,5 +1,6 @@
import fs from 'fs';
import path from 'path';
import { isObjectLike } from 'lodash-es';
import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
import { getAgentDir } from './paths';
@@ -42,7 +43,7 @@ function safeStringify(obj: unknown): string {
};
}
// Handle circular references
if (typeof value === 'object' && value !== null) {
if (isObjectLike(value)) {
if (seen.has(value)) {
return '[Circular]';
}
@@ -143,7 +144,7 @@ class Logger {
private formatArgs(args: unknown[]): string {
return args
.map((arg) => (typeof arg === 'object' ? safeStringify(arg) : String(arg)))
.map((arg) => (isObjectLike(arg) ? safeStringify(arg) : String(arg)))
.join(' ');
}
+6
View File
@@ -31,6 +31,12 @@ export function roundQuotaPercentage(value: number): number {
return Math.round(value * 10) / 10;
}
export function formatAiCreditsAmount(credits: number): string {
return new Intl.NumberFormat('en-US', {
maximumFractionDigits: 2,
}).format(credits);
}
export function formatTimeRemaining(dateStr: string): string | null {
const targetDate = new Date(dateStr);
if (Number.isNaN(targetDate.getTime())) {
+6 -4
View File
@@ -1,3 +1,5 @@
import { isObjectLike, isString } from 'lodash-es';
/**
* Keys to mask in logs to avoid exposing sensitive data.
*/
@@ -39,10 +41,10 @@ function sanitizeWithSeen(obj: unknown, seen: WeakSet<object>): unknown {
return obj;
}
if (typeof obj === 'string') {
if (isString(obj)) {
try {
const parsed = JSON.parse(obj);
if (typeof parsed === 'object' && parsed !== null) {
if (isObjectLike(parsed)) {
return JSON.stringify(sanitizeWithSeen(parsed, seen));
}
} catch {
@@ -59,7 +61,7 @@ function sanitizeWithSeen(obj: unknown, seen: WeakSet<object>): unknown {
return obj.map((item) => sanitizeWithSeen(item, seen));
}
if (typeof obj === 'object') {
if (isObjectLike(obj)) {
if (seen.has(obj)) {
return CIRCULAR_PLACEHOLDER;
}
@@ -94,7 +96,7 @@ export function safeStringifyPacket(obj: unknown): string {
const sanitized = sanitizeObject(obj);
const seen = new WeakSet();
return JSON.stringify(sanitized, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (isObjectLike(value)) {
if (seen.has(value)) {
return '[Circular]';
}
BIN
View File
Binary file not shown.