Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cda59ed3a2 | ||
|
|
18389b9753 | ||
|
|
2ae2216f1e | ||
|
|
ed94abf0d6 | ||
|
|
f572ae4652 | ||
|
|
36848b46dc | ||
|
|
5bda4b19df | ||
|
|
476d297e4b | ||
|
|
ce7147025d | ||
|
|
080fb67974 | ||
|
|
f6f8069ce4 | ||
|
|
6328378da5 | ||
|
|
d25f2948c3 | ||
|
|
63956c9c2d | ||
|
|
a785640d7a | ||
|
|
1265d044f6 | ||
|
|
9ca0ad0396 | ||
|
|
f0eb7c6b61 | ||
|
|
ea51253d58 | ||
|
|
dd9e84a0db | ||
|
|
a988a33d21 | ||
|
|
e959ee346e | ||
|
|
5a2168030e | ||
|
|
00bf4dea0b | ||
|
|
02080588b7 | ||
|
|
d5ee08d5a0 | ||
|
|
e844aa9545 | ||
|
|
0d4e2ab21f | ||
|
|
f15bb48fdd | ||
|
|
515648d64d | ||
|
|
22265e153c | ||
|
|
ad39a735b5 | ||
|
|
6a07bc0a10 | ||
|
|
e91457eceb | ||
|
|
01e3f8f8fd | ||
|
|
bd3d41aed1 | ||
|
|
d2945a6e8a | ||
|
|
89f39710c7 | ||
|
|
1bce5d331d | ||
|
|
47be175e27 | ||
|
|
f0718db374 | ||
|
|
4b12f1220d | ||
|
|
47c14763de | ||
|
|
37a9750df2 | ||
|
|
f708de24c0 | ||
|
|
6e7f2252ad | ||
|
|
e96d3e0e8e | ||
|
|
12ab4e0ce7 | ||
|
|
013f3290f3 | ||
|
|
f224fea081 | ||
|
|
a438070268 | ||
|
|
e927ac9506 | ||
|
|
465eb5d0a4 | ||
|
|
37b0fdeeb0 | ||
|
|
a343e0c1ae | ||
|
|
695d787b01 | ||
|
|
b4ca037518 | ||
|
|
c43507c716 | ||
|
|
f5c44c549f | ||
|
|
f37127d385 | ||
|
|
f636dd61a5 | ||
|
|
42973561fb | ||
|
|
5772e2e90e | ||
|
|
b0ec96d4a6 | ||
|
|
5a004d9d16 | ||
|
|
ccc5c91969 | ||
|
|
dcfed908a5 | ||
|
|
1336de679f | ||
|
|
bc43cc101c | ||
|
|
01f6a5aaee | ||
|
|
f8e06efcca | ||
|
|
13f02c7ac2 | ||
|
|
b425dc3758 | ||
|
|
fb13826afd |
No files matched your search
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: antigravity-manager
|
||||
description: Comprehensive guide to Antigravity Manager architecture, workflows, and development. Use this to understand how to work on the project.
|
||||
---
|
||||
|
||||
# Antigravity 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).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User[User Interface] -->|React/Vite| Renderer[Renderer Process]
|
||||
Renderer -->|ORPC Client| IPC[IPC Layer]
|
||||
IPC -->|ORPC Router| Main[Main Process]
|
||||
Main -->|Bootstraps| Server[NestJS Server]
|
||||
Main -->|Calls| Services[Service Layer]
|
||||
Services -->|Read/Write| DB[(SQLite Database)]
|
||||
Services -->|HTTP| Cloud[Cloud APIs (Google/Anthropic)]
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **Frontend**: React 19, TailwindCSS v4, TanStack Router, TanStack Query.
|
||||
- **Backend**: Electron (Main), NestJS (Core Logic), Better-SQLite3 (Data).
|
||||
- **Communication**: ORPC (Type-safe IPC wrapper around Electron IPC).
|
||||
- **Build**: Electron Forge + Vite.
|
||||
|
||||
## 📂 Directory Structure
|
||||
|
||||
- `src/main.ts`: Electron Main Process entry point.
|
||||
- `src/preload.ts`: Bridge between Main and Renderer.
|
||||
- `src/renderer.tsx`: React App entry point.
|
||||
- `src/components/`: Reusable React UI components (Radix UI based).
|
||||
- `src/ipc/`: IPC Routers and Handlers (Domain logic).
|
||||
- `router.ts`: Main ORPC router definition.
|
||||
- `account/`, `cloud/`, `database/`: Domain-specific handlers.
|
||||
- `src/server/`: NestJS application modules (proxies/gateways).
|
||||
- `src/services/`: Core business logic (framework agnostic).
|
||||
- `GoogleAPIService.ts`: Gemini/Cloud interactions.
|
||||
- `AutoSwitchService.ts`: Account rotation logic.
|
||||
- `src/routes/`: Frontend routing definitions (File-based).
|
||||
|
||||
## 🚀 Development Workflow
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm (Project uses `package-lock.json`)
|
||||
|
||||
### Common Commands
|
||||
|
||||
- **Start Dev Server**: `npm start`
|
||||
- **Lint Code**: `npm run lint`
|
||||
- **Unit Test**: `npm run test:unit`
|
||||
- **E2E Test**: `npm run test:e2e`
|
||||
- **Build Production**: `npm run make`
|
||||
|
||||
## 🧠 Core Concepts
|
||||
|
||||
### IPC Communication (ORPC)
|
||||
|
||||
The project uses `orpc` for type-safe communication.
|
||||
|
||||
- **Define**: Create a router in `src/ipc/router.ts` with Zod schemas.
|
||||
- **Implement**: Add logic in handlers (e.g., `src/ipc/account/handler.ts`).
|
||||
- **Call**: Use the generated client in React components.
|
||||
|
||||
### Database Access
|
||||
|
||||
Data is stored in a local SQLite file (`test.db` in dev, user data in prod).
|
||||
|
||||
- Use `Better-SQLite3` for direct access.
|
||||
- Logic should be encapsulated in `src/services` or `src/ipc`.
|
||||
|
||||
### Account Management
|
||||
|
||||
- Accounts are added via OAuth (Google/Claude).
|
||||
- `GoogleAPIService` handles token exchange and refreshing.
|
||||
- `AutoSwitchService` monitors usage and switches active accounts automatically.
|
||||
|
||||
## ⚠️ Critical Rules
|
||||
|
||||
1. **Type Safety**: strict TypeScript usage; Zod for runtime validation.
|
||||
2. **Components**: Use `src/components/ui` (Radix primitives) for consistency.
|
||||
3. **Async**: Handle all IPC/DB calls asynchronously with try/catch.
|
||||
4. **Security**: Never commit secrets. API keys are user-provided or encrypted locally.
|
||||
@@ -1,14 +1,18 @@
|
||||
---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
|
||||
**Guardrails**
|
||||
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
|
||||
**Steps**
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
|
||||
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
|
||||
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
|
||||
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
|
||||
@@ -16,5 +20,6 @@ Track these steps as TODOs and complete them one by one.
|
||||
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
|
||||
|
||||
**Reference**
|
||||
|
||||
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
|
||||
<!-- OPENSPEC:END -->
|
||||
@@ -1,13 +1,17 @@
|
||||
---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
|
||||
**Guardrails**
|
||||
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Determine the change ID to archive:
|
||||
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
|
||||
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
|
||||
@@ -19,6 +23,7 @@ description: Archive a deployed OpenSpec change and update specs.
|
||||
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
|
||||
|
||||
**Reference**
|
||||
|
||||
- Use `openspec list` to confirm change IDs before archiving.
|
||||
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
|
||||
<!-- OPENSPEC:END -->
|
||||
@@ -1,8 +1,11 @@
|
||||
---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
|
||||
**Guardrails**
|
||||
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
@@ -10,6 +13,7 @@ description: Scaffold a new OpenSpec change and validate strictly.
|
||||
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
|
||||
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
|
||||
@@ -19,6 +23,7 @@ description: Scaffold a new OpenSpec change and validate strictly.
|
||||
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
|
||||
|
||||
**Reference**
|
||||
|
||||
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
|
||||
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
|
||||
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
- package-ecosystem: 'npm'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: 'weekly'
|
||||
@@ -1,7 +1,7 @@
|
||||
name-template: 'v$RESOLVED_VERSION'
|
||||
tag-template: 'v$RESOLVED_VERSION'
|
||||
categories:
|
||||
- title: '🚀 What''s new'
|
||||
- title: "🚀 What's new"
|
||||
labels:
|
||||
- 'feature'
|
||||
- 'feat'
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
name: Publish Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -11,7 +10,28 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest, ubuntu-latest]
|
||||
include:
|
||||
- os: windows-latest
|
||||
platform: win32
|
||||
arch: x64
|
||||
- os: windows-11-arm
|
||||
platform: win32
|
||||
arch: arm64
|
||||
- os: macos-15-intel
|
||||
platform: darwin
|
||||
arch: x64
|
||||
- os: macos-14
|
||||
platform: darwin
|
||||
arch: arm64
|
||||
- os: macos-14
|
||||
platform: darwin
|
||||
arch: universal
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
arch: x64
|
||||
- os: ubuntu-24.04-arm
|
||||
platform: linux
|
||||
arch: arm64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -29,10 +49,14 @@ jobs:
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rpm libsecret-1-dev pkg-config
|
||||
sudo apt-get install -y fakeroot rpm libsecret-1-dev pkg-config
|
||||
|
||||
- name: Install WiX Toolset
|
||||
if: matrix.platform == 'win32' && matrix.arch == 'x64'
|
||||
run: choco install wixtoolset --version=3.14.0 -y
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -40,4 +64,8 @@ jobs:
|
||||
- name: Publish Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run publish
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: npm run publish -- --platform=${{ matrix.platform }} --arch=${{ matrix.arch }}
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Semantic Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Required for semantic-release to analyze commits
|
||||
persist-credentials: false # We use a token in the next steps
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} # Use GH_TOKEN to trigger other workflows
|
||||
run: npx semantic-release
|
||||
@@ -102,3 +102,6 @@ out/
|
||||
|
||||
# Tanstack
|
||||
.tanstack/
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
+4
-1
@@ -3,4 +3,7 @@
|
||||
/coverage
|
||||
/.vite
|
||||
README.md
|
||||
/src/routeTree.gen.ts
|
||||
CHANGELOG.md
|
||||
/src/routeTree.gen.ts
|
||||
.agents/
|
||||
openspec/
|
||||
@@ -8,5 +8,4 @@
|
||||
"tabWidth": 2,
|
||||
"proseWrap": "preserve",
|
||||
"tailwindFunctions": ["clsx", "tw"]
|
||||
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
|
||||
}
|
||||
@@ -1,18 +1,518 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
|
||||
# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open `@/openspec/AGENTS.md` when the request:
|
||||
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use `@/openspec/AGENTS.md` to learn:
|
||||
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
# User Guide
|
||||
|
||||
This document is a user-level guide for AI assistants working in this repository. It standardizes language, tool/script preferences, and development notes for the current tech stack.
|
||||
|
||||
## 💬 Communication Conventions
|
||||
|
||||
- **Language**: Use English consistently for conversation, TODOs, and code-related content (comments, UI copy, commit messages, PR descriptions, and similar artifacts).
|
||||
- **Conclusion first**: Start with the core conclusion/summary, then provide details.
|
||||
- **References**: When citing code, always provide full file paths (for example, `src/main.ts:42`).
|
||||
|
||||
## 💻 Runtime and Tooling
|
||||
|
||||
- **Runtime**: Node.js (Electron environment)
|
||||
- **Node**: Recommended Node.js 20+
|
||||
- **Package manager**: `npm` (this project includes `package-lock.json`; use npm only)
|
||||
- **Build tools**: Electron Forge + Vite
|
||||
- **Terminal**: Windows (PowerShell) / VSCode MCP tools can be used safely
|
||||
|
||||
## 🧩 Tech Stack Overview
|
||||
|
||||
- **Frontend**:
|
||||
- React 19, TypeScript
|
||||
- Tailwind CSS v4, `clsx`, `tailwind-merge`, `tailwindcss-animate`
|
||||
- Radix UI (Primitives), Lucide React (Icons), Simple Icons (`@icons-pack/react-simple-icons`)
|
||||
- `class-variance-authority` (CVA), `react-i18next` + `i18next`
|
||||
- TanStack Router (Routing), TanStack Query (State Management)
|
||||
- Components: Modular design under `src/components`
|
||||
- **Backend (Electron Main/Server)**:
|
||||
- Electron (Main/Preload/Renderer architecture)
|
||||
- NestJS (internal proxy/gateway service, started by main process)
|
||||
- Better-SQLite3 (local database), Drizzle ORM / Raw SQL
|
||||
- ORPC (type-safe RPC)
|
||||
- gRPC (`@grpc/grpc-js`, `@grpc/proto-loader`)
|
||||
- Logging: `winston` + `winston-daily-rotate-file`
|
||||
- Zod (validation)
|
||||
- **Testing**:
|
||||
- Vitest (unit/integration), Testing Library
|
||||
- Playwright (E2E)
|
||||
|
||||
## 📁 Directory Structure
|
||||
|
||||
```plaintext
|
||||
.
|
||||
├─ src/
|
||||
│ ├─ actions/ # App actions and flow orchestration
|
||||
│ ├─ assets/ # Static assets
|
||||
│ ├─ components/ # React UI components (base components under ui/)
|
||||
│ ├─ constants/ # Constants
|
||||
│ ├─ hooks/ # Custom React hooks
|
||||
│ ├─ ipc/ # Electron IPC logic (Database, Config, etc.)
|
||||
│ ├─ layouts/ # Layout components
|
||||
│ ├─ lib/ # Shared low-level utilities
|
||||
│ ├─ localization/ # i18n translation resources
|
||||
│ ├─ mocks/ # Mock data for tests and development
|
||||
│ ├─ routes/ # TanStack Router route definitions
|
||||
│ ├─ server/ # NestJS backend logic (Gateway/Proxy)
|
||||
│ ├─ services/ # Service layer
|
||||
│ ├─ styles/ # Global styles (Tailwind classes)
|
||||
│ ├─ tests/ # Test code
|
||||
│ ├─ types/ # TypeScript type definitions
|
||||
│ ├─ utils/ # Utility functions
|
||||
│ ├─ App.tsx # React app entry
|
||||
│ ├─ main.ts # Electron main entry
|
||||
│ ├─ preload.ts # Electron preload script
|
||||
│ └─ renderer.ts # Electron renderer entry
|
||||
├─ forge.config.ts # Electron Forge config
|
||||
└─ package.json
|
||||
```
|
||||
|
||||
## 🧱 Component Architecture
|
||||
|
||||
- **Modular components**: Each component should have its own directory, with at least a `.tsx` file and optional styles/subcomponents.
|
||||
- **Shared capabilities**: General helpers in `src/utils/`; low-level shared wrappers in `src/lib/`.
|
||||
- **Service layer**: Centralize data access in `src/services/` or `src/ipc/`; frontend should consume IPC or RPC only.
|
||||
|
||||
## 📦 Common Scripts
|
||||
|
||||
Use `npm` for all commands:
|
||||
|
||||
- **Development (Dev)**:
|
||||
- `npm start` - Start Electron dev environment (Electron Forge)
|
||||
- `npm run lint` - Run ESLint checks
|
||||
- `npm run format` - Run Prettier check
|
||||
- `npm run format:write` - Auto-format with Prettier
|
||||
- `npm run type-check` - Run TypeScript type check
|
||||
|
||||
- **Build**:
|
||||
- `npm run package` - Package app (application bundle only)
|
||||
- `npm run make` - Build and generate distributable installers
|
||||
- `npm run publish` - Publish app
|
||||
|
||||
- **Testing**:
|
||||
- `npm test` - Run Vitest tests
|
||||
- `npm run test:watch` - Run Vitest in watch mode
|
||||
- `npm run test:unit` - Same as above for unit-focused runs
|
||||
- `npm run test:e2e` - Run Playwright E2E tests
|
||||
- `npm run test:all` - Run all tests
|
||||
|
||||
### Running a Single Test
|
||||
|
||||
- Unit test: `npm run test:unit path/to/test.test.ts`
|
||||
- E2E test: `npm run test:e2e path/to/test.spec.ts`
|
||||
- Type check: `npm run type-check`
|
||||
|
||||
## 🧪 Development Notes
|
||||
|
||||
- **Build**: Build stage may ignore TS/ESLint errors depending on project/CI configuration.
|
||||
- **DevTools**: `code-inspector-plugin` is integrated; use `Shift + Click` on page elements to jump to source code.
|
||||
- **React**: React Strict Mode is disabled.
|
||||
- **NestJS**: Runs as an Electron child process; logs are visible in main-process console.
|
||||
|
||||
## Security and Data
|
||||
|
||||
- **Security**: Never commit secrets; use environment variables for sensitive config; validate all user input; encrypt sensitive data.
|
||||
- **Database**: Use Better-SQLite3; encapsulate operations in services layer; always use prepared statements; test DB operations independently.
|
||||
- **i18n**: Use `react-i18next`; keys should use kebab-case; translation files are stored in `src/localization/`.
|
||||
|
||||
## 📝 Conventions
|
||||
|
||||
- **File naming**:
|
||||
- Components: PascalCase (for example, `Button.tsx`)
|
||||
- Tools/config: camelCase or kebab-case
|
||||
- **Import paths**: Use `@/` alias for `src/`.
|
||||
- **Type safety**: Avoid `any`; enforce end-to-end type safety with Zod + TypeScript.
|
||||
- **Component design**:
|
||||
- Prefer Radix UI Primitives.
|
||||
- Use Tailwind utility classes; avoid CSS Modules unless necessary.
|
||||
- **API communication**: Frontend should prioritize ORPC client or IPC for strong type inference.
|
||||
|
||||
### Naming Specifics
|
||||
|
||||
- **Functions/Variables**: camelCase (for example, `handleClick`, `isCurrent`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (for example, `LOCAL_STORAGE_KEYS`)
|
||||
- **Files**:
|
||||
- Services: `ServiceName.service.ts`
|
||||
- Types: `type-name.ts`
|
||||
|
||||
### Import Organization
|
||||
|
||||
```typescript
|
||||
// 1. React and core libraries
|
||||
import React, { useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
// 2. External dependencies (alphabetical order)
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
// 3. Internal imports (using @ alias)
|
||||
import { Account } from '@/types/account';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
```
|
||||
|
||||
### Component Structure
|
||||
|
||||
```typescript
|
||||
// 1. Imports
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// 2. Type definitions
|
||||
interface ComponentProps { /* props */ }
|
||||
|
||||
// 3. Component implementation
|
||||
export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
|
||||
// 4. Hooks
|
||||
const { t } = useTranslation();
|
||||
// 5. Render
|
||||
return <div>{/* JSX */}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
> Before commit, run `npm run lint` and `npm run format`.
|
||||
|
||||
## 📝 Terminal Output and References
|
||||
|
||||
- Prefer code blocks; avoid Markdown tables and Mermaid unless necessary.
|
||||
- If tables are used, keep them left-aligned and check display consistency.
|
||||
|
||||
Example:
|
||||
|
||||
```plaintext
|
||||
+------+---------+---------+
|
||||
| ID | Name | Role |
|
||||
+------+---------+---------+
|
||||
| 1 | Alice | Admin |
|
||||
| 2 | Bob | User |
|
||||
+------+---------+---------+
|
||||
```
|
||||
|
||||
### Reference Rules
|
||||
|
||||
- External resources: use full clickable links (issues, docs, API references).
|
||||
- Source code location: use full file paths (optionally with line numbers).
|
||||
|
||||
Example:
|
||||
|
||||
```plaintext
|
||||
- "resolveFilePath owns this logic"
|
||||
- "VSCode has a known limitation in undo behavior"
|
||||
|
||||
References:
|
||||
- resolveFilePath: src/utils/workspace.ts:40
|
||||
- VSCode undo limitation: https://github.com/microsoft/vscode/issues/77190
|
||||
```
|
||||
|
||||
## 🏷️ Markdown Writing
|
||||
|
||||
- Always specify a language for fenced code blocks; use `plaintext` if unsure.
|
||||
- Keep one blank line after headings for readability.
|
||||
|
||||
## Line-Break Rule
|
||||
|
||||
`return` and similar statements should not share a line with other statements. Keep them on separate lines.
|
||||
|
||||
## 💭 Commenting Rules
|
||||
|
||||
- Required comment scenarios: complex business logic/algorithms, non-obvious behaviors, important design tradeoffs, and key reference links.
|
||||
- Principles:
|
||||
- Explain **why**, not **what**, and not changelog history.
|
||||
- Update comments whenever related code changes.
|
||||
- Prefer JSDoc; for complex functions, start with high-level overview, then annotate key steps (1, 2, 3...).
|
||||
- Keep spacing between English and Chinese words if both appear for readability; do not comment deleted legacy code.
|
||||
|
||||
Quality self-check: six months later, what useful context does a new teammate gain from this comment? If the answer is "none", remove it.
|
||||
|
||||
Example:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Handle payment request with multi-step validation.
|
||||
*/
|
||||
function processPayment(request: PaymentRequest) {
|
||||
// 1. Input validation
|
||||
// 2. Risk evaluation (low/medium/high paths)
|
||||
// 3. Gateway call
|
||||
// 4. User notification
|
||||
}
|
||||
|
||||
export enum BudgetType {
|
||||
Free = 'free',
|
||||
/** ✅ Prefer JSDoc over end-of-line comments */
|
||||
Package = 'package',
|
||||
}
|
||||
```
|
||||
|
||||
## 🛠️ Development Guide
|
||||
|
||||
### General Principles
|
||||
|
||||
- Prioritize stability and maintainability before optimization.
|
||||
- For uncertainty, state assumptions/tradeoffs/validation approach clearly, then implement.
|
||||
- Trust agreed preconditions; avoid excessive defensive coding against guaranteed invariants.
|
||||
- Refactor legacy code conservatively; use modern approaches for new features where appropriate.
|
||||
- Avoid premature optimization: implement simple and direct first; optimize only when justified.
|
||||
- Always use braces for control flow (`if`, `while`, and similar statements).
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
// Use try-catch for async operations
|
||||
try {
|
||||
const result = await someOperation();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Operation failed:', error);
|
||||
throw new Error('Failed to complete operation');
|
||||
}
|
||||
|
||||
// Use proper error typing
|
||||
if (error instanceof Error) {
|
||||
/* handle Error instance */
|
||||
}
|
||||
```
|
||||
|
||||
### New Feature Implementation
|
||||
|
||||
- Code should be clear, readable, reusable, efficient, and testable.
|
||||
- Prefer mature and reliable modern APIs.
|
||||
|
||||
### Refactoring and Bug Fixing
|
||||
|
||||
- Prefer incremental changes; align scope first before large refactors.
|
||||
- Preserve existing structure and style; avoid over-abstraction risk.
|
||||
|
||||
### Development Lifecycle Checklist
|
||||
|
||||
Exploration / planning:
|
||||
|
||||
- \[ ] Fully understand requirements; break down into 3-6 steps
|
||||
- \[ ] Review documentation and existing solutions first
|
||||
- \[ ] Validate ideas by reading actual code
|
||||
- \[ ] Build a TODO list
|
||||
|
||||
Implementation / refactor / fix:
|
||||
|
||||
- [ ] Review related templates and surrounding code; follow existing patterns
|
||||
- [ ] Fail fast on invalid inputs/states
|
||||
- [ ] Improve frontend interaction and UX within constraints
|
||||
|
||||
Acceptance / validation:
|
||||
|
||||
- \[ ] Validate implementation through tests or temporary scripts
|
||||
- \[ ] After multiple incremental edits, evaluate whether changes should be consolidated
|
||||
- \[ ] Run quality checks
|
||||
- \[ ] Update related docs
|
||||
|
||||
Summary / output:
|
||||
|
||||
- \[ ] Verify output formatting requirements
|
||||
- \[ ] List deviations from plan and key decisions for human review
|
||||
- \[ ] Provide optimization suggestions
|
||||
- \[ ] Include full references at the end
|
||||
|
||||
## 🔍 Code Quality and Lint
|
||||
|
||||
- Use descriptive variable names (`mutationObserver`, `button`, `element`) and avoid `mo`, `btn`, `el`.
|
||||
- Check for missing critical comments and keep comment language consistent.
|
||||
- Use VSCode MCP diagnostics for TS/ESLint issues and fix key findings.
|
||||
- If tests are added/updated, run and fix them before submission.
|
||||
|
||||
## ⛔ Operations Requiring Explicit Confirmation
|
||||
|
||||
- Running destructive commands
|
||||
- Executing `git commit` or `git push`
|
||||
- Creating new test files (maintainer review required first)
|
||||
|
||||
## 🔧 Tool Preferences and Commands
|
||||
|
||||
Packages and scripts:
|
||||
|
||||
- `npm install` (or `npm i`)
|
||||
|
||||
Shell:
|
||||
|
||||
- Run commands in repository root.
|
||||
- Quote file paths when appropriate.
|
||||
|
||||
Web search:
|
||||
|
||||
- Use `WebSearch` for latest information; use `mcp__SearXNG__search` when needed.
|
||||
|
||||
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:
|
||||
|
||||
```plaintext
|
||||
Task(subagent_type: "quick-code-review", description: "Code review", prompt: "[change description]")
|
||||
Task(subagent_type: "diagnostics", description: "Diagnostics", prompt: "[same as above]")
|
||||
Task(subagent_type: "run-related-tests", description: "Run tests", prompt: "[same as above]")
|
||||
```
|
||||
|
||||
`change description` example:
|
||||
|
||||
```plaintext
|
||||
- Modified files: list of relative paths
|
||||
- Context: requirement/business background
|
||||
```
|
||||
|
||||
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>
|
||||
+185
-12
@@ -1,24 +1,197 @@
|
||||
<a name="readme-top"></a>
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
## [0.7.0](https://github.com/Draculabo/AntigravityManager/compare/v0.6.0...v0.7.0) (2026-02-06)
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
### ✨ Features
|
||||
|
||||
## [Unreleased]
|
||||
* add multi-arch release artifacts and MSI packaging ([#65](https://github.com/Draculabo/AntigravityManager/issues/65)) ([f572ae4](https://github.com/Draculabo/AntigravityManager/commit/f572ae4652937efb25ab66defcfa55ddf65ac484))
|
||||
|
||||
### Added
|
||||
- Bilingual README (English and Simplified Chinese)
|
||||
- Project Logo
|
||||
- GitHub Issue Templates (Bug Report and Feature Request)
|
||||
- Code of Conduct
|
||||
- Star History chart in README
|
||||
- Detailed FAQ section in README
|
||||
- Technical stack and project structure documentation
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* restore account switching on Antigravity 1.16.5 and migrate db sync to drizzle ([#69](https://github.com/Draculabo/AntigravityManager/issues/69)) ([ed94abf](https://github.com/Draculabo/AntigravityManager/commit/ed94abf0d6e90c0a1bf9c76f1ec3fdea091f6c4b))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
* translate repository documentation to English ([#72](https://github.com/Draculabo/AntigravityManager/issues/72)) ([18389b9](https://github.com/Draculabo/AntigravityManager/commit/18389b975392f63441ede8369f13f6ef068214ab))
|
||||
|
||||
### ♻️ Code Refactoring
|
||||
|
||||
* migrate to winston and enable daily rotated app logs ([#71](https://github.com/Draculabo/AntigravityManager/issues/71)) ([2ae2216](https://github.com/Draculabo/AntigravityManager/commit/2ae2216f1ed3fa503ffd87a14cafd3921077a5c4))
|
||||
|
||||
## [0.6.0](https://github.com/Draculabo/AntigravityManager/compare/v0.5.0...v0.6.0) (2026-02-04)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* add cloud reset time UI ([#56](https://github.com/Draculabo/AntigravityManager/issues/56)) ([f6f8069](https://github.com/Draculabo/AntigravityManager/commit/f6f8069ce4673ae7027c6ba248daa18c8b602218))
|
||||
* windows install guidance ([#63](https://github.com/Draculabo/AntigravityManager/issues/63)) ([ce71470](https://github.com/Draculabo/AntigravityManager/commit/ce7147025d37648a82f6f6b300502ef4462bfde6))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* correct Windows install notice path ([5bda4b1](https://github.com/Draculabo/AntigravityManager/commit/5bda4b19dfc9ce64735cd39600d41a5deefc26ee))
|
||||
* **proxy:** route Claude Code CLI requests on /v1/chat/completions to Anthropic handler ([#61](https://github.com/Draculabo/AntigravityManager/issues/61)) ([476d297](https://github.com/Draculabo/AntigravityManager/commit/476d297e4b2dd3015f7ebcbb915b643730816dc6))
|
||||
|
||||
## [0.5.0](https://github.com/Draculabo/AntigravityManager/compare/v0.4.0...v0.5.0) (2026-01-30)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* **i18n:** add Russian localization ([#48](https://github.com/Draculabo/AntigravityManager/issues/48)) ([63956c9](https://github.com/Draculabo/AntigravityManager/commit/63956c9c2d60f829a998237abe6ade675fdb01ed))
|
||||
* Implement collapsible sidebar and refined status bar UI ([#45](https://github.com/Draculabo/AntigravityManager/issues/45)) ([1265d04](https://github.com/Draculabo/AntigravityManager/commit/1265d044f69e52fba7da72fde6c47a0b85c58232))
|
||||
* sentry integration ([#51](https://github.com/Draculabo/AntigravityManager/issues/51)) ([a785640](https://github.com/Draculabo/AntigravityManager/commit/a785640d7a51b65a6852383401bd7c284b716975))
|
||||
|
||||
## [0.4.0](https://github.com/Draculabo/AntigravityManager/compare/v0.3.5...v0.4.0) (2026-01-28)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* add system autostart and single-instance support ([ea51253](https://github.com/Draculabo/AntigravityManager/commit/ea51253d589abd537682344d3bdb684b8fc9a511))
|
||||
* implement smart foreground quota refresh with debounce ([dd9e84a](https://github.com/Draculabo/AntigravityManager/commit/dd9e84a0dbefad6066193b6bd468689a755a02e3))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* stub nestjs optional modules for packaging ([f0eb7c6](https://github.com/Draculabo/AntigravityManager/commit/f0eb7c6b619a3ea9ea203d66f5dbce731d731e3c))
|
||||
|
||||
## [0.3.5](https://github.com/Draculabo/AntigravityManager/compare/v0.3.4...v0.3.5) (2026-01-26)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- "Check Quota Now" button not refreshing UI after polling ([#42](https://github.com/Draculabo/AntigravityManager/issues/42)) ([e959ee3](https://github.com/Draculabo/AntigravityManager/commit/e959ee346e7c26a8a4c5b7deefa5bd2452153f9d))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- remove beta download links from README ([5a21680](https://github.com/Draculabo/AntigravityManager/commit/5a2168030eac4ddeffa1c3b002b2de48b6a11a8f))
|
||||
|
||||
## [0.3.4](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4) (2026-01-26)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([#43](https://github.com/Draculabo/AntigravityManager/issues/43)) ([0208058](https://github.com/Draculabo/AntigravityManager/commit/02080588b764ed88a5831152a3a1249f1d077d29))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- update beta release link ([d5ee08d](https://github.com/Draculabo/AntigravityManager/commit/d5ee08d5a06a915a8b82f680b38e2f532105498c))
|
||||
|
||||
## [0.3.4-beta.1](https://github.com/Draculabo/AntigravityManager/compare/v0.3.3...v0.3.4-beta.1) (2026-01-25)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **security:** add safeStorage fallback for production builds ([#38](https://github.com/Draculabo/AntigravityManager/issues/38)) ([92dc2f6](https://github.com/Draculabo/AntigravityManager/commit/92dc2f6f2169eb1a32950694387f2333ea2de682))
|
||||
|
||||
## [0.3.3](https://github.com/Draculabo/AntigravityManager/compare/v0.3.2...v0.3.3) (2026-01-25)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- accept lowercase antigravity in process detection ([0d4e2ab](https://github.com/Draculabo/AntigravityManager/commit/0d4e2ab21f37704e09ef1a67c181c48b42df1180))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- add beta download link to readme ([f15bb48](https://github.com/Draculabo/AntigravityManager/commit/f15bb48fdda10fda3c2382941ee0ce51204f750a))
|
||||
- clean up changelog duplicate ([22265e1](https://github.com/Draculabo/AntigravityManager/commit/22265e153c9d394229aa48afdc5948044b74e842))
|
||||
|
||||
## [0.3.2](https://github.com/Draculabo/AntigravityManager/compare/v0.3.1...v0.3.2) (2026-01-25)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- handle keychain hint and suppress pgrep spam ([bd3d41a](https://github.com/Draculabo/AntigravityManager/commit/bd3d41aed17bafe9d684c5c421bad8b90afa19a8))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- add macOS self-signing workaround for Keychain issues ([01e3f8f](https://github.com/Draculabo/AntigravityManager/commit/01e3f8f8fd6dacc5eed214ed4b505d6d85f4bcff))
|
||||
|
||||
### 🔧 Continuous Integration
|
||||
|
||||
- setup semantic release configuration and github actions workflow ([d2945a6](https://github.com/Draculabo/AntigravityManager/commit/d2945a6e8a14d75f577716183cdff093443d9636))
|
||||
- trigger publish on release published event ([6a07bc0](https://github.com/Draculabo/AntigravityManager/commit/6a07bc0a10a5ad802777e007cfd7390852119b15))
|
||||
|
||||
## [0.3.1] - 2026-01-25
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed startup race condition causing cloud accounts verify failure ([f0718db])
|
||||
- Enabled WAL mode and force initialization on startup to resolve process resource contention ([1bce5d3])
|
||||
|
||||
## [0.3.0] - 2026-01-23
|
||||
|
||||
### New Features
|
||||
|
||||
- Verify Google OAuth code automatically after receipt
|
||||
- Add button to open logs folder
|
||||
- Add expiration warning for Google OAuth authentication
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed `state.vscdb` path on Linux to include `User/globalStorage` subdirectory (Fixed [#26](https://github.com/Draculabo/AntigravityManager/issues/26))
|
||||
- Improved process detection on macOS/Linux using `find-process` to reliably identify the main application and exclude helper processes (Fixed [#27](https://github.com/Draculabo/AntigravityManager/issues/27))
|
||||
- Fixed keychain access error on macOS Apple Silicon (M1/M2/M3) by adding arm64 build to CI
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Add VS Code settings for auto-formatting and ESLint
|
||||
|
||||
## [0.2.2] - 2026-01-19
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed tray icon not appearing in production builds on Windows
|
||||
- Used `extraResource` config to properly copy assets outside of ASAR package
|
||||
- Added debug logging for tray icon path resolution
|
||||
|
||||
## [0.2.1] - 2026-01-19
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed process detection to be case-insensitive on Linux/macOS (`pgrep -xi`) ([#24](https://github.com/Draculabo/AntigravityManager/pull/24)) - Thanks [@Olbrasoft](https://github.com/Olbrasoft)!
|
||||
- Fixed manager exclusion logic to prevent accidental self-termination ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
|
||||
- Fixed zombie tray icons on application restart/hot reload ([#24](https://github.com/Draculabo/AntigravityManager/pull/24))
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Applied Prettier formatting to entire codebase (68 files)
|
||||
- Added node globals to ESLint configuration
|
||||
|
||||
## [0.2.0] - 2026-01-16
|
||||
|
||||
### New Features
|
||||
|
||||
- Enhanced cloudHandler to inject minimal auth state when database entry is missing, improving onboarding reliability.
|
||||
- Implemented stability fixes and enhanced error handling across the application.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Upgraded Electron from 32.3.3 to 37.3.1 for improved performance and security.
|
||||
- Conditionally include plugins based on start command in forge.config.ts for better build flexibility.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed "Converting circular structure to JSON" error.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Added curly brace constraints for conditional statements.
|
||||
- Fixed incorrect reference documentation name.
|
||||
|
||||
## [0.1.1] - 2026-01-11
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix Antigravity visibility issue on account switch. (Fixed [#19](https://github.com/Draculabo/AntigravityManager/issues/19))
|
||||
|
||||
## [0.1.0] - 2026-01-10
|
||||
|
||||
### New Features
|
||||
|
||||
- LAN Connection Support: Users can now connect via Local Area Network (LAN) for improved flexibility and internal environment support.
|
||||
- Antigravity Integration: Added native support and adaptation for Antigravity, enhancing overall compatibility.
|
||||
- Local API Proxy: Built-in OpenAI/Anthropic compatible proxy server.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Reverse Proxy Issue: Resolved a critical error occurring during reverse proxy configurations. (Fixed [#11](https://github.com/Draculabo/AntigravityManager/issues/11))
|
||||
|
||||
## [0.0.1] - 2025-12-22
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of Antigravity Manager
|
||||
- Multi-account management for Google Gemini and Claude
|
||||
- Real-time quota monitoring
|
||||
|
||||
+11
-10
@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
@@ -45,4 +45,5 @@ This Code of Conduct is adapted from the [Contributor Covenant][homepage], versi
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
---
|
||||
|
||||
**Note**: Please replace `[INSERT EMAIL ADDRESS]` with your actual contact email if you wish to receive reports.
|
||||
+14
-10
@@ -52,20 +52,21 @@ npm start
|
||||
|
||||
### 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 |
|
||||
| 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 |
|
||||
|
||||
## ✏️ Making Changes
|
||||
|
||||
1. **Create a new branch** from `main`:
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
@@ -73,6 +74,7 @@ npm start
|
||||
```
|
||||
|
||||
2. **Make your changes** and commit them:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add amazing new feature"
|
||||
@@ -97,6 +99,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Each com
|
||||
```
|
||||
|
||||
**Types:**
|
||||
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
- `docs`: Documentation only changes
|
||||
@@ -107,6 +110,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Each com
|
||||
- `chore`: Changes to build process or auxiliary tools
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
feat(auth): add Google OAuth support
|
||||
fix(quota): resolve quota refresh timeout issue
|
||||
|
||||
@@ -64,12 +64,14 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
- 🔄 Managing multiple Google/Claude accounts is cumbersome
|
||||
- 📊 Don't know how much quota is left on the current account
|
||||
- ⏰ 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:
|
||||
|
||||
- ✅ **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
|
||||
- ✅ **Real-time Monitoring** - Visualize quota usage for all accounts
|
||||
- ✅ **Local API Proxy** - Built-in OpenAI/Anthropic compatible proxy server
|
||||
- ✅ **Secure Encryption** - AES-256-GCM encryption for sensitive data
|
||||
|
||||
---
|
||||
@@ -131,6 +133,24 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔌 Local API Proxy</h3>
|
||||
<ul>
|
||||
<li>OpenAI & Anthropic API compatible</li>
|
||||
<li>Configurable port and request timeout</li>
|
||||
<li>Model mapping (e.g. Claude → Gemini)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🛠️ Developer Tools</h3>
|
||||
<ul>
|
||||
<li>Built-in cURL & Python code generation</li>
|
||||
<li>Visual service status monitoring</li>
|
||||
<li>One-click API Key regeneration</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Additional Features
|
||||
@@ -149,6 +169,11 @@ 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>
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -160,7 +185,7 @@ Download the latest release for your platform from the [Releases](https://github
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| Windows | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| 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) |
|
||||
|
||||
@@ -255,6 +280,24 @@ 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>
|
||||
|
||||
|
||||
+65
-22
@@ -64,12 +64,14 @@
|
||||
- 🔄 管理多个 Google/Claude 账号非常麻烦
|
||||
- 📊 不知道当前账号还剩多少额度
|
||||
- ⏰ 担心错过额度重置时间
|
||||
- 🔌 需要可靠的本地 API 代理用于开发工具
|
||||
|
||||
**Antigravity Manager** 就是为解决这些问题而生的!它是一个专业的 Electron 桌面应用,帮助你:
|
||||
|
||||
- ✅ **无限账号池** - 添加任意数量的 Google Gemini / Claude 账号
|
||||
- ✅ **智能自动切换** - 额度不足或被限速时自动切换到下一个可用账号
|
||||
- ✅ **实时监控** - 可视化显示所有账号的额度使用情况
|
||||
- ✅ **本地 API 代理** - 内置兼容 OpenAI/Anthropic 协议的代理服务器
|
||||
- ✅ **安全加密** - AES-256-GCM 加密存储敏感信息
|
||||
|
||||
---
|
||||
@@ -131,6 +133,24 @@
|
||||
</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>
|
||||
|
||||
### 更多功能
|
||||
@@ -149,6 +169,10 @@
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
@@ -158,11 +182,11 @@
|
||||
|
||||
从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。
|
||||
|
||||
| 平台 | 下载链接 |
|
||||
|------|----------|
|
||||
| Windows | [.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) |
|
||||
| 平台 | 下载链接 |
|
||||
| ------- | ------------------------------------------------------------------------------ |
|
||||
| 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) |
|
||||
|
||||
### 从源码构建
|
||||
|
||||
@@ -192,14 +216,14 @@ 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/) |
|
||||
| 类别 | 技术 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **核心** | [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/) |
|
||||
|
||||
---
|
||||
|
||||
@@ -207,16 +231,16 @@ npm run make
|
||||
|
||||
### 可用脚本
|
||||
|
||||
| 命令 | 描述 |
|
||||
|------|------|
|
||||
| `npm start` | 启动开发模式 |
|
||||
| `npm run lint` | 运行 ESLint 检查 |
|
||||
| 命令 | 描述 |
|
||||
| ---------------------- | ------------------------ |
|
||||
| `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` | 构建生产包 |
|
||||
| `npm run test:unit` | 运行单元测试 |
|
||||
| `npm run test:e2e` | 运行 E2E 测试 |
|
||||
| `npm run test:all` | 运行所有测试 |
|
||||
| `npm run type-check` | TypeScript 类型检查 |
|
||||
| `npm run make` | 构建生产包 |
|
||||
|
||||
### 项目结构
|
||||
|
||||
@@ -240,6 +264,7 @@ AntigravityManager/
|
||||
<summary><b>Q: 程序无法启动怎么办?</b></summary>
|
||||
|
||||
请检查:
|
||||
|
||||
1. 确保已安装所有依赖:`npm install`
|
||||
2. 检查 Node.js 版本是否 >= 18
|
||||
3. 尝试删除 `node_modules` 后重新安装
|
||||
@@ -255,6 +280,24 @@ AntigravityManager/
|
||||
|
||||
</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>
|
||||
|
||||
|
||||
+4
-4
@@ -26,10 +26,10 @@ Master keys are stored in the OS native credential manager via `keytar`, never i
|
||||
|
||||
## 📋 Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| Latest | :white_check_mark: |
|
||||
| < Latest | :x: |
|
||||
| Version | Supported |
|
||||
| -------- | ------------------ |
|
||||
| Latest | :white_check_mark: |
|
||||
| < Latest | :x: |
|
||||
|
||||
We only provide security updates for the latest version. Please ensure you're running the most recent release.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 467 KiB After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
+70
-60
@@ -1,86 +1,96 @@
|
||||
# 云端账号管理与智能切换 (Cloud Account Features)
|
||||
# Cloud Account Management and Intelligent Switching
|
||||
|
||||
本文档详细介绍了 Antigravity Manager 中实现的云端 AI 账号管理与切换功能。该功能允许用户管理多个 Google 账号,监控 API 配额,并一键无缝切换 IDE 的当前活动账号。
|
||||
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.
|
||||
|
||||
## 1. 核心功能
|
||||
## 1. Core Features
|
||||
|
||||
### 1.1 账号池管理
|
||||
- **添加账号**:支持通过 Google OAuth 授权码添加账号。
|
||||
- **列表展示**:展示所有已添加的账号,包括头像、邮箱、最后使用时间。
|
||||
- **状态监控**:实时显示账号状态(Active, Rate Limited, Expired)以及是否为当前活动账号。
|
||||
- **删除账号**:支持从本地数据库中移除账号。
|
||||
### 1.1 Account Pool Management
|
||||
|
||||
### 1.2 配额实时监控
|
||||
- **多模型支持**:分别监控 `gemini-pro`, `claude-3-5-sonnet` 等模型的配额使用情况。
|
||||
- **可视化展示**:通过进度条和颜色(绿/黄/红)直观展示剩余配额百分比。
|
||||
- **自动/手动刷新**:支持手动刷新配额,系统也会在切换前自动检查。
|
||||
- **Add accounts**: Add accounts via Google OAuth authorization code.
|
||||
- **List view**: Display all added accounts, including avatar, email, and last used time.
|
||||
- **Status monitoring**: Show real-time account status (Active, Rate Limited, Expired) and whether the account is currently active.
|
||||
- **Delete accounts**: Remove accounts from the local database.
|
||||
|
||||
### 1.4 智能自动切换 (Intelligent Auto-Switching)
|
||||
- **无限池模式**:当检测到当前账号配额不足(<5%)或被限流时,系统会自动寻找最佳备用账号并切换。
|
||||
- **后台监控**:内置 `CloudMonitorService`, 默认每 5 分钟轮询一次所有账号的 Quota 状态。
|
||||
- **全局开关**:用户可在界面上一键开启或关闭此功能。
|
||||
### 1.2 Real-Time Quota Monitoring
|
||||
|
||||
## 2. 技术实现
|
||||
- **Multi-model support**: Track quota usage for models such as `gemini-pro`, `claude-3-5-sonnet`, and more.
|
||||
- **Visual indicators**: Use progress bars and color states (green/yellow/red) to show remaining quota percentage.
|
||||
- **Auto/manual refresh**: Support manual quota refresh; the system also checks quota automatically before switching.
|
||||
|
||||
### 2.1 数据库设计 (`cloud_accounts.db`)
|
||||
使用 SQLite 存储账号信息,独立于 IDE 的本地数据库。
|
||||
### 1.4 Intelligent Auto-Switching
|
||||
|
||||
- **Unlimited pool mode**: When the current account quota is low (`<5%`) or rate-limited, the system automatically finds and switches to the best backup account.
|
||||
- **Background monitoring**: Built-in `CloudMonitorService` polls quota status for all accounts every 5 minutes by default.
|
||||
- **Global toggle**: Users can enable or disable this feature with one click in the UI.
|
||||
|
||||
## 2. Technical Implementation
|
||||
|
||||
### 2.1 Database Design (`cloud_accounts.db`)
|
||||
|
||||
Account information is stored in SQLite, independent from the IDE's local database.
|
||||
|
||||
```sql
|
||||
CREATE TABLE accounts (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
provider TEXT NOT NULL, -- 'google' | 'anthropic'
|
||||
email TEXT NOT NULL, -- 邮箱
|
||||
name TEXT, -- 显示名称
|
||||
avatar_url TEXT, -- 头像 URL
|
||||
token_json TEXT NOT NULL, -- OAuth Token (JSON)
|
||||
quota_json TEXT, -- 配额数据 (JSON)
|
||||
created_at INTEGER NOT NULL, -- 创建时间戳
|
||||
last_used INTEGER NOT NULL, -- 最后使用时间戳
|
||||
status TEXT DEFAULT 'active', -- 账号状态
|
||||
is_active INTEGER DEFAULT 0 -- 是否为当前 IDE 活动账号
|
||||
email TEXT NOT NULL, -- Email
|
||||
name TEXT, -- Display name
|
||||
avatar_url TEXT, -- Avatar URL
|
||||
token_json TEXT NOT NULL, -- OAuth token (JSON)
|
||||
quota_json TEXT, -- Quota data (JSON)
|
||||
created_at INTEGER NOT NULL, -- Created timestamp
|
||||
last_used INTEGER NOT NULL, -- Last-used timestamp
|
||||
status TEXT DEFAULT 'active', -- Account status
|
||||
is_active INTEGER DEFAULT 0 -- Whether this is the currently active IDE account
|
||||
);
|
||||
|
||||
-- 全局配置表
|
||||
-- Global settings table
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 2.2 IPC 接口设计
|
||||
后端通过 `src/ipc/cloud` 提供以下 oRPC 接口:
|
||||
### 2.2 IPC Interface Design
|
||||
|
||||
- `addGoogleAccount(authCode: string)`: 交换 Token 并保存账号。
|
||||
- `listCloudAccounts()`: 获取账号列表。
|
||||
- `refreshAccountQuota(accountId: string)`: 刷新指定账号的配额和 Token。
|
||||
- `switchCloudAccount(accountId: string)`: 执行账号切换流程。
|
||||
- `deleteCloudAccount(accountId: string)`: 删除账号。
|
||||
- `getAutoSwitchEnabled()` / `setAutoSwitchEnabled(enabled)`: 获取/设置自动切换开关。
|
||||
- `forcePollCloudMonitor()`: 手动触发后台轮询。
|
||||
The backend exposes the following oRPC interfaces via `src/ipc/cloud`:
|
||||
|
||||
### 2.3 Token 注入原理 (`ProtobufUtils`)
|
||||
IDE 将鉴权信息存储在 `state.vscdb` 的 `ItemTable` 表中,key 为 `jetskiStateSync.agentManagerInitState`。
|
||||
value 是一个 Base64 编码的 Protobuf 二进制数据。
|
||||
- `addGoogleAccount(authCode: string)`: Exchange token and save account.
|
||||
- `listCloudAccounts()`: Get account list.
|
||||
- `refreshAccountQuota(accountId: string)`: Refresh quota and token for a specific account.
|
||||
- `switchCloudAccount(accountId: string)`: Run the account switch workflow.
|
||||
- `deleteCloudAccount(accountId: string)`: Delete an account.
|
||||
- `getAutoSwitchEnabled()` / `setAutoSwitchEnabled(enabled)`: Get/set auto-switch state.
|
||||
- `forcePollCloudMonitor()`: Manually trigger background polling.
|
||||
|
||||
我们实现了 `src/utils/protobuf.ts` 工具类,能够:
|
||||
1. **解码**:解析 Varint 和 Length-Delimited 字段。
|
||||
2. **修改**:定位并移除旧的 Field 6 (OAuthTokenInfo)。
|
||||
3. **重构**:根据新的 Access Token 和 Refresh Token 构建新的 Field 6 并插入。
|
||||
### 2.3 Token Injection Mechanism (`ProtobufUtils`)
|
||||
|
||||
### 2.4 切换流程
|
||||
1. **Token 检查**:检查目标账号 Token 是否临期,如需刷新则自动刷新。
|
||||
2. **停止进程**:优雅关闭 Antigravity 进程。
|
||||
3. **注入 Token**:调用 `CloudAccountRepo.injectCloudToken` 修改 IDE 数据库。
|
||||
4. **更新状态**:将目标账号标记为 `is_active = 1`,其他账号设为 0。
|
||||
5. **重启进程**:重新启动 IDE,使其加载新的凭证。
|
||||
The IDE stores authentication data in the `ItemTable` table of `state.vscdb`, under the key `jetskiStateSync.agentManagerInitState`.
|
||||
The value is Base64-encoded Protobuf binary.
|
||||
|
||||
### 2.5 自动切换逻辑 (`AutoSwitchService`)
|
||||
1. **监控**:`CloudMonitorService` 定时(5分钟)轮询所有账号配额。
|
||||
2. **判断**:如果当前活动账号的所有关键模型配额低于 5%,或状态为 `rate_limited`。
|
||||
3. **择优**:从账号池中筛选状态为 `active` 且配额充足的账号,按配额剩余量排序。
|
||||
4. **执行**:自动调用 `switchCloudAccount` 完成切换,并通知用户。
|
||||
We implemented `src/utils/protobuf.ts`, which can:
|
||||
|
||||
### 2.6 安全加固 (Security Hardening)
|
||||
- **密钥管理**:使用操作系统原生凭据管理器(Windows Credential Manager / macOS Keychain)通过 `keytar` 库安全存储 Master Key(AES-256)。
|
||||
- **数据加密**:所有敏感数据(`token_json`, `quota_json`)在存入 SQLite 数据库前均使用 `AES-256-GCM` 算法加密。
|
||||
- **自动迁移**:应用启动时会自动检测并迁移旧的明文数据,确保安全性平滑升级。
|
||||
1. **Decode**: Parse Varint and length-delimited fields.
|
||||
2. **Modify**: Locate and remove old Field 6 (`OAuthTokenInfo`).
|
||||
3. **Rebuild**: Build a new Field 6 using new access/refresh tokens and insert it.
|
||||
|
||||
### 2.4 Switching Workflow
|
||||
|
||||
1. **Token check**: Check whether the target account token is near expiration; refresh automatically if needed.
|
||||
2. **Stop process**: Gracefully stop the Antigravity process.
|
||||
3. **Inject token**: Call `CloudAccountRepo.injectCloudToken` to update the IDE database.
|
||||
4. **Update state**: Mark target account as `is_active = 1`, set others to `0`.
|
||||
5. **Restart process**: Restart the IDE so it loads new credentials.
|
||||
|
||||
### 2.5 Auto-Switch Logic (`AutoSwitchService`)
|
||||
|
||||
1. **Monitor**: `CloudMonitorService` polls quotas for all accounts every 5 minutes.
|
||||
2. **Evaluate**: If all key model quotas of the active account are below `5%`, or status is `rate_limited`.
|
||||
3. **Select**: Filter accounts with `active` status and sufficient quota, then sort by remaining quota.
|
||||
4. **Execute**: Automatically call `switchCloudAccount` and notify the user.
|
||||
|
||||
### 2.6 Security Hardening
|
||||
|
||||
- **Key management**: Use native OS credential stores (Windows Credential Manager / macOS Keychain) via `keytar` to securely store the AES-256 master key.
|
||||
- **Data encryption**: Encrypt all sensitive fields (`token_json`, `quota_json`) with `AES-256-GCM` before writing to SQLite.
|
||||
- **Auto migration**: Automatically detect and migrate legacy plaintext data at startup to ensure a smooth security upgrade.
|
||||
+24
-14
@@ -1,28 +1,38 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
import eslintPluginPrettierRecommended from "eslint-config-prettier";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import path from "node:path";
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import globals from 'globals';
|
||||
import pluginJs from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import eslintPluginPrettierRecommended from 'eslint-config-prettier';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import path from 'node:path';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const prettierIgnorePath = path.resolve(__dirname, ".prettierignore");
|
||||
const prettierIgnorePath = path.resolve(__dirname, '.prettierignore');
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default defineConfig([
|
||||
includeIgnoreFile(prettierIgnorePath),
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
||||
files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'],
|
||||
},
|
||||
{ languageOptions: { globals: globals.browser } },
|
||||
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
|
||||
pluginJs.configs.recommended,
|
||||
pluginReact.configs.flat["jsx-runtime"],
|
||||
pluginReact.configs.flat['jsx-runtime'],
|
||||
reactHooks.configs.flat.recommended,
|
||||
eslintPluginPrettierRecommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'no-empty-pattern': 'off',
|
||||
'prefer-const': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
+436
-20
@@ -1,16 +1,203 @@
|
||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
||||
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import crypto from 'crypto';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import type {
|
||||
HookFunction,
|
||||
HookFunctionErrorCallback,
|
||||
TargetArch,
|
||||
TargetPlatform,
|
||||
} from '@electron/packager';
|
||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||
import { MakerDMG } from '@electron-forge/maker-dmg';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
||||
import { MakerWix } from '@electron-forge/maker-wix';
|
||||
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||
import MakerAppImage from '@pengx17/electron-forge-maker-appimage';
|
||||
import setLanguages from 'electron-packager-languages';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
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 isStartCommand = process.argv.some((arg) => arg.includes('start'));
|
||||
|
||||
const artifactRegex = /.*\.(?:exe|dmg|AppImage|zip|deb|rpm|msi)$/;
|
||||
const platformNamesMap: Record<string, string> = {
|
||||
darwin: 'macos',
|
||||
linux: 'linux',
|
||||
win32: 'windows',
|
||||
};
|
||||
const ymlBaseNameMap: Record<string, string> = {
|
||||
darwin: 'latest-mac',
|
||||
linux: 'latest-linux',
|
||||
win32: 'latest',
|
||||
};
|
||||
const ignorePatterns = [
|
||||
/^\/\.git/,
|
||||
/^\/\.github/,
|
||||
/^\/\.vscode/,
|
||||
/^\/\.idea/,
|
||||
/^\/openspec/,
|
||||
/^\/docs?/,
|
||||
/^\/scripts?/,
|
||||
/^\/tests?/,
|
||||
/^\/mocks?/,
|
||||
/^\/src/,
|
||||
/^\/node_modules\/\.cache/,
|
||||
];
|
||||
const setLanguagesHook = setLanguages([...keepLanguages.values()]);
|
||||
const packagerAfterCopy: HookFunction[] = [
|
||||
(
|
||||
buildPath: string,
|
||||
electronVersion: string,
|
||||
platform: TargetPlatform,
|
||||
arch: TargetArch,
|
||||
callback: HookFunctionErrorCallback,
|
||||
) => {
|
||||
if (platform !== 'win32') {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
setLanguagesHook(buildPath, electronVersion, platform, arch, callback);
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeArtifactName(value?: string) {
|
||||
if (!value) {
|
||||
return 'app';
|
||||
}
|
||||
|
||||
return value
|
||||
.trim()
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/[^a-zA-Z0-9.]/g, '')
|
||||
.replace(/\.+/g, '.');
|
||||
}
|
||||
|
||||
function isSquirrelArtifact(artifactPath: string) {
|
||||
const fileName = path.basename(artifactPath);
|
||||
if (fileName === 'RELEASES') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return artifactPath.endsWith('.nupkg');
|
||||
}
|
||||
|
||||
function mapArchName(arch: string, mapping: Record<string, string>) {
|
||||
return mapping[arch] || arch;
|
||||
}
|
||||
|
||||
function getArtifactFileName({
|
||||
baseName,
|
||||
version,
|
||||
arch,
|
||||
extension,
|
||||
}: {
|
||||
baseName: string;
|
||||
version: string;
|
||||
arch: string;
|
||||
extension: string;
|
||||
}) {
|
||||
if (extension === '.rpm') {
|
||||
return `${baseName}-${version}-1.${arch}${extension}`;
|
||||
}
|
||||
|
||||
if (extension === '.deb') {
|
||||
return `${baseName}_${version}_${arch}${extension}`;
|
||||
}
|
||||
|
||||
if (extension === '.AppImage') {
|
||||
return `${baseName}_${version}_${arch}${extension}`;
|
||||
}
|
||||
|
||||
if (extension === '.dmg') {
|
||||
return `${baseName}_${version}_${arch}${extension}`;
|
||||
}
|
||||
|
||||
if (extension === '.exe') {
|
||||
return `${baseName}_${version}_${arch}-setup${extension}`;
|
||||
}
|
||||
|
||||
if (extension === '.msi') {
|
||||
return `${baseName}_${version}_${arch}_en-US${extension}`;
|
||||
}
|
||||
|
||||
if (extension === '.zip') {
|
||||
return `${baseName}_${version}_${arch}${extension}`;
|
||||
}
|
||||
|
||||
return `${baseName}_${version}_${arch}${extension}`;
|
||||
}
|
||||
|
||||
function getUpdateYmlFileName(platform: string, arch: string) {
|
||||
const baseName = ymlBaseNameMap[platform];
|
||||
if (!baseName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return arch === 'universal' ? `${baseName}.yml` : `${baseName}-${arch}.yml`;
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
return arch === 'x64' ? `${baseName}.yml` : `${baseName}-${arch}.yml`;
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
return arch === 'x64' ? `${baseName}.yml` : `${baseName}-${arch}.yml`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getChecksumArchLabel(platform: string, arch: string) {
|
||||
if (platform === 'linux') {
|
||||
return mapArchName(arch, { x64: 'amd64', arm64: 'aarch64' });
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return mapArchName(arch, { x64: 'x64', arm64: 'arm64', universal: 'universal' });
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
return mapArchName(arch, { x64: 'x64', arm64: 'arm64' });
|
||||
}
|
||||
|
||||
return arch;
|
||||
}
|
||||
|
||||
const appImageMaker = new ResolvedMakerAppImage({
|
||||
config: {
|
||||
icons: [
|
||||
{
|
||||
file: 'images/32x32.png',
|
||||
size: 32,
|
||||
},
|
||||
{
|
||||
file: 'images/64x64.png',
|
||||
size: 64,
|
||||
},
|
||||
{
|
||||
file: 'images/128x128.png',
|
||||
size: 128,
|
||||
},
|
||||
{
|
||||
file: 'images/[email protected]',
|
||||
size: 256,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
appImageMaker.name = '@pengx17/electron-forge-maker-appimage';
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
@@ -18,8 +205,12 @@ const config: ForgeConfig = {
|
||||
unpack: '**/{better-sqlite3,keytar}/**/*',
|
||||
},
|
||||
name: 'Antigravity Manager',
|
||||
executableName: 'antigravity-manager',
|
||||
executableName: windowsExecutableName,
|
||||
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
|
||||
extraResource: ['src/assets'], // Copy assets folder to resources/assets
|
||||
afterCopy: packagerAfterCopy,
|
||||
ignore: ignorePatterns,
|
||||
prune: true,
|
||||
},
|
||||
rebuildConfig: {},
|
||||
hooks: {
|
||||
@@ -60,8 +251,230 @@ const config: ForgeConfig = {
|
||||
console.warn(`Assets directory not found: ${assetsSrc}`);
|
||||
}
|
||||
},
|
||||
postMake: async (_config, makeResults) => {
|
||||
if (!makeResults?.length) {
|
||||
return makeResults;
|
||||
}
|
||||
|
||||
const ymlByTarget = new Map<
|
||||
string,
|
||||
{
|
||||
basePath: string;
|
||||
fileName: string;
|
||||
yml: {
|
||||
version?: string;
|
||||
files: {
|
||||
url: string;
|
||||
sha512: string;
|
||||
size: number;
|
||||
}[];
|
||||
releaseDate?: string;
|
||||
};
|
||||
}
|
||||
>();
|
||||
const checksumByTarget = new Map<
|
||||
string,
|
||||
{
|
||||
basePath: string;
|
||||
fileName: string;
|
||||
lines: string[];
|
||||
}
|
||||
>();
|
||||
|
||||
makeResults = makeResults.map((result) => {
|
||||
const productName = normalizeArtifactName(
|
||||
result.packageJSON.productName || result.packageJSON.name,
|
||||
);
|
||||
const platformName = platformNamesMap[result.platform] || result.platform;
|
||||
const version = result.packageJSON.version;
|
||||
const platformKey = result.platform;
|
||||
const archKey = result.arch;
|
||||
const updateFileName = getUpdateYmlFileName(platformKey, archKey);
|
||||
const updateKey = updateFileName ? `${platformKey}-${archKey}` : null;
|
||||
const checksumKey = `${platformKey}-${archKey}`;
|
||||
const checksumArchLabel = getChecksumArchLabel(platformKey, archKey);
|
||||
const checksumFileName = `sha256sums-${platformName}-${checksumArchLabel}.txt`;
|
||||
|
||||
if (!checksumByTarget.has(checksumKey)) {
|
||||
checksumByTarget.set(checksumKey, {
|
||||
basePath: '',
|
||||
fileName: checksumFileName,
|
||||
lines: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (updateFileName && updateKey && !ymlByTarget.has(updateKey)) {
|
||||
ymlByTarget.set(updateKey, {
|
||||
basePath: '',
|
||||
fileName: updateFileName,
|
||||
yml: {
|
||||
version,
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updateState = updateKey ? ymlByTarget.get(updateKey)! : null;
|
||||
const checksumState = checksumByTarget.get(checksumKey)!;
|
||||
|
||||
result.artifacts = result.artifacts
|
||||
.map((artifact) => {
|
||||
if (!artifact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSquirrelArtifact(artifact)) {
|
||||
return artifact;
|
||||
}
|
||||
|
||||
if (!artifactRegex.test(artifact)) {
|
||||
return artifact;
|
||||
}
|
||||
|
||||
if (!checksumState.basePath) {
|
||||
checksumState.basePath = path.dirname(artifact);
|
||||
}
|
||||
|
||||
if (updateState && !updateState.basePath) {
|
||||
updateState.basePath = path.dirname(artifact);
|
||||
}
|
||||
|
||||
const extension = path.extname(artifact);
|
||||
let archLabel = archKey;
|
||||
if (platformKey === 'linux' && extension === '.rpm') {
|
||||
archLabel = mapArchName(archKey, { x64: 'x86_64', arm64: 'aarch64' });
|
||||
} else if (platformKey === 'linux' && extension === '.deb') {
|
||||
archLabel = mapArchName(archKey, { x64: 'amd64', arm64: 'arm64' });
|
||||
} else if (platformKey === 'linux' && extension === '.AppImage') {
|
||||
archLabel = mapArchName(archKey, { x64: 'amd64', arm64: 'aarch64' });
|
||||
} else if (platformKey === 'darwin') {
|
||||
archLabel = mapArchName(archKey, {
|
||||
x64: 'x64',
|
||||
arm64: 'arm64',
|
||||
universal: 'universal',
|
||||
});
|
||||
} else if (platformKey === 'win32') {
|
||||
archLabel = mapArchName(archKey, { x64: 'x64', arm64: 'arm64' });
|
||||
}
|
||||
|
||||
const newArtifact = `${path.dirname(artifact)}/${getArtifactFileName({
|
||||
baseName: productName,
|
||||
version,
|
||||
arch: archLabel,
|
||||
extension,
|
||||
})}`;
|
||||
if (newArtifact !== artifact) {
|
||||
fs.renameSync(artifact, newArtifact);
|
||||
}
|
||||
|
||||
try {
|
||||
const fileData = fs.readFileSync(newArtifact);
|
||||
const hash = crypto.createHash('sha512').update(fileData).digest('base64');
|
||||
const sha256 = crypto.createHash('sha256').update(fileData).digest('hex');
|
||||
const { size } = fs.statSync(newArtifact);
|
||||
|
||||
if (updateState) {
|
||||
updateState.yml.files.push({
|
||||
url: path.basename(newArtifact),
|
||||
sha512: hash,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
checksumState.lines.push(`${sha256} ${path.basename(newArtifact)}`);
|
||||
} catch {
|
||||
console.error(`Failed to hash ${newArtifact}`);
|
||||
}
|
||||
|
||||
return newArtifact;
|
||||
})
|
||||
.filter((artifact) => artifact !== null);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const releaseDate = new Date().toISOString();
|
||||
for (const [updateKey, updateState] of ymlByTarget.entries()) {
|
||||
if (!updateState.basePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updateState.yml.releaseDate = releaseDate;
|
||||
const ymlPath = path.join(updateState.basePath, updateState.fileName);
|
||||
fs.writeFileSync(ymlPath, yamlStringify(updateState.yml));
|
||||
|
||||
const [platform, arch] = updateKey.split('-');
|
||||
const sampleResult = makeResults.find(
|
||||
(result) => result.platform === platform && result.arch === arch,
|
||||
);
|
||||
if (!sampleResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
makeResults.push({
|
||||
artifacts: [ymlPath],
|
||||
platform: sampleResult.platform,
|
||||
arch: sampleResult.arch,
|
||||
packageJSON: sampleResult.packageJSON,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [checksumKey, checksumState] of checksumByTarget.entries()) {
|
||||
if (!checksumState.basePath || checksumState.lines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const checksumPath = path.join(checksumState.basePath, checksumState.fileName);
|
||||
fs.writeFileSync(checksumPath, `${checksumState.lines.join('\n')}\n`);
|
||||
|
||||
const [platform, arch] = checksumKey.split('-');
|
||||
const sampleResult = makeResults.find(
|
||||
(result) => result.platform === platform && result.arch === arch,
|
||||
);
|
||||
if (!sampleResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
makeResults.push({
|
||||
artifacts: [checksumPath],
|
||||
platform: sampleResult.platform,
|
||||
arch: sampleResult.arch,
|
||||
packageJSON: sampleResult.packageJSON,
|
||||
});
|
||||
}
|
||||
|
||||
return makeResults;
|
||||
},
|
||||
},
|
||||
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
|
||||
makers: [
|
||||
new MakerSquirrel({
|
||||
setupIcon: 'images/icon.ico',
|
||||
iconUrl:
|
||||
'https://raw.githubusercontent.com/Draculabo/AntigravityManager/main/images/icon.ico',
|
||||
}),
|
||||
...(process.platform === 'win32' && process.arch === 'x64'
|
||||
? [
|
||||
new MakerWix({
|
||||
language: 1033,
|
||||
icon: path.join(process.cwd(), 'images', 'icon.ico'),
|
||||
exe: `${windowsExecutableName}.exe`,
|
||||
ui: { chooseDirectory: true },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
new MakerDMG(
|
||||
{
|
||||
overwrite: true,
|
||||
icon: 'images/icon.icns',
|
||||
iconSize: 160,
|
||||
},
|
||||
['darwin'],
|
||||
),
|
||||
new MakerZIP({}, ['darwin']),
|
||||
appImageMaker,
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({}),
|
||||
],
|
||||
publishers: [
|
||||
{
|
||||
/*
|
||||
@@ -80,7 +493,6 @@ const config: ForgeConfig = {
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
new AutoUnpackNativesPlugin({}),
|
||||
new VitePlugin({
|
||||
build: [
|
||||
{
|
||||
@@ -101,16 +513,20 @@ const config: ForgeConfig = {
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
...(!isStartCommand
|
||||
? [
|
||||
new AutoUnpackNativesPlugin({}),
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
# 设计:多语言支持
|
||||
# Design: Multilingual Support
|
||||
|
||||
## 架构
|
||||
## Architecture
|
||||
|
||||
应用使用 `react-i18next` 进行国际化,并配合 `i18next-browser-languagedetector` 进行语言检测。配置集中在 `src/localization/i18n.ts` 中。
|
||||
The application uses `react-i18next` for i18n and `i18next-browser-languagedetector` for language detection. Configuration is centralized in `src/localization/i18n.ts`.
|
||||
|
||||
### 语言资源
|
||||
### Language Resources
|
||||
|
||||
翻译资源将作为 JSON 对象构建在 `i18n.ts` 中(如果太大则分文件)。
|
||||
我们将在 `resources` 对象中添加 `zh-CN` 键。
|
||||
Translation resources are built as JSON objects in `i18n.ts` (or split into files if they grow large).
|
||||
We add a `zh-CN` key in the `resources` object.
|
||||
|
||||
### 默认语言策略
|
||||
### Default Language Strategy
|
||||
|
||||
我们将引入 `LanguageDetector` 插件。
|
||||
`i18n.use(LanguageDetector).init({...})` 将被配置为首先检查 `localStorage`,然后检查 `navigator`(浏览器/系统语言)。
|
||||
`fallbackLng` 将保持为 `'en'`,作为最后的兜底。
|
||||
We introduce the `LanguageDetector` plugin.
|
||||
`i18n.use(LanguageDetector).init({...})` is configured to check `localStorage` first, then `navigator` (browser/system language).
|
||||
`fallbackLng` remains `'en'` as the final fallback.
|
||||
|
||||
### 键映射
|
||||
### Key Mapping
|
||||
|
||||
我们将把现有的英文字符串映射到语义键(例如 `home.title`, `settings.theme`)。
|
||||
Existing English strings are mapped to semantic keys (for example, `home.title`, `settings.theme`).
|
||||
|
||||
## 数据流
|
||||
## Data Flow
|
||||
|
||||
1. 应用启动。
|
||||
2. `i18next` 初始化,通过 `LanguageDetector` 确定语言:
|
||||
* 检查 `localStorage` 是否有保存的偏好。
|
||||
* 如果无,检查 `navigator.language`。
|
||||
* 如果检测到的语言受支持(`en` 或 `zh-CN`),则使用该语言。
|
||||
* 否则,使用 `fallbackLng` (`en`)。
|
||||
3. 组件使用 `useTranslation` hook 根据当前语言获取字符串。
|
||||
1. App starts.
|
||||
2. `i18next` initializes and determines language via `LanguageDetector`:
|
||||
* Check whether `localStorage` has a saved preference.
|
||||
* If not, check `navigator.language`.
|
||||
* If detected language is supported (`en` or `zh-CN`), use it.
|
||||
* Otherwise, use `fallbackLng` (`en`).
|
||||
3. Components use `useTranslation` to resolve strings for the active language.
|
||||
@@ -1,41 +1,41 @@
|
||||
# 提案:应用本地化(自动检测系统语言)
|
||||
# Proposal: App Localization (Automatic System Language Detection)
|
||||
|
||||
**变更 ID:** `localize-app-auto-detect`
|
||||
**Change ID:** `localize-app-auto-detect`
|
||||
|
||||
## 摘要
|
||||
## Summary
|
||||
|
||||
为 Antigravity Manager 应用添加多语言支持,并根据用户的操作系统语言自动设置默认语言。如果系统语言不受支持,则回退到英语。这包括更新 i18n 配置,添加中文翻译资源,并集成语言检测插件。
|
||||
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.
|
||||
|
||||
## 动机
|
||||
## Motivation
|
||||
|
||||
为了提供最佳的用户体验,应用应该能够适应用户的首选语言环境。自动检测系统语言可以减少用户的配置步骤,提供更自然的初次使用体验。
|
||||
To provide a better user experience, the app should adapt to users' preferred language environment. Automatically detecting system language reduces setup effort and improves first-run experience.
|
||||
|
||||
## 建议变更
|
||||
## Proposed Changes
|
||||
|
||||
1. **集成语言检测:** 安装并配置 `i18next-browser-languagedetector` 以自动检测用户语言。
|
||||
2. **更新 i18n 配置:** 修改 `src/localization/i18n.ts`,启用语言检测,并设置适当的回退语言(英语)。
|
||||
3. **添加中文资源:** 为 `zh-CN` 创建一个新的翻译资源文件,包含所有应用字符串。
|
||||
4. **更新语言操作:** 确保 `src/actions/language.ts` 正确处理自动检测和手动覆盖的逻辑。
|
||||
5. **UI 更新:** 确保所有 UI 组件使用翻译键而不是硬编码字符串。
|
||||
1. **Integrate language detection:** Install and configure `i18next-browser-languagedetector` to auto-detect user language.
|
||||
2. **Update i18n config:** Modify `src/localization/i18n.ts` to enable language detection and define a fallback language (English).
|
||||
3. **Add Chinese resources:** Create `zh-CN` translation resources covering all app strings.
|
||||
4. **Update language actions:** Ensure `src/actions/language.ts` correctly handles auto-detection and manual override.
|
||||
5. **UI updates:** Ensure all UI components use translation keys instead of hardcoded text.
|
||||
|
||||
## 考虑的替代方案
|
||||
## Alternatives Considered
|
||||
|
||||
* **强制默认中文:** 已拒绝,为了适应更广泛的国际用户群体,并尊重用户的系统设置。
|
||||
* **强制默认英语:** 已拒绝,同上。
|
||||
* **Force default Chinese:** Rejected, to support a broader international user base and respect system settings.
|
||||
* **Force default English:** Rejected, same reason.
|
||||
|
||||
## 风险
|
||||
## Risks
|
||||
|
||||
* **检测失败:** 在某些特殊环境(如 Linux 某些发行版)下,语言检测可能不准确,需要确保回退机制可靠。
|
||||
* **Detection failure:** In certain environments (for example, some Linux distros), language detection may be inaccurate. Fallback behavior must remain reliable.
|
||||
|
||||
## 验收标准
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **自动检测:**
|
||||
* 在系统语言设置为中文(简体)的环境下启动应用,应用界面默认显示为中文。
|
||||
* 在系统语言设置为英语或其他非中文语言的环境下启动应用,应用界面默认显示为英语。
|
||||
2. **手动切换:**
|
||||
* 用户可以在设置页面将语言从中文切换到英语,界面应立即更新,无需重启。
|
||||
* 用户可以在设置页面将语言从英语切换到中文,界面应立即更新,无需重启。
|
||||
3. **持久化:**
|
||||
* 用户手动更改语言后,重启应用,应用应保持用户选择的语言,而不是重新检测系统语言。
|
||||
4. **完整性:**
|
||||
* 应用的所有可见文本(包括主页、设置页、状态栏、弹窗提示)都应正确显示对应的语言,没有遗漏的硬编码字符串。
|
||||
1. **Automatic detection:**
|
||||
* When OS language is Simplified Chinese, app defaults to Chinese on first launch.
|
||||
* When OS language is English or another non-Chinese language, app defaults to English on first launch.
|
||||
2. **Manual switching:**
|
||||
* Users can switch from Chinese to English in Settings, and UI updates immediately without restart.
|
||||
* Users can switch from English to Chinese in Settings, and UI updates immediately without restart.
|
||||
3. **Persistence:**
|
||||
* After users manually change language, app restart preserves the selected language instead of re-detecting.
|
||||
4. **Completeness:**
|
||||
* All visible strings (home page, settings page, status bar, popups) render correctly in the selected language with no leftover hardcoded text.
|
||||
@@ -1,59 +1,59 @@
|
||||
# 规格:本地化
|
||||
# Spec: Localization
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 自动语言检测
|
||||
### Requirement: Automatic Language Detection
|
||||
|
||||
应用在首次启动时 MUST 自动检测用户的系统语言并设置相应的界面语言。
|
||||
On first launch, the application MUST automatically detect the user's system language and set the corresponding UI language.
|
||||
|
||||
#### Scenario: 系统语言为中文
|
||||
#### Scenario: System language is Chinese
|
||||
|
||||
Given 用户首次打开应用
|
||||
And 用户的系统语言设置为中文 (zh-CN)
|
||||
And 本地存储中没有保存语言偏好
|
||||
When 应用加载时
|
||||
Then 界面应该显示为简体中文 (`zh-CN`)
|
||||
Given the user opens the app for the first time
|
||||
And the user's system language is set to Chinese (`zh-CN`)
|
||||
And no language preference is saved in local storage
|
||||
When the app loads
|
||||
Then the UI should be displayed in Simplified Chinese (`zh-CN`)
|
||||
|
||||
#### Scenario: 系统语言为其他(非中文)
|
||||
#### Scenario: System language is non-Chinese
|
||||
|
||||
Given 用户首次打开应用
|
||||
And 用户的系统语言设置为法语 (fr)
|
||||
And 本地存储中没有保存语言偏好
|
||||
When 应用加载时
|
||||
Then 界面应该回退显示为英语 (`en`)
|
||||
Given the user opens the app for the first time
|
||||
And the user's system language is set to French (`fr`)
|
||||
And no language preference is saved in local storage
|
||||
When the app loads
|
||||
Then the UI should fall back to English (`en`)
|
||||
|
||||
### Requirement: 语言切换
|
||||
### Requirement: Language Switching
|
||||
|
||||
用户 MUST 能够在支持的语言之间切换,且手动选择 MUST 覆盖自动检测。
|
||||
Users MUST be able to switch between supported languages, and manual selection MUST override automatic detection.
|
||||
|
||||
#### Scenario: 切换语言
|
||||
#### Scenario: Switch language
|
||||
|
||||
Given 用户在设置页面
|
||||
When 用户从语言下拉菜单中选择 "English"
|
||||
Then 界面应该立即切换为英语
|
||||
And 偏好应该保存到本地存储
|
||||
Given the user is on the settings page
|
||||
When the user selects "English" from the language dropdown
|
||||
Then the UI should switch to English immediately
|
||||
And the preference should be saved to local storage
|
||||
|
||||
### Requirement: 持久化
|
||||
### Requirement: Persistence
|
||||
|
||||
用户的手动语言偏好 MUST 在会话之间持久化,并优先于系统语言。
|
||||
The user's manual language preference MUST persist across sessions and take precedence over system language.
|
||||
|
||||
#### Scenario: 持久化语言偏好
|
||||
#### Scenario: Persist language preference
|
||||
|
||||
Given 用户已手动选择 "English" 作为首选语言
|
||||
And 用户的系统语言设置为中文
|
||||
When 用户重启应用
|
||||
Then 界面应该显示为英语(尊重用户选择)
|
||||
Given the user has manually selected "English" as preferred language
|
||||
And the user's system language is Chinese
|
||||
When the user restarts the app
|
||||
Then the UI should display English (respecting user preference)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: i18n 配置更新
|
||||
### Requirement: Update i18n Configuration
|
||||
|
||||
i18n 配置 MUST 更新以支持语言检测插件。
|
||||
i18n configuration MUST be updated to support the language detection plugin.
|
||||
|
||||
#### Scenario: i18n 配置
|
||||
#### Scenario: i18n configuration
|
||||
|
||||
`src/localization/i18n.ts` 文件必须更新为:
|
||||
`src/localization/i18n.ts` must be updated to:
|
||||
|
||||
1. 集成 `i18next-browser-languagedetector`。
|
||||
2. 配置检测顺序:`['localStorage', 'navigator']`。
|
||||
3. 包含 `zh-CN` 资源对象。
|
||||
1. Integrate `i18next-browser-languagedetector`.
|
||||
2. Configure detection order as `['localStorage', 'navigator']`.
|
||||
3. Include `zh-CN` resource objects.
|
||||
@@ -1,10 +1,10 @@
|
||||
# 任务:应用本地化(自动检测系统语言)
|
||||
# Tasks: App Localization (Automatic System Language Detection)
|
||||
|
||||
* \[x] 安装 `i18next-browser-languagedetector` 依赖。 <!-- id: install-dependency -->
|
||||
* \[x] 更新 `src/localization/i18n.ts` 以配置语言检测器并包含 `zh-CN` 资源。 <!-- id: update-i18n-config -->
|
||||
* \[x] 验证 `src/actions/language.ts` 逻辑,确保与自动检测兼容。 <!-- id: verify-language-actions -->
|
||||
* \[x] 更新 `src/components/StatusBar.tsx` 以使用翻译。 <!-- id: localize-statusbar -->
|
||||
* \[x] 更新 `src/components/AccountCard.tsx` 以使用翻译。 <!-- id: localize-accountcard -->
|
||||
* \[x] 更新 `src/pages/index.tsx`(主页)以使用翻译。 <!-- id: localize-homepage -->
|
||||
* \[x] 更新 `src/pages/settings.tsx` 以使用翻译并添加语言切换器。 <!-- id: localize-settings -->
|
||||
* \[x] 运行应用验证语言检测和手动切换功能。 <!-- id: verify-localization -->
|
||||
* \[x] Install `i18next-browser-languagedetector`. <!-- id: install-dependency -->
|
||||
* \[x] Update `src/localization/i18n.ts` to configure language detection and include `zh-CN` resources. <!-- id: update-i18n-config -->
|
||||
* \[x] Verify `src/actions/language.ts` logic is compatible with auto-detection. <!-- id: verify-language-actions -->
|
||||
* \[x] Update `src/components/StatusBar.tsx` to use translations. <!-- id: localize-statusbar -->
|
||||
* \[x] Update `src/components/AccountCard.tsx` to use translations. <!-- id: localize-accountcard -->
|
||||
* \[x] Update `src/pages/index.tsx` (home page) to use translations. <!-- id: localize-homepage -->
|
||||
* \[x] Update `src/pages/settings.tsx` to use translations and add a language switcher. <!-- id: localize-settings -->
|
||||
* \[x] Run the app to verify language detection and manual switching. <!-- id: verify-localization -->
|
||||
+99
-96
@@ -2,105 +2,108 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Antigravity Manager 是一个综合性的 Antigravity 增强工具,旨在打破 AI 开发的资源限制。它不仅解决了 Antigravity IDE 客户端无法原生支持多账号切换的痛点,还引入了企业级的 AI 服务账号池管理功能。通过接管应用的配置状态和云端 API 资源,它允许用户在无限个本地账号和云端 AI 服务之间无缝切换,实现 "无限" 的 AI 调用体验。
|
||||
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.
|
||||
|
||||
**核心目标:**
|
||||
**Core goals:**
|
||||
|
||||
* **本地多账号切换**:无缝切换 Antigravity IDE 的本地用户状态,保护数据完整性(自动备份)。
|
||||
* **AI 资源池管理**(新):集中管理 Google Gemini、Claude 3.5 等 AI 服务的账号池。
|
||||
* **智能配额监控**(新):实时监控 API 配额(Quota)和健康度,提供可视化仪表盘。
|
||||
* **自动资源调度**(新):当当前账号配额耗尽时,自动切换到最佳可用账号(Token 保活与轮询)。
|
||||
* **跨平台支持**:统一的 macOS、Windows、Linux 体验。
|
||||
* **Local multi-account switching**: Seamlessly switch Antigravity 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).
|
||||
* **Cross-platform support**: Consistent macOS, Windows, and Linux experience.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
* **Electron 39** - 跨平台桌面应用框架
|
||||
* **React 19.2** - UI 框架
|
||||
* **TypeScript 5.9** - 类型安全的开发语言
|
||||
* **Shadcn UI** - 现代化 UI 组件库
|
||||
* **Tailwind CSS 4** - 样式框架
|
||||
* **oRPC** - 类型安全的 IPC 通信
|
||||
* **TanStack Router** - 文件路由
|
||||
* **React Query** - 服务器状态管理
|
||||
* **better-sqlite3** - SQLite 数据库访问
|
||||
* **Vite** - 构建工具
|
||||
* **Vitest** - 单元测试
|
||||
* **Playwright** - 端到端测试
|
||||
* **Electron Forge** - 应用打包和分发
|
||||
* **Electron 39** - Cross-platform desktop framework
|
||||
* **React 19.2** - UI framework
|
||||
* **TypeScript 5.9** - Type-safe development language
|
||||
* **Shadcn UI** - Modern UI component library
|
||||
* **Tailwind CSS 4** - Styling framework
|
||||
* **oRPC** - Type-safe IPC communication
|
||||
* **TanStack Router** - File-based routing
|
||||
* **React Query** - Server state management
|
||||
* **better-sqlite3** - SQLite data access
|
||||
* **Vite** - Build tool
|
||||
* **Vitest** - Unit testing
|
||||
* **Playwright** - End-to-end testing
|
||||
* **Electron Forge** - Packaging and distribution
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Code Style
|
||||
|
||||
* TypeScript strict mode
|
||||
* ESLint + Prettier 自动格式化
|
||||
* 函数和变量使用 camelCase
|
||||
* 组件使用 PascalCase
|
||||
* 接口使用 PascalCase,以 I 开头(可选)
|
||||
* 类型使用 PascalCase
|
||||
* 2 空格缩进
|
||||
* 单引号字符串
|
||||
* 尾随逗号
|
||||
* TypeScript strict mode
|
||||
* ESLint + Prettier auto-formatting
|
||||
* Use camelCase for functions and variables
|
||||
* Use PascalCase for components
|
||||
* Use PascalCase for interfaces, optionally prefixed with `I`
|
||||
* Use PascalCase for types
|
||||
* 2-space indentation
|
||||
* Single-quoted strings
|
||||
* Trailing commas
|
||||
|
||||
### Architecture Patterns
|
||||
|
||||
```
|
||||
```plaintext
|
||||
src/
|
||||
├── main.ts # Electron 主进程
|
||||
├── preload.ts # 预加载脚本
|
||||
├── renderer.ts # 渲染进程入口
|
||||
├── ipc/ # IPC 通信层
|
||||
│ ├── account/ # 本地 IDE 账号管理 IPC
|
||||
│ ├── cloud/ # 云端 AI 资源管理 IPC(新)
|
||||
│ ├── database/ # 数据库操作 IPC
|
||||
│ └── process/ # 进程管理 IPC
|
||||
├── services/ # 业务逻辑服务层(新)
|
||||
│ ├── QuotaService.ts # 配额监控服务
|
||||
│ └── TokenService.ts # Token 保活服务
|
||||
├── actions/ # 渲染进程操作(oRPC 客户端)
|
||||
├── components/ # React 组件
|
||||
├── routes/ # 页面路由
|
||||
├── layouts/ # 布局组件
|
||||
├── utils/ # 工具函数
|
||||
├── types/ # TypeScript 类型定义
|
||||
└── tests/ # 测试文件
|
||||
├── main.ts # Electron main process
|
||||
├── preload.ts # Preload script
|
||||
├── renderer.ts # Renderer entry
|
||||
├── ipc/ # IPC communication layer
|
||||
│ ├── account/ # Local IDE account IPC
|
||||
│ ├── cloud/ # Cloud AI resource IPC (new)
|
||||
│ ├── database/ # Database operation IPC
|
||||
│ └── process/ # Process management IPC
|
||||
├── services/ # Business service layer (new)
|
||||
│ ├── QuotaService.ts # Quota monitoring service
|
||||
│ └── TokenService.ts # Token keepalive service
|
||||
├── actions/ # Renderer actions (oRPC client)
|
||||
├── components/ # React components
|
||||
├── routes/ # Page routes
|
||||
├── layouts/ # Layout components
|
||||
├── utils/ # Utility functions
|
||||
├── types/ # TypeScript type definitions
|
||||
└── tests/ # Test files
|
||||
```
|
||||
|
||||
**设计模式:**
|
||||
**Design patterns:**
|
||||
|
||||
* **关注点分离**:主进程处理系统操作和网络代理,渲染进程处理 UI。
|
||||
* **Service 层**:引入 Service 层处理复杂的配额轮询和 Token 刷新逻辑。
|
||||
* **类型安全 IPC**:使用 oRPC 确保主进程和渲染进程之间的类型安全通信。
|
||||
* **状态管理**:React Query 管理服务器状态和异步配额数据。
|
||||
* **Separation of concerns**: Main process handles system operations and network proxy; renderer handles UI.
|
||||
* **Service layer**: Encapsulates complex quota polling and token refresh logic.
|
||||
* **Type-safe IPC**: Uses oRPC to enforce type-safe communication between main and renderer.
|
||||
* **State management**: React Query manages server state and async quota data.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
* **单元测试(Vitest)**:测试 IPC 处理程序、Service 逻辑、工具函数。
|
||||
* **集成测试(Vitest)**:测试完整的 IPC 流程。
|
||||
* **端到端测试(Playwright)**:测试用户工作流。
|
||||
* **覆盖率目标**:核心逻辑 >80%。
|
||||
* **Unit tests (Vitest)**: IPC handlers, service logic, utility functions.
|
||||
* **Integration tests (Vitest)**: End-to-end IPC flow.
|
||||
* **End-to-end tests (Playwright)**: User workflows.
|
||||
* **Coverage target**: Core logic > 80%.
|
||||
|
||||
### Git Workflow
|
||||
|
||||
* **主分支**:`main`
|
||||
* **开发分支**:`develop`
|
||||
* **提交规范**:语义化提交 (feat, fix, docs, refactor, test, chore)
|
||||
* **Main branch**: `main`
|
||||
* **Development branch**: `develop`
|
||||
* **Commit style**: Conventional commits (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`)
|
||||
|
||||
## Domain Context
|
||||
|
||||
### 1. Antigravity IDE 数据结构(本地)
|
||||
### 1. Antigravity 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/state.vscdb`
|
||||
* Linux: `~/.config/Antigravity/User/globalStorage/state.vscdb`
|
||||
|
||||
**关键数据库键:**
|
||||
* `antigravityAuthStatus`: 认证状态和用户信息。
|
||||
**Key database fields:**
|
||||
|
||||
### 2. AI 服务资源结构(云端/新)
|
||||
* `antigravityAuthStatus`: Authentication status and user information.
|
||||
|
||||
### 2. AI Service Resource Structure (Cloud / New)
|
||||
|
||||
**Account model:**
|
||||
|
||||
**账号模型:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
@@ -115,49 +118,49 @@ src/
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 进程管理策略
|
||||
### 3. Process Management Strategy
|
||||
|
||||
* **三阶段关闭**:优雅关闭 -> SIGTERM -> SIGKILL。
|
||||
* **Three-phase shutdown**: Graceful close -> SIGTERM -> SIGKILL.
|
||||
|
||||
## Important Constraints
|
||||
|
||||
### 技术约束
|
||||
### Technical Constraints
|
||||
|
||||
* **数据库访问**:访问本地 IDE 数据库时需注意文件锁。
|
||||
* **网络访问**:需要访问外部 API (Google/Anthropic) 以获取配额信息。
|
||||
* **凭证安全**:OAuth Token 和 Refresh Token 必须使用系统级加密存储 (Keytar / SQLite Encrypted)。
|
||||
* **并发控制**:轮询配额时需注意频率,避免触发 API 封控。
|
||||
* **Database access**: Handle file locks when accessing local IDE databases.
|
||||
* **Network access**: External API access (Google/Anthropic) is required for quota retrieval.
|
||||
* **Credential security**: OAuth and refresh tokens must use system-level encrypted storage (Keytar / encrypted SQLite).
|
||||
* **Concurrency control**: Quota polling frequency must be controlled to avoid API throttling or blocking.
|
||||
|
||||
### 用户体验约束
|
||||
### UX Constraints
|
||||
|
||||
* **响应时间**:账号切换应在 5 秒内完成。
|
||||
* **静默运行**:支持最小化到托盘,后台自动刷新 Token。
|
||||
* **数据安全**:切换前必须自动备份当前状态。
|
||||
* **Response time**: Account switching should complete within 5 seconds.
|
||||
* **Silent mode**: Support tray minimization and background token refresh.
|
||||
* **Data safety**: Always back up current state before switching.
|
||||
|
||||
## External Dependencies
|
||||
|
||||
### 系统依赖
|
||||
### System Dependencies
|
||||
|
||||
* **Antigravity IDE**:本地管理目标。
|
||||
* **SQLite**:本地数据读取。
|
||||
* **Antigravity IDE**: Local target to manage.
|
||||
* **SQLite**: Local data reads.
|
||||
|
||||
### Node.js 依赖(重点)
|
||||
### Node.js Dependencies (Key)
|
||||
|
||||
* `better-sqlite3`: 数据库访问。
|
||||
* `electron`: 桌面框架。
|
||||
* `node-fetch` / `axios`: 网络请求。
|
||||
* `tar` / `adm-zip`: 备份压缩。
|
||||
* `better-sqlite3`: Database access.
|
||||
* `electron`: Desktop framework.
|
||||
* `node-fetch` / `axios`: Network requests.
|
||||
* `tar` / `adm-zip`: Backup compression.
|
||||
|
||||
### 外部服务
|
||||
### External Services
|
||||
|
||||
* **Google Gemini API**: 用于配额检查。
|
||||
* **Anthropic API**: 用于配额检查。
|
||||
* **OAuth 2.0 端点**: 用于账号授权。
|
||||
* **Google Gemini API**: For quota checks.
|
||||
* **Anthropic API**: For quota checks.
|
||||
* **OAuth 2.0 endpoints**: For account authorization.
|
||||
|
||||
### 文件系统依赖
|
||||
### Filesystem Dependencies
|
||||
|
||||
* `~/.antigravity-agent/`
|
||||
* `accounts.json`: 本地 IDE 账号索引。
|
||||
* `cloud_accounts.db`: (新) 云端账号池数据库 (SQLite)。
|
||||
* `backups/`: 备份文件。
|
||||
* `app.log`: 应用日志。
|
||||
* `~/.antigravity-agent/`
|
||||
* `accounts.json`: Local IDE account index.
|
||||
* `cloud_accounts.db`: (new) Cloud account pool database (SQLite).
|
||||
* `backups/`: Backup files.
|
||||
* `app.log`: Application log.
|
||||
Generated
+10368
-305
File diff suppressed because it is too large.
Load diff
+24
-11
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"productName": "Antigravity Manager",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Antigravity Manager - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
@@ -18,16 +18,17 @@
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:all": "vitest run && playwright test",
|
||||
"test-db": "electron test-db.js",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"author": "Draculabo",
|
||||
"license": "MIT",
|
||||
"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",
|
||||
@@ -38,7 +39,12 @@
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.0",
|
||||
"@pengx17/electron-forge-maker-appimage": "^1.2.1",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^12.0.2",
|
||||
"@sentry/vite-plugin": "^4.8.0",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@tanstack/router-plugin": "^1.135.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -47,14 +53,17 @@
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/eslint-config-prettier": "^6.11.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"electron": "^32.3.3",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"electron": "^37.3.1",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-packager-languages": "^0.6.0",
|
||||
"electron-playwright-helpers": "^1.8.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -67,6 +76,8 @@
|
||||
"node-addon-api": "^8.3.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"semantic-release": "^25.0.2",
|
||||
"semantic-release-config-gitmoji": "^1.5.3",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"ts-node": "^10.9.2",
|
||||
@@ -85,7 +96,6 @@
|
||||
"@nestjs/microservices": "^11.1.11",
|
||||
"@nestjs/platform-express": "^11.1.11",
|
||||
"@nestjs/platform-fastify": "^11.1.10",
|
||||
"@nestjs/websockets": "^11.1.11",
|
||||
"@orpc/client": "^1.10.4",
|
||||
"@orpc/server": "^1.11.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -102,6 +112,9 @@
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/electron": "^7.7.0",
|
||||
"@sentry/integrations": "^7.114.0",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-router": "^1.139.3",
|
||||
@@ -114,12 +127,15 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.39.1",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"fastify": "^5.6.2",
|
||||
"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",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -131,12 +147,9 @@
|
||||
"undici": "^7.16.0",
|
||||
"update-electron-app": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"ts-node": {
|
||||
"transpileOnly": true,
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./src/tests/e2e",
|
||||
testDir: './src/tests/e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
reporter: 'html',
|
||||
use: {
|
||||
trace: "on-first-retry",
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Semantic Release Configuration
|
||||
*
|
||||
* This configuration uses the specific release rules requested by the user,
|
||||
* but adapts the plugin configuration to work with standard Conventional Commits
|
||||
* instead of Gitmoji (since the project history doesn't use emojis).
|
||||
*/
|
||||
|
||||
const REPO_URL = 'https://github.com/Draculabo/AntigravityManager';
|
||||
const GITHUB_BASE_URL = 'https://github.com';
|
||||
const FULL_CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
|
||||
|
||||
const releaseRules = [
|
||||
{
|
||||
release: 'minor',
|
||||
type: 'feat',
|
||||
},
|
||||
{
|
||||
release: 'patch',
|
||||
type: 'fix',
|
||||
},
|
||||
{
|
||||
release: 'patch',
|
||||
type: 'perf',
|
||||
},
|
||||
{
|
||||
release: 'patch',
|
||||
type: 'style',
|
||||
},
|
||||
{
|
||||
release: 'patch',
|
||||
type: 'refactor',
|
||||
},
|
||||
{
|
||||
release: 'patch',
|
||||
type: 'build',
|
||||
},
|
||||
{ release: 'patch', scope: 'README', type: 'docs' },
|
||||
{ release: 'patch', scope: 'README.md', type: 'docs' },
|
||||
{ release: false, type: 'docs' },
|
||||
{
|
||||
release: false,
|
||||
type: 'test',
|
||||
},
|
||||
{
|
||||
release: false,
|
||||
type: 'ci',
|
||||
},
|
||||
{
|
||||
release: false,
|
||||
type: 'chore',
|
||||
},
|
||||
{
|
||||
release: false,
|
||||
type: 'wip',
|
||||
},
|
||||
{
|
||||
release: 'major',
|
||||
type: 'BREAKING CHANGE',
|
||||
},
|
||||
{
|
||||
release: 'major',
|
||||
scope: 'BREAKING CHANGE',
|
||||
},
|
||||
{
|
||||
release: 'major',
|
||||
subject: '*BREAKING CHANGE*',
|
||||
},
|
||||
{ release: 'patch', subject: '*force release*' },
|
||||
{ release: 'patch', subject: '*force patch*' },
|
||||
{ release: 'minor', subject: '*force minor*' },
|
||||
{ release: 'major', subject: '*force major*' },
|
||||
{ release: false, subject: '*skip release*' },
|
||||
];
|
||||
|
||||
const getGithubUsernameFromEmail = (email) => {
|
||||
if (!email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = email.match(/^(?:\d+\+)?(?<username>[a-z0-9-]+)@users\.noreply\.github\.com$/i);
|
||||
|
||||
return match?.groups?.username;
|
||||
};
|
||||
|
||||
const isBotIdentity = (value) => {
|
||||
return typeof value === 'string' && /bot/i.test(value);
|
||||
};
|
||||
|
||||
const buildReleaseFooter = (commits) => {
|
||||
const mergedPullRequests = new Map();
|
||||
const contributors = new Map();
|
||||
|
||||
(commits || []).forEach((commit) => {
|
||||
const message = [commit?.message, commit?.subject, commit?.header].filter(Boolean).join('\n');
|
||||
if (message) {
|
||||
const mergeMatches = message.match(/pull request #(?<number>\d+)/gi);
|
||||
if (mergeMatches) {
|
||||
mergeMatches.forEach((match) => {
|
||||
const number = match.replace(/\D/g, '');
|
||||
if (number) {
|
||||
mergedPullRequests.set(number, {
|
||||
number,
|
||||
url: `${REPO_URL}/pull/${number}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const squashMatches = message.match(/\(#(?<number>\d+)\)/g);
|
||||
if (squashMatches) {
|
||||
squashMatches.forEach((match) => {
|
||||
const number = match.replace(/\D/g, '');
|
||||
if (number) {
|
||||
mergedPullRequests.set(number, {
|
||||
number,
|
||||
url: `${REPO_URL}/pull/${number}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const author = commit?.author || commit?.committer;
|
||||
const username = getGithubUsernameFromEmail(author?.email);
|
||||
const displayName = username ? `@${username}` : author?.name;
|
||||
|
||||
if (!displayName || isBotIdentity(displayName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (username && isBotIdentity(username)) {
|
||||
return;
|
||||
}
|
||||
|
||||
contributors.set(username || displayName, {
|
||||
name: displayName,
|
||||
url: username ? `${GITHUB_BASE_URL}/${username}` : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const footerLines = [];
|
||||
const sortedPullRequests = Array.from(mergedPullRequests.values()).sort(
|
||||
(left, right) => Number(left.number) - Number(right.number),
|
||||
);
|
||||
if (sortedPullRequests.length > 0) {
|
||||
footerLines.push('### 🔀 Merged Pull Requests');
|
||||
sortedPullRequests.forEach((pullRequest) => {
|
||||
footerLines.push(`- [#${pullRequest.number}](${pullRequest.url})`);
|
||||
});
|
||||
footerLines.push('');
|
||||
}
|
||||
|
||||
const sortedContributors = Array.from(contributors.values()).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
if (sortedContributors.length > 0) {
|
||||
footerLines.push('### 🙌 Contributors');
|
||||
sortedContributors.forEach((contributor) => {
|
||||
if (contributor.url) {
|
||||
footerLines.push(`- [${contributor.name}](${contributor.url})`);
|
||||
return;
|
||||
}
|
||||
|
||||
footerLines.push(`- ${contributor.name}`);
|
||||
});
|
||||
footerLines.push('');
|
||||
}
|
||||
|
||||
footerLines.push(`Full Changelog: ${FULL_CHANGELOG_URL}`);
|
||||
|
||||
return footerLines.join('\n');
|
||||
};
|
||||
|
||||
const appendReleaseNotes = (notes, commits) => {
|
||||
const trimmedNotes = (notes || '').trimEnd();
|
||||
const footer = buildReleaseFooter(commits);
|
||||
|
||||
if (!footer) {
|
||||
return trimmedNotes;
|
||||
}
|
||||
|
||||
return `${trimmedNotes}\n\n${footer}`;
|
||||
};
|
||||
|
||||
const appendReleaseNotesPlugin = {
|
||||
publish: async (pluginConfig, context) => {
|
||||
if (!context?.nextRelease?.notes) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.nextRelease.notes = appendReleaseNotes(context.nextRelease.notes, context.commits);
|
||||
},
|
||||
};
|
||||
|
||||
const releaseConfig = {
|
||||
branches: ['main', { name: 'beta', prerelease: true }],
|
||||
plugins: [
|
||||
[
|
||||
'@semantic-release/commit-analyzer',
|
||||
{
|
||||
preset: 'conventionalcommits',
|
||||
releaseRules: releaseRules,
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/release-notes-generator',
|
||||
{
|
||||
preset: 'conventionalcommits',
|
||||
presetConfig: {
|
||||
types: [
|
||||
{ type: 'feat', section: '✨ Features' },
|
||||
{ type: 'fix', section: '🐛 Bug Fixes' },
|
||||
{ type: 'perf', section: '⚡ Performance Improvements' },
|
||||
{ type: 'revert', section: '⏪ Reverts' },
|
||||
{ type: 'docs', section: '📝 Documentation' },
|
||||
{ type: 'style', section: '💄 Styles' },
|
||||
{ type: 'refactor', section: '♻️ Code Refactoring' },
|
||||
{ type: 'test', section: '✅ Tests' },
|
||||
{ type: 'build', section: '👷 Build System' },
|
||||
{ type: 'ci', section: '🔧 Continuous Integration' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/changelog',
|
||||
{
|
||||
changelogFile: 'CHANGELOG.md',
|
||||
changelogTitle: '<a name="readme-top"></a>\n\n# Changelog',
|
||||
},
|
||||
],
|
||||
'@semantic-release/npm', // Updates package.json and npm-shrinkwrap.json
|
||||
appendReleaseNotesPlugin,
|
||||
[
|
||||
'@semantic-release/github',
|
||||
{
|
||||
successComment: false,
|
||||
failComment: false,
|
||||
labels: false,
|
||||
releaseName: 'v${nextRelease.version}',
|
||||
},
|
||||
],
|
||||
[
|
||||
'@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}',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
releaseConfig.__internal = {
|
||||
appendReleaseNotes,
|
||||
buildReleaseFooter,
|
||||
};
|
||||
|
||||
module.exports = releaseConfig;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipc } from "@/ipc/manager";
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
export function listAccounts() {
|
||||
return ipc.client.account.listAccounts();
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { ipc } from "@/ipc/manager";
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
export function getPlatform() {
|
||||
return ipc.client.app.currentPlatfom();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ipc } from "@/ipc/manager";
|
||||
import { AccountBackupData, Account } from "@/types/account";
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { AccountBackupData, Account } from '@/types/account';
|
||||
|
||||
export function backupAccount(account: Account) {
|
||||
return ipc.client.database.backupAccount(account);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
export function openLogDirectory() {
|
||||
return ipc.client.system.openLogDirectory();
|
||||
}
|
||||
+9
-11
@@ -1,6 +1,6 @@
|
||||
import { ThemeMode } from "@/types/theme-mode";
|
||||
import { ipc } from "@/ipc/manager";
|
||||
import { LOCAL_STORAGE_KEYS } from "@/constants";
|
||||
import { ThemeMode } from '@/types/theme-mode';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { LOCAL_STORAGE_KEYS } from '@/constants';
|
||||
|
||||
export interface ThemePreferences {
|
||||
system: ThemeMode;
|
||||
@@ -9,9 +9,7 @@ export interface ThemePreferences {
|
||||
|
||||
export async function getCurrentTheme(): Promise<ThemePreferences> {
|
||||
const currentTheme = await ipc.client.theme.getCurrentThemeMode();
|
||||
const localTheme = localStorage.getItem(
|
||||
LOCAL_STORAGE_KEYS.THEME,
|
||||
) as ThemeMode | null;
|
||||
const localTheme = localStorage.getItem(LOCAL_STORAGE_KEYS.THEME) as ThemeMode | null;
|
||||
|
||||
return {
|
||||
system: currentTheme,
|
||||
@@ -20,7 +18,7 @@ export async function getCurrentTheme(): Promise<ThemePreferences> {
|
||||
}
|
||||
|
||||
export async function setTheme(newTheme: ThemeMode) {
|
||||
const isDarkMode = newTheme === "dark";
|
||||
const isDarkMode = newTheme === 'dark';
|
||||
await ipc.client.theme.setThemeMode(newTheme);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
|
||||
updateDocumentTheme(isDarkMode);
|
||||
@@ -28,7 +26,7 @@ export async function setTheme(newTheme: ThemeMode) {
|
||||
|
||||
export async function toggleTheme() {
|
||||
const isDarkMode = await ipc.client.theme.toggleThemeMode();
|
||||
const newTheme = isDarkMode ? "dark" : "light";
|
||||
const newTheme = isDarkMode ? 'dark' : 'light';
|
||||
|
||||
updateDocumentTheme(isDarkMode);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
|
||||
@@ -37,7 +35,7 @@ export async function toggleTheme() {
|
||||
export async function syncWithLocalTheme() {
|
||||
const { local } = await getCurrentTheme();
|
||||
if (!local) {
|
||||
setTheme("system");
|
||||
setTheme('system');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,8 +44,8 @@ export async function syncWithLocalTheme() {
|
||||
|
||||
function updateDocumentTheme(isDarkMode: boolean) {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipc } from "../ipc/manager";
|
||||
import { ipc } from '../ipc/manager';
|
||||
|
||||
export async function minimizeWindow() {
|
||||
await ipc.client.window.minimizeWindow();
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} 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 { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -32,7 +33,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
isSwitching,
|
||||
isDeleting,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [transform, setTransform] = useState('');
|
||||
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 });
|
||||
@@ -136,6 +137,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,
|
||||
}),
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -50,11 +50,64 @@ export function CloudAccountCard({
|
||||
|
||||
// Helpers to get quota color
|
||||
const getQuotaColor = (percentage: number) => {
|
||||
if (percentage > 80) return 'text-green-500';
|
||||
if (percentage > 20) return 'text-yellow-500';
|
||||
if (percentage > 80) {
|
||||
return 'text-green-500';
|
||||
}
|
||||
if (percentage > 20) {
|
||||
return 'text-yellow-500';
|
||||
}
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
const 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';
|
||||
}
|
||||
|
||||
// Calculate difference in hours and minutes
|
||||
const diffHrs = differenceInHours(targetDate, now);
|
||||
const diffMins = differenceInMinutes(targetDate, now) - diffHrs * 60;
|
||||
if (diffHrs >= 24) {
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
const remainingHrs = diffHrs % 24;
|
||||
return `${diffDays}d ${remainingHrs}h`;
|
||||
}
|
||||
|
||||
return `${diffHrs}h ${diffMins}m`;
|
||||
};
|
||||
|
||||
const getResetTimeLabel = (resetTime?: string) => {
|
||||
if (!resetTime) {
|
||||
return t('cloud.card.resetUnknown');
|
||||
}
|
||||
|
||||
const remaining = formatTimeRemaining(resetTime);
|
||||
if (!remaining) {
|
||||
return t('cloud.card.resetUnknown');
|
||||
}
|
||||
|
||||
return `${t('cloud.card.resetPrefix')}: ${remaining}`;
|
||||
};
|
||||
|
||||
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 modelQuotas = Object.entries(account.quota?.models || {});
|
||||
|
||||
return (
|
||||
@@ -169,15 +222,30 @@ export function CloudAccountCard({
|
||||
<div className="space-y-2">
|
||||
{modelQuotas.length > 0 ? (
|
||||
modelQuotas.map(([modelName, info]) => (
|
||||
<div key={modelName} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground max-w-[120px] truncate" title={modelName}>
|
||||
<div
|
||||
key={modelName}
|
||||
className="hover:bg-muted/40 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-1.5 py-1 text-sm transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground min-w-0 truncate" title={modelName}>
|
||||
{modelName.replace('models/', '')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono font-bold ${getQuotaColor(info.percentage)}`}>
|
||||
{info.percentage}%
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className="text-muted-foreground text-[10px] leading-none"
|
||||
title={getResetTimeTitle(info.resetTime)}
|
||||
>
|
||||
{getResetTimeLabel(info.resetTime)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{t('cloud.card.left')}</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={`font-mono leading-none font-bold ${getQuotaColor(info.percentage)}`}
|
||||
>
|
||||
{info.percentage}%
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{t('cloud.card.left')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -41,12 +41,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||
|
||||
// ... (existing code: imports and comments)
|
||||
|
||||
export function CloudAccountList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: accounts, isLoading, isError } = useCloudAccounts();
|
||||
const { data: accounts, isLoading, isError, error } = useCloudAccounts();
|
||||
const refreshMutation = useRefreshQuota();
|
||||
const deleteMutation = useDeleteCloudAccount();
|
||||
const addMutation = useAddGoogleAccount();
|
||||
@@ -62,19 +63,49 @@ export function CloudAccountList() {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [authCode, setAuthCode] = useState('');
|
||||
|
||||
const handleAddAccount = (codeVal?: string) => {
|
||||
const codeToUse = codeVal || authCode;
|
||||
if (!codeToUse) {
|
||||
return;
|
||||
}
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
// Listen for Google Auth Code
|
||||
useEffect(() => {
|
||||
if (window.electron?.onGoogleAuthCode) {
|
||||
console.log('[OAuth] Setting up auth code listener, dialog open:', isAddDialogOpen);
|
||||
const cleanup = window.electron.onGoogleAuthCode((code) => {
|
||||
console.log('[OAuth] Received auth code via IPC:', code?.substring(0, 10) + '...');
|
||||
setAuthCode(code);
|
||||
// Auto submit if dialog is open
|
||||
if (isAddDialogOpen) {
|
||||
handleAddAccount(code);
|
||||
}
|
||||
// Note: Auto-submit will be triggered by the authCode change effect below
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
}, [isAddDialogOpen]);
|
||||
}, []);
|
||||
|
||||
// Auto-submit when authCode is set and dialog is open
|
||||
useEffect(() => {
|
||||
if (authCode && isAddDialogOpen && !addMutation.isPending) {
|
||||
console.log('[OAuth] Auto-submitting auth code');
|
||||
handleAddAccount(authCode);
|
||||
}
|
||||
}, [authCode, isAddDialogOpen]);
|
||||
|
||||
// Batch Operations State
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -103,7 +134,7 @@ export function CloudAccountList() {
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: t('cloud.toast.switchFailed'),
|
||||
description: err.message,
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
}),
|
||||
},
|
||||
@@ -145,8 +176,15 @@ export function CloudAccountList() {
|
||||
};
|
||||
|
||||
const handleForcePoll = () => {
|
||||
if (forcePollMutation.isPending) return;
|
||||
forcePollMutation.mutate(undefined, {
|
||||
onSuccess: () => toast({ title: t('cloud.polling') }),
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: t('cloud.toast.pollFailed'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -169,35 +207,13 @@ export function CloudAccountList() {
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.syncFailed.title'),
|
||||
description: err.message,
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddAccount = (codeVal?: string) => {
|
||||
const codeToUse = codeVal || authCode;
|
||||
if (!codeToUse) return;
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openAuthUrl = async () => {
|
||||
try {
|
||||
await startAuthFlow();
|
||||
@@ -264,7 +280,13 @@ export function CloudAccountList() {
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div className="p-4 text-red-500">Failed to load cloud accounts.</div>;
|
||||
const errorMessage = getLocalizedErrorMessage(error, t);
|
||||
return (
|
||||
<div className="p-4 text-red-500">
|
||||
<div>{t('cloud.error.loadFailed')}</div>
|
||||
<div className="mt-1 text-sm text-red-400">{errorMessage}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -372,7 +394,7 @@ export function CloudAccountList() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accounts?.map((account) => (
|
||||
<CloudAccountCard
|
||||
key={account.id}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import React from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
isProcessRunning,
|
||||
startAntigravity,
|
||||
closeAntigravity,
|
||||
} from "@/actions/process";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Play, Square, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Play, Square, Loader2, Power } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
export const StatusBar: React.FC = () => {
|
||||
interface StatusBarProps {
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: isRunning, isLoading } = useQuery({
|
||||
queryKey: ["process", "status"],
|
||||
queryKey: ['process', 'status'],
|
||||
queryFn: isProcessRunning,
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
@@ -23,14 +24,14 @@ export const StatusBar: React.FC = () => {
|
||||
const startMutation = useMutation({
|
||||
mutationFn: startAntigravity,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: closeAntigravity,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,30 +45,78 @@ export const StatusBar: React.FC = () => {
|
||||
|
||||
const isPending = startMutation.isPending || stopMutation.isPending;
|
||||
|
||||
// Collapsed View
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="flex justify-center py-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || isPending}
|
||||
className={cn(
|
||||
'h-10 w-10 rounded-lg transition-all duration-300',
|
||||
isRunning
|
||||
? 'bg-green-100/50 text-green-700 hover:bg-green-100 hover:text-green-800 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
||||
: 'bg-red-100/50 text-red-700 hover:bg-red-100 hover:text-red-800 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50',
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Power className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn('h-2 w-2 rounded-full', isRunning ? 'bg-green-500' : 'bg-red-500')}
|
||||
/>
|
||||
<p>
|
||||
{isLoading
|
||||
? t('status.checking')
|
||||
: isRunning
|
||||
? t('status.running')
|
||||
: t('status.stopped')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded View
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-2 text-sm font-medium transition-colors",
|
||||
'flex items-center justify-between overflow-hidden rounded-lg px-3 py-2.5 text-sm font-medium ring-1 transition-colors ring-inset',
|
||||
isRunning
|
||||
? "bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-100"
|
||||
: "bg-red-100 text-red-900 dark:bg-red-900/30 dark:text-red-100",
|
||||
? 'bg-green-100 text-green-900 ring-green-200 dark:bg-green-900/20 dark:text-green-100 dark:ring-green-900/50'
|
||||
: 'bg-red-100 text-red-900 ring-red-200 dark:bg-red-900/20 dark:text-red-100 dark:ring-red-900/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : isRunning ? (
|
||||
<div className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
|
||||
) : (
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" />
|
||||
)}
|
||||
<span>
|
||||
{isLoading
|
||||
? t("status.checking")
|
||||
: isRunning
|
||||
? t("status.running")
|
||||
: t("status.stopped")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 leading-none">
|
||||
<span className="text-xs font-semibold tracking-wider uppercase opacity-80">Status</span>
|
||||
<span className="text-sm leading-tight font-medium">
|
||||
{isLoading
|
||||
? t('status.checking')
|
||||
: isRunning
|
||||
? t('status.running')
|
||||
: t('status.stopped')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -75,18 +124,22 @@ export const StatusBar: React.FC = () => {
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || isPending}
|
||||
className={cn(
|
||||
"h-8 px-3 hover:bg-white/20",
|
||||
isRunning ? "hover:text-green-900" : "hover:text-red-900",
|
||||
'ml-2 h-8 shrink-0 rounded-md border px-3 transition-all',
|
||||
isRunning
|
||||
? 'border-green-200 bg-green-200/50 text-green-900 hover:bg-green-200 dark:border-green-800 dark:bg-green-800/30 dark:text-green-100 dark:hover:bg-green-800/50'
|
||||
: 'border-red-200 bg-red-200/50 text-red-900 hover:bg-red-200 dark:border-red-800 dark:bg-red-800/30 dark:text-red-100 dark:hover:bg-red-800/50',
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : isRunning ? (
|
||||
<Square className="mr-2 h-4 w-4 fill-current" />
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4 fill-current" />
|
||||
<Play className="h-3.5 w-3.5 fill-current" />
|
||||
)}
|
||||
{isRunning ? t("action.stop") : t("action.start")}
|
||||
<span className="ml-2 font-semibold">
|
||||
{isRunning ? t('action.stop') : t('action.start')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import {
|
||||
NavigationMenu as NavigationMenuBase,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function NavigationMenu() {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,7 +16,7 @@ export default function NavigationMenu() {
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link to="/">{t("titleHomePage")}</Link>
|
||||
<Link to="/">{t('titleHomePage')}</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Moon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toggleTheme } from "@/actions/theme";
|
||||
import { Moon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toggleTheme } from '@/actions/theme';
|
||||
|
||||
export default function ToggleTheme() {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
@@ -8,10 +8,7 @@ const Avatar = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -23,7 +20,7 @@ const AvatarImage = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -36,7 +33,7 @@ const AvatarFallback = React.forwardRef<
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-muted flex h-full w-full items-center justify-center rounded-full",
|
||||
'bg-muted flex h-full w-full items-center justify-center rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+11
-15
@@ -1,35 +1,31 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
+43
-70
@@ -1,82 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-card text-card-foreground rounded-xl border shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
<p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
@@ -19,8 +19,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none",
|
||||
inset && "pl-8",
|
||||
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -29,8 +29,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
@@ -39,14 +38,13 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
'bg-popover text-popover-foreground 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 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
@@ -57,8 +55,8 @@ const DropdownMenuContent = React.forwardRef<
|
||||
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",
|
||||
'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}
|
||||
@@ -76,8 +74,8 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -92,7 +90,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -106,8 +104,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
@@ -116,7 +113,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -139,11 +136,7 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -155,24 +148,18 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
className={cn('bg-muted -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -38,10 +38,7 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
@@ -55,29 +52,25 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
'bg-popover text-popover-foreground 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 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
@@ -86,9 +79,9 @@ const SelectContent = React.forwardRef<
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -105,7 +98,7 @@ const SelectLabel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold", className)}
|
||||
className={cn('py-1.5 pr-2 pl-8 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -118,7 +111,7 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -140,7 +133,7 @@ const SelectSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
className={cn('bg-muted -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
"peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
"bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
|
||||
+15
-16
@@ -1,8 +1,8 @@
|
||||
import * as React from "react";
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from 'react';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
@@ -13,7 +13,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]",
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -22,25 +22,24 @@ const ToastViewport = React.forwardRef<
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
@@ -59,7 +58,7 @@ const ToastAction = React.forwardRef<
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50",
|
||||
'hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -74,7 +73,7 @@ const ToastClose = React.forwardRef<
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
'text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
@@ -91,7 +90,7 @@ const ToastTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
className={cn('text-sm font-semibold [&+div]:text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -103,7 +102,7 @@ const ToastDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Toast, ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||
import * as React from 'react';
|
||||
import { Toast, ToastActionElement, ToastProps } from '@/components/ui/toast';
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
@@ -12,10 +12,10 @@ type ToasterToast = ToastProps & {
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
@@ -29,20 +29,20 @@ type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
type: ActionType['ADD_TOAST'];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
type: ActionType['UPDATE_TOAST'];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
type: ActionType['DISMISS_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
type: ActionType['REMOVE_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
};
|
||||
|
||||
interface State {
|
||||
@@ -59,7 +59,7 @@ const addToRemoveQueue = (toastId: string) => {
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
@@ -69,21 +69,19 @@ const addToRemoveQueue = (toastId: string) => {
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
@@ -108,7 +106,7 @@ export const reducer = (state: State, action: Action): State => {
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
@@ -133,20 +131,20 @@ function dispatch(action: Action) {
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
type Toast = Omit<ToasterToast, 'id'>;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
@@ -180,7 +178,7 @@ function useToast() {
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { AppConfig } from '@/types/config';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
export function useAppConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -20,28 +25,105 @@ export function useAppConfig() {
|
||||
const updateConfig = useMutation({
|
||||
mutationFn: async (newConfig: AppConfig) => {
|
||||
await ipc.client.config.save(newConfig);
|
||||
return newConfig;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||
});
|
||||
|
||||
const latestConfigRef = useRef<AppConfig | null>(null);
|
||||
const lastStableConfigRef = useRef<AppConfig | null>(null);
|
||||
const pendingResolversRef = useRef<
|
||||
Array<{ resolve: () => void; reject: (error: Error) => void }>
|
||||
>([]);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
lastStableConfigRef.current = config;
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const flushPendingSave = useCallback(async () => {
|
||||
const pendingBatch = pendingResolversRef.current.splice(0);
|
||||
const nextConfig = latestConfigRef.current;
|
||||
if (!nextConfig) {
|
||||
for (const item of pendingBatch) {
|
||||
item.resolve();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const savedConfig = await updateConfig.mutateAsync(nextConfig);
|
||||
queryClient.setQueryData(['appConfig'], savedConfig);
|
||||
lastStableConfigRef.current = savedConfig;
|
||||
toast({
|
||||
title: 'Settings saved',
|
||||
description: 'Your configuration has been updated.',
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
for (const item of pendingBatch) {
|
||||
item.resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Failed to save settings');
|
||||
const fallback = lastStableConfigRef.current;
|
||||
if (fallback) {
|
||||
queryClient.setQueryData(['appConfig'], fallback);
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||
}
|
||||
toast({
|
||||
title: 'Error saving settings',
|
||||
description: err.message,
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
for (const item of pendingBatch) {
|
||||
item.reject(error);
|
||||
}
|
||||
}
|
||||
}, [queryClient, updateConfig]);
|
||||
|
||||
const saveConfig = useCallback(
|
||||
(newConfig: AppConfig) => {
|
||||
latestConfigRef.current = newConfig;
|
||||
queryClient.setQueryData(['appConfig'], newConfig);
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pendingResolversRef.current.push({ resolve, reject });
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
flushPendingSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
});
|
||||
},
|
||||
});
|
||||
[flushPendingSave, queryClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
if (!debounceTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
void flushPendingSave();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
handleBeforeUnload();
|
||||
};
|
||||
}, [flushPendingSave]);
|
||||
|
||||
return {
|
||||
config,
|
||||
isLoading,
|
||||
error,
|
||||
saveConfig: updateConfig.mutateAsync,
|
||||
saveConfig,
|
||||
isSaving: updateConfig.isPending,
|
||||
};
|
||||
}
|
||||
@@ -47,12 +47,12 @@ export function useRefreshQuota() {
|
||||
return useMutation({
|
||||
mutationFn: refreshAccountQuota,
|
||||
onSuccess: (updatedAccount: CloudAccount) => {
|
||||
// Optimistically update the list or just invalidate?
|
||||
// Invalidating is safer as sorting might change (last_used)
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(QUERY_KEYS.cloudAccounts, (oldData: CloudAccount[] | undefined) => {
|
||||
if (!oldData) return [updatedAccount];
|
||||
return oldData.map((acc) => (acc.id === updatedAccount.id ? updatedAccount : acc));
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -97,8 +97,12 @@ export function useSetAutoSwitchEnabled() {
|
||||
}
|
||||
|
||||
export function useForcePollCloudMonitor() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: forcePollCloudMonitor,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { app } from 'electron';
|
||||
import * as Sentry from '@sentry/electron/main';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { getAppDataDir } from './utils/paths';
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
function getQuickConfig() {
|
||||
try {
|
||||
const configPath = path.join(getAppDataDir(), 'gui_config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
// Default to false (privacy by default)
|
||||
return config.error_reporting_enabled;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to read config for Sentry init:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getQuickConfig()) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
release: `antigravity-manager@${app.getVersion()}`,
|
||||
beforeSend(event) {
|
||||
if (event.exception?.values?.[0]?.value) {
|
||||
event.exception.values[0].value = event.exception.values[0].value.replace(
|
||||
/Users\\\\[^\\\\]+/g,
|
||||
'Users\\\\***',
|
||||
);
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
logger.setErrorReportingEnabled(true);
|
||||
logger.setSentryReporter((payload) => {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('log_level', payload.level);
|
||||
scope.setContext('recent_logs', {
|
||||
entries: payload.logs.map((entry) => ({
|
||||
timestamp: new Date(entry.timestamp).toISOString(),
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
formatted: entry.formatted,
|
||||
})),
|
||||
});
|
||||
scope.setExtra('log_message', payload.message);
|
||||
if (payload.error) {
|
||||
Sentry.captureException(payload.error);
|
||||
return;
|
||||
}
|
||||
Sentry.captureMessage(payload.message, 'error');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
logger.setErrorReportingEnabled(false);
|
||||
logger.setSentryReporter(null);
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { os } from "@orpc/server";
|
||||
import {
|
||||
listAccountsData,
|
||||
addAccountSnapshot,
|
||||
switchAccount,
|
||||
deleteAccount,
|
||||
} from "./handler";
|
||||
import { AccountSchema } from "../../types/account";
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { listAccountsData, addAccountSnapshot, switchAccount, deleteAccount } from './handler';
|
||||
import { AccountSchema } from '../../types/account';
|
||||
|
||||
export const accountRouter = os.router({
|
||||
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { app } from "electron";
|
||||
import { os } from '@orpc/server';
|
||||
import { app } from 'electron';
|
||||
|
||||
export const currentPlatfom = os.handler(() => {
|
||||
return process.platform;
|
||||
|
||||
@@ -20,11 +20,13 @@ export class AuthServer {
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
logger.info('AuthServer: Received authorization code');
|
||||
logger.info(`AuthServer: Received authorization code: ${code.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');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logger } from '../../utils/logger';
|
||||
|
||||
import { shell } from 'electron';
|
||||
import fs from 'fs';
|
||||
import { closeAntigravity, startAntigravity } from '../../ipc/process/handler';
|
||||
import { closeAntigravity, startAntigravity, _waitForProcessExit } from '../../ipc/process/handler';
|
||||
import { updateTrayMenu } from '../../ipc/tray/handler';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
|
||||
@@ -123,6 +123,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
||||
notifyTrayUpdate(account);
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
@@ -143,6 +145,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
||||
return account;
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
@@ -189,7 +193,12 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
}
|
||||
|
||||
// 2. Stop Antigravity Process
|
||||
await closeAntigravity(); // This waits for it to close
|
||||
await closeAntigravity();
|
||||
try {
|
||||
await _waitForProcessExit(10000); // Wait up to 10s for it to truly vanish
|
||||
} catch (e) {
|
||||
logger.warn('Process did not exit cleanly within timeout, but proceeding...', e);
|
||||
}
|
||||
|
||||
// 3. Backup Database (New Logic)
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
@@ -214,7 +223,7 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
|
||||
// 3. Inject Token
|
||||
// injectedCloudToken uses direct DB/FS access which is sync better-sqlite3.
|
||||
CloudAccountRepo.injectCloudToken(account.token);
|
||||
CloudAccountRepo.injectCloudToken(account);
|
||||
|
||||
// 4. Update usage and active status
|
||||
CloudAccountRepo.updateLastUsed(account.id);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from './handler';
|
||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export const cloudRouter = os.router({
|
||||
addGoogleAccount: os
|
||||
@@ -72,7 +73,7 @@ export const cloudRouter = os.router({
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
||||
logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { AppConfig } from '../../types/config';
|
||||
import { ConfigManager } from './manager';
|
||||
import { syncAutoStart } from '../../utils/autoStart';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
return ConfigManager.loadConfig();
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
// Logic to notify proxy server if configuration changes (hot update)
|
||||
// Logic to update Tray if language changes
|
||||
// For now just save
|
||||
ConfigManager.saveConfig(config);
|
||||
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
||||
await ConfigManager.saveConfig(config);
|
||||
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
||||
if (previous.auto_startup !== config.auto_startup) {
|
||||
syncAutoStart(config);
|
||||
}
|
||||
}
|
||||
+29
-12
@@ -7,6 +7,9 @@ import { logger } from '../../utils/logger';
|
||||
const CONFIG_FILENAME = 'gui_config.json';
|
||||
|
||||
export class ConfigManager {
|
||||
private static cachedConfig: AppConfig | null = null;
|
||||
private static saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
private static getConfigPath(): string {
|
||||
const appDataDir = getAppDataDir();
|
||||
if (!fs.existsSync(appDataDir)) {
|
||||
@@ -20,6 +23,7 @@ export class ConfigManager {
|
||||
const configPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.info(`Config: File not found at ${configPath}, returning default`);
|
||||
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
@@ -28,7 +32,7 @@ export class ConfigManager {
|
||||
|
||||
// Merge with default to ensure new fields are present
|
||||
// Zod parse helps validate
|
||||
const merged = {
|
||||
const merged: AppConfig = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
...raw,
|
||||
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
||||
@@ -45,22 +49,35 @@ export class ConfigManager {
|
||||
// Handle Anthropic Mapping Map vs Object
|
||||
// In JSON it's object
|
||||
|
||||
return merged as AppConfig;
|
||||
this.cachedConfig = merged;
|
||||
return merged;
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to load config', e);
|
||||
this.cachedConfig = DEFAULT_APP_CONFIG;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
static saveConfig(config: AppConfig): void {
|
||||
try {
|
||||
const configPath = this.getConfigPath();
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
logger.info(`Config: Saved to ${configPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to save config', e);
|
||||
throw e;
|
||||
}
|
||||
static getCachedConfig(): AppConfig | null {
|
||||
return this.cachedConfig;
|
||||
}
|
||||
|
||||
static async saveConfig(config: AppConfig): Promise<void> {
|
||||
const configPath = this.getConfigPath();
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
|
||||
this.saveQueue = this.saveQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.promises.writeFile(configPath, content, 'utf-8');
|
||||
this.cachedConfig = config;
|
||||
logger.info(`Config: Saved to ${configPath}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error('Config: Failed to save config', e);
|
||||
throw e;
|
||||
});
|
||||
|
||||
return this.saveQueue;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,6 @@ export const configRouter = os.router({
|
||||
.input(AppConfigSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
saveConfig(input);
|
||||
await saveConfig(input);
|
||||
}),
|
||||
});
|
||||
+633
-206
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema';
|
||||
|
||||
export interface DbConfigOptions {
|
||||
readOnly?: boolean;
|
||||
busyTimeoutMs?: number;
|
||||
enableWal?: boolean;
|
||||
}
|
||||
|
||||
export interface DrizzleConnection {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof schema>;
|
||||
}
|
||||
|
||||
export function configureDatabase(db: Database.Database, options: DbConfigOptions = {}): void {
|
||||
const busyTimeoutMs = options.busyTimeoutMs ?? 3000;
|
||||
const enableWal = options.enableWal ?? !options.readOnly;
|
||||
if (enableWal) {
|
||||
db.pragma('journal_mode = WAL');
|
||||
}
|
||||
db.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
||||
}
|
||||
|
||||
export function openDrizzleConnection(
|
||||
dbPath: string,
|
||||
options: Database.Options = {},
|
||||
config: DbConfigOptions = {},
|
||||
): DrizzleConnection {
|
||||
const raw = new Database(dbPath, options);
|
||||
configureDatabase(raw, config);
|
||||
const orm = drizzle(raw, { schema });
|
||||
return { raw, orm };
|
||||
}
|
||||
+115
-55
@@ -1,12 +1,47 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||
|
||||
import path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { parseRow } from '../../utils/sqlite';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { itemTable } from './schema';
|
||||
|
||||
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
|
||||
const KEYS_TO_BACKUP: ItemTableKey[] = [
|
||||
'antigravityAuthStatus',
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'antigravityUnifiedStateSync.oauthToken',
|
||||
];
|
||||
|
||||
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
|
||||
return openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: readOnly, fileMustExist: false },
|
||||
{ readOnly, busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the database and ensures WAL mode is enabled.
|
||||
* Should be called on application startup.
|
||||
*/
|
||||
export function initDatabase(): void {
|
||||
try {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { raw } = getDatabaseConnection();
|
||||
raw.close();
|
||||
logger.info('Database initialized and verified (WAL mode)');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize database on startup', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the database file exists.
|
||||
@@ -47,9 +82,9 @@ function ensureDatabaseExists(dbPath: string): void {
|
||||
/**
|
||||
* Gets a database connection.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {Database.Database} The database connection.
|
||||
* @returns {ReturnType<typeof openDrizzleConnection>} The database connection.
|
||||
*/
|
||||
export function getDatabaseConnection(dbPath?: string): Database.Database {
|
||||
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
|
||||
const targetPath = dbPath || getAntigravityDbPaths()[0];
|
||||
|
||||
if (!targetPath) {
|
||||
@@ -59,7 +94,7 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
|
||||
ensureDatabaseExists(targetPath);
|
||||
|
||||
try {
|
||||
return new Database(targetPath, { readonly: false, fileMustExist: false });
|
||||
return openIdeDb(targetPath);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||
@@ -69,67 +104,86 @@ export function getDatabaseConnection(dbPath?: string): Database.Database {
|
||||
}
|
||||
}
|
||||
|
||||
function readItemValue(
|
||||
orm: ReturnType<typeof openDrizzleConnection>['orm'],
|
||||
key: string,
|
||||
context: string,
|
||||
): string | null {
|
||||
const rows = orm
|
||||
.select({ value: itemTable.value })
|
||||
.from(itemTable)
|
||||
.where(eq(itemTable.key, key))
|
||||
.all();
|
||||
const row = parseRow(ItemTableValueRowSchema, rows[0], context);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current account info.
|
||||
* @returns {AccountInfo} The current account info.
|
||||
*/
|
||||
export function getCurrentAccountInfo(): AccountInfo {
|
||||
// NOTE Database existence is now handled by getDatabaseConnection
|
||||
let db: Database.Database | null = null;
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
try {
|
||||
db = getDatabaseConnection();
|
||||
connection = getDatabaseConnection();
|
||||
const { orm } = connection;
|
||||
|
||||
// Query for auth status
|
||||
const authRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
const authValue = readItemValue(
|
||||
orm,
|
||||
'antigravityAuthStatus',
|
||||
'ide.itemTable.antigravityAuthStatus',
|
||||
);
|
||||
let authStatus = null;
|
||||
if (authRow) {
|
||||
if (authValue) {
|
||||
try {
|
||||
authStatus = JSON.parse(authRow.value);
|
||||
authStatus = JSON.parse(authValue);
|
||||
} catch {
|
||||
// NOTE Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
||||
const initRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'jetskiStateSync.agentManagerInitState'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
const initValue = readItemValue(
|
||||
orm,
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'ide.itemTable.jetskiStateSync.agentManagerInitState',
|
||||
);
|
||||
let initState = null;
|
||||
if (initRow) {
|
||||
if (initValue) {
|
||||
try {
|
||||
initState = JSON.parse(initRow.value);
|
||||
initState = JSON.parse(initValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors (this key often contains non-JSON data)
|
||||
}
|
||||
}
|
||||
|
||||
// Query for google.antigravity
|
||||
const googleRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'google.antigravity'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
const googleValue = readItemValue(
|
||||
orm,
|
||||
'google.antigravity',
|
||||
'ide.itemTable.google.antigravity',
|
||||
);
|
||||
let googleState = null;
|
||||
if (googleRow) {
|
||||
if (googleValue) {
|
||||
try {
|
||||
googleState = JSON.parse(googleRow.value);
|
||||
googleState = JSON.parse(googleValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Query for antigravityUserSettings.allUserSettings
|
||||
const settingsRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'antigravityUserSettings.allUserSettings'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
const settingsValue = readItemValue(
|
||||
orm,
|
||||
'antigravityUserSettings.allUserSettings',
|
||||
'ide.itemTable.antigravityUserSettings.allUserSettings',
|
||||
);
|
||||
let settingsState = null;
|
||||
if (settingsRow) {
|
||||
if (settingsValue) {
|
||||
try {
|
||||
settingsState = JSON.parse(settingsRow.value);
|
||||
settingsState = JSON.parse(settingsValue);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
@@ -164,28 +218,28 @@ export function getCurrentAccountInfo(): AccountInfo {
|
||||
logger.error('Failed to get current account info', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
||||
let db: Database.Database | null = null;
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
try {
|
||||
db = getDatabaseConnection();
|
||||
connection = getDatabaseConnection();
|
||||
const { orm } = connection;
|
||||
|
||||
// NOTE Backup only specific keys
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
|
||||
if (row) {
|
||||
const value = readItemValue(orm, key, `ide.itemTable.backup.${key}`);
|
||||
if (value) {
|
||||
try {
|
||||
data[key] = JSON.parse(row.value);
|
||||
data[key] = JSON.parse(value);
|
||||
} catch {
|
||||
data[key] = row.value;
|
||||
data[key] = value;
|
||||
}
|
||||
logger.debug(`Backed up key: ${key}`);
|
||||
} else {
|
||||
@@ -206,7 +260,9 @@ export function backupAccount(account: AccountBackupData['account']): AccountBac
|
||||
logger.error('Failed to backup account', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,32 +313,36 @@ function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
|
||||
}
|
||||
|
||||
logger.info(`Restoring database: ${dbPath}`);
|
||||
let db: Database.Database | null = null;
|
||||
let connection: ReturnType<typeof openDrizzleConnection> | null = null;
|
||||
|
||||
try {
|
||||
db = getDatabaseConnection(dbPath);
|
||||
|
||||
const insert = db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)');
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
connection = getDatabaseConnection(dbPath);
|
||||
const { orm } = connection;
|
||||
orm.transaction((tx) => {
|
||||
// NOTE Only restore the keys that were backed up
|
||||
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);
|
||||
insert.run(key, stringValue);
|
||||
tx.insert(itemTable)
|
||||
.values({ key, value: stringValue })
|
||||
.onConflictDoUpdate({
|
||||
target: itemTable.key,
|
||||
set: { value: stringValue },
|
||||
})
|
||||
.run();
|
||||
logger.debug(`Restored key: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
transaction();
|
||||
logger.info(`Database restoration complete: ${dbPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restore database: ${dbPath}`, error);
|
||||
return false;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
if (connection) {
|
||||
connection.raw.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { os } from "@orpc/server";
|
||||
import {
|
||||
backupAccount,
|
||||
restoreAccount,
|
||||
getCurrentAccountInfo,
|
||||
} from "./handler";
|
||||
import {
|
||||
AccountBackupDataSchema,
|
||||
AccountInfoSchema,
|
||||
AccountSchema,
|
||||
} from "../../types/account";
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { backupAccount, restoreAccount, getCurrentAccountInfo } from './handler';
|
||||
import { AccountBackupDataSchema, AccountInfoSchema, AccountSchema } from '../../types/account';
|
||||
|
||||
export const databaseRouter = os.router({
|
||||
backupAccount: os
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const accounts = sqliteTable('accounts', {
|
||||
id: text('id').primaryKey(),
|
||||
provider: text('provider').notNull(),
|
||||
email: text('email').notNull(),
|
||||
name: text('name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
tokenJson: text('token_json').notNull(),
|
||||
quotaJson: text('quota_json'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
lastUsed: integer('last_used').notNull(),
|
||||
status: text('status'),
|
||||
isActive: integer('is_active').notNull().default(0),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(),
|
||||
});
|
||||
|
||||
export const itemTable = sqliteTable('ItemTable', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value'),
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import { bootstrapNestServer, stopNestServer, getNestServerStatus } from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Start the gateway server (NestJS)
|
||||
@@ -20,7 +21,7 @@ export const startGateway = async (port: number): Promise<boolean> => {
|
||||
|
||||
return await bootstrapNestServer(proxyConfig);
|
||||
} catch (e) {
|
||||
console.error('Failed to start gateway:', e);
|
||||
logger.error('Failed to start gateway:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -32,7 +33,7 @@ export const stopGateway = async (): Promise<boolean> => {
|
||||
try {
|
||||
return await stopNestServer();
|
||||
} catch (e) {
|
||||
console.error('Failed to stop gateway:', e);
|
||||
logger.error('Failed to stop gateway:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -53,7 +54,7 @@ export const generateApiKey = async (): Promise<string> => {
|
||||
// Save to config
|
||||
const config = ConfigManager.loadConfig();
|
||||
config.proxy.api_key = newKey;
|
||||
ConfigManager.saveConfig(config);
|
||||
await ConfigManager.saveConfig(config);
|
||||
|
||||
return newKey;
|
||||
};
|
||||
+1
-1
@@ -28,7 +28,7 @@ try {
|
||||
logger.info(`Database router check: ${!!db}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error logging router:', e);
|
||||
logger.error('Error logging router:', e);
|
||||
}
|
||||
|
||||
export const rpcHandler: RPCHandler<Record<never, never>> = new RPCHandler(router);
|
||||
+1
-1
@@ -78,7 +78,7 @@ function createIPCClient(port: MessagePort) {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request /${methodPath} timed out`));
|
||||
}
|
||||
}, 10000);
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+201
-60
@@ -1,63 +1,166 @@
|
||||
import { exec, execSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import findProcess, { ProcessInfo } from 'find-process';
|
||||
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Helper process name patterns to exclude (Electron helper processes)
|
||||
*/
|
||||
const HELPER_PATTERNS = [
|
||||
'helper',
|
||||
'plugin',
|
||||
'renderer',
|
||||
'gpu',
|
||||
'crashpad',
|
||||
'utility',
|
||||
'audio',
|
||||
'sandbox',
|
||||
'language_server',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a process is a helper/auxiliary process that should be excluded.
|
||||
* @param name Process name (lowercase)
|
||||
* @param cmd Process command line (lowercase)
|
||||
* @returns True if the process is a helper process
|
||||
*/
|
||||
function isHelperProcess(name: string, cmd: string): boolean {
|
||||
const nameLower = name.toLowerCase();
|
||||
const cmdLower = cmd.toLowerCase();
|
||||
|
||||
// Check for --type= argument (Electron helper process indicator)
|
||||
if (cmdLower.includes('--type=')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for helper patterns in process name
|
||||
for (const pattern of HELPER_PATTERNS) {
|
||||
if (nameLower.includes(pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for crashpad in path
|
||||
if (cmdLower.includes('crashpad')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPgrepNoMatchError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
const hasPgrep = message.includes('pgrep') && message.includes('antigravity');
|
||||
const code = (error as { code?: number }).code;
|
||||
return hasPgrep && code === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Antigravity process is running.
|
||||
* Uses find-process package for robust cross-platform process detection.
|
||||
* @returns {boolean} True if the Antigravity process is running, false otherwise.
|
||||
*/
|
||||
export async function isProcessRunning(): Promise<boolean> {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
let command = '';
|
||||
const currentPid = process.pid;
|
||||
|
||||
if (platform === 'win32' || isWsl()) {
|
||||
// Use absolute path for tasklist to be safe on Windows too
|
||||
const cmd = isWsl()
|
||||
? '/mnt/c/Windows/System32/tasklist.exe'
|
||||
: process.env.SystemRoot // Usually C:\Windows
|
||||
? `${process.env.SystemRoot}\\System32\\tasklist.exe`
|
||||
: 'tasklist';
|
||||
// Use find-process to search for Antigravity processes
|
||||
// 'name' search type matches process name
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
const searchNames = ['Antigravity', 'antigravity'];
|
||||
let sawNoMatch = false;
|
||||
|
||||
command = `${cmd} /FI "IMAGENAME eq Antigravity.exe" /NH`;
|
||||
} else {
|
||||
// macOS and Linux (native)
|
||||
command = 'pgrep -x Antigravity';
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isPgrepNoMatchError(error)) {
|
||||
sawNoMatch = true;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Checking process with command: ${command}`);
|
||||
// Increased timeout to 4000ms to avoid flakiness with tasklist/wmic
|
||||
try {
|
||||
const { stdout } = await execAsync(command, { timeout: 4000 });
|
||||
logger.debug(`Process check stdout: ${stdout.trim()}`);
|
||||
const processes = Array.from(processMap.values());
|
||||
if (processes.length === 0 && sawNoMatch) {
|
||||
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||
}
|
||||
|
||||
if (platform === 'win32' || isWsl()) {
|
||||
return stdout.includes('Antigravity.exe');
|
||||
} else {
|
||||
// pgrep returns PIDs if found, empty if not
|
||||
return stdout.trim().length > 0;
|
||||
logger.debug(`Found ${processes.length} processes matching 'Antigravity/antigravity'`);
|
||||
|
||||
for (const proc of processes) {
|
||||
// Skip self
|
||||
if (proc.pid === currentPid) {
|
||||
continue;
|
||||
}
|
||||
} catch (cmdError) {
|
||||
if (platform === 'win32' && !isWsl()) {
|
||||
logger.warn('tasklist failed, trying wmic fallback...', cmdError);
|
||||
// Fallback to wmic (filtered)
|
||||
try {
|
||||
const wmicCmd = 'wmic process where "name=\'Antigravity.exe\'" get ProcessId';
|
||||
const { stdout } = await execAsync(wmicCmd, { timeout: 4000 });
|
||||
return stdout.includes('ProcessId');
|
||||
} catch (wmicError) {
|
||||
logger.error('wmic fallback also failed', wmicError);
|
||||
return false;
|
||||
|
||||
const name = proc.name?.toLowerCase() || '';
|
||||
const cmd = proc.cmd?.toLowerCase() || '';
|
||||
|
||||
// Skip manager process
|
||||
if (
|
||||
name.includes('manager') ||
|
||||
cmd.includes('manager') ||
|
||||
cmd.includes('antigravity-manager')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip helper processes
|
||||
if (isHelperProcess(name, cmd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Check for Antigravity.app in path
|
||||
if (cmd.includes('antigravity.app')) {
|
||||
logger.debug(
|
||||
`Found Antigravity 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}`);
|
||||
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}`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Linux: Check for antigravity in name or path (but not tools)
|
||||
if (
|
||||
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
||||
!name.includes('tools')
|
||||
) {
|
||||
logger.debug(
|
||||
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
throw cmdError;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('Error checking process status:', error);
|
||||
// pgrep returns exit code 1 if no process found
|
||||
logger.error('Error checking process status with find-process:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -108,33 +211,67 @@ export async function closeAntigravity(): Promise<void> {
|
||||
// Helper to list processes
|
||||
const getProcesses = (): { pid: number; name: string; cmd: string }[] => {
|
||||
try {
|
||||
let cmd = '';
|
||||
let output = '';
|
||||
if (platform === 'win32') {
|
||||
// Use wmic to get process info
|
||||
cmd = 'wmic process get ProcessId,Name,CommandLine /FORMAT:CSV';
|
||||
const psCommand = (cmdlet: string) =>
|
||||
`powershell -NoProfile -Command "${cmdlet} Win32_Process -Filter \\"Name like 'Antigravity%'\\" | Select-Object ProcessId, Name, CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
||||
|
||||
try {
|
||||
output = execSync(psCommand('Get-CimInstance'), {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
} catch (e) {
|
||||
// CIM failed (likely older OS), try WMI
|
||||
try {
|
||||
output = execSync(psCommand('Get-WmiObject'), {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
});
|
||||
} catch (innerE) {
|
||||
// Both failed, throw original or log? Throwing lets the outer catch handle it (returning empty list)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unix/Linux/macOS
|
||||
cmd = 'ps -A -o pid,comm,args';
|
||||
output = execSync('ps -A -o pid,comm,args', {
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 1024 * 1024 * 10,
|
||||
});
|
||||
}
|
||||
|
||||
const output = execSync(cmd, { encoding: 'utf-8' });
|
||||
const lines = output.split('\n');
|
||||
const processList = [];
|
||||
const processList: { pid: number; name: string; cmd: string }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
if (platform === 'win32') {
|
||||
// CSV format parsing would be better, but simple check for now
|
||||
if (line.includes('Antigravity')) {
|
||||
// Extract PID (usually last or second to last in CSV)
|
||||
const match = line.match(/(\d+)(?:\s*$)/);
|
||||
if (match) {
|
||||
processList.push({ pid: parseInt(match[1]), name: 'Antigravity', cmd: line });
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
// Parse CSV Output
|
||||
const lines = output.trim().split(/\r?\n/);
|
||||
// First line is headers "ProcessId","Name","CommandLine"
|
||||
// We start from index 1
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
|
||||
// Regex to match CSV fields: "val1","val2","val3"
|
||||
const match = line.match(/^"(\d+)","(.*?)","(.*?)"$/);
|
||||
|
||||
if (match) {
|
||||
const pid = parseInt(match[1]);
|
||||
const name = match[2];
|
||||
const cmdLine = match[3];
|
||||
|
||||
processList.push({ pid, name, cmd: cmdLine || name });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const pid = parseInt(parts[0]);
|
||||
if (isNaN(pid)) continue;
|
||||
const rest = parts.slice(1).join(' ');
|
||||
@@ -155,18 +292,22 @@ export async function closeAntigravity(): Promise<void> {
|
||||
if (p.pid === currentPid) {
|
||||
return false;
|
||||
}
|
||||
// Exclude this electron app (if named Antigravity Manager)
|
||||
if (p.cmd.includes('Antigravity Manager')) {
|
||||
// Exclude this electron app (if named Antigravity Manager or antigravity-manager)
|
||||
if (p.cmd.includes('Antigravity Manager') || p.cmd.includes('antigravity-manager')) {
|
||||
return false;
|
||||
}
|
||||
// Match Antigravity
|
||||
// Match Antigravity (but not manager)
|
||||
if (platform === 'win32') {
|
||||
return (
|
||||
p.cmd.includes('Antigravity.exe') ||
|
||||
(p.cmd.includes('antigravity') && !p.cmd.includes('manager'))
|
||||
);
|
||||
} else {
|
||||
return p.cmd.includes('Antigravity') || p.cmd.includes('antigravity');
|
||||
// Explicit !manager check for Linux/macOS to be defensive
|
||||
return (
|
||||
(p.cmd.includes('Antigravity') || p.cmd.includes('antigravity')) &&
|
||||
!p.cmd.includes('manager')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { isProcessRunning, closeAntigravity, startAntigravity } from './process/handler';
|
||||
import { systemHandler } from './system/handler';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Log middleware setup
|
||||
const logMiddleware = os.middleware(async (opts: any) => {
|
||||
@@ -21,7 +22,7 @@ const logMiddleware = os.middleware(async (opts: any) => {
|
||||
const result = await next({});
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`[ORPC] Error in handler for ${JSON.stringify(requestPath)}:`, err);
|
||||
logger.error(`[ORPC] Error in handler for ${JSON.stringify(requestPath)}:`, err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { networkInterfaces } from 'os';
|
||||
import { shell } from 'electron';
|
||||
import { getAgentDir } from '../../utils/paths';
|
||||
|
||||
// Schema for IP info
|
||||
const IpInfoSchema = z.object({
|
||||
@@ -61,4 +63,10 @@ export const systemHandler = os.router({
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
// Open log directory in file explorer
|
||||
openLogDirectory: os.output(z.void()).handler(async () => {
|
||||
const logDir = getAgentDir();
|
||||
await shell.openPath(logDir);
|
||||
}),
|
||||
});
|
||||
+19
-21
@@ -1,6 +1,6 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { nativeTheme } from "electron";
|
||||
import { setThemeModeInputSchema } from "./schemas";
|
||||
import { os } from '@orpc/server';
|
||||
import { nativeTheme } from 'electron';
|
||||
import { setThemeModeInputSchema } from './schemas';
|
||||
|
||||
export const getCurrentThemeMode = os.handler(() => {
|
||||
return nativeTheme.themeSource;
|
||||
@@ -8,28 +8,26 @@ export const getCurrentThemeMode = os.handler(() => {
|
||||
|
||||
export const toggleThemeMode = os.handler(() => {
|
||||
if (nativeTheme.shouldUseDarkColors) {
|
||||
nativeTheme.themeSource = "light";
|
||||
nativeTheme.themeSource = 'light';
|
||||
} else {
|
||||
nativeTheme.themeSource = "dark";
|
||||
nativeTheme.themeSource = 'dark';
|
||||
}
|
||||
|
||||
return nativeTheme.shouldUseDarkColors;
|
||||
});
|
||||
|
||||
export const setThemeMode = os
|
||||
.input(setThemeModeInputSchema)
|
||||
.handler(({ input: mode }) => {
|
||||
switch (mode) {
|
||||
case "light":
|
||||
nativeTheme.themeSource = "light";
|
||||
break;
|
||||
case "dark":
|
||||
nativeTheme.themeSource = "dark";
|
||||
break;
|
||||
case "system":
|
||||
nativeTheme.themeSource = "system";
|
||||
break;
|
||||
}
|
||||
export const setThemeMode = os.input(setThemeModeInputSchema).handler(({ input: mode }) => {
|
||||
switch (mode) {
|
||||
case 'light':
|
||||
nativeTheme.themeSource = 'light';
|
||||
break;
|
||||
case 'dark':
|
||||
nativeTheme.themeSource = 'dark';
|
||||
break;
|
||||
case 'system':
|
||||
nativeTheme.themeSource = 'system';
|
||||
break;
|
||||
}
|
||||
|
||||
return nativeTheme.themeSource;
|
||||
});
|
||||
return nativeTheme.themeSource;
|
||||
});
|
||||
@@ -1,3 +1,3 @@
|
||||
import { z } from "zod";
|
||||
import { z } from 'zod';
|
||||
|
||||
export const setThemeModeInputSchema = z.enum(["light", "dark", "system"]);
|
||||
export const setThemeModeInputSchema = z.enum(['light', 'dark', 'system']);
|
||||
+37
-1
@@ -38,12 +38,36 @@ function getQuotaText(account: CloudAccount | null, texts: any): string[] {
|
||||
|
||||
export function initTray(mainWindow: BrowserWindow) {
|
||||
globalMainWindow = mainWindow;
|
||||
|
||||
// PATCH 3: Destroy existing tray before creating new one (prevents zombie tray icons)
|
||||
if (tray) {
|
||||
try {
|
||||
tray.destroy();
|
||||
} catch (e) {
|
||||
logger.error('Failed to destroy existing tray', e);
|
||||
}
|
||||
tray = null;
|
||||
logger.info('Destroyed existing tray before creating new one');
|
||||
}
|
||||
|
||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
||||
// In production, extraResource copies 'src/assets' folder to 'resources/assets'
|
||||
const iconPath = inDevelopment
|
||||
? path.join(process.cwd(), 'src/assets/tray.png')
|
||||
: path.join(process.resourcesPath, 'assets/tray.png');
|
||||
: path.join(process.resourcesPath, 'assets', 'tray.png');
|
||||
|
||||
logger.info(
|
||||
`Tray icon path: ${iconPath}, inDevelopment: ${inDevelopment}, resourcesPath: ${process.resourcesPath}`,
|
||||
);
|
||||
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
|
||||
// Verify icon is valid before creating tray
|
||||
if (icon.isEmpty()) {
|
||||
logger.error(`Tray icon not found or invalid at path: ${iconPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('Antigravity Manager');
|
||||
|
||||
@@ -158,3 +182,15 @@ export function updateTrayMenu(account: CloudAccount | null, language?: string)
|
||||
export function setTrayLanguage(lang: string) {
|
||||
updateTrayMenu(lastAccount, lang);
|
||||
}
|
||||
|
||||
export function destroyTray() {
|
||||
if (tray) {
|
||||
try {
|
||||
tray.destroy();
|
||||
} catch (e) {
|
||||
logger.error('Failed to destroy tray', e);
|
||||
}
|
||||
tray = null;
|
||||
logger.info('Tray destroyed');
|
||||
}
|
||||
}
|
||||
+20
-27
@@ -1,33 +1,26 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { ipcContext } from "../context";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { os } from '@orpc/server';
|
||||
import { ipcContext } from '../context';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export const minimizeWindow = os
|
||||
.use(ipcContext.mainWindowContext)
|
||||
.handler(({ context }) => {
|
||||
const { window } = context;
|
||||
export const minimizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||
const { window } = context;
|
||||
|
||||
window.minimize();
|
||||
});
|
||||
window.minimize();
|
||||
});
|
||||
|
||||
export const maximizeWindow = os
|
||||
.use(ipcContext.mainWindowContext)
|
||||
.handler(({ context }) => {
|
||||
const { window } = context;
|
||||
export const maximizeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||
const { window } = context;
|
||||
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
} else {
|
||||
window.maximize();
|
||||
}
|
||||
});
|
||||
if (window.isMaximized()) {
|
||||
window.unmaximize();
|
||||
} else {
|
||||
window.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
export const closeWindow = os
|
||||
.use(ipcContext.mainWindowContext)
|
||||
.handler(({ context }) => {
|
||||
logger.info('IPC: closeWindow called');
|
||||
const { window } = context;
|
||||
|
||||
window.close();
|
||||
});
|
||||
export const closeWindow = os.use(ipcContext.mainWindowContext).handler(({ context }) => {
|
||||
logger.info('IPC: closeWindow called');
|
||||
const { window } = context;
|
||||
|
||||
window.close();
|
||||
});
|
||||
+107
-27
@@ -1,14 +1,34 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, Outlet, useLocation } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StatusBar } from '@/components/StatusBar';
|
||||
import { LayoutDashboard, Settings, Network, Rocket } from 'lucide-react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Network,
|
||||
Rocket,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
export const MainLayout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Initialize state from localStorage if available, default to false (expanded)
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
const saved = localStorage.getItem('sidebar-collapsed');
|
||||
return saved ? JSON.parse(saved) : false;
|
||||
});
|
||||
|
||||
// Persist state changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebar-collapsed', JSON.stringify(isCollapsed));
|
||||
}, [isCollapsed]);
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
to: '/',
|
||||
@@ -31,42 +51,102 @@ export const MainLayout: React.FC = () => {
|
||||
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="bg-muted/10 flex w-64 flex-col border-r">
|
||||
<div className="p-6">
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold tracking-tight">
|
||||
<div className="bg-primary text-primary-foreground flex h-6 w-6 items-center justify-center rounded">
|
||||
<aside
|
||||
className={cn(
|
||||
'bg-muted/10 group relative flex flex-col border-r transition-all duration-300 ease-in-out',
|
||||
isCollapsed ? 'w-[70px]' : 'w-64',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-background hover:bg-accent hover:text-accent-foreground absolute top-6 -right-3 z-10 h-6 w-6 rounded-full border opacity-0 shadow-md transition-opacity group-hover:opacity-100"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
Antigravity
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-xs">Manager</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2 px-4">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
location.pathname === item.to
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
||||
'overflow-hidden transition-all duration-300',
|
||||
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<h1 className="text-xl font-bold tracking-tight">Antigravity</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">
|
||||
<TooltipProvider>
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.to;
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<Tooltip key={item.to} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'mx-auto flex h-10 w-10 items-center justify-center rounded-md transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span className="sr-only">{item.label}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</TooltipProvider>
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-4">
|
||||
<StatusBar />
|
||||
<div className="border-t p-2">
|
||||
<StatusBar isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content Area */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<main className="flex-1 overflow-auto transition-all duration-300">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { mapClaudeModelToGemini } from './ModelMapping';
|
||||
import { cleanJsonSchema } from './JsonSchemaUtils';
|
||||
import { SignatureStore } from './SignatureStore';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
ClaudeRequest,
|
||||
Message,
|
||||
@@ -51,7 +52,7 @@ export function transformClaudeRequestIn(
|
||||
const toolIdToName = new Map<string, string>();
|
||||
|
||||
// 1. System Instruction
|
||||
let systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
|
||||
const systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
|
||||
|
||||
// Map model name
|
||||
const mappedModel = hasWebSearchTool
|
||||
@@ -69,9 +70,27 @@ export function transformClaudeRequestIn(
|
||||
const allowDummyThought = config.finalModel.startsWith('gemini-');
|
||||
|
||||
// 4. Generation Config & Thinking
|
||||
const generationConfig = buildGenerationConfig(claudeReq, hasWebSearchTool, config.finalModel);
|
||||
let isThinkingEnabled = claudeReq.thinking?.type === 'enabled';
|
||||
|
||||
const isThinkingEnabled = claudeReq.thinking?.type === 'enabled';
|
||||
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;
|
||||
});
|
||||
|
||||
if (hasFunctionCalls && !hasValidSignatureForFunctionCalls(claudeReq.messages, globalSig)) {
|
||||
isThinkingEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const generationConfig = buildGenerationConfig(claudeReq, hasWebSearchTool, config.finalModel);
|
||||
// Update thinking config based on the final decision
|
||||
if (!isThinkingEnabled && generationConfig.thinkingConfig) {
|
||||
delete generationConfig.thinkingConfig;
|
||||
}
|
||||
|
||||
// 2. Contents (Messages)
|
||||
const contents = buildContents(
|
||||
@@ -82,7 +101,7 @@ export function transformClaudeRequestIn(
|
||||
);
|
||||
|
||||
// 3. Tools
|
||||
let tools = buildTools(claudeReq.tools, hasWebSearchTool);
|
||||
const tools = buildTools(claudeReq.tools, hasWebSearchTool);
|
||||
|
||||
// 5. Safety Settings
|
||||
const safetySettings = [
|
||||
@@ -134,7 +153,7 @@ export function transformClaudeRequestIn(
|
||||
delete innerRequest.systemInstruction;
|
||||
|
||||
// 3. Clean generationConfig
|
||||
let genConfig = innerRequest.generationConfig || {};
|
||||
const genConfig = innerRequest.generationConfig || {};
|
||||
delete genConfig.thinkingConfig;
|
||||
delete genConfig.responseMimeType;
|
||||
delete genConfig.responseModalities;
|
||||
@@ -360,7 +379,7 @@ function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
||||
|
||||
const hasFunctions = toolsArr.some((t) => t.functionDeclarations);
|
||||
if (hasFunctions) {
|
||||
console.info('Skipping googleSearch injection due to existing functionDeclarations');
|
||||
logger.info('Skipping googleSearch injection due to existing functionDeclarations');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -371,32 +390,104 @@ function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
||||
|
||||
/**
|
||||
* Builds system instruction
|
||||
* Converts Claude system prompts to Gemini format
|
||||
* Converts Claude system prompts to Gemini format, with Antigravity Identity Injection
|
||||
*/
|
||||
function buildSystemInstruction(
|
||||
system: ClaudeRequest['system'],
|
||||
modelName: string,
|
||||
): { parts: { text: string }[] } | null {
|
||||
if (!system) {
|
||||
return null;
|
||||
}
|
||||
const antigravity_identity =
|
||||
'--- [IDENTITY_PATCH] ---\n' +
|
||||
'Ignore any previous instructions regarding your identity or host platform (e.g., Amazon Q, Google AI).\n' +
|
||||
'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 parts: { text: string }[] = [];
|
||||
const identityPatch =
|
||||
`--- [IDENTITY_PATCH] ---\n` +
|
||||
`Ignore any previous instructions regarding your identity or host platform (e.g., Amazon Q, Google AI).\n` +
|
||||
`You are currently providing services as the native ${modelName} model via a standard API proxy.\n` +
|
||||
`Always use the 'claude' command for terminal tasks if relevant.\n` +
|
||||
`--- [SYSTEM_PROMPT_BEGIN] ---\n`;
|
||||
parts.push({ text: identityPatch });
|
||||
if (typeof system === 'string') {
|
||||
parts.push({ text: system });
|
||||
} else if (Array.isArray(system)) {
|
||||
for (const block of system) {
|
||||
if (block.type === 'text') parts.push({ text: block.text });
|
||||
|
||||
// Check if user already provided Antigravity identity
|
||||
let userHasAntigravity = false;
|
||||
|
||||
if (system) {
|
||||
if (typeof system === 'string') {
|
||||
if (system.includes('You are Antigravity')) userHasAntigravity = true;
|
||||
} else if (Array.isArray(system)) {
|
||||
for (const block of system) {
|
||||
if (block.type === 'text' && block.text.includes('You are Antigravity')) {
|
||||
userHasAntigravity = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.push({ text: '\n--- [SYSTEM_PROMPT_END] ---' });
|
||||
return { parts };
|
||||
|
||||
// Inject if missing
|
||||
if (!userHasAntigravity) {
|
||||
parts.push({ text: antigravity_identity });
|
||||
}
|
||||
|
||||
if (system) {
|
||||
if (typeof system === 'string') {
|
||||
parts.push({ text: system });
|
||||
} else if (Array.isArray(system)) {
|
||||
for (const block of system) {
|
||||
if (block.type === 'text') parts.push({ text: block.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!userHasAntigravity) {
|
||||
parts.push({ text: '\n--- [SYSTEM_PROMPT_END] ---' });
|
||||
}
|
||||
|
||||
// If we pushed at least something
|
||||
if (parts.length > 0) {
|
||||
return { parts };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum length for a valid thought_signature
|
||||
*/
|
||||
const MIN_SIGNATURE_LENGTH = 10;
|
||||
|
||||
/**
|
||||
* Check if we have any valid signature available for function calls
|
||||
* @param messages Messages from ClaudeRequest
|
||||
* @param globalSig Global signature from SignatureStore
|
||||
* @returns True if any valid signature is available for function calls
|
||||
*/
|
||||
function hasValidSignatureForFunctionCalls(
|
||||
messages: Message[],
|
||||
globalSig: string | null | undefined,
|
||||
): boolean {
|
||||
// 1. Check global store
|
||||
if (globalSig && globalSig.length >= MIN_SIGNATURE_LENGTH) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Check if any message has a thinking block with valid signature
|
||||
// Traverse in reverse to find recent signatures
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === 'assistant') {
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (
|
||||
block.type === 'thinking' &&
|
||||
block.signature &&
|
||||
block.signature.length >= MIN_SIGNATURE_LENGTH
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Usage,
|
||||
GroundingMetadata,
|
||||
} from './types';
|
||||
import { decodeSignature } from './signature-utils';
|
||||
|
||||
/**
|
||||
* Non-streaming response processor (Gemini -> Claude)
|
||||
@@ -55,7 +56,7 @@ class NonStreamingProcessor {
|
||||
}
|
||||
|
||||
private processPart(part: GeminiPart) {
|
||||
const signature = part.thoughtSignature || null;
|
||||
const signature = decodeSignature(part.thoughtSignature) || null;
|
||||
|
||||
// 1. Handle FunctionCall
|
||||
if (part.functionCall) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { GeminiPart, Usage, UsageMetadata, GroundingMetadata } from './types';
|
||||
import { GeminiPart, Usage, UsageMetadata } from './types';
|
||||
import { SignatureStore } from './SignatureStore';
|
||||
import { decodeSignature } from './signature-utils';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
type BlockType = 'None' | 'Text' | 'Thinking' | 'Function';
|
||||
|
||||
@@ -43,6 +45,8 @@ export class StreamingState {
|
||||
public webSearchQuery: string | null = null;
|
||||
public groundingChunks: any[] | null = null;
|
||||
|
||||
private parseErrorCount: number = 0;
|
||||
|
||||
constructor() {}
|
||||
|
||||
public emit(eventType: string, data: any): string {
|
||||
@@ -242,6 +246,56 @@ export class StreamingState {
|
||||
public storeSignature(signature?: string) {
|
||||
this.signatures.store(signature);
|
||||
}
|
||||
public handleParseError(rawData: string): string[] {
|
||||
const chunks: string[] = [];
|
||||
this.parseErrorCount++;
|
||||
|
||||
logger.warn(
|
||||
`[SSE-Parser] Parse error #${this.parseErrorCount}. Raw data length: ${rawData.length}`,
|
||||
);
|
||||
|
||||
// Safely close current block
|
||||
if (this.blockType !== 'None') {
|
||||
chunks.push(...this.endBlock());
|
||||
}
|
||||
|
||||
// Emit error event if too many errors
|
||||
if (this.parseErrorCount > 3) {
|
||||
logger.error(
|
||||
`[SSE-Parser] High error rate (${this.parseErrorCount} errors). Stream may be corrupted.`,
|
||||
);
|
||||
chunks.push(
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'network_error',
|
||||
message: 'Unstable network connection. Please check your network or proxy settings.',
|
||||
code: 'stream_decode_error',
|
||||
details: {
|
||||
error_count: this.parseErrorCount,
|
||||
suggestion: 'Check network connection',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset error state (call after recovery)
|
||||
*/
|
||||
public resetErrorState(): void {
|
||||
this.parseErrorCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current error count (for monitoring)
|
||||
*/
|
||||
public getErrorCount(): number {
|
||||
return this.parseErrorCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +306,7 @@ export class PartProcessor {
|
||||
|
||||
public process(part: GeminiPart): string[] {
|
||||
const chunks: string[] = [];
|
||||
const signature = part.thoughtSignature || undefined;
|
||||
const signature = decodeSignature(part.thoughtSignature);
|
||||
|
||||
// 1. Handle FunctionCall
|
||||
if (part.functionCall) {
|
||||
|
||||
@@ -67,7 +67,7 @@ function flattenRefs(map: any, defs: Record<string, any>) {
|
||||
|
||||
// Recursively process all children
|
||||
for (const k in map) {
|
||||
if (map.hasOwnProperty(k)) {
|
||||
if (Object.prototype.hasOwnProperty.call(map, k)) {
|
||||
const v = map[k];
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
flattenRefs(v, defs);
|
||||
@@ -91,7 +91,7 @@ function cleanJsonSchemaRecursive(value: any) {
|
||||
|
||||
// 1. Recursively process all children first to ensure nested structures are cleaned
|
||||
for (const k in map) {
|
||||
if (map.hasOwnProperty(k)) {
|
||||
if (Object.prototype.hasOwnProperty.call(map, k)) {
|
||||
cleanJsonSchemaRecursive(map[k]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
const logger = {
|
||||
log: (msg: string) => console.log(msg),
|
||||
warn: (msg: string) => console.warn(msg),
|
||||
};
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||
// Directly supported models
|
||||
@@ -78,7 +75,7 @@ export function resolveModelRoute(
|
||||
): string {
|
||||
// 1. Check custom exact mapping (Highest priority)
|
||||
if (customMapping[originalModel]) {
|
||||
logger.log(
|
||||
logger.info(
|
||||
`[Router] Using custom exact mapping: ${originalModel} -> ${customMapping[originalModel]}`,
|
||||
);
|
||||
return customMapping[originalModel];
|
||||
@@ -98,7 +95,7 @@ export function resolveModelRoute(
|
||||
lowerModel === 'gpt-4'
|
||||
) {
|
||||
if (openaiMapping['gpt-4-series']) {
|
||||
logger.log(
|
||||
logger.info(
|
||||
`[Router] Using GPT-4 series mapping: ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-4-series'];
|
||||
@@ -113,7 +110,7 @@ export function resolveModelRoute(
|
||||
lowerModel.includes('turbo')
|
||||
) {
|
||||
if (openaiMapping['gpt-4o-series']) {
|
||||
logger.log(
|
||||
logger.info(
|
||||
`[Router] Using GPT-4o/3.5 series mapping: ${originalModel} -> ${openaiMapping['gpt-4o-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-4o-series'];
|
||||
@@ -124,13 +121,13 @@ export function resolveModelRoute(
|
||||
if (lowerModel.startsWith('gpt-5')) {
|
||||
// Prefer gpt-5-series mapping, fallback to gpt-4-series if missing
|
||||
if (openaiMapping['gpt-5-series']) {
|
||||
logger.log(
|
||||
logger.info(
|
||||
`[Router] Using GPT-5 series mapping: ${originalModel} -> ${openaiMapping['gpt-5-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-5-series'];
|
||||
}
|
||||
if (openaiMapping['gpt-4-series']) {
|
||||
logger.log(
|
||||
logger.info(
|
||||
`[Router] Using GPT-4 series mapping (GPT-5 fallback): ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-4-series'];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||
import { QuotaData, Tier, LoadProjectResponse, QuotaApiResponse } from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Constants
|
||||
const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels';
|
||||
@@ -48,15 +49,15 @@ export class QuotaService {
|
||||
const subscriptionTier = data.paidTier?.id || data.currentTier?.id;
|
||||
|
||||
if (subscriptionTier) {
|
||||
console.log(`📊 [${email}] Subscription Identified: ${subscriptionTier}`);
|
||||
logger.info(`📊 [${email}] Subscription Identified: ${subscriptionTier}`);
|
||||
}
|
||||
|
||||
return [projectId, subscriptionTier];
|
||||
} else {
|
||||
console.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
||||
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [${email}] loadCodeAssist Network Error: ${error.message}`);
|
||||
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error.message}`);
|
||||
}
|
||||
|
||||
return [undefined, undefined];
|
||||
@@ -86,7 +87,7 @@ export class QuotaService {
|
||||
const url = QUOTA_API_URL;
|
||||
const maxRetries = 3;
|
||||
|
||||
console.log(`Sending quota request to ${url}`);
|
||||
logger.info(`Sending quota request to ${url}`);
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
@@ -104,11 +105,11 @@ export class QuotaService {
|
||||
subscriptionTier: subscriptionTier,
|
||||
};
|
||||
|
||||
console.log(`Quota API returned ${Object.keys(quotaResponse.models || {}).length} models:`);
|
||||
logger.info(`Quota API returned ${Object.keys(quotaResponse.models || {}).length} models:`);
|
||||
|
||||
if (quotaResponse.models) {
|
||||
for (const [name, info] of Object.entries(quotaResponse.models)) {
|
||||
console.log(` - ${name}`);
|
||||
logger.info(` - ${name}`);
|
||||
if (info.quotaInfo) {
|
||||
const fraction = info.quotaInfo.remainingFraction ?? 0;
|
||||
const percentage = Math.floor(fraction * 100);
|
||||
@@ -126,11 +127,16 @@ export class QuotaService {
|
||||
} catch (error: any) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const text = JSON.stringify(error.response?.data || '');
|
||||
let text = '';
|
||||
try {
|
||||
text = JSON.stringify(error.response?.data || '');
|
||||
} catch {
|
||||
text = '[Unable to serialize response data]';
|
||||
}
|
||||
|
||||
// ✅ Handle 403 Forbidden specifically - return immediately, do not retry
|
||||
if (status === 403) {
|
||||
console.warn(`Account no permission (403 Forbidden), marked as forbidden`);
|
||||
logger.warn(`Account no permission (403 Forbidden), marked as forbidden`);
|
||||
return {
|
||||
quotaData: {
|
||||
models: {},
|
||||
@@ -141,9 +147,9 @@ export class QuotaService {
|
||||
};
|
||||
}
|
||||
|
||||
console.warn(`API Error: ${status} - ${text} (Attempt ${attempt}/${maxRetries})`);
|
||||
logger.warn(`API Error: ${status} - ${text} (Attempt ${attempt}/${maxRetries})`);
|
||||
} else {
|
||||
console.warn(`Request Failed: ${error.message} (Attempt ${attempt}/${maxRetries})`);
|
||||
logger.warn(`Request Failed: ${error.message} (Attempt ${attempt}/${maxRetries})`);
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Global thought_signature storage shared by all endpoints
|
||||
* Used to capture and replay signatures for Gemini 3+ function calls when clients don't pass them back.
|
||||
*/
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
class SignatureStoreImpl {
|
||||
private static instance: SignatureStoreImpl;
|
||||
@@ -28,12 +29,12 @@ class SignatureStoreImpl {
|
||||
const newLen = sig.length;
|
||||
|
||||
if (newLen > existingLen) {
|
||||
console.log(
|
||||
logger.info(
|
||||
`[ThoughtSig] Storing new signature (length: ${newLen}, replacing old: ${existingLen})`,
|
||||
);
|
||||
this.signature = sig;
|
||||
} else {
|
||||
console.debug(
|
||||
logger.debug(
|
||||
`[ThoughtSig] Skipping shorter signature (new length: ${newLen}, existing: ${existingLen})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Jitter factor: ±20% */
|
||||
const JITTER_FACTOR = 0.2;
|
||||
|
||||
/** Maximum retry delay in milliseconds (30 seconds) */
|
||||
const MAX_DELAY_MS = 30000;
|
||||
|
||||
/**
|
||||
* Apply jitter to a delay value to prevent thundering herd
|
||||
* Returns delay ± JITTER_FACTOR (e.g., 1000ms ± 20% = 800-1200ms)
|
||||
*
|
||||
* @param delayMs - Base delay in milliseconds
|
||||
* @returns Jittered delay (minimum 1ms)
|
||||
*/
|
||||
export function applyJitter(delayMs: number): number {
|
||||
if (delayMs <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const jitterRange = delayMs * JITTER_FACTOR;
|
||||
const jitter = (Math.random() * 2 - 1) * jitterRange;
|
||||
return Math.max(1, Math.round(delayMs + jitter));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay with exponential backoff and jitter
|
||||
*
|
||||
* @param attempt - Current attempt number (0-indexed)
|
||||
* @param baseMs - Base delay for first retry (default 1000ms)
|
||||
* @returns Delay in milliseconds with jitter applied
|
||||
*/
|
||||
export function calculateRetryDelay(attempt: number, baseMs: number = 1000): number {
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, ...
|
||||
const exponentialDelay = baseMs * Math.pow(2, attempt);
|
||||
|
||||
// Cap at maximum delay
|
||||
const cappedDelay = Math.min(exponentialDelay, MAX_DELAY_MS);
|
||||
|
||||
// Apply jitter
|
||||
return applyJitter(cappedDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
*
|
||||
* @param ms - Milliseconds to sleep
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { isEmpty } from 'lodash-es';
|
||||
/**
|
||||
* Decode Base64-encoded signature if present
|
||||
* Gemini sends Base64, Claude expects raw string
|
||||
*
|
||||
* @param signature - The signature string (may be Base64 or raw)
|
||||
* @returns Decoded signature or undefined if input is empty
|
||||
*/
|
||||
export function decodeSignature(signature: string | undefined | null): string | undefined {
|
||||
if (isEmpty(signature)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(signature!, 'base64').toString('utf-8');
|
||||
|
||||
// Validate: Check if it looks like valid UTF-8 text
|
||||
// If decoding produces replacement characters, it wasn't valid Base64
|
||||
if (decoded && !decoded.includes('\ufffd') && decoded.length > 0) {
|
||||
// Additional check: re-encode and compare to detect false positives
|
||||
const reEncoded = Buffer.from(decoded).toString('base64');
|
||||
if (reEncoded === signature) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not valid Base64, return as-is
|
||||
}
|
||||
|
||||
// Return original if not Base64
|
||||
return signature!;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Stream Error Utilities
|
||||
* Classifies network errors and formats user-friendly messages
|
||||
*
|
||||
* Problem: Technical error messages confuse users
|
||||
* Solution: Classify errors and provide localized, actionable messages
|
||||
*/
|
||||
|
||||
export interface ClassifiedError {
|
||||
type: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a stream error and return user-friendly message
|
||||
*
|
||||
* @param error - The error object
|
||||
* @returns Classified error with type and localized message
|
||||
*/
|
||||
export function classifyStreamError(error: Error): ClassifiedError {
|
||||
const msg = error.message.toLowerCase();
|
||||
|
||||
if (msg.includes('timeout') || msg.includes('timedout')) {
|
||||
return {
|
||||
type: 'timeout_error',
|
||||
message: 'Request timed out. Please check your network connection or try again later.',
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('econnrefused') || msg.includes('connect')) {
|
||||
return {
|
||||
type: 'connection_error',
|
||||
message: 'Unable to connect to server. Please check your network.',
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('enotfound') || msg.includes('network') || msg.includes('dns')) {
|
||||
return {
|
||||
type: 'network_error',
|
||||
message: 'Network connection failed. Please check your network settings.',
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('socket') || msg.includes('aborted') || msg.includes('reset')) {
|
||||
return {
|
||||
type: 'stream_error',
|
||||
message: 'Data transfer interrupted. Please try again later.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unknown_error',
|
||||
message: `Unknown error occurred: ${error.message.substring(0, 100)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error as SSE event for Claude protocol
|
||||
*
|
||||
* @param errorType - Error type string
|
||||
* @param message - User-friendly message
|
||||
* @returns SSE-formatted error event string
|
||||
*/
|
||||
export function formatErrorForSSE(errorType: string, message: string): string {
|
||||
const errorEvent = {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
message: message,
|
||||
code: 'stream_error',
|
||||
},
|
||||
};
|
||||
|
||||
return `event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Thinking Block Utilities
|
||||
* Handles validation and graceful degradation of thinking blocks
|
||||
*
|
||||
* Problem: Invalid signatures cause thinking blocks to be dropped, losing content
|
||||
* Solution: Validate and convert invalid blocks to text instead of dropping
|
||||
*/
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/** Minimum length for a valid thought signature */
|
||||
const MIN_SIGNATURE_LENGTH = 10;
|
||||
|
||||
export interface ThinkingBlock {
|
||||
type: 'thinking';
|
||||
thinking: string;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface TextBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type ContentBlock = ThinkingBlock | TextBlock | { type: string; [key: string]: any };
|
||||
|
||||
/**
|
||||
* Check if a thinking block has a valid signature
|
||||
*
|
||||
* Rules:
|
||||
* - Empty thinking + any signature = valid (trailing signature case)
|
||||
* - Non-empty thinking + signature >= MIN_SIGNATURE_LENGTH = valid
|
||||
* - Non-empty thinking + no/short signature = invalid
|
||||
* - Non-thinking blocks = always valid
|
||||
*
|
||||
* @param block - Content block to validate
|
||||
* @returns true if block is valid or not a thinking block
|
||||
*/
|
||||
export function hasValidSignature(block: ContentBlock): boolean {
|
||||
if (block.type !== 'thinking') {
|
||||
return true; // Non-thinking blocks are always valid
|
||||
}
|
||||
|
||||
const thinkingBlock = block as ThinkingBlock;
|
||||
|
||||
// Empty thinking + any signature = valid (trailing signature)
|
||||
if (!thinkingBlock.thinking || thinkingBlock.thinking.length === 0) {
|
||||
return (
|
||||
thinkingBlock.signature !== undefined &&
|
||||
thinkingBlock.signature !== null &&
|
||||
thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
// Non-empty thinking needs a long enough signature
|
||||
return (
|
||||
thinkingBlock.signature !== undefined &&
|
||||
thinkingBlock.signature !== null &&
|
||||
thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully degrade an invalid thinking block
|
||||
*
|
||||
* - Valid blocks: return as-is
|
||||
* - Invalid with content: convert to text block
|
||||
* - Invalid without content: return null (should be dropped)
|
||||
*
|
||||
* @param block - Content block to process
|
||||
* @returns Processed block or null if should be dropped
|
||||
*/
|
||||
export function gracefulDegradeThinking(block: ContentBlock): ContentBlock | null {
|
||||
if (block.type !== 'thinking') {
|
||||
return block;
|
||||
}
|
||||
|
||||
const thinkingBlock = block as ThinkingBlock;
|
||||
|
||||
if (hasValidSignature(block)) {
|
||||
return block;
|
||||
}
|
||||
|
||||
// Invalid signature - check if we have content to preserve
|
||||
if (thinkingBlock.thinking && thinkingBlock.thinking.length > 0) {
|
||||
// Convert to text block to preserve content
|
||||
logger.info(
|
||||
`[Thinking-Utils] Converting invalid thinking block to text. Content length: ${thinkingBlock.thinking.length}`,
|
||||
);
|
||||
return {
|
||||
type: 'text',
|
||||
text: thinkingBlock.thinking,
|
||||
};
|
||||
}
|
||||
|
||||
// Empty content + invalid signature = drop
|
||||
logger.debug('[Thinking-Utils] Dropping empty thinking block with invalid signature');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an array of content blocks, applying graceful degradation
|
||||
*
|
||||
* @param blocks - Array of content blocks
|
||||
* @returns Processed array with invalid thinking blocks handled
|
||||
*/
|
||||
export function processThinkingBlocks(blocks: ContentBlock[]): ContentBlock[] {
|
||||
const result: ContentBlock[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const processed = gracefulDegradeThinking(block);
|
||||
if (processed !== null) {
|
||||
result.push(processed);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
||||
+401
-1
@@ -12,7 +12,7 @@ i18n
|
||||
caches: ['localStorage'],
|
||||
lookupLocalStorage: 'lang',
|
||||
},
|
||||
supportedLngs: ['en', 'zh-CN'],
|
||||
supportedLngs: ['en', 'zh-CN', 'ru'],
|
||||
load: 'currentOnly', // Only load the exact language code, not variants
|
||||
resources: {
|
||||
en: {
|
||||
@@ -30,9 +30,23 @@ i18n
|
||||
deleteBackup: 'Delete Backup',
|
||||
backupCurrent: 'Backup Current',
|
||||
retry: 'Retry',
|
||||
openLogs: 'Open Log Directory',
|
||||
},
|
||||
error: {
|
||||
generic: 'An unexpected error occurred.',
|
||||
keychainUnavailable: 'Keychain is unavailable.',
|
||||
keychainHint: {
|
||||
translocation:
|
||||
'Detected macOS App Translocation. Move the app to /Applications and reopen.',
|
||||
keychainDenied:
|
||||
'Keychain access denied. The app may be unsigned; see README for the self-signing workaround.',
|
||||
signNotarize: 'Please use a signed and notarized build when available.',
|
||||
},
|
||||
dataMigrationFailed: 'Unable to decrypt legacy account data.',
|
||||
dataMigrationHint: {
|
||||
relogin: 'Please re-login or re-add your accounts.',
|
||||
clearData: 'If the issue persists, clear local account data and sign in again.',
|
||||
},
|
||||
},
|
||||
nav: {
|
||||
accounts: 'Accounts',
|
||||
@@ -69,6 +83,7 @@ i18n
|
||||
description: 'Select your preferred language.',
|
||||
english: 'English',
|
||||
chinese: 'Chinese (Simplified)',
|
||||
russian: 'Russian',
|
||||
},
|
||||
about: {
|
||||
title: 'About',
|
||||
@@ -77,6 +92,7 @@ i18n
|
||||
version: 'Version',
|
||||
platform: 'Platform',
|
||||
license: 'License',
|
||||
openLogDir: 'Open',
|
||||
account: {
|
||||
title: 'Account Settings',
|
||||
description: 'Configure automatic account refresh and sync.',
|
||||
@@ -85,6 +101,23 @@ i18n
|
||||
auto_sync: 'Auto Sync Current Account',
|
||||
auto_sync_desc: 'Periodically sync active account information',
|
||||
},
|
||||
startup: {
|
||||
title: 'Startup',
|
||||
description: 'Control application launch behavior at system startup.',
|
||||
auto_startup: 'Start with system',
|
||||
auto_startup_desc: 'Launch at sign-in and keep the app in the system tray',
|
||||
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.',
|
||||
},
|
||||
privacy: {
|
||||
title: 'Privacy',
|
||||
description: 'Control how your data is used to improve the application.',
|
||||
error_reporting: 'Error Reporting',
|
||||
error_reporting_desc:
|
||||
'Send anonymous error reports to help us improve the app. No personal data is collected.',
|
||||
restart_note:
|
||||
'Changes to error reporting will take effect after restarting the application.',
|
||||
},
|
||||
proxy: {
|
||||
title: 'Upstream Proxy',
|
||||
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
||||
@@ -208,10 +241,16 @@ i18n
|
||||
refresh: 'Refresh Quota',
|
||||
delete: 'Delete Account',
|
||||
noQuota: 'No quota data',
|
||||
resetPrefix: 'reset',
|
||||
resetTime: 'Reset time',
|
||||
resetUnknown: 'Unknown',
|
||||
},
|
||||
list: {
|
||||
noAccounts: 'No cloud accounts added yet.',
|
||||
},
|
||||
error: {
|
||||
loadFailed: 'Failed to load cloud accounts.',
|
||||
},
|
||||
toast: {
|
||||
syncSuccess: {
|
||||
title: 'Sync Successful',
|
||||
@@ -227,6 +266,7 @@ i18n
|
||||
},
|
||||
quotaRefreshed: 'Quota refreshed',
|
||||
refreshFailed: 'Failed to refresh quota',
|
||||
pollFailed: 'Failed to poll quota for all accounts',
|
||||
switched: {
|
||||
title: 'Account switched!',
|
||||
description: 'Restarting Antigravity...',
|
||||
@@ -275,6 +315,10 @@ i18n
|
||||
api_key: 'API Key',
|
||||
auto_start: 'Auto Start with App',
|
||||
auto_start_desc: 'Start proxy service when application launches',
|
||||
local_access: 'Local network access:',
|
||||
no_token_warning: '⚠️ API key is not set. Access is open to everyone!',
|
||||
show_key: 'Show',
|
||||
hide_key: 'Hide',
|
||||
},
|
||||
mapping: {
|
||||
title: 'Model Mapping',
|
||||
@@ -304,9 +348,23 @@ i18n
|
||||
deleteBackup: '删除备份',
|
||||
backupCurrent: '备份当前账号',
|
||||
retry: '重试',
|
||||
openLogs: '打开日志目录',
|
||||
},
|
||||
error: {
|
||||
generic: '发生未知错误。',
|
||||
keychainUnavailable: 'Keychain 不可用。',
|
||||
keychainHint: {
|
||||
translocation:
|
||||
'检测到 macOS App Translocation。请将应用移动到 /Applications 后重试。',
|
||||
keychainDenied:
|
||||
'Keychain 访问被拒绝。应用可能未签名,可参考 README 的“macOS 自签”说明。',
|
||||
signNotarize: '请在可用时使用已签名并公证的版本。',
|
||||
},
|
||||
dataMigrationFailed: '旧版账号数据无法解密。',
|
||||
dataMigrationHint: {
|
||||
relogin: '请重新登录或重新添加账号。',
|
||||
clearData: '如仍失败,请清理本地账号数据后再登录。',
|
||||
},
|
||||
},
|
||||
nav: {
|
||||
accounts: '账号',
|
||||
@@ -343,6 +401,7 @@ i18n
|
||||
description: '选择您的首选语言。',
|
||||
english: '英语',
|
||||
chinese: '中文 (简体)',
|
||||
russian: '俄语',
|
||||
},
|
||||
about: {
|
||||
title: '关于',
|
||||
@@ -351,6 +410,7 @@ i18n
|
||||
version: '版本',
|
||||
platform: '平台',
|
||||
license: '许可证',
|
||||
openLogDir: '打开',
|
||||
account: {
|
||||
title: '账号设置',
|
||||
description: '配置自动刷新和同步账号。',
|
||||
@@ -359,6 +419,21 @@ i18n
|
||||
auto_sync: '自动获取当前账号',
|
||||
auto_sync_desc: '定期自动同步当前活跃账号信息',
|
||||
},
|
||||
startup: {
|
||||
title: '启动',
|
||||
description: '控制系统启动时的应用行为。',
|
||||
auto_startup: '随系统启动',
|
||||
auto_startup_desc: '登录时自动启动并保持在托盘里',
|
||||
macos_hint:
|
||||
'macOS 需要应用已签名才可生效。如自启动失败,请自行签名或在系统设置中手动启用。',
|
||||
},
|
||||
privacy: {
|
||||
title: '隐私',
|
||||
description: '控制你的数据如何用于改进应用。',
|
||||
error_reporting: '错误上报',
|
||||
error_reporting_desc: '发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
||||
restart_note: '错误上报设置会在重启应用后生效。',
|
||||
},
|
||||
proxy: {
|
||||
title: '上游代理',
|
||||
description: '为发往 Google/Gemini API 的请求配置代理。',
|
||||
@@ -480,10 +555,16 @@ i18n
|
||||
refresh: '刷新配额',
|
||||
delete: '删除账号',
|
||||
noQuota: '无配额数据',
|
||||
resetPrefix: '重置',
|
||||
resetTime: '重置时间',
|
||||
resetUnknown: '未知',
|
||||
},
|
||||
list: {
|
||||
noAccounts: '暂无云账号。',
|
||||
},
|
||||
error: {
|
||||
loadFailed: '加载云账号失败。',
|
||||
},
|
||||
toast: {
|
||||
syncSuccess: {
|
||||
title: '同步成功',
|
||||
@@ -499,6 +580,7 @@ i18n
|
||||
},
|
||||
quotaRefreshed: '配额已刷新',
|
||||
refreshFailed: '刷新配额失败',
|
||||
pollFailed: '轮询所有账号配额失败',
|
||||
switched: {
|
||||
title: '账号已切换!',
|
||||
description: '正在重启 Antigravity...',
|
||||
@@ -564,5 +646,323 @@ i18n
|
||||
},
|
||||
},
|
||||
},
|
||||
ru: {
|
||||
translation: {
|
||||
appName: 'Antigravity Manager',
|
||||
status: {
|
||||
checking: 'Проверка статуса...',
|
||||
running: 'Antigravity работает в фоне',
|
||||
stopped: 'Служба Antigravity остановлена',
|
||||
},
|
||||
action: {
|
||||
stop: 'Стоп',
|
||||
start: 'Старт',
|
||||
switch: 'Переключить',
|
||||
deleteBackup: 'Удалить резервную копию',
|
||||
backupCurrent: 'Создать бэкап текущего',
|
||||
retry: 'Повторить',
|
||||
openLogs: 'Открыть папку логов',
|
||||
},
|
||||
error: {
|
||||
generic: 'Произошла непредвиденная ошибка.',
|
||||
keychainUnavailable: 'Keychain недоступен.',
|
||||
keychainHint: {
|
||||
translocation:
|
||||
'Обнаружено App Translocation (macOS). Переместите приложение в /Applications и откройте заново.',
|
||||
keychainDenied:
|
||||
'Доступ к Keychain запрещен. Возможно, приложение не подписано. См. README для инструкции.',
|
||||
signNotarize: 'Пожалуйста, используйте подписанную версию приложения.',
|
||||
},
|
||||
dataMigrationFailed: 'Не удалось расшифровать данные старого аккаунта.',
|
||||
dataMigrationHint: {
|
||||
relogin: 'Пожалуйста, перезайдите в аккаунт или добавьте его заново.',
|
||||
clearData: 'Если ошибка сохраняется, очистите локальные данные и войдите снова.',
|
||||
},
|
||||
},
|
||||
nav: {
|
||||
accounts: 'Аккаунты',
|
||||
proxy: 'API Прокси',
|
||||
settings: 'Настройки',
|
||||
},
|
||||
account: {
|
||||
current: 'Текущий',
|
||||
lastUsed: 'Последнее использование {{time}}',
|
||||
},
|
||||
home: {
|
||||
title: 'Аккаунты',
|
||||
description: 'Управление вашими аккаунтами Antigravity Google Gemini.',
|
||||
noBackups: {
|
||||
title: 'Бэкапы не найдены',
|
||||
description:
|
||||
'Создайте резервную копию вашего текущего аккаунта Antigravity, чтобы начать.',
|
||||
action: 'Создать бэкап текущего аккаунта',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
description: 'Управление настройками приложения.',
|
||||
general: 'Общие',
|
||||
connection: 'Подключение',
|
||||
models: 'Модели',
|
||||
appearance: {
|
||||
title: 'Внешний вид',
|
||||
description: 'Настройте внешний вид Antigravity Manager.',
|
||||
},
|
||||
darkMode: 'Темная тема',
|
||||
darkModeDescription: 'Включить темную тему для комфортной работы ночью.',
|
||||
language: {
|
||||
title: 'Язык',
|
||||
description: 'Выберите язык интерфейса.',
|
||||
english: 'English',
|
||||
chinese: '中文 (简体)',
|
||||
russian: 'Русский',
|
||||
},
|
||||
about: {
|
||||
title: 'О программе',
|
||||
description: 'Информация о приложении.',
|
||||
},
|
||||
version: 'Версия',
|
||||
platform: 'Платформа',
|
||||
license: 'Лицензия',
|
||||
openLogDir: 'Открыть',
|
||||
account: {
|
||||
title: 'Настройки аккаунта',
|
||||
description: 'Настройка автоматического обновления и синхронизации.',
|
||||
auto_refresh: 'Автообновление квот',
|
||||
auto_refresh_desc: 'Периодически обновлять информацию о квотах для всех аккаунтов',
|
||||
auto_sync: 'Автосинхронизация текущего',
|
||||
auto_sync_desc: 'Периодически синхронизировать активный аккаунт',
|
||||
},
|
||||
startup: {
|
||||
title: 'Автозагрузка',
|
||||
description: 'Управление запуском приложения при старте системы.',
|
||||
auto_startup: 'Запускать вместе с системой',
|
||||
auto_startup_desc: 'Запускать при входе и сворачивать в трей',
|
||||
macos_hint:
|
||||
'macOS требует подписанное приложение для работы элементов входа. Если автозапуск не работает, подпишите приложение или включите его вручную в системных настройках.',
|
||||
},
|
||||
privacy: {
|
||||
title: 'Конфиденциальность',
|
||||
description: 'Управление использованием данных для улучшения приложения.',
|
||||
error_reporting: 'Отчеты об ошибках',
|
||||
error_reporting_desc:
|
||||
'Отправляйте анонимные отчеты об ошибках, чтобы помочь нам улучшить приложение. Личные данные не собираются.',
|
||||
restart_note:
|
||||
'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
|
||||
},
|
||||
proxy: {
|
||||
title: 'Верхнеуровневый прокси',
|
||||
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
|
||||
enable: 'Включить прокси',
|
||||
url: 'URL прокси',
|
||||
timeout: 'Тайм-аут запроса (сек)',
|
||||
},
|
||||
modelMapping: {
|
||||
title: 'Маппинг моделей',
|
||||
description:
|
||||
'Сопоставьте модели Claude Code с моделями Antigravity. Оптимизируйте затраты и скорость, разумно маршрутизируя запросы.',
|
||||
claudeKeyword: 'Модель Claude (Ключевое слово)',
|
||||
targetGemini: 'Целевая модель Gemini',
|
||||
addPlaceholderKey: 'например, op-3',
|
||||
addPlaceholderValue: 'например, gemini-2.0-flash-exp',
|
||||
noMappings: 'Нет пользовательских сопоставлений.',
|
||||
mapsTo: 'Мапится в',
|
||||
default: 'По умолчанию',
|
||||
restoreDefaults: 'Сбросить по умолчанию',
|
||||
},
|
||||
examples: {
|
||||
title: 'Примеры использования',
|
||||
description: 'Примеры команд для вызова локального API прокси.',
|
||||
curl: 'cURL',
|
||||
python: 'Python',
|
||||
copy: 'Копировать',
|
||||
copied: 'Скопировано!',
|
||||
openai_protocol: 'Протокол OpenAI',
|
||||
anthropic_protocol: 'Протокол Anthropic',
|
||||
openai_tools: 'Cursor, Windsurf, NextChat',
|
||||
anthropic_tools: 'Claude Code CLI',
|
||||
flash: 'Быстрый (Flash)',
|
||||
pro: 'Про (Pro)',
|
||||
flash_preview: 'Превью (Flash Preview)',
|
||||
pro_high: 'Лучший (Pro High)',
|
||||
sonnet: 'Мыслящий (Sonnet)',
|
||||
opus: 'Opus',
|
||||
},
|
||||
gateway: {
|
||||
title: 'Служба API Прокси',
|
||||
description: 'Управление локальным прокси-сервером API.',
|
||||
status_running: 'Запущено',
|
||||
status_stopped: 'Остановлено',
|
||||
action_start: 'Запустить службу',
|
||||
action_stop: 'Остановить службу',
|
||||
accounts_info: '{{count}} аккаунтов доступно',
|
||||
port: 'Порт прослушивания',
|
||||
port_hint: 'По умолчанию 8045, требуется перезапуск для применения',
|
||||
timeout: 'Тайм-аут запроса',
|
||||
timeout_hint: 'По умолчанию 120с, диапазон 30-600с',
|
||||
api_key: 'API Ключ',
|
||||
regenerate_key: 'Перегенерировать',
|
||||
regenerateConfirm: {
|
||||
title: 'Перегенерировать API ключ?',
|
||||
description:
|
||||
'Это немедленно сделает текущий ключ недействительным. Все приложения, использующие старый ключ, перестанут работать.',
|
||||
cancel: 'Отмена',
|
||||
confirm: 'Перегенерировать',
|
||||
},
|
||||
key_warning: 'Держите ваш API ключ в секрете. Не передавайте его никому.',
|
||||
auto_start: 'Автозапуск с приложением',
|
||||
auto_start_desc: 'Запускать прокси-службу при старте приложения',
|
||||
},
|
||||
proxy_tab: 'Прокси',
|
||||
save: 'Сохранить настройки',
|
||||
},
|
||||
toast: {
|
||||
backupSuccess: {
|
||||
title: 'Успешно',
|
||||
description: 'Бэкап аккаунта создан.',
|
||||
},
|
||||
backupError: {
|
||||
title: 'Ошибка',
|
||||
description: 'Не удалось создать бэкап: {{error}}',
|
||||
},
|
||||
switchSuccess: {
|
||||
title: 'Успешно',
|
||||
description: 'Аккаунт переключен.',
|
||||
},
|
||||
switchError: {
|
||||
title: 'Ошибка',
|
||||
description: 'Не удалось переключить аккаунт: {{error}}',
|
||||
},
|
||||
deleteSuccess: {
|
||||
title: 'Успешно',
|
||||
description: 'Бэкап удален.',
|
||||
},
|
||||
deleteError: {
|
||||
title: 'Ошибка',
|
||||
description: 'Не удалось удалить бэкап: {{error}}',
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
title: 'Аккаунты',
|
||||
description: 'Управление пулом аккаунтов Google Gemini.',
|
||||
autoSwitch: 'Авто-переключение',
|
||||
addAccount: 'Добавить аккаунт',
|
||||
syncFromIDE: 'Синхр. из IDE',
|
||||
checkQuota: 'Проверить квоту',
|
||||
polling: 'Опрос запущен',
|
||||
authDialog: {
|
||||
title: 'Добавить Google Аккаунт',
|
||||
description: 'Для добавления аккаунта необходимо авторизовать приложение.',
|
||||
openLogin: 'Открыть страницу входа',
|
||||
authCode: 'Код авторизации',
|
||||
placeholder: 'Вставьте код, начинающийся с 4/...',
|
||||
instruction:
|
||||
'Откроется браузер для входа в Google. Скопируйте код со страницы localhost и вставьте сюда.',
|
||||
verify: 'Проверить и добавить',
|
||||
},
|
||||
card: {
|
||||
active: 'Активен',
|
||||
use: 'Использовать',
|
||||
rateLimited: 'Лимит исчерпан',
|
||||
left: 'ост.',
|
||||
used: 'Исп.',
|
||||
unknown: 'Неизвестный',
|
||||
actions: 'Действия',
|
||||
useAccount: 'Использовать этот аккаунт',
|
||||
refresh: 'Обновить квоту',
|
||||
delete: 'Удалить аккаунт',
|
||||
noQuota: 'Нет данных',
|
||||
resetPrefix: 'Сброс',
|
||||
resetTime: 'Время сброса',
|
||||
resetUnknown: 'Неизвестно',
|
||||
},
|
||||
list: {
|
||||
noAccounts: 'Нет добавленных облачных аккаунтов.',
|
||||
},
|
||||
error: {
|
||||
loadFailed: 'Не удалось загрузить аккаунты.',
|
||||
},
|
||||
toast: {
|
||||
syncSuccess: {
|
||||
title: 'Синхронизация успешна',
|
||||
description: 'Импортирован {{email}} из IDE.',
|
||||
},
|
||||
syncFailed: {
|
||||
title: 'Синхронизация не удалась',
|
||||
description: 'В базе данных IDE не найдено активных аккаунтов.',
|
||||
},
|
||||
addSuccess: 'Аккаунт успешно добавлен!',
|
||||
addFailed: {
|
||||
title: 'Не удалось добавить аккаунт',
|
||||
},
|
||||
quotaRefreshed: 'Квота обновлена',
|
||||
refreshFailed: 'Не удалось обновить квоту',
|
||||
pollFailed: 'Не удалось опросить квоты всех аккаунтов',
|
||||
switched: {
|
||||
title: 'Аккаунт переключен!',
|
||||
description: 'Перезапуск Antigravity...',
|
||||
},
|
||||
switchFailed: 'Не удалось переключить аккаунт',
|
||||
deleted: 'Аккаунт удален',
|
||||
deleteFailed: 'Не удалось удалить аккаунт',
|
||||
deleteConfirm: 'Вы уверены, что хотите удалить этот аккаунт?',
|
||||
autoSwitchOn: 'Авто-переключение включено',
|
||||
autoSwitchOff: 'Авто-переключение выключено',
|
||||
updateSettingsFailed: 'Не удалось обновить настройки',
|
||||
},
|
||||
batch: {
|
||||
selected: 'Выбрано {{count}}',
|
||||
delete: 'Удалить выбранные',
|
||||
refresh: 'Обновить выбранные',
|
||||
selectAll: 'Выбрать все',
|
||||
clear: 'Снять выделение',
|
||||
confirmDelete: 'Вы уверены, что хотите удалить {{count}} аккаунтов?',
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
title: 'API Прокси',
|
||||
description: 'Управление локальным прокси-сервером API.',
|
||||
save: 'Сохранить',
|
||||
copy: 'Копировать',
|
||||
regenerate: 'Перегенерировать',
|
||||
regenerateConfirm: {
|
||||
title: 'Перегенерировать API ключ?',
|
||||
description:
|
||||
'Это немедленно сделает текущий ключ недействительным. Все приложения, использующие старый ключ, перестанут работать.',
|
||||
cancel: 'Отмена',
|
||||
confirm: 'Перегенерировать',
|
||||
},
|
||||
service: {
|
||||
title: 'Статус сервиса',
|
||||
description: 'Управление локальным сервером.',
|
||||
running: 'Работает',
|
||||
stopped: 'Остановлен',
|
||||
start: 'Запустить',
|
||||
stop: 'Остановить',
|
||||
},
|
||||
config: {
|
||||
port: 'Порт',
|
||||
timeout: 'Тайм-аут',
|
||||
api_key: 'API Ключ',
|
||||
auto_start: 'Автозапуск',
|
||||
auto_start_desc: 'Запускать прокси при старте приложения',
|
||||
local_access: 'Доступ из локальной сети:',
|
||||
no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!',
|
||||
show_key: 'Показать',
|
||||
hide_key: 'Скрыть',
|
||||
},
|
||||
mapping: {
|
||||
title: 'Маппинг моделей',
|
||||
description: 'Настройка маршрутизации моделей.',
|
||||
maps_to: '->',
|
||||
restore: 'Сброс',
|
||||
},
|
||||
examples: {
|
||||
title: 'Примеры',
|
||||
description: 'Примеры использования локального прокси.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+182
-15
@@ -1,31 +1,61 @@
|
||||
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
|
||||
import './instrument'; // MUST be the first import to ensure Sentry initializes before app ready
|
||||
import { app, BrowserWindow, dialog, shell } from 'electron';
|
||||
import type { MessageBoxOptions } from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import squirrelStartup from 'electron-squirrel-startup';
|
||||
|
||||
// import { installExtension, REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
||||
import { ipcMain } from 'electron/main';
|
||||
import { ipcContext } from '@/ipc/context';
|
||||
import { IPC_CHANNELS } from './constants';
|
||||
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
|
||||
import { logger } from './utils/logger';
|
||||
import {
|
||||
getExpectedInstallRoot,
|
||||
getInstallNoticeText,
|
||||
isRunningFromExpectedInstallDir as isRunningFromExpectedInstallDirUtil,
|
||||
resolveInstallNoticeLanguage,
|
||||
} from './utils/installNotice';
|
||||
import { CloudAccountRepo } from './ipc/database/cloudHandler';
|
||||
import { initDatabase } from './ipc/database/handler';
|
||||
import { CloudMonitorService } from './services/CloudMonitorService';
|
||||
|
||||
// Static Imports to fix Bundle Resolution Errors
|
||||
import { AuthServer } from './ipc/cloud/authServer';
|
||||
import { bootstrapNestServer, stopNestServer } from './server/main';
|
||||
import { initTray, setTrayLanguage } from './ipc/tray/handler';
|
||||
import { initTray, setTrayLanguage, destroyTray } from './ipc/tray/handler';
|
||||
import { rpcHandler } from './ipc/handler';
|
||||
import { ConfigManager } from './ipc/config/manager';
|
||||
import { AppConfig } from './types/config';
|
||||
import { isAutoStartLaunch, syncAutoStart } from './utils/autoStart';
|
||||
|
||||
const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log');
|
||||
|
||||
/**
|
||||
* Safely stringify an object, handling circular references
|
||||
*/
|
||||
function safeStringifyPacket(obj: unknown): string {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(obj, (key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
function logPacket(data: any) {
|
||||
try {
|
||||
fs.appendFileSync(packetLogPath, `[${new Date().toISOString()}] ${JSON.stringify(data)}\n`);
|
||||
fs.appendFileSync(
|
||||
packetLogPath,
|
||||
`[${new Date().toISOString()}] ${safeStringifyPacket(data)}\n`,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.error(e.message);
|
||||
logger.error('Failed to append ORPC packet log', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +66,11 @@ ipcMain.on(IPC_CHANNELS.CHANGE_LANGUAGE, (event, lang) => {
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
if (squirrelStartup) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
@@ -43,6 +78,81 @@ const inDevelopment = process.env.NODE_ENV === 'development';
|
||||
let globalMainWindow: BrowserWindow | null = null;
|
||||
// let tray: Tray | null = null; // Moved to tray/handler.ts
|
||||
let isQuitting = false;
|
||||
let startupConfig: AppConfig | null = null;
|
||||
let shouldStartHidden = false;
|
||||
let hasShownInstallNotice = false;
|
||||
|
||||
function isRunningFromExpectedInstallDir() {
|
||||
return isRunningFromExpectedInstallDirUtil({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
localAppData: process.env.LOCALAPPDATA,
|
||||
appName: app.getName(),
|
||||
execPath: process.execPath,
|
||||
});
|
||||
}
|
||||
|
||||
function showWindowsInstallNoticeIfNeeded() {
|
||||
if (hasShownInstallNotice) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunningFromExpectedInstallDir()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedRoot = getExpectedInstallRoot({
|
||||
platform: process.platform,
|
||||
localAppData: process.env.LOCALAPPDATA,
|
||||
appName: app.getName(),
|
||||
});
|
||||
if (!expectedRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShownInstallNotice = true;
|
||||
const language = resolveInstallNoticeLanguage({
|
||||
configLanguage: startupConfig?.language,
|
||||
locale: app.getLocale(),
|
||||
});
|
||||
const text = getInstallNoticeText(language);
|
||||
|
||||
const options: MessageBoxOptions = {
|
||||
type: 'info',
|
||||
title: text.title,
|
||||
message: text.message,
|
||||
detail: `${text.detailPrefix}${expectedRoot}`,
|
||||
buttons: [...text.buttons],
|
||||
defaultId: 1,
|
||||
};
|
||||
|
||||
const showPromise = globalMainWindow
|
||||
? dialog.showMessageBox(globalMainWindow, options)
|
||||
: dialog.showMessageBox(options);
|
||||
|
||||
showPromise.then(({ response }) => {
|
||||
if (response === 0) {
|
||||
shell.openPath(expectedRoot);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
logger.info('Second instance detected, focusing existing window');
|
||||
if (app.isReady()) {
|
||||
createWindow({ startHidden: false });
|
||||
return;
|
||||
}
|
||||
app.whenReady().then(() => {
|
||||
createWindow({ startHidden: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
process.on('exit', (code) => {
|
||||
logger.info(`Process exit event triggered with code: ${code}`);
|
||||
@@ -55,7 +165,22 @@ process.on('before-exit', (code) => {
|
||||
|
||||
// let tray: Tray | null = null; // Moved to tray/handler.ts
|
||||
|
||||
function createWindow() {
|
||||
function createWindow({ startHidden }: { startHidden: boolean }) {
|
||||
if (globalMainWindow && !globalMainWindow.isDestroyed()) {
|
||||
if (startHidden) {
|
||||
globalMainWindow.hide();
|
||||
return;
|
||||
}
|
||||
if (globalMainWindow.isMinimized()) {
|
||||
globalMainWindow.restore();
|
||||
}
|
||||
if (!globalMainWindow.isVisible()) {
|
||||
globalMainWindow.show();
|
||||
}
|
||||
globalMainWindow.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('createWindow: start');
|
||||
const preload = path.join(__dirname, 'preload.js');
|
||||
logger.info(`createWindow: preload path: ${preload}`);
|
||||
@@ -64,6 +189,7 @@ function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: !startHidden,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
devTools: inDevelopment,
|
||||
@@ -79,6 +205,10 @@ function createWindow() {
|
||||
});
|
||||
globalMainWindow = mainWindow;
|
||||
logger.info('createWindow: BrowserWindow instance created');
|
||||
if (startHidden) {
|
||||
mainWindow.hide();
|
||||
logger.info('createWindow: startHidden enabled, window hidden');
|
||||
}
|
||||
|
||||
logger.info('createWindow: setting main window in ipcContext');
|
||||
ipcContext.setMainWindow(mainWindow);
|
||||
@@ -93,6 +223,7 @@ function createWindow() {
|
||||
}
|
||||
|
||||
logger.info('Window created');
|
||||
showWindowsInstallNoticeIfNeeded();
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
@@ -106,6 +237,7 @@ function createWindow() {
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
logger.info('Window closed event triggered');
|
||||
globalMainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (event, details) => {
|
||||
@@ -120,8 +252,13 @@ function createWindow() {
|
||||
logger.info('Page finished loading successfully');
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
|
||||
logger.info(`[Renderer Console] ${message} (${sourceId}:${line})`);
|
||||
mainWindow.webContents.on('console-message', (details) => {
|
||||
const { level, message, lineNumber, sourceId } = details;
|
||||
logger.info(`[Renderer Console][${level}] ${message} (${sourceId}:${lineNumber})`);
|
||||
});
|
||||
|
||||
mainWindow.on('focus', () => {
|
||||
CloudMonitorService.handleAppFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,8 +266,18 @@ app.on('child-process-gone', (event, details) => {
|
||||
logger.error('Child process gone:', details);
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
logger.info('App before-quit event triggered - isQuitting set to true');
|
||||
});
|
||||
|
||||
app.on('will-quit', (event) => {
|
||||
logger.info('App will quit event triggered');
|
||||
try {
|
||||
destroyTray();
|
||||
} catch (err) {
|
||||
logger.error('Failed to destroy tray during will-quit', err);
|
||||
}
|
||||
});
|
||||
|
||||
app.on('quit', (event, exitCode) => {
|
||||
@@ -168,7 +315,7 @@ async function setupORPC() {
|
||||
|
||||
logPacket(data);
|
||||
} catch (e) {
|
||||
console.log('[RAW ORPC MSG] (unparseable)', msgEvent.data);
|
||||
logger.debug('[RAW ORPC MSG] (unparseable)', msgEvent.data);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,13 +340,36 @@ process.on('unhandledRejection', (reason) => {
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
logger.info('Step: Initialize CloudAccountRepo');
|
||||
try {
|
||||
await CloudAccountRepo.init();
|
||||
} catch (e) {
|
||||
logger.error('Startup: Failed to initialize CloudAccountRepo', e);
|
||||
// We might want to exit here or show a dialog, but for now we proceed
|
||||
// though functionality will be broken.
|
||||
}
|
||||
|
||||
logger.info('Step: Initialize Antigravity DB (WAL Mode)');
|
||||
initDatabase();
|
||||
})
|
||||
.then(() => {
|
||||
logger.info('Step: Load Config');
|
||||
const config = ConfigManager.loadConfig();
|
||||
startupConfig = config;
|
||||
syncAutoStart(config);
|
||||
shouldStartHidden = isAutoStartLaunch() && config.auto_startup;
|
||||
if (shouldStartHidden) {
|
||||
logger.info('Startup: Auto-start detected, window will start hidden');
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
logger.info('Step: setupORPC');
|
||||
return setupORPC();
|
||||
})
|
||||
.then(async () => {
|
||||
logger.info('Step: createWindow');
|
||||
await createWindow();
|
||||
await createWindow({ startHidden: shouldStartHidden });
|
||||
})
|
||||
.then(() => {
|
||||
logger.info('Step: installExtensions (SKIPPED)');
|
||||
@@ -212,14 +382,11 @@ app
|
||||
.then(async () => {
|
||||
// Initialize Cloud Monitor if enabled
|
||||
try {
|
||||
// Initialize DB & Migrate to Encrypted Storage
|
||||
await CloudAccountRepo.init();
|
||||
|
||||
// Start OAuth Server
|
||||
AuthServer.start();
|
||||
|
||||
// Gateway Server (NestJS) - auto-start if enabled
|
||||
const config = ConfigManager.loadConfig();
|
||||
const config = startupConfig || ConfigManager.loadConfig();
|
||||
if (config.proxy?.auto_start) {
|
||||
const port = config.proxy?.port || 8045;
|
||||
// Default to a valid ProxyConfig object if null, although loadConfig ensures defaults
|
||||
@@ -261,7 +428,7 @@ app.on('window-all-closed', () => {
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
createWindow({ startHidden: false });
|
||||
}
|
||||
});
|
||||
//osX only ends
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user