Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e98b02b1 | ||
|
|
83489d5084 | ||
|
|
0454aecf70 | ||
|
|
27da62afc9 | ||
|
|
faabf7ba7a | ||
|
|
433dccbaa9 | ||
|
|
e524a340cb | ||
|
|
6577f1c51f | ||
|
|
3e1e10b2a5 | ||
|
|
7093c982ba | ||
|
|
440a934280 | ||
|
|
87887dc4b9 | ||
|
|
00ff50055f | ||
|
|
d0c282dfff | ||
|
|
bc35b96b5d | ||
|
|
0223e8d807 | ||
|
|
1fd0fde700 | ||
|
|
2c1755e578 | ||
|
|
3100db93ed | ||
|
|
e9c4f72883 | ||
|
|
87c17a1ed0 | ||
|
|
4fa0a3c63d | ||
|
|
388fbcb2a9 | ||
|
|
bcdf2a6c24 | ||
|
|
385036d577 | ||
|
|
f970ccb2ed | ||
|
|
fce38ec6f0 | ||
|
|
61716bb512 | ||
|
|
ed0dd811a8 | ||
|
|
50a325be11 | ||
|
|
20fffff29c | ||
|
|
c95f494569 | ||
|
|
5401e40943 | ||
|
|
0a60c0eafa | ||
|
|
30e38dcb8b |
No files matched your search
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: antigravity-manager
|
||||
description: Comprehensive guide to Antigravity Manager architecture, workflows, and development. Use this to understand how to work on the project.
|
||||
name: geminiNexus-manager
|
||||
description: Comprehensive guide to Gemini Nexus Manager architecture, workflows, and development. Use this to understand how to work on the project.
|
||||
---
|
||||
|
||||
# Antigravity Manager Developer Guide
|
||||
# Gemini Nexus Manager Developer Guide
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
Antigravity Manager is a hybrid Desktop Application built with Electron, React, and NestJS. It follows a modular architecture where the frontend (Renderer) communicates with the backend (Main) via type-safe IPC (ORPC).
|
||||
Gemini Nexus Manager is a hybrid Desktop Application built with Electron, React, and NestJS. It follows a modular architecture where the frontend (Renderer) communicates with the backend (Main) via type-safe IPC (ORPC).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -123,3 +123,6 @@ build/
|
||||
cli/aliases.json
|
||||
*.backup
|
||||
accounts_backup.json
|
||||
|
||||
# Bun lock file
|
||||
bun.lock
|
||||
@@ -12,6 +12,30 @@
|
||||
"outFiles": ["${workspaceFolder}/.vite/build/**/*.js"],
|
||||
"console": "integratedTerminal",
|
||||
"autoAttachChildProcesses": true
|
||||
},
|
||||
{
|
||||
"name": "Debug Main Process (Proxyman)",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "cmd.exe",
|
||||
"runtimeArgs": ["/c", "npm", "run", "start"],
|
||||
"sourceMaps": true,
|
||||
"outFiles": ["${workspaceFolder}/.vite/build/**/*.js"],
|
||||
"console": "integratedTerminal",
|
||||
"autoAttachChildProcesses": true,
|
||||
"env": {
|
||||
"NODE_USE_ENV_PROXY": "1",
|
||||
"HTTP_PROXY": "http://127.0.0.1:9090",
|
||||
"HTTPS_PROXY": "http://127.0.0.1:9090",
|
||||
"ALL_PROXY": "http://127.0.0.1:9090",
|
||||
"NO_PROXY": "localhost,127.0.0.1,::1",
|
||||
"ELECTRON_PROXY_SERVER": "http://127.0.0.1:9090",
|
||||
"ELECTRON_PROXY_BYPASS_LIST": "<local>;localhost;127.0.0.1;::1",
|
||||
"DEBUG_PROXY_TRACE": "1",
|
||||
"NODE_EXTRA_CA_CERTS": "C:\\Users\\Administrator\\AppData\\Roaming\\Proxyman\\certificate\\certs\\ca.pem",
|
||||
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -345,12 +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)
|
||||
|
||||
After a set of changes, run these three checks in parallel instead of full lint immediately:
|
||||
@@ -371,128 +365,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>
|
||||
@@ -2,6 +2,53 @@
|
||||
|
||||
# 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
|
||||
|
||||
* account management overhaul + 7 issue resolutions ([#147](https://github.com/Draculabo/AntigravityManager/issues/147)) ([ed0dd81](https://github.com/Draculabo/AntigravityManager/commit/ed0dd811a8958928a2702839db07444e89f9fdb2)), closes [#79](https://github.com/Draculabo/AntigravityManager/issues/79) [#126](https://github.com/Draculabo/AntigravityManager/issues/126) [#99](https://github.com/Draculabo/AntigravityManager/issues/99) [#145](https://github.com/Draculabo/AntigravityManager/issues/145) [#134](https://github.com/Draculabo/AntigravityManager/issues/134) [#53](https://github.com/Draculabo/AntigravityManager/issues/53) [#117](https://github.com/Draculabo/AntigravityManager/issues/117)
|
||||
* **antigravity:** add model specs and update mapping logic ([0a60c0e](https://github.com/Draculabo/AntigravityManager/commit/0a60c0eafa698202722be9c283cb52831e2e0179))
|
||||
* **cloud-oauth:** support selectable google oauth clients ([bcdf2a6](https://github.com/Draculabo/AntigravityManager/commit/bcdf2a6c24fc992d32b4ae883f2a34c8e64969e3))
|
||||
* **cloud-status:** classify blocked accounts and surface validation state ([388fbcb](https://github.com/Draculabo/AntigravityManager/commit/388fbcb2a9522285eb0bb4a541b48cd3e1049470))
|
||||
* **cloud-sync:** persist project and status metadata for synced accounts ([385036d](https://github.com/Draculabo/AntigravityManager/commit/385036d577495caeebf95301542811b08c9f333e))
|
||||
* **protobuf:** add unified state helpers for oauth payloads ([f970ccb](https://github.com/Draculabo/AntigravityManager/commit/f970ccb2ed1f3a3e9de09221895f0b650c1115b9))
|
||||
* **proxy:** add upstream error and rate-limit tracking support ([20fffff](https://github.com/Draculabo/AntigravityManager/commit/20fffff29c47d1147e9b429884d9e8b613e7da6f))
|
||||
* **proxy:** improve parity, rate limiting, and token scheduling ([5401e40](https://github.com/Draculabo/AntigravityManager/commit/5401e40943824d54bc95971229a83934e55ad88f))
|
||||
* **proxy:** improve quota fallback and proxy parity handling ([4fa0a3c](https://github.com/Draculabo/AntigravityManager/commit/4fa0a3c63dd4b973d0d07feb7351831ad827176d))
|
||||
* **ui:** update cloud account and provider visibility experience ([c95f494](https://github.com/Draculabo/AntigravityManager/commit/c95f494569b49ff1fae871970764c9f0a3fbda59))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **cloud:** add missing oauth action bindings and validation status helper ([87c17a1](https://github.com/Draculabo/AntigravityManager/commit/87c17a1ed0856572f0f8c46ede923b563b726108))
|
||||
* **cloud:** fetch AI credits on startup with endpoint fallback and preserve cached credits ([61716bb](https://github.com/Draculabo/AntigravityManager/commit/61716bb51259c0a5899b8659aa864c5772b75cbb))
|
||||
* **linux:** dynamic executable path detection ([#121](https://github.com/Draculabo/AntigravityManager/issues/121)) ([30e38dc](https://github.com/Draculabo/AntigravityManager/commit/30e38dcb8b9329b68601c9df896fd815ccadc1ec))
|
||||
* **linux:** harden account-switch relaunch against GPU process crashes ([2c1755e](https://github.com/Draculabo/AntigravityManager/commit/2c1755e578ac91962e813a141ec7d3df6fa61a74))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
* **proxyman:** add setup and debugging guides and link them from readmes ([3100db9](https://github.com/Draculabo/AntigravityManager/commit/3100db93ed92f9f8ef19aceae49a699768e20ca9))
|
||||
|
||||
### ✅ Tests
|
||||
|
||||
* **proxy:** refine signature test text ([50a325b](https://github.com/Draculabo/AntigravityManager/commit/50a325be113ef59dd9d0144b84d64f9eb40ea67a))
|
||||
|
||||
## [0.10.0](https://github.com/Draculabo/AntigravityManager/compare/v0.9.2...v0.10.0) (2026-02-19)
|
||||
|
||||
### ✨ Features
|
||||
@@ -254,7 +301,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of Antigravity Manager
|
||||
- Initial release of Gemini Nexus
|
||||
- Multi-account management for Google Gemini and Claude
|
||||
- Real-time quota monitoring
|
||||
- Intelligent auto-switching capabilities
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Contributing to Antigravity Manager
|
||||
# Contributing to Gemini Nexus
|
||||
|
||||
First off, thank you for considering contributing to Antigravity Manager! 🎉
|
||||
First off, thank you for considering contributing to Gemini Nexus! 🎉
|
||||
|
||||
It's people like you that make Antigravity Manager such a great tool. We welcome contributions from everyone, whether it's a bug report, feature suggestion, documentation improvement, or code contribution.
|
||||
It's people like you that make Gemini Nexus such a great tool. We welcome contributions from everyone, whether it's a bug report, feature suggestion, documentation improvement, or code contribution.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
@@ -24,8 +24,8 @@ This project and everyone participating in it is governed by our [Code of Conduc
|
||||
1. **Fork the repository** on GitHub
|
||||
2. **Clone your fork** locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/AntigravityManager.git
|
||||
cd AntigravityManager
|
||||
git clone https://github.com/YOUR_USERNAME/GeminiNexusManager.git
|
||||
cd Gemini NexusManager
|
||||
```
|
||||
3. **Add the upstream remote**:
|
||||
```bash
|
||||
@@ -178,7 +178,7 @@ Have an idea? Please [create an issue](https://github.com/Draculabo/AntigravityM
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
Your contributions help make Antigravity Manager better for everyone. Thank you for taking the time to contribute!
|
||||
Your contributions help make Gemini Nexus better for everyone. Thank you for taking the time to contribute!
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,64 +1,56 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/logo.png" alt="Antigravity Manager" width="128" height="128" />
|
||||
<img src="docs/assets/logo.png" alt="Gemini Nexus" width="128" height="128" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Antigravity Manager</h1>
|
||||
<h1 align="center">Gemini Nexus</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>🚀 Professional multi-account manager for Google Gemini & Claude AI</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English | <a href="README.zh-CN.md">简体中文</a>
|
||||
English | <a href="README.pt-BR.md">Português (Brasil)</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/actions/workflows/testing.yaml">
|
||||
<img src="https://github.com/Draculabo/GeminiNexus/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/actions/workflows/lint.yaml">
|
||||
<img src="https://github.com/Draculabo/GeminiNexus/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Draculabo/AntigravityManager?style=flat-square" alt="Release" />
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Draculabo/GeminiNexus?style=flat-square" alt="Release" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/downloads/Draculabo/AntigravityManager/total?style=flat-square&color=blue" alt="Downloads" />
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/releases">
|
||||
<img src="https://img.shields.io/github/downloads/Draculabo/GeminiNexus/total?style=flat-square&color=blue" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Draculabo/AntigravityManager?style=flat-square" alt="License" />
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Draculabo/GeminiNexus?style=flat-square" alt="License" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/Draculabo/AntigravityManager?style=flat-square" alt="Stars" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="TailwindCSS" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📖 Table of Contents
|
||||
|
||||
- [Why Antigravity Manager?](#-why-antigravity-manager)
|
||||
- [Why Gemini Nexus?](#-why-gemini-nexus)
|
||||
- [Improvements over Base Project](#-improvements-over-base-project)
|
||||
- [Features](#-features)
|
||||
- [Screenshots](#-screenshots)
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Tech Stack](#️-tech-stack)
|
||||
- [Development](#-development)
|
||||
- [Debugging Docs](#-debugging-docs)
|
||||
- [FAQ](#-faq)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Why Antigravity Manager?
|
||||
## ✨ Why Gemini Nexus?
|
||||
|
||||
When using Antigravity IDE, have you ever encountered these problems?
|
||||
When using your favorite IDEs, have you ever encountered these problems?
|
||||
|
||||
- 😫 Single account quota runs out quickly, requiring frequent manual switching
|
||||
- 🔄 Managing multiple Google/Claude accounts is cumbersome
|
||||
@@ -66,7 +58,7 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
- ⏰ Worried about missing quota reset times
|
||||
- 🔌 Need a reliable local API proxy for development tools
|
||||
|
||||
**Antigravity Manager** is here to solve these problems! It's a professional Electron desktop app that helps you:
|
||||
**Gemini Nexus** is here to solve these problems! It's a professional Electron desktop app that helps you:
|
||||
|
||||
- ✅ **Unlimited Account Pool** - Add any number of Google Gemini / Claude accounts
|
||||
- ✅ **Smart Auto-Switching** - Automatically switch to the next available account when quota is low or rate-limited
|
||||
@@ -76,6 +68,19 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Improvements over Base Project
|
||||
|
||||
This project contains significant development improvements, bug fixes, and configuration enhancements compared to the original base project.
|
||||
|
||||
Key improvements include:
|
||||
- **Enhanced API Management UI:** Implemented dynamic model selectors and "Copy API Name" features to streamline integration with other tools.
|
||||
- **Robust SSE Streaming:** Fixed critical Server-Sent Events (SSE) streaming bugs in the `/v1/chat/completions` proxy endpoint to perfectly support IDEs like Cursor, OpenCode, and Windsurf.
|
||||
- **Rebranding and Localization:** Complete rebranding to Gemini Nexus with improved Portuguese (Brazil) localization and English defaults.
|
||||
- **Agentic Capabilities:** Integration of advanced AI developer agents and skill frameworks (GSD, AutoResearch, Superpowers) directly into the project ecosystem for rapid autonomous development.
|
||||
- **Build & Dev Env:** Better cross-platform build configurations, updated schemas, and automated toolchain processes.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
<table>
|
||||
@@ -115,24 +120,6 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>💾 Account Backup</h3>
|
||||
<ul>
|
||||
<li>Capture snapshots of account state</li>
|
||||
<li>Fast switching between saved accounts</li>
|
||||
<li>View, organize, and delete snapshots</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>⚙️ Process Control</h3>
|
||||
<ul>
|
||||
<li>Auto-detect if Antigravity is running</li>
|
||||
<li>Launch via URI protocol or executable</li>
|
||||
<li>Graceful close or force kill</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔌 Local API Proxy</h3>
|
||||
@@ -153,14 +140,6 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Additional Features
|
||||
|
||||
- **🖥️ System Tray** - Background mode with tray icon and right-click menu
|
||||
- **🔗 IDE Sync** - Automatically scan and import accounts from IDE's `state.vscdb`
|
||||
- **📦 Batch Operations** - Batch refresh and delete multiple accounts
|
||||
- **🌏 Internationalization** - Multi-language support (English / 中文)
|
||||
- **🎨 Modern UI** - Built with React, TailwindCSS, and Shadcn UI
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
@@ -169,39 +148,23 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
<img src="docs/assets/screenshot-main.png" alt="Main Interface" width="80%" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/screenshot-proxy.png" alt="Proxy Interface" width="48%" />
|
||||
<img src="docs/assets/screenshot-setting.png" alt="Settings Interface" width="48%" />
|
||||
</p>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## � Quick Start
|
||||
|
||||
### Download
|
||||
|
||||
Download the latest release for your platform from the [Releases](https://github.com/Draculabo/AntigravityManager/releases) page.
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| Windows (x64/ARM64) | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| macOS | [.dmg installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
## ⚡ Quick Start
|
||||
|
||||
### Build from Source
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Node.js v18 or higher
|
||||
- npm or yarn
|
||||
- npm or pnpm
|
||||
|
||||
#### Steps
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Draculabo/AntigravityManager.git
|
||||
cd AntigravityManager
|
||||
git clone https://github.com/Draculabo/GeminiNexus.git
|
||||
cd GeminiNexus
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
@@ -215,47 +178,15 @@ npm run make
|
||||
|
||||
---
|
||||
|
||||
## �🛠️ Tech Stack
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
| Category | Technologies |
|
||||
|----------|-------------|
|
||||
| **Core** | [Electron](https://www.electronjs.org/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
|
||||
| **Build Tool** | [Vite](https://vitejs.dev/) |
|
||||
| **Styling** | [TailwindCSS](https://tailwindcss.com/), [Shadcn UI](https://ui.shadcn.com/) |
|
||||
| **State** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
|
||||
| **State** | [TanStack Query](https://tanstack.com/query/latest) |
|
||||
| **Database** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
|
||||
| **Testing** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
|
||||
|
||||
---
|
||||
|
||||
## � Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm start` | Start the app in development mode |
|
||||
| `npm run lint` | Run ESLint to check for code issues |
|
||||
| `npm run format:write` | Format code with Prettier |
|
||||
| `npm run test:unit` | Run unit tests with Vitest |
|
||||
| `npm run test:e2e` | Run E2E tests with Playwright |
|
||||
| `npm run test:all` | Run all tests |
|
||||
| `npm run type-check` | Run TypeScript type checking |
|
||||
| `npm run make` | Build production packages |
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
AntigravityManager/
|
||||
├── src/
|
||||
│ ├── main.ts # Electron main process
|
||||
│ ├── preload.ts # Preload script
|
||||
│ ├── renderer/ # React renderer process
|
||||
│ ├── ipc/ # IPC communication handlers
|
||||
│ └── server/ # Built-in server
|
||||
├── docs/ # Documentation and assets
|
||||
└── .github/ # GitHub configuration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -280,50 +211,11 @@ Please check:
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: macOS shows Keychain/Credential error and OAuth cannot be saved?</b></summary>
|
||||
|
||||
This is a common macOS security behavior, usually when the app is unsigned or run directly from Downloads.
|
||||
This is a **temporary workaround** for personal use:
|
||||
|
||||
1. Move the app to `/Applications`
|
||||
2. Run the following commands in Terminal (repeat after every update)
|
||||
|
||||
```plaintext
|
||||
sudo xattr -dr com.apple.quarantine "/Applications/Antigravity Manager 2.app"
|
||||
codesign --force --deep --sign - "/Applications/Antigravity Manager 2.app"
|
||||
```
|
||||
|
||||
Reopen the app and allow Keychain access if prompted.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: How to report issues or suggestions?</b></summary>
|
||||
|
||||
Please submit issues or suggestions via [GitHub Issues](https://github.com/Draculabo/AntigravityManager/issues).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
We follow the [Contributor Covenant](CODE_OF_CONDUCT.md) Code of Conduct.
|
||||
Contributions are welcome! Please read `CONTRIBUTING.md` for details.
|
||||
|
||||
---
|
||||
|
||||
@@ -344,6 +236,6 @@ We follow the [Contributor Covenant](CODE_OF_CONDUCT.md) Code of Conduct.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
If this project helps you, please give it a ⭐ Star!
|
||||
</p>
|
||||
## 🙏 Credits
|
||||
|
||||
**Gemini Nexus** is a significantly enhanced and expanded version of the original project [AntigravityManager](https://github.com/Draculabo/AntigravityManager) created by [Draculabo](https://github.com/Draculabo). All fundamental architecture and design were made by the original author.
|
||||
@@ -0,0 +1,146 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/logo.png" alt="Gemini Nexus" width="128" height="128" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Gemini Nexus</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>🚀 Gerenciador multi-contas profissional para Google Gemini & Claude AI</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | Português (Brasil)
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/actions/workflows/testing.yaml">
|
||||
<img src="https://github.com/Draculabo/GeminiNexus/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/actions/workflows/lint.yaml">
|
||||
<img src="https://github.com/Draculabo/GeminiNexus/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Draculabo/GeminiNexus?style=flat-square" alt="Release" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Por que o Gemini Nexus?
|
||||
|
||||
Quando você usa suas IDEs favoritas, já encontrou estes problemas?
|
||||
|
||||
- 😫 A cota da sua única conta acaba rápido, exigindo trocas manuais frequentes
|
||||
- 🔄 Gerenciar múltiplas contas do Google/Claude é cansativo
|
||||
- 📊 Você não sabe quanta cota resta na conta atual
|
||||
- 🔌 Precisa de um proxy local e confiável para suas ferramentas de desenvolvimento
|
||||
|
||||
**Gemini Nexus** está aqui para resolver tudo isso! Ele é um app desktop profissional construído em Electron que te ajuda a:
|
||||
|
||||
- ✅ **Contas Ilimitadas** - Adicione quantas contas Google Gemini / Claude quiser
|
||||
- ✅ **Troca Automática** - Troca inteligentemente para a próxima conta disponível quando o limite é atingido
|
||||
- ✅ **Monitoramento em Tempo Real** - Visualize o uso de cota de todas as contas
|
||||
- ✅ **Proxy de API Local** - Servidor proxy integrado compatível com OpenAI/Anthropic
|
||||
- ✅ **Criptografia Segura** - Criptografia AES-256-GCM para seus dados sensíveis
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Melhorias sobre o Projeto Base
|
||||
|
||||
Este projeto contém melhorias significativas de desenvolvimento, correção de erros e melhorias de configuração em relação ao projeto original base.
|
||||
|
||||
Principais melhorias incluem:
|
||||
|
||||
- **Interface de Gerenciamento de API Aprimorada:** Seletores dinâmicos de modelos e recursos de "Copiar Nome da API" implementados para facilitar a integração com outras ferramentas.
|
||||
- **Streaming Robusto (SSE):** Correção de bugs críticos no streaming via Server-Sent Events (SSE) no endpoint proxy `/v1/chat/completions` para suportar perfeitamente IDEs como Cursor, OpenCode e Windsurf.
|
||||
- **Rebranding e Localização:** Rebranding completo para Gemini Nexus com traduções melhoradas em Português (Brasil) e padrões em Inglês.
|
||||
- **Capacidades de Agentes:** Integração direta de frameworks avançados de agentes desenvolvedores de IA e skills (GSD, AutoResearch, Superpowers) no ecossistema do projeto para desenvolvimento autônomo rápido.
|
||||
- **Ambiente de Build e Dev:** Melhores configurações de build cross-platform, schemas atualizados e processos de toolchain automatizados.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Funcionalidades
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>☁️ Pool de Contas na Nuvem</h3>
|
||||
<ul>
|
||||
<li>Adicione contas via OAuth do Google e Anthropic</li>
|
||||
<li>Mostra avatar, e-mail, status e último horário de uso</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>📊 Monitoramento de Cota</h3>
|
||||
<ul>
|
||||
<li>Suporte multi-modelo: gemini-pro, claude-3-5-sonnet, etc.</li>
|
||||
<li>Barras de progresso visuais com indicadores de cores</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔄 Troca Automática</h3>
|
||||
<ul>
|
||||
<li>Modo de pool ilimitado com seleção inteligente</li>
|
||||
<li>Monitoramento a cada 5 minutos em segundo plano</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🔌 Proxy Local</h3>
|
||||
<ul>
|
||||
<li>Compatível com API da OpenAI & Anthropic</li>
|
||||
<li>Mapeamento de modelos customizável (ex: Claude → Gemini)</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Começo Rápido
|
||||
|
||||
### Construindo pelo Código Fonte
|
||||
|
||||
#### Pré-requisitos
|
||||
|
||||
- Node.js v18 ou superior
|
||||
- npm
|
||||
|
||||
#### Passos
|
||||
|
||||
```bash
|
||||
# Clone o repositório
|
||||
git clone https://github.com/Draculabo/GeminiNexus.git
|
||||
cd GeminiNexus
|
||||
|
||||
# Instale as dependências
|
||||
npm install
|
||||
|
||||
# Inicie o modo de desenvolvimento
|
||||
npm start
|
||||
|
||||
# Construa para produção
|
||||
npm run make
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licença
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Aviso Legal
|
||||
|
||||
> [!WARNING]
|
||||
> **Apenas para Fins Educacionais**
|
||||
>
|
||||
> Este projeto destina-se exclusivamente a fins de pesquisa e educação. Ele é fornecido "como está" sem qualquer garantia. **O uso comercial é estritamente proibido.**
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Créditos
|
||||
|
||||
**Gemini Nexus** é uma versão significativamente aprimorada e expandida baseada no projeto original [AntigravityManager](https://github.com/Draculabo/AntigravityManager) criado por [Draculabo](https://github.com/Draculabo). Toda a arquitetura fundamental e design do projeto foram criados pelo autor original.
|
||||
@@ -1,349 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/logo.png" alt="Antigravity Manager" width="128" height="128" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Antigravity Manager</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>🚀 专业的 Google Gemini & Claude AI 多账号管理器</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | 简体中文
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Draculabo/AntigravityManager?style=flat-square" alt="Release" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/downloads/Draculabo/AntigravityManager/total?style=flat-square&color=blue" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Draculabo/AntigravityManager?style=flat-square" alt="License" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/Draculabo/AntigravityManager?style=flat-square" alt="Stars" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="TailwindCSS" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📖 目录
|
||||
|
||||
- [为什么选择 Antigravity Manager?](#-为什么选择-antigravity-manager)
|
||||
- [功能特性](#-功能特性)
|
||||
- [截图预览](#-截图预览)
|
||||
- [快速开始](#-快速开始)
|
||||
- [技术栈](#️-技术栈)
|
||||
- [开发指南](#-开发指南)
|
||||
- [常见问题](#-常见问题)
|
||||
- [贡献指南](#-贡献指南)
|
||||
- [许可证](#-许可证)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 为什么选择 Antigravity Manager?
|
||||
|
||||
在使用 Antigravity IDE 时,你是否遇到过这些问题?
|
||||
|
||||
- 😫 单个账号额度很快用完,需要频繁手动切换
|
||||
- 🔄 管理多个 Google/Claude 账号非常麻烦
|
||||
- 📊 不知道当前账号还剩多少额度
|
||||
- ⏰ 担心错过额度重置时间
|
||||
- 🔌 需要可靠的本地 API 代理用于开发工具
|
||||
|
||||
**Antigravity Manager** 就是为解决这些问题而生的!它是一个专业的 Electron 桌面应用,帮助你:
|
||||
|
||||
- ✅ **无限账号池** - 添加任意数量的 Google Gemini / Claude 账号
|
||||
- ✅ **智能自动切换** - 额度不足或被限速时自动切换到下一个可用账号
|
||||
- ✅ **实时监控** - 可视化显示所有账号的额度使用情况
|
||||
- ✅ **本地 API 代理** - 内置兼容 OpenAI/Anthropic 协议的代理服务器
|
||||
- ✅ **安全加密** - AES-256-GCM 加密存储敏感信息
|
||||
|
||||
---
|
||||
|
||||
## 🎯 功能特性
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>☁️ 云账号池管理</h3>
|
||||
<ul>
|
||||
<li>通过 OAuth 添加无限 Google Gemini / Claude 账号</li>
|
||||
<li>显示头像、邮箱、状态和最后使用时间</li>
|
||||
<li>实时状态监控(活跃、限速、过期)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>📊 实时额度监控</h3>
|
||||
<ul>
|
||||
<li>多模型支持:gemini-pro、claude-3-5-sonnet 等</li>
|
||||
<li>可视化进度条配色指示器</li>
|
||||
<li>自动刷新与手动刷新</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔄 智能自动切换</h3>
|
||||
<ul>
|
||||
<li>无限池模式,智能选择备用账号</li>
|
||||
<li>额度低于 5% 或被限速时自动切换</li>
|
||||
<li>后台每 5 分钟自动监控</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🔐 安全优先</h3>
|
||||
<ul>
|
||||
<li>AES-256-GCM 加密敏感数据</li>
|
||||
<li>集成操作系统原生凭证管理器</li>
|
||||
<li>自动迁移旧版明文数据</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>💾 账号备份</h3>
|
||||
<ul>
|
||||
<li>捕获账号状态快照</li>
|
||||
<li>在已保存的账号间快速切换</li>
|
||||
<li>查看、整理和删除快照</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>⚙️ 进程控制</h3>
|
||||
<ul>
|
||||
<li>自动检测 Antigravity 是否运行</li>
|
||||
<li>通过 URI 协议或可执行文件启动</li>
|
||||
<li>优雅关闭或强制终止</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔌 本地 API 代理</h3>
|
||||
<ul>
|
||||
<li>兼容 OpenAI & Anthropic API 协议</li>
|
||||
<li>可配置端口和请求超时</li>
|
||||
<li>模型映射(如:Claude → Gemini)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🛠️ 开发者工具</h3>
|
||||
<ul>
|
||||
<li>内置 cURL & Python 代码生成</li>
|
||||
<li>可视化服务状态监控</li>
|
||||
<li>一键重新生成 API Key</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 更多功能
|
||||
|
||||
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
|
||||
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
|
||||
- **📦 批量操作** - 批量刷新和删除多个账号
|
||||
- **🌏 国际化** - 多语言支持(English / 中文)
|
||||
- **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建
|
||||
|
||||
---
|
||||
|
||||
## 📸 截图预览
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/screenshot-main.png" alt="主界面" width="80%" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/screenshot-proxy.png" alt="代理界面" width="48%" />
|
||||
<img src="docs/assets/screenshot-setting.png" alt="设置界面" width="48%" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 下载安装
|
||||
|
||||
从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。
|
||||
|
||||
| 平台 | 下载链接 |
|
||||
| ------------------- | ------------------------------------------------------------------------------ |
|
||||
| Windows (x64/ARM64) | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
|
||||
### 从源码构建
|
||||
|
||||
#### 前置要求
|
||||
|
||||
- Node.js v18 或更高版本
|
||||
- npm 或 yarn
|
||||
|
||||
#### 步骤
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/Draculabo/AntigravityManager.git
|
||||
cd AntigravityManager
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发模式
|
||||
npm start
|
||||
|
||||
# 构建生产版本
|
||||
npm run make
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
| 类别 | 技术 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) |
|
||||
| **构建工具** | [Vite](https://vitejs.dev/) |
|
||||
| **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) |
|
||||
| **状态管理** | [TanStack Query](https://tanstack.com/query/latest)、[TanStack Router](https://tanstack.com/router/latest) |
|
||||
| **数据库** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
|
||||
| **测试** | [Vitest](https://vitest.dev/)、[Playwright](https://playwright.dev/) |
|
||||
|
||||
---
|
||||
|
||||
## 💻 开发指南
|
||||
|
||||
### 可用脚本
|
||||
|
||||
| 命令 | 描述 |
|
||||
| ---------------------- | ------------------------ |
|
||||
| `npm start` | 启动开发模式 |
|
||||
| `npm run lint` | 运行 ESLint 检查 |
|
||||
| `npm run format:write` | 使用 Prettier 格式化代码 |
|
||||
| `npm run test:unit` | 运行单元测试 |
|
||||
| `npm run test:e2e` | 运行 E2E 测试 |
|
||||
| `npm run test:all` | 运行所有测试 |
|
||||
| `npm run type-check` | TypeScript 类型检查 |
|
||||
| `npm run make` | 构建生产包 |
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
AntigravityManager/
|
||||
├── src/
|
||||
│ ├── main.ts # Electron 主进程
|
||||
│ ├── preload.ts # 预加载脚本
|
||||
│ ├── renderer/ # React 渲染进程
|
||||
│ ├── ipc/ # IPC 通信处理
|
||||
│ └── server/ # 内置服务器
|
||||
├── docs/ # 文档和资源
|
||||
└── .github/ # GitHub 配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
<details>
|
||||
<summary><b>Q: 程序无法启动怎么办?</b></summary>
|
||||
|
||||
请检查:
|
||||
|
||||
1. 确保已安装所有依赖:`npm install`
|
||||
2. 检查 Node.js 版本是否 >= 18
|
||||
3. 尝试删除 `node_modules` 后重新安装
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: 账号登录失败?</b></summary>
|
||||
|
||||
1. 确保网络连接正常
|
||||
2. 尝试清除应用数据后重新登录
|
||||
3. 检查账号是否被 Google/Claude 限制
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: macOS 上提示 Keychain/凭据管理不可用,OAuth 无法保存?</b></summary>
|
||||
|
||||
这是 macOS 安全机制导致的常见问题,通常出现在未签名的应用或从 Downloads 直接运行的场景。
|
||||
这是**临时方案**,仅适合个人设备:
|
||||
|
||||
1. 将应用移动到 `/Applications`
|
||||
2. 打开终端执行以下命令(每次更新后都需要重新执行)
|
||||
|
||||
```plaintext
|
||||
sudo xattr -dr com.apple.quarantine "/Applications/Antigravity Manager 2.app"
|
||||
codesign --force --deep --sign - "/Applications/Antigravity Manager 2.app"
|
||||
```
|
||||
|
||||
重新打开应用后,Keychain 应会提示授权(可选“始终允许”)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: 如何反馈问题或建议?</b></summary>
|
||||
|
||||
请通过 [GitHub Issues](https://github.com/Draculabo/AntigravityManager/issues) 提交问题或建议。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎贡献代码!请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解详情。
|
||||
|
||||
我们遵循 [Contributor Covenant](CODE_OF_CONDUCT.md) 行为准则。
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
> [!WARNING]
|
||||
> **仅供学习研究使用**
|
||||
>
|
||||
> 本项目仅供教育和研究目的,按"原样"提供,不提供任何保证。**严禁商业使用。**
|
||||
>
|
||||
> 使用本软件即表示您同意不会将其用于任何商业目的,并自行负责确保您的使用符合所有适用法律法规。作者和贡献者对因使用本软件而产生的任何滥用或损害不承担责任。
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
如果这个项目对你有帮助,请给一个 ⭐ Star 支持!
|
||||
</p>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
Antigravity Manager takes security seriously. Here are the security measures we've implemented:
|
||||
Gemini Nexus takes security seriously. Here are the security measures we've implemented:
|
||||
|
||||
### Encryption
|
||||
|
||||
@@ -35,7 +35,7 @@ We only provide security updates for the latest version. Please ensure you're ru
|
||||
|
||||
## 🚨 Reporting a Vulnerability
|
||||
|
||||
We take all security vulnerabilities seriously. Thank you for helping to improve the security of Antigravity Manager.
|
||||
We take all security vulnerabilities seriously. Thank you for helping to improve the security of Gemini Nexus.
|
||||
|
||||
### How to Report
|
||||
|
||||
@@ -75,7 +75,7 @@ Please include the following information in your report:
|
||||
|
||||
To ensure the security of your data:
|
||||
|
||||
1. **Keep Updated**: Always use the latest version of Antigravity Manager
|
||||
1. **Keep Updated**: Always use the latest version of Gemini Nexus
|
||||
2. **System Security**: Keep your operating system updated
|
||||
3. **Secure Environment**: Use the application on trusted devices only
|
||||
4. **Backup**: Regularly backup your account data using the built-in backup feature
|
||||
@@ -90,4 +90,4 @@ When we receive a security bug report, we will:
|
||||
3. Prepare fixes for all supported versions
|
||||
4. Release new versions as soon as possible
|
||||
|
||||
Thank you for helping keep Antigravity Manager and its users safe!
|
||||
Thank you for helping keep Gemini Nexus and its users safe!
|
||||
@@ -1,102 +0,0 @@
|
||||
# CLI Feature - Pull Request Summary
|
||||
|
||||
## What This PR Adds
|
||||
|
||||
A complete **Command-Line Interface (CLI)** for Antigravity Manager with 18+ commands for power users.
|
||||
|
||||
## Key Features
|
||||
|
||||
- 📊 **Live Quota Monitoring** - Real-time tracking for Gemini & Claude models
|
||||
- 🔄 **Account Switching** - Seamless switching without manual token management
|
||||
- ✅ **Token Validation** - Auto-refresh expired tokens
|
||||
- 🎯 **Smart Auto-Switch** - Automatically pick best account by quota
|
||||
- 🏷️ **Aliases** - Shortcut names for accounts (`work`, `personal`)
|
||||
- 💾 **Backup/Restore** - Export/import account configurations
|
||||
- 🎮 **Interactive Mode** - Keyboard-navigated menus for beginners
|
||||
- 🩺 **Diagnostics** - System health checks
|
||||
|
||||
## Technical Highlights
|
||||
|
||||
- **Zero Hardcoded Paths**: Intelligently discovers database and executable locations
|
||||
- **Cross-Platform Ready**: Uses environment variables and checks for Windows/Mac/Linux
|
||||
- **Secure**: Leverages existing DPAPI + AES-256-GCM encryption (same as GUI)
|
||||
- **Extensible**: Clean separation between core logic (`core.py`) and commands (`main.py`)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
cli/
|
||||
├── README.md # Full documentation
|
||||
├── requirements.txt # Python dependencies
|
||||
├── core.py # Database, encryption, API logic
|
||||
├── main.py # CLI commands and interactive UI
|
||||
└── proto_utils.py # Protobuf encoding helpers
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```powershell
|
||||
# Interactive mode
|
||||
agm
|
||||
|
||||
# Quick status
|
||||
agm status
|
||||
|
||||
# Switch accounts
|
||||
agm switch work
|
||||
|
||||
# Auto-switch to best account
|
||||
agm auto-switch --model claude --min-quota 50
|
||||
|
||||
# Live monitoring
|
||||
agm watch
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- ✅ Tested on Windows 10/11
|
||||
- ✅ No hardcoded paths (uses standard Windows env vars)
|
||||
- ✅ Works with both installed and development builds
|
||||
- ✅ Database encryption/decryption verified
|
||||
|
||||
## Dependencies
|
||||
|
||||
All Python dependencies are self-contained in `cli/requirements.txt`:
|
||||
- `typer` - CLI framework
|
||||
- `rich` - Beautiful terminal output
|
||||
- `questionary` - Interactive menus
|
||||
- `pywin32` - Windows DPAPI support
|
||||
- `cryptography` - AES-256-GCM encryption
|
||||
- `httpx` - Async HTTP client
|
||||
- `psutil` - Process management
|
||||
- `protobuf` - Google API protocol buffers
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **Windows**: Full support (tested)
|
||||
- **macOS/Linux**: Partial support (no DPAPI, but core features work)
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None. This is a pure addition—no changes to existing GUI code.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- OAuth flow for adding new accounts via CLI
|
||||
- TUI (Terminal UI) with live dashboard
|
||||
- Scheduled auto-refresh via Windows Task Scheduler
|
||||
- Config file for user preferences
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Code follows existing style
|
||||
- [x] All commands have help text
|
||||
- [x] No hardcoded paths/credentials
|
||||
- [x] README with examples
|
||||
- [x] Dependencies in requirements.txt
|
||||
- [x] .gitignore updated for Python
|
||||
- [x] Works with fresh installs
|
||||
|
||||
---
|
||||
|
||||
**Ready to merge!** 🚀
|
||||
@@ -1,6 +1,6 @@
|
||||
# Antigravity Manager CLI
|
||||
# Gemini Nexus CLI
|
||||
|
||||
A powerful command-line interface for managing multiple Google Cloud AI accounts in Antigravity IDE. Supports account switching, quota monitoring, token validation, and more.
|
||||
A powerful command-line interface for managing multiple Google Cloud AI accounts in Gemini Nexus IDE. Supports account switching, quota monitoring, token validation, and more.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -147,9 +147,9 @@ agm doctor
|
||||
|
||||
## How It Works
|
||||
|
||||
The CLI interacts directly with the Antigravity Manager's encrypted database:
|
||||
The CLI interacts directly with the Gemini Nexus's encrypted database:
|
||||
|
||||
1. **Database Location**: Auto-discovers the database in `%APPDATA%\AntigravityManager` or IDE installation directory
|
||||
1. **Database Location**: Auto-discovers the database in `%APPDATA%\GeminiNexus` or IDE installation directory
|
||||
2. **Encryption**: Uses Windows DPAPI + AES-256-GCM to decrypt account tokens (same as the IDE)
|
||||
3. **Quota API**: Fetches live quota data from Google Cloud AI REST API
|
||||
4. **Process Control**: Can start/stop the IDE executable automatically
|
||||
@@ -157,7 +157,7 @@ The CLI interacts directly with the Antigravity Manager's encrypted database:
|
||||
## Troubleshooting
|
||||
|
||||
**"Database not found" error:**
|
||||
- Ensure Antigravity Manager has been run at least once
|
||||
- Ensure Gemini Nexus has been run at least once
|
||||
- Check if accounts exist in the GUI first
|
||||
|
||||
**"Failed to decrypt" error:**
|
||||
@@ -175,7 +175,7 @@ The CLI interacts directly with the Antigravity Manager's encrypted database:
|
||||
|
||||
- Windows 10/11
|
||||
- Python 3.8+
|
||||
- Antigravity Manager (for database access)
|
||||
- Gemini Nexus (for database access)
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 390 KiB |
@@ -1,6 +1,6 @@
|
||||
# Cloud Account Management and Intelligent Switching
|
||||
|
||||
This document explains the cloud AI account management and switching capabilities in Antigravity Manager. The feature allows users to manage multiple Google accounts, monitor API quotas, and seamlessly switch the active IDE account with one click.
|
||||
This document explains the cloud AI account management and switching capabilities in Gemini Nexus. The feature allows users to manage multiple Google accounts, monitor API quotas, and seamlessly switch the active IDE account with one click.
|
||||
|
||||
## 1. Core Features
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# Proxyman Debugging Guide for Antigravity and Gemini Nexus
|
||||
|
||||
This guide explains how to use Proxyman for two debugging targets:
|
||||
|
||||
1. The installed official `Antigravity` desktop client
|
||||
2. The `Gemini Nexus` development build from this repository
|
||||
|
||||
Before you start, complete [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md).
|
||||
|
||||
## Scope
|
||||
|
||||
Use this guide when you want to:
|
||||
|
||||
- inspect login, quota, project context, or model-list requests from `Antigravity`
|
||||
- inspect OAuth, quota refresh, or proxy traffic from `Gemini Nexus`
|
||||
- compare requests from the official client with requests produced by this project
|
||||
|
||||
## Understand the Two Traffic Paths
|
||||
|
||||
### Official `Antigravity` Client
|
||||
|
||||
`Antigravity` is an Electron application. Its traffic may come from:
|
||||
|
||||
- Chromium or renderer-side requests
|
||||
- Electron or Node-side requests
|
||||
|
||||
### `Gemini Nexus` in This Repository
|
||||
|
||||
This project is also an Electron app, but it additionally includes Node, Undici, and internal proxy flows. You may need to inspect:
|
||||
|
||||
- Electron main-process outbound requests
|
||||
- renderer-side requests
|
||||
- upstream requests emitted by `src/server/`
|
||||
|
||||
## Debug the Installed Official Antigravity Client
|
||||
|
||||
### Recommended Path: Use the Repository Script
|
||||
|
||||
Use the provided script:
|
||||
|
||||
- [scripts/start-antigravity-with-proxyman.ps1](../scripts/start-antigravity-with-proxyman.ps1)
|
||||
|
||||
This should be the default path for the installed official client because it already does the following:
|
||||
|
||||
- sets `HTTP_PROXY`, `HTTPS_PROXY`, and `ALL_PROXY`
|
||||
- sets `NODE_EXTRA_CA_CERTS`
|
||||
- passes `--proxy-server` and `--proxy-bypass-list`
|
||||
- stops existing `Antigravity` processes so the new environment takes effect
|
||||
|
||||
Typical usage:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1
|
||||
```
|
||||
|
||||
If Proxyman is using a different port:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1 -Port 9091
|
||||
```
|
||||
|
||||
If you are temporarily debugging TLS failures:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1 -AllowInsecureTls
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- This script targets the installed official `Antigravity` client.
|
||||
- It does not launch this repository's `Gemini Nexus` development build.
|
||||
- Override `-AppPath`, `-CaPath`, `-ProxyHost`, or `-NoProxy` if your local environment differs.
|
||||
|
||||
### Suggested Flow
|
||||
|
||||
1. Open Proxyman.
|
||||
2. Confirm that installation and certificate setup are already complete.
|
||||
3. Confirm the local proxy port, for example `9090`.
|
||||
4. Launch `Antigravity` with the script.
|
||||
5. Trigger a request that is easy to identify, such as:
|
||||
- Google sign-in
|
||||
- model list loading
|
||||
- quota refresh
|
||||
- a generated request or prompt action
|
||||
|
||||
### Why Manual Parameters Are Still Documented
|
||||
|
||||
The script is the recommended default, but the raw parameters are still useful because:
|
||||
|
||||
- contributors may want to understand exactly what the script configures
|
||||
- some people launch from VS Code, another shell, or a custom wrapper
|
||||
- some debugging sessions need a partial override rather than the full script
|
||||
|
||||
### If Windows System Proxy Is Not Enough
|
||||
|
||||
In some environments you may still need to point the system proxy to Proxyman manually. At minimum, verify:
|
||||
|
||||
- HTTP proxy: `127.0.0.1:9090`
|
||||
- HTTPS proxy: `127.0.0.1:9090`
|
||||
- the bypass list is not excluding the domains you want to inspect
|
||||
|
||||
### If You Need Direct Chromium Proxy Arguments
|
||||
|
||||
If system proxy behavior is unreliable, you can launch the installed client with explicit Chromium proxy arguments:
|
||||
|
||||
```powershell
|
||||
& "C:\Users\<YourUser>\AppData\Local\Programs\Antigravity\Antigravity.exe" `
|
||||
--proxy-server="http://127.0.0.1:9090" `
|
||||
--proxy-bypass-list="<local>;localhost;127.0.0.1;::1"
|
||||
```
|
||||
|
||||
Use this as a fallback, not the default path.
|
||||
|
||||
## Debug Gemini Nexus From This Repository
|
||||
|
||||
This project has more layers than the official client. The script above is not the general launcher for this repository's development app.
|
||||
|
||||
### Capture Main-Process, Node, and Internal Proxy Traffic
|
||||
|
||||
Set proxy-related environment variables before starting the app:
|
||||
|
||||
```powershell
|
||||
$env:HTTP_PROXY = "http://127.0.0.1:9090"
|
||||
$env:HTTPS_PROXY = "http://127.0.0.1:9090"
|
||||
$env:ALL_PROXY = "http://127.0.0.1:9090"
|
||||
$env:NO_PROXY = "localhost,127.0.0.1,::1"
|
||||
$env:NODE_EXTRA_CA_CERTS = "$env:APPDATA\\Proxyman\\certificate\\certs\\ca.pem"
|
||||
|
||||
# Enable only for temporary TLS debugging
|
||||
$env:NODE_TLS_REJECT_UNAUTHORIZED = "0"
|
||||
|
||||
npm start
|
||||
```
|
||||
|
||||
This primarily affects:
|
||||
|
||||
- Node HTTP and HTTPS requests
|
||||
- `undici`, `fetch`, and some dependency-driven upstream requests
|
||||
- internal proxy requests forwarded to upstream services
|
||||
|
||||
### Also Capture Electron and Chromium Traffic
|
||||
|
||||
If you want both Node-side and renderer-side traffic, add Chromium proxy arguments:
|
||||
|
||||
```powershell
|
||||
$env:HTTP_PROXY = "http://127.0.0.1:9090"
|
||||
$env:HTTPS_PROXY = "http://127.0.0.1:9090"
|
||||
$env:ALL_PROXY = "http://127.0.0.1:9090"
|
||||
$env:NO_PROXY = "localhost,127.0.0.1,::1"
|
||||
$env:NODE_EXTRA_CA_CERTS = "$env:APPDATA\\Proxyman\\certificate\\certs\\ca.pem"
|
||||
$env:NODE_TLS_REJECT_UNAUTHORIZED = "0"
|
||||
|
||||
npm start -- --proxy-server="http://127.0.0.1:9090" --proxy-bypass-list="<local>;localhost;127.0.0.1;::1"
|
||||
```
|
||||
|
||||
If your local `npm start` flow does not forward arguments to Electron Forge, use one of these fallback approaches:
|
||||
|
||||
- capture only Node and main-process traffic
|
||||
- wrap the Electron launch in your own PowerShell or VS Code task
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### I Can See Requests but HTTPS Bodies Are Empty
|
||||
|
||||
Check:
|
||||
|
||||
- the Proxyman root certificate is really trusted
|
||||
- `NODE_EXTRA_CA_CERTS` is set when Node-side traffic needs it
|
||||
- `NODE_TLS_REJECT_UNAUTHORIZED=0` is only being used as a temporary debugging override
|
||||
- the target app is not using SSL pinning
|
||||
|
||||
Background setup guide:
|
||||
|
||||
- [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md)
|
||||
|
||||
### I Can See Browser Traffic but Not Node or Main-Process Traffic
|
||||
|
||||
Common causes:
|
||||
|
||||
- Node is not using system proxy settings
|
||||
- the HTTP client does not read `HTTP_PROXY`
|
||||
- only the system proxy was configured, but runtime environment variables were not
|
||||
|
||||
### I Cannot See `localhost` Traffic
|
||||
|
||||
Common cause:
|
||||
|
||||
- loopback traffic is bypassing the proxy
|
||||
|
||||
Keep this bypass list in mind when launching Electron:
|
||||
|
||||
```plaintext
|
||||
--proxy-bypass-list="<local>;localhost;127.0.0.1;::1"
|
||||
```
|
||||
|
||||
Adjust it only if your current debugging target requires a different loopback behavior.
|
||||
|
||||
### TLS Still Fails After the Certificate Was Installed
|
||||
|
||||
Common causes:
|
||||
|
||||
- the process started before the certificate was installed and needs a full restart
|
||||
- `ca.cer` was imported but `ca.pem` was not passed to Node when required
|
||||
- a corporate proxy, VPN, or security product is changing network behavior
|
||||
- the target app uses SSL pinning
|
||||
|
||||
## Security Notes
|
||||
|
||||
Keep local debugging settings out of the repository and out of public issue threads.
|
||||
|
||||
- Do not commit your local proxy address, certificate paths, cookies, or OAuth codes.
|
||||
- Do not leave `NODE_TLS_REJECT_UNAUTHORIZED=0` enabled beyond temporary local debugging.
|
||||
- Do not post raw tokens, sessions, or authorization headers in public issues.
|
||||
- Redact logs before sharing them.
|
||||
|
||||
## Minimal Workflows
|
||||
|
||||
### Official Antigravity Client
|
||||
|
||||
1. Complete [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md).
|
||||
2. Open Proxyman.
|
||||
3. Run:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1
|
||||
```
|
||||
|
||||
4. Trigger login, quota, or model-list traffic.
|
||||
|
||||
### Gemini Nexus in This Repository
|
||||
|
||||
1. Complete [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md).
|
||||
2. Open PowerShell.
|
||||
3. Set `HTTP_PROXY`, `HTTPS_PROXY`, and `NODE_EXTRA_CA_CERTS`.
|
||||
4. Run `npm start`.
|
||||
5. Filter Proxyman traffic by domains such as:
|
||||
- `googleapis.com`
|
||||
- `googleusercontent.com`
|
||||
- the upstream domain you are debugging
|
||||
|
||||
## Related Files
|
||||
|
||||
- Setup guide: [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md)
|
||||
- Official-client launcher: [scripts/start-antigravity-with-proxyman.ps1](../scripts/start-antigravity-with-proxyman.ps1)
|
||||
@@ -0,0 +1,140 @@
|
||||
# Proxyman Installation and Basic Setup on Windows
|
||||
|
||||
This guide covers the Proxyman prerequisites for contributors:
|
||||
|
||||
1. Install Proxyman for Windows
|
||||
2. Trust the Proxyman certificate so HTTPS traffic can be decrypted
|
||||
3. Verify that Proxyman is working before you start debugging Antigravity traffic
|
||||
|
||||
If your goal is to debug the installed Antigravity client or this repository's development build, continue with [Proxyman Debugging Guide](./proxyman-debugging.md).
|
||||
|
||||
## Scope
|
||||
|
||||
This document is intentionally limited to the setup layer. It does not cover Antigravity-specific debugging flows.
|
||||
|
||||
Use it when you need to answer:
|
||||
|
||||
- How do I install Proxyman on Windows?
|
||||
- How do I trust the certificate?
|
||||
- Why can I only see CONNECT requests but not HTTPS bodies?
|
||||
- How do I verify that Proxyman itself is working?
|
||||
|
||||
## Install Proxyman
|
||||
|
||||
Official resources:
|
||||
|
||||
- Download: [https://proxyman.com/download](https://proxyman.com/download)
|
||||
- Windows product page: [https://proxyman.com/windows](https://proxyman.com/windows)
|
||||
|
||||
Recommended steps:
|
||||
|
||||
1. Install the Windows build from the official download page.
|
||||
2. Launch Proxyman.
|
||||
3. Confirm that Proxyman is listening on a local proxy port.
|
||||
4. This guide uses `9090` as the example port. Replace it with your local port if it differs.
|
||||
|
||||
## Install and Trust the Certificate
|
||||
|
||||
Without the Proxyman root certificate, you will usually only see CONNECT tunnels or TLS handshakes instead of decrypted HTTPS requests.
|
||||
|
||||
Official references:
|
||||
|
||||
- Windows certificate installation: [https://docs.proxyman.com/proxyman-windows/install-certificate](https://docs.proxyman.com/proxyman-windows/install-certificate)
|
||||
- SSL troubleshooting: [https://docs.proxyman.com/troubleshooting/get-ssl-error-from-https-request-and-response](https://docs.proxyman.com/troubleshooting/get-ssl-error-from-https-request-and-response)
|
||||
|
||||
### Recommended Path: Install Automatically
|
||||
|
||||
In Proxyman, run:
|
||||
|
||||
```plaintext
|
||||
Certificate -> Install Certificate on this Windows...
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
1. Choose `Install & Trust`
|
||||
2. Approve the administrator prompt
|
||||
|
||||
According to the official documentation, Proxyman uses Windows `certutil` to import the root certificate into the trusted store.
|
||||
|
||||
### Prepare a PEM File for Node and Electron
|
||||
|
||||
Some Node.js and Electron traffic needs more than system trust. You may also need to point the runtime at the PEM file:
|
||||
|
||||
```plaintext
|
||||
%APPDATA%\Proxyman\certificate\certs\ca.pem
|
||||
```
|
||||
|
||||
If you only see `ca.cer` and not `ca.pem`, confirm that Proxyman has generated the PEM variant before moving on.
|
||||
|
||||
## Verify That Proxyman Works
|
||||
|
||||
Do this before trying to debug any application-specific flow.
|
||||
|
||||
### Verify Browser Traffic
|
||||
|
||||
1. Open Proxyman.
|
||||
2. Visit any HTTPS page in a browser, for example:
|
||||
|
||||
```plaintext
|
||||
https://httpbin.org/get
|
||||
```
|
||||
|
||||
3. Confirm that the request and response appear in Proxyman.
|
||||
|
||||
### If You Cannot See Any Traffic
|
||||
|
||||
Check these first:
|
||||
|
||||
- Proxyman is actually listening on the expected port
|
||||
- Windows proxy settings are pointing to Proxyman
|
||||
- A VPN, endpoint security agent, or antivirus is not taking over proxy behavior
|
||||
- The certificate is trusted
|
||||
|
||||
If localhost traffic is missing, see:
|
||||
|
||||
- [https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server](https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server)
|
||||
|
||||
The official page includes macOS-specific examples, but the localhost bypass concept is still relevant on Windows.
|
||||
|
||||
## Basic Troubleshooting
|
||||
|
||||
### HTTPS Bodies Are Missing
|
||||
|
||||
Common causes:
|
||||
|
||||
- The Proxyman root certificate is not actually trusted
|
||||
- The target process was already running before the certificate was installed
|
||||
- The application uses SSL pinning
|
||||
|
||||
### Certificate Is Installed but TLS Still Fails
|
||||
|
||||
Common causes:
|
||||
|
||||
- You imported `ca.cer` but did not provide `ca.pem` to the runtime when needed
|
||||
- A corporate proxy, VPN, or security product is overriding the network path
|
||||
- The target application uses SSL pinning
|
||||
|
||||
### Localhost Traffic Is Missing
|
||||
|
||||
Common cause:
|
||||
|
||||
- Local loopback traffic is bypassing the proxy
|
||||
|
||||
Official reference:
|
||||
|
||||
- [https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server](https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server)
|
||||
|
||||
## Next Step
|
||||
|
||||
Once Proxyman is installed, trusted, and verified, continue with:
|
||||
|
||||
- [Proxyman Debugging Guide](./proxyman-debugging.md)
|
||||
|
||||
## References
|
||||
|
||||
- Proxyman download page: [https://proxyman.com/download](https://proxyman.com/download)
|
||||
- Proxyman Windows page: [https://proxyman.com/windows](https://proxyman.com/windows)
|
||||
- Proxyman Windows certificate guide: [https://docs.proxyman.com/proxyman-windows/install-certificate](https://docs.proxyman.com/proxyman-windows/install-certificate)
|
||||
- Proxyman localhost troubleshooting: [https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server](https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server)
|
||||
- Proxyman SSL troubleshooting: [https://docs.proxyman.com/troubleshooting/get-ssl-error-from-https-request-and-response](https://docs.proxyman.com/troubleshooting/get-ssl-error-from-https-request-and-response)
|
||||
@@ -0,0 +1,43 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Extensões permitidas
|
||||
const allowedExtensions = /\.(ts|tsx)$/;
|
||||
|
||||
function walkAndFix(dir) {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
for (const file of files) {
|
||||
if (['node_modules', '.git', 'dist', 'out'].includes(file)) continue;
|
||||
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
walkAndFix(fullPath);
|
||||
} else if (allowedExtensions.test(file)) {
|
||||
let content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const original = content;
|
||||
|
||||
// Fix specific known broken identifiers
|
||||
content = content.replace(/closeGemini Nexus/g, 'closeGeminiNexus');
|
||||
content = content.replace(/startGemini Nexus/g, 'startGeminiNexus');
|
||||
content = content.replace(/Gemini NexusVersion/g, 'GeminiNexusVersion');
|
||||
content = content.replace(/Gemini NexusDb/g, 'GeminiNexusDb');
|
||||
content = content.replace(/Gemini NexusStorage/g, 'GeminiNexusStorage');
|
||||
content = content.replace(/Gemini NexusExecutable/g, 'GeminiNexusExecutable');
|
||||
content = content.replace(/Gemini NexusCoreFeatures/g, 'GeminiNexusCoreFeatures');
|
||||
content = content.replace(/getGemini Nexus/g, 'getGeminiNexus');
|
||||
|
||||
if (content !== original) {
|
||||
fs.writeFileSync(fullPath, content, 'utf-8');
|
||||
console.log(`Fixed TS errors in: ${fullPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkAndFix(projectRoot);
|
||||
console.log('Fix complete.');
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1774106199,
|
||||
"narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
description = "Antigravity Manager";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
}: let
|
||||
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
|
||||
version = "0.10.0";
|
||||
|
||||
sources = {
|
||||
"x86_64-linux" = {
|
||||
url = "https://github.com/Draculabo/AntigravityManager/releases/download/v${version}/Antigravity.Manager_${version}_amd64.deb";
|
||||
sha256 = "5fc08b4a9308c71fc00e44b95d4fb3178182aea8821708fa51fdf2bf5dbbb856";
|
||||
};
|
||||
"aarch64-linux" = {
|
||||
url = "https://github.com/Draculabo/AntigravityManager/releases/download/v${version}/Antigravity.Manager_${version}_arm64.deb";
|
||||
sha256 = "666c9659efa66bb6be6a8e7fb0c384ceb799a17245eab374bf9b3d7901c5c4cb";
|
||||
};
|
||||
};
|
||||
|
||||
mkPackage = pkgs: let
|
||||
system = pkgs.stdenv.hostPlatform.system;
|
||||
source = sources.${system} or (throw "Unsupported system: ${system}");
|
||||
in
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "antigravity-manager";
|
||||
inherit version;
|
||||
|
||||
src = pkgs.fetchurl {
|
||||
inherit (source) url sha256;
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
autoPatchelfHook
|
||||
makeWrapper
|
||||
zstd
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
alsa-lib
|
||||
at-spi2-atk
|
||||
atk
|
||||
cups
|
||||
dbus
|
||||
expat
|
||||
glib
|
||||
gtk3
|
||||
libsecret
|
||||
libdrm
|
||||
libxkbcommon
|
||||
mesa
|
||||
nspr
|
||||
nss
|
||||
pango
|
||||
systemd
|
||||
libx11
|
||||
libxcomposite
|
||||
libxdamage
|
||||
libxext
|
||||
libxfixes
|
||||
libxrandr
|
||||
libxcb
|
||||
];
|
||||
|
||||
unpackPhase = ''
|
||||
ar x $src
|
||||
tar xf data.tar.* --no-same-permissions
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin $out/lib $out/share
|
||||
cp -r usr/lib/antigravity-manager $out/lib/
|
||||
cp -r usr/share/* $out/share/
|
||||
|
||||
ln -s $out/lib/antigravity-manager/antigravity-manager $out/bin/antigravity-manager
|
||||
'';
|
||||
|
||||
postFixup = ''
|
||||
# Wrap the executable so it finds Wayland/X11 and libsecret
|
||||
wrapProgram $out/bin/antigravity-manager \
|
||||
--add-flags "--enable-features=WaylandWindowDecorations" \
|
||||
--add-flags "--ozone-platform-hint=auto" \
|
||||
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath [pkgs.libsecret]}
|
||||
|
||||
if [ -e $out/share/applications/antigravity-manager.desktop ]; then
|
||||
substituteInPlace $out/share/applications/antigravity-manager.desktop \
|
||||
--replace "/usr/lib/antigravity-manager/antigravity-manager" "antigravity-manager"
|
||||
fi
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Professional multi-account manager for Google Gemini & Claude AI";
|
||||
homepage = "https://github.com/Draculabo/AntigravityManager";
|
||||
license = licenses.cc-by-nc-sa-40;
|
||||
maintainers = [];
|
||||
platforms = ["x86_64-linux" "aarch64-linux"];
|
||||
mainProgram = "antigravity-manager";
|
||||
};
|
||||
};
|
||||
in {
|
||||
overlays.default = final: prev: {
|
||||
antigravity-manager = mkPackage final;
|
||||
};
|
||||
|
||||
packages = forAllSystems (system: let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
in {
|
||||
default = mkPackage pkgs;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import { stringify as yamlStringify } from 'yaml';
|
||||
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
|
||||
const ResolvedMakerAppImage = MakerAppImage;
|
||||
const keepLanguages = new Set(['en', 'en-US', 'zh-CN', 'ru']);
|
||||
const windowsExecutableName = 'antigravity-manager';
|
||||
const windowsExecutableName = 'gemini-nexus';
|
||||
|
||||
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
|
||||
|
||||
@@ -204,7 +204,7 @@ const config: ForgeConfig = {
|
||||
asar: {
|
||||
unpack: '**/{better-sqlite3,keytar}/**/*',
|
||||
},
|
||||
name: 'Antigravity Manager',
|
||||
name: 'Gemini Nexus',
|
||||
executableName: windowsExecutableName,
|
||||
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
|
||||
extraResource: ['src/assets'], // Copy assets folder to resources/assets
|
||||
|
||||
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 864 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 390 KiB |
@@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Antigravity Manager</title>
|
||||
<title>Gemini Nexus</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src 'self';" />
|
||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- Affected code:
|
||||
- `src/ipc/database/cloudHandler.ts`
|
||||
- `src/ipc/database/handler.ts`
|
||||
- `src/utils/antigravityVersion.ts`
|
||||
- `src/utils/geminiNexusVersion.ts`
|
||||
- `src/utils/paths.ts` (this stage covers default runtime configuration path only)
|
||||
- `src/types/db.ts`
|
||||
|
||||
|
||||
@@ -38,5 +38,5 @@ The system SHALL use a strategy pattern to split new/legacy/dual-write injection
|
||||
The system SHALL prefer new-format injection when new-format keys are detected.
|
||||
|
||||
#### Scenario: New-format key exists
|
||||
- **WHEN** `antigravityUnifiedStateSync.oauthToken` exists
|
||||
- **WHEN** `geminiNexusUnifiedStateSync.oauthToken` exists
|
||||
- **THEN** injection uses new format with higher priority
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Add multilingual support to Antigravity Manager and automatically set the default UI language based on the user's OS language. If the OS language is unsupported, fall back to English. This includes updating i18n configuration, adding Chinese translation resources, and integrating a language detection plugin.
|
||||
Add multilingual support to Gemini Nexus and automatically set the default UI language based on the user's OS language. If the OS language is unsupported, fall back to English. This includes updating i18n configuration, adding Chinese translation resources, and integrating a language detection plugin.
|
||||
|
||||
## Motivation
|
||||
|
||||
|
||||
@@ -2,32 +2,32 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Enable Antigravity Manager running in WSL (Windows Subsystem for Linux) to manage the Antigravity application installed on the Windows host.
|
||||
Enable Gemini Nexus running in WSL (Windows Subsystem for Linux) to manage the Gemini Nexus application installed on the Windows host.
|
||||
|
||||
## Motivation
|
||||
|
||||
Users running the manager in a WSL development environment need to interact with the actual Antigravity instance running on Windows, as Antigravity is a Windows application.
|
||||
Users running the manager in a WSL development environment need to interact with the actual Gemini Nexus instance running on Windows, as Gemini Nexus is a Windows application.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Path Resolution (`src/utils/paths.ts`)
|
||||
|
||||
* Implement `isWsl()` utility function.
|
||||
* In `getAppDataDir()`: If WSL, return the Windows AppData path (e.g., `/mnt/c/Users/<User>/AppData/Roaming/Antigravity`).
|
||||
* In `getAntigravityExecutablePath()`: If WSL, return the Windows executable path (e.g., `/mnt/c/Users/<User>/AppData/Local/Programs/Antigravity/Antigravity.exe`).
|
||||
* In `getAppDataDir()`: If WSL, return the Windows AppData path (e.g., `/mnt/c/Users/<User>/AppData/Roaming/GeminiNexus`).
|
||||
* In `getGeminiNexusExecutablePath()`: If WSL, return the Windows executable path (e.g., `/mnt/c/Users/<User>/AppData/Local/Programs/GeminiNexus/GeminiNexus.exe`).
|
||||
* **Challenge:** Determining the correct Windows username.
|
||||
* **Strategy:** Assume Windows username matches WSL username, or scan `/mnt/c/Users` for a likely candidate (excluding Public, Default, etc.).
|
||||
|
||||
### 2. Process Management (`src/ipc/process/handler.ts`)
|
||||
|
||||
* In `isProcessRunning()`: If WSL, execute `tasklist.exe` (via `/mnt/c/Windows/System32/tasklist.exe`) to check for `Antigravity.exe`.
|
||||
* In `startAntigravity()`: If WSL, execute the Windows executable directly.
|
||||
* In `closeAntigravity()`: If WSL, use `taskkill.exe` (via `/mnt/c/Windows/System32/taskkill.exe`).
|
||||
* In `isProcessRunning()`: If WSL, execute `tasklist.exe` (via `/mnt/c/Windows/System32/tasklist.exe`) to check for `GeminiNexus.exe`.
|
||||
* In `startGeminiNexus()`: If WSL, execute the Windows executable directly.
|
||||
* In `closeGeminiNexus()`: If WSL, use `taskkill.exe` (via `/mnt/c/Windows/System32/taskkill.exe`).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
* \[ ] Application detects it is running in WSL.
|
||||
* \[ ] Database file is correctly located on the Windows file system.
|
||||
* \[ ] "Start" button successfully launches Antigravity on Windows.
|
||||
* \[ ] "Stop" button successfully terminates Antigravity on Windows.
|
||||
* \[ ] "Start" button successfully launches Gemini Nexus on Windows.
|
||||
* \[ ] "Stop" button successfully terminates Gemini Nexus on Windows.
|
||||
* \[ ] Process status (Running/Stopped) is correctly reflected in the UI.
|
||||
@@ -1,6 +1,6 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: New-Format Injection for 1.16.5+
|
||||
The system SHALL write only the new format `antigravityUnifiedStateSync.oauthToken` when IDE version is >= 1.16.5.
|
||||
The system SHALL write only the new format `geminiNexusUnifiedStateSync.oauthToken` when IDE version is >= 1.16.5.
|
||||
|
||||
#### Scenario: New-version injection
|
||||
- **WHEN** IDE version is 1.16.5 or above
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Antigravity Manager is a comprehensive enhancement tool for Antigravity, designed to remove AI development resource limits. It not only solves the lack of native multi-account switching in Antigravity IDE, but also introduces enterprise-grade AI account pool management. By taking control of app configuration state and cloud API resources, it allows users to switch seamlessly between unlimited local accounts and cloud AI services for an effectively "unlimited" AI usage experience.
|
||||
Gemini Nexus is a comprehensive enhancement tool for Gemini Nexus, designed to remove AI development resource limits. It not only solves the lack of native multi-account switching in Gemini Nexus IDE, but also introduces enterprise-grade AI account pool management. By taking control of app configuration state and cloud API resources, it allows users to switch seamlessly between unlimited local accounts and cloud AI services for an effectively "unlimited" AI usage experience.
|
||||
|
||||
**Core goals:**
|
||||
|
||||
* **Local multi-account switching**: Seamlessly switch Antigravity IDE local user state while protecting data integrity (automatic backup).
|
||||
* **Local multi-account switching**: Seamlessly switch Gemini Nexus IDE local user state while protecting data integrity (automatic backup).
|
||||
* **AI resource pool management (new)**: Centrally manage account pools for Google Gemini, Claude 3.5, and similar services.
|
||||
* **Intelligent quota monitoring (new)**: Monitor API quota and health in real time with a visual dashboard.
|
||||
* **Automatic resource scheduling (new)**: Automatically switch to the best available account when the current one runs out of quota (token keepalive + polling).
|
||||
@@ -88,17 +88,17 @@ src/
|
||||
|
||||
## Domain Context
|
||||
|
||||
### 1. Antigravity IDE Data Structure (Local)
|
||||
### 1. Gemini Nexus IDE Data Structure (Local)
|
||||
|
||||
**Database locations:**
|
||||
|
||||
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
|
||||
* Windows: `%APPDATA%/Antigravity/User/globalStorage/state.vscdb`
|
||||
* Linux: `~/.config/Antigravity/User/globalStorage/state.vscdb`
|
||||
* macOS: `~/Library/Application Support/GeminiNexus/User/globalStorage/state.vscdb`
|
||||
* Windows: `%APPDATA%/GeminiNexus/User/globalStorage/state.vscdb`
|
||||
* Linux: `~/.config/GeminiNexus/User/globalStorage/state.vscdb`
|
||||
|
||||
**Key database fields:**
|
||||
|
||||
* `antigravityAuthStatus`: Authentication status and user information.
|
||||
* `geminiNexusAuthStatus`: Authentication status and user information.
|
||||
|
||||
### 2. AI Service Resource Structure (Cloud / New)
|
||||
|
||||
@@ -141,7 +141,7 @@ src/
|
||||
|
||||
### System Dependencies
|
||||
|
||||
* **Antigravity IDE**: Local target to manage.
|
||||
* **Gemini Nexus IDE**: Local target to manage.
|
||||
* **SQLite**: Local data reads.
|
||||
|
||||
### Node.js Dependencies (Key)
|
||||
@@ -159,7 +159,7 @@ src/
|
||||
|
||||
### Filesystem Dependencies
|
||||
|
||||
* `~/.antigravity-agent/`
|
||||
* `~/.geminiNexus-agent/`
|
||||
* `accounts.json`: Local IDE account index.
|
||||
* `cloud_accounts.db`: (new) Cloud account pool database (SQLite).
|
||||
* `backups/`: Backup files.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"productName": "Antigravity Manager",
|
||||
"version": "0.10.0",
|
||||
"description": "Antigravity Manager - Electron App",
|
||||
"name": "gemini-nexus",
|
||||
"productName": "Gemini Nexus",
|
||||
"version": "0.11.1",
|
||||
"description": "Gemini Nexus - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"start": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
@@ -23,18 +24,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 +44,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",
|
||||
@@ -62,6 +63,7 @@
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^37.3.1",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-packager-languages": "^0.6.0",
|
||||
@@ -73,12 +75,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 +96,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 +124,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",
|
||||
@@ -153,5 +155,12 @@
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.1.12"
|
||||
}
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@bitdisaster/exe-icon-extractor",
|
||||
"@nestjs/core",
|
||||
"@sentry/cli",
|
||||
"@swc/core",
|
||||
"protobufjs"
|
||||
]
|
||||
}
|
||||
@@ -245,7 +245,7 @@ const releaseConfig = {
|
||||
'@semantic-release/git',
|
||||
{
|
||||
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
|
||||
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
|
||||
message: 'chore(release): ${nextRelease.version}\n\n${nextRelease.notes}',
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Extensões permitidas
|
||||
const allowedExtensions = /\.(ts|tsx|js|jsx|json|md|html|jsonc|yml|yaml|css)$/;
|
||||
|
||||
// Arquivos/Pastas a ignorar (já curados ou irrelevantes)
|
||||
const ignoreDirs = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.gemini',
|
||||
'dist',
|
||||
'out',
|
||||
'build',
|
||||
'images',
|
||||
'docs',
|
||||
'.planning',
|
||||
];
|
||||
const ignoreFiles = [
|
||||
'README.md',
|
||||
'README.pt-BR.md',
|
||||
'CHANGELOG.md',
|
||||
'forge.config.ts',
|
||||
'package.json',
|
||||
'rename.js',
|
||||
'rename-deep.js',
|
||||
'task.md',
|
||||
'walkthrough.md',
|
||||
];
|
||||
|
||||
function walkAndReplace(dir) {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
for (const file of files) {
|
||||
if (ignoreDirs.includes(file)) continue;
|
||||
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
walkAndReplace(fullPath);
|
||||
|
||||
// Rename directory if it contains 'antigravity'
|
||||
if (file.toLowerCase().includes('antigravity')) {
|
||||
const newName = file.replace(/antigravity/gi, (match) => {
|
||||
if (match === 'antigravity') return 'geminiNexus';
|
||||
if (match === 'Antigravity') return 'GeminiNexus';
|
||||
return 'geminiNexus';
|
||||
});
|
||||
const newPath = path.join(dir, newName);
|
||||
fs.renameSync(fullPath, newPath);
|
||||
console.log(`Renamed directory: ${fullPath} -> ${newPath}`);
|
||||
}
|
||||
} else {
|
||||
if (ignoreFiles.includes(file)) continue;
|
||||
|
||||
if (allowedExtensions.test(file)) {
|
||||
let content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const originalContent = content;
|
||||
|
||||
// Save original GitHub links
|
||||
const repoUrlRegex = /github\.com\/Draculabo\/AntigravityManager/gi;
|
||||
const tempUrlMarker = '___TEMP_GITHUB_URL___';
|
||||
content = content.replace(repoUrlRegex, tempUrlMarker);
|
||||
|
||||
// 1. Strings exact match "Antigravity" -> "Gemini Nexus"
|
||||
content = content.replace(/['"]Antigravity['"]/g, (match) => {
|
||||
return match[0] + 'Gemini Nexus' + match[0];
|
||||
});
|
||||
|
||||
// 2. String "Antigravity " -> "Gemini Nexus "
|
||||
content = content.replace(/Antigravity /g, 'Gemini Nexus ');
|
||||
|
||||
// 3. String " Antigravity" -> " Gemini Nexus"
|
||||
content = content.replace(/ Antigravity/g, ' Gemini Nexus');
|
||||
|
||||
// 4. Identifiers Antigravity -> GeminiNexus
|
||||
content = content.replace(/Antigravity/g, 'GeminiNexus');
|
||||
|
||||
// 5. Identifiers antigravity -> geminiNexus
|
||||
content = content.replace(/antigravity/g, 'geminiNexus');
|
||||
|
||||
// Restore GitHub links
|
||||
content = content.replace(
|
||||
new RegExp(tempUrlMarker, 'g'),
|
||||
'github.com/Draculabo/AntigravityManager',
|
||||
);
|
||||
|
||||
// Cleanup any double replacements
|
||||
content = content.replace(/GeminiNexus Nexus/g, 'Gemini Nexus');
|
||||
|
||||
if (content !== originalContent) {
|
||||
fs.writeFileSync(fullPath, content, 'utf-8');
|
||||
console.log(`Updated file content: ${fullPath}`);
|
||||
}
|
||||
|
||||
// Rename file if it contains 'antigravity'
|
||||
if (file.toLowerCase().includes('antigravity')) {
|
||||
const newName = file.replace(/antigravity/gi, (match) => {
|
||||
if (match === 'antigravity') return 'geminiNexus'; // For files like antigravityVersion.ts -> geminiNexusVersion.ts
|
||||
if (match === 'Antigravity') return 'GeminiNexus';
|
||||
return 'geminiNexus';
|
||||
});
|
||||
const newPath = path.join(dir, newName);
|
||||
fs.renameSync(fullPath, newPath);
|
||||
console.log(`Renamed file: ${fullPath} -> ${newPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Starting global deep rename of Antigravity to GeminiNexus...');
|
||||
walkAndReplace(projectRoot);
|
||||
console.log('Rename complete.');
|
||||
@@ -0,0 +1,118 @@
|
||||
# Starts the installed Antigravity app with Proxyman proxy settings for local debugging.
|
||||
#
|
||||
# Usage:
|
||||
# powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1
|
||||
# powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1 -Port 9090 -AllowInsecureTls
|
||||
#
|
||||
# Notes:
|
||||
# - This script is intended for local debugging only.
|
||||
# - It terminates existing Antigravity processes first so the new environment variables take effect.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Port = 9090,
|
||||
[string]$ProxyHost = '127.0.0.1',
|
||||
[string]$AppPath = 'C:\Users\Administrator\AppData\Local\Programs\Antigravity\Antigravity.exe',
|
||||
[string]$CaPath = 'C:\Users\Administrator\AppData\Roaming\Proxyman\certificate\certs\ca.pem',
|
||||
[string]$NoProxy = 'localhost,127.0.0.1,::1',
|
||||
[switch]$AllowInsecureTls,
|
||||
[switch]$KeepExisting
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-ColorLine {
|
||||
param(
|
||||
[string]$Color,
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
Write-Host $Message -ForegroundColor $Color
|
||||
}
|
||||
|
||||
function Info {
|
||||
param([string]$Message)
|
||||
|
||||
Write-ColorLine -Color 'Cyan' -Message "[INFO] $Message"
|
||||
}
|
||||
|
||||
function Success {
|
||||
param([string]$Message)
|
||||
|
||||
Write-ColorLine -Color 'Green' -Message "[OK] $Message"
|
||||
}
|
||||
|
||||
function Warn {
|
||||
param([string]$Message)
|
||||
|
||||
Write-ColorLine -Color 'Yellow' -Message "[WARN] $Message"
|
||||
}
|
||||
|
||||
function Fail {
|
||||
param([string]$Message)
|
||||
|
||||
Write-ColorLine -Color 'Red' -Message "[ERROR] $Message"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$proxyUrl = "http://${ProxyHost}:${Port}"
|
||||
$proxyBypassList = '<local>;localhost;127.0.0.1;::1'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $AppPath)) {
|
||||
Fail "Antigravity executable not found: $AppPath"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $CaPath)) {
|
||||
Fail "Proxyman CA file not found: $CaPath"
|
||||
}
|
||||
|
||||
Info "Proxy URL: $proxyUrl"
|
||||
Info "Antigravity path: $AppPath"
|
||||
Info "CA path: $CaPath"
|
||||
|
||||
if (-not $KeepExisting) {
|
||||
$existingProcesses = Get-Process -Name 'Antigravity' -ErrorAction SilentlyContinue
|
||||
if ($existingProcesses) {
|
||||
Warn "Stopping existing Antigravity processes so the new proxy environment takes effect..."
|
||||
$existingProcesses | Stop-Process -Force
|
||||
Start-Sleep -Milliseconds 800
|
||||
} else {
|
||||
Info 'No running Antigravity process found.'
|
||||
}
|
||||
} else {
|
||||
Warn 'KeepExisting enabled; existing Antigravity processes were not terminated.'
|
||||
}
|
||||
|
||||
$env:HTTP_PROXY = $proxyUrl
|
||||
$env:HTTPS_PROXY = $proxyUrl
|
||||
$env:ALL_PROXY = $proxyUrl
|
||||
$env:NO_PROXY = $NoProxy
|
||||
$env:ELECTRON_PROXY_SERVER = $proxyUrl
|
||||
$env:ELECTRON_PROXY_BYPASS_LIST = $proxyBypassList
|
||||
$env:NODE_EXTRA_CA_CERTS = $CaPath
|
||||
|
||||
if ($AllowInsecureTls) {
|
||||
$env:NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
Warn 'NODE_TLS_REJECT_UNAUTHORIZED=0 is enabled for this launch. Use for debugging only.'
|
||||
} else {
|
||||
Remove-Item Env:NODE_TLS_REJECT_UNAUTHORIZED -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Info 'Starting Antigravity with Proxyman proxy settings...'
|
||||
$process = Start-Process -FilePath $AppPath -ArgumentList @(
|
||||
"--proxy-server=$proxyUrl",
|
||||
"--proxy-bypass-list=$proxyBypassList"
|
||||
) -WorkingDirectory (Split-Path -Parent $AppPath) -PassThru
|
||||
|
||||
Success "Antigravity started. PID: $($process.Id)"
|
||||
Write-Host ''
|
||||
Write-Host 'Environment used for this launch:' -ForegroundColor Gray
|
||||
Write-Host " HTTP_PROXY=$($env:HTTP_PROXY)" -ForegroundColor Gray
|
||||
Write-Host " HTTPS_PROXY=$($env:HTTPS_PROXY)" -ForegroundColor Gray
|
||||
Write-Host " NO_PROXY=$($env:NO_PROXY)" -ForegroundColor Gray
|
||||
Write-Host " NODE_EXTRA_CA_CERTS=$($env:NODE_EXTRA_CA_CERTS)" -ForegroundColor Gray
|
||||
if ($AllowInsecureTls) {
|
||||
Write-Host ' NODE_TLS_REJECT_UNAUTHORIZED=0' -ForegroundColor Gray
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Host 'If Proxyman still does not show traffic, verify that no older Antigravity instance is still alive.' -ForegroundColor Gray
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import type { DeviceProfile } from '@/types/account';
|
||||
import { isValidProxyUrl } from '@/utils/url';
|
||||
|
||||
export function addGoogleAccount(input: { authCode: string }) {
|
||||
export function addGoogleAccount(input: { authCode: string; oauthClientKey?: string }) {
|
||||
return ipc.client.cloud.addGoogleAccount(input);
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@ export function deleteCloudAccount(input: { accountId: string }) {
|
||||
}
|
||||
|
||||
export function refreshAccountQuota(input: { accountId: string }) {
|
||||
console.log(`[Action] Calling refreshAccountQuota for: ${input.accountId}`);
|
||||
return ipc.client.cloud.refreshAccountQuota(input);
|
||||
}
|
||||
|
||||
@@ -37,8 +39,29 @@ export function syncLocalAccount() {
|
||||
return ipc.client.cloud.syncLocalAccount();
|
||||
}
|
||||
|
||||
export function startAuthFlow() {
|
||||
return ipc.client.cloud.startAuthFlow();
|
||||
export interface OAuthClientDescriptor {
|
||||
key: string;
|
||||
label: string;
|
||||
client_id: string;
|
||||
is_active: boolean;
|
||||
is_builtin: boolean;
|
||||
}
|
||||
|
||||
export function startAuthFlow(input?: { oauthClientKey?: string }) {
|
||||
return ipc.client.cloud.startAuthFlow(input);
|
||||
}
|
||||
|
||||
export function listOAuthClients() {
|
||||
return ipc.client.cloud.listOAuthClients() as Promise<OAuthClientDescriptor[]>;
|
||||
}
|
||||
|
||||
export async function getActiveOAuthClient() {
|
||||
const response = await ipc.client.cloud.getActiveOAuthClient();
|
||||
return response.client_key;
|
||||
}
|
||||
|
||||
export function setActiveOAuthClient(input: { clientKey: string }) {
|
||||
return ipc.client.cloud.setActiveOAuthClient(input);
|
||||
}
|
||||
|
||||
export function getSwitchStatus() {
|
||||
@@ -88,3 +111,26 @@ export function deleteCloudIdentityProfileRevision(input: {
|
||||
export function openCloudIdentityStorageFolder() {
|
||||
return ipc.client.cloud.openIdentityStorageFolder();
|
||||
}
|
||||
|
||||
export function setAccountProxy(input: { accountId: string; proxyUrl: string | null }) {
|
||||
if (input.proxyUrl && !isValidProxyUrl(input.proxyUrl)) {
|
||||
throw new Error('Invalid proxy URL format');
|
||||
}
|
||||
return ipc.client.cloud.setAccountProxy(input);
|
||||
}
|
||||
|
||||
export function exportCloudAccounts(input: { stripTokens?: boolean }) {
|
||||
return ipc.client.cloud.exportCloudAccounts(input);
|
||||
}
|
||||
|
||||
export function importCloudAccounts(input: {
|
||||
jsonContent: string;
|
||||
strategy?: 'merge' | 'overwrite' | 'skip-existing';
|
||||
}) {
|
||||
try {
|
||||
JSON.parse(input.jsonContent);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON content provided for import');
|
||||
}
|
||||
return ipc.client.cloud.importCloudAccounts(input);
|
||||
}
|
||||
@@ -4,10 +4,10 @@ export function isProcessRunning() {
|
||||
return ipc.client.proc.isProcessRunning();
|
||||
}
|
||||
|
||||
export function closeAntigravity() {
|
||||
return ipc.client.proc.closeAntigravity();
|
||||
export function closeGeminiNexus() {
|
||||
return ipc.client.proc.closeGeminiNexus();
|
||||
}
|
||||
|
||||
export function startAntigravity() {
|
||||
return ipc.client.proc.startAntigravity();
|
||||
export function startGeminiNexus() {
|
||||
return ipc.client.proc.startGeminiNexus();
|
||||
}
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 390 KiB |
@@ -11,11 +11,20 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { enUS, zhCN, ru } from 'date-fns/locale';
|
||||
import { formatDistanceToNow, type Locale } from 'date-fns';
|
||||
import { enUS, zhCN, ru, vi } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const DEFAULT_GLARE_POSITION = { x: 50, y: 50 };
|
||||
const DEFAULT_CARD_TRANSFORM = 'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)';
|
||||
const DATE_LOCALE_MAP: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
'zh-CN': zhCN,
|
||||
ru: ru,
|
||||
vi: vi,
|
||||
};
|
||||
|
||||
interface AccountCardProps {
|
||||
account: Account;
|
||||
isCurrent: boolean;
|
||||
@@ -35,8 +44,8 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [transform, setTransform] = useState('');
|
||||
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 });
|
||||
const [cardTransform, setCardTransform] = useState('');
|
||||
const [glarePosition, setGlarePosition] = useState(DEFAULT_GLARE_POSITION);
|
||||
|
||||
const initials = account.name
|
||||
? account.name
|
||||
@@ -47,38 +56,38 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
.slice(0, 2)
|
||||
: account.email[0].toUpperCase();
|
||||
|
||||
// 处理鼠标移动,计算 3D 旋转角度
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Handle pointer movement and calculate 3D rotation angles
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!cardRef.current) return;
|
||||
|
||||
const rect = cardRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
// 计算鼠标相对于卡片中心的位置 (-1 到 1)
|
||||
const relativeX = (e.clientX - centerX) / (rect.width / 2);
|
||||
const relativeY = (e.clientY - centerY) / (rect.height / 2);
|
||||
// Calculate pointer position relative to the card center (-1 to 1)
|
||||
const relativeX = (event.clientX - centerX) / (rect.width / 2);
|
||||
const relativeY = (event.clientY - centerY) / (rect.height / 2);
|
||||
|
||||
// 最大旋转角度
|
||||
// Maximum rotation angle
|
||||
const maxRotation = 15;
|
||||
const rotateX = -relativeY * maxRotation; // 上下倾斜
|
||||
const rotateY = relativeX * maxRotation; // 左右倾斜
|
||||
const rotateX = -relativeY * maxRotation; // Vertical tilt
|
||||
const rotateY = relativeX * maxRotation; // Horizontal tilt
|
||||
|
||||
setTransform(
|
||||
setCardTransform(
|
||||
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.02, 1.02, 1.02)`,
|
||||
);
|
||||
|
||||
// 更新光泽位置
|
||||
// Update glare position
|
||||
setGlarePosition({
|
||||
x: ((e.clientX - rect.left) / rect.width) * 100,
|
||||
y: ((e.clientY - rect.top) / rect.height) * 100,
|
||||
x: ((event.clientX - rect.left) / rect.width) * 100,
|
||||
y: ((event.clientY - rect.top) / rect.height) * 100,
|
||||
});
|
||||
};
|
||||
|
||||
// 鼠标离开时重置
|
||||
// Reset when pointer leaves
|
||||
const handleMouseLeave = () => {
|
||||
setTransform('perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)');
|
||||
setGlarePosition({ x: 50, y: 50 });
|
||||
setCardTransform(DEFAULT_CARD_TRANSFORM);
|
||||
setGlarePosition(DEFAULT_GLARE_POSITION);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -87,18 +96,18 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
transform,
|
||||
transform: cardTransform,
|
||||
transformStyle: 'preserve-3d',
|
||||
transition: transform ? 'transform 0.1s ease-out' : 'transform 0.5s ease-out',
|
||||
transition: cardTransform ? 'transform 0.1s ease-out' : 'transform 0.5s ease-out',
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{/* 光泽效果层 */}
|
||||
{/* Glare overlay */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
background: `radial-gradient(circle at ${glarePosition.x}% ${glarePosition.y}%, rgba(255,255,255,0.15) 0%, transparent 60%)`,
|
||||
opacity: transform ? 0.6 : 0,
|
||||
opacity: cardTransform ? 0.6 : 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -137,7 +146,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
{t('account.lastUsed', {
|
||||
time: formatDistanceToNow(new Date(account.last_used), {
|
||||
addSuffix: true,
|
||||
locale: { en: enUS, 'zh-CN': zhCN, ru: ru }[i18n.language] || enUS,
|
||||
locale: DATE_LOCALE_MAP[i18n.language] || enUS,
|
||||
}),
|
||||
})}
|
||||
</p>
|
||||
@@ -154,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">
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCloudAccounts, useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { isValidProxyUrl } from '@/utils/url';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
function AccountProxyItem({ account }: { account: CloudAccount }) {
|
||||
const { t } = useTranslation();
|
||||
const setAccountProxy = useSetAccountProxy();
|
||||
const [proxyUrl, setProxyUrl] = useState(account.proxy_url || '');
|
||||
const [proxySaved, setProxySaved] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-b py-3 last:border-0">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
{account.avatar_url ? (
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
alt={account.name || ''}
|
||||
className="bg-muted h-8 w-8 shrink-0 rounded-full border"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary/10 text-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full border">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">{account.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={proxyUrl}
|
||||
onChange={(e) => {
|
||||
setProxyUrl(e.target.value);
|
||||
setProxySaved(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const trimmed = proxyUrl.trim();
|
||||
if (trimmed && !isValidProxyUrl(trimmed)) {
|
||||
setProxyUrl(account.proxy_url || '');
|
||||
return;
|
||||
}
|
||||
if (trimmed !== (account.proxy_url || '')) {
|
||||
setAccountProxy.mutate({
|
||||
accountId: account.id,
|
||||
proxyUrl: trimmed || null,
|
||||
});
|
||||
setProxySaved(true);
|
||||
setTimeout(() => setProxySaved(false), 2000);
|
||||
}
|
||||
}}
|
||||
placeholder={t('cloud.card.proxyPlaceholder') || 'e.g. http://127.0.0.1:7890'}
|
||||
className="h-8 w-48 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="w-16">
|
||||
{proxySaved && (
|
||||
<span className="text-[10px] whitespace-nowrap text-green-500">
|
||||
{t('cloud.card.proxySaved')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountProxySettings() {
|
||||
const { t } = useTranslation();
|
||||
const { data: accounts, isLoading } = useCloudAccounts();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.proxy.accountProxies.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.proxy.accountProxies.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center p-6">
|
||||
<Loader2 className="text-muted-foreground animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.proxy.accountProxies.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.proxy.accountProxies.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col">
|
||||
{accounts.map((account) => (
|
||||
<AccountProxyItem key={account.id} account={account} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
import { CloudAccount, CloudQuotaModelInfo } from '@/types/cloudAccount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -17,19 +17,139 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide-react';
|
||||
import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns';
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint, Eye, EyeOff } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { useProviderGrouping } from '@/hooks/useProviderGrouping';
|
||||
import { ProviderGroup } from '@/components/ProviderGroup';
|
||||
import {
|
||||
clampQuotaPercentage,
|
||||
formatAiCreditsAmount,
|
||||
formatResetTimeLabel,
|
||||
formatResetTimeTitle,
|
||||
getQuotaStatus,
|
||||
type QuotaStatus,
|
||||
} from '@/utils/quota-display';
|
||||
import { getValidationBlockedStatusLabel } from '@/components/accountValidationStatus';
|
||||
|
||||
interface ModelQuotaInfo {
|
||||
percentage: number;
|
||||
resetTime: string;
|
||||
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
||||
|
||||
const GEMINI_LEGACY_MODEL_PATTERN = /gemini-[12](\.|$|-)/i;
|
||||
const GEMINI_PRO_COMBINED_MODEL_ID = 'gemini-3.1-pro-low/high';
|
||||
|
||||
const MODEL_DISPLAY_REPLACEMENTS: Array<[string, string]> = [
|
||||
[GEMINI_PRO_COMBINED_MODEL_ID, 'Gemini 3.1 Pro (Low/High)'],
|
||||
['gemini-3.1-pro-preview', 'Gemini 3.1 Pro Preview'],
|
||||
['gemini-3-pro-image', 'Gemini 3 Pro Image'],
|
||||
['gemini-3.1-pro', 'Gemini 3.1 Pro'],
|
||||
['gemini-3-pro', 'Gemini 3 Pro'],
|
||||
['gemini-3-flash', 'Gemini 3 Flash'],
|
||||
['claude-sonnet-4-6-thinking', 'Claude 4.6 Sonnet (Thinking)'],
|
||||
['claude-sonnet-4-6', 'Claude 4.6 Sonnet'],
|
||||
['claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)'],
|
||||
['claude-sonnet-4-5', 'Claude 4.5 Sonnet'],
|
||||
['claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)'],
|
||||
['claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)'],
|
||||
['claude-3-5-sonnet', 'Claude 3.5 Sonnet'],
|
||||
];
|
||||
|
||||
const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
high: 'text-green-500',
|
||||
medium: 'text-yellow-500',
|
||||
low: 'text-red-500',
|
||||
};
|
||||
|
||||
const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
high: 'bg-emerald-500',
|
||||
medium: 'bg-amber-500',
|
||||
low: 'bg-rose-500',
|
||||
};
|
||||
|
||||
function isGeminiProLowModel(modelName: string): boolean {
|
||||
const normalizedModelName = modelName.toLowerCase();
|
||||
return normalizedModelName.includes('gemini-3.1-pro-low');
|
||||
}
|
||||
|
||||
function isGeminiProHighModel(modelName: string): boolean {
|
||||
const normalizedModelName = modelName.toLowerCase();
|
||||
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');
|
||||
}
|
||||
|
||||
function mergeGeminiProQuotaEntries(
|
||||
entries: ModelQuotaEntry[],
|
||||
): Record<string, CloudQuotaModelInfo> {
|
||||
const mergedModels: Record<string, CloudQuotaModelInfo> = {};
|
||||
const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName));
|
||||
const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName));
|
||||
const proLowModelInfo = entries.find(([modelName]) => isGeminiProLowModel(modelName))?.[1];
|
||||
|
||||
for (const [modelName, modelInfo] of entries) {
|
||||
if (isGeminiProLowModel(modelName) && hasProHighModel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isGeminiProHighModel(modelName) && hasProLowModel) {
|
||||
const mergedPercentage = proLowModelInfo
|
||||
? Math.min(modelInfo.percentage, proLowModelInfo.percentage)
|
||||
: modelInfo.percentage;
|
||||
|
||||
mergedModels[GEMINI_PRO_COMBINED_MODEL_ID] = {
|
||||
...modelInfo,
|
||||
...proLowModelInfo,
|
||||
percentage: mergedPercentage,
|
||||
display_name: 'Gemini 3.1 Pro',
|
||||
resetTime:
|
||||
modelInfo.resetTime && proLowModelInfo?.resetTime
|
||||
? modelInfo.resetTime < proLowModelInfo.resetTime
|
||||
? modelInfo.resetTime
|
||||
: proLowModelInfo.resetTime
|
||||
: modelInfo.resetTime || proLowModelInfo?.resetTime || '',
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
mergedModels[modelName] = modelInfo;
|
||||
}
|
||||
|
||||
return mergedModels;
|
||||
}
|
||||
|
||||
function formatModelDisplayName(modelName: string): string {
|
||||
let displayName = modelName.replace('models/', '');
|
||||
for (const [source, target] of MODEL_DISPLAY_REPLACEMENTS) {
|
||||
displayName = displayName.replace(source, target);
|
||||
}
|
||||
|
||||
return displayName
|
||||
.replace(/-/g, ' ')
|
||||
.split(' ')
|
||||
.map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
interface CloudAccountCardProps {
|
||||
account: CloudAccount;
|
||||
onRefresh: (id: string) => void;
|
||||
@@ -56,7 +176,7 @@ export function CloudAccountCard({
|
||||
isSwitching,
|
||||
}: CloudAccountCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { config, saveConfig } = useAppConfig();
|
||||
const {
|
||||
enabled: providerGroupingsEnabled,
|
||||
getAccountStats,
|
||||
@@ -64,114 +184,56 @@ export function CloudAccountCard({
|
||||
toggleProviderCollapse,
|
||||
} = useProviderGrouping();
|
||||
|
||||
// Helpers to get quota color
|
||||
const getQuotaColor = (percentage: number) => {
|
||||
if (percentage > 80) return 'text-green-500';
|
||||
if (percentage > 20) return 'text-yellow-500';
|
||||
return 'text-red-500';
|
||||
const getQuotaTextColorClass = (percentage: number) => {
|
||||
const quotaStatus = getQuotaStatus(percentage);
|
||||
return QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus];
|
||||
};
|
||||
|
||||
const getQuotaBarColor = (percentage: number) => {
|
||||
if (percentage > 80) return 'bg-emerald-500';
|
||||
if (percentage > 20) return 'bg-amber-500';
|
||||
return 'bg-rose-500';
|
||||
const getQuotaBarColorClass = (percentage: number) => {
|
||||
const quotaStatus = getQuotaStatus(percentage);
|
||||
return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus];
|
||||
};
|
||||
|
||||
const getQuotaLabel = (percentage: number) => {
|
||||
const formatQuotaLabel = (percentage: number) => {
|
||||
if (percentage === 0) {
|
||||
return t('cloud.card.rateLimitedQuota');
|
||||
}
|
||||
return `${percentage}%`;
|
||||
};
|
||||
|
||||
const formatTimeRemaining = (dateStr: string) => {
|
||||
const targetDate = new Date(dateStr);
|
||||
if (Number.isNaN(targetDate.getTime())) return null;
|
||||
|
||||
const now = new Date();
|
||||
if (isBefore(targetDate, now)) return '0h 0m';
|
||||
|
||||
const diffHrs = Math.max(0, differenceInHours(targetDate, now));
|
||||
const diffMins = Math.max(0, differenceInMinutes(targetDate, now) - diffHrs * 60);
|
||||
if (diffHrs >= 24) {
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
const remainingHrs = diffHrs % 24;
|
||||
return `${diffDays}d ${remainingHrs}h`;
|
||||
}
|
||||
return `${diffHrs}h ${diffMins}m`;
|
||||
const formatResetTimeLabelText = (resetTime?: string) => {
|
||||
return formatResetTimeLabel(resetTime, {
|
||||
prefix: t('cloud.card.resetPrefix'),
|
||||
unknown: t('cloud.card.resetUnknown'),
|
||||
});
|
||||
};
|
||||
|
||||
const getResetTimeLabel = (resetTime?: string) => {
|
||||
if (!resetTime) return t('cloud.card.resetUnknown');
|
||||
const remaining = formatTimeRemaining(resetTime);
|
||||
if (!remaining) return t('cloud.card.resetUnknown');
|
||||
return `${t('cloud.card.resetPrefix')}: ${remaining}`;
|
||||
const formatResetTimeTitleText = (resetTime?: string) => {
|
||||
return formatResetTimeTitle(resetTime, t('cloud.card.resetTime'));
|
||||
};
|
||||
|
||||
const getResetTimeTitle = (resetTime?: string) => {
|
||||
if (!resetTime) return undefined;
|
||||
const resetDate = new Date(resetTime);
|
||||
if (Number.isNaN(resetDate.getTime())) return undefined;
|
||||
return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`;
|
||||
};
|
||||
const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[];
|
||||
|
||||
// --- Logic for model groups ---
|
||||
const rawModels = Object.entries(account.quota?.models || {}).filter(
|
||||
const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
||||
);
|
||||
) as ModelQuotaEntry[];
|
||||
|
||||
// Group Gemini 3 Pro Low/High if both exist
|
||||
const processedModels: Record<string, ModelQuotaInfo> = {};
|
||||
const hasLow = rawModels.some(([name]) => name.includes('gemini-3-pro-low'));
|
||||
const hasHigh = rawModels.some(([name]) => name.includes('gemini-3-pro-high'));
|
||||
const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries);
|
||||
|
||||
for (const [name, info] of rawModels) {
|
||||
if (name.includes('gemini-3-pro-low') && hasHigh) continue;
|
||||
if (name.includes('gemini-3-pro-high') && hasLow) {
|
||||
// Use the lower percentage if both exist, to be safe
|
||||
const lowInfo = rawModels.find(([n]) => n.includes('gemini-3-pro-low'))?.[1];
|
||||
const mergedPercentage = lowInfo
|
||||
? Math.min(info.percentage, lowInfo.percentage)
|
||||
: info.percentage;
|
||||
processedModels['gemini-3-pro-low/high'] = { ...info, percentage: mergedPercentage };
|
||||
continue;
|
||||
}
|
||||
processedModels[name] = info;
|
||||
}
|
||||
|
||||
const geminiModels = Object.entries(processedModels)
|
||||
.filter(([name]) => name.includes('gemini') && !/gemini-[12](\.|$|-)/i.test(name))
|
||||
const geminiModels = Object.entries(mergedModelQuotas)
|
||||
.filter(([name]) => name.includes('gemini') && !GEMINI_LEGACY_MODEL_PATTERN.test(name))
|
||||
.sort((a, b) => b[1].percentage - a[1].percentage);
|
||||
|
||||
const claudeModels = Object.entries(processedModels)
|
||||
const claudeModels = Object.entries(mergedModelQuotas)
|
||||
.filter(([name]) => name.includes('claude'))
|
||||
.sort((a, b) => b[1].percentage - a[1].percentage);
|
||||
|
||||
const hasHighTier = geminiModels.some(
|
||||
([name, info]) => name.includes('gemini-3-pro') && info.percentage > 50,
|
||||
([name, info]) => isGeminiProFamilyModel(name) && info.percentage > 50,
|
||||
);
|
||||
const hasRenderableModels = geminiModels.length > 0 || claudeModels.length > 0;
|
||||
const hasVisibleQuotaModels = geminiModels.length > 0 || claudeModels.length > 0;
|
||||
|
||||
const formatModelName = (name: string) => {
|
||||
return name
|
||||
.replace('models/', '')
|
||||
.replace('gemini-3-pro-low/high', 'Gemini 3 Pro (Low/High)')
|
||||
.replace('gemini-3-pro-preview', 'Gemini 3 Pro Preview')
|
||||
.replace('gemini-3-pro-image', 'Gemini 3 Pro Image')
|
||||
.replace('gemini-3-pro', 'Gemini 3 Pro')
|
||||
.replace('gemini-3-flash', 'Gemini 3 Flash')
|
||||
.replace('claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)')
|
||||
.replace('claude-sonnet-4-5', 'Claude 4.5 Sonnet')
|
||||
.replace('claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)')
|
||||
.replace('claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)')
|
||||
.replace('claude-3-5-sonnet', 'Claude 3.5 Sonnet')
|
||||
.replace(/-/g, ' ')
|
||||
.split(' ')
|
||||
.map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const renderModelGroup = (title: string, models: [string, ModelQuotaInfo][]) => {
|
||||
const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => {
|
||||
if (models.length === 0) return null;
|
||||
return (
|
||||
<div key={title} className="space-y-1">
|
||||
@@ -190,25 +252,25 @@ export function CloudAccountCard({
|
||||
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
|
||||
title={modelName}
|
||||
>
|
||||
{formatModelName(modelName)}
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span
|
||||
className="text-muted-foreground text-[9px] leading-none opacity-80"
|
||||
title={getResetTimeTitle(info.resetTime)}
|
||||
title={formatResetTimeTitleText(info.resetTime)}
|
||||
>
|
||||
{getResetTimeLabel(info.resetTime)}
|
||||
{formatResetTimeLabelText(info.resetTime)}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className={`font-mono text-xs leading-none font-bold ${getQuotaColor(info.percentage)}`}
|
||||
className={`font-mono text-xs leading-none font-bold ${getQuotaTextColorClass(info.percentage)}`}
|
||||
>
|
||||
{info.percentage}%
|
||||
</span>
|
||||
<div className="bg-muted h-1 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(info.percentage)}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, info.percentage))}%` }}
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,6 +281,67 @@ export function CloudAccountCard({
|
||||
);
|
||||
};
|
||||
|
||||
const emptyQuotaState = (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
|
||||
<Box className="mb-2 h-8 w-8 opacity-20" />
|
||||
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null;
|
||||
const providerGroupedQuotaSection =
|
||||
providerStats && providerStats.visibleModels > 0 ? (
|
||||
<>
|
||||
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
|
||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
|
||||
>
|
||||
{formatQuotaLabel(providerStats.overallPercentage)}
|
||||
</span>
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(providerStats.overallPercentage)}`}
|
||||
style={{
|
||||
width: `${clampQuotaPercentage(providerStats.overallPercentage)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{providerStats.providers.map((group) => (
|
||||
<ProviderGroup
|
||||
key={group.providerKey}
|
||||
stats={group}
|
||||
isCollapsed={isProviderCollapsed(account.id, group.providerKey)}
|
||||
onToggleCollapse={() => toggleProviderCollapse(account.id, group.providerKey)}
|
||||
getQuotaTextColorClass={getQuotaTextColorClass}
|
||||
getQuotaBarColorClass={getQuotaBarColorClass}
|
||||
formatQuotaLabel={formatQuotaLabel}
|
||||
formatResetTimeLabel={formatResetTimeLabelText}
|
||||
formatResetTimeTitle={formatResetTimeTitleText}
|
||||
leftLabel={t('cloud.card.left')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
emptyQuotaState
|
||||
);
|
||||
|
||||
const aiCredits = account.quota?.ai_credits;
|
||||
const shouldShowAiCredits =
|
||||
!!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
|
||||
|
||||
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||
account.status,
|
||||
account.status_reason,
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
|
||||
@@ -252,7 +375,75 @@ export function CloudAccountCard({
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
||||
|
||||
{shouldShowAiCredits && aiCredits && (
|
||||
<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">
|
||||
·{' '}
|
||||
{t('cloud.card.creditsExpiry', {
|
||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allModelEntries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
{(() => {
|
||||
const hiddenCount = allModelEntries.filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||
).length;
|
||||
return hiddenCount > 0 ? (
|
||||
<EyeOff className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
);
|
||||
})()}
|
||||
<span className="sr-only">{t('cloud.card.modelVisibility')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
||||
<div className="max-h-64 overflow-auto px-2 py-1">
|
||||
{allModelEntries.map(([modelName]) => {
|
||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={modelName}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) => {
|
||||
if (config) {
|
||||
const newVisibility = { ...config.model_visibility };
|
||||
newVisibility[modelName] = checked as boolean;
|
||||
saveConfig({ ...config, model_visibility: newVisibility });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-xs" title={modelName}>
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
@@ -293,7 +484,11 @@ export function CloudAccountCard({
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
|
||||
variant={
|
||||
account.status === 'rate_limited' || account.status === 'expired'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{account.provider.toUpperCase()}
|
||||
@@ -303,9 +498,9 @@ export function CloudAccountCard({
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'rate_limited' && (
|
||||
{validationBlockedStatusLabel && (
|
||||
<span className="text-destructive text-xs font-medium">
|
||||
{t('cloud.card.rateLimited')}
|
||||
{validationBlockedStatusLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -344,71 +539,20 @@ export function CloudAccountCard({
|
||||
|
||||
<div className="space-y-2">
|
||||
{providerGroupingsEnabled ? (
|
||||
(() => {
|
||||
const accountStats = getAccountStats(account);
|
||||
if (accountStats.visibleModels === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
|
||||
<Box className="mb-2 h-8 w-8 opacity-20" />
|
||||
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
|
||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`font-mono font-bold ${getQuotaColor(accountStats.overallPercentage)}`}
|
||||
>
|
||||
{getQuotaLabel(accountStats.overallPercentage)}
|
||||
</span>
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(accountStats.overallPercentage)}`}
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, accountStats.overallPercentage))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{accountStats.providers.map((providerStats) => (
|
||||
<ProviderGroup
|
||||
key={providerStats.providerKey}
|
||||
stats={providerStats}
|
||||
isCollapsed={isProviderCollapsed(account.id, providerStats.providerKey)}
|
||||
onToggleCollapse={() =>
|
||||
toggleProviderCollapse(account.id, providerStats.providerKey)
|
||||
}
|
||||
getQuotaColor={getQuotaColor}
|
||||
getQuotaBarColor={getQuotaBarColor}
|
||||
getQuotaLabel={getQuotaLabel}
|
||||
getResetTimeLabel={getResetTimeLabel}
|
||||
getResetTimeTitle={getResetTimeTitle}
|
||||
leftLabel={t('cloud.card.left')}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : hasRenderableModels ? (
|
||||
providerGroupedQuotaSection
|
||||
) : hasVisibleQuotaModels ? (
|
||||
<div className="space-y-3">
|
||||
{renderModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
|
||||
{renderQuotaModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
|
||||
<div className="pt-1" />
|
||||
{renderModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
|
||||
{renderQuotaModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
|
||||
<Box className="mb-2 h-8 w-8 opacity-20" />
|
||||
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
||||
</div>
|
||||
emptyQuotaState
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="bg-muted/20 text-muted-foreground justify-between border-t p-2 px-4 text-xs">
|
||||
<CardFooter className="bg-muted/20 text-muted-foreground justify-center border-t p-2 px-4 text-xs">
|
||||
<span>
|
||||
{t('cloud.card.used')}{' '}
|
||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||
@@ -417,3 +561,259 @@ export function CloudAccountCard({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface CompactCloudAccountCardProps {
|
||||
account: CloudAccount;
|
||||
onRefresh: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSwitch: (id: string) => void;
|
||||
onManageIdentity: (id: string) => void;
|
||||
isRefreshing?: boolean;
|
||||
isDeleting?: boolean;
|
||||
isSwitching?: boolean;
|
||||
}
|
||||
|
||||
export function CompactCloudAccountCard({
|
||||
account,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
onSwitch,
|
||||
onManageIdentity,
|
||||
isRefreshing,
|
||||
isDeleting,
|
||||
isSwitching,
|
||||
}: CompactCloudAccountCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config, saveConfig } = useAppConfig();
|
||||
|
||||
const getQuotaBarColorClass = (percentage: number) => {
|
||||
const quotaStatus = getQuotaStatus(percentage);
|
||||
return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus];
|
||||
};
|
||||
|
||||
const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
||||
) as ModelQuotaEntry[];
|
||||
|
||||
const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[];
|
||||
|
||||
const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries);
|
||||
|
||||
const compactModels = Object.entries(mergedModelQuotas).sort(
|
||||
(a, b) => b[1].percentage - a[1].percentage,
|
||||
);
|
||||
|
||||
const aiCredits = account.quota?.ai_credits;
|
||||
const shouldShowAiCredits =
|
||||
!!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
|
||||
|
||||
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||
account.status,
|
||||
account.status_reason,
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200">
|
||||
{account.avatar_url ? (
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
alt={account.name || ''}
|
||||
className="bg-muted h-7 w-7 rounded-full border"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary/10 text-primary flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
account.status === 'rate_limited' || account.status === 'expired'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{account.provider.toUpperCase()}
|
||||
</Badge>
|
||||
{account.is_active && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="shrink-0 bg-green-500 text-[10px] hover:bg-green-600"
|
||||
>
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{shouldShowAiCredits && aiCredits && (
|
||||
<span className="shrink-0 text-blue-500">
|
||||
{t('cloud.card.aiCreditsValue', {
|
||||
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||
})}
|
||||
{aiCredits.expiryDate && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
·{' '}
|
||||
{t('cloud.card.creditsExpiry', {
|
||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{compactModels.length > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
{compactModels.map(([modelName, info]) => (
|
||||
<TooltipProvider key={modelName}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-muted h-1.5 w-12 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{formatModelDisplayName(modelName)}: {info.percentage}%
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{allModelEntries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
||||
{(() => {
|
||||
const hiddenCount = allModelEntries.filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||
).length;
|
||||
return hiddenCount > 0 ? (
|
||||
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
);
|
||||
})()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
||||
<div className="max-h-64 overflow-auto px-2 py-1">
|
||||
{allModelEntries.map(([modelName]) => {
|
||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={modelName}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) => {
|
||||
if (config) {
|
||||
const newVisibility = { ...config.model_visibility };
|
||||
newVisibility[modelName] = checked as boolean;
|
||||
saveConfig({ ...config, model_visibility: newVisibility });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-xs" title={modelName}>
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{account.is_active ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
className="h-7 text-xs text-green-600 opacity-100"
|
||||
>
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
{t('cloud.card.active')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onSwitch(account.id)}
|
||||
disabled={isSwitching}
|
||||
className="h-7 cursor-pointer text-xs"
|
||||
>
|
||||
{isSwitching ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{t('cloud.card.use')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.useAccount')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.refresh')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.identityProfile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(account.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
||||
@@ -9,82 +9,120 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Search, RotateCcw, Save } from 'lucide-react';
|
||||
import { Loader2, Search, RotateCcw, Save, Copy, CheckCircle } from 'lucide-react';
|
||||
import { filter, flatMap, includes, size, sortBy, sumBy, uniq, values } from 'lodash-es';
|
||||
import type { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
interface ModelVisibilitySettingsProps {}
|
||||
function collectAvailableModelIds(accounts: CloudAccount[] | undefined): string[] {
|
||||
if (!accounts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = () => {
|
||||
const modelNames = flatMap(accounts, (account) => {
|
||||
if (!account.quota?.models) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(account.quota.models);
|
||||
});
|
||||
|
||||
return sortBy(uniq(modelNames));
|
||||
}
|
||||
|
||||
function filterModelIdsByQuery(modelIds: string[], query: string): string[] {
|
||||
const normalizedSearchQuery = query.toLowerCase();
|
||||
|
||||
return filter(modelIds, (modelId) => includes(modelId.toLowerCase(), normalizedSearchQuery));
|
||||
}
|
||||
|
||||
export function ModelVisibilitySettings() {
|
||||
const { t } = useTranslation();
|
||||
const { config, saveConfig } = useAppConfig();
|
||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [modelVisibility, setModelVisibility] = useState<Record<string, boolean>>({});
|
||||
const [providerGroupingsEnabled, setProviderGroupingsEnabled] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [modelVisibilityMap, setModelVisibilityMap] = useState<Record<string, boolean>>({});
|
||||
const [providerGroupingEnabled, setProviderGroupingEnabled] = useState(false);
|
||||
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||
const [copiedModel, setCopiedModel] = useState<string | null>(null);
|
||||
|
||||
// Initialize model visibility and provider groupings from config
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (config?.model_visibility) {
|
||||
setModelVisibility(config.model_visibility);
|
||||
setModelVisibilityMap(config.model_visibility);
|
||||
}
|
||||
if (config?.provider_groupings_enabled !== undefined) {
|
||||
setProviderGroupingsEnabled(config.provider_groupings_enabled);
|
||||
setProviderGroupingEnabled(config.provider_groupings_enabled);
|
||||
}
|
||||
}, [config?.model_visibility, config?.provider_groupings_enabled]);
|
||||
|
||||
// Get all unique models from all accounts
|
||||
const allModels = useMemo(() => {
|
||||
if (!accounts) return [];
|
||||
|
||||
const modelSet = new Set<string>();
|
||||
accounts.forEach((account) => {
|
||||
if (account.quota?.models) {
|
||||
Object.keys(account.quota.models).forEach((model) => {
|
||||
modelSet.add(model);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(modelSet).sort();
|
||||
const availableModelIds = useMemo(() => {
|
||||
return collectAvailableModelIds(accounts);
|
||||
}, [accounts]);
|
||||
|
||||
// Filter models based on search term
|
||||
const filteredModels = useMemo(() => {
|
||||
return allModels.filter((model) => model.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}, [allModels, searchTerm]);
|
||||
const filteredModelIds = useMemo(() => {
|
||||
return filterModelIdsByQuery(availableModelIds, searchQuery);
|
||||
}, [availableModelIds, searchQuery]);
|
||||
|
||||
// Handle model visibility toggle
|
||||
const handleModelToggle = (modelName: string, checked: boolean) => {
|
||||
setModelVisibility((prev) => ({
|
||||
...prev,
|
||||
[modelName]: checked,
|
||||
}));
|
||||
const hiddenModelCount = useMemo(() => {
|
||||
return sumBy(values(modelVisibilityMap), (isVisible) => (isVisible === false ? 1 : 0));
|
||||
}, [modelVisibilityMap]);
|
||||
|
||||
const isModelVisible = (modelId: string): boolean => {
|
||||
return modelVisibilityMap[modelId] !== false;
|
||||
};
|
||||
|
||||
// Reset to defaults (all models visible)
|
||||
const handleReset = () => {
|
||||
setModelVisibility({});
|
||||
const resetVisibilityOverrides = () => {
|
||||
setModelVisibilityMap({});
|
||||
};
|
||||
|
||||
// Save configuration
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
const saveVisibilitySettings = async () => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setIsSavingSettings(true);
|
||||
try {
|
||||
await saveConfig({
|
||||
const nextConfig = {
|
||||
...config,
|
||||
model_visibility: modelVisibility,
|
||||
provider_groupings_enabled: providerGroupingsEnabled,
|
||||
});
|
||||
model_visibility: modelVisibilityMap,
|
||||
provider_groupings_enabled: providerGroupingEnabled,
|
||||
};
|
||||
await saveConfig(nextConfig);
|
||||
} catch (error) {
|
||||
console.error('Failed to save model visibility settings:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchQueryChange = (nextQuery: string) => {
|
||||
setSearchQuery(nextQuery);
|
||||
};
|
||||
|
||||
const handleProviderGroupingToggle = (checked: boolean) => {
|
||||
setProviderGroupingEnabled(checked);
|
||||
};
|
||||
|
||||
const handleModelVisibilityChange = (modelId: string, checked: boolean) => {
|
||||
setModelVisibilityMap((prev) => ({
|
||||
...prev,
|
||||
[modelId]: checked,
|
||||
}));
|
||||
};
|
||||
|
||||
const copyModelId = (modelId: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(modelId);
|
||||
setCopiedModel(modelId);
|
||||
setTimeout(() => setCopiedModel(null), 2000);
|
||||
};
|
||||
|
||||
if (accountsLoading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -101,7 +139,9 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>{t('settings.modelVisibility.title')}</span>
|
||||
<Badge variant="secondary">{filteredModels.length} models</Badge>
|
||||
<Badge variant="secondary">
|
||||
{t('settings.providerGroupings.models', { count: filteredModelIds.length })}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>{t('settings.modelVisibility.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -111,8 +151,8 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder={t('settings.modelVisibility.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(event) => handleSearchQueryChange(event.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
@@ -129,38 +169,53 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
||||
</div>
|
||||
<Switch
|
||||
id="provider-groupings"
|
||||
checked={providerGroupingsEnabled}
|
||||
onCheckedChange={setProviderGroupingsEnabled}
|
||||
checked={providerGroupingEnabled}
|
||||
onCheckedChange={handleProviderGroupingToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model List */}
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto rounded-lg border p-4">
|
||||
{filteredModels.length === 0 ? (
|
||||
{filteredModelIds.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
{searchTerm
|
||||
{searchQuery
|
||||
? t('settings.modelVisibility.noModelsFound')
|
||||
: t('settings.modelVisibility.noModels')}
|
||||
</div>
|
||||
) : (
|
||||
filteredModels.map((modelName) => {
|
||||
const isVisible = modelVisibility[modelName] !== false; // Default to true
|
||||
filteredModelIds.map((modelId) => {
|
||||
const isVisible = isModelVisible(modelId);
|
||||
return (
|
||||
<div
|
||||
key={modelName}
|
||||
key={modelId}
|
||||
className="hover:bg-muted/50 flex items-center space-x-3 rounded p-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`model-${modelName}`}
|
||||
id={`model-${modelId}`}
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) => handleModelToggle(modelName, checked as boolean)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModelVisibilityChange(modelId, checked as boolean)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`model-${modelName}`}
|
||||
className="flex-1 cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{modelName}
|
||||
</label>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<label
|
||||
htmlFor={`model-${modelId}`}
|
||||
className="cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{modelId}
|
||||
</label>
|
||||
<button
|
||||
onClick={(e) => copyModelId(modelId, e)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('proxy.copy')}
|
||||
>
|
||||
{copiedModel === modelId ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!isVisible && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t('settings.modelVisibility.hidden')}
|
||||
@@ -176,16 +231,26 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={Object.keys(modelVisibility).length === 0}
|
||||
onClick={resetVisibilityOverrides}
|
||||
disabled={size(modelVisibilityMap) === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{t('settings.modelVisibility.reset')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving} className="flex items-center gap-2">
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
{isSaving ? t('settings.modelVisibility.saving') : t('settings.modelVisibility.save')}
|
||||
<Button
|
||||
onClick={saveVisibilitySettings}
|
||||
disabled={isSavingSettings}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isSavingSettings ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{isSavingSettings
|
||||
? t('settings.modelVisibility.saving')
|
||||
: t('settings.modelVisibility.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -193,19 +258,18 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
||||
<div className="text-muted-foreground border-t pt-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>
|
||||
{t('settings.modelVisibility.totalModels')}: {allModels.length}
|
||||
{t('settings.modelVisibility.totalModels')}: {availableModelIds.length}
|
||||
</span>
|
||||
<span>
|
||||
{t('settings.modelVisibility.visibleModels')}:{' '}
|
||||
{allModels.length - Object.values(modelVisibility).filter((v) => !v).length}
|
||||
{availableModelIds.length - hiddenModelCount}
|
||||
</span>
|
||||
<span>
|
||||
{t('settings.modelVisibility.hiddenModels')}:{' '}
|
||||
{Object.values(modelVisibility).filter((v) => !v).length}
|
||||
{t('settings.modelVisibility.hiddenModels')}: {hiddenModelCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -4,16 +4,17 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { ProviderStats } from '@/utils/provider-grouping';
|
||||
import { clampQuotaPercentage } from '@/utils/quota-display';
|
||||
|
||||
interface ProviderGroupProps {
|
||||
stats: ProviderStats;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
getQuotaColor: (percentage: number) => string;
|
||||
getQuotaBarColor: (percentage: number) => string;
|
||||
getQuotaLabel: (percentage: number) => string;
|
||||
getResetTimeLabel: (resetTime?: string) => string;
|
||||
getResetTimeTitle: (resetTime?: string) => string | undefined;
|
||||
getQuotaTextColorClass: (percentage: number) => string;
|
||||
getQuotaBarColorClass: (percentage: number) => string;
|
||||
formatQuotaLabel: (percentage: number) => string;
|
||||
formatResetTimeLabel: (resetTime?: string) => string;
|
||||
formatResetTimeTitle: (resetTime?: string) => string | undefined;
|
||||
leftLabel: string;
|
||||
}
|
||||
|
||||
@@ -21,11 +22,11 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
stats,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
getQuotaColor,
|
||||
getQuotaBarColor,
|
||||
getQuotaLabel,
|
||||
getResetTimeLabel,
|
||||
getResetTimeTitle,
|
||||
getQuotaTextColorClass,
|
||||
getQuotaBarColorClass,
|
||||
formatQuotaLabel,
|
||||
formatResetTimeLabel,
|
||||
formatResetTimeTitle,
|
||||
leftLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -69,16 +70,18 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
{earliestReset && (
|
||||
<span
|
||||
className="text-muted-foreground text-[10px]"
|
||||
title={getResetTimeTitle(earliestReset)}
|
||||
title={formatResetTimeTitle(earliestReset)}
|
||||
>
|
||||
{getResetTimeLabel(earliestReset)}
|
||||
{formatResetTimeLabel(earliestReset)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`font-mono text-xs font-bold ${getQuotaColor(avgPercentage)}`}>
|
||||
{getQuotaLabel(avgPercentage)}
|
||||
<span
|
||||
className={`font-mono text-xs font-bold ${getQuotaTextColorClass(avgPercentage)}`}
|
||||
>
|
||||
{formatQuotaLabel(avgPercentage)}
|
||||
</span>
|
||||
{avgPercentage > 0 && (
|
||||
{Number.isFinite(avgPercentage) && avgPercentage > 0 && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{t('settings.providerGroupings.avgLabel')}
|
||||
</span>
|
||||
@@ -86,8 +89,8 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(avgPercentage)}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, avgPercentage))}%` }}
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(avgPercentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(avgPercentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,15 +112,15 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className="text-muted-foreground text-[10px] leading-none"
|
||||
title={getResetTimeTitle(model.resetTime)}
|
||||
title={formatResetTimeTitle(model.resetTime)}
|
||||
>
|
||||
{getResetTimeLabel(model.resetTime)}
|
||||
{formatResetTimeLabel(model.resetTime)}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={`font-mono leading-none font-bold ${getQuotaColor(model.percentage)}`}
|
||||
className={`font-mono leading-none font-bold ${getQuotaTextColorClass(model.percentage)}`}
|
||||
>
|
||||
{getQuotaLabel(model.percentage)}
|
||||
{formatQuotaLabel(model.percentage)}
|
||||
</span>
|
||||
{model.percentage > 0 && (
|
||||
<span className="text-muted-foreground text-[10px]">{leftLabel}</span>
|
||||
@@ -125,8 +128,8 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(model.percentage)}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, model.percentage))}%` }}
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(model.percentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(model.percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
|
||||
import { isProcessRunning, startGeminiNexus, closeGeminiNexus } from '@/actions/process';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Play, Square, Loader2, Power } from 'lucide-react';
|
||||
@@ -22,14 +22,14 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: startAntigravity,
|
||||
mutationFn: startGeminiNexus,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: closeAntigravity,
|
||||
mutationFn: closeGeminiNexus,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { isOAuthReauthReason, isRateLimitReason } from '@/utils/account-status';
|
||||
|
||||
export function getValidationBlockedStatusLabel(
|
||||
status: 'active' | 'rate_limited' | 'expired' | undefined,
|
||||
reason: string | undefined,
|
||||
t: TFunction,
|
||||
): string | null {
|
||||
const normalizedReason = (reason || '').toLowerCase();
|
||||
const hasReason = normalizedReason !== '';
|
||||
const isBlockedByStatus = status === 'rate_limited' || status === 'expired';
|
||||
|
||||
if (!isBlockedByStatus && !hasReason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'rate_limited' || isRateLimitReason(normalizedReason)) {
|
||||
return t('cloud.card.validationRiskControlled');
|
||||
}
|
||||
|
||||
if (status === 'expired' || isOAuthReauthReason(normalizedReason)) {
|
||||
return t('cloud.card.validationOAuthReauthRequired');
|
||||
}
|
||||
|
||||
return t('cloud.card.validationRequired');
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -48,21 +49,38 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
|
||||
}
|
||||
>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => {
|
||||
if (isNumber(collisionPadding)) {
|
||||
if (collisionPadding < 0) {
|
||||
throw new Error('collisionPadding must be a non-negative number');
|
||||
}
|
||||
} 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -4,11 +4,16 @@ import {
|
||||
addGoogleAccount,
|
||||
deleteCloudAccount,
|
||||
refreshAccountQuota,
|
||||
setAccountProxy,
|
||||
listOAuthClients,
|
||||
setActiveOAuthClient,
|
||||
type OAuthClientDescriptor,
|
||||
} from '@/actions/cloud';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
export const QUERY_KEYS = {
|
||||
cloudAccounts: ['cloudAccounts'],
|
||||
oauthClients: ['oauthClients'],
|
||||
};
|
||||
|
||||
export function useCloudAccounts() {
|
||||
@@ -30,6 +35,24 @@ export function useAddGoogleAccount() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useOAuthClients() {
|
||||
return useQuery<OAuthClientDescriptor[]>({
|
||||
queryKey: QUERY_KEYS.oauthClients,
|
||||
queryFn: listOAuthClients,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetActiveOAuthClient() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: setActiveOAuthClient,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.oauthClients });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCloudAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -120,3 +143,41 @@ export function useSyncLocalAccount() {
|
||||
|
||||
import { startAuthFlow } from '@/actions/cloud';
|
||||
export { startAuthFlow };
|
||||
|
||||
export function useSetAccountProxy() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: setAccountProxy,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('[Mutation] setAccountProxy failed:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
import { exportCloudAccounts, importCloudAccounts } from '@/actions/cloud';
|
||||
|
||||
export function useExportCloudAccounts() {
|
||||
return useMutation<string, Error, { stripTokens?: boolean }>({
|
||||
mutationFn: exportCloudAccounts,
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportCloudAccounts() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
{ imported: number; skipped: number; updated: number; errors: string[] },
|
||||
Error,
|
||||
{ jsonContent: string; strategy?: 'merge' | 'overwrite' | 'skip-existing' }
|
||||
>({
|
||||
mutationFn: importCloudAccounts,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('[Mutation] importCloudAccounts failed:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -23,7 +23,7 @@ function getQuickConfig() {
|
||||
if (getQuickConfig()) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
release: `antigravity-manager@${app.getVersion()}`,
|
||||
release: `gemini-nexus@${app.getVersion()}`,
|
||||
beforeSend(event) {
|
||||
if (event.exception?.values?.[0]?.value) {
|
||||
event.exception.values[0].value = event.exception.values[0].value.replace(
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function addAccountSnapshot(): Promise<Account> {
|
||||
const info = getCurrentAccountInfo();
|
||||
if (!info.isAuthenticated) {
|
||||
const errorMsg =
|
||||
'No authenticated account found. Please ensure Antigravity is running and you are logged in.';
|
||||
'No authenticated account found. Please ensure Gemini Nexus is running and you are logged in.';
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,129 @@
|
||||
import http from 'http';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ipcContext } from '../context';
|
||||
import { escapeHtml } from '../../utils/url';
|
||||
|
||||
export class AuthServer {
|
||||
private static server: http.Server | null = null;
|
||||
private static PORT = 8888;
|
||||
|
||||
static start() {
|
||||
static async start() {
|
||||
if (this.server) {
|
||||
logger.warn('AuthServer: Server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
||||
const tryPorts = [8888, 8889, 8890, 8891, 8892];
|
||||
let boundPort: number | null = null;
|
||||
|
||||
if (url.pathname === '/oauth-callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
for (const port of tryPorts) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const testServer = http.createServer();
|
||||
testServer.once('error', reject);
|
||||
testServer.listen(port, '127.0.0.1', () => {
|
||||
testServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
boundPort = port;
|
||||
break;
|
||||
} catch {
|
||||
logger.debug(`AuthServer: Port ${port} is in use, trying next...`);
|
||||
}
|
||||
}
|
||||
|
||||
if (code) {
|
||||
logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`);
|
||||
if (!boundPort) {
|
||||
logger.error('AuthServer: No available ports found for OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
logger.info('AuthServer: Sending code to renderer via IPC');
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
logger.info('AuthServer: Code sent successfully');
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
if (boundPort !== 8888) {
|
||||
logger.warn(`AuthServer: Using fallback port ${boundPort} (default 8888 is in use)`);
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
this.PORT = boundPort;
|
||||
|
||||
try {
|
||||
this.server = http.createServer((req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { Allow: 'GET' });
|
||||
res.end('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
||||
|
||||
if (url.pathname === '/oauth-callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
const escapedCode = escapeHtml(code);
|
||||
logger.info(
|
||||
`AuthServer: Received authorization code: ${escapedCode.substring(0, 10)}...`,
|
||||
);
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
logger.info('AuthServer: Sending code to renderer via IPC');
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
logger.info('AuthServer: Code sent successfully');
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
||||
<h1>Login Successful</h1>
|
||||
<p>You can close this window and return to Antigravity Manager.</p>
|
||||
<p>You can close this window and return to Gemini Nexus.</p>
|
||||
<script>
|
||||
setTimeout(() => window.close(), 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (error) {
|
||||
logger.error(`AuthServer: OAuth error: ${error}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
} else if (error) {
|
||||
const escapedError = escapeHtml(error);
|
||||
logger.error(`AuthServer: OAuth error: ${escapedError}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
<p>Error: ${escapedError}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing code parameter');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing code parameter');
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error('AuthServer: Server error', err);
|
||||
});
|
||||
|
||||
this.server.listen(this.PORT, '127.0.0.1', () => {
|
||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('AuthServer: Failed to create or start server', e);
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error('AuthServer: Server error', err);
|
||||
});
|
||||
|
||||
this.server.listen(this.PORT, () => {
|
||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
||||
});
|
||||
static getRedirectUri(): string {
|
||||
return `http://localhost:${this.PORT}/oauth-callback`;
|
||||
}
|
||||
|
||||
static stop() {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { GoogleAPIService, type OAuthClientDescriptor } from '../../services/GoogleAPIService';
|
||||
import { CloudAccount, CloudAccountExportSchema } from '../../types/cloudAccount';
|
||||
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,
|
||||
@@ -16,21 +17,11 @@ import {
|
||||
readCurrentDeviceProfile,
|
||||
saveGlobalOriginalProfile,
|
||||
} from '../../ipc/device/handler';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import { runWithSwitchGuard } from '../../ipc/switchGuard';
|
||||
import { executeSwitchFlow } from '../../ipc/switchFlow';
|
||||
import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account';
|
||||
|
||||
// Fallback constants if service constants are not available or for direct usage
|
||||
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
||||
const SCOPE = [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/cclog',
|
||||
'https://www.googleapis.com/auth/experimentsandconfigs',
|
||||
].join(' ');
|
||||
import { classifyAccountStatusFromError, extractErrorMessage } from '../../utils/account-status';
|
||||
|
||||
// Helper to update tray
|
||||
function notifyTrayUpdate(account: CloudAccount) {
|
||||
@@ -44,15 +35,232 @@ function notifyTrayUpdate(account: CloudAccount) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function addGoogleAccount(authCode: string): Promise<CloudAccount> {
|
||||
const ACTIVE_OAUTH_CLIENT_KEY_SETTING = 'active_oauth_client_key';
|
||||
const OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING = 'oauth_client_key_backfill_v1_done';
|
||||
const ENTERPRISE_OAUTH_CLIENT_KEY = 'geminiNexus_enterprise';
|
||||
|
||||
function isEnterpriseClient(clientKey?: string): boolean {
|
||||
if (!clientKey) {
|
||||
return false;
|
||||
}
|
||||
return clientKey.trim().toLowerCase() === ENTERPRISE_OAUTH_CLIENT_KEY;
|
||||
}
|
||||
|
||||
function normalizeProjectId(projectId?: string): string | null {
|
||||
if (!isString(projectId)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = projectId.trim();
|
||||
return normalized === '' ? null : normalized;
|
||||
}
|
||||
|
||||
function recoverCachedQuotaOnRateLimit(
|
||||
account: CloudAccount,
|
||||
error: unknown,
|
||||
): CloudAccount['quota'] | null {
|
||||
const classified = classifyAccountStatusFromError(error);
|
||||
if (!classified || classified.status !== 'rate_limited') {
|
||||
return null;
|
||||
}
|
||||
if (!account.quota || !account.quota.models || Object.keys(account.quota.models).length === 0) {
|
||||
return null;
|
||||
}
|
||||
return account.quota;
|
||||
}
|
||||
|
||||
function formatSwitchRefreshError(reason: string): string {
|
||||
const normalized = reason.toLowerCase();
|
||||
if (
|
||||
normalized.includes('unauthorized_client') ||
|
||||
normalized.includes('invalid_client') ||
|
||||
normalized.includes('invalid_grant')
|
||||
) {
|
||||
return `Token refresh failed: OAuth client is not authorized for this account. Please re-login and complete authorization. Raw error: ${reason}`;
|
||||
}
|
||||
if (
|
||||
normalized.includes('verify your account') ||
|
||||
normalized.includes('further action is required') ||
|
||||
normalized.includes('validation required') ||
|
||||
normalized.includes('validation_url') ||
|
||||
normalized.includes('appeal_url')
|
||||
) {
|
||||
return `Token refresh failed: account requires additional verification. Please finish verification and retry. Raw error: ${reason}`;
|
||||
}
|
||||
if (
|
||||
normalized.includes('resource_exhausted') ||
|
||||
normalized.includes('resource has been exhausted')
|
||||
) {
|
||||
return `Token refresh failed: account is rate-limited or temporarily restricted (RESOURCE_EXHAUSTED). Please retry later. Raw error: ${reason}`;
|
||||
}
|
||||
return `Token refresh failed: ${reason}`;
|
||||
}
|
||||
|
||||
async function ensureEnterpriseProjectReady(account: CloudAccount): Promise<void> {
|
||||
if (!isEnterpriseClient(account.token.oauth_client_key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizeProjectId(account.token.project_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[OAuth] Account ${account.email} is using enterprise OAuth client but missing project_id. Resolving before switch...`,
|
||||
);
|
||||
|
||||
let resolvedProjectId: string | null = null;
|
||||
try {
|
||||
resolvedProjectId = await GoogleAPIService.fetchProjectId(
|
||||
account.token.access_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Account ${account.email} cannot be switched safely: missing enterprise project_id and auto-resolve failed (${extractErrorMessage(
|
||||
error,
|
||||
)}). Please re-login with a non-enterprise OAuth client or a project-enabled token.`,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedProjectId = normalizeProjectId(resolvedProjectId ?? undefined);
|
||||
if (!normalizedProjectId) {
|
||||
throw new Error(
|
||||
`Account ${account.email} cannot be switched safely: enterprise OAuth requires a valid project_id.`,
|
||||
);
|
||||
}
|
||||
|
||||
account.token.project_id = normalizedProjectId;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
}
|
||||
|
||||
async function markAccountStatusFromError(account: CloudAccount, error: unknown): Promise<void> {
|
||||
const classified = classifyAccountStatusFromError(error);
|
||||
if (!classified) {
|
||||
return;
|
||||
}
|
||||
|
||||
account.status = classified.status;
|
||||
account.status_reason = classified.reason;
|
||||
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
|
||||
}
|
||||
|
||||
async function clearAccountStatus(account: CloudAccount): Promise<void> {
|
||||
account.status = 'active';
|
||||
account.status_reason = undefined;
|
||||
await CloudAccountRepo.setAccountStatus(account.id, 'active', null);
|
||||
}
|
||||
|
||||
function hydrateActiveOAuthClientFromSettings(): void {
|
||||
const preferredClientKey = CloudAccountRepo.getSetting<string>(
|
||||
ACTIVE_OAUTH_CLIENT_KEY_SETTING,
|
||||
'',
|
||||
);
|
||||
if (isString(preferredClientKey) && !isEmpty(preferredClientKey.trim())) {
|
||||
try {
|
||||
GoogleAPIService.setActiveOAuthClientKey(preferredClientKey);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OAuth] Stored active OAuth client '${preferredClientKey}' is invalid, falling back to default`,
|
||||
error,
|
||||
);
|
||||
CloudAccountRepo.setSetting(ACTIVE_OAUTH_CLIENT_KEY_SETTING, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillMissingOAuthClientKeyForLegacyAccounts(
|
||||
accounts: CloudAccount[],
|
||||
): Promise<boolean> {
|
||||
const backfillDone = CloudAccountRepo.getSetting<boolean>(
|
||||
OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING,
|
||||
false,
|
||||
);
|
||||
if (backfillDone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
const activeClientKey = GoogleAPIService.getActiveOAuthClientKey().trim().toLowerCase();
|
||||
if (activeClientKey === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let updatedCount = 0;
|
||||
let skippedEnterpriseGuardCount = 0;
|
||||
let hasFailure = false;
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.provider !== 'google') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentClientKey = account.token.oauth_client_key?.trim();
|
||||
if (currentClientKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const refreshToken = account.token.refresh_token?.trim();
|
||||
if (!refreshToken) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectMissing =
|
||||
!isString(account.token.project_id) || isEmpty(account.token.project_id.trim());
|
||||
if (activeClientKey === ENTERPRISE_OAUTH_CLIENT_KEY && projectMissing) {
|
||||
skippedEnterpriseGuardCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await CloudAccountRepo.updateToken(account.id, {
|
||||
...account.token,
|
||||
oauth_client_key: activeClientKey,
|
||||
});
|
||||
updatedCount += 1;
|
||||
} catch (error) {
|
||||
hasFailure = true;
|
||||
logger.warn(
|
||||
`[OAuth] Failed to backfill oauth_client_key for account ${account.email} (${account.id})`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasFailure) {
|
||||
CloudAccountRepo.setSetting(OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING, true);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[OAuth] Backfill oauth_client_key completed: updated=${updatedCount}, skipped_enterprise_guard=${skippedEnterpriseGuardCount}, has_failure=${hasFailure}`,
|
||||
);
|
||||
|
||||
return updatedCount > 0;
|
||||
}
|
||||
|
||||
export async function addGoogleAccount(
|
||||
authCode: string,
|
||||
oauthClientKey?: string,
|
||||
): Promise<CloudAccount> {
|
||||
try {
|
||||
if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) {
|
||||
setActiveOAuthClient(oauthClientKey);
|
||||
} else {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
}
|
||||
|
||||
// 1. Exchange code for tokens
|
||||
const tokenResp = await GoogleAPIService.exchangeCode(authCode);
|
||||
const tokenResp = await GoogleAPIService.exchangeCode(authCode, undefined, oauthClientKey);
|
||||
|
||||
// 2. Get User Info
|
||||
const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token);
|
||||
|
||||
// 3. Construct CloudAccount Object
|
||||
// 3. Check for existing account
|
||||
const existing = await CloudAccountRepo.getAccountByEmail(userInfo.email);
|
||||
if (existing) {
|
||||
throw new Error(`Account with email ${userInfo.email} already exists.`);
|
||||
}
|
||||
|
||||
// 4. Construct CloudAccount Object
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const account: CloudAccount = {
|
||||
id: uuidv4(),
|
||||
@@ -62,11 +270,13 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
||||
avatar_url: userInfo.picture,
|
||||
token: {
|
||||
access_token: tokenResp.access_token,
|
||||
refresh_token: tokenResp.refresh_token || '', // prompt=consent guarantees this, but we fallback safely
|
||||
refresh_token: tokenResp.refresh_token || '',
|
||||
expires_in: tokenResp.expires_in,
|
||||
expiry_timestamp: now + tokenResp.expires_in,
|
||||
token_type: tokenResp.token_type,
|
||||
email: userInfo.email,
|
||||
oauth_client_key: tokenResp.oauth_client_key,
|
||||
is_gcp_tos: true,
|
||||
},
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
@@ -76,14 +286,25 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
||||
logger.warn(`No refresh token received for ${account.email}. Account will expire in 1 hour.`);
|
||||
}
|
||||
|
||||
// 4. Save to DB
|
||||
await CloudAccountRepo.addAccount(account);
|
||||
|
||||
// 5. Initial Quota Check (Async, best effort)
|
||||
try {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
try {
|
||||
const aiCredits = await GoogleAPIService.fetchAICredits(
|
||||
account.token.access_token,
|
||||
undefined,
|
||||
);
|
||||
if (aiCredits) {
|
||||
quota.ai_credits = aiCredits;
|
||||
} else {
|
||||
logger.info(`No AI credits returned for ${account.email} during initial sync`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch initial AI credits', e);
|
||||
}
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||
notifyTrayUpdate(account);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch initial quota', e);
|
||||
@@ -97,7 +318,12 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
||||
}
|
||||
|
||||
export async function listCloudAccounts(): Promise<CloudAccount[]> {
|
||||
return CloudAccountRepo.getAccounts();
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
const backfilled = await backfillMissingOAuthClientKeyForLegacyAccounts(accounts);
|
||||
if (backfilled) {
|
||||
return CloudAccountRepo.getAccounts();
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
export async function deleteCloudAccount(accountId: string): Promise<void> {
|
||||
@@ -110,70 +336,150 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// Check if token needs refresh
|
||||
let now = Math.floor(Date.now() / 1000);
|
||||
if (account.token.expiry_timestamp < now + 300) {
|
||||
// 5 minutes buffer
|
||||
logger.info(`Token for ${account.email} near expiry, refreshing...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
|
||||
// Update token in memory object
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
account.token.oauth_client_key = GoogleAPIService.normalizeRefreshedOAuthClientKey(
|
||||
account.token,
|
||||
newTokenData.oauth_client_key,
|
||||
);
|
||||
|
||||
// Save to DB
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to refresh token during time-check for ${account.email}`, e);
|
||||
await markAccountStatusFromError(account, e);
|
||||
throw new Error(`Token refresh failed for ${account.email}. Please try logging in again.`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
const previousAICredits = account.quota?.ai_credits;
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token, account.proxy_url);
|
||||
|
||||
try {
|
||||
const aiCredits = await GoogleAPIService.fetchAICredits(
|
||||
account.token.access_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
if (aiCredits) {
|
||||
quota.ai_credits = aiCredits;
|
||||
} else {
|
||||
logger.info(`No AI credits returned for ${account.email} during quota refresh`);
|
||||
if (previousAICredits) {
|
||||
quota.ai_credits = previousAICredits;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch AI credits', e);
|
||||
if (previousAICredits) {
|
||||
quota.ai_credits = previousAICredits;
|
||||
}
|
||||
}
|
||||
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
||||
account.last_used = Math.floor(Date.now() / 1000);
|
||||
await clearAccountStatus(account);
|
||||
notifyTrayUpdate(account);
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
if (error.message === 'UNAUTHORIZED') {
|
||||
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
||||
try {
|
||||
// Force Refresh
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
now = Math.floor(Date.now() / 1000);
|
||||
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
account.token.oauth_client_key = GoogleAPIService.normalizeRefreshedOAuthClientKey(
|
||||
account.token,
|
||||
newTokenData.oauth_client_key,
|
||||
);
|
||||
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
|
||||
// Retry Quota
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
const previousAICredits = account.quota?.ai_credits;
|
||||
const quota = await GoogleAPIService.fetchQuota(
|
||||
account.token.access_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
|
||||
try {
|
||||
const aiCredits = await GoogleAPIService.fetchAICredits(
|
||||
account.token.access_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
if (aiCredits) {
|
||||
quota.ai_credits = aiCredits;
|
||||
} else {
|
||||
logger.info(`No AI credits returned for ${account.email} after token refresh`);
|
||||
if (previousAICredits) {
|
||||
quota.ai_credits = previousAICredits;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch AI credits after token refresh', e);
|
||||
if (previousAICredits) {
|
||||
quota.ai_credits = previousAICredits;
|
||||
}
|
||||
}
|
||||
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
||||
account.last_used = Math.floor(Date.now() / 1000);
|
||||
await clearAccountStatus(account);
|
||||
return account;
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
`Failed to force refresh token or retry quota for ${account.email}`,
|
||||
refreshError,
|
||||
);
|
||||
const cachedQuota = recoverCachedQuotaOnRateLimit(account, refreshError);
|
||||
if (cachedQuota) {
|
||||
logger.warn(
|
||||
`[OAuth] Quota request is rate-limited for ${account.email}; reusing cached quota as fallback.`,
|
||||
);
|
||||
await markAccountStatusFromError(account, refreshError);
|
||||
return account;
|
||||
}
|
||||
await markAccountStatusFromError(account, refreshError);
|
||||
throw refreshError;
|
||||
}
|
||||
} else if (error.message === 'FORBIDDEN') {
|
||||
logger.warn(
|
||||
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
||||
);
|
||||
// Return existing account to avoid crash
|
||||
await markAccountStatusFromError(account, error);
|
||||
return account;
|
||||
}
|
||||
|
||||
const cachedQuota = recoverCachedQuotaOnRateLimit(account, error);
|
||||
if (cachedQuota) {
|
||||
logger.warn(
|
||||
`[OAuth] Quota request is rate-limited for ${account.email}; reusing cached quota as fallback.`,
|
||||
);
|
||||
await markAccountStatusFromError(account, error);
|
||||
return account;
|
||||
}
|
||||
|
||||
await markAccountStatusFromError(account, error);
|
||||
logger.error(`Failed to refresh quota for ${account.email}`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -200,34 +506,49 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
// 1. Prepare token refresh promise (start it in parallel with process exit)
|
||||
const tokenRefreshPromise = (async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (account.token.expiry_timestamp < now + 1200) { // Increased buffer to 20m
|
||||
if (account.token.expiry_timestamp < now + 1200) {
|
||||
logger.info(`Token for ${account.email} near expiry, refreshing in parallel...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
|
||||
const updatedToken = {
|
||||
...account.token,
|
||||
access_token: newTokenData.access_token,
|
||||
expires_in: newTokenData.expires_in,
|
||||
expiry_timestamp: now + newTokenData.expires_in,
|
||||
oauth_client_key: GoogleAPIService.normalizeRefreshedOAuthClientKey(
|
||||
account.token,
|
||||
newTokenData.oauth_client_key,
|
||||
),
|
||||
};
|
||||
await CloudAccountRepo.updateToken(account.id, updatedToken);
|
||||
|
||||
account.token = updatedToken;
|
||||
logger.info(`Token refreshed for ${account.email}`);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to refresh token in parallel, will try to use existing', e);
|
||||
logger.warn('Failed to refresh token in parallel', e);
|
||||
await markAccountStatusFromError(account, e);
|
||||
const reason = extractErrorMessage(e);
|
||||
throw new Error(formatSwitchRefreshError(reason));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
await tokenRefreshPromise;
|
||||
await ensureEnterpriseProjectReady(account);
|
||||
|
||||
await executeSwitchFlow({
|
||||
scope: 'cloud',
|
||||
targetProfile: account.device_profile || null,
|
||||
applyFingerprint: isIdentityProfileApplyEnabled(),
|
||||
processExitTimeoutMs: 10000,
|
||||
performSwitch: async () => {
|
||||
// Wait for token refresh to complete before injection if it was started
|
||||
await tokenRefreshPromise;
|
||||
|
||||
// 3. Backup Database (Optimized to avoid race conditions)
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
for (const dbPath of dbPaths) {
|
||||
try {
|
||||
const backupPath = `${dbPath}.backup`;
|
||||
@@ -247,6 +568,7 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
// 5. Update usage and active status
|
||||
CloudAccountRepo.updateLastUsed(account.id);
|
||||
CloudAccountRepo.setActive(account.id);
|
||||
await clearAccountStatus(account);
|
||||
|
||||
logger.info(`Successfully switched to cloud account: ${account.email}`);
|
||||
notifyTrayUpdate(account);
|
||||
@@ -374,9 +696,148 @@ export async function forcePollCloudMonitor(): Promise<void> {
|
||||
await CloudMonitorService.poll();
|
||||
}
|
||||
|
||||
export async function startAuthFlow(): Promise<void> {
|
||||
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=${SCOPE}&access_type=offline&prompt=consent&include_granted_scopes=true`;
|
||||
export async function startAuthFlow(oauthClientKey?: string): Promise<void> {
|
||||
if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) {
|
||||
setActiveOAuthClient(oauthClientKey);
|
||||
} else {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
}
|
||||
const url = GoogleAPIService.getAuthUrl(oauthClientKey);
|
||||
|
||||
logger.info(`Starting auth flow, opening URL: ${url}`);
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
|
||||
export function listOAuthClients(): OAuthClientDescriptor[] {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
return GoogleAPIService.listOAuthClients();
|
||||
}
|
||||
|
||||
export function getActiveOAuthClient(): string {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
return GoogleAPIService.getActiveOAuthClientKey();
|
||||
}
|
||||
|
||||
export function setActiveOAuthClient(clientKey: string): void {
|
||||
GoogleAPIService.setActiveOAuthClientKey(clientKey);
|
||||
CloudAccountRepo.setSetting(
|
||||
ACTIVE_OAUTH_CLIENT_KEY_SETTING,
|
||||
GoogleAPIService.getActiveOAuthClientKey(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportCloudAccounts(stripTokens = false): Promise<string> {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
const exportData = {
|
||||
version: '1.0' as const,
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
accounts: accounts.map((acc) => ({
|
||||
provider: acc.provider,
|
||||
email: acc.email,
|
||||
name: acc.name,
|
||||
avatar_url: acc.avatar_url,
|
||||
token: stripTokens ? undefined : acc.token,
|
||||
quota: acc.quota,
|
||||
device_profile: acc.device_profile,
|
||||
device_history: acc.device_history,
|
||||
proxy_url: acc.proxy_url ?? null,
|
||||
status: acc.status,
|
||||
status_reason: acc.status_reason,
|
||||
})),
|
||||
};
|
||||
|
||||
CloudAccountExportSchema.parse(exportData);
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
export type ImportStrategy = 'merge' | 'overwrite' | 'skip-existing';
|
||||
|
||||
export async function importCloudAccounts(
|
||||
jsonContent: string,
|
||||
strategy: ImportStrategy = 'merge',
|
||||
): Promise<{ imported: number; skipped: number; updated: number; errors: string[] }> {
|
||||
const result = { imported: 0, skipped: 0, updated: 0, errors: [] as string[] };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonContent);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON format');
|
||||
}
|
||||
|
||||
const validated = CloudAccountExportSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
throw new Error(
|
||||
`Invalid export file: ${validated.error.issues[0]?.message || 'schema mismatch'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const importEmails = new Set<string>();
|
||||
for (const acc of validated.data.accounts) {
|
||||
const emailLower = acc.email.toLowerCase();
|
||||
if (importEmails.has(emailLower)) {
|
||||
throw new Error(`Duplicate email found in import file: ${acc.email}`);
|
||||
}
|
||||
importEmails.add(emailLower);
|
||||
}
|
||||
|
||||
const existingAccounts = await CloudAccountRepo.getAccounts();
|
||||
const existingByEmail = new Map(existingAccounts.map((a) => [a.email.toLowerCase(), a]));
|
||||
|
||||
for (const acc of validated.data.accounts) {
|
||||
try {
|
||||
const existing = existingByEmail.get(acc.email.toLowerCase());
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (existing) {
|
||||
if (strategy === 'skip-existing') {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedAccount: CloudAccount = {
|
||||
...existing,
|
||||
provider: acc.provider,
|
||||
name: acc.name ?? existing.name,
|
||||
avatar_url: acc.avatar_url ?? existing.avatar_url,
|
||||
token: acc.token,
|
||||
quota: acc.quota ?? existing.quota,
|
||||
device_profile: acc.device_profile ?? existing.device_profile,
|
||||
device_history: acc.device_history ?? existing.device_history,
|
||||
proxy_url: acc.proxy_url ?? existing.proxy_url,
|
||||
status: acc.status ?? existing.status,
|
||||
status_reason: acc.status_reason ?? existing.status_reason,
|
||||
last_used: now,
|
||||
};
|
||||
|
||||
await CloudAccountRepo.addAccount(updatedAccount);
|
||||
result.updated++;
|
||||
} else {
|
||||
const newAccount: CloudAccount = {
|
||||
id: uuidv4(),
|
||||
provider: acc.provider,
|
||||
email: acc.email,
|
||||
name: acc.name,
|
||||
avatar_url: acc.avatar_url,
|
||||
token: acc.token,
|
||||
quota: acc.quota,
|
||||
device_profile: acc.device_profile,
|
||||
device_history: acc.device_history,
|
||||
proxy_url: acc.proxy_url ?? undefined,
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
status: acc.status ?? 'active',
|
||||
status_reason: acc.status_reason,
|
||||
is_active: false,
|
||||
};
|
||||
|
||||
await CloudAccountRepo.addAccount(newAccount);
|
||||
result.imported++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to import ${acc.email}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { os, ORPCError } from '@orpc/server';
|
||||
import {
|
||||
addGoogleAccount,
|
||||
bindCloudIdentityProfile,
|
||||
@@ -18,10 +18,15 @@ import {
|
||||
setAutoSwitchEnabled,
|
||||
forcePollCloudMonitor,
|
||||
startAuthFlow,
|
||||
listOAuthClients,
|
||||
getActiveOAuthClient,
|
||||
setActiveOAuthClient,
|
||||
exportCloudAccounts,
|
||||
importCloudAccounts,
|
||||
} from './handler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
||||
import { getSwitchGuardSnapshot } from '../switchGuard';
|
||||
@@ -65,12 +70,44 @@ const switchStatusSnapshotSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function toSyncLocalAccountORPCError(error: unknown): ORPCError<string, undefined> {
|
||||
const message = extractErrorMessage(error);
|
||||
const normalized = message.toLowerCase();
|
||||
|
||||
if (
|
||||
normalized.includes('unauthenticated') ||
|
||||
normalized.includes('unauthorized') ||
|
||||
normalized.includes('token may be expired') ||
|
||||
normalized.includes('re-login in geminiNexus ide')
|
||||
) {
|
||||
return new ORPCError('UNAUTHORIZED', { message });
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('no cloud account found in ide') ||
|
||||
normalized.includes('no oauth token found in ide state') ||
|
||||
normalized.includes('geminiNexus database not found')
|
||||
) {
|
||||
return new ORPCError('BAD_REQUEST', { message });
|
||||
}
|
||||
|
||||
return new ORPCError('INTERNAL_SERVER_ERROR', { message });
|
||||
}
|
||||
|
||||
export const cloudRouter = os.router({
|
||||
addGoogleAccount: os
|
||||
.input(z.object({ authCode: z.string() }))
|
||||
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return addGoogleAccount(input.authCode);
|
||||
return addGoogleAccount(input.authCode, input.oauthClientKey);
|
||||
}),
|
||||
|
||||
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
||||
@@ -113,10 +150,54 @@ export const cloudRouter = os.router({
|
||||
await forcePollCloudMonitor();
|
||||
}),
|
||||
|
||||
startAuthFlow: os.output(z.void()).handler(async () => {
|
||||
await startAuthFlow();
|
||||
startAuthFlow: os
|
||||
.input(z.object({ oauthClientKey: z.string().optional() }).optional())
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await startAuthFlow(input?.oauthClientKey);
|
||||
}),
|
||||
|
||||
listOAuthClients: os
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
client_id: z.string(),
|
||||
is_active: z.boolean(),
|
||||
is_builtin: z.boolean(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handler(async () => {
|
||||
return listOAuthClients();
|
||||
}),
|
||||
|
||||
getActiveOAuthClient: os.output(z.object({ client_key: z.string() })).handler(async () => {
|
||||
return {
|
||||
client_key: getActiveOAuthClient(),
|
||||
};
|
||||
}),
|
||||
|
||||
setActiveOAuthClient: os
|
||||
.input(z.object({ clientKey: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
setActiveOAuthClient(input.clientKey);
|
||||
}),
|
||||
|
||||
setAccountProxy: os
|
||||
.input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] setAccountProxy error:', error.message, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
|
||||
try {
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
@@ -124,7 +205,7 @@ export const cloudRouter = os.router({
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
||||
throw error;
|
||||
throw toSyncLocalAccountORPCError(error);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -185,4 +266,40 @@ export const cloudRouter = os.router({
|
||||
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
||||
await openCloudIdentityStorageFolder();
|
||||
}),
|
||||
|
||||
exportCloudAccounts: os
|
||||
.input(z.object({ stripTokens: z.boolean().default(false) }))
|
||||
.output(z.string())
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await exportCloudAccounts(input.stripTokens);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] exportCloudAccounts error:', error.message, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
importCloudAccounts: os
|
||||
.input(
|
||||
z.object({
|
||||
jsonContent: z.string(),
|
||||
strategy: z.enum(['merge', 'overwrite', 'skip-existing']).default('merge'),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
imported: z.number(),
|
||||
skipped: z.number(),
|
||||
updated: z.number(),
|
||||
errors: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await importCloudAccounts(input.jsonContent, input.strategy);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] importCloudAccounts error:', error.message, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { AppConfig } from '../../types/config';
|
||||
import { ConfigManager } from './manager';
|
||||
import { syncAutoStart } from '../../utils/autoStart';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { setServerConfig } from '../../server/server-config';
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
return ConfigManager.loadConfig();
|
||||
@@ -13,6 +14,7 @@ export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
// For now just save
|
||||
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
||||
await ConfigManager.saveConfig(config);
|
||||
setServerConfig(config.proxy);
|
||||
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
||||
if (previous.auto_startup !== config.auto_startup) {
|
||||
syncAutoStart(config);
|
||||
|
||||
@@ -4,15 +4,21 @@ 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 { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { isNumber, isObjectLike, isPlainObject, isString } from 'lodash-es';
|
||||
import { getCloudAccountsDbPath, getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import {
|
||||
CloudAccount,
|
||||
CloudAccountSchema,
|
||||
CloudQuotaDataSchema,
|
||||
CloudTokenDataSchema,
|
||||
} from '../../types/cloudAccount';
|
||||
import { type DeviceProfile, type DeviceProfileVersion } from '../../types/account';
|
||||
import { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db';
|
||||
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
||||
import { ProtobufUtils } from '../../utils/protobuf';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { getAntigravityVersion, isNewVersion } from '../../utils/antigravityVersion';
|
||||
import { getGeminiNexusVersion, isNewVersion } from '../../utils/geminiNexusVersion';
|
||||
import { parseRow, parseRows } from '../../utils/sqlite';
|
||||
import { configureDatabase, openDrizzleConnection } from './dbConnection';
|
||||
import { accounts, itemTable, settings } from './schema';
|
||||
@@ -30,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;
|
||||
@@ -80,6 +86,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
status_reason TEXT,
|
||||
is_active INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
@@ -90,6 +97,8 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
const hasIsActive = tableInfo.some((col) => col.name === 'is_active');
|
||||
const hasDeviceProfileJson = tableInfo.some((col) => col.name === 'device_profile_json');
|
||||
const hasDeviceHistoryJson = tableInfo.some((col) => col.name === 'device_history_json');
|
||||
const hasProxyUrl = tableInfo.some((col) => col.name === 'proxy_url');
|
||||
const hasStatusReason = tableInfo.some((col) => col.name === 'status_reason');
|
||||
if (!hasIsActive) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
||||
}
|
||||
@@ -99,6 +108,12 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
if (!hasDeviceHistoryJson) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN device_history_json TEXT');
|
||||
}
|
||||
if (!hasProxyUrl) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN proxy_url TEXT');
|
||||
}
|
||||
if (!hasStatusReason) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN status_reason TEXT');
|
||||
}
|
||||
|
||||
// Create index on email for faster lookups
|
||||
// Create index on email for faster lookups
|
||||
@@ -154,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;
|
||||
}
|
||||
}
|
||||
@@ -172,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;
|
||||
@@ -203,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 {
|
||||
@@ -271,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,
|
||||
@@ -450,6 +466,9 @@ export class CloudAccountRepo {
|
||||
}
|
||||
|
||||
static async addAccount(account: CloudAccount): Promise<void> {
|
||||
// Validate account data before processing
|
||||
CloudAccountSchema.parse(account);
|
||||
|
||||
const { raw, orm } = getCloudDb();
|
||||
try {
|
||||
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
||||
@@ -467,7 +486,9 @@ export class CloudAccountRepo {
|
||||
createdAt: account.created_at,
|
||||
lastUsed: account.last_used,
|
||||
status: account.status || 'active',
|
||||
statusReason: account.status_reason ?? null,
|
||||
isActive: account.is_active ? 1 : 0,
|
||||
proxyUrl: account.proxy_url ?? null,
|
||||
};
|
||||
|
||||
orm.transaction((tx) => {
|
||||
@@ -509,79 +530,87 @@ export class CloudAccountRepo {
|
||||
|
||||
const cloudAccounts: CloudAccount[] = [];
|
||||
for (const normalizedRow of rows) {
|
||||
let tokenResult: DecryptFieldResult;
|
||||
try {
|
||||
tokenResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'tokenJson',
|
||||
normalizedRow.tokenJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
let tokenResult: DecryptFieldResult;
|
||||
try {
|
||||
tokenResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'tokenJson',
|
||||
normalizedRow.tokenJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error);
|
||||
continue; // Skip corrupted account
|
||||
}
|
||||
|
||||
let quotaResult: DecryptFieldResult;
|
||||
try {
|
||||
quotaResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'quotaJson',
|
||||
normalizedRow.quotaJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
let quotaResult: DecryptFieldResult;
|
||||
try {
|
||||
quotaResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'quotaJson',
|
||||
normalizedRow.quotaJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error);
|
||||
quotaResult = { value: null, migrated: false }; // Quota is optional, proceed
|
||||
}
|
||||
|
||||
if (!tokenResult.value) {
|
||||
throw new Error(`Missing token data for account ${normalizedRow.id}`);
|
||||
}
|
||||
if (!tokenResult.value) {
|
||||
logger.warn(`Missing token data for account ${normalizedRow.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tokenResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (tokenResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (tokenResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (tokenResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (quotaResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (quotaResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (quotaResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (quotaResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cloudAccounts.push({
|
||||
id: normalizedRow.id,
|
||||
provider: normalizedRow.provider as CloudAccount['provider'],
|
||||
email: normalizedRow.email,
|
||||
name: normalizedRow.name ?? undefined,
|
||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||
created_at: normalizedRow.createdAt,
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
is_active: Boolean(normalizedRow.isActive),
|
||||
});
|
||||
cloudAccounts.push({
|
||||
id: normalizedRow.id,
|
||||
provider: normalizedRow.provider as CloudAccount['provider'],
|
||||
email: normalizedRow.email,
|
||||
name: normalizedRow.name ?? undefined,
|
||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||
created_at: normalizedRow.createdAt,
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
status_reason: normalizedRow.statusReason ?? undefined,
|
||||
is_active: Boolean(normalizedRow.isActive),
|
||||
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
||||
});
|
||||
} catch (rowError) {
|
||||
logger.error(`Unexpected error processing row for account ${normalizedRow.id}`, rowError);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return cloudAccounts;
|
||||
@@ -618,21 +647,41 @@ export class CloudAccountRepo {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokenResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'tokenJson',
|
||||
normalizedRow.tokenJson,
|
||||
);
|
||||
const quotaResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'quotaJson',
|
||||
normalizedRow.quotaJson,
|
||||
);
|
||||
let tokenResult: DecryptFieldResult;
|
||||
try {
|
||||
tokenResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'tokenJson',
|
||||
normalizedRow.tokenJson,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[CloudAccountRepo] getAccount ${id} failed - Decryption failed for token`,
|
||||
error,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!tokenResult.value) {
|
||||
throw new Error(`Missing token data for account ${normalizedRow.id}`);
|
||||
let quotaResult: DecryptFieldResult;
|
||||
try {
|
||||
quotaResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'quotaJson',
|
||||
normalizedRow.quotaJson,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[CloudAccountRepo] getAccount ${id} failed - Decryption failed for quota`,
|
||||
error,
|
||||
);
|
||||
quotaResult = { value: null, migrated: false };
|
||||
}
|
||||
|
||||
const tokenValue = tokenResult.value;
|
||||
if (!tokenValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -641,14 +690,16 @@ export class CloudAccountRepo {
|
||||
email: normalizedRow.email,
|
||||
name: normalizedRow.name ?? undefined,
|
||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
token: JSON.parse(tokenValue),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||
created_at: normalizedRow.createdAt,
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
status_reason: normalizedRow.statusReason ?? undefined,
|
||||
is_active: Boolean(normalizedRow.isActive),
|
||||
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
||||
};
|
||||
} finally {
|
||||
raw.close();
|
||||
@@ -666,22 +717,42 @@ export class CloudAccountRepo {
|
||||
}
|
||||
|
||||
static async updateToken(id: string, token: any): Promise<void> {
|
||||
// Validate token data before encryption
|
||||
CloudTokenDataSchema.parse(token);
|
||||
|
||||
const { raw, orm } = getCloudDb();
|
||||
|
||||
try {
|
||||
const encrypted = await encrypt(JSON.stringify(token));
|
||||
orm.update(accounts).set({ tokenJson: encrypted }).where(eq(accounts.id, id)).run();
|
||||
const result = orm
|
||||
.update(accounts)
|
||||
.set({ tokenJson: encrypted })
|
||||
.where(eq(accounts.id, id))
|
||||
.run();
|
||||
if (result.changes === 0) {
|
||||
logger.warn(`updateToken: No account found with ID ${id}`);
|
||||
}
|
||||
} finally {
|
||||
raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async updateQuota(id: string, quota: any): Promise<void> {
|
||||
// Validate quota data before encryption
|
||||
CloudQuotaDataSchema.parse(quota);
|
||||
|
||||
const { raw, orm } = getCloudDb();
|
||||
|
||||
try {
|
||||
const encrypted = await encrypt(JSON.stringify(quota));
|
||||
orm.update(accounts).set({ quotaJson: encrypted }).where(eq(accounts.id, id)).run();
|
||||
const result = orm
|
||||
.update(accounts)
|
||||
.set({ quotaJson: encrypted })
|
||||
.where(eq(accounts.id, id))
|
||||
.run();
|
||||
if (result.changes === 0) {
|
||||
logger.warn(`updateQuota: No account found with ID ${id}`);
|
||||
}
|
||||
} finally {
|
||||
raw.close();
|
||||
}
|
||||
@@ -911,6 +982,44 @@ export class CloudAccountRepo {
|
||||
}
|
||||
}
|
||||
|
||||
static setAccountProxy(id: string, proxyUrl: string | null): void {
|
||||
const { raw, orm } = getCloudDb();
|
||||
try {
|
||||
orm.update(accounts).set({ proxyUrl }).where(eq(accounts.id, id)).run();
|
||||
logger.info(`Updated proxy for account ${id}: ${proxyUrl ?? 'none'}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update proxy for account ${id}`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async setAccountStatus(
|
||||
id: string,
|
||||
status: CloudAccount['status'],
|
||||
reason?: string | null,
|
||||
): Promise<void> {
|
||||
const { raw, orm } = getCloudDb();
|
||||
try {
|
||||
orm
|
||||
.update(accounts)
|
||||
.set({
|
||||
status,
|
||||
statusReason: reason?.trim() ? reason.trim() : null,
|
||||
})
|
||||
.where(eq(accounts.id, id))
|
||||
.run();
|
||||
} finally {
|
||||
raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async getAccountByEmail(email: string): Promise<CloudAccount | null> {
|
||||
const all = await this.getAccounts();
|
||||
return all.find((a) => a.email.toLowerCase() === email.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
private static upsertItemValue(db: DrizzleExecutor, key: string, value: string): void {
|
||||
db.insert(itemTable)
|
||||
.values({ key, value })
|
||||
@@ -928,9 +1037,9 @@ export class CloudAccountRepo {
|
||||
apiKey: account.token.access_token,
|
||||
};
|
||||
|
||||
this.upsertItemValue(db, 'antigravityAuthStatus', JSON.stringify(authStatus));
|
||||
this.upsertItemValue(db, 'antigravityOnboarding', 'true');
|
||||
db.delete(itemTable).where(eq(itemTable.key, 'google.antigravity')).run();
|
||||
this.upsertItemValue(db, 'geminiNexusAuthStatus', JSON.stringify(authStatus));
|
||||
this.upsertItemValue(db, 'geminiNexusOnboarding', 'true');
|
||||
db.delete(itemTable).where(eq(itemTable.key, 'google.geminiNexus')).run();
|
||||
}
|
||||
|
||||
private static getItemValue(db: DrizzleExecutor, key: string, context: string): string | null {
|
||||
@@ -951,10 +1060,30 @@ export class CloudAccountRepo {
|
||||
account.token.access_token,
|
||||
account.token.refresh_token,
|
||||
account.token.expiry_timestamp,
|
||||
account.token.is_gcp_tos ?? true,
|
||||
);
|
||||
const userStatusPayload = ProtobufUtils.createMinimalUserStatusPayload(account.email);
|
||||
const userStatusEntry = ProtobufUtils.createUnifiedStateEntry(
|
||||
'userStatusSentinelKey',
|
||||
userStatusPayload,
|
||||
);
|
||||
const normalizedProjectId = account.token.project_id?.trim();
|
||||
|
||||
orm.transaction((tx) => {
|
||||
this.upsertItemValue(tx, 'antigravityUnifiedStateSync.oauthToken', oauthToken);
|
||||
this.upsertItemValue(tx, 'geminiNexusUnifiedStateSync.oauthToken', oauthToken);
|
||||
this.upsertItemValue(tx, 'geminiNexusUnifiedStateSync.userStatus', userStatusEntry);
|
||||
if (normalizedProjectId) {
|
||||
const projectPayload = ProtobufUtils.createStringValuePayload(normalizedProjectId);
|
||||
const projectEntry = ProtobufUtils.createUnifiedStateEntry(
|
||||
'enterpriseGcpProjectId',
|
||||
projectPayload,
|
||||
);
|
||||
this.upsertItemValue(tx, 'geminiNexusUnifiedStateSync.enterprisePreferences', projectEntry);
|
||||
} else {
|
||||
tx.delete(itemTable)
|
||||
.where(eq(itemTable.key, 'geminiNexusUnifiedStateSync.enterprisePreferences'))
|
||||
.run();
|
||||
}
|
||||
this.writeAuthStatusAndCleanup(tx, account);
|
||||
});
|
||||
}
|
||||
@@ -1011,8 +1140,8 @@ export class CloudAccountRepo {
|
||||
private static detectFormatCapability(db: DrizzleExecutor): 'new' | 'old' | null {
|
||||
const unifiedValue = this.getItemValue(
|
||||
db,
|
||||
'antigravityUnifiedStateSync.oauthToken',
|
||||
'ide.itemTable.antigravityUnifiedStateSync.oauthToken',
|
||||
'geminiNexusUnifiedStateSync.oauthToken',
|
||||
'ide.itemTable.geminiNexusUnifiedStateSync.oauthToken',
|
||||
);
|
||||
if (unifiedValue) {
|
||||
return 'new';
|
||||
@@ -1035,7 +1164,7 @@ export class CloudAccountRepo {
|
||||
reason: string;
|
||||
} {
|
||||
try {
|
||||
const version = getAntigravityVersion();
|
||||
const version = getGeminiNexusVersion();
|
||||
return {
|
||||
name: isNewVersion(version) ? 'new' : 'old',
|
||||
reason: `version:${version.shortVersion}`,
|
||||
@@ -1119,16 +1248,16 @@ export class CloudAccountRepo {
|
||||
}
|
||||
|
||||
static injectCloudToken(account: CloudAccount): void {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
|
||||
|
||||
if (!dbPath) {
|
||||
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
||||
throw new Error(`Gemini Nexus database not found. Checked paths: ${dbPaths.join(', ')}`);
|
||||
}
|
||||
|
||||
const result = this.injectWithRetry(dbPath, account);
|
||||
logger.info(
|
||||
`Successfully injected cloud token and identity for ${account.email} into Antigravity database at ${dbPath} (strategy=${result.strategy}, attempts=${result.attempts}).`,
|
||||
`Successfully injected cloud token and identity for ${account.email} into Gemini Nexus database at ${dbPath} (strategy=${result.strategy}, attempts=${result.attempts}).`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1173,11 +1302,13 @@ export class CloudAccountRepo {
|
||||
private static readTokenInfoFromDb(db: DrizzleExecutor): {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
projectId?: string;
|
||||
} {
|
||||
const enterpriseProjectId = this.readEnterpriseProjectIdFromDb(db);
|
||||
const unifiedValue = this.getItemValue(
|
||||
db,
|
||||
'antigravityUnifiedStateSync.oauthToken',
|
||||
'ide.itemTable.antigravityUnifiedStateSync.oauthToken',
|
||||
'geminiNexusUnifiedStateSync.oauthToken',
|
||||
'ide.itemTable.geminiNexusUnifiedStateSync.oauthToken',
|
||||
);
|
||||
|
||||
let tokenInfo: { accessToken: string; refreshToken: string } | null = null;
|
||||
@@ -1200,7 +1331,7 @@ export class CloudAccountRepo {
|
||||
|
||||
if (!value) {
|
||||
const errorMsg =
|
||||
'No cloud account found in IDE. Please login to a Google account in Antigravity IDE first.';
|
||||
'No cloud account found in IDE. Please login to a Google account in Gemini Nexus IDE first.';
|
||||
logger.warn(`SyncLocal: ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
@@ -1212,17 +1343,21 @@ export class CloudAccountRepo {
|
||||
|
||||
if (!tokenInfo) {
|
||||
const errorMsg =
|
||||
'No OAuth token found in IDE state. Please login to a Google account in Antigravity IDE first.';
|
||||
'No OAuth token found in IDE state. Please login to a Google account in Gemini Nexus IDE first.';
|
||||
logger.warn(`SyncLocal: ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return tokenInfo;
|
||||
return {
|
||||
...tokenInfo,
|
||||
projectId: enterpriseProjectId,
|
||||
};
|
||||
}
|
||||
|
||||
private static readTokenInfoWithRetry(dbPath: string): {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
projectId?: string;
|
||||
} {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= SQLITE_MAX_RETRIES; attempt += 1) {
|
||||
@@ -1244,9 +1379,44 @@ export class CloudAccountRepo {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private static readEnterpriseProjectIdFromDb(db: DrizzleExecutor): string | undefined {
|
||||
const enterprisePreferencesValue = this.getItemValue(
|
||||
db,
|
||||
'geminiNexusUnifiedStateSync.enterprisePreferences',
|
||||
'ide.itemTable.geminiNexusUnifiedStateSync.enterprisePreferences',
|
||||
);
|
||||
if (!enterprisePreferencesValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const { sentinelKey, payload } = ProtobufUtils.decodeUnifiedStateEntry(
|
||||
enterprisePreferencesValue,
|
||||
);
|
||||
if (sentinelKey !== 'enterpriseGcpProjectId') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const projectBytes = ProtobufUtils.getField(payload, 3);
|
||||
if (!projectBytes) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const projectId = ProtobufUtils.readString(projectBytes).trim();
|
||||
if (projectId === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return projectId;
|
||||
} catch (error) {
|
||||
logger.warn('SyncLocal: Failed to parse enterprise project preference', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static async syncFromIDE(): Promise<CloudAccount | null> {
|
||||
// Try all possible database paths
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
|
||||
|
||||
const dbPath =
|
||||
@@ -1256,12 +1426,12 @@ export class CloudAccountRepo {
|
||||
}) ?? null;
|
||||
|
||||
if (!dbPath) {
|
||||
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
||||
const errorMsg = `Gemini Nexus database not found. Please ensure Gemini Nexus IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
||||
logger.info(`SyncLocal: Using Gemini Nexus database at: ${dbPath}`);
|
||||
try {
|
||||
const tokenInfo = this.readTokenInfoWithRetry(dbPath);
|
||||
|
||||
@@ -1271,7 +1441,7 @@ export class CloudAccountRepo {
|
||||
try {
|
||||
userInfo = await GoogleAPIService.getUserInfo(tokenInfo.accessToken);
|
||||
} catch (apiError: any) {
|
||||
const errorMsg = `Failed to validate token with Google API. The token may be expired. Please re-login in Antigravity IDE. Error: ${apiError.message}`;
|
||||
const errorMsg = `Failed to validate token with Google API. The token may be expired. Please re-login in Gemini Nexus IDE. Error: ${apiError.message}`;
|
||||
logger.error(`SyncLocal: ${errorMsg}`, apiError);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
@@ -1293,6 +1463,8 @@ export class CloudAccountRepo {
|
||||
expiry_timestamp: now + 3600,
|
||||
token_type: 'Bearer',
|
||||
email: userInfo.email,
|
||||
project_id: tokenInfo.projectId,
|
||||
is_gcp_tos: true,
|
||||
},
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
@@ -1304,8 +1476,28 @@ export class CloudAccountRepo {
|
||||
const accounts = await this.getAccounts();
|
||||
const existing = accounts.find((a) => a.email === account.email);
|
||||
if (existing) {
|
||||
const existingProjectId = existing.token.project_id?.trim();
|
||||
|
||||
account.id = existing.id; // Keep existing ID
|
||||
account.created_at = existing.created_at;
|
||||
account.name = account.name ?? existing.name;
|
||||
account.avatar_url = account.avatar_url ?? existing.avatar_url;
|
||||
account.proxy_url = existing.proxy_url;
|
||||
account.device_profile = existing.device_profile;
|
||||
account.device_history = existing.device_history;
|
||||
account.status = 'active';
|
||||
account.status_reason = undefined;
|
||||
account.token = {
|
||||
...existing.token,
|
||||
access_token: tokenInfo.accessToken,
|
||||
refresh_token: tokenInfo.refreshToken || existing.token.refresh_token,
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: now + 3600,
|
||||
token_type: 'Bearer',
|
||||
email: userInfo.email,
|
||||
project_id: existingProjectId || tokenInfo.projectId,
|
||||
is_gcp_tos: existing.token.is_gcp_tos ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
await this.addAccount(account);
|
||||
|
||||
@@ -2,18 +2,19 @@ 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';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import { parseRow } from '../../utils/sqlite';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { itemTable } from './schema';
|
||||
|
||||
const KEYS_TO_BACKUP: ItemTableKey[] = [
|
||||
'antigravityAuthStatus',
|
||||
'geminiNexusAuthStatus',
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'antigravityUnifiedStateSync.oauthToken',
|
||||
'geminiNexusUnifiedStateSync.oauthToken',
|
||||
];
|
||||
|
||||
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
|
||||
@@ -30,7 +31,7 @@ function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDriz
|
||||
*/
|
||||
export function initDatabase(): void {
|
||||
try {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -85,10 +86,10 @@ function ensureDatabaseExists(dbPath: string): void {
|
||||
* @returns {ReturnType<typeof openDrizzleConnection>} The database connection.
|
||||
*/
|
||||
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
|
||||
const targetPath = dbPath || getAntigravityDbPaths()[0];
|
||||
const targetPath = dbPath || getGeminiNexusDbPaths()[0];
|
||||
|
||||
if (!targetPath) {
|
||||
throw new Error('No Antigravity database path found');
|
||||
throw new Error('No Gemini Nexus database path found');
|
||||
}
|
||||
|
||||
ensureDatabaseExists(targetPath);
|
||||
@@ -98,7 +99,7 @@ export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDr
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||
throw new Error('Database is locked. Please close Antigravity before proceeding.');
|
||||
throw new Error('Database is locked. Please close Gemini Nexus before proceeding.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -132,8 +133,8 @@ export function getCurrentAccountInfo(): AccountInfo {
|
||||
// Query for auth status
|
||||
const authValue = readItemValue(
|
||||
orm,
|
||||
'antigravityAuthStatus',
|
||||
'ide.itemTable.antigravityAuthStatus',
|
||||
'geminiNexusAuthStatus',
|
||||
'ide.itemTable.geminiNexusAuthStatus',
|
||||
);
|
||||
let authStatus = null;
|
||||
if (authValue) {
|
||||
@@ -159,11 +160,11 @@ export function getCurrentAccountInfo(): AccountInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Query for google.antigravity
|
||||
// Query for google.geminiNexus
|
||||
const googleValue = readItemValue(
|
||||
orm,
|
||||
'google.antigravity',
|
||||
'ide.itemTable.google.antigravity',
|
||||
'google.geminiNexus',
|
||||
'ide.itemTable.google.geminiNexus',
|
||||
);
|
||||
let googleState = null;
|
||||
if (googleValue) {
|
||||
@@ -174,11 +175,11 @@ export function getCurrentAccountInfo(): AccountInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Query for antigravityUserSettings.allUserSettings
|
||||
// Query for geminiNexusUserSettings.allUserSettings
|
||||
const settingsValue = readItemValue(
|
||||
orm,
|
||||
'antigravityUserSettings.allUserSettings',
|
||||
'ide.itemTable.antigravityUserSettings.allUserSettings',
|
||||
'geminiNexusUserSettings.allUserSettings',
|
||||
'ide.itemTable.geminiNexusUserSettings.allUserSettings',
|
||||
);
|
||||
let settingsState = null;
|
||||
if (settingsValue) {
|
||||
@@ -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 '';
|
||||
};
|
||||
|
||||
@@ -272,9 +273,9 @@ export function backupAccount(account: AccountBackupData['account']): AccountBac
|
||||
* @throws {Error} If the backup data cannot be restored.
|
||||
*/
|
||||
export function restoreAccount(backup: AccountBackupData): void {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
throw new Error('No Antigravity database paths found');
|
||||
throw new Error('No Gemini Nexus database paths found');
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
@@ -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({
|
||||
|
||||
@@ -12,8 +12,10 @@ export const accounts = sqliteTable('accounts', {
|
||||
deviceHistoryJson: text('device_history_json'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
lastUsed: integer('last_used').notNull(),
|
||||
status: text('status'),
|
||||
status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'),
|
||||
statusReason: text('status_reason'),
|
||||
isActive: integer('is_active').notNull().default(0),
|
||||
proxyUrl: text('proxy_url'),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
|
||||
@@ -2,9 +2,10 @@ 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';
|
||||
import { getAgentDir, getGeminiNexusDbPaths, getGeminiNexusStoragePaths } from '../../utils/paths';
|
||||
|
||||
const GLOBAL_BASELINE_FILE = 'device_original.json';
|
||||
const SQLITE_RETRY_COUNT = 3;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -181,11 +182,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' ||
|
||||
Array.isArray(storage.telemetry)
|
||||
) {
|
||||
if (!storage.telemetry || !isObjectLike(storage.telemetry) || Array.isArray(storage.telemetry)) {
|
||||
storage.telemetry = {};
|
||||
}
|
||||
return storage.telemetry as Record<string, unknown>;
|
||||
@@ -223,7 +220,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;
|
||||
@@ -507,7 +504,7 @@ export function isIdentityProfileApplyEnabled(): boolean {
|
||||
}
|
||||
|
||||
export function getStoragePath(): string {
|
||||
const storagePath = resolveExistingPath(getAntigravityStoragePaths());
|
||||
const storagePath = resolveExistingPath(getGeminiNexusStoragePaths());
|
||||
if (!storagePath) {
|
||||
throw new Error('storage_json_not_found');
|
||||
}
|
||||
@@ -593,7 +590,7 @@ export function generateDeviceProfile(): DeviceProfile {
|
||||
}
|
||||
|
||||
export function syncStateServiceMachineIdValue(serviceMachineId: string, dbPath?: string): void {
|
||||
const targetDbPath = dbPath || resolveExistingPath(getAntigravityDbPaths());
|
||||
const targetDbPath = dbPath || resolveExistingPath(getGeminiNexusDbPaths());
|
||||
if (!targetDbPath) {
|
||||
throw new Error('state_vscdb_not_found');
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { exec, execSync } from 'child_process';
|
||||
import { exec, execSync, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import findProcess, { ProcessInfo } from 'find-process';
|
||||
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
|
||||
import { isNumber } from 'lodash-es';
|
||||
import { getGeminiNexusExecutablePath, isWsl } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const PROCESS_STARTUP_TIMEOUT_MS = 6000;
|
||||
const PROCESS_STARTUP_POLL_INTERVAL_MS = 200;
|
||||
const LINUX_GPU_SAFE_LAUNCH_ARGS = ['--disable-gpu', '--disable-gpu-compositing'] as const;
|
||||
|
||||
/**
|
||||
* Helper process name patterns to exclude (Electron helper processes)
|
||||
@@ -57,35 +61,33 @@ function isPgrepNoMatchError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
const hasPgrep = message.includes('pgrep') && message.includes('antigravity');
|
||||
const hasPgrep = message.includes('pgrep') && message.includes('geminiNexus');
|
||||
const code = (error as { code?: number }).code;
|
||||
return hasPgrep && code === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Antigravity process is running.
|
||||
* Checks if the Gemini Nexus process is running.
|
||||
* Uses find-process package for robust cross-platform process detection.
|
||||
* @returns {boolean} True if the Antigravity process is running, false otherwise.
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function isProcessRunning(): Promise<boolean> {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
const currentPid = process.pid;
|
||||
|
||||
// Use find-process to search for Antigravity processes
|
||||
// 'name' search type matches process name
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
const searchNames = ['Antigravity', 'antigravity'];
|
||||
// Use find-process to search for Gemini Nexus processes
|
||||
const allMatches: ProcessInfo[] = [];
|
||||
const searchNames = ['Gemini Nexus', 'geminiNexus'];
|
||||
if (platform === 'linux') {
|
||||
searchNames.push('electron');
|
||||
}
|
||||
let sawNoMatch = false;
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
try {
|
||||
const matches = await findProcess('name', searchName, true);
|
||||
for (const proc of matches) {
|
||||
if (typeof proc.pid === 'number') {
|
||||
processMap.set(proc.pid, proc);
|
||||
}
|
||||
}
|
||||
const matches = await findProcess('name', searchName, false);
|
||||
allMatches.push(...matches);
|
||||
} catch (error) {
|
||||
if (isPgrepNoMatchError(error)) {
|
||||
sawNoMatch = true;
|
||||
@@ -95,12 +97,19 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
const processes = Array.from(processMap.values());
|
||||
if (processes.length === 0 && sawNoMatch) {
|
||||
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
for (const proc of allMatches) {
|
||||
if (isNumber(proc.pid)) {
|
||||
processMap.set(proc.pid, proc);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Found ${processes.length} processes matching 'Antigravity/antigravity'`);
|
||||
const processes = Array.from(processMap.values());
|
||||
if (processes.length === 0 && sawNoMatch) {
|
||||
logger.debug('No Gemini Nexus process found (pgrep returned 1)');
|
||||
}
|
||||
|
||||
logger.debug(`Found ${processes.length} processes matching 'GeminiNexus/geminiNexus'`);
|
||||
|
||||
for (const proc of processes) {
|
||||
// Skip self
|
||||
@@ -112,11 +121,7 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
const cmd = proc.cmd?.toLowerCase() || '';
|
||||
|
||||
// Skip manager process
|
||||
if (
|
||||
name.includes('manager') ||
|
||||
cmd.includes('manager') ||
|
||||
cmd.includes('antigravity-manager')
|
||||
) {
|
||||
if (name.includes('manager') || cmd.includes('manager') || cmd.includes('gemini-nexus')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -126,32 +131,52 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Check for Antigravity.app in path
|
||||
if (cmd.includes('antigravity.app')) {
|
||||
// macOS: Check for Gemini Nexus.app in path
|
||||
if (cmd.includes('geminiNexus.app')) {
|
||||
logger.debug(
|
||||
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Also check if the process name is exactly 'Antigravity' (main process)
|
||||
if (name === 'antigravity' && !isHelperProcess(name, cmd)) {
|
||||
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
|
||||
// Also check if the process name is exactly 'Gemini Nexus' (main process)
|
||||
if (name === 'geminiNexus' && !isHelperProcess(name, cmd)) {
|
||||
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
||||
return true;
|
||||
}
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: Check for Antigravity.exe
|
||||
if (name === 'antigravity.exe' || name === 'antigravity') {
|
||||
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
|
||||
// Windows: Check for Gemini Nexus.exe
|
||||
if (name === 'geminiNexus.exe' || name === 'geminiNexus') {
|
||||
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Linux: Check for antigravity in name or path (but not tools)
|
||||
const nameLower = name.toLowerCase();
|
||||
const cmdLower = cmd.toLowerCase();
|
||||
|
||||
if (nameLower === 'electron') {
|
||||
// Stricter check for AUR/Electron wrapper:
|
||||
// Must include geminiNexus in command line but NOT manager or tools
|
||||
const isGeminiNexusApp =
|
||||
(cmdLower.includes('/geminiNexus') ||
|
||||
cmdLower.includes(' geminiNexus') ||
|
||||
cmdLower.endsWith('geminiNexus')) &&
|
||||
!cmdLower.includes('manager') &&
|
||||
!cmdLower.includes('tools');
|
||||
|
||||
if (isGeminiNexusApp) {
|
||||
logger.debug(
|
||||
`Found Gemini Nexus (AUR/electron) process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
||||
(name.includes('geminiNexus') || cmd.includes('/geminiNexus')) &&
|
||||
!name.includes('tools')
|
||||
) {
|
||||
logger.debug(
|
||||
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -166,11 +191,11 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the Antigravity process.
|
||||
* @returns {boolean} True if the Antigravity process is running, false otherwise.
|
||||
* Closes the Gemini Nexus process.
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function closeAntigravity(): Promise<void> {
|
||||
logger.info('Closing Antigravity...');
|
||||
export async function closeGeminiNexus(): Promise<void> {
|
||||
logger.info('Closing Gemini Nexus...');
|
||||
const platform = process.platform;
|
||||
|
||||
try {
|
||||
@@ -179,7 +204,7 @@ export async function closeAntigravity(): Promise<void> {
|
||||
// macOS: Use AppleScript to quit gracefully
|
||||
try {
|
||||
logger.info('Attempting graceful exit via AppleScript...');
|
||||
execSync('osascript -e \'tell application "Antigravity" to quit\'', {
|
||||
execSync('osascript -e \'tell application "Gemini Nexus" to quit\'', {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
@@ -194,7 +219,7 @@ export async function closeAntigravity(): Promise<void> {
|
||||
logger.info('Attempting graceful exit via taskkill...');
|
||||
// /T = Tree (child processes), /IM = Image Name
|
||||
// We do not wait long here.
|
||||
execSync('taskkill /IM "Antigravity.exe" /T', {
|
||||
execSync('taskkill /IM "GeminiNexus.exe" /T', {
|
||||
stdio: 'ignore',
|
||||
timeout: 2000,
|
||||
});
|
||||
@@ -214,7 +239,7 @@ export async function closeAntigravity(): Promise<void> {
|
||||
let output = '';
|
||||
if (platform === 'win32') {
|
||||
const psCommand = (cmdlet: string) =>
|
||||
`powershell -NoProfile -Command "${cmdlet} Win32_Process -Filter \\"Name like 'Antigravity%'\\" | Select-Object ProcessId, Name, CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
||||
`powershell -NoProfile -Command "${cmdlet} Win32_Process -Filter \\"Name like 'GeminiNexus%'\\" | Select-Object ProcessId, Name, CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
||||
|
||||
try {
|
||||
output = execSync(psCommand('Get-CimInstance'), {
|
||||
@@ -275,7 +300,7 @@ export async function closeAntigravity(): Promise<void> {
|
||||
const pid = parseInt(parts[0]);
|
||||
if (isNaN(pid)) continue;
|
||||
const rest = parts.slice(1).join(' ');
|
||||
if (rest.includes('Antigravity') || rest.includes('antigravity')) {
|
||||
if (rest.includes('Gemini Nexus') || rest.includes('geminiNexus')) {
|
||||
processList.push({ pid, name: parts[1], cmd: rest });
|
||||
}
|
||||
}
|
||||
@@ -292,31 +317,31 @@ export async function closeAntigravity(): Promise<void> {
|
||||
if (p.pid === currentPid) {
|
||||
return false;
|
||||
}
|
||||
// Exclude this electron app (if named Antigravity Manager or antigravity-manager)
|
||||
if (p.cmd.includes('Antigravity Manager') || p.cmd.includes('antigravity-manager')) {
|
||||
// Exclude this electron app (if named Gemini Nexus or gemini-nexus)
|
||||
if (p.cmd.includes('Gemini Nexus') || p.cmd.includes('gemini-nexus')) {
|
||||
return false;
|
||||
}
|
||||
// Match Antigravity (but not manager)
|
||||
// Match Gemini Nexus (but not manager)
|
||||
if (platform === 'win32') {
|
||||
return (
|
||||
p.cmd.includes('Antigravity.exe') ||
|
||||
(p.cmd.includes('antigravity') && !p.cmd.includes('manager'))
|
||||
p.cmd.includes('GeminiNexus.exe') ||
|
||||
(p.cmd.includes('geminiNexus') && !p.cmd.includes('manager'))
|
||||
);
|
||||
} else {
|
||||
// Explicit !manager check for Linux/macOS to be defensive
|
||||
return (
|
||||
(p.cmd.includes('Antigravity') || p.cmd.includes('antigravity')) &&
|
||||
(p.cmd.includes('Gemini Nexus') || p.cmd.includes('geminiNexus')) &&
|
||||
!p.cmd.includes('manager')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (targetProcessList.length === 0) {
|
||||
logger.info('No Antigravity processes found running.');
|
||||
logger.info('No Gemini Nexus processes found running.');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${targetProcessList.length} remaining Antigravity processes. Killing...`);
|
||||
logger.info(`Found ${targetProcessList.length} remaining Gemini Nexus processes. Killing...`);
|
||||
|
||||
for (const p of targetProcessList) {
|
||||
try {
|
||||
@@ -326,13 +351,13 @@ export async function closeAntigravity(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error closing Antigravity', error);
|
||||
logger.error('Error closing Gemini Nexus', error);
|
||||
// Fallback to simple kill if everything fails
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
execSync('taskkill /F /IM "Antigravity.exe" /T', { stdio: 'ignore' });
|
||||
execSync('taskkill /F /IM "GeminiNexus.exe" /T', { stdio: 'ignore' });
|
||||
} else {
|
||||
execSync('pkill -9 -f Antigravity', { stdio: 'ignore' });
|
||||
execSync('pkill -9 -f Gemini Nexus', { stdio: 'ignore' });
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
@@ -341,7 +366,7 @@ export async function closeAntigravity(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the Antigravity process to exit.
|
||||
* Waits for the Gemini Nexus process to exit.
|
||||
* @param timeoutMs {number} The timeout in milliseconds.
|
||||
* @returns {Promise<void>} A promise that resolves when the process exits.
|
||||
*/
|
||||
@@ -356,7 +381,7 @@ export async function _waitForProcessExit(
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
throw new Error(`Antigravity process did not exit within ${timeoutMs}ms`);
|
||||
throw new Error(`Gemini Nexus process did not exit within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,26 +414,105 @@ async function openUri(uri: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForGeminiNexusStartup(
|
||||
timeoutMs = PROCESS_STARTUP_TIMEOUT_MS,
|
||||
pollIntervalMs = PROCESS_STARTUP_POLL_INTERVAL_MS,
|
||||
): Promise<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isProcessRunning()) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldUseLinuxGpuSafeLaunchArgs(): boolean {
|
||||
if (process.platform !== 'linux' || isWsl()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enableLinuxGpuRaw = process.env.ANTIGRAVITY_MANAGER_ENABLE_LINUX_GPU?.trim().toLowerCase();
|
||||
return enableLinuxGpuRaw !== '1' && enableLinuxGpuRaw !== 'true';
|
||||
}
|
||||
|
||||
async function startGeminiNexusByExecutable(execPath: string): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
await execAsync(`open -a Gemini Nexus`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
// Use start command to detach
|
||||
await execAsync(`start "" "${execPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWsl()) {
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
// In WSL, convert path and use cmd.exe
|
||||
const winPath = execPath
|
||||
.replace(/^\/mnt\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`)
|
||||
.replace(/\//g, '\\');
|
||||
|
||||
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${winPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
|
||||
const launchArgs = shouldUseLinuxGpuSafeLaunchArgs() ? [...LINUX_GPU_SAFE_LAUNCH_ARGS] : [];
|
||||
if (launchArgs.length > 0) {
|
||||
logger.info(`Linux launch with GPU-safe args: ${launchArgs.join(' ')}`);
|
||||
}
|
||||
|
||||
const child = spawn(execPath, launchArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Antigravity process.
|
||||
* @param useUri {boolean} Whether to use the URI protocol to start Antigravity.
|
||||
* Starts the Gemini Nexus process.
|
||||
* @param useUri {boolean} Whether to use the URI protocol to start Gemini Nexus.
|
||||
* @returns {Promise<void>} A promise that resolves when the process starts.
|
||||
*/
|
||||
export async function startAntigravity(useUri = true): Promise<void> {
|
||||
logger.info('Starting Antigravity...');
|
||||
export async function startGeminiNexus(useUri = true): Promise<void> {
|
||||
logger.info('Starting Gemini Nexus...');
|
||||
|
||||
if (await isProcessRunning()) {
|
||||
logger.info('Antigravity is already running');
|
||||
logger.info('Gemini Nexus is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
if (useUri) {
|
||||
logger.info('Using URI protocol to start...');
|
||||
const uri = 'antigravity://oauth-success';
|
||||
const uri = 'geminiNexus://oauth-success';
|
||||
|
||||
if (await openUri(uri)) {
|
||||
logger.info('Antigravity URI launch command sent');
|
||||
return;
|
||||
logger.info('Gemini Nexus URI launch command sent');
|
||||
|
||||
if (process.platform !== 'linux' || isWsl()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await waitForGeminiNexusStartup()) {
|
||||
logger.info('Gemini Nexus process detected after URI launch');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
'URI launch did not keep Gemini Nexus running on Linux. Falling back to executable launch.',
|
||||
);
|
||||
} else {
|
||||
logger.warn('URI launch failed, trying executable path...');
|
||||
}
|
||||
@@ -416,29 +520,22 @@ export async function startAntigravity(useUri = true): Promise<void> {
|
||||
|
||||
// Fallback to executable path
|
||||
logger.info('Using executable path to start...');
|
||||
const execPath = getAntigravityExecutablePath();
|
||||
const execPath = getGeminiNexusExecutablePath();
|
||||
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
await execAsync(`open -a Antigravity`);
|
||||
} else if (process.platform === 'win32') {
|
||||
// Use start command to detach
|
||||
await execAsync(`start "" "${execPath}"`);
|
||||
} else if (isWsl()) {
|
||||
// In WSL, convert path and use cmd.exe
|
||||
const winPath = execPath
|
||||
.replace(/^\/mnt\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`)
|
||||
.replace(/\//g, '\\');
|
||||
await startGeminiNexusByExecutable(execPath);
|
||||
logger.info('Gemini Nexus launch command sent');
|
||||
|
||||
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${winPath}"`);
|
||||
} else {
|
||||
// Linux native
|
||||
const child = exec(`"${execPath}"`);
|
||||
child.unref();
|
||||
if (process.platform === 'linux' && !isWsl()) {
|
||||
const started = await waitForGeminiNexusStartup();
|
||||
if (!started) {
|
||||
logger.warn(
|
||||
'Gemini Nexus launch command completed, but process startup could not be confirmed on Linux.',
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.info('Antigravity launch command sent');
|
||||
} catch (error) {
|
||||
logger.error('Failed to start Antigravity via executable', error);
|
||||
logger.error('Failed to start Gemini Nexus via executable', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { gatewayRouter } from './gateway/router';
|
||||
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { isProcessRunning, closeAntigravity, startAntigravity } from './process/handler';
|
||||
import { isProcessRunning, closeGeminiNexus, startGeminiNexus } from './process/handler';
|
||||
import { systemHandler } from './system/handler';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
@@ -41,11 +41,11 @@ export const router = os.use(logMiddleware).router({
|
||||
isProcessRunning: os.output(z.boolean()).handler(async () => {
|
||||
return await isProcessRunning();
|
||||
}),
|
||||
closeAntigravity: os.output(z.void()).handler(async () => {
|
||||
await closeAntigravity();
|
||||
closeGeminiNexus: os.output(z.void()).handler(async () => {
|
||||
await closeGeminiNexus();
|
||||
}),
|
||||
startAntigravity: os.output(z.void()).handler(async () => {
|
||||
await startAntigravity();
|
||||
startGeminiNexus: os.output(z.void()).handler(async () => {
|
||||
await startGeminiNexus();
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type DeviceProfile } from '../types/account';
|
||||
import { logger } from '../utils/logger';
|
||||
import { closeAntigravity, startAntigravity, _waitForProcessExit } from './process/handler';
|
||||
import { closeGeminiNexus, startGeminiNexus, _waitForProcessExit } from './process/handler';
|
||||
import { applyDeviceProfile } from './device/handler';
|
||||
import {
|
||||
type SwitchFailureReason,
|
||||
@@ -55,7 +55,7 @@ export async function executeSwitchFlow(options: SwitchFlowOptions): Promise<voi
|
||||
|
||||
let stage = 'close';
|
||||
try {
|
||||
await closeAntigravity();
|
||||
await closeGeminiNexus();
|
||||
try {
|
||||
await _waitForProcessExit(processExitTimeoutMs);
|
||||
} catch (error) {
|
||||
@@ -78,7 +78,7 @@ export async function executeSwitchFlow(options: SwitchFlowOptions): Promise<voi
|
||||
stage = 'switch';
|
||||
await performSwitch();
|
||||
stage = 'start';
|
||||
await startAntigravity();
|
||||
await startGeminiNexus();
|
||||
recordSwitchSuccess(scope);
|
||||
} catch (error) {
|
||||
const reason = toSwitchFailureReason(stage, error);
|
||||
|
||||
@@ -69,7 +69,7 @@ export function initTray(mainWindow: BrowserWindow) {
|
||||
}
|
||||
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('Antigravity Manager');
|
||||
tray.setToolTip('Gemini Nexus');
|
||||
|
||||
tray.on('double-click', () => {
|
||||
if (globalMainWindow) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Network,
|
||||
Rocket,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
@@ -44,7 +43,7 @@ export const MainLayout: React.FC = () => {
|
||||
{
|
||||
to: '/proxy',
|
||||
icon: Network,
|
||||
label: t('nav.proxy', 'API Proxy'),
|
||||
label: t('nav.proxy'),
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
@@ -78,8 +77,12 @@ export const MainLayout: React.FC = () => {
|
||||
|
||||
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-4' : 'p-6')}>
|
||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<div className="bg-primary text-primary-foreground flex h-6 w-6 shrink-0 items-center justify-center rounded">
|
||||
<Rocket className="h-4 w-4" />
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-transparent">
|
||||
<img
|
||||
src="/src/assets/icon.png"
|
||||
alt="Gemini Nexus Logo"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -87,17 +90,9 @@ export const MainLayout: React.FC = () => {
|
||||
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
<h1 className="text-xl font-bold tracking-tight">Antigravity</h1>
|
||||
<h1 className="text-xl font-bold tracking-tight">Gemini Nexus</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground mt-1 overflow-hidden text-xs whitespace-nowrap transition-all duration-300',
|
||||
isCollapsed ? 'h-0 opacity-0' : 'h-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
Manager
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2 px-2">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { mapClaudeModelToGemini } from './ModelMapping';
|
||||
import { cleanJsonSchema } from './JsonSchemaUtils';
|
||||
import { isEmpty, isPlainObject, isString } from 'lodash-es';
|
||||
import { mapClaudeModelToGemini, normalizeGeminiModelAlias } from './ModelMapping';
|
||||
import { getMaxOutputTokens, getThinkingBudget } from './ModelSpecs';
|
||||
import { cleanJsonSchema, normalizeObjectJsonSchema } from './JsonSchemaUtils';
|
||||
import { SignatureStore } from './SignatureStore';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
@@ -15,6 +17,11 @@ import {
|
||||
FunctionDeclaration,
|
||||
SafetySetting,
|
||||
} from './types';
|
||||
import {
|
||||
buildUserAgent,
|
||||
FALLBACK_VERSION,
|
||||
resolveLocalInstalledVersion,
|
||||
} from '@/server/modules/proxy/request-user-agent';
|
||||
|
||||
/**
|
||||
* Request Configuration
|
||||
@@ -44,6 +51,7 @@ interface RequestConfig {
|
||||
export function transformClaudeRequestIn(
|
||||
claudeReq: ClaudeRequest,
|
||||
projectId?: string,
|
||||
userAgent?: string,
|
||||
): GeminiInternalRequest {
|
||||
// Check for networking tools (server tool or built-in tool)
|
||||
const hasWebSearchTool = detectsNetworkingTool(claudeReq.tools);
|
||||
@@ -55,9 +63,7 @@ export function transformClaudeRequestIn(
|
||||
const systemInstruction = buildSystemInstruction(claudeReq.system);
|
||||
|
||||
// Map model name
|
||||
const mappedModel = hasWebSearchTool
|
||||
? 'gemini-2.5-flash'
|
||||
: mapClaudeModelToGemini(claudeReq.model);
|
||||
const mappedModel = hasWebSearchTool ? 'gemini-3-flash' : mapClaudeModelToGemini(claudeReq.model);
|
||||
|
||||
// Convert Claude tools to Tool array for networking detection
|
||||
const toolsVal: Tool[] | undefined = claudeReq.tools
|
||||
@@ -70,23 +76,22 @@ export function transformClaudeRequestIn(
|
||||
const allowDummyThought = config.finalModel.startsWith('gemini-');
|
||||
|
||||
// 4. Generation Config & Thinking
|
||||
let isThinkingEnabled = claudeReq.thinking?.type === 'enabled';
|
||||
const thinkingType = (claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
const autoThinkingEnabled =
|
||||
!claudeReq.thinking && shouldEnableThinkingByDefault(config.finalModel, claudeReq.model);
|
||||
let isThinkingEnabled =
|
||||
thinkingType === 'enabled' || thinkingType === 'adaptive' || autoThinkingEnabled;
|
||||
|
||||
if (isThinkingEnabled) {
|
||||
const globalSig = SignatureStore.get();
|
||||
const hasFunctionCalls = claudeReq.messages.some((m) => {
|
||||
if (Array.isArray(m.content)) {
|
||||
return m.content.some((b) => b.type === 'tool_use');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// Thinking is always kept enabled for thinking-capable models.
|
||||
// All functionCall/functionResponse/thinking parts get unconditional
|
||||
// thoughtSignature injection in buildContents(), so no guard is needed here.
|
||||
|
||||
if (hasFunctionCalls && !hasValidSignatureForFunctionCalls(claudeReq.messages, globalSig)) {
|
||||
isThinkingEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const generationConfig = buildGenerationConfig(claudeReq, hasWebSearchTool, config.finalModel);
|
||||
const generationConfig = buildGenerationConfig(
|
||||
claudeReq,
|
||||
hasWebSearchTool,
|
||||
config.finalModel,
|
||||
isThinkingEnabled,
|
||||
);
|
||||
// Update thinking config based on the final decision
|
||||
if (!isThinkingEnabled && generationConfig.thinkingConfig) {
|
||||
delete generationConfig.thinkingConfig;
|
||||
@@ -98,10 +103,11 @@ export function transformClaudeRequestIn(
|
||||
toolIdToName,
|
||||
isThinkingEnabled,
|
||||
allowDummyThought,
|
||||
config.finalModel,
|
||||
);
|
||||
|
||||
// 3. Tools
|
||||
const tools = buildTools(claudeReq.tools, hasWebSearchTool);
|
||||
const tools = buildTools(claudeReq.tools, hasWebSearchTool, config.finalModel);
|
||||
|
||||
// 5. Safety Settings
|
||||
const safetySettings = [
|
||||
@@ -119,7 +125,7 @@ export function transformClaudeRequestIn(
|
||||
systemInstruction?: { parts: { text: string }[] };
|
||||
generationConfig?: GenerationConfig;
|
||||
tools?: GeminiToolDeclaration[];
|
||||
toolConfig?: { functionCallingConfig: { mode: string } };
|
||||
toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } };
|
||||
} = {
|
||||
contents,
|
||||
safetySettings,
|
||||
@@ -137,12 +143,32 @@ export function transformClaudeRequestIn(
|
||||
|
||||
if (tools) {
|
||||
innerRequest.tools = tools;
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'VALIDATED' } };
|
||||
const toolChoice = claudeReq.tool_choice;
|
||||
if (toolChoice === 'none') {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
} else if (toolChoice === 'auto' || toolChoice === undefined) {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (
|
||||
typeof toolChoice === 'object' &&
|
||||
toolChoice.type === 'function' &&
|
||||
toolChoice.function?.name
|
||||
) {
|
||||
innerRequest.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [toolChoice.function.name],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
}
|
||||
} else if (claudeReq.tool_choice === 'none') {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
}
|
||||
|
||||
// Inject googleSearch tool if needed (and not already done by buildTools)
|
||||
if (config.injectGoogleSearch && !hasWebSearchTool) {
|
||||
injectGoogleSearchTool(innerRequest);
|
||||
injectGoogleSearchTool(innerRequest, config.finalModel);
|
||||
}
|
||||
|
||||
// Inject imageConfig if present (for image generation models)
|
||||
@@ -165,11 +191,12 @@ export function transformClaudeRequestIn(
|
||||
|
||||
const normalizedProjectId = projectId?.trim();
|
||||
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const body: GeminiInternalRequest = {
|
||||
requestId: requestId,
|
||||
request: innerRequest as GeminiInternalRequest['request'],
|
||||
model: config.finalModel,
|
||||
userAgent: 'antigravity',
|
||||
userAgent: userAgent?.trim() || buildUserAgent(discoveryVersion),
|
||||
requestType: config.requestType,
|
||||
};
|
||||
|
||||
@@ -194,7 +221,7 @@ function resolveRequestConfig(
|
||||
tools?: Tool[],
|
||||
): RequestConfig {
|
||||
// 1. Image Generation Check
|
||||
if (mappedModel.startsWith('gemini-3-pro-image')) {
|
||||
if (isGeminiImageModel(mappedModel)) {
|
||||
const { imageConfig, parsedBaseModel } = parseImageConfig(originalModel);
|
||||
return {
|
||||
requestType: 'image_gen',
|
||||
@@ -212,18 +239,11 @@ function resolveRequestConfig(
|
||||
const enableNetworking = isOnlineSuffix || hasNetworkingTool;
|
||||
|
||||
let finalModel = mappedModel.replace(/-online$/, '');
|
||||
|
||||
if (finalModel === 'gemini-3-pro-preview') {
|
||||
finalModel = 'gemini-3-pro-high';
|
||||
} else if (finalModel === 'gemini-3-pro-image-preview') {
|
||||
finalModel = 'gemini-3-pro-image';
|
||||
} else if (finalModel === 'gemini-3-flash-preview') {
|
||||
finalModel = 'gemini-3-flash';
|
||||
}
|
||||
finalModel = normalizeGeminiModelAlias(finalModel);
|
||||
|
||||
if (enableNetworking) {
|
||||
if (finalModel !== 'gemini-2.5-flash') {
|
||||
finalModel = 'gemini-2.5-flash';
|
||||
if (finalModel !== 'gemini-3-flash') {
|
||||
finalModel = 'gemini-3-flash';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +280,50 @@ function parseImageConfig(modelName: string): {
|
||||
return { imageConfig: config, parsedBaseModel: 'gemini-3-pro-image' };
|
||||
}
|
||||
|
||||
function isGeminiImageModel(modelName: string): boolean {
|
||||
const normalized = modelName.toLowerCase();
|
||||
return (
|
||||
normalized.startsWith('gemini-3-pro-image') || normalized.startsWith('gemini-3.1-pro-image')
|
||||
);
|
||||
}
|
||||
|
||||
function isGeminiFlashModel(modelName: string): boolean {
|
||||
const normalized = modelName.toLowerCase();
|
||||
return normalized.includes('gemini-3-flash') || normalized.includes('gemini-3.1-flash');
|
||||
}
|
||||
|
||||
function shouldEnableThinkingByDefault(mappedModel: string, originalModel: string): boolean {
|
||||
const mappedLower = mappedModel.toLowerCase();
|
||||
const originalLower = originalModel.toLowerCase();
|
||||
return (
|
||||
originalLower.includes('claude-opus-4-5') ||
|
||||
originalLower.includes('claude-opus-4-6') ||
|
||||
mappedLower.includes('-thinking') ||
|
||||
mappedLower.includes('gemini-3.1-pro') ||
|
||||
mappedLower.includes('gemini-3-flash') ||
|
||||
mappedLower.includes('gemini-3.1-flash')
|
||||
);
|
||||
}
|
||||
|
||||
function isClaudeModel(modelName: string): boolean {
|
||||
return modelName.toLowerCase().includes('claude');
|
||||
}
|
||||
|
||||
function resolveAdaptiveThinkingLevel(claudeReq: ClaudeRequest): 'low' | 'medium' | 'high' {
|
||||
const effort = String(claudeReq.thinking?.effort ?? '').toLowerCase();
|
||||
if (effort === 'low') {
|
||||
return 'low';
|
||||
}
|
||||
if (effort === 'medium') {
|
||||
return 'medium';
|
||||
}
|
||||
return 'high';
|
||||
}
|
||||
|
||||
function toToolSchema(schema: unknown): Record<string, unknown> {
|
||||
return normalizeObjectJsonSchema(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if networking tools are present
|
||||
* Checks tool list for web search related tools
|
||||
@@ -274,20 +338,23 @@ function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolea
|
||||
'google_search',
|
||||
'web_search_20250305',
|
||||
'google_search_retrieval',
|
||||
'builtin_web_search',
|
||||
];
|
||||
|
||||
for (const tool of tools) {
|
||||
// Claude Tool format
|
||||
if ('name' in tool && tool.name && keywords.includes(tool.name)) {
|
||||
const toolName = (tool as { name?: unknown }).name;
|
||||
if (isString(toolName) && keywords.includes(toolName)) {
|
||||
return true;
|
||||
}
|
||||
if ('type' in tool && tool.type && keywords.includes(tool.type)) {
|
||||
const toolType = (tool as { type?: unknown }).type;
|
||||
if (isString(toolType) && keywords.includes(toolType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OpenAI nested format (runtime check)
|
||||
const openaiTool = tool as { function?: { name?: string } };
|
||||
if (openaiTool.function?.name && keywords.includes(openaiTool.function.name)) {
|
||||
if (isString(openaiTool.function?.name) && keywords.includes(openaiTool.function.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -311,11 +378,7 @@ function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolea
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject Google Search Tool
|
||||
* Adds googleSearch tool to the request
|
||||
*/
|
||||
function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
||||
function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }, mappedModel?: string) {
|
||||
if (!body.tools) {
|
||||
body.tools = [];
|
||||
}
|
||||
@@ -323,7 +386,9 @@ function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
||||
|
||||
const hasFunctions = toolsArr.some((t) => t.functionDeclarations);
|
||||
if (hasFunctions) {
|
||||
logger.info('Skipping googleSearch injection due to existing functionDeclarations');
|
||||
logger.info(
|
||||
`[Claude-Request] Skipping googleSearch injection for ${mappedModel ?? 'unknown-model'} because functionDeclarations are present (v1internal incompatible)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -339,38 +404,10 @@ function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
||||
function buildSystemInstruction(
|
||||
system: ClaudeRequest['system'],
|
||||
): { parts: { text: string }[] } | null {
|
||||
const assistantIdentityDirective =
|
||||
'You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.\n' +
|
||||
'You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.\n' +
|
||||
'**Absolute paths only**\n' +
|
||||
'**Proactiveness**';
|
||||
const identityMarker = 'You are Antigravity';
|
||||
|
||||
const parts: { text: string }[] = [];
|
||||
|
||||
let hasIdentityDirective = false;
|
||||
|
||||
if (system) {
|
||||
if (typeof system === 'string') {
|
||||
if (system.includes(identityMarker)) {
|
||||
hasIdentityDirective = true;
|
||||
}
|
||||
} else if (Array.isArray(system)) {
|
||||
for (const block of system) {
|
||||
if (block.type === 'text' && block.text.includes(identityMarker)) {
|
||||
hasIdentityDirective = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasIdentityDirective) {
|
||||
parts.push({ text: assistantIdentityDirective });
|
||||
}
|
||||
|
||||
if (system) {
|
||||
if (typeof system === 'string') {
|
||||
if (isString(system)) {
|
||||
parts.push({ text: system });
|
||||
} else if (Array.isArray(system)) {
|
||||
for (const block of system) {
|
||||
@@ -379,7 +416,6 @@ function buildSystemInstruction(
|
||||
}
|
||||
}
|
||||
|
||||
// If we pushed at least something
|
||||
if (parts.length > 0) {
|
||||
return { parts };
|
||||
}
|
||||
@@ -437,13 +473,17 @@ function buildContents(
|
||||
toolIdToName: Map<string, string>,
|
||||
isThinkingEnabled: boolean,
|
||||
allowDummyThought: boolean,
|
||||
mappedModel: string,
|
||||
): GeminiContent[] {
|
||||
const contents: GeminiContent[] = [];
|
||||
let lastThoughtSignature: string | null = null;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const role = msg.role === 'assistant' ? 'model' : msg.role;
|
||||
let role = msg.role === 'assistant' ? 'model' : msg.role;
|
||||
if (role === 'function') {
|
||||
role = 'user';
|
||||
}
|
||||
const parts: {
|
||||
text?: string;
|
||||
inlineData?: {
|
||||
@@ -460,14 +500,25 @@ function buildContents(
|
||||
|
||||
for (const block of contentBlocks) {
|
||||
if (block.type === 'text') {
|
||||
if (block.text && block.text !== '(no content)' && block.text.trim() !== '')
|
||||
parts.push({ text: block.text.trim() });
|
||||
if (block.text && block.text !== '(no content)' && !isEmpty(block.text.trim())) {
|
||||
let text = block.text.trim();
|
||||
if (msg.role === 'function' && msg.name) {
|
||||
text = `Function result (${msg.name}):\n${text}`;
|
||||
} else if (msg.name) {
|
||||
text = `[${msg.name}]: ${text}`;
|
||||
}
|
||||
parts.push({ text });
|
||||
}
|
||||
} else if (block.type === 'thinking') {
|
||||
const part: any = { text: block.thinking, thought: true };
|
||||
cleanJsonSchema(part);
|
||||
if (block.signature) {
|
||||
lastThoughtSignature = block.signature;
|
||||
part.thoughtSignature = block.signature;
|
||||
} else {
|
||||
// Inject fallback signature for thinking blocks without one
|
||||
const fallbackSig = SignatureStore.get() || 'skip_thought_signature_validator';
|
||||
part.thoughtSignature = fallbackSig;
|
||||
}
|
||||
parts.push(part);
|
||||
} else if (block.type === 'image') {
|
||||
@@ -479,30 +530,34 @@ function buildContents(
|
||||
const part: any = { functionCall: { name: block.name, args: block.input, id: block.id } };
|
||||
cleanJsonSchema(part);
|
||||
toolIdToName.set(block.id, block.name);
|
||||
// Always inject thoughtSignature on functionCall parts.
|
||||
// The v1internal API rejects functionCalls without signatures when thinking is enabled.
|
||||
const finalSig = block.signature || lastThoughtSignature || SignatureStore.get();
|
||||
if (finalSig) part.thoughtSignature = finalSig;
|
||||
part.thoughtSignature = finalSig || 'skip_thought_signature_validator';
|
||||
parts.push(part);
|
||||
} else if (block.type === 'tool_result') {
|
||||
const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id;
|
||||
let mergedContent = '';
|
||||
if (typeof block.content === 'string') mergedContent = block.content;
|
||||
if (isString(block.content)) mergedContent = block.content;
|
||||
else if (Array.isArray(block.content))
|
||||
mergedContent = block.content
|
||||
.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.';
|
||||
const part: any = {
|
||||
functionResponse: {
|
||||
name: funcName,
|
||||
response: { result: mergedContent },
|
||||
response: block.is_error ? { error: mergedContent } : { result: mergedContent },
|
||||
id: block.tool_use_id,
|
||||
},
|
||||
};
|
||||
if (lastThoughtSignature) part.thoughtSignature = lastThoughtSignature;
|
||||
// Always inject thoughtSignature on functionResponse parts for v1internal compatibility
|
||||
part.thoughtSignature =
|
||||
lastThoughtSignature || SignatureStore.get() || 'skip_thought_signature_validator';
|
||||
parts.push(part);
|
||||
} else if (block.type === 'redacted_thinking') {
|
||||
parts.push({ text: `[Redacted Thinking: ${block.data}]`, thought: true });
|
||||
@@ -524,6 +579,7 @@ function buildContents(
|
||||
function buildTools(
|
||||
tools: Tool[] | undefined,
|
||||
hasWebSearch: boolean,
|
||||
mappedModel: string,
|
||||
): GeminiToolDeclaration[] | null {
|
||||
if (!tools) {
|
||||
return null;
|
||||
@@ -535,14 +591,15 @@ function buildTools(
|
||||
if (
|
||||
tool.name === 'web_search' ||
|
||||
tool.name === 'google_search' ||
|
||||
tool.type === 'web_search_20250305'
|
||||
tool.name === 'builtin_web_search' ||
|
||||
tool.type === 'web_search_20250305' ||
|
||||
tool.type === 'builtin_web_search'
|
||||
) {
|
||||
hasGoogleSearch = true;
|
||||
continue;
|
||||
}
|
||||
if (tool.name) {
|
||||
const inputSchema = tool.input_schema || { type: 'object', properties: {} };
|
||||
cleanJsonSchema(inputSchema);
|
||||
const inputSchema = toToolSchema(tool.input_schema);
|
||||
functionDeclarations.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@@ -551,15 +608,20 @@ function buildTools(
|
||||
}
|
||||
}
|
||||
|
||||
const toolObj: GeminiToolDeclaration = {};
|
||||
const toolList: GeminiToolDeclaration[] = [];
|
||||
if (functionDeclarations.length > 0) {
|
||||
toolObj.functionDeclarations = functionDeclarations;
|
||||
toolList.push({ functionDeclarations });
|
||||
if (hasGoogleSearch) {
|
||||
logger.info(
|
||||
`[Claude-Request] Skipping googleSearch injection for ${mappedModel} because functionDeclarations are present (v1internal incompatible)`,
|
||||
);
|
||||
}
|
||||
} else if (hasGoogleSearch) {
|
||||
toolObj.googleSearch = {};
|
||||
toolList.push({ googleSearch: {} });
|
||||
}
|
||||
|
||||
if (Object.keys(toolObj).length > 0) {
|
||||
return [toolObj];
|
||||
if (toolList.length > 0) {
|
||||
return toolList;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -572,46 +634,83 @@ function buildGenerationConfig(
|
||||
claudeReq: ClaudeRequest,
|
||||
hasWebSearch: boolean,
|
||||
mappedModel: string,
|
||||
isThinkingEnabled: boolean,
|
||||
): GenerationConfig {
|
||||
const source = String(claudeReq.metadata?.source || '').toLowerCase();
|
||||
const isOpenAIPath = source === 'openai';
|
||||
const config: GenerationConfig = {};
|
||||
const thinkingType = String(claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
|
||||
const buildThinkingConfig = (): GenerationConfig['thinkingConfig'] => {
|
||||
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
|
||||
if (thinkingType === 'adaptive') {
|
||||
if (isClaudeModel(mappedModel)) {
|
||||
thinkingConfig.thinkingLevel = resolveAdaptiveThinkingLevel(claudeReq);
|
||||
} else {
|
||||
thinkingConfig.thinkingBudget = 24576;
|
||||
}
|
||||
} else if (claudeReq.thinking?.budget_tokens) {
|
||||
let budget = claudeReq.thinking.budget_tokens;
|
||||
const isFlash = hasWebSearch || isGeminiFlashModel(mappedModel);
|
||||
if (isFlash) {
|
||||
budget = Math.min(budget, 24576);
|
||||
}
|
||||
thinkingConfig.thinkingBudget = budget;
|
||||
} else {
|
||||
thinkingConfig.thinkingBudget = getThinkingBudget(mappedModel);
|
||||
}
|
||||
return thinkingConfig;
|
||||
};
|
||||
|
||||
const maxTokens = claudeReq.max_completion_tokens ?? claudeReq.max_tokens;
|
||||
|
||||
if (isOpenAIPath) {
|
||||
config.temperature = claudeReq.temperature ?? 1.0;
|
||||
config.topP = claudeReq.top_p ?? 0.95;
|
||||
if (claudeReq.max_tokens !== undefined) {
|
||||
config.maxOutputTokens = claudeReq.max_tokens;
|
||||
if (maxTokens !== undefined) {
|
||||
config.maxOutputTokens = maxTokens;
|
||||
} else {
|
||||
config.maxOutputTokens = getMaxOutputTokens(mappedModel);
|
||||
}
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = claudeReq.stop_sequences;
|
||||
}
|
||||
return config;
|
||||
if (isThinkingEnabled) {
|
||||
config.thinkingConfig = buildThinkingConfig();
|
||||
}
|
||||
} else {
|
||||
if (isThinkingEnabled) {
|
||||
config.thinkingConfig = buildThinkingConfig();
|
||||
}
|
||||
if (claudeReq.temperature !== undefined) {
|
||||
config.temperature = claudeReq.temperature;
|
||||
}
|
||||
if (claudeReq.top_p !== undefined) {
|
||||
config.topP = claudeReq.top_p;
|
||||
}
|
||||
if (claudeReq.top_k !== undefined) {
|
||||
config.topK = claudeReq.top_k;
|
||||
}
|
||||
if (maxTokens !== undefined) {
|
||||
config.maxOutputTokens = maxTokens;
|
||||
}
|
||||
const defaultStopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:'];
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])];
|
||||
} else {
|
||||
config.stopSequences = defaultStopSequences;
|
||||
}
|
||||
}
|
||||
|
||||
if (claudeReq.thinking?.type === 'enabled') {
|
||||
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
|
||||
if (claudeReq.thinking.budget_tokens) {
|
||||
let budget = claudeReq.thinking.budget_tokens;
|
||||
const isFlash = hasWebSearch || mappedModel.includes('gemini-2.5-flash');
|
||||
if (isFlash) budget = Math.min(budget, 24576);
|
||||
thinkingConfig.thinkingBudget = budget;
|
||||
if (claudeReq.response_format) {
|
||||
if (claudeReq.response_format.type === 'json_object') {
|
||||
config.responseMimeType = 'application/json';
|
||||
} else if (claudeReq.response_format.type === 'json_schema' && claudeReq.response_format.json_schema) {
|
||||
config.responseMimeType = 'application/json';
|
||||
config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema);
|
||||
}
|
||||
config.thinkingConfig = thinkingConfig;
|
||||
}
|
||||
if (claudeReq.temperature !== undefined) {
|
||||
config.temperature = claudeReq.temperature;
|
||||
}
|
||||
if (claudeReq.top_p !== undefined) {
|
||||
config.topP = claudeReq.top_p;
|
||||
}
|
||||
if (claudeReq.top_k !== undefined) {
|
||||
config.topK = claudeReq.top_k;
|
||||
}
|
||||
if (claudeReq.max_tokens !== undefined) {
|
||||
config.maxOutputTokens = claudeReq.max_tokens;
|
||||
}
|
||||
config.stopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:'];
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -623,7 +722,7 @@ function buildGenerationConfig(
|
||||
function deepCleanUndefined(obj: unknown): void {
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach(deepCleanUndefined);
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
} else if (isPlainObject(obj)) {
|
||||
const record = obj as Record<string, unknown>;
|
||||
Object.keys(record).forEach((key) => {
|
||||
if (record[key] === undefined) delete record[key];
|
||||
@@ -23,7 +23,7 @@ class NonStreamingProcessor {
|
||||
|
||||
constructor() {}
|
||||
|
||||
public process(geminiResponse: GeminiResponse): ClaudeResponse {
|
||||
public process(geminiResponse: GeminiResponse, modelName?: string): ClaudeResponse {
|
||||
const candidate = geminiResponse.candidates?.[0];
|
||||
const parts = candidate?.content?.parts || [];
|
||||
|
||||
@@ -52,7 +52,7 @@ class NonStreamingProcessor {
|
||||
}
|
||||
|
||||
// 5. Build response
|
||||
return this.buildResponse(geminiResponse);
|
||||
return this.buildResponse(geminiResponse, modelName);
|
||||
}
|
||||
|
||||
private processPart(part: GeminiPart) {
|
||||
@@ -165,21 +165,21 @@ class NonStreamingProcessor {
|
||||
let groundingText = '';
|
||||
|
||||
if (grounding.webSearchQueries && grounding.webSearchQueries.length > 0) {
|
||||
groundingText += `\n\n---\n**🔍 已为您搜索:** ${grounding.webSearchQueries.join(', ')}`;
|
||||
groundingText += `\n\n---\n**🔍 Searched for you:** ${grounding.webSearchQueries.join(', ')}`;
|
||||
}
|
||||
|
||||
if (grounding.groundingChunks) {
|
||||
const links: string[] = [];
|
||||
grounding.groundingChunks.forEach((chunk, index) => {
|
||||
if (chunk.web) {
|
||||
const title = chunk.web.title || '网页来源';
|
||||
const title = chunk.web.title || 'Web source';
|
||||
const uri = chunk.web.uri || '#';
|
||||
links.push(`[${index + 1}] [${title}](${uri})`);
|
||||
}
|
||||
});
|
||||
|
||||
if (links.length > 0) {
|
||||
groundingText += `\n\n**🌐 来源引文:**\n` + links.join('\n');
|
||||
groundingText += `\n\n**🌐 Citations:**\n` + links.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ class NonStreamingProcessor {
|
||||
this.thinkingSignature = null;
|
||||
}
|
||||
|
||||
private buildResponse(geminiResponse: GeminiResponse): ClaudeResponse {
|
||||
private buildResponse(geminiResponse: GeminiResponse, modelName?: string): ClaudeResponse {
|
||||
const finishReason = geminiResponse.candidates?.[0]?.finishReason;
|
||||
|
||||
let stopReason = 'end_turn';
|
||||
@@ -221,6 +221,15 @@ class NonStreamingProcessor {
|
||||
stopReason = 'tool_use';
|
||||
} else if (finishReason === 'MAX_TOKENS') {
|
||||
stopReason = 'max_tokens';
|
||||
} else if (
|
||||
finishReason === 'SAFETY' ||
|
||||
finishReason === 'RECITATION' ||
|
||||
finishReason === 'OTHER' ||
|
||||
finishReason === 'IMAGE_SAFETY' ||
|
||||
finishReason === 'PROHIBITED_CONTENT' ||
|
||||
finishReason === 'SPII'
|
||||
) {
|
||||
stopReason = 'stop_sequence';
|
||||
}
|
||||
|
||||
const usage: Usage = {
|
||||
@@ -234,7 +243,7 @@ class NonStreamingProcessor {
|
||||
id: geminiResponse.responseId || `msg_${uuidv4()}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: geminiResponse.modelVersion || '',
|
||||
model: geminiResponse.modelVersion || modelName || '',
|
||||
content: this.contentBlocks,
|
||||
stop_reason: stopReason,
|
||||
usage: usage,
|
||||
@@ -245,7 +254,7 @@ class NonStreamingProcessor {
|
||||
/**
|
||||
* Public API: Transform Gemini Response to Claude Response
|
||||
*/
|
||||
export function transformResponse(geminiResponse: GeminiResponse): ClaudeResponse {
|
||||
export function transformResponse(geminiResponse: GeminiResponse, modelName?: string): ClaudeResponse {
|
||||
const processor = new NonStreamingProcessor();
|
||||
return processor.process(geminiResponse);
|
||||
return processor.process(geminiResponse, modelName);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export class StreamingState {
|
||||
return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
public emitMessageStart(rawJson: any): string {
|
||||
public emitMessageStart(rawJson: any, modelName?: string): string {
|
||||
if (this.messageStartSent) return '';
|
||||
|
||||
const usageMeta = rawJson.usageMetadata;
|
||||
@@ -69,7 +69,7 @@ export class StreamingState {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model: rawJson.modelVersion || '',
|
||||
model: rawJson.modelVersion || modelName || '',
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: usage,
|
||||
@@ -173,19 +173,19 @@ export class StreamingState {
|
||||
// Process grounding (web search) -> convert to Markdown text block
|
||||
let groundingText = '';
|
||||
if (this.webSearchQuery) {
|
||||
groundingText += `\n\n---\n**🔍 已为您搜索:** ${this.webSearchQuery}`;
|
||||
groundingText += `\n\n---\n**🔍 Searched for you:** ${this.webSearchQuery}`;
|
||||
}
|
||||
if (this.groundingChunks && this.groundingChunks.length > 0) {
|
||||
const links: string[] = [];
|
||||
this.groundingChunks.forEach((chunk, i) => {
|
||||
if (chunk.web) {
|
||||
const title = chunk.web.title || '网页来源';
|
||||
const title = chunk.web.title || 'Web source';
|
||||
const uri = chunk.web.uri || '#';
|
||||
links.push(`[${i + 1}] [${title}](${uri})`);
|
||||
}
|
||||
});
|
||||
if (links.length > 0) {
|
||||
groundingText += `\n\n**🌐 来源引文:**\n` + links.join('\n');
|
||||
groundingText += `\n\n**🌐 Citations:**\n` + links.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -32,14 +34,38 @@ export function cleanJsonSchema(value: any) {
|
||||
cleanJsonSchemaRecursive(value);
|
||||
}
|
||||
|
||||
export function normalizeObjectJsonSchema(schema: unknown): Record<string, unknown> {
|
||||
const fallbackSchema: Record<string, unknown> = { type: 'object', properties: {} };
|
||||
if (!isObjectLike(schema) || isArray(schema)) {
|
||||
return fallbackSchema;
|
||||
}
|
||||
|
||||
const normalizedSchema = JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
||||
cleanJsonSchema(normalizedSchema);
|
||||
|
||||
if (!isString(normalizedSchema.type)) {
|
||||
normalizedSchema.type = 'object';
|
||||
}
|
||||
if (
|
||||
normalizedSchema.type === 'object' &&
|
||||
(!normalizedSchema.properties ||
|
||||
!isObjectLike(normalizedSchema.properties) ||
|
||||
isArray(normalizedSchema.properties))
|
||||
) {
|
||||
normalizedSchema.properties = {};
|
||||
}
|
||||
|
||||
return normalizedSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively expand $ref
|
||||
*/
|
||||
function flattenRefs(map: any, defs: Record<string, any>) {
|
||||
if (!map || typeof map !== 'object') return;
|
||||
if (!isObjectLike(map)) return;
|
||||
|
||||
// 检查并替换 $ref
|
||||
if (typeof map['$ref'] === 'string') {
|
||||
// Check and replace $ref
|
||||
if (isString(map['$ref'])) {
|
||||
const refPath = map['$ref'];
|
||||
// Parse reference name (e.g. #/$defs/MyType -> MyType)
|
||||
const parts = refPath.split('/');
|
||||
@@ -50,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) {
|
||||
@@ -69,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);
|
||||
}
|
||||
}
|
||||
@@ -77,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);
|
||||
@@ -118,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 {
|
||||
@@ -164,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;
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
import { isEmpty, isString } from 'lodash-es';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const PUBLIC_SUPPORTED_MODELS = [
|
||||
'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro-low',
|
||||
'gemini-3-flash',
|
||||
'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-6-thinking',
|
||||
] as const;
|
||||
|
||||
const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||
// Directly supported models
|
||||
'claude-opus-4-5-thinking': 'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-6-thinking': 'claude-opus-4-6-thinking',
|
||||
'claude-sonnet-4-5': 'claude-sonnet-4-5',
|
||||
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
|
||||
'gemini-3.1-pro-low': 'gemini-3.1-pro-low',
|
||||
'gemini-3.1-pro-high': 'gemini-3.1-pro-high',
|
||||
'gemini-3-flash': 'gemini-3-flash',
|
||||
|
||||
// Alias mappings
|
||||
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-thinking',
|
||||
'claude-3-5-sonnet-20241022': 'claude-sonnet-4-5',
|
||||
'claude-3-5-sonnet-20240620': 'claude-sonnet-4-5',
|
||||
'claude-opus-4': 'claude-opus-4-5-thinking',
|
||||
'claude-opus-4-5-20251101': 'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-6': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-6-20260219': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-5': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-6-thinking',
|
||||
'claude-3-5-sonnet-20241022': 'claude-sonnet-4-6-thinking',
|
||||
'claude-3-5-sonnet-20240620': 'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4': 'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-5-thinking': 'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-5-20251101': 'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-6': 'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-6-20260201': 'claude-opus-4-6-thinking',
|
||||
'claude-haiku-4': 'claude-sonnet-4-5',
|
||||
'claude-3-haiku-20240307': 'claude-sonnet-4-5',
|
||||
'claude-haiku-4-5-20251001': 'claude-sonnet-4-5',
|
||||
'claude-haiku-4': 'claude-sonnet-4-6-thinking',
|
||||
'claude-3-haiku-20240307': 'claude-sonnet-4-6-thinking',
|
||||
'claude-haiku-4-5-20251001': 'claude-sonnet-4-6-thinking',
|
||||
|
||||
// OpenAI Protocol Mapping
|
||||
'gpt-4': 'gemini-3-flash',
|
||||
@@ -42,12 +57,17 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||
|
||||
// Gemini Protocol Mapping
|
||||
'gemini-2.5-flash-lite': 'gemini-3-flash',
|
||||
'gemini-3-pro-low': 'gemini-3-pro-preview',
|
||||
'gemini-3-pro-high': 'gemini-3-pro-preview',
|
||||
'gemini-3-pro-preview': 'gemini-3-pro-preview',
|
||||
'gemini-3.1-pro-preview': 'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro': 'gemini-3.1-pro-high',
|
||||
'gemini-3.0-pro': 'gemini-3.1-pro-high',
|
||||
'gemini-3-pro': 'gemini-3-pro-preview',
|
||||
'gemini-3-pro-preview': 'gemini-3-pro-preview',
|
||||
'gemini-3-pro-low': 'gemini-3-pro-low',
|
||||
'gemini-3-pro-high': 'gemini-3-pro-high',
|
||||
'gemini-2.5-flash': 'gemini-3-flash',
|
||||
'gemini-3-flash': 'gemini-3-flash',
|
||||
'gemini-2.5-pro': 'gemini-3.1-pro-high',
|
||||
'gemini-2.0-flash': 'gemini-3-flash',
|
||||
'gemini-2.0-flash-online': 'gemini-3-flash',
|
||||
'gemini-3-pro-image': 'gemini-3-pro-image',
|
||||
'internal-background-task': 'gemini-3-flash',
|
||||
};
|
||||
@@ -55,17 +75,50 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||
const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image';
|
||||
const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k'];
|
||||
const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9'];
|
||||
const EXTRA_DYNAMIC_MODELS = ['gemini-3-flash', 'gemini-3-pro-high', 'gemini-3-pro-low'];
|
||||
const EXTRA_DYNAMIC_MODELS = ['gemini-3-flash', 'gemini-3.1-pro-high', 'gemini-3.1-pro-low'];
|
||||
|
||||
const DYNAMIC_MODEL_FORWARDING_RULES = new Map<string, string>();
|
||||
|
||||
export const MODEL_LIST_CREATED_AT = 1770652800;
|
||||
|
||||
export const MODEL_LIST_OWNER = 'antigravity';
|
||||
export const MODEL_LIST_OWNER = 'geminiNexus';
|
||||
|
||||
const GEMINI_MODEL_ALIASES: Record<string, string> = {
|
||||
'gemini-3-pro-image-preview': 'gemini-3-pro-image',
|
||||
'gemini-3-flash-preview': 'gemini-3-flash',
|
||||
'gemini-3.1-pro-preview': 'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro': 'gemini-3.1-pro-high',
|
||||
};
|
||||
|
||||
export function getSupportedModels(): string[] {
|
||||
return Object.keys(CLAUDE_TO_GEMINI);
|
||||
return [...PUBLIC_SUPPORTED_MODELS];
|
||||
}
|
||||
|
||||
export function getAllDynamicModels(customMapping: Record<string, string> = {}): string[] {
|
||||
export function updateDynamicForwardingRules(oldModel: string, newModel: string): void {
|
||||
if (!isString(oldModel) || !isString(newModel)) {
|
||||
return;
|
||||
}
|
||||
const normalizedOld = oldModel.trim();
|
||||
const normalizedNew = newModel.trim();
|
||||
if (!normalizedOld || !normalizedNew) {
|
||||
return;
|
||||
}
|
||||
if (!DYNAMIC_MODEL_FORWARDING_RULES.has(normalizedOld)) {
|
||||
logger.info(
|
||||
`[Router] Registered dynamic forwarding rule: ${normalizedOld} -> ${normalizedNew}`,
|
||||
);
|
||||
}
|
||||
DYNAMIC_MODEL_FORWARDING_RULES.set(normalizedOld, normalizedNew);
|
||||
}
|
||||
|
||||
export function getDynamicForwardingTarget(modelId: string): string | undefined {
|
||||
return DYNAMIC_MODEL_FORWARDING_RULES.get(modelId);
|
||||
}
|
||||
|
||||
export function getAllDynamicModels(
|
||||
customMapping: Record<string, string> = {},
|
||||
dynamicModelIds?: Iterable<string>,
|
||||
): string[] {
|
||||
const modelIds = new Set<string>();
|
||||
|
||||
for (const modelId of getSupportedModels()) {
|
||||
@@ -86,10 +139,25 @@ export function getAllDynamicModels(customMapping: Record<string, string> = {}):
|
||||
modelIds.add(modelId);
|
||||
}
|
||||
|
||||
return [...modelIds].filter((id) => !/gemini-[12](\.|$|-)/.test(id)).sort();
|
||||
for (const aliasModelId of Object.keys(CLAUDE_TO_GEMINI)) {
|
||||
modelIds.add(aliasModelId);
|
||||
}
|
||||
|
||||
if (dynamicModelIds) {
|
||||
for (const dynamicModelId of dynamicModelIds) {
|
||||
if (isString(dynamicModelId) && !isEmpty(dynamicModelId.trim())) {
|
||||
modelIds.add(dynamicModelId.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...modelIds].filter((id) => !shouldHideDeprecatedModelFromList(id)).sort();
|
||||
}
|
||||
|
||||
export function mapClaudeModelToGemini(input: string): string {
|
||||
if (!isString(input) || isEmpty(input)) {
|
||||
return '';
|
||||
}
|
||||
const mappedModel = CLAUDE_TO_GEMINI[input];
|
||||
if (mappedModel) {
|
||||
return mappedModel;
|
||||
@@ -98,6 +166,11 @@ export function mapClaudeModelToGemini(input: string): string {
|
||||
return input;
|
||||
}
|
||||
|
||||
export function normalizeGeminiModelAlias(modelId: string): string {
|
||||
const normalizedModelId = modelId.trim().toLowerCase();
|
||||
return GEMINI_MODEL_ALIASES[normalizedModelId] ?? modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core Model Routing Engine
|
||||
* Priority: Custom Mapping (Exact) > Group Mapping (Family) > System Mapping (Built-in Plugin)
|
||||
@@ -108,6 +181,14 @@ export function resolveModelRoute(
|
||||
openaiMapping: Record<string, string>,
|
||||
anthropicMapping: Record<string, string>,
|
||||
): string {
|
||||
const dynamicForwarded = getDynamicForwardingTarget(originalModel);
|
||||
if (dynamicForwarded && getSupportedModels().includes(dynamicForwarded)) {
|
||||
logger.info(
|
||||
`[Router] Dynamic deprecated-model forwarding: ${originalModel} -> ${dynamicForwarded}`,
|
||||
);
|
||||
return dynamicForwarded;
|
||||
}
|
||||
|
||||
// 1. Check custom exact mapping (Highest priority)
|
||||
if (customMapping[originalModel]) {
|
||||
logger.info(
|
||||
@@ -194,3 +275,31 @@ export function resolveModelRoute(
|
||||
// 4. Fall through to system default mapping logic
|
||||
return mapClaudeModelToGemini(originalModel);
|
||||
}
|
||||
|
||||
function shouldHideDeprecatedModelFromList(modelId: string): boolean {
|
||||
const normalized = modelId.toLowerCase();
|
||||
|
||||
if (/^gemini-1(\.|$|-)/.test(normalized) || /^gemini-2(\.|$|-)/.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === 'gemini-3.1-pro' || normalized === 'gemini-3.1-pro-preview') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isLegacyGeminiPro =
|
||||
/^gemini-3(\.0)?-pro($|-)/.test(normalized) && !normalized.startsWith('gemini-3-pro-image');
|
||||
if (isLegacyGeminiPro) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('claude-sonnet-4-5') ||
|
||||
normalized.includes('claude-opus-4-5') ||
|
||||
normalized.includes('claude-haiku-4-5')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import modelSpecsJson from './model-specs';
|
||||
import { isNumber } from 'lodash-es';
|
||||
|
||||
type ModelSpec = {
|
||||
max_output_tokens?: number;
|
||||
thinking_budget?: number;
|
||||
is_thinking?: boolean;
|
||||
};
|
||||
|
||||
type SpecsConfig = {
|
||||
models: Record<string, ModelSpec>;
|
||||
aliases: Record<string, string>;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_OUTPUT_TOKENS = 65535;
|
||||
const DEFAULT_THINKING_BUDGET = 24576;
|
||||
|
||||
const SPECS = modelSpecsJson as SpecsConfig;
|
||||
|
||||
export function resolveModelAlias(modelId: string): string {
|
||||
const normalized = modelId.trim();
|
||||
return SPECS.aliases[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
export function getMaxOutputTokens(modelId: string): number {
|
||||
const resolved = resolveModelAlias(modelId);
|
||||
const fromSpec = SPECS.models[resolved]?.max_output_tokens;
|
||||
if (isNumber(fromSpec) && Number.isFinite(fromSpec) && fromSpec > 0) {
|
||||
return Math.floor(fromSpec);
|
||||
}
|
||||
return DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
}
|
||||
|
||||
export function getThinkingBudget(modelId: string): number {
|
||||
const resolved = resolveModelAlias(modelId);
|
||||
const fromSpec = SPECS.models[resolved]?.thinking_budget;
|
||||
if (isNumber(fromSpec) && Number.isFinite(fromSpec) && fromSpec >= 0) {
|
||||
return Math.floor(fromSpec);
|
||||
}
|
||||
return DEFAULT_THINKING_BUDGET;
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { isNumber } from 'lodash-es';
|
||||
import { QuotaData, LoadProjectResponse, QuotaApiResponse } from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Constants
|
||||
const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels';
|
||||
const QUOTA_API_ENDPOINTS = [
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||
] as const;
|
||||
const CLOUD_CODE_BASE_URL = 'https://cloudcode-pa.googleapis.com';
|
||||
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64'; // Keeping the same UA as source
|
||||
const USER_AGENT = 'geminiNexus/1.11.3 Darwin/arm64'; // Keeping the same UA as source
|
||||
|
||||
// Service Class
|
||||
export class QuotaService {
|
||||
@@ -36,7 +41,7 @@ export class QuotaService {
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': 'antigravity/windows/amd64',
|
||||
'User-Agent': 'geminiNexus/windows/amd64',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -83,15 +88,16 @@ export class QuotaService {
|
||||
const finalProjectId = projectId;
|
||||
|
||||
const client = this.createClient();
|
||||
const payload = { project: finalProjectId };
|
||||
const url = QUOTA_API_URL;
|
||||
const maxRetries = 3;
|
||||
const payload = finalProjectId ? { project: finalProjectId } : {};
|
||||
let lastError: Error | null = null;
|
||||
|
||||
logger.info(`Sending quota request to ${url}`);
|
||||
for (let endpointIndex = 0; endpointIndex < QUOTA_API_ENDPOINTS.length; endpointIndex++) {
|
||||
const endpoint = QUOTA_API_ENDPOINTS[endpointIndex];
|
||||
const hasNextEndpoint = endpointIndex + 1 < QUOTA_API_ENDPOINTS.length;
|
||||
logger.info(`Sending quota request to ${endpoint}`);
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await client.post<QuotaApiResponse>(url, payload, {
|
||||
const response = await client.post<QuotaApiResponse>(endpoint, payload, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': USER_AGENT,
|
||||
@@ -127,8 +133,14 @@ export class QuotaService {
|
||||
}
|
||||
}
|
||||
|
||||
return { quotaData, projectId: projectId };
|
||||
if (endpointIndex > 0) {
|
||||
logger.info(`Quota API fallback succeeded at endpoint #${endpointIndex + 1}`);
|
||||
}
|
||||
|
||||
return { quotaData, projectId };
|
||||
} catch (error: any) {
|
||||
let shouldFallback = true;
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
let text = '';
|
||||
@@ -147,23 +159,34 @@ export class QuotaService {
|
||||
isForbidden: true,
|
||||
subscriptionTier: subscriptionTier,
|
||||
},
|
||||
projectId: projectId,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn(`API Error: ${status} - ${text} (Attempt ${attempt}/${maxRetries})`);
|
||||
if (hasNextEndpoint && (status === 429 || (isNumber(status) && status >= 500))) {
|
||||
logger.warn(`Quota API ${endpoint} returned ${status}, falling back to next endpoint`);
|
||||
lastError = new Error(`HTTP ${status} - ${text}`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.warn(`API Error: ${status} - ${text}`);
|
||||
lastError = new Error(`HTTP ${status} - ${text}`);
|
||||
shouldFallback = !isNumber(status);
|
||||
} else {
|
||||
logger.warn(`Request Failed: ${error.message} (Attempt ${attempt}/${maxRetries})`);
|
||||
logger.warn(`Request Failed at ${endpoint}: ${error.message}`);
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
if (hasNextEndpoint && shouldFallback) {
|
||||
logger.warn(`Quota API request failed at ${endpoint}, falling back to next endpoint`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
} else {
|
||||
throw new Error(`Quota query failed: ${error.message}`);
|
||||
throw lastError ?? new Error(`Quota query failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unknown error in fetchQuota'); // Should not reach here
|
||||
throw lastError ?? new Error('Unknown error in fetchQuota');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"models": {
|
||||
"gemini-3-flash": {
|
||||
"max_output_tokens": 65536,
|
||||
"thinking_budget": 32768,
|
||||
"is_thinking": true
|
||||
},
|
||||
"gemini-3.1-pro-low": {
|
||||
"max_output_tokens": 65536,
|
||||
"thinking_budget": 32768,
|
||||
"is_thinking": true
|
||||
},
|
||||
"gemini-3.1-pro-high": {
|
||||
"max_output_tokens": 65536,
|
||||
"thinking_budget": 49152,
|
||||
"is_thinking": true
|
||||
},
|
||||
"gemini-3-pro-image": {
|
||||
"max_output_tokens": 65536,
|
||||
"thinking_budget": 24576,
|
||||
"is_thinking": true
|
||||
},
|
||||
"claude-sonnet-4-6-thinking": {
|
||||
"max_output_tokens": 64000,
|
||||
"thinking_budget": 32768,
|
||||
"is_thinking": true
|
||||
},
|
||||
"claude-opus-4-6-thinking": {
|
||||
"max_output_tokens": 64000,
|
||||
"thinking_budget": 32768,
|
||||
"is_thinking": true
|
||||
},
|
||||
"gpt-oss-120b-medium": {
|
||||
"max_output_tokens": 32768,
|
||||
"thinking_budget": 0,
|
||||
"is_thinking": false
|
||||
}
|
||||
},
|
||||
"aliases": {
|
||||
"gpt-4o": "gemini-3-flash",
|
||||
"gpt-3.5-turbo": "gemini-3-flash",
|
||||
"gemini-2.0-flash": "gemini-3-flash",
|
||||
"gemini-2.5-flash": "gemini-3-flash",
|
||||
"gemini-3.1-pro": "gemini-3.1-pro-high",
|
||||
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
|
||||
"claude-sonnet-4-6": "claude-sonnet-4-6-thinking",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-6-thinking",
|
||||
"claude-3-5-sonnet": "claude-sonnet-4-6-thinking",
|
||||
"claude-3-7-sonnet": "claude-sonnet-4-6-thinking",
|
||||
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const modelSpecs = {
|
||||
models: {
|
||||
'gemini-3-flash': {
|
||||
max_output_tokens: 65536,
|
||||
thinking_budget: 32768,
|
||||
is_thinking: true,
|
||||
},
|
||||
'gemini-3.1-pro-low': {
|
||||
max_output_tokens: 65536,
|
||||
thinking_budget: 32768,
|
||||
is_thinking: true,
|
||||
},
|
||||
'gemini-3.1-pro-high': {
|
||||
max_output_tokens: 65536,
|
||||
thinking_budget: 49152,
|
||||
is_thinking: true,
|
||||
},
|
||||
'gemini-3-pro-image': {
|
||||
max_output_tokens: 65536,
|
||||
thinking_budget: 24576,
|
||||
is_thinking: true,
|
||||
},
|
||||
'claude-sonnet-4-6-thinking': {
|
||||
max_output_tokens: 64000,
|
||||
thinking_budget: 32768,
|
||||
is_thinking: true,
|
||||
},
|
||||
'claude-opus-4-6-thinking': {
|
||||
max_output_tokens: 64000,
|
||||
thinking_budget: 32768,
|
||||
is_thinking: true,
|
||||
},
|
||||
'gpt-oss-120b-medium': {
|
||||
max_output_tokens: 32768,
|
||||
thinking_budget: 0,
|
||||
is_thinking: false,
|
||||
},
|
||||
},
|
||||
aliases: {
|
||||
'gpt-4o': 'gemini-3-flash',
|
||||
'gpt-3.5-turbo': 'gemini-3-flash',
|
||||
'gemini-2.0-flash': 'gemini-3-flash',
|
||||
'gemini-2.5-flash': 'gemini-3-flash',
|
||||
'gemini-3.1-pro': 'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro-preview': 'gemini-3.1-pro-high',
|
||||
'claude-sonnet-4-6': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-5': 'claude-sonnet-4-6-thinking',
|
||||
'claude-3-5-sonnet': 'claude-sonnet-4-6-thinking',
|
||||
'claude-3-7-sonnet': 'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-5-thinking': 'claude-opus-4-6-thinking',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default modelSpecs;
|
||||
@@ -53,17 +53,21 @@ export interface ClaudeRequest {
|
||||
tools?: Tool[];
|
||||
stream?: boolean;
|
||||
max_tokens?: number;
|
||||
max_completion_tokens?: number;
|
||||
stop_sequences?: string[];
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
thinking?: ThinkingConfig;
|
||||
metadata?: Metadata;
|
||||
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
||||
response_format?: { type: 'text' | 'json_object' | 'json_schema'; json_schema?: any };
|
||||
}
|
||||
|
||||
export interface ThinkingConfig {
|
||||
type: 'enabled' | string;
|
||||
budget_tokens?: number;
|
||||
effort?: 'low' | 'medium' | 'high' | 'max' | string;
|
||||
}
|
||||
|
||||
export type SystemPrompt = string | SystemBlock[];
|
||||
@@ -76,6 +80,7 @@ export interface SystemBlock {
|
||||
export interface Message {
|
||||
role: string;
|
||||
content: string | ContentBlock[];
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type ContentBlock =
|
||||
@@ -184,6 +189,8 @@ export interface GenerationConfig {
|
||||
responseModalities?: string[];
|
||||
/** Image generation configuration */
|
||||
imageConfig?: ImageConfig;
|
||||
/** Response schema for structured output */
|
||||
responseSchema?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,6 +201,8 @@ export interface ThinkingGeminiConfig {
|
||||
includeThoughts: boolean;
|
||||
/** Thinking token budget */
|
||||
thinkingBudget?: number;
|
||||
/** Thinking level for Claude-native adaptive modes */
|
||||
thinkingLevel?: 'low' | 'medium' | 'high' | string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,11 +315,22 @@ export interface QuotaData {
|
||||
models: Record<string, ModelQuotaInfo>;
|
||||
isForbidden: boolean;
|
||||
subscriptionTier?: string;
|
||||
model_forwarding_rules?: Record<string, string>;
|
||||
is_forbidden?: boolean;
|
||||
subscription_tier?: string;
|
||||
}
|
||||
|
||||
export interface ModelQuotaInfo {
|
||||
percentage: number;
|
||||
resetTime: string;
|
||||
display_name?: string;
|
||||
supports_images?: boolean;
|
||||
supports_thinking?: boolean;
|
||||
thinking_budget?: number;
|
||||
recommended?: boolean;
|
||||
max_tokens?: number;
|
||||
max_output_tokens?: number;
|
||||
supported_mime_types?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface LoadProjectResponse {
|
||||
@@ -327,7 +347,21 @@ export interface Tier {
|
||||
}
|
||||
|
||||
export interface QuotaApiResponse {
|
||||
models: Record<string, { quotaInfo?: { remainingFraction?: number; resetTime?: string } }>;
|
||||
models: Record<
|
||||
string,
|
||||
{
|
||||
quotaInfo?: { remainingFraction?: number; resetTime?: string };
|
||||
displayName?: string;
|
||||
supportsImages?: boolean;
|
||||
supportsThinking?: boolean;
|
||||
thinkingBudget?: number;
|
||||
recommended?: boolean;
|
||||
maxTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
supportedMimeTypes?: Record<string, boolean>;
|
||||
}
|
||||
>;
|
||||
deprecatedModelIds?: Record<string, { newModelId?: string }>;
|
||||
}
|
||||
|
||||
// --- Response Models ---
|
||||
@@ -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,16 +13,23 @@ 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',
|
||||
appName: 'Gemini Nexus',
|
||||
common: {
|
||||
loading: 'Loading...',
|
||||
error: 'Error',
|
||||
unknown: 'Unknown',
|
||||
notAvailable: 'N/A',
|
||||
openMenu: 'Open menu',
|
||||
},
|
||||
status: {
|
||||
checking: 'Checking status...',
|
||||
running: 'Antigravity is running in background',
|
||||
stopped: 'Antigravity service stopped',
|
||||
running: 'Gemini Nexus is running in background',
|
||||
stopped: 'Gemini Nexus service stopped',
|
||||
},
|
||||
action: {
|
||||
stop: 'Stop',
|
||||
@@ -59,10 +67,10 @@ i18n
|
||||
},
|
||||
home: {
|
||||
title: 'Accounts',
|
||||
description: 'Manage your Antigravity Google Gemini accounts.',
|
||||
description: 'Manage your Gemini Nexus Google Gemini accounts.',
|
||||
noBackups: {
|
||||
title: 'No backups found',
|
||||
description: 'Create a backup of your current Antigravity account to get started.',
|
||||
description: 'Create a backup of your current Gemini Nexus account to get started.',
|
||||
action: 'Backup Current Account',
|
||||
},
|
||||
},
|
||||
@@ -74,7 +82,7 @@ i18n
|
||||
models: 'Models',
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
description: 'Customize how Antigravity Manager looks on your device.',
|
||||
description: 'Customize how Gemini Nexus looks on your device.',
|
||||
},
|
||||
darkMode: 'Dark Mode',
|
||||
darkModeDescription: 'Enable dark mode for better viewing at night.',
|
||||
@@ -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.',
|
||||
@@ -118,21 +136,36 @@ i18n
|
||||
restart_note:
|
||||
'Changes to error reporting will take effect after restarting the application.',
|
||||
},
|
||||
notifications: {
|
||||
title: 'Notifications',
|
||||
description: 'Configure desktop alerts for account events.',
|
||||
quotaAlert: 'Low Quota Alerts',
|
||||
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',
|
||||
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
||||
enable: 'Enable Upstream Proxy',
|
||||
url: 'Proxy URL',
|
||||
timeout: 'Request Timeout (Seconds)',
|
||||
accountProxies: {
|
||||
title: 'Account Proxies',
|
||||
description:
|
||||
'Configure specific proxies for individual accounts. These will override the global upstream proxy for that specific account.',
|
||||
},
|
||||
},
|
||||
modelMapping: {
|
||||
title: 'Model Mapping',
|
||||
description:
|
||||
'Map Claude Code models to Antigravity models. Optimize cost and speed by routing requests intelligently.',
|
||||
'Map Claude Code models to Gemini Nexus models. Optimize cost and speed by routing requests intelligently.',
|
||||
claudeKeyword: 'Claude Model (Keyword)',
|
||||
targetGemini: 'Target Gemini Model',
|
||||
addPlaceholderKey: 'e.g. op-3',
|
||||
addPlaceholderValue: 'e.g. gemini-2.5-flash',
|
||||
addPlaceholderValue: 'e.g. gemini-3-flash',
|
||||
noMappings: 'No custom mappings defined.',
|
||||
mapsTo: 'Maps to',
|
||||
default: 'Default',
|
||||
@@ -258,10 +291,13 @@ i18n
|
||||
twoCol: '2 Columns',
|
||||
threeCol: '3 Columns',
|
||||
list: 'List',
|
||||
compact: 'Compact',
|
||||
},
|
||||
authDialog: {
|
||||
title: 'Add Google Account',
|
||||
description: 'To add an account, you need to authorize the application.',
|
||||
oauthClient: 'OAuth Client',
|
||||
oauthClientPlaceholder: 'Select OAuth client',
|
||||
openLogin: 'Open Login Page',
|
||||
authCode: 'Authorization Code',
|
||||
placeholder: 'Paste the code starting with 4/...',
|
||||
@@ -273,6 +309,9 @@ i18n
|
||||
active: 'Active',
|
||||
use: 'Use',
|
||||
rateLimited: 'Rate Limited',
|
||||
validationRiskControlled: 'Risk / Rate Limited',
|
||||
validationOAuthReauthRequired: 'OAuth Re-auth Required',
|
||||
validationRequired: 'Verification Required',
|
||||
left: 'left',
|
||||
used: 'Used',
|
||||
unknown: 'Unknown User',
|
||||
@@ -289,6 +328,14 @@ i18n
|
||||
gemini3Ready: 'Gemini 3 Ready',
|
||||
groupGoogleGemini: 'Google Gemini',
|
||||
groupAnthropicClaude: 'Anthropic Claude',
|
||||
proxy: 'Proxy',
|
||||
proxyPlaceholder: 'e.g. http://127.0.0.1:7890',
|
||||
proxySaved: 'Proxy saved',
|
||||
noProxy: 'No proxy',
|
||||
aiCredits: 'AI Credits',
|
||||
aiCreditsValue: '{{amount}} credits',
|
||||
creditsExpiry: 'expires {{date}}',
|
||||
modelVisibility: 'Model Visibility',
|
||||
},
|
||||
identity: {
|
||||
title: 'Identity Profile',
|
||||
@@ -339,7 +386,7 @@ i18n
|
||||
pollFailed: 'Failed to poll quota for all accounts',
|
||||
switched: {
|
||||
title: 'Account switched!',
|
||||
description: 'Restarting Antigravity...',
|
||||
description: 'Restarting Gemini Nexus...',
|
||||
},
|
||||
switchFailed: 'Failed to switch account',
|
||||
deleted: 'Account deleted',
|
||||
@@ -348,6 +395,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}}',
|
||||
@@ -357,12 +418,42 @@ i18n
|
||||
clear: 'Clear Selection',
|
||||
confirmDelete: 'Are you sure you want to delete {{count}} accounts?',
|
||||
},
|
||||
sort: {
|
||||
recentlyUsed: 'Recently Used',
|
||||
quotaOverall: 'Overall Quota',
|
||||
quotaClaude: 'Claude Quota',
|
||||
quotaPro3: 'Pro3 Quota',
|
||||
quotaFlash: 'Flash Quota',
|
||||
},
|
||||
exportImport: {
|
||||
export: 'Export',
|
||||
import: 'Import',
|
||||
exportTitle: 'Export Accounts',
|
||||
exportDesc: 'Choose whether to include authentication tokens in the export file.',
|
||||
includeTokens: 'Include tokens (less secure)',
|
||||
stripTokens: 'Strip tokens (safer for sharing)',
|
||||
exportSuccess: 'Accounts exported successfully',
|
||||
importTitle: 'Import Accounts',
|
||||
importDesc: 'Select a previously exported JSON file.',
|
||||
importStrategy: 'Import Strategy',
|
||||
strategyMerge: 'Merge - Update existing, add new',
|
||||
strategyOverwrite: 'Overwrite - Replace all existing data',
|
||||
strategySkip: 'Skip - Only add new accounts',
|
||||
importSuccess: 'Imported {{imported}}, updated {{updated}}, skipped {{skipped}}',
|
||||
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: {
|
||||
title: 'API Proxy',
|
||||
description: 'Manage the local API proxy service.',
|
||||
save: 'Save Settings',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied!',
|
||||
regenerate: 'Regenerate',
|
||||
regenerateConfirm: {
|
||||
title: 'Regenerate API Key?',
|
||||
@@ -386,6 +477,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',
|
||||
@@ -403,13 +495,23 @@ i18n
|
||||
},
|
||||
},
|
||||
},
|
||||
vi: {
|
||||
translation: vi,
|
||||
},
|
||||
'zh-CN': {
|
||||
translation: {
|
||||
appName: 'Antigravity 管理器',
|
||||
appName: 'Gemini Nexus 管理器',
|
||||
common: {
|
||||
loading: '加载中...',
|
||||
error: '错误',
|
||||
unknown: '未知',
|
||||
notAvailable: '不适用',
|
||||
openMenu: '打开菜单',
|
||||
},
|
||||
status: {
|
||||
checking: '正在检查状态...',
|
||||
running: 'Antigravity 正在后台运行',
|
||||
stopped: 'Antigravity 服务已停止',
|
||||
running: 'Gemini Nexus 正在后台运行',
|
||||
stopped: 'Gemini Nexus 服务已停止',
|
||||
},
|
||||
action: {
|
||||
stop: '停止',
|
||||
@@ -447,10 +549,10 @@ i18n
|
||||
},
|
||||
home: {
|
||||
title: '账号列表',
|
||||
description: '管理您的 Antigravity Google Gemini 账号。',
|
||||
description: '管理您的 Gemini Nexus Google Gemini 账号。',
|
||||
noBackups: {
|
||||
title: '未找到备份',
|
||||
description: '备份您当前的 Antigravity 账号以开始使用。',
|
||||
description: '备份您当前的 Gemini Nexus 账号以开始使用。',
|
||||
action: '备份当前账号',
|
||||
},
|
||||
},
|
||||
@@ -462,7 +564,7 @@ i18n
|
||||
models: '模型',
|
||||
appearance: {
|
||||
title: '外观',
|
||||
description: '自定义 Antigravity 管理器在您设备上的显示方式。',
|
||||
description: '自定义 Gemini Nexus 管理器在您设备上的显示方式。',
|
||||
},
|
||||
darkMode: '深色模式',
|
||||
darkModeDescription: '启用深色模式以获得更好的夜间观看体验。',
|
||||
@@ -472,6 +574,7 @@ i18n
|
||||
english: '英语',
|
||||
chinese: '中文 (简体)',
|
||||
russian: '俄语',
|
||||
vietnamese: '越南语',
|
||||
},
|
||||
about: {
|
||||
title: '关于',
|
||||
@@ -481,6 +584,15 @@ i18n
|
||||
platform: '平台',
|
||||
license: '许可证',
|
||||
openLogDir: '打开',
|
||||
toast: {
|
||||
saved: {
|
||||
title: '设置已保存',
|
||||
description: '配置已更新。',
|
||||
},
|
||||
saveFailed: {
|
||||
title: '保存设置失败',
|
||||
},
|
||||
},
|
||||
account: {
|
||||
title: '账号设置',
|
||||
description: '配置自动刷新和同步账号。',
|
||||
@@ -504,21 +616,35 @@ i18n
|
||||
error_reporting_desc: '发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
||||
restart_note: '错误上报设置会在重启应用后生效。',
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
description: '配置账号事件的桌面提醒。',
|
||||
quotaAlert: '低配额提醒',
|
||||
quotaAlertDesc: '当模型配额低于设定阈值时收到提醒',
|
||||
quotaThreshold: '提醒阈值',
|
||||
quotaThresholdDesc: '触发提醒的百分比阈值',
|
||||
saveFailed: '保存通知设置失败',
|
||||
thresholdSaveFailed: '保存阈值设置失败',
|
||||
},
|
||||
proxy: {
|
||||
title: '上游代理',
|
||||
description: '为发往 Google/Gemini API 的请求配置代理。',
|
||||
enable: '启用上游代理',
|
||||
url: '代理地址',
|
||||
timeout: '请求超时 (秒)',
|
||||
accountProxies: {
|
||||
title: '独立账号代理',
|
||||
description: '为各个独立账号配置特定的代理。这些配置将覆盖该账号的全局上游代理。',
|
||||
},
|
||||
},
|
||||
modelMapping: {
|
||||
title: '模型映射',
|
||||
description:
|
||||
'将 Claude Code 模型映射到 Antigravity 模型,智能路由请求以优化成本和速度。',
|
||||
'将 Claude Code 模型映射到 Gemini Nexus 模型,智能路由请求以优化成本和速度。',
|
||||
claudeKeyword: 'Claude 模型 (关键词)',
|
||||
targetGemini: '目标 Gemini 模型',
|
||||
addPlaceholderKey: '例如 op-3',
|
||||
addPlaceholderValue: '例如 gemini-2.5-flash',
|
||||
addPlaceholderValue: '例如 gemini-3-flash',
|
||||
noMappings: '暂无自定义映射。',
|
||||
mapsTo: '映射到',
|
||||
default: '默认',
|
||||
@@ -642,10 +768,13 @@ i18n
|
||||
twoCol: '2 列',
|
||||
threeCol: '3 列',
|
||||
list: '列表',
|
||||
compact: '紧凑',
|
||||
},
|
||||
authDialog: {
|
||||
title: '添加 Google 账号',
|
||||
description: '添加账号需要进行应用授权。',
|
||||
oauthClient: 'OAuth 客户端',
|
||||
oauthClientPlaceholder: '请选择 OAuth 客户端',
|
||||
openLogin: '打开登录页面',
|
||||
authCode: '授权码',
|
||||
placeholder: '粘贴以 4/ 开头的代码...',
|
||||
@@ -656,6 +785,9 @@ i18n
|
||||
active: '活跃',
|
||||
use: '使用',
|
||||
rateLimited: '受限',
|
||||
validationRiskControlled: '风控 / 限流',
|
||||
validationOAuthReauthRequired: '需要重新 OAuth 授权',
|
||||
validationRequired: '需要校验',
|
||||
left: '剩余',
|
||||
used: '已用',
|
||||
unknown: '未知用户',
|
||||
@@ -672,6 +804,14 @@ i18n
|
||||
gemini3Ready: 'Gemini 3 就绪',
|
||||
groupGoogleGemini: 'Google Gemini',
|
||||
groupAnthropicClaude: 'Anthropic Claude',
|
||||
proxy: '代理',
|
||||
proxyPlaceholder: '例如 http://127.0.0.1:7890',
|
||||
proxySaved: '代理已保存',
|
||||
noProxy: '无代理',
|
||||
aiCredits: 'AI 积分',
|
||||
aiCreditsValue: '{{amount}} 积分',
|
||||
creditsExpiry: '{{date}} 过期',
|
||||
modelVisibility: '模型可见性',
|
||||
},
|
||||
identity: {
|
||||
title: '身份配置',
|
||||
@@ -722,7 +862,7 @@ i18n
|
||||
pollFailed: '轮询所有账号配额失败',
|
||||
switched: {
|
||||
title: '账号已切换!',
|
||||
description: '正在重启 Antigravity...',
|
||||
description: '正在重启 Gemini Nexus...',
|
||||
},
|
||||
switchFailed: '切换账号失败',
|
||||
deleted: '账号已删除',
|
||||
@@ -731,6 +871,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}} 项',
|
||||
@@ -740,12 +894,42 @@ i18n
|
||||
clear: '取消选择',
|
||||
confirmDelete: '确定要删除选中的 {{count}} 个账号吗?',
|
||||
},
|
||||
sort: {
|
||||
recentlyUsed: '最近使用',
|
||||
quotaOverall: '总体配额',
|
||||
quotaClaude: 'Claude 配额',
|
||||
quotaPro3: 'Pro3 配额',
|
||||
quotaFlash: 'Flash 配额',
|
||||
},
|
||||
exportImport: {
|
||||
export: '导出',
|
||||
import: '导入',
|
||||
exportTitle: '导出账号',
|
||||
exportDesc: '选择是否在导出文件中包含认证令牌。',
|
||||
includeTokens: '包含令牌(安全性较低)',
|
||||
stripTokens: '移除令牌(适合分享)',
|
||||
exportSuccess: '账号导出成功',
|
||||
importTitle: '导入账号',
|
||||
importDesc: '选择之前导出的 JSON 文件。',
|
||||
importStrategy: '导入策略',
|
||||
strategyMerge: '合并 - 更新已有,添加新账号',
|
||||
strategyOverwrite: '覆盖 - 替换所有已有数据',
|
||||
strategySkip: '跳过 - 仅添加新账号',
|
||||
importSuccess: '导入 {{imported}},更新 {{updated}},跳过 {{skipped}}',
|
||||
importErrors: '导入完成,但有 {{count}} 个错误',
|
||||
selectFile: '选择文件',
|
||||
importing: '导入中...',
|
||||
fileTooLarge: '文件大小超过 5MB 限制',
|
||||
invalidJson: 'JSON 文件格式无效',
|
||||
readFileFailed: '读取文件失败',
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
title: 'API 反代',
|
||||
description: '管理本地 API 代理服务。',
|
||||
save: '保存设置',
|
||||
copy: '复制',
|
||||
copied: '已复制!',
|
||||
regenerate: '重新生成',
|
||||
regenerateConfirm: {
|
||||
title: '重新生成 API 密钥?',
|
||||
@@ -768,6 +952,7 @@ i18n
|
||||
auto_start: '随应用启动',
|
||||
auto_start_desc: '应用启动时自动启动代理服务',
|
||||
local_access: '局域网访问:',
|
||||
select_ip: '选择 IP',
|
||||
no_token_warning: '⚠️ 未设置 API 密钥。服务对公网开放!',
|
||||
show_key: '显示密钥',
|
||||
hide_key: '隐藏密钥',
|
||||
@@ -787,11 +972,18 @@ i18n
|
||||
},
|
||||
ru: {
|
||||
translation: {
|
||||
appName: 'Antigravity Manager',
|
||||
appName: 'Gemini Nexus',
|
||||
common: {
|
||||
loading: 'Загрузка...',
|
||||
error: 'Ошибка',
|
||||
unknown: 'Неизвестно',
|
||||
notAvailable: 'Н/Д',
|
||||
openMenu: 'Открыть меню',
|
||||
},
|
||||
status: {
|
||||
checking: 'Проверка статуса...',
|
||||
running: 'Antigravity работает в фоне',
|
||||
stopped: 'Служба Antigravity остановлена',
|
||||
running: 'Gemini Nexus работает в фоне',
|
||||
stopped: 'Служба Gemini Nexus остановлена',
|
||||
},
|
||||
action: {
|
||||
stop: 'Стоп',
|
||||
@@ -829,11 +1021,11 @@ i18n
|
||||
},
|
||||
home: {
|
||||
title: 'Аккаунты',
|
||||
description: 'Управление вашими аккаунтами Antigravity Google Gemini.',
|
||||
description: 'Управление вашими аккаунтами Gemini Nexus Google Gemini.',
|
||||
noBackups: {
|
||||
title: 'Бэкапы не найдены',
|
||||
description:
|
||||
'Создайте резервную копию вашего текущего аккаунта Antigravity, чтобы начать.',
|
||||
'Создайте резервную копию вашего текущего аккаунта Gemini Nexus, чтобы начать.',
|
||||
action: 'Создать бэкап текущего аккаунта',
|
||||
},
|
||||
},
|
||||
@@ -845,7 +1037,7 @@ i18n
|
||||
models: 'Модели',
|
||||
appearance: {
|
||||
title: 'Внешний вид',
|
||||
description: 'Настройте внешний вид Antigravity Manager.',
|
||||
description: 'Настройте внешний вид Gemini Nexus.',
|
||||
},
|
||||
darkMode: 'Темная тема',
|
||||
darkModeDescription: 'Включить темную тему для комфортной работы ночью.',
|
||||
@@ -855,6 +1047,7 @@ i18n
|
||||
english: 'English',
|
||||
chinese: '中文 (简体)',
|
||||
russian: 'Русский',
|
||||
vietnamese: 'Вьетнамский',
|
||||
},
|
||||
about: {
|
||||
title: 'О программе',
|
||||
@@ -864,6 +1057,15 @@ i18n
|
||||
platform: 'Платформа',
|
||||
license: 'Лицензия',
|
||||
openLogDir: 'Открыть',
|
||||
toast: {
|
||||
saved: {
|
||||
title: 'Настройки сохранены',
|
||||
description: 'Конфигурация обновлена.',
|
||||
},
|
||||
saveFailed: {
|
||||
title: 'Ошибка сохранения настроек',
|
||||
},
|
||||
},
|
||||
account: {
|
||||
title: 'Настройки аккаунта',
|
||||
description: 'Настройка автоматического обновления и синхронизации.',
|
||||
@@ -889,21 +1091,37 @@ i18n
|
||||
restart_note:
|
||||
'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
|
||||
},
|
||||
notifications: {
|
||||
title: 'Уведомления',
|
||||
description: 'Настройте уведомления о событиях аккаунтов.',
|
||||
quotaAlert: 'Уведомления о низкой квоте',
|
||||
quotaAlertDesc:
|
||||
'Получайте уведомления, когда квота модели падает ниже установленного порога',
|
||||
quotaThreshold: 'Порог уведомления',
|
||||
quotaThresholdDesc: 'Процент, ниже которого срабатывает уведомление',
|
||||
saveFailed: 'Не удалось сохранить настройки уведомлений',
|
||||
thresholdSaveFailed: 'Не удалось сохранить порог уведомления',
|
||||
},
|
||||
proxy: {
|
||||
title: 'Верхнеуровневый прокси',
|
||||
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
|
||||
enable: 'Включить прокси',
|
||||
url: 'URL прокси',
|
||||
timeout: 'Тайм-аут запроса (сек)',
|
||||
accountProxies: {
|
||||
title: 'Прокси аккаунтов',
|
||||
description:
|
||||
'Настройка индивидуальных прокси для каждого аккаунта. Эти настройки переопределят глобальный прокси для данного аккаунта.',
|
||||
},
|
||||
},
|
||||
modelMapping: {
|
||||
title: 'Маппинг моделей',
|
||||
description:
|
||||
'Сопоставьте модели Claude Code с моделями Antigravity. Оптимизируйте затраты и скорость, разумно маршрутизируя запросы.',
|
||||
'Сопоставьте модели Claude Code с моделями Gemini Nexus. Оптимизируйте затраты и скорость, разумно маршрутизируя запросы.',
|
||||
claudeKeyword: 'Модель Claude (Ключевое слово)',
|
||||
targetGemini: 'Целевая модель Gemini',
|
||||
addPlaceholderKey: 'например, op-3',
|
||||
addPlaceholderValue: 'например, gemini-2.5-flash',
|
||||
addPlaceholderValue: 'например, gemini-3-flash',
|
||||
noMappings: 'Нет пользовательских сопоставлений.',
|
||||
mapsTo: 'Мапится в',
|
||||
default: 'По умолчанию',
|
||||
@@ -1028,10 +1246,13 @@ i18n
|
||||
twoCol: '2 колонки',
|
||||
threeCol: '3 колонки',
|
||||
list: 'Список',
|
||||
compact: 'Компактный',
|
||||
},
|
||||
authDialog: {
|
||||
title: 'Добавить Google Аккаунт',
|
||||
description: 'Для добавления аккаунта необходимо авторизовать приложение.',
|
||||
oauthClient: 'OAuth клиент',
|
||||
oauthClientPlaceholder: 'Выберите OAuth клиент',
|
||||
openLogin: 'Открыть страницу входа',
|
||||
authCode: 'Код авторизации',
|
||||
placeholder: 'Вставьте код, начинающийся с 4/...',
|
||||
@@ -1043,6 +1264,9 @@ i18n
|
||||
active: 'Активен',
|
||||
use: 'Использовать',
|
||||
rateLimited: 'Лимит исчерпан',
|
||||
validationRiskControlled: 'Риск / Ограничение',
|
||||
validationOAuthReauthRequired: 'Требуется повторный OAuth',
|
||||
validationRequired: 'Требуется проверка',
|
||||
left: 'ост.',
|
||||
used: 'Исп.',
|
||||
unknown: 'Неизвестный',
|
||||
@@ -1059,6 +1283,14 @@ i18n
|
||||
gemini3Ready: 'Gemini 3 готов',
|
||||
groupGoogleGemini: 'Google Gemini',
|
||||
groupAnthropicClaude: 'Anthropic Claude',
|
||||
proxy: 'Прокси',
|
||||
proxyPlaceholder: 'напр. http://127.0.0.1:7890',
|
||||
proxySaved: 'Прокси сохранён',
|
||||
noProxy: 'Без прокси',
|
||||
aiCredits: 'AI Кредиты',
|
||||
aiCreditsValue: '{{amount}} кредитов',
|
||||
creditsExpiry: 'истекает {{date}}',
|
||||
modelVisibility: 'Видимость моделей',
|
||||
},
|
||||
identity: {
|
||||
title: 'Профиль идентичности',
|
||||
@@ -1109,7 +1341,7 @@ i18n
|
||||
pollFailed: 'Не удалось опросить квоты всех аккаунтов',
|
||||
switched: {
|
||||
title: 'Аккаунт переключен!',
|
||||
description: 'Перезапуск Antigravity...',
|
||||
description: 'Перезапуск Gemini Nexus...',
|
||||
},
|
||||
switchFailed: 'Не удалось переключить аккаунт',
|
||||
deleted: 'Аккаунт удален',
|
||||
@@ -1118,6 +1350,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}}',
|
||||
@@ -1127,12 +1373,43 @@ i18n
|
||||
clear: 'Снять выделение',
|
||||
confirmDelete: 'Вы уверены, что хотите удалить {{count}} аккаунтов?',
|
||||
},
|
||||
sort: {
|
||||
recentlyUsed: 'Недавние',
|
||||
quotaOverall: 'Общая квота',
|
||||
quotaClaude: 'Квота Claude',
|
||||
quotaPro3: 'Квота Pro3',
|
||||
quotaFlash: 'Квота Flash',
|
||||
},
|
||||
exportImport: {
|
||||
export: 'Экспорт',
|
||||
import: 'Импорт',
|
||||
exportTitle: 'Экспорт аккаунтов',
|
||||
exportDesc: 'Выберите, включать ли токены аутентификации в файл экспорта.',
|
||||
includeTokens: 'Включить токены (менее безопасно)',
|
||||
stripTokens: 'Удалить токены (безопаснее для обмена)',
|
||||
exportSuccess: 'Аккаунты успешно экспортированы',
|
||||
importTitle: 'Импорт аккаунтов',
|
||||
importDesc: 'Выберите ранее экспортированный JSON файл.',
|
||||
importStrategy: 'Стратегия импорта',
|
||||
strategyMerge: 'Объединить - Обновить существующие, добавить новые',
|
||||
strategyOverwrite: 'Перезаписать - Заменить все существующие данные',
|
||||
strategySkip: 'Пропустить - Добавить только новые аккаунты',
|
||||
importSuccess:
|
||||
'Импортировано {{imported}}, обновлено {{updated}}, пропущено {{skipped}}',
|
||||
importErrors: 'Импорт завершён с {{count}} ошибкой(ами)',
|
||||
selectFile: 'Выбрать файл',
|
||||
importing: 'Импорт...',
|
||||
fileTooLarge: 'Размер файла превышает лимит в 5 МБ',
|
||||
invalidJson: 'Некорректный формат JSON-файла',
|
||||
readFileFailed: 'Не удалось прочитать файл',
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
title: 'API Прокси',
|
||||
description: 'Управление локальным прокси-сервером API.',
|
||||
save: 'Сохранить',
|
||||
copy: 'Копировать',
|
||||
copied: 'Скопировано!',
|
||||
regenerate: 'Перегенерировать',
|
||||
regenerateConfirm: {
|
||||
title: 'Перегенерировать API ключ?',
|
||||
@@ -1156,6 +1433,7 @@ i18n
|
||||
auto_start: 'Автозапуск',
|
||||
auto_start_desc: 'Запускать прокси при старте приложения',
|
||||
local_access: 'Доступ из локальной сети:',
|
||||
select_ip: 'Выбрать IP',
|
||||
no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!',
|
||||
show_key: 'Показать',
|
||||
hide_key: 'Скрыть',
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
const vi = {
|
||||
appName: 'Gemini Nexus',
|
||||
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: 'Gemini Nexus đang chạy nền',
|
||||
stopped: 'Dịch vụ Gemini Nexus đã 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 Gemini Nexus 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 Gemini Nexus 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 Gemini Nexus 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)',
|
||||
accountProxies: {
|
||||
title: 'Proxy tài khoản',
|
||||
description:
|
||||
'Cấu hình proxy riêng cho từng tài khoản. Những cấu hình này sẽ ghi đè proxy trung gian chung cho tài khoản đó.',
|
||||
},
|
||||
},
|
||||
modelMapping: {
|
||||
title: 'Ánh xạ mô hình',
|
||||
description:
|
||||
'Ánh xạ các model Claude Code sang model Gemini Nexus để 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 Gemini Nexus...',
|
||||
},
|
||||
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;
|
||||
@@ -4,6 +4,7 @@ import type { MessageBoxOptions } from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import squirrelStartup from 'electron-squirrel-startup';
|
||||
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
|
||||
import { ipcMain } from 'electron/main';
|
||||
import { ipcContext } from '@/ipc/context';
|
||||
@@ -51,12 +52,53 @@ ipcMain.on(IPC_CHANNELS.CHANGE_LANGUAGE, (event, lang) => {
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
app.commandLine.appendSwitch('disable-gpu-compositing');
|
||||
logger.info('Applied Linux GPU-safe Chromium switches for Gemini Nexus startup');
|
||||
}
|
||||
|
||||
if (squirrelStartup) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
||||
const debugHttpProxy = process.env.http_proxy?.trim() || process.env.HTTP_PROXY?.trim();
|
||||
const debugHttpsProxy = process.env.https_proxy?.trim() || process.env.HTTPS_PROXY?.trim();
|
||||
const debugNoProxy = process.env.no_proxy?.trim() || process.env.NO_PROXY?.trim();
|
||||
const debugProxyServer =
|
||||
process.env.ELECTRON_PROXY_SERVER?.trim() || debugHttpsProxy || debugHttpProxy;
|
||||
const debugProxyBypassList =
|
||||
process.env.ELECTRON_PROXY_BYPASS_LIST?.trim() || '<local>;localhost;127.0.0.1;::1';
|
||||
|
||||
function configureDebugProxy() {
|
||||
if (debugHttpProxy || debugHttpsProxy) {
|
||||
setGlobalDispatcher(
|
||||
new EnvHttpProxyAgent({
|
||||
httpProxy: debugHttpProxy,
|
||||
httpsProxy: debugHttpsProxy,
|
||||
noProxy: debugNoProxy,
|
||||
}),
|
||||
);
|
||||
logger.info(
|
||||
`[Debug Proxy] Node fetch proxy enabled (http: ${debugHttpProxy ?? 'none'}, https: ${debugHttpsProxy ?? 'none'}, no_proxy: ${debugNoProxy ?? 'none'})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!debugProxyServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Route Chromium traffic through the local debug proxy while keeping local dev servers direct.
|
||||
app.commandLine.appendSwitch('proxy-server', debugProxyServer);
|
||||
app.commandLine.appendSwitch('proxy-bypass-list', debugProxyBypassList);
|
||||
logger.info(
|
||||
`[Debug Proxy] Electron proxy enabled: ${debugProxyServer} (bypass: ${debugProxyBypassList})`,
|
||||
);
|
||||
}
|
||||
|
||||
configureDebugProxy();
|
||||
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
// be closed automatically when the JavaScript object is garbage collected.
|
||||
@@ -122,10 +164,17 @@ function showWindowsInstallNoticeIfNeeded() {
|
||||
});
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
|
||||
if (isDev) {
|
||||
app.setName('Gemini Nexus Dev');
|
||||
}
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
logger.info('Second instance detected, focusing existing window');
|
||||
@@ -316,7 +365,7 @@ function checkForUpdates() {
|
||||
updateElectronApp({
|
||||
updateSource: {
|
||||
type: UpdateSourceType.ElectronPublicUpdateService,
|
||||
repo: 'Draculabo/AntigravityManager',
|
||||
repo: 'Draculabo/GeminiNexusManager',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -368,7 +417,7 @@ app
|
||||
// though functionality will be broken.
|
||||
}
|
||||
|
||||
logger.info('Step: Initialize Antigravity DB (WAL Mode)');
|
||||
logger.info('Step: Initialize Gemini Nexus DB (WAL Mode)');
|
||||
initDatabase();
|
||||
})
|
||||
.then(() => {
|
||||
@@ -418,6 +467,9 @@ app
|
||||
if (enabled) {
|
||||
logger.info('Startup: Auto-Switch enabled, starting monitor...');
|
||||
CloudMonitorService.start();
|
||||
} else {
|
||||
logger.info('Startup: Auto-Switch disabled, running one-time quota and AI credits sync...');
|
||||
await CloudMonitorService.poll();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Startup: Failed to initialize services', e);
|
||||
|
||||
@@ -14,12 +14,12 @@ try {
|
||||
if (process.platform === 'win32') {
|
||||
appDataPath = path.join(
|
||||
process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
|
||||
'Antigravity',
|
||||
'Gemini Nexus',
|
||||
);
|
||||
} else if (process.platform === 'darwin') {
|
||||
appDataPath = path.join(home, 'Library', 'Application Support', 'Antigravity');
|
||||
appDataPath = path.join(home, 'Library', 'Application Support', 'Gemini Nexus');
|
||||
} else {
|
||||
appDataPath = path.join(home, '.config', 'Antigravity');
|
||||
appDataPath = path.join(home, '.config', 'Gemini Nexus');
|
||||
}
|
||||
|
||||
const configPath = path.join(appDataPath, 'gui_config.json');
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useState, useMemo, type ReactNode } from 'react';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { ProxyConfig } from '@/types/config';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -17,23 +17,14 @@ import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Loader2,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
Zap,
|
||||
Cpu,
|
||||
Sparkles,
|
||||
BrainCircuit,
|
||||
Code,
|
||||
Terminal,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { Loader2, Copy, CheckCircle, Code, Terminal, Eye, EyeOff } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -43,6 +34,48 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
||||
import { flatMap, sortBy, uniq } from 'lodash-es';
|
||||
|
||||
type ProxyProtocol = 'openai' | 'anthropic';
|
||||
|
||||
const DEFAULT_MODELS = [
|
||||
'gemini-3-flash',
|
||||
'gemini-3.1-pro-low',
|
||||
'gemini-3.1-pro-high',
|
||||
'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-6-thinking',
|
||||
];
|
||||
|
||||
const ANTHROPIC_ROUTE_OPTIONS = [
|
||||
'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-6-thinking',
|
||||
'gemini-3-flash',
|
||||
'gemini-3.1-pro-low',
|
||||
'gemini-3.1-pro-high',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_ANTHROPIC_MAPPING: Record<string, string> = {
|
||||
'claude-sonnet-4-6-20260219': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-6-20260201': 'claude-opus-4-6-thinking',
|
||||
opus: 'claude-opus-4-6-thinking',
|
||||
};
|
||||
|
||||
function resolveAnthropicMappingValue(
|
||||
anthropicMapping: Record<string, string>,
|
||||
keys: string[],
|
||||
fallback: string,
|
||||
): string {
|
||||
for (const key of keys) {
|
||||
const value = anthropicMapping[key];
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function ProxyPage() {
|
||||
const { t } = useTranslation();
|
||||
const { config, isLoading, saveConfig } = useAppConfig();
|
||||
@@ -97,14 +130,14 @@ function ProxyPage() {
|
||||
} else {
|
||||
setProxyConfig(config.proxy);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// If status check fails, just use config value
|
||||
setProxyConfig(config.proxy);
|
||||
}
|
||||
};
|
||||
syncServerStatus();
|
||||
}
|
||||
}, [config]);
|
||||
}, [config, saveConfig]);
|
||||
|
||||
// Helper to update proxyConfig and auto-save
|
||||
const updateProxyConfig = async (newProxyConfig: ProxyConfig) => {
|
||||
@@ -115,24 +148,75 @@ function ProxyPage() {
|
||||
};
|
||||
|
||||
// ===== Usage Examples State =====
|
||||
const [selectedProtocol, setSelectedProtocol] = useState<'openai' | 'anthropic'>('openai');
|
||||
const [activeModelTab, setActiveModelTab] = useState('gemini-2.5-flash');
|
||||
const { data: accounts } = useCloudAccounts();
|
||||
const [selectedProtocol, setSelectedProtocol] = useState<ProxyProtocol>('openai');
|
||||
const [activeModelTab, setActiveModelTab] = useState('gemini-3.1-pro-high');
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
// Models list for examples
|
||||
const models = [
|
||||
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', icon: <Zap size={14} /> },
|
||||
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', icon: <Cpu size={14} /> },
|
||||
{ id: 'gemini-3-flash', name: 'Gemini 3 Flash', icon: <Zap size={14} /> },
|
||||
{ id: 'gemini-3-pro-high', name: 'Gemini 3 Pro (High)', icon: <Cpu size={14} /> },
|
||||
{ id: 'claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5', icon: <Sparkles size={14} /> },
|
||||
{ id: 'claude-opus-4-5-thinking', name: 'Claude Opus 4.5', icon: <BrainCircuit size={14} /> },
|
||||
];
|
||||
const availableModelIds: string[] = useMemo(() => {
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return DEFAULT_MODELS;
|
||||
}
|
||||
const modelNames = flatMap(accounts, (account) => {
|
||||
if (!account.quota?.models) return [];
|
||||
return Object.keys(account.quota.models);
|
||||
});
|
||||
const uniqueModels = sortBy(uniq(modelNames));
|
||||
return uniqueModels.length > 0 ? uniqueModels : DEFAULT_MODELS;
|
||||
}, [accounts]);
|
||||
|
||||
const groupedModels = useMemo(() => {
|
||||
const groups: Record<string, string[]> = {
|
||||
Anthropic: [],
|
||||
Google: [],
|
||||
OpenAI: [],
|
||||
Other: [],
|
||||
};
|
||||
|
||||
availableModelIds.forEach((id) => {
|
||||
const lower = id.toLowerCase();
|
||||
if (lower.includes('gemini') || lower.includes('learnlm') || lower.includes('gemma')) {
|
||||
groups['Google'].push(id);
|
||||
} else if (lower.includes('claude')) {
|
||||
groups['Anthropic'].push(id);
|
||||
} else if (
|
||||
lower.includes('gpt') ||
|
||||
lower.includes('o1') ||
|
||||
lower.includes('o3') ||
|
||||
lower.includes('dall-e')
|
||||
) {
|
||||
groups['OpenAI'].push(id);
|
||||
} else {
|
||||
groups['Other'].push(id);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [availableModelIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableModelIds.includes(activeModelTab) && availableModelIds.length > 0) {
|
||||
setActiveModelTab(availableModelIds[0]);
|
||||
}
|
||||
}, [availableModelIds, activeModelTab]);
|
||||
|
||||
// Computed values for examples
|
||||
const apiKey = proxyConfig?.api_key || 'YOUR_API_KEY';
|
||||
const baseUrl = `http://localhost:${proxyConfig?.port || 8045}`;
|
||||
|
||||
const updateAnthropicMapping = (mappingPatch: Record<string, string>) => {
|
||||
if (!proxyConfig) {
|
||||
return;
|
||||
}
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
anthropic_mapping: {
|
||||
...proxyConfig.anthropic_mapping,
|
||||
...mappingPatch,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, type: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(type);
|
||||
@@ -201,16 +285,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>
|
||||
@@ -218,7 +300,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) => (
|
||||
@@ -232,10 +314,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>
|
||||
@@ -247,19 +326,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>
|
||||
@@ -280,16 +355,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"
|
||||
@@ -301,9 +374,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"
|
||||
@@ -320,7 +391,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
|
||||
@@ -349,28 +420,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"
|
||||
@@ -381,7 +445,7 @@ print(response.choices[0].message.content)`;
|
||||
setIsRegenerateDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('proxy.regenerateConfirm.confirm', 'Regenerate')}
|
||||
{t('proxy.regenerateConfirm.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -391,10 +455,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}
|
||||
@@ -409,36 +471,32 @@ 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-3">
|
||||
{/* Sonnet 4.5 Card */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* Sonnet 4.6 Card */}
|
||||
<div className="flex flex-col rounded-lg border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100/50 p-4 dark:border-blue-800/50 dark:from-blue-950/30 dark:to-blue-900/20">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-500"></div>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
Claude Sonnet 4.5
|
||||
Claude Sonnet 4.6 (Thinking)
|
||||
</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={
|
||||
proxyConfig.anthropic_mapping['claude-sonnet-4-5-20250929'] ||
|
||||
'claude-sonnet-4-5-thinking'
|
||||
}
|
||||
value={resolveAnthropicMappingValue(
|
||||
proxyConfig.anthropic_mapping,
|
||||
['claude-sonnet-4-6-20260219', 'claude-sonnet-4-5-20250929'],
|
||||
'claude-sonnet-4-6-thinking',
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
anthropic_mapping: {
|
||||
...proxyConfig.anthropic_mapping,
|
||||
'claude-sonnet-4-5-20250929': value,
|
||||
},
|
||||
updateAnthropicMapping({
|
||||
'claude-sonnet-4-6-20260219': value,
|
||||
'claude-sonnet-4-5-20250929': value,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -446,34 +504,36 @@ print(response.choices[0].message.content)`;
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude-sonnet-4-5-thinking">
|
||||
claude-sonnet-4-5-thinking
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini-2.5-flash">gemini-2.5-flash</SelectItem>
|
||||
<SelectItem value="gemini-2.5-pro">gemini-2.5-pro</SelectItem>
|
||||
<SelectItem value="gemini-3-flash">gemini-3-flash</SelectItem>
|
||||
<SelectItem value="gemini-3-pro-high">gemini-3-pro-high</SelectItem>
|
||||
{ANTHROPIC_ROUTE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Opus 4.5 Card */}
|
||||
{/* Opus 4.6 Card */}
|
||||
<div className="flex flex-col rounded-lg border-2 border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100/50 p-4 dark:border-purple-800/50 dark:from-purple-950/30 dark:to-purple-900/20">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-purple-500"></div>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
Claude Opus 4.5
|
||||
Claude Opus 4.6 (Thinking)
|
||||
</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={proxyConfig.anthropic_mapping['opus'] || 'claude-opus-4-5-thinking'}
|
||||
value={resolveAnthropicMappingValue(
|
||||
proxyConfig.anthropic_mapping,
|
||||
['claude-opus-4-6-20260201', 'opus'],
|
||||
'claude-opus-4-6-thinking',
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
anthropic_mapping: { ...proxyConfig.anthropic_mapping, opus: value },
|
||||
updateAnthropicMapping({
|
||||
'claude-opus-4-6-20260201': value,
|
||||
opus: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -481,45 +541,11 @@ print(response.choices[0].message.content)`;
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude-opus-4-5-thinking">claude-opus-4-5-thinking</SelectItem>
|
||||
<SelectItem value="gemini-2.5-pro">gemini-2.5-pro</SelectItem>
|
||||
<SelectItem value="gemini-3-pro-high">gemini-3-pro-high</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Haiku 4.5 Card */}
|
||||
<div className="flex flex-col rounded-lg border-2 border-green-200 bg-gradient-to-br from-green-50 to-green-100/50 p-4 dark:border-green-800/50 dark:from-green-950/30 dark:to-green-900/20">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500"></div>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
Claude Haiku 4.5
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
{t('proxy.mapping.maps_to', 'Maps to')}
|
||||
</p>
|
||||
<Select
|
||||
value={
|
||||
proxyConfig.anthropic_mapping['claude-haiku-4-5-20251001'] || 'gemini-2.5-flash'
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
anthropic_mapping: {
|
||||
...proxyConfig.anthropic_mapping,
|
||||
'claude-haiku-4-5-20251001': value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gemini-2.5-flash">gemini-2.5-flash</SelectItem>
|
||||
<SelectItem value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</SelectItem>
|
||||
<SelectItem value="gemini-3-flash">gemini-3-flash</SelectItem>
|
||||
{ANTHROPIC_ROUTE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -532,15 +558,11 @@ print(response.choices[0].message.content)`;
|
||||
onClick={() =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
anthropic_mapping: {
|
||||
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-thinking',
|
||||
opus: 'claude-opus-4-5-thinking',
|
||||
'claude-haiku-4-5-20251001': 'gemini-2.5-flash',
|
||||
},
|
||||
anthropic_mapping: { ...DEFAULT_ANTHROPIC_MAPPING },
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('proxy.mapping.restore', 'Restore Defaults')}
|
||||
{t('proxy.mapping.restore')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -551,11 +573,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 */}
|
||||
@@ -570,7 +590,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">
|
||||
@@ -579,7 +599,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>
|
||||
|
||||
@@ -593,7 +613,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">
|
||||
@@ -601,22 +621,57 @@ 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>
|
||||
|
||||
{/* Model Tabs */}
|
||||
<div className="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-700">
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => setActiveModelTab(model.id)}
|
||||
className={`flex items-center gap-1 rounded-t-lg px-3 py-2 text-xs font-medium whitespace-nowrap transition-colors ${activeModelTab === model.id ? 'border-b-2 border-blue-600 bg-blue-50/50 text-blue-600 dark:border-blue-400 dark:bg-blue-900/10 dark:text-blue-400' : 'text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800'}`}
|
||||
>
|
||||
{model.icon}
|
||||
<span>{model.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{/* Model Selector & Copy API ID */}
|
||||
<div className="flex flex-col gap-4 border-b border-gray-200 pb-4 sm:flex-row sm:items-center sm:justify-between dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm font-medium">
|
||||
Model{' '}
|
||||
<span className="text-muted-foreground ml-1 font-normal">
|
||||
({availableModelIds.length} available)
|
||||
</span>
|
||||
</Label>
|
||||
<Select value={activeModelTab} onValueChange={setActiveModelTab}>
|
||||
<SelectTrigger className="h-9 w-[280px] font-mono text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedModels).map(([provider, models]) => {
|
||||
if (models.length === 0) return null;
|
||||
return (
|
||||
<SelectGroup key={provider}>
|
||||
<SelectLabel className="text-muted-foreground text-xs font-bold">
|
||||
{provider}
|
||||
</SelectLabel>
|
||||
{models.map((modelId) => (
|
||||
<SelectItem key={modelId} value={modelId} className="font-mono text-xs">
|
||||
{modelId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(activeModelTab, 'model-id')}
|
||||
className="h-9 text-xs"
|
||||
>
|
||||
{copied === 'model-id' ? (
|
||||
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Copy API Name
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* cURL Example */}
|
||||
@@ -631,7 +686,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">
|
||||
@@ -651,7 +706,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">
|
||||
|
||||
@@ -17,16 +17,19 @@ import { getAppVersion, getPlatform } from '@/actions/app';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { setAppLanguage } from '@/actions/language';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Loader2, FolderOpen } from 'lucide-react';
|
||||
import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ProxyConfig } from '@/types/config';
|
||||
import { openLogDirectory } from '@/actions/system';
|
||||
import { AccountProxySettings } from '@/components/AccountProxySettings';
|
||||
|
||||
function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { config, isLoading, saveConfig } = useAppConfig();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Local state for configuration editing
|
||||
const [proxyConfig, setProxyConfig] = useState<ProxyConfig | undefined>(undefined);
|
||||
@@ -82,9 +85,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 --- */}
|
||||
@@ -128,6 +131,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>
|
||||
@@ -137,22 +141,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}
|
||||
@@ -167,13 +164,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}
|
||||
@@ -190,23 +182,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
|
||||
@@ -220,10 +204,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>
|
||||
@@ -238,10 +219,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>
|
||||
@@ -252,7 +233,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>
|
||||
@@ -261,23 +242,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
|
||||
@@ -289,12 +262,76 @@ 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')}</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')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.notifications.quotaAlertDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.quota_alert_enabled || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
try {
|
||||
await saveConfig({ ...config, quota_alert_enabled: checked });
|
||||
} catch {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: t('settings.notifications.saveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.notifications.quotaThreshold')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.notifications.quotaThresholdDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config?.quota_alert_threshold ?? 20}
|
||||
onChange={async (e) => {
|
||||
const rawValue = e.target.value;
|
||||
const parsed = parseInt(rawValue, 10);
|
||||
if (isNaN(parsed) || parsed < 0 || parsed > 100) return;
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
await saveConfig({ ...config, quota_alert_threshold: parsed });
|
||||
} catch {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: t('settings.notifications.thresholdSaveFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-16 rounded-md border bg-transparent px-2 py-1 text-center text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -344,6 +381,8 @@ function SettingsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AccountProxySettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||