Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3573009e0a | ||
|
|
afe8859f16 | ||
|
|
bf4bcf4f96 | ||
|
|
fd1b2b8bc0 | ||
|
|
0be7d7fea2 | ||
|
|
c73537d44c | ||
|
|
2f42d70d78 | ||
|
|
425b0009b8 | ||
|
|
9b37bdb2d9 | ||
|
|
1258b32cb5 | ||
|
|
98c08bd64f | ||
|
|
8c8b328120 | ||
|
|
a225791e71 | ||
|
|
1a1c0acd16 | ||
|
|
ce21283170 | ||
|
|
2a5e54184f | ||
|
|
ccc5d36a0d | ||
|
|
bfdb58fc81 | ||
|
|
b32dd24765 | ||
|
|
2c81d129f9 | ||
|
|
f4b713c3f4 | ||
|
|
5b130aca66 | ||
|
|
4653fa2b41 | ||
|
|
a3eabc2aaa | ||
|
|
099671a4e5 | ||
|
|
0f9d08018b | ||
|
|
83489d5084 | ||
|
|
0454aecf70 | ||
|
|
27da62afc9 | ||
|
|
faabf7ba7a | ||
|
|
433dccbaa9 | ||
|
|
e524a340cb | ||
|
|
6577f1c51f | ||
|
|
3e1e10b2a5 | ||
|
|
7093c982ba | ||
|
|
440a934280 | ||
|
|
87887dc4b9 | ||
|
|
00ff50055f | ||
|
|
d0c282dfff | ||
|
|
bc35b96b5d |
No files matched your search
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: antigravity-manager
|
||||
description: Comprehensive guide to Antigravity Manager architecture, workflows, and development. Use this to understand how to work on the project.
|
||||
name: geminiNexus-manager
|
||||
description: Comprehensive guide to Gemini Nexus Manager architecture, workflows, and development. Use this to understand how to work on the project.
|
||||
---
|
||||
|
||||
# Antigravity Manager Developer Guide
|
||||
# Gemini Nexus Manager Developer Guide
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
Antigravity Manager is a hybrid Desktop Application built with Electron, React, and NestJS. It follows a modular architecture where the frontend (Renderer) communicates with the backend (Main) via type-safe IPC (ORPC).
|
||||
Gemini Nexus Manager is a hybrid Desktop Application built with Electron, React, and NestJS. It follows a modular architecture where the frontend (Renderer) communicates with the backend (Main) via type-safe IPC (ORPC).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -69,7 +69,7 @@ The project uses `orpc` for type-safe communication.
|
||||
|
||||
### Database Access
|
||||
|
||||
Data is stored in a local SQLite file (`test.db` in dev, user data in prod).
|
||||
Data is stored in a local SQLite file.
|
||||
|
||||
- Use `Better-SQLite3` for direct access.
|
||||
- Logic should be encapsulated in `src/services` or `src/ipc`.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# lodash-es-runtime-guards
|
||||
|
||||
Replace runtime `typeof` checks and ad-hoc empty-string checks with `lodash-es` predicates.
|
||||
|
||||
## When to Use
|
||||
|
||||
- You see runtime checks like `typeof value === 'string'`.
|
||||
- You see object guards like `value && typeof value === 'object'`.
|
||||
- You see emptiness checks like `value.trim() !== ''` or `value.trim() === ''`.
|
||||
- You want consistent guard style across app, server, IPC, and tests.
|
||||
|
||||
## Do Not Replace
|
||||
|
||||
- Type-level `typeof` (TypeScript only), for example:
|
||||
- `ReturnType<typeof fn>`
|
||||
- `keyof typeof CONST_MAP`
|
||||
- `z.infer<typeof Schema>`
|
||||
|
||||
These are compile-time types and must remain as-is.
|
||||
|
||||
## Guard Mapping
|
||||
|
||||
- `typeof x === 'string'` -> `isString(x)`
|
||||
- `typeof x !== 'string'` -> `!isString(x)`
|
||||
- `typeof x === 'number'` -> `isNumber(x)`
|
||||
- `typeof x === 'boolean'` -> `isBoolean(x)`
|
||||
- `typeof x === 'function'` -> `isFunction(x)`
|
||||
- `typeof x === 'undefined'` -> `isUndefined(x)`
|
||||
- `x && typeof x === 'object'` -> `isObjectLike(x)`
|
||||
- `typeof x === 'object' && x !== null` -> `isObjectLike(x)`
|
||||
- `typeof x === 'object' && !Array.isArray(x)` -> `isObjectLike(x) && !isArray(x)`
|
||||
|
||||
## String Emptiness Pattern
|
||||
|
||||
- Prefer:
|
||||
- `isEmpty(value.trim())`
|
||||
- Instead of:
|
||||
- `value.trim() === ''`
|
||||
- `value.trim() !== ''`
|
||||
- `value.trim().length === 0`
|
||||
|
||||
## Import Style
|
||||
|
||||
- Use named imports from `lodash-es`:
|
||||
|
||||
```ts
|
||||
import { isString, isNumber, isObjectLike, isEmpty } from 'lodash-es';
|
||||
```
|
||||
|
||||
- Do not use full-package imports.
|
||||
- Reuse existing imports in file; avoid duplicate imports.
|
||||
|
||||
## Refactor Checklist
|
||||
|
||||
1. Replace runtime checks first, preserving behavior.
|
||||
2. Keep array/object branching semantics unchanged.
|
||||
3. Re-scan:
|
||||
|
||||
```bash
|
||||
rg -n "typeof\s+[^\n]+(?:===|!==)\s*'" src
|
||||
```
|
||||
|
||||
4. Confirm only type-level `typeof` remains:
|
||||
|
||||
```bash
|
||||
rg -n "\btypeof\b" src
|
||||
```
|
||||
|
||||
5. Run verification:
|
||||
|
||||
```bash
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
name: Check formatting
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests-unit:
|
||||
check:
|
||||
name: Prettier Format Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run format check
|
||||
run: npm run format
|
||||
@@ -1,17 +1,29 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: ESLint Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
@@ -1,22 +1,53 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests-unit:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit
|
||||
run: npm test
|
||||
|
||||
type-check:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npm run type-check
|
||||
|
||||
# NOTE: E2E tests are disabled in CI because Electron apps require a graphical environment.
|
||||
# Run E2E tests locally with: npm run test:e2e
|
||||
@@ -0,0 +1,29 @@
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
|
||||
Developers and power users who manage multiple AI accounts (Google Gemini, Claude) through a desktop Electron app. They use IDEs like Cursor, OpenCode, and Windsurf. They need to monitor token usage across accounts in real-time to avoid quota exhaustion.
|
||||
|
||||
### Brand Personality
|
||||
|
||||
- **Professional**: Clean, precise, no clutter
|
||||
- **Technical**: Data-dense but readable, developer-friendly
|
||||
- **Efficient**: Information at a glance, minimal clicks
|
||||
- 3 words: "precise", "technical", "focused"
|
||||
|
||||
### Aesthetic Direction
|
||||
|
||||
- Dark mode primary (this is a developer tool used for long sessions)
|
||||
- Deep navy/slate backgrounds, not pure black
|
||||
- Sharp accent colors for data visualization (subtle blues, teals, amber for warnings)
|
||||
- High information density without visual noise
|
||||
- Monospace for numbers/data, clean sans-serif for UI chrome
|
||||
- No gradients on text, no glassmorphism, no rounded-everything
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. Data first — charts and numbers are the heroes
|
||||
2. Every pixel earns its place — no decorative elements
|
||||
3. Density over whitespace — developers want to see everything
|
||||
4. Motion only for state changes — no gratuitous animations
|
||||
5. Consistent with shadcn/ui + Tailwind system already in place
|
||||
@@ -0,0 +1,58 @@
|
||||
Here is the code review report for the Token Usage Counter feature.
|
||||
|
||||
## Summary
|
||||
|
||||
The review found 2 critical security issues and 2 warnings. The overall architecture is solid, but the security issues must be addressed before this code is merged.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: SQL Injection Vulnerability in TokenUsageRepo filter methods
|
||||
|
||||
**Issue:** `buildAccountFilter` concatenates `accountId` directly into raw SQL.
|
||||
**Fix:** Use parameterized queries.
|
||||
|
||||
```typescript
|
||||
function buildAccountFilter(accountId?: string): { sql: string; params: string[] } {
|
||||
return accountId ? { sql: `AND account_id = ?`, params: [accountId] } : { sql: '', params: [] };
|
||||
}
|
||||
```
|
||||
|
||||
### CR-02: Missing validation in TokenUsageRepo queries
|
||||
|
||||
**Issue:** `buildDateFilter` directly interpolates `start` and `end` arguments.
|
||||
**Fix:** Ensure parameters are passed safely using parameterized queries.
|
||||
|
||||
```typescript
|
||||
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: number[] = [];
|
||||
if (start !== undefined) {
|
||||
conditions.push(`timestamp >= ?`);
|
||||
params.push(start);
|
||||
}
|
||||
if (end !== undefined) {
|
||||
conditions.push(`timestamp <= ?`);
|
||||
params.push(end);
|
||||
}
|
||||
return {
|
||||
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Synchronous Database Writing in ProxyService
|
||||
|
||||
**Issue:** `recordUsage` calls a synchronous database insertion on every proxy request, which can cause severe performance bottlenecks under load.
|
||||
**Fix:** Consider making the usage recording asynchronous (fire-and-forget), using an asynchronous database driver, or implementing a batching mechanism.
|
||||
|
||||
### WR-02: Missing Unsubscribe / Cleanup in React Query hooks
|
||||
|
||||
**Issue:** The usage dashboard queries lack an auto-refresh mechanism.
|
||||
**Fix:** Add `refetchInterval: 10000` to the query configuration.
|
||||
|
||||
## Info
|
||||
|
||||
Consider using CSS variables for the Recharts colors instead of hardcoded hex values to support light/dark mode better.
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
phase: 02-code-review-command
|
||||
reviewed: 2026-04-22T14:00:00Z
|
||||
depth: standard
|
||||
files_reviewed: 10
|
||||
files_reviewed_list:
|
||||
- src/ipc/database/cloudHandler.ts
|
||||
- src/ipc/database/schema.ts
|
||||
- src/ipc/database/usageHandler.ts
|
||||
- src/ipc/router.ts
|
||||
- src/ipc/usage/router.ts
|
||||
- src/server/modules/proxy/proxy.module.ts
|
||||
- src/server/modules/proxy/proxy.service.ts
|
||||
- src/server/modules/usage/token-usage.service.ts
|
||||
- src/types/db.ts
|
||||
- src/routes/usage.tsx
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 02-code-review-command: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-04-22T14:00:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 10
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
This review covers the new Token Usage Counter feature introduced in the `feature/token-usage-counter` branch. The feature introduces a new `token_usage` table in SQLite, an IPC router and UI components for displaying token usage dashboards with charts, and hooks into `ProxyService` to record usage per request.
|
||||
|
||||
Overall the architecture is well-structured and aligns with the existing project layout. The introduction of `TokenUsageRepo` directly querying the DB with `getCloudDb()` is consistent with the rest of the app. The UI is built using Recharts and properly localized.
|
||||
|
||||
However, there are several **critical security and architecture issues** relating to SQLite usage (specifically SQL injection vulnerabilities in `TokenUsageRepo`) and some minor logic/performance concerns.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: SQL Injection Vulnerability in TokenUsageRepo filter methods
|
||||
|
||||
**File:** `src/ipc/database/usageHandler.ts:33-35`
|
||||
**Issue:** The `buildAccountFilter` function directly concatenates the `accountId` string into the SQL query without using parameterized queries or escaping. This allows for SQL injection if an attacker can manipulate the `accountId` value.
|
||||
**Fix:**
|
||||
Use parameterized queries (e.g., `?` placeholders) and pass the `accountId` as an argument to `raw.prepare().all(accountId)`.
|
||||
|
||||
```typescript
|
||||
function buildAccountFilter(accountId?: string): { sql: string; params: string[] } {
|
||||
return accountId ? { sql: `AND account_id = ?`, params: [accountId] } : { sql: '', params: [] };
|
||||
}
|
||||
```
|
||||
|
||||
### CR-02: Missing try-catch and cleanup around database connection in TokenUsageRepo queries
|
||||
|
||||
**File:** `src/ipc/database/usageHandler.ts:89`
|
||||
**Issue:** In `TokenUsageRepo`, the schema explicitly sets `timestamp` as an `integer`, but `start` and `end` arguments are directly interpolated into the query via `buildDateFilter` (lines 23-29). If `start` or `end` are somehow strings, this could also lead to SQL injection.
|
||||
**Fix:**
|
||||
Ensure `start` and `end` are strictly validated as integers before passing to the query, or use parameterized queries.
|
||||
|
||||
```typescript
|
||||
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: number[] = [];
|
||||
if (start !== undefined) {
|
||||
conditions.push(`timestamp >= ?`);
|
||||
params.push(start);
|
||||
}
|
||||
if (end !== undefined) {
|
||||
conditions.push(`timestamp <= ?`);
|
||||
params.push(end);
|
||||
}
|
||||
return {
|
||||
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Synchronous Database Writing in ProxyService
|
||||
|
||||
**File:** `src/server/modules/proxy/proxy.service.ts:57`
|
||||
**Issue:** The `recordUsage` method calls `this.tokenUsageService.recordUsage()`, which internally calls `TokenUsageRepo.recordUsage()`. `TokenUsageRepo.recordUsage()` performs a synchronous database insertion (`orm.insert(...).run()`) and closes the connection. Since this is called for every single proxy request (in `handleAnthropicMessages` and `handleOpenAIMessages`), the synchronous opening, inserting, and closing of the database connection could severely impact the performance and throughput of the proxy service under high load.
|
||||
**Fix:** Consider making the usage recording asynchronous (fire-and-forget), using an asynchronous database driver, or implementing a batching mechanism for token usage recording to avoid blocking the proxy request cycle.
|
||||
|
||||
### WR-02: Missing Unsubscribe / Cleanup in React Query hooks
|
||||
|
||||
**File:** `src/routes/usage.tsx`
|
||||
**Issue:** The `useQuery` hooks are configured properly, but the UI lacks an auto-refresh mechanism. Given this is a dashboard tracking live API usage, it might be beneficial to include a `refetchInterval` or a manual refresh button.
|
||||
**Fix:** Add `refetchInterval: 10000` to the query configuration.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: UI Chart Colors and Styling
|
||||
|
||||
**File:** `src/routes/usage.tsx:23`
|
||||
**Issue:** The `COLORS` array is hardcoded with hex values. In a themable application (e.g., light/dark mode), these colors might not adapt correctly, potentially causing visibility issues on certain backgrounds.
|
||||
**Fix:** Consider using CSS variables from the theme (e.g., `hsl(var(--primary))`) instead of hardcoded hex colors for Recharts components.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-04-22T14:00:00Z_
|
||||
_Reviewer: OpenCode (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,92 @@
|
||||
# Proxy Page — Feature Brainstorm
|
||||
|
||||
> Generated from designer agent analysis of `src/routes/proxy.tsx`
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Current State
|
||||
The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basic proxy controls (start/stop, port, protocol, API key, code examples) but leaves significant value on the table. The backend (`ProxyService`, `ProxyModule`) already implements many advanced features with no UI exposure.
|
||||
|
||||
## Proposed New Features
|
||||
|
||||
### 1. Live Traffic Monitor
|
||||
- Real-time SSE/WebSocket feed of proxied requests
|
||||
- Show request method, model, account used, latency, token count
|
||||
- Filter by status (success, rate-limited, error)
|
||||
- Inspired by: Request logging already exists in `TrafficLogger`
|
||||
|
||||
### 2. IDE Quick-Connect Panel
|
||||
- One-click copy of ready-to-use configurations for:
|
||||
- Cursor (`settings.json` snippet)
|
||||
- OpenCode (`.env` snippet)
|
||||
- Windsurf (`config.yaml` snippet)
|
||||
- Claude Code / CLI tools
|
||||
- Include the local IP, port, and API key pre-filled
|
||||
- QR code for mobile/quick access
|
||||
|
||||
### 3. API Playground
|
||||
- Built-in Swagger/OpenAPI UI or custom test panel
|
||||
- Pre-loaded with all supported endpoints:
|
||||
- `/v1/chat/completions` (OpenAI-compatible)
|
||||
- `/v1/messages` (Anthropic-compatible)
|
||||
- `/v1beta/models/...generateContent` (Gemini-native)
|
||||
- Allow sending test requests and viewing formatted responses
|
||||
- Show exact curl equivalent for every request
|
||||
|
||||
### 4. Health Dashboard
|
||||
- Real-time metrics cards:
|
||||
- Requests/minute
|
||||
- Average latency (p50, p95, p99)
|
||||
- Error rate %
|
||||
- Active connections
|
||||
- Circuit breaker status (open/closed/half-open)
|
||||
- Sparkline charts for the last 5 minutes
|
||||
- Color-coded status indicators
|
||||
|
||||
### 5. Model Availability Status
|
||||
- Live grid showing which models are currently available
|
||||
- Per-model indicators:
|
||||
- Green: available
|
||||
- Yellow: rate-limited (retry after)
|
||||
- Red: unavailable / quota exhausted
|
||||
- Show last-used time and remaining quota (if known)
|
||||
- Collapse/expand by provider (Gemini, Anthropic)
|
||||
|
||||
### 6. Error Feed
|
||||
- Scrollable log of recent errors
|
||||
- Columns: timestamp, endpoint, model, account, error code, message
|
||||
- Click to expand full request/response
|
||||
- Filter by error type (rate limit, timeout, auth, network)
|
||||
- Export to JSON/CSV
|
||||
|
||||
### 7. Connection Visualizer
|
||||
- Diagram showing: IDE → Proxy → Account Pool → Upstream API
|
||||
- Animated flow dots representing live requests
|
||||
- Highlight currently active account
|
||||
- Show queue depth / waiting requests
|
||||
- Simple but effective for understanding routing
|
||||
|
||||
### 8. One-Click Diagnostics
|
||||
- "Run Diagnostics" button that performs:
|
||||
- Check if proxy port is accessible
|
||||
- Test each configured account
|
||||
- Verify model mapping resolution
|
||||
- Check upstream API reachability
|
||||
- Validate API key format
|
||||
- Output: green checkmarks or detailed error report with fix suggestions
|
||||
|
||||
### 9. Session Stats
|
||||
- Summary of current session since proxy started:
|
||||
- Total requests handled
|
||||
- Total tokens proxied
|
||||
- Unique accounts used
|
||||
- Top 3 models used
|
||||
- Uptime counter
|
||||
- Resettable per-session (not persisted)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
- Many of these features require new IPC endpoints in `src/ipc/proxy/router.ts`
|
||||
- Health metrics likely already exist in `ProxyService` but need exposure
|
||||
- Traffic logging exists via `TrafficLogger` — just needs UI
|
||||
- Model availability data is already fetched for the account pool page
|
||||
@@ -0,0 +1,81 @@
|
||||
# Proxy Page — UI/UX Improvements
|
||||
|
||||
> Generated from designer agent analysis of `src/routes/proxy.tsx`
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Current Pain Points
|
||||
1. **Visual noise**: Gradient backgrounds (`bg-gradient-to-br`) feel dated and clash with the dark, technical aesthetic
|
||||
2. **Information overload**: All content is visible at once — no progressive disclosure
|
||||
3. **Code examples dominate**: Large, always-visible code blocks push important controls below the fold
|
||||
4. **Inconsistent density**: Too much padding in some areas, cramped in others
|
||||
5. **Security concern**: API key is displayed in plain text with only a basic copy button
|
||||
6. **No status context**: User can't see which account/model is being used for requests
|
||||
|
||||
## Proposed Improvements
|
||||
|
||||
### 1. Remove Gradient Backgrounds
|
||||
- Replace `bg-gradient-to-br from-slate-800 to-slate-900` with solid `bg-card` or subtle borders
|
||||
- Use elevation (borders, subtle shadows) instead of gradients to create hierarchy
|
||||
- Align with the rest of the app which uses flat, technical styling
|
||||
|
||||
### 2. Tabs-Based Layout
|
||||
Organize the page into tabs:
|
||||
- **Status** (default): Service state, quick actions, health metrics
|
||||
- **Routing**: Model mapping, account scheduling, protocol settings
|
||||
- **API Docs**: Code examples, endpoint documentation, IDE configs
|
||||
- **Logs**: Live traffic feed, error history
|
||||
|
||||
### 3. Compact Protocol Selector
|
||||
- Current: large buttons with icons
|
||||
- Proposed: `Select` dropdown or compact segmented control
|
||||
- Save horizontal space for more important controls
|
||||
|
||||
### 4. Collapsible Code Examples
|
||||
- Wrap each code block in a `Collapsible` or `Accordion`
|
||||
- Show only the title + language icon by default
|
||||
- Expand on click
|
||||
- Add nested tabs for different languages (cURL, Python, JavaScript, TypeScript)
|
||||
|
||||
### 5. Security-Focused API Key Display
|
||||
- Mask the API key by default (show `sk-••••••••`)
|
||||
- Reveal on explicit "Show" toggle
|
||||
- Add "Regenerate" button with confirmation dialog
|
||||
- Show "Last used" timestamp
|
||||
- Add warning if key is the default/weak key
|
||||
|
||||
### 6. Configurable Model Mapping Table
|
||||
- Instead of a static list, show an editable table:
|
||||
- Requested model | Resolved model | Provider | Status
|
||||
- Allow users to add custom mappings
|
||||
- Show which mappings are active/inactive
|
||||
- Validate that resolved models exist in the account pool
|
||||
|
||||
### 7. Account Quota Context
|
||||
- Show the currently selected "primary" account with its quota bar
|
||||
- If auto-switching is enabled, show "Pool Mode: Active (N accounts)"
|
||||
- Link to the account pool page for detailed management
|
||||
|
||||
### 8. Toast Copy Feedback
|
||||
- When user clicks "Copy", show a `Toast` or `Sonner` notification
|
||||
- Current: no feedback — user doesn't know if copy succeeded
|
||||
- Message: "Copied to clipboard" or "Failed to copy"
|
||||
|
||||
### 9. Health Metrics Stub
|
||||
- Even if backend metrics aren't wired yet, create the UI skeleton
|
||||
- Show placeholder charts with "Coming soon" badge
|
||||
- This sets expectations and makes the page feel more complete
|
||||
|
||||
### 10. Empty States
|
||||
- When proxy is stopped, show a clear empty state:
|
||||
- Icon: `ServerOff` from lucide
|
||||
- Message: "Proxy is not running"
|
||||
- Action: "Start Proxy" button prominently displayed
|
||||
- When no accounts are configured, show a CTA to add accounts
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
- **Density over whitespace**: This is a developer tool — show more information per pixel
|
||||
- **Progressive disclosure**: Hide advanced options behind tabs/accordions
|
||||
- **Immediate feedback**: Every action should have a visible result (toast, spinner, state change)
|
||||
- **Security first**: API keys and sensitive data should never be displayed casually
|
||||
@@ -0,0 +1,125 @@
|
||||
# Proxy Page — Advanced Features Hidden in Backend
|
||||
|
||||
> Generated from oracle agent analysis of `src/server/modules/proxy/`
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Discovery
|
||||
The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many sophisticated features that are **completely invisible** in the current UI. The proxy page only shows: port, protocol, API key, and basic start/stop.
|
||||
|
||||
## Hidden Backend Features
|
||||
|
||||
### 1. Advanced Routing & Scheduling Controls
|
||||
**Backend location**: `proxy.service.ts`, `proxy-scheduler.service.ts`
|
||||
**What's implemented**:
|
||||
- `scheduler.maxRequestsPerAccount`: Limit requests per account before switching
|
||||
- `scheduler.maxWaitTime`: Max time to wait for an available account
|
||||
- `scheduler.retryAttempts`: Number of retries per request
|
||||
- `scheduler.backoffStrategy`: `exponential` | `linear` | `fixed`
|
||||
- `scheduler.priorityAccounts`: Array of account IDs to prioritize
|
||||
|
||||
**UI exposure**: None. These are hardcoded or set via config files only.
|
||||
|
||||
### 2. Live Traffic Inspector
|
||||
**Backend location**: `traffic-logger.service.ts`
|
||||
**What's implemented**:
|
||||
- `TrafficLogger` captures every request/response with metadata
|
||||
- Fields: timestamp, requestId, accountId, model, promptTokens, completionTokens, latency, status, error
|
||||
- Log rotation and cleanup policies
|
||||
- Export to JSON/CSV
|
||||
|
||||
**UI exposure**: None. Logs are only written to files.
|
||||
|
||||
### 3. Circuit Breaker Dashboard
|
||||
**Backend location**: `proxy-circuit-breaker.service.ts`
|
||||
**What's implemented**:
|
||||
- Per-account circuit breaker state: `CLOSED`, `OPEN`, `HALF_OPEN`
|
||||
- Failure threshold and recovery timeout
|
||||
- When open: requests fail fast without hitting the API
|
||||
- Metrics: failure rate, last failure time, consecutive successes
|
||||
|
||||
**UI exposure**: None. Circuit breaker state is internal only.
|
||||
|
||||
### 4. Parity Testing Sandbox
|
||||
**Backend location**: `proxy-parity.service.ts`
|
||||
**What's implemented**:
|
||||
- Send identical requests to multiple models simultaneously
|
||||
- Compare responses for consistency
|
||||
- Detect model drift or API changes
|
||||
- Configurable test suites
|
||||
|
||||
**UI exposure**: None. Only accessible via manual API calls or scripts.
|
||||
|
||||
### 5. Custom Model Mapping Table
|
||||
**Backend location**: `proxy-model-mapper.service.ts`
|
||||
**What's implemented**:
|
||||
- Dynamic model resolution: `claude-opus-4` → `gemini-3-flash`
|
||||
- User-defined mappings stored in database
|
||||
- Regex-based model matching
|
||||
- Fallback chain: if mapped model unavailable, try next
|
||||
|
||||
**UI exposure**: A static, read-only list is shown. No editing capability.
|
||||
|
||||
### 6. Upstream Proxy Configuration
|
||||
**Backend location**: `proxy-upstream.service.ts`
|
||||
**What's implemented**:
|
||||
- `upstreamProxy.url`: Route all traffic through another proxy
|
||||
- `upstreamProxy.auth`: Basic auth or bearer token for upstream
|
||||
- `upstreamProxy.bypassList`: Domains to bypass
|
||||
|
||||
**UI exposure**: None. Set via environment variables only.
|
||||
|
||||
### 7. IDE Integration Quick-Setup
|
||||
**Backend location**: `proxy-config.service.ts` — `generateIdeConfig()`
|
||||
**What's implemented**:
|
||||
- Generates ready-to-paste configs for:
|
||||
- Cursor (`settings.json`)
|
||||
- VS Code + Cline/Roo Code
|
||||
- OpenCode (`.env`)
|
||||
- Claude Code (`claude_desktop_config.json`)
|
||||
- Generic OpenAI client
|
||||
- Includes local IP detection, port, and API key
|
||||
|
||||
**UI exposure**: Code examples are generic curl snippets. No IDE-specific configs.
|
||||
|
||||
### 8. Health & Performance Metrics
|
||||
**Backend location**: `proxy-metrics.service.ts`
|
||||
**What's implemented**:
|
||||
- Request latency histogram (p50, p95, p99)
|
||||
- Requests per minute counter
|
||||
- Error rate by status code
|
||||
- Active connection gauge
|
||||
- Token throughput (tokens/minute)
|
||||
- Account utilization percentage
|
||||
|
||||
**UI exposure**: None. Metrics are only exposed via a `/metrics` endpoint for Prometheus.
|
||||
|
||||
### 9. Request Replay & Debug Tool
|
||||
**Backend location**: `proxy-replay.service.ts`
|
||||
**What's implemented**:
|
||||
- Store recent requests (last 100 by default)
|
||||
- Replay any request with one click
|
||||
- Modify headers/body before replay
|
||||
- Compare original vs. replayed response
|
||||
- Export request as curl command
|
||||
|
||||
**UI exposure**: None.
|
||||
|
||||
### 10. Global Timeout & Max Wait Controls
|
||||
**Backend location**: `proxy-config.service.ts`
|
||||
**What's implemented**:
|
||||
- `requestTimeout`: Global timeout for all upstream requests
|
||||
- `connectTimeout`: TCP connection timeout
|
||||
- `maxWaitTime`: Max time to wait for an available account
|
||||
- `idleTimeout`: Close idle connections after N seconds
|
||||
|
||||
**UI exposure**: None. Hardcoded defaults.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
Create a new "Advanced" tab on the proxy page that exposes these features progressively:
|
||||
1. **Phase 1**: Read-only views (circuit breaker status, health metrics, traffic logs)
|
||||
2. **Phase 2**: Interactive controls (model mapping editor, scheduler settings)
|
||||
3. **Phase 3**: Power-user tools (replay, parity testing, upstream proxy config)
|
||||
|
||||
This would transform the proxy page from a simple on/off switch into a comprehensive API gateway dashboard.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Proxy Page — Code Organization Refactoring Plan
|
||||
|
||||
> Generated from oracle agent analysis of `src/routes/proxy.tsx`
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Current State
|
||||
`src/routes/proxy.tsx` is a **724-line monolith** containing:
|
||||
- Route definition
|
||||
- State management (useState, useEffect, useMemo)
|
||||
- Data fetching (useQuery equivalents for proxy config, local IPs)
|
||||
- Derived state (code examples, URLs)
|
||||
- UI rendering (service card, model mapping, code examples, local access banner)
|
||||
- Event handlers (start/stop, copy, protocol change)
|
||||
- Constants and helper functions
|
||||
|
||||
## Target Architecture
|
||||
Split into **7+ files** following the project's conventions:
|
||||
|
||||
```
|
||||
src/
|
||||
├── routes/
|
||||
│ └── proxy.tsx # ~50 lines — route definition + composition
|
||||
├── components/
|
||||
│ └── proxy/
|
||||
│ ├── ProxyServiceCard.tsx
|
||||
│ ├── ProxyModelMappingCard.tsx
|
||||
│ ├── ProxyUsageExamplesCard.tsx
|
||||
│ └── ProxyLocalAccessBanner.tsx
|
||||
├── hooks/
|
||||
│ ├── useProxyConfig.ts
|
||||
│ ├── useLocalIps.ts
|
||||
│ └── useProxyExamples.ts
|
||||
├── constants/
|
||||
│ └── proxy.ts # API endpoints, default ports, protocols
|
||||
└── utils/
|
||||
└── proxy.ts # URL builders, formatters, parsers
|
||||
```
|
||||
|
||||
## File Breakdown
|
||||
|
||||
### 1. `src/hooks/useProxyConfig.ts`
|
||||
**Responsibility**: Fetch and mutate proxy configuration
|
||||
```typescript
|
||||
export function useProxyConfig() {
|
||||
// Fetches from ipc.client.proxy.getConfig()
|
||||
// Provides: config, isLoading, error, updateConfig, startProxy, stopProxy
|
||||
// Handles optimistic updates for start/stop toggling
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `src/hooks/useLocalIps.ts`
|
||||
**Responsibility**: Get local IP addresses for the access banner
|
||||
```typescript
|
||||
export function useLocalIps() {
|
||||
// Fetches from ipc.client.system.getLocalIps()
|
||||
// Returns: ips[], isLoading, error
|
||||
}
|
||||
```
|
||||
|
||||
### 3. `src/hooks/useProxyExamples.ts`
|
||||
**Responsibility**: Generate code examples based on current config
|
||||
```typescript
|
||||
export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
||||
// Returns memoized examples for curl, Python, Node.js
|
||||
// Updates when port, protocol, or API key changes
|
||||
}
|
||||
```
|
||||
|
||||
### 4. `src/components/proxy/ProxyServiceCard.tsx`
|
||||
**Responsibility**: The main service control card
|
||||
- Start/stop toggle
|
||||
- Port input
|
||||
- Protocol selector
|
||||
- API key display (with mask/reveal)
|
||||
- Status indicator
|
||||
|
||||
### 5. `src/components/proxy/ProxyModelMappingCard.tsx`
|
||||
**Responsibility**: Model mapping display/editor
|
||||
- Read-only list (current state)
|
||||
- Future: editable table with add/remove
|
||||
- Show provider badges
|
||||
|
||||
### 6. `src/components/proxy/ProxyUsageExamplesCard.tsx`
|
||||
**Responsibility**: Code examples section
|
||||
- Collapsible/accordion layout
|
||||
- Tabs for different languages
|
||||
- Copy-to-clipboard buttons
|
||||
- Syntax highlighting (if available)
|
||||
|
||||
### 7. `src/components/proxy/ProxyLocalAccessBanner.tsx`
|
||||
**Responsibility**: "Access your proxy locally" banner
|
||||
- Show local IPs
|
||||
- QR code (future)
|
||||
- Copy URL buttons
|
||||
|
||||
### 8. `src/constants/proxy.ts`
|
||||
**Responsibility**: Constants
|
||||
```typescript
|
||||
export const DEFAULT_PROXY_PORT = 3000;
|
||||
export const PROTOCOLS = ['http', 'https'] as const;
|
||||
export const EXAMPLE_MODELS = ['gpt-4o', 'claude-3-opus', 'gemini-pro'];
|
||||
```
|
||||
|
||||
### 9. `src/utils/proxy.ts`
|
||||
**Responsibility**: Pure helper functions
|
||||
```typescript
|
||||
export function buildProxyUrl(protocol: string, ip: string, port: number): string;
|
||||
export function maskApiKey(key: string): string;
|
||||
export function formatCurlExample(url: string, model: string, apiKey: string): string;
|
||||
```
|
||||
|
||||
## Route File After Refactoring
|
||||
|
||||
```typescript
|
||||
// src/routes/proxy.tsx — ~50 lines
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useProxyConfig } from '@/hooks/useProxyConfig';
|
||||
import { useLocalIps } from '@/hooks/useLocalIps';
|
||||
import { ProxyServiceCard } from '@/components/proxy/ProxyServiceCard';
|
||||
import { ProxyModelMappingCard } from '@/components/proxy/ProxyModelMappingCard';
|
||||
import { ProxyUsageExamplesCard } from '@/components/proxy/ProxyUsageExamplesCard';
|
||||
import { ProxyLocalAccessBanner } from '@/components/proxy/ProxyLocalAccessBanner';
|
||||
|
||||
function ProxyPage() {
|
||||
const { config, isLoading, startProxy, stopProxy } = useProxyConfig();
|
||||
const { ips } = useLocalIps();
|
||||
|
||||
if (isLoading) return <ProxySkeleton />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<ProxyServiceCard config={config} onStart={startProxy} onStop={stopProxy} />
|
||||
<ProxyModelMappingCard mappings={config.modelMappings} />
|
||||
<ProxyUsageExamplesCard config={config} ips={ips} />
|
||||
<ProxyLocalAccessBanner ips={ips} port={config.port} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/proxy')({ component: ProxyPage });
|
||||
```
|
||||
|
||||
## Migration Steps
|
||||
1. Create new files (hooks, components, constants, utils)
|
||||
2. Move code from `proxy.tsx` to appropriate files
|
||||
3. Update imports in `proxy.tsx`
|
||||
4. Extract and test each hook independently
|
||||
5. Add Storybook stories for each component (if Storybook is used)
|
||||
6. Verify no regression in functionality
|
||||
|
||||
## Benefits
|
||||
- **Testability**: Each hook and component can be unit tested in isolation
|
||||
- **Reusability**: `useLocalIps` can be used in other pages (e.g., settings)
|
||||
- **Maintainability**: 50-line route file vs. 724-line monolith
|
||||
- **Code review**: Smaller files = faster, more focused PRs
|
||||
- **Onboarding**: New developers can understand the page in minutes
|
||||
@@ -0,0 +1,132 @@
|
||||
# Proxy Page — Code Quality Issues
|
||||
|
||||
> Generated from fixer agent analysis of `src/routes/proxy.tsx`
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Severity Legend
|
||||
- 🔴 **Critical**: Bugs or crashes
|
||||
- 🟡 **High**: Performance or reliability issues
|
||||
- 🟢 **Medium**: Maintainability or best practice violations
|
||||
|
||||
## Issues Found
|
||||
|
||||
### 1. 🔴 State Duplication Between Proxy Config and Local State
|
||||
**Location**: Lines ~120-180
|
||||
**Issue**: The component maintains both `proxyConfig` (from IPC) and local `useState` copies for `port`, `protocol`, `apiKey`. These can drift out of sync if the backend config changes externally.
|
||||
**Fix**: Use a single source of truth. Either:
|
||||
- Derive local state from `proxyConfig` with `useEffect` sync
|
||||
- Or use `useQuery` with `staleTime: 0` and no local cache
|
||||
|
||||
### 2. 🟡 Race Condition in Start/Stop Toggle
|
||||
**Location**: Lines ~200-250
|
||||
**Issue**: If user clicks start/stop rapidly, multiple IPC calls fire in parallel. The UI shows intermediate states that may not reflect the actual backend state.
|
||||
**Fix**: Disable the toggle button while a transition is in progress. Add an `isTransitioning` flag.
|
||||
|
||||
### 3. 🟢 Clipboard Error Handling
|
||||
**Location**: Lines ~300-320
|
||||
**Issue**: `navigator.clipboard.writeText()` can fail (permission denied, not in secure context). The code has no try/catch.
|
||||
**Fix**:
|
||||
```typescript
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success('Copied!');
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 🟢 Unstable Callbacks Causing Re-renders
|
||||
**Location**: Throughout
|
||||
**Issue**: Event handlers are defined inline in JSX, causing child components to re-render unnecessarily.
|
||||
**Fix**: Wrap handlers in `useCallback`:
|
||||
```typescript
|
||||
const handleStart = useCallback(() => startProxy(), [startProxy]);
|
||||
const handleStop = useCallback(() => stopProxy(), [stopProxy]);
|
||||
```
|
||||
|
||||
### 5. 🟡 Memory Leak in setTimeout
|
||||
**Location**: Lines ~400-420
|
||||
**Issue**: A `setTimeout` is used for a "copy feedback" animation but is never cleared on unmount.
|
||||
**Fix**: Use `useEffect` cleanup:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setCopied(false), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [copied]);
|
||||
```
|
||||
|
||||
### 6. 🟢 Missing Loading States for Mutations
|
||||
**Location**: Lines ~200-250
|
||||
**Issue**: When starting/stopping the proxy, there's no loading spinner or disabled state on the button.
|
||||
**Fix**: Add `isPending` state to the mutation hook and disable the button + show spinner while pending.
|
||||
|
||||
### 7. 🟢 Hardcoded i18n Strings
|
||||
**Location**: Throughout
|
||||
**Issue**: Many UI strings are hardcoded in English instead of using `useTranslation()`:
|
||||
- "Proxy Service"
|
||||
- "Start Proxy"
|
||||
- "Stop Proxy"
|
||||
- "Copy"
|
||||
- "Copied!"
|
||||
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
|
||||
|
||||
### 8. 🟢 Magic Numbers
|
||||
**Location**: Lines ~450-500
|
||||
**Issue**: Port validation uses hardcoded `3000`, `65535` without constants.
|
||||
**Fix**:
|
||||
```typescript
|
||||
const MIN_PORT = 1024;
|
||||
const MAX_PORT = 65535;
|
||||
const DEFAULT_PORT = 3000;
|
||||
```
|
||||
|
||||
### 9. 🟢 Unsafe Type Cast
|
||||
**Location**: Lines ~550-600
|
||||
**Issue**: `event.target.value` is cast to `Protocol` without validation.
|
||||
**Fix**:
|
||||
```typescript
|
||||
const protocol = event.target.value;
|
||||
if (!PROTOCOLS.includes(protocol)) return;
|
||||
setProtocol(protocol as Protocol);
|
||||
```
|
||||
|
||||
### 10. 🟡 Dangerous Parsing of Local IPs
|
||||
**Location**: Lines ~650-700
|
||||
**Issue**: `localIps` is parsed from a shell command output using string splitting that could break on unexpected formats.
|
||||
**Fix**: Use a robust parser or, better, expose `os.networkInterfaces()` via a safe IPC endpoint.
|
||||
|
||||
### 11. 🟢 Unnecessary Re-renders from Inline Objects
|
||||
**Location**: Lines ~350-380
|
||||
**Issue**: Inline style objects and arrays are recreated on every render, breaking `React.memo` optimizations in child components.
|
||||
**Fix**: Extract static styles outside the component or memoize:
|
||||
```typescript
|
||||
const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
|
||||
```
|
||||
|
||||
### 12. 🟢 No Validation on Port Input
|
||||
**Location**: Lines ~150-170
|
||||
**Issue**: User can type any value into the port field, including non-numbers, negatives, or out-of-range values.
|
||||
**Fix**: Add input validation:
|
||||
```typescript
|
||||
<input
|
||||
type="number"
|
||||
min={1024}
|
||||
max={65535}
|
||||
value={port}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (value >= 1024 && value <= 65535) setPort(value);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
- Add unit tests for `useProxyConfig` hook (mock IPC client)
|
||||
- Test race conditions: rapid start/stop clicks
|
||||
- Test clipboard failure path
|
||||
- Test port validation edge cases (0, 80, 65536, NaN)
|
||||
- Test with `react-testing-library` + `user-event`
|
||||
@@ -0,0 +1,141 @@
|
||||
# Proxy Page — Architecture Overview
|
||||
|
||||
> Generated from explorer agent mapping of proxy-related files
|
||||
> Date: 2026-04-22
|
||||
|
||||
## File Map
|
||||
|
||||
### Frontend
|
||||
| File | Lines | Role |
|
||||
|------|-------|------|
|
||||
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
|
||||
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
|
||||
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
|
||||
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
|
||||
|
||||
### Backend (NestJS)
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/server/modules/proxy/proxy.module.ts` | NestJS module definition |
|
||||
| `src/server/modules/proxy/proxy.service.ts` | 1883 lines — core proxy logic, all handlers |
|
||||
| `src/server/modules/proxy/proxy.controller.ts` | HTTP endpoints for proxy control |
|
||||
| `src/server/modules/proxy/proxy-scheduler.service.ts` | Account rotation & scheduling |
|
||||
| `src/server/modules/proxy/proxy-circuit-breaker.service.ts` | Per-account circuit breaker |
|
||||
| `src/server/modules/proxy/traffic-logger.service.ts` | Request/response logging |
|
||||
| `src/server/modules/proxy/proxy-metrics.service.ts` | Prometheus metrics |
|
||||
| `src/server/modules/proxy/proxy-model-mapper.service.ts` | Model name resolution |
|
||||
| `src/server/modules/proxy/proxy-parity.service.ts` | Parity testing |
|
||||
| `src/server/modules/proxy/proxy-replay.service.ts` | Request replay |
|
||||
| `src/server/modules/proxy/proxy-config.service.ts` | Config management |
|
||||
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
|
||||
|
||||
### IPC Layer
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/ipc/router.ts` | Main IPC router composition |
|
||||
| `src/ipc/proxy/router.ts` | Proxy-specific IPC endpoints |
|
||||
| `src/ipc/manager.ts` | IPC client initialization |
|
||||
|
||||
### Types & Schema
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/types/config.ts` | `ProxyConfig` Zod schema — reveals hidden fields |
|
||||
| `src/types/proxy.ts` | Request/response types |
|
||||
|
||||
## Hidden ProxyConfig Fields
|
||||
|
||||
The `ProxyConfig` schema in `src/types/config.ts` contains many fields never shown in the UI:
|
||||
|
||||
```typescript
|
||||
interface ProxyConfig {
|
||||
// Shown in UI
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
protocol: 'http' | 'https';
|
||||
apiKey: string;
|
||||
|
||||
// Hidden from UI
|
||||
scheduler: {
|
||||
maxRequestsPerAccount: number;
|
||||
maxWaitTime: number;
|
||||
retryAttempts: number;
|
||||
backoffStrategy: 'exponential' | 'linear' | 'fixed';
|
||||
priorityAccounts: string[];
|
||||
};
|
||||
upstreamProxy?: {
|
||||
url: string;
|
||||
auth?: { username: string; password: string };
|
||||
bypassList: string[];
|
||||
};
|
||||
circuitBreaker: {
|
||||
failureThreshold: number;
|
||||
recoveryTimeout: number;
|
||||
halfOpenMaxCalls: number;
|
||||
};
|
||||
logging: {
|
||||
enabled: boolean;
|
||||
maxEntries: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
metrics: {
|
||||
enabled: boolean;
|
||||
histogramBuckets: number[];
|
||||
};
|
||||
modelMappings: Array<{
|
||||
from: string;
|
||||
to: string;
|
||||
provider: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────┐ IPC ┌──────────────────┐
|
||||
│ proxy.tsx │ ◄──────────► │ proxy/router.ts │
|
||||
│ (React UI) │ │ (IPC handlers) │
|
||||
└─────────────────┘ └────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ ProxyController │
|
||||
│ (HTTP endpoints) │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ ProxyService │
|
||||
│ (Core orchestrator) │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
|
||||
│ Scheduler │ │ CircuitBreaker│ │Model Mapper │
|
||||
└─────────────┘ └───────────────┘ └──────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
|
||||
│TrafficLogger│ │MetricsService │ │ParityService │
|
||||
└─────────────┘ └───────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## Key Observations
|
||||
1. **Backend is significantly more capable than the UI suggests** — 12 services vs. 1 page
|
||||
2. **IPC layer is thin** — most backend services are not exposed via IPC, only HTTP
|
||||
3. **ProxyConfig schema is the source of truth** — it documents everything the backend can do
|
||||
4. **No real-time communication** — UI polls for status; no WebSocket or SSE for live updates
|
||||
5. **Separation of concerns is good on backend**, terrible on frontend (724-line monolith)
|
||||
|
||||
## Recommended Architecture Changes
|
||||
1. Extend `src/ipc/proxy/router.ts` to expose all `ProxyConfig` fields
|
||||
2. Add IPC endpoints for:
|
||||
- `proxy.getTrafficLogs()`
|
||||
- `proxy.getCircuitBreakerStatus()`
|
||||
- `proxy.getMetrics()`
|
||||
- `proxy.replayRequest()`
|
||||
3. Create a WebSocket or SSE channel for live traffic updates
|
||||
4. Refactor frontend into the 7-file structure proposed in `04-code-organization.md`
|
||||
@@ -345,12 +345,6 @@ Documentation/usage lookup:
|
||||
|
||||
- Use `context7` for latest dependency usage.
|
||||
|
||||
VSCode MCP (if available):
|
||||
|
||||
- `mcp__vscode-mcp__get_references` for refactor impact analysis
|
||||
- `mcp__vscode-mcp__rename_symbol` for safe renaming
|
||||
- `mcp__vscode-mcp__get_symbol_lsp_info` for types/signatures/definitions
|
||||
|
||||
## 🚨 Local Quality Checks (Optional Flow)
|
||||
|
||||
After a set of changes, run these three checks in parallel instead of full lint immediately:
|
||||
@@ -371,128 +365,3 @@ Task(subagent_type: "run-related-tests", description: "Run tests", prompt: "[sam
|
||||
Flow: initial check -> fix key issues -> re-check -> iterate until key issues are resolved.
|
||||
|
||||
Note: these tools are read-only analyzers; you still need to apply fixes manually. Pass precise file paths, not broad directories.
|
||||
|
||||
<skills_system priority="1">
|
||||
|
||||
## Available Skills
|
||||
|
||||
<!-- SKILLS_TABLE_START -->
|
||||
<usage>
|
||||
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
|
||||
|
||||
How to use skills:
|
||||
|
||||
- Invoke: Bash("openskills read <skill-name>")
|
||||
- The skill content will load with detailed instructions on how to complete the task
|
||||
- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
|
||||
|
||||
Usage notes:
|
||||
|
||||
- Only use skills listed in <available_skills> below
|
||||
- Do not invoke a skill that is already loaded in your context
|
||||
- Each skill invocation is stateless
|
||||
</usage>
|
||||
|
||||
<available_skills>
|
||||
|
||||
<skill>
|
||||
<name>algorithmic-art</name>
|
||||
<description>Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>brand-guidelines</name>
|
||||
<description>Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>canvas-design</name>
|
||||
<description>Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>doc-coauthoring</name>
|
||||
<description>Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>docx</name>
|
||||
<description>"Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. When Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>frontend-design</name>
|
||||
<description>Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>internal-comms</name>
|
||||
<description>A set of resources to help me write all kinds of internal communications, using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.).</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>mcp-builder</name>
|
||||
<description>Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>pdf</name>
|
||||
<description>Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>pptx</name>
|
||||
<description>"Presentation creation, editing, and analysis. When Claude needs to work with presentations (.pptx files) for: (1) Creating new presentations, (2) Modifying or editing content, (3) Working with layouts, (4) Adding comments or speaker notes, or any other presentation tasks"</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>skill-creator</name>
|
||||
<description>Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>slack-gif-creator</name>
|
||||
<description>Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>theme-factory</name>
|
||||
<description>Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>web-artifacts-builder</name>
|
||||
<description>Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>webapp-testing</name>
|
||||
<description>Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
<skill>
|
||||
<name>xlsx</name>
|
||||
<description>"Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When Claude needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas"</description>
|
||||
<location>project</location>
|
||||
</skill>
|
||||
|
||||
</available_skills>
|
||||
|
||||
<!-- SKILLS_TABLE_END -->
|
||||
|
||||
</skills_system>
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [0.11.1](https://github.com/Draculabo/AntigravityManager/compare/v0.11.0...v0.11.1) (2026-04-18)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* add unit label to AI credit display ([6577f1c](https://github.com/Draculabo/AntigravityManager/commit/6577f1c51fd155004b81f6171eb5feffa2448128))
|
||||
* remaining localization gaps ([#156](https://github.com/Draculabo/AntigravityManager/issues/156)) ([7093c98](https://github.com/Draculabo/AntigravityManager/commit/7093c982ba790380cda7d1e8a53891970df6b3cf))
|
||||
* remove AI credit refresh time scraping ([3e1e10b](https://github.com/Draculabo/AntigravityManager/commit/3e1e10b2a580ebf5a64b9a8982bd812a6a1f2924))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
* **repo:** update project docs ([440a934](https://github.com/Draculabo/AntigravityManager/commit/440a9342800f244287c758b1a77a05fac9096df7))
|
||||
|
||||
### ♻️ Code Refactoring
|
||||
|
||||
* **ipc:** migrate renderer IPC client to official oRPC client ([d0c282d](https://github.com/Draculabo/AntigravityManager/commit/d0c282dffff24bb74a81cfa523f2f72f949bf8f9))
|
||||
* standardize runtime guard patterns with lodash-es ([#154](https://github.com/Draculabo/AntigravityManager/issues/154)) ([bc35b96](https://github.com/Draculabo/AntigravityManager/commit/bc35b96b5d2d873cf0f187a57de695aedc32108f))
|
||||
|
||||
## [0.11.0](https://github.com/Draculabo/AntigravityManager/compare/v0.10.0...v0.11.0) (2026-04-11)
|
||||
|
||||
### ✨ Features
|
||||
@@ -284,7 +301,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of Antigravity Manager
|
||||
- Initial release of Gemini Nexus
|
||||
- Multi-account management for Google Gemini and Claude
|
||||
- Real-time quota monitoring
|
||||
- Intelligent auto-switching capabilities
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Contributing to Antigravity Manager
|
||||
# Contributing to Gemini Nexus
|
||||
|
||||
First off, thank you for considering contributing to Antigravity Manager! 🎉
|
||||
First off, thank you for considering contributing to Gemini Nexus! 🎉
|
||||
|
||||
It's people like you that make Antigravity Manager such a great tool. We welcome contributions from everyone, whether it's a bug report, feature suggestion, documentation improvement, or code contribution.
|
||||
It's people like you that make Gemini Nexus such a great tool. We welcome contributions from everyone, whether it's a bug report, feature suggestion, documentation improvement, or code contribution.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
@@ -24,8 +24,8 @@ This project and everyone participating in it is governed by our [Code of Conduc
|
||||
1. **Fork the repository** on GitHub
|
||||
2. **Clone your fork** locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/AntigravityManager.git
|
||||
cd AntigravityManager
|
||||
git clone https://github.com/YOUR_USERNAME/GeminiNexusManager.git
|
||||
cd Gemini NexusManager
|
||||
```
|
||||
3. **Add the upstream remote**:
|
||||
```bash
|
||||
@@ -178,7 +178,7 @@ Have an idea? Please [create an issue](https://github.com/Draculabo/AntigravityM
|
||||
|
||||
## 🙏 Thank You
|
||||
|
||||
Your contributions help make Antigravity Manager better for everyone. Thank you for taking the time to contribute!
|
||||
Your contributions help make Gemini Nexus better for everyone. Thank you for taking the time to contribute!
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,50 +1,41 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/logo.png" alt="Antigravity Manager" width="128" height="128" />
|
||||
<img src="docs/assets/logo.png" alt="Gemini Nexus" width="128" height="128" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Antigravity Manager</h1>
|
||||
<h1 align="center">Gemini Nexus</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>🚀 Professional multi-account manager for Google Gemini & Claude AI</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English | <a href="README.zh-CN.md">简体中文</a>
|
||||
English | <a href="README.pt-BR.md">Português (Brasil)</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/actions/workflows/testing.yaml">
|
||||
<img src="https://github.com/Draculabo/GeminiNexus/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/actions/workflows/lint.yaml">
|
||||
<img src="https://github.com/Draculabo/GeminiNexus/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Draculabo/AntigravityManager?style=flat-square" alt="Release" />
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Draculabo/GeminiNexus?style=flat-square" alt="Release" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/downloads/Draculabo/AntigravityManager/total?style=flat-square&color=blue" alt="Downloads" />
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/releases">
|
||||
<img src="https://img.shields.io/github/downloads/Draculabo/GeminiNexus/total?style=flat-square&color=blue" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Draculabo/AntigravityManager?style=flat-square" alt="License" />
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Draculabo/GeminiNexus?style=flat-square" alt="License" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/Draculabo/AntigravityManager?style=flat-square" alt="Stars" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="TailwindCSS" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📖 Table of Contents
|
||||
|
||||
- [Why Antigravity Manager?](#-why-antigravity-manager)
|
||||
- [Why Gemini Nexus?](#-why-gemini-nexus)
|
||||
- [Improvements over Base Project](#-improvements-over-base-project)
|
||||
- [Features](#-features)
|
||||
- [Screenshots](#-screenshots)
|
||||
- [Quick Start](#-quick-start)
|
||||
@@ -57,9 +48,9 @@
|
||||
|
||||
---
|
||||
|
||||
## ✨ Why Antigravity Manager?
|
||||
## ✨ Why Gemini Nexus?
|
||||
|
||||
When using Antigravity IDE, have you ever encountered these problems?
|
||||
When using your favorite IDEs, have you ever encountered these problems?
|
||||
|
||||
- 😫 Single account quota runs out quickly, requiring frequent manual switching
|
||||
- 🔄 Managing multiple Google/Claude accounts is cumbersome
|
||||
@@ -67,7 +58,7 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
- ⏰ Worried about missing quota reset times
|
||||
- 🔌 Need a reliable local API proxy for development tools
|
||||
|
||||
**Antigravity Manager** is here to solve these problems! It's a professional Electron desktop app that helps you:
|
||||
**Gemini Nexus** is here to solve these problems! It's a professional Electron desktop app that helps you:
|
||||
|
||||
- ✅ **Unlimited Account Pool** - Add any number of Google Gemini / Claude accounts
|
||||
- ✅ **Smart Auto-Switching** - Automatically switch to the next available account when quota is low or rate-limited
|
||||
@@ -77,6 +68,19 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Improvements over Base Project
|
||||
|
||||
This project contains significant development improvements, bug fixes, and configuration enhancements compared to the original base project.
|
||||
|
||||
Key improvements include:
|
||||
- **Enhanced API Management UI:** Implemented dynamic model selectors and "Copy API Name" features to streamline integration with other tools.
|
||||
- **Robust SSE Streaming:** Fixed critical Server-Sent Events (SSE) streaming bugs in the `/v1/chat/completions` proxy endpoint to perfectly support IDEs like Cursor, OpenCode, and Windsurf.
|
||||
- **Rebranding and Localization:** Complete rebranding to Gemini Nexus with improved Portuguese (Brazil) localization and English defaults.
|
||||
- **Agentic Capabilities:** Integration of advanced AI developer agents and skill frameworks (GSD, AutoResearch, Superpowers) directly into the project ecosystem for rapid autonomous development.
|
||||
- **Build & Dev Env:** Better cross-platform build configurations, updated schemas, and automated toolchain processes.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
<table>
|
||||
@@ -116,24 +120,6 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>💾 Account Backup</h3>
|
||||
<ul>
|
||||
<li>Capture snapshots of account state</li>
|
||||
<li>Fast switching between saved accounts</li>
|
||||
<li>View, organize, and delete snapshots</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>⚙️ Process Control</h3>
|
||||
<ul>
|
||||
<li>Auto-detect if Antigravity is running</li>
|
||||
<li>Launch via URI protocol or executable</li>
|
||||
<li>Graceful close or force kill</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔌 Local API Proxy</h3>
|
||||
@@ -154,19 +140,6 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Additional Features
|
||||
|
||||
- **🖥️ System Tray** - Background mode with tray icon and right-click menu
|
||||
- **🔗 IDE Sync** - Automatically scan and import accounts from IDE's `state.vscdb`
|
||||
- **📦 Batch Operations** - Batch refresh and delete multiple accounts
|
||||
- **📤 JSON Export/Import** - Export and import account pools with schema validation and deduplication
|
||||
- **🔔 Desktop Notifications** - Configurable alerts when model quota drops below your threshold
|
||||
- **🌐 Per-Account Proxy** - Route individual accounts through their own HTTP/SOCKS5 proxy
|
||||
- **📊 Smart Sorting** - Sort accounts by recently used, overall quota, or specific model groups
|
||||
- **📋 Compact Layout** - Dense horizontal view to maximize visible accounts
|
||||
- **🌏 Internationalization** - Multi-language support (English / 中文 / Русский)
|
||||
- **🎨 Modern UI** - Built with React, TailwindCSS, and Shadcn UI
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
@@ -175,86 +148,23 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
<img src="docs/assets/screenshot-main.png" alt="Main Interface" width="80%" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/screenshot-proxy.png" alt="Proxy Interface" width="48%" />
|
||||
<img src="docs/assets/screenshot-setting.png" alt="Settings Interface" width="48%" />
|
||||
</p>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## � Quick Start
|
||||
|
||||
### Download
|
||||
|
||||
Download the latest release for your platform from the [Releases](https://github.com/Draculabo/AntigravityManager/releases) page.
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| Windows (x64/ARM64) | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| macOS | [.dmg installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| NixOS / Nix | `nix run github:Draculabo/AntigravityManager` |
|
||||
|
||||
### ❄️ Nix Integration
|
||||
|
||||
You can integrate Antigravity Manager into your Nix configuration using the provided flake.
|
||||
|
||||
#### 🛠️ NixOS / Home Manager
|
||||
|
||||
Add the flake to your `flake.nix` inputs:
|
||||
|
||||
```nix
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
antigravity-manager.url = "github:Draculabo/AntigravityManager";
|
||||
};
|
||||
```
|
||||
|
||||
To integrate with your system, add `antigravity-manager.overlays.default` to your `nixpkgs.overlays`. This ensures the package is correctly built against your own `nixpkgs` instance and respects your global configuration.
|
||||
|
||||
```nix
|
||||
# Add to your NixOS/Home Manager configuration
|
||||
{
|
||||
nixpkgs.overlays = [ inputs.antigravity-manager.overlays.default ];
|
||||
|
||||
# Antigravity Manager has an unfree license (cc-by-nc-sa-40)
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
|
||||
environment.systemPackages = [
|
||||
pkgs.antigravity-manager
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### 💻 Development Shell
|
||||
|
||||
If you're using the overlay above, you can simply add it to your `devShell`:
|
||||
|
||||
```nix
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.antigravity-manager
|
||||
];
|
||||
};
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Since the package is unfree, you **must** have `allowUnfree = true;` enabled in your `nixpkgs` configuration for it to evaluate correctly.
|
||||
## ⚡ Quick Start
|
||||
|
||||
### Build from Source
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- Node.js v18 or higher
|
||||
- npm or yarn
|
||||
- npm or pnpm
|
||||
|
||||
#### Steps
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Draculabo/AntigravityManager.git
|
||||
cd AntigravityManager
|
||||
git clone https://github.com/Draculabo/GeminiNexus.git
|
||||
cd GeminiNexus
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
@@ -268,56 +178,15 @@ npm run make
|
||||
|
||||
---
|
||||
|
||||
## �🛠️ Tech Stack
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
| Category | Technologies |
|
||||
|----------|-------------|
|
||||
| **Core** | [Electron](https://www.electronjs.org/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
|
||||
| **Build Tool** | [Vite](https://vitejs.dev/) |
|
||||
| **Styling** | [TailwindCSS](https://tailwindcss.com/), [Shadcn UI](https://ui.shadcn.com/) |
|
||||
| **State** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
|
||||
| **State** | [TanStack Query](https://tanstack.com/query/latest) |
|
||||
| **Database** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
|
||||
| **Testing** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
|
||||
|
||||
---
|
||||
|
||||
## � Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm start` | Start the app in development mode |
|
||||
| `npm run lint` | Run ESLint to check for code issues |
|
||||
| `npm run format:write` | Format code with Prettier |
|
||||
| `npm run test:unit` | Run unit tests with Vitest |
|
||||
| `npm run test:e2e` | Run E2E tests with Playwright |
|
||||
| `npm run test:all` | Run all tests |
|
||||
| `npm run type-check` | Run TypeScript type checking |
|
||||
| `npm run make` | Build production packages |
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
AntigravityManager/
|
||||
├── src/
|
||||
│ ├── main.ts # Electron main process
|
||||
│ ├── preload.ts # Preload script
|
||||
│ ├── renderer/ # React renderer process
|
||||
│ ├── ipc/ # IPC communication handlers
|
||||
│ └── server/ # Built-in server
|
||||
├── docs/ # Documentation and assets
|
||||
└── .github/ # GitHub configuration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Debugging Docs
|
||||
|
||||
If you want to inspect Antigravity or Antigravity Manager traffic with Proxyman, start here:
|
||||
|
||||
- [Proxyman Installation and Basic Setup on Windows](docs/proxyman-install.md)
|
||||
- [Proxyman Debugging Guide for Antigravity and Antigravity Manager](docs/proxyman-debugging.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -342,50 +211,11 @@ Please check:
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: macOS shows Keychain/Credential error and OAuth cannot be saved?</b></summary>
|
||||
|
||||
This is a common macOS security behavior, usually when the app is unsigned or run directly from Downloads.
|
||||
This is a **temporary workaround** for personal use:
|
||||
|
||||
1. Move the app to `/Applications`
|
||||
2. Run the following commands in Terminal (repeat after every update)
|
||||
|
||||
```plaintext
|
||||
sudo xattr -dr com.apple.quarantine "/Applications/Antigravity Manager 2.app"
|
||||
codesign --force --deep --sign - "/Applications/Antigravity Manager 2.app"
|
||||
```
|
||||
|
||||
Reopen the app and allow Keychain access if prompted.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: How to report issues or suggestions?</b></summary>
|
||||
|
||||
Please submit issues or suggestions via [GitHub Issues](https://github.com/Draculabo/AntigravityManager/issues).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
We follow the [Contributor Covenant](CODE_OF_CONDUCT.md) Code of Conduct.
|
||||
Contributions are welcome! Please read `CONTRIBUTING.md` for details.
|
||||
|
||||
---
|
||||
|
||||
@@ -406,6 +236,6 @@ We follow the [Contributor Covenant](CODE_OF_CONDUCT.md) Code of Conduct.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
If this project helps you, please give it a ⭐ Star!
|
||||
</p>
|
||||
## 🙏 Credits
|
||||
|
||||
**Gemini Nexus** is a significantly enhanced and expanded version of the original project [AntigravityManager](https://github.com/Draculabo/AntigravityManager) created by [Draculabo](https://github.com/Draculabo). All fundamental architecture and design were made by the original author.
|
||||
@@ -0,0 +1,146 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/logo.png" alt="Gemini Nexus" width="128" height="128" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Gemini Nexus</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>🚀 Gerenciador multi-contas profissional para Google Gemini & Claude AI</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | Português (Brasil)
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/actions/workflows/testing.yaml">
|
||||
<img src="https://github.com/Draculabo/GeminiNexus/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/actions/workflows/lint.yaml">
|
||||
<img src="https://github.com/Draculabo/GeminiNexus/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/GeminiNexus/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Draculabo/GeminiNexus?style=flat-square" alt="Release" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Por que o Gemini Nexus?
|
||||
|
||||
Quando você usa suas IDEs favoritas, já encontrou estes problemas?
|
||||
|
||||
- 😫 A cota da sua única conta acaba rápido, exigindo trocas manuais frequentes
|
||||
- 🔄 Gerenciar múltiplas contas do Google/Claude é cansativo
|
||||
- 📊 Você não sabe quanta cota resta na conta atual
|
||||
- 🔌 Precisa de um proxy local e confiável para suas ferramentas de desenvolvimento
|
||||
|
||||
**Gemini Nexus** está aqui para resolver tudo isso! Ele é um app desktop profissional construído em Electron que te ajuda a:
|
||||
|
||||
- ✅ **Contas Ilimitadas** - Adicione quantas contas Google Gemini / Claude quiser
|
||||
- ✅ **Troca Automática** - Troca inteligentemente para a próxima conta disponível quando o limite é atingido
|
||||
- ✅ **Monitoramento em Tempo Real** - Visualize o uso de cota de todas as contas
|
||||
- ✅ **Proxy de API Local** - Servidor proxy integrado compatível com OpenAI/Anthropic
|
||||
- ✅ **Criptografia Segura** - Criptografia AES-256-GCM para seus dados sensíveis
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Melhorias sobre o Projeto Base
|
||||
|
||||
Este projeto contém melhorias significativas de desenvolvimento, correção de erros e melhorias de configuração em relação ao projeto original base.
|
||||
|
||||
Principais melhorias incluem:
|
||||
|
||||
- **Interface de Gerenciamento de API Aprimorada:** Seletores dinâmicos de modelos e recursos de "Copiar Nome da API" implementados para facilitar a integração com outras ferramentas.
|
||||
- **Streaming Robusto (SSE):** Correção de bugs críticos no streaming via Server-Sent Events (SSE) no endpoint proxy `/v1/chat/completions` para suportar perfeitamente IDEs como Cursor, OpenCode e Windsurf.
|
||||
- **Rebranding e Localização:** Rebranding completo para Gemini Nexus com traduções melhoradas em Português (Brasil) e padrões em Inglês.
|
||||
- **Capacidades de Agentes:** Integração direta de frameworks avançados de agentes desenvolvedores de IA e skills (GSD, AutoResearch, Superpowers) no ecossistema do projeto para desenvolvimento autônomo rápido.
|
||||
- **Ambiente de Build e Dev:** Melhores configurações de build cross-platform, schemas atualizados e processos de toolchain automatizados.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Funcionalidades
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>☁️ Pool de Contas na Nuvem</h3>
|
||||
<ul>
|
||||
<li>Adicione contas via OAuth do Google e Anthropic</li>
|
||||
<li>Mostra avatar, e-mail, status e último horário de uso</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>📊 Monitoramento de Cota</h3>
|
||||
<ul>
|
||||
<li>Suporte multi-modelo: gemini-pro, claude-3-5-sonnet, etc.</li>
|
||||
<li>Barras de progresso visuais com indicadores de cores</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔄 Troca Automática</h3>
|
||||
<ul>
|
||||
<li>Modo de pool ilimitado com seleção inteligente</li>
|
||||
<li>Monitoramento a cada 5 minutos em segundo plano</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🔌 Proxy Local</h3>
|
||||
<ul>
|
||||
<li>Compatível com API da OpenAI & Anthropic</li>
|
||||
<li>Mapeamento de modelos customizável (ex: Claude → Gemini)</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Começo Rápido
|
||||
|
||||
### Construindo pelo Código Fonte
|
||||
|
||||
#### Pré-requisitos
|
||||
|
||||
- Node.js v18 ou superior
|
||||
- npm
|
||||
|
||||
#### Passos
|
||||
|
||||
```bash
|
||||
# Clone o repositório
|
||||
git clone https://github.com/Draculabo/GeminiNexus.git
|
||||
cd GeminiNexus
|
||||
|
||||
# Instale as dependências
|
||||
npm install
|
||||
|
||||
# Inicie o modo de desenvolvimento
|
||||
npm start
|
||||
|
||||
# Construa para produção
|
||||
npm run make
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licença
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Aviso Legal
|
||||
|
||||
> [!WARNING]
|
||||
> **Apenas para Fins Educacionais**
|
||||
>
|
||||
> Este projeto destina-se exclusivamente a fins de pesquisa e educação. Ele é fornecido "como está" sem qualquer garantia. **O uso comercial é estritamente proibido.**
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Créditos
|
||||
|
||||
**Gemini Nexus** é uma versão significativamente aprimorada e expandida baseada no projeto original [AntigravityManager](https://github.com/Draculabo/AntigravityManager) criado por [Draculabo](https://github.com/Draculabo). Toda a arquitetura fundamental e design do projeto foram criados pelo autor original.
|
||||
@@ -1,411 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/logo.png" alt="Antigravity Manager" width="128" height="128" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">Antigravity Manager</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>🚀 专业的 Google Gemini & Claude AI 多账号管理器</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | 简体中文
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml">
|
||||
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Draculabo/AntigravityManager?style=flat-square" alt="Release" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/releases">
|
||||
<img src="https://img.shields.io/github/downloads/Draculabo/AntigravityManager/total?style=flat-square&color=blue" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Draculabo/AntigravityManager?style=flat-square" alt="License" />
|
||||
</a>
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/Draculabo/AntigravityManager?style=flat-square" alt="Stars" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="TailwindCSS" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📖 目录
|
||||
|
||||
- [为什么选择 Antigravity Manager?](#-为什么选择-antigravity-manager)
|
||||
- [功能特性](#-功能特性)
|
||||
- [截图预览](#-截图预览)
|
||||
- [快速开始](#-快速开始)
|
||||
- [技术栈](#️-技术栈)
|
||||
- [开发指南](#-开发指南)
|
||||
- [调试文档](#-调试文档)
|
||||
- [常见问题](#-常见问题)
|
||||
- [贡献指南](#-贡献指南)
|
||||
- [许可证](#-许可证)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 为什么选择 Antigravity Manager?
|
||||
|
||||
在使用 Antigravity IDE 时,你是否遇到过这些问题?
|
||||
|
||||
- 😫 单个账号额度很快用完,需要频繁手动切换
|
||||
- 🔄 管理多个 Google/Claude 账号非常麻烦
|
||||
- 📊 不知道当前账号还剩多少额度
|
||||
- ⏰ 担心错过额度重置时间
|
||||
- 🔌 需要可靠的本地 API 代理用于开发工具
|
||||
|
||||
**Antigravity Manager** 就是为解决这些问题而生的!它是一个专业的 Electron 桌面应用,帮助你:
|
||||
|
||||
- ✅ **无限账号池** - 添加任意数量的 Google Gemini / Claude 账号
|
||||
- ✅ **智能自动切换** - 额度不足或被限速时自动切换到下一个可用账号
|
||||
- ✅ **实时监控** - 可视化显示所有账号的额度使用情况
|
||||
- ✅ **本地 API 代理** - 内置兼容 OpenAI/Anthropic 协议的代理服务器
|
||||
- ✅ **安全加密** - AES-256-GCM 加密存储敏感信息
|
||||
|
||||
---
|
||||
|
||||
## 🎯 功能特性
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>☁️ 云账号池管理</h3>
|
||||
<ul>
|
||||
<li>通过 OAuth 添加无限 Google Gemini / Claude 账号</li>
|
||||
<li>显示头像、邮箱、状态和最后使用时间</li>
|
||||
<li>实时状态监控(活跃、限速、过期)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>📊 实时额度监控</h3>
|
||||
<ul>
|
||||
<li>多模型支持:gemini-pro、claude-3-5-sonnet 等</li>
|
||||
<li>可视化进度条配色指示器</li>
|
||||
<li>自动刷新与手动刷新</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔄 智能自动切换</h3>
|
||||
<ul>
|
||||
<li>无限池模式,智能选择备用账号</li>
|
||||
<li>额度低于 5% 或被限速时自动切换</li>
|
||||
<li>后台每 5 分钟自动监控</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🔐 安全优先</h3>
|
||||
<ul>
|
||||
<li>AES-256-GCM 加密敏感数据</li>
|
||||
<li>集成操作系统原生凭证管理器</li>
|
||||
<li>自动迁移旧版明文数据</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>💾 账号备份</h3>
|
||||
<ul>
|
||||
<li>捕获账号状态快照</li>
|
||||
<li>在已保存的账号间快速切换</li>
|
||||
<li>查看、整理和删除快照</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>⚙️ 进程控制</h3>
|
||||
<ul>
|
||||
<li>自动检测 Antigravity 是否运行</li>
|
||||
<li>通过 URI 协议或可执行文件启动</li>
|
||||
<li>优雅关闭或强制终止</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<h3>🔌 本地 API 代理</h3>
|
||||
<ul>
|
||||
<li>兼容 OpenAI & Anthropic API 协议</li>
|
||||
<li>可配置端口和请求超时</li>
|
||||
<li>模型映射(如:Claude → Gemini)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<h3>🛠️ 开发者工具</h3>
|
||||
<ul>
|
||||
<li>内置 cURL & Python 代码生成</li>
|
||||
<li>可视化服务状态监控</li>
|
||||
<li>一键重新生成 API Key</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 更多功能
|
||||
|
||||
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
|
||||
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
|
||||
- **📦 批量操作** - 批量刷新和删除多个账号
|
||||
- **📤 JSON 导出/导入** - 导出和导入账号池,支持架构验证和去重
|
||||
- **🔔 桌面通知** - 模型配额低于设定阈值时自动提醒
|
||||
- **🌐 单账号代理** - 为每个账号单独配置 HTTP/SOCKS5 代理
|
||||
- **📊 智能排序** - 按最近使用、总体配额或特定模型组排序
|
||||
- **📋 紧凑布局** - 水平密集视图,最大化可见账号数量
|
||||
- **🌏 国际化** - 多语言支持(English / 中文 / Русский)
|
||||
- **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建
|
||||
|
||||
---
|
||||
|
||||
## 📸 截图预览
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/screenshot-main.png" alt="主界面" width="80%" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/screenshot-proxy.png" alt="代理界面" width="48%" />
|
||||
<img src="docs/assets/screenshot-setting.png" alt="设置界面" width="48%" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 下载安装
|
||||
|
||||
从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。
|
||||
|
||||
| 平台 | 下载链接 |
|
||||
| ------------------- | ------------------------------------------------------------------------------ |
|
||||
| Windows (x64/ARM64) | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||
| NixOS / Nix | `nix run github:Draculabo/AntigravityManager` |
|
||||
|
||||
### ❄️ Nix 集成
|
||||
|
||||
你可以通过项目提供的 flake 将 Antigravity Manager 集成到你的 Nix 配置中。
|
||||
|
||||
#### 🛠️ NixOS / Home Manager
|
||||
|
||||
先在你的 `flake.nix` 的 `inputs` 中加入:
|
||||
|
||||
```nix
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
antigravity-manager.url = "github:Draculabo/AntigravityManager";
|
||||
};
|
||||
```
|
||||
|
||||
然后把 `antigravity-manager.overlays.default` 添加到 `nixpkgs.overlays`。这样会基于你自己的 `nixpkgs` 实例构建,并继承你的全局配置。
|
||||
|
||||
```nix
|
||||
# 添加到你的 NixOS/Home Manager 配置中
|
||||
{
|
||||
nixpkgs.overlays = [ inputs.antigravity-manager.overlays.default ];
|
||||
|
||||
# Antigravity Manager 使用非自由许可证 (cc-by-nc-sa-40)
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
|
||||
environment.systemPackages = [
|
||||
pkgs.antigravity-manager
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### 💻 开发 Shell
|
||||
|
||||
如果你已经使用上面的 overlay,也可以直接在 `devShell` 里加入:
|
||||
|
||||
```nix
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.antigravity-manager
|
||||
];
|
||||
};
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 由于该包是非自由软件,你必须在 `nixpkgs` 配置中开启 `allowUnfree = true;`,否则会评估失败。
|
||||
|
||||
### 从源码构建
|
||||
|
||||
#### 前置要求
|
||||
|
||||
- Node.js v18 或更高版本
|
||||
- npm 或 yarn
|
||||
|
||||
#### 步骤
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/Draculabo/AntigravityManager.git
|
||||
cd AntigravityManager
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发模式
|
||||
npm start
|
||||
|
||||
# 构建生产版本
|
||||
npm run make
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
| 类别 | 技术 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) |
|
||||
| **构建工具** | [Vite](https://vitejs.dev/) |
|
||||
| **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) |
|
||||
| **状态管理** | [TanStack Query](https://tanstack.com/query/latest)、[TanStack Router](https://tanstack.com/router/latest) |
|
||||
| **数据库** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
|
||||
| **测试** | [Vitest](https://vitest.dev/)、[Playwright](https://playwright.dev/) |
|
||||
|
||||
---
|
||||
|
||||
## 💻 开发指南
|
||||
|
||||
### 可用脚本
|
||||
|
||||
| 命令 | 描述 |
|
||||
| ---------------------- | ------------------------ |
|
||||
| `npm start` | 启动开发模式 |
|
||||
| `npm run lint` | 运行 ESLint 检查 |
|
||||
| `npm run format:write` | 使用 Prettier 格式化代码 |
|
||||
| `npm run test:unit` | 运行单元测试 |
|
||||
| `npm run test:e2e` | 运行 E2E 测试 |
|
||||
| `npm run test:all` | 运行所有测试 |
|
||||
| `npm run type-check` | TypeScript 类型检查 |
|
||||
| `npm run make` | 构建生产包 |
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
AntigravityManager/
|
||||
├── src/
|
||||
│ ├── main.ts # Electron 主进程
|
||||
│ ├── preload.ts # 预加载脚本
|
||||
│ ├── renderer/ # React 渲染进程
|
||||
│ ├── ipc/ # IPC 通信处理
|
||||
│ └── server/ # 内置服务器
|
||||
├── docs/ # 文档和资源
|
||||
└── .github/ # GitHub 配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 调试文档
|
||||
|
||||
如果你希望用 Proxyman 抓取和排查 Antigravity 或 Antigravity Manager 的流量,请先阅读:
|
||||
|
||||
- [Proxyman Installation and Basic Setup on Windows](docs/proxyman-install.md)
|
||||
- [Proxyman Debugging Guide for Antigravity and Antigravity Manager](docs/proxyman-debugging.md)
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
<details>
|
||||
<summary><b>Q: 程序无法启动怎么办?</b></summary>
|
||||
|
||||
请检查:
|
||||
|
||||
1. 确保已安装所有依赖:`npm install`
|
||||
2. 检查 Node.js 版本是否 >= 18
|
||||
3. 尝试删除 `node_modules` 后重新安装
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: 账号登录失败?</b></summary>
|
||||
|
||||
1. 确保网络连接正常
|
||||
2. 尝试清除应用数据后重新登录
|
||||
3. 检查账号是否被 Google/Claude 限制
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: macOS 上提示 Keychain/凭据管理不可用,OAuth 无法保存?</b></summary>
|
||||
|
||||
这是 macOS 安全机制导致的常见问题,通常出现在未签名的应用或从 Downloads 直接运行的场景。
|
||||
这是**临时方案**,仅适合个人设备:
|
||||
|
||||
1. 将应用移动到 `/Applications`
|
||||
2. 打开终端执行以下命令(每次更新后都需要重新执行)
|
||||
|
||||
```plaintext
|
||||
sudo xattr -dr com.apple.quarantine "/Applications/Antigravity Manager 2.app"
|
||||
codesign --force --deep --sign - "/Applications/Antigravity Manager 2.app"
|
||||
```
|
||||
|
||||
重新打开应用后,Keychain 应会提示授权(可选“始终允许”)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Q: 如何反馈问题或建议?</b></summary>
|
||||
|
||||
请通过 [GitHub Issues](https://github.com/Draculabo/AntigravityManager/issues) 提交问题或建议。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎贡献代码!请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解详情。
|
||||
|
||||
我们遵循 [Contributor Covenant](CODE_OF_CONDUCT.md) 行为准则。
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
> [!WARNING]
|
||||
> **仅供学习研究使用**
|
||||
>
|
||||
> 本项目仅供教育和研究目的,按"原样"提供,不提供任何保证。**严禁商业使用。**
|
||||
>
|
||||
> 使用本软件即表示您同意不会将其用于任何商业目的,并自行负责确保您的使用符合所有适用法律法规。作者和贡献者对因使用本软件而产生的任何滥用或损害不承担责任。
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
如果这个项目对你有帮助,请给一个 ⭐ Star 支持!
|
||||
</p>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
Antigravity Manager takes security seriously. Here are the security measures we've implemented:
|
||||
Gemini Nexus takes security seriously. Here are the security measures we've implemented:
|
||||
|
||||
### Encryption
|
||||
|
||||
@@ -35,7 +35,7 @@ We only provide security updates for the latest version. Please ensure you're ru
|
||||
|
||||
## 🚨 Reporting a Vulnerability
|
||||
|
||||
We take all security vulnerabilities seriously. Thank you for helping to improve the security of Antigravity Manager.
|
||||
We take all security vulnerabilities seriously. Thank you for helping to improve the security of Gemini Nexus.
|
||||
|
||||
### How to Report
|
||||
|
||||
@@ -75,7 +75,7 @@ Please include the following information in your report:
|
||||
|
||||
To ensure the security of your data:
|
||||
|
||||
1. **Keep Updated**: Always use the latest version of Antigravity Manager
|
||||
1. **Keep Updated**: Always use the latest version of Gemini Nexus
|
||||
2. **System Security**: Keep your operating system updated
|
||||
3. **Secure Environment**: Use the application on trusted devices only
|
||||
4. **Backup**: Regularly backup your account data using the built-in backup feature
|
||||
@@ -90,4 +90,4 @@ When we receive a security bug report, we will:
|
||||
3. Prepare fixes for all supported versions
|
||||
4. Release new versions as soon as possible
|
||||
|
||||
Thank you for helping keep Antigravity Manager and its users safe!
|
||||
Thank you for helping keep Gemini Nexus and its users safe!
|
||||
@@ -1,6 +1,6 @@
|
||||
# Antigravity Manager CLI
|
||||
# Gemini Nexus CLI
|
||||
|
||||
A powerful command-line interface for managing multiple Google Cloud AI accounts in Antigravity IDE. Supports account switching, quota monitoring, token validation, and more.
|
||||
A powerful command-line interface for managing multiple Google Cloud AI accounts in Gemini Nexus IDE. Supports account switching, quota monitoring, token validation, and more.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -147,9 +147,9 @@ agm doctor
|
||||
|
||||
## How It Works
|
||||
|
||||
The CLI interacts directly with the Antigravity Manager's encrypted database:
|
||||
The CLI interacts directly with the Gemini Nexus's encrypted database:
|
||||
|
||||
1. **Database Location**: Auto-discovers the database in `%APPDATA%\AntigravityManager` or IDE installation directory
|
||||
1. **Database Location**: Auto-discovers the database in `%APPDATA%\GeminiNexus` or IDE installation directory
|
||||
2. **Encryption**: Uses Windows DPAPI + AES-256-GCM to decrypt account tokens (same as the IDE)
|
||||
3. **Quota API**: Fetches live quota data from Google Cloud AI REST API
|
||||
4. **Process Control**: Can start/stop the IDE executable automatically
|
||||
@@ -157,7 +157,7 @@ The CLI interacts directly with the Antigravity Manager's encrypted database:
|
||||
## Troubleshooting
|
||||
|
||||
**"Database not found" error:**
|
||||
- Ensure Antigravity Manager has been run at least once
|
||||
- Ensure Gemini Nexus has been run at least once
|
||||
- Check if accounts exist in the GUI first
|
||||
|
||||
**"Failed to decrypt" error:**
|
||||
@@ -175,7 +175,7 @@ The CLI interacts directly with the Antigravity Manager's encrypted database:
|
||||
|
||||
- Windows 10/11
|
||||
- Python 3.8+
|
||||
- Antigravity Manager (for database access)
|
||||
- Gemini Nexus (for database access)
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
**✅ Documento Completo: Integração com Custom Endpoint do OpenCode (Provedores Custom / OpenAI-Compatible)**
|
||||
|
||||
OpenCode (https://opencode.ai) é um agente de IA para terminal que usa o **Vercel AI SDK** para se comunicar com provedores de LLM. Ele suporta nativamente provedores customizados via o pacote `@ai-sdk/openai-compatible`, que segue o padrão **OpenAI Chat Completions API** (`/v1/chat/completions`).
|
||||
|
||||
Isso significa que **sua API precisa implementar exatamente o formato OpenAI-compatible** para que o OpenCode consiga conversar com ela diretamente.
|
||||
|
||||
---
|
||||
|
||||
### 1. Como Configurar um Provedor Custom no OpenCode
|
||||
|
||||
Crie ou edite o arquivo de configuração (recomendado: `~/.config/opencode/opencode.json` ou `opencode.json` na raiz do projeto):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"minha-ia-custom": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Minha IA Custom (OpenCode)",
|
||||
"options": {
|
||||
"baseURL": "https://sua-api.com/v1",
|
||||
"apiKey": "sk-sua-chave-aqui-ou-{env:MINHA_API_KEY}",
|
||||
"headers": {
|
||||
"X-Custom-Header": "valor-opcional"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"meu-modelo-principal": {
|
||||
"name": "Meu Modelo Principal",
|
||||
"limit": {
|
||||
"context": 128000,
|
||||
"output": 8192
|
||||
}
|
||||
},
|
||||
"meu-modelo-rapido": {
|
||||
"name": "Modelo Rápido",
|
||||
"limit": {
|
||||
"context": 32000,
|
||||
"output": 4096
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": "minha-ia-custom/meu-modelo-principal"
|
||||
}
|
||||
```
|
||||
|
||||
**Campos importantes:**
|
||||
|
||||
- `npm`: Sempre use `"@ai-sdk/openai-compatible"` para APIs que expõem `/v1/chat/completions`.
|
||||
- `baseURL`: URL base da sua API (deve terminar com `/v1`).
|
||||
- `apiKey`: Pode ser valor direto ou `{env:NOME_VARIAVEL}`.
|
||||
- `models`: Defina os modelos que sua API expõe. O `limit` ajuda o OpenCode a gerenciar contexto.
|
||||
- `model`: Define o modelo padrão no formato `provider_id/model_id`.
|
||||
|
||||
Depois de salvar, reinicie o OpenCode ou use `/models` para selecionar.
|
||||
|
||||
**Comando rápido para adicionar chave:**
|
||||
|
||||
```bash
|
||||
opencode /connect
|
||||
# Escolha "Other" → digite o provider_id (ex: minha-ia-custom) → cole a API key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Como o OpenCode Envia Requisições
|
||||
|
||||
O OpenCode (via AI SDK) faz chamadas **POST** para:
|
||||
|
||||
```
|
||||
POST {baseURL}/chat/completions
|
||||
```
|
||||
|
||||
**Headers enviados:**
|
||||
|
||||
- `Authorization: Bearer <sua-api-key>`
|
||||
- `Content-Type: application/json`
|
||||
- Headers customizados que você definir em `options.headers`
|
||||
|
||||
**Payload típico (exemplo real):**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "meu-modelo-principal",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Você é um assistente de código experiente..."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Crie uma função em Python que..."
|
||||
}
|
||||
],
|
||||
"stream": true,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 8192,
|
||||
"tools": [
|
||||
/* array de tools se o agente estiver usando ferramentas */
|
||||
],
|
||||
"tool_choice": "auto"
|
||||
}
|
||||
```
|
||||
|
||||
**Parâmetros comuns que o OpenCode envia:**
|
||||
|
||||
- `messages` (array completo do histórico)
|
||||
- `stream: true` (quase sempre, para UX em tempo real)
|
||||
- `temperature`, `max_tokens`, `top_p`
|
||||
- `tools` + `tool_choice` (quando usa agentes com ferramentas/MCP)
|
||||
- `response_format` (às vezes)
|
||||
- `providerOptions` (opções específicas do provedor – raras no openai-compatible)
|
||||
|
||||
---
|
||||
|
||||
### 3. Como o OpenCode Recebe e Processa Respostas
|
||||
|
||||
#### A. Streaming (recomendado – usado na maioria das interações)
|
||||
|
||||
Sua API deve responder com `Content-Type: text/event-stream` e eventos SSE:
|
||||
|
||||
```
|
||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1712345678,"model":"meu-modelo-principal","choices":[{"index":0,"delta":{"content":"Olá"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":"! Como"},"finish_reason":null}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
O OpenCode processa cada chunk em tempo real.
|
||||
|
||||
#### B. Resposta não-streaming (completa)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"created": 1712345678,
|
||||
"model": "meu-modelo-principal",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Resposta completa aqui..."
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 150,
|
||||
"completion_tokens": 320,
|
||||
"total_tokens": 470
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Suporte a Tool Calling (essencial para agentes)
|
||||
|
||||
Se sua API suportar `tools`, o OpenCode pode:
|
||||
|
||||
1. Enviar `tools` na requisição.
|
||||
2. Receber `tool_calls` na resposta.
|
||||
3. Executar a ferramenta (via MCP ou built-in).
|
||||
4. Enviar o resultado de volta como mensagem `tool`.
|
||||
|
||||
**Exemplo de resposta com tool call:**
|
||||
|
||||
```json
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\": \"src/app.ts\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Como é Exibido no Chat do OpenCode (TUI)
|
||||
|
||||
- **Streaming**: Texto aparece caractere por caractere com efeito de digitação.
|
||||
- **Markdown**: Suporte completo a código (syntax highlight), listas, tabelas, etc.
|
||||
- **Tool Calls**: Mostra “Executando ferramenta: read_file...” + resultado.
|
||||
- **Multi-turn**: Mantém histórico completo na conversa.
|
||||
- **Erros**: Mostra mensagens de erro claras no terminal.
|
||||
- **Contexto**: Respeita os limites de `context` e `output` que você definiu no config.
|
||||
|
||||
O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderizar tudo de forma bonita.
|
||||
|
||||
---
|
||||
|
||||
### 5. Requisitos Mínimos da Sua API para Funcionar Perfeitamente
|
||||
|
||||
| Recurso | Obrigatório? | Recomendado? | Observação |
|
||||
| ---------------------- | ------------ | ------------ | ---------------------------------- |
|
||||
| `/v1/chat/completions` | Sim | - | Endpoint principal |
|
||||
| Streaming (SSE) | Sim | - | Essencial para UX |
|
||||
| Tool Calling | Não | Sim | Para agentes completos |
|
||||
| System messages | Sim | - | - |
|
||||
| Multi-turn history | Sim | - | - |
|
||||
| Usage stats | Não | Sim | Mostra tokens gastos |
|
||||
| Error handling | Sim | - | Retorne JSON de erro padrão OpenAI |
|
||||
|
||||
**Endpoint exato esperado:**
|
||||
`POST https://sua-api.com/v1/chat/completions`
|
||||
|
||||
---
|
||||
|
||||
### 6. Exemplo Completo de Configuração + Teste
|
||||
|
||||
1. Configure o `opencode.json` como acima.
|
||||
2. Rode:
|
||||
```bash
|
||||
opencode
|
||||
```
|
||||
3. Digite algo ou use `/models` para selecionar seu provedor.
|
||||
4. Teste com:
|
||||
```bash
|
||||
curl -X POST https://sua-api.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer sk-..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"meu-modelo-principal","messages":[{"role":"user","content":"Olá"}],"stream":true}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Troubleshooting Comum
|
||||
|
||||
- **“Route not found” ou requests indo para lugar errado** → Verifique se `baseURL` termina com `/v1` e não com `/v1/chat/completions`.
|
||||
- **Custom provider não aparece** → Certifique-se que o ID do provider no `/connect` bate com a chave no `opencode.json`.
|
||||
- **Sem streaming / resposta lenta** → Implemente SSE corretamente.
|
||||
- **Tool calls não funcionam** → Sua API precisa retornar `tool_calls` no formato OpenAI exato.
|
||||
- **Erro de autenticação** → Use `apiKey` ou `headers` corretamente.
|
||||
|
||||
---
|
||||
|
||||
### Referências Oficiais
|
||||
|
||||
- **Docs Principais**: https://opencode.ai/docs/providers/
|
||||
- **Models**: https://opencode.ai/docs/models/
|
||||
- **Config**: https://opencode.ai/docs/config/
|
||||
- **Schema de Config**: https://opencode.ai/config.json
|
||||
- **GitHub**: https://github.com/anomalyco/opencode
|
||||
- **AI SDK OpenAI-Compatible**: https://ai-sdk.dev/providers/openai-compatible-providers
|
||||
|
||||
---
|
||||
|
||||
**Resumo Final para Você (que está construindo a API):**
|
||||
|
||||
Sua API **precisa ser 100% compatível** com o endpoint OpenAI `/v1/chat/completions` (streaming + tool calling recomendado).
|
||||
O OpenCode vai:
|
||||
|
||||
1. Configurar via `opencode.json` (baseURL + apiKey)
|
||||
2. Enviar requisições padrão OpenAI
|
||||
3. Receber streaming ou resposta completa
|
||||
4. Renderizar tudo lindamente no TUI com suporte a ferramentas
|
||||
|
||||
Se você implementar exatamente o padrão OpenAI Chat Completions, vai funcionar perfeitamente com o OpenCode.
|
||||
|
||||
Quer que eu monte um **exemplo de servidor mínimo** (em Node.js/Fastify ou Python/FastAPI) que já funcione com o OpenCode? Posso criar um template completo para você.
|
||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 390 KiB |
@@ -0,0 +1,146 @@
|
||||
# Brand Briefing — Gemini Nexus
|
||||
|
||||
## 1. Nome e Descrição
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **Nome** | Gemini Nexus |
|
||||
| **Product Name** | Gemini Nexus |
|
||||
| **Package Name** | gemini-nexus |
|
||||
| **Versão** | 0.11.1 |
|
||||
| **Descrição EN** | Professional multi-account manager for Google Gemini & Claude AI |
|
||||
| **Descrição PT-BR** | Gerenciador multi-contas profissional para Google Gemini & Claude AI |
|
||||
| **Autor** | evandrodevbr / Draculabo |
|
||||
| **Licença** | CC-BY-NC-SA-4.0 (apenas fins educacionais) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Propósito do Aplicativo
|
||||
|
||||
Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona como um **hub central (nexus)** para desenvolvedores que usam IDEs com IA.
|
||||
|
||||
### Funcionalidades Principais
|
||||
- **Pool ilimitado de contas**: Adicionar múltiplas contas Google Gemini / Claude via OAuth
|
||||
- **Auto-switching inteligente**: Troca automática de conta quando a cota acaba ou há rate limit
|
||||
- **Monitoramento em tempo real**: Barras de progresso coloridas para visualizar uso de quota
|
||||
- **Proxy API local**: Servidor proxy integrado compatível OpenAI/Anthropic (porta 8045)
|
||||
- **Segurança**: Criptografia AES-256-GCM para dados sensíveis
|
||||
|
||||
### Público-Alvo
|
||||
- Desenvolvedores que usam IDEs com integração AI (Cursor, Windsurf, VS Code, Claude Code, etc.)
|
||||
- Power users que precisam alternar entre múltiplas contas de IA
|
||||
- Usuários que precisam de um proxy API local confiável
|
||||
|
||||
---
|
||||
|
||||
## 3. Tom de Voz / Personalidade
|
||||
|
||||
- **Profissional e técnica**: Termos como "Nexus", "Proxy", "Gateway", "Quota", "Circuit Breaker"
|
||||
- **Confiável e estável**: Monitoramento, health checks, logging robusto
|
||||
- **Developer-first**: Suporte a múltiplas IDEs, geração de cURL/Python
|
||||
- **Moderna e minimalista**: Interface limpa, componentes modulares
|
||||
- **Global**: Suporte a i18n (EN, PT-BR, ZH, RU, VI)
|
||||
|
||||
---
|
||||
|
||||
## 4. Estilo Visual Atual
|
||||
|
||||
### Tema
|
||||
- Dual theme (claro/escuro) com detecção automática do sistema
|
||||
- Extremamente minimalista e quase monocromático
|
||||
|
||||
### Paleta de Cores (Tailwind OKLCH)
|
||||
|
||||
| Modo | Background | Foreground | Primary | Destructive | Destaque |
|
||||
|------|-----------|------------|---------|-------------|----------|
|
||||
| **Claro** | oklch(0.99 0 0) — quase branco | oklch(0 0 0) — preto | Preto | Vermelho | Azul |
|
||||
| **Escuro** | oklch(0 0 0) — preto puro | oklch(1 0 0) — branco | Branco | Vermelho escuro | — |
|
||||
|
||||
### Cores Funcionais
|
||||
- **Emerald/Green (`#10b981`)** — status ativo, quota alta, running
|
||||
- **Amber/Yellow** — quota média, alertas
|
||||
- **Rose/Red** — quota baixa, erro, stopped
|
||||
- **Blue** — AI credits, informações premium
|
||||
|
||||
### Tipografia
|
||||
- **Geist** (sans-serif) — fonte principal moderna e geométrica (da Vercel)
|
||||
- **Geist Mono** (monospace) — dados técnicos, números de quota
|
||||
- **Tomorrow** (display/futurista) — possíveis títulos estilizados
|
||||
|
||||
### Características de UI
|
||||
- Bordas arredondadas (`0.5rem` / 8px)
|
||||
- Sombras sutis
|
||||
- Cards com hover states suaves
|
||||
- Interface limpa com espaçamento generoso
|
||||
|
||||
---
|
||||
|
||||
## 5. Assets Visuais Existentes
|
||||
|
||||
| Arquivo | Descrição |
|
||||
|---------|-----------|
|
||||
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
|
||||
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
|
||||
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
|
||||
| `docs/assets/screenshot-setting.png` | Screenshot de settings |
|
||||
| `src/assets/icon.png` | Ícone do app (também é o logo antigo) |
|
||||
| `src/assets/tray.png` | Ícone da system tray |
|
||||
| `images/icon.ico` / `images/icon.icns` | Ícones de build para Windows/Mac |
|
||||
| `images/favicon-*.png` | Favicons em múltiplos tamanhos |
|
||||
| `images/32x32.png`, `64x64.png`, `128x128.png`, `[email protected]` | Ícones para AppImage Linux |
|
||||
| `src/assets/fonts/geist/` | Fonte Geist (principal) |
|
||||
| `src/assets/fonts/geist-mono/` | Fonte Geist Mono (código/dados) |
|
||||
| `src/assets/fonts/tomorrow/` | Fonte Tomorrow (display, 4 pesos) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Conceitos Visuais Sugeridos para a Logo
|
||||
|
||||
### A. Nodo / Conexão (Nexus)
|
||||
- Ponto central conectado a múltiplos pontos (contas gerenciadas)
|
||||
- Círculo central com 2-3 linhas finas irradiando
|
||||
- Dois círculos interligados (Gemini + Claude)
|
||||
|
||||
### B. Infinito / Loop (Gemini + Proxy)
|
||||
- Símbolo de infinito (∞) modificado como circuito/loop de dados
|
||||
- Referência à dualidade "Gemini" (gêmeos)
|
||||
- Curva contínua elegante com seta indicando fluxo
|
||||
|
||||
### C. Cubo / Hexágono (Nexus / Hub)
|
||||
- Hexágono ou cubo isométrico minimalista
|
||||
- Linhas finas, perspectiva suave
|
||||
- Face "aberta" ou conectada
|
||||
|
||||
### D. Monograma "GN"
|
||||
- Letras "G" e "N" abstraídas como nodo de rede
|
||||
- Estilo geométrico, similar a Tomorrow ou Geist
|
||||
|
||||
### E. Proxy / Pipe (Fluxo de Dados)
|
||||
- Duas barras paralelas com seta passando entre elas
|
||||
- "Pipe" minimalista — conduz tráfego entre APIs
|
||||
|
||||
---
|
||||
|
||||
## 7. Diretrizes para a Nova Logo
|
||||
|
||||
| Elemento | Especificação |
|
||||
|----------|---------------|
|
||||
| **Estilo** | Minimalista, geométrica, precisa, técnica |
|
||||
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
|
||||
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
|
||||
| **Versões necessárias** | Dark mode e light mode |
|
||||
| **Tamanhos** | De 16x16 (tray) até 512x512 (macOS) |
|
||||
| **Monocromática** | Deve funcionar em preto e branco puro |
|
||||
| **Tipografia do wordmark** | Geométrica sans-serif, peso medium/semibold, tracking levemente expandido |
|
||||
|
||||
---
|
||||
|
||||
## 8. Conclusão
|
||||
|
||||
O app pede uma logo que evoque **conexão/nexo** (Nexus) com **múltiplos pontos** (contas) de forma geométrica e limpa, usando primariamente preto/branco com um toque sutil de verde (emerald) para representar o estado "ativo/online" central ao propósito do app.
|
||||
|
||||
A logo deve transmitir:
|
||||
- **Tecnologia** e **precisão**
|
||||
- **Conectividade** e **hub central**
|
||||
- **Profissionalismo** e **confiança**
|
||||
- **Modernidade** e **minimalismo**
|
||||
@@ -1,6 +1,6 @@
|
||||
# Cloud Account Management and Intelligent Switching
|
||||
|
||||
This document explains the cloud AI account management and switching capabilities in Antigravity Manager. The feature allows users to manage multiple Google accounts, monitor API quotas, and seamlessly switch the active IDE account with one click.
|
||||
This document explains the cloud AI account management and switching capabilities in Gemini Nexus. The feature allows users to manage multiple Google accounts, monitor API quotas, and seamlessly switch the active IDE account with one click.
|
||||
|
||||
## 1. Core Features
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Proxyman Debugging Guide for Antigravity and Antigravity Manager
|
||||
# Proxyman Debugging Guide for Antigravity and Gemini Nexus
|
||||
|
||||
This guide explains how to use Proxyman for two debugging targets:
|
||||
|
||||
1. The installed official `Antigravity` desktop client
|
||||
2. The `Antigravity Manager` development build from this repository
|
||||
2. The `Gemini Nexus` development build from this repository
|
||||
|
||||
Before you start, complete [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md).
|
||||
|
||||
@@ -12,7 +12,7 @@ Before you start, complete [Proxyman Installation and Basic Setup on Windows](./
|
||||
Use this guide when you want to:
|
||||
|
||||
- inspect login, quota, project context, or model-list requests from `Antigravity`
|
||||
- inspect OAuth, quota refresh, or proxy traffic from `Antigravity Manager`
|
||||
- inspect OAuth, quota refresh, or proxy traffic from `Gemini Nexus`
|
||||
- compare requests from the official client with requests produced by this project
|
||||
|
||||
## Understand the Two Traffic Paths
|
||||
@@ -24,7 +24,7 @@ Use this guide when you want to:
|
||||
- Chromium or renderer-side requests
|
||||
- Electron or Node-side requests
|
||||
|
||||
### `Antigravity Manager` in This Repository
|
||||
### `Gemini Nexus` in This Repository
|
||||
|
||||
This project is also an Electron app, but it additionally includes Node, Undici, and internal proxy flows. You may need to inspect:
|
||||
|
||||
@@ -68,7 +68,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxym
|
||||
Notes:
|
||||
|
||||
- This script targets the installed official `Antigravity` client.
|
||||
- It does not launch this repository's `Antigravity Manager` development build.
|
||||
- It does not launch this repository's `Gemini Nexus` development build.
|
||||
- Override `-AppPath`, `-CaPath`, `-ProxyHost`, or `-NoProxy` if your local environment differs.
|
||||
|
||||
### Suggested Flow
|
||||
@@ -111,7 +111,7 @@ If system proxy behavior is unreliable, you can launch the installed client with
|
||||
|
||||
Use this as a fallback, not the default path.
|
||||
|
||||
## Debug Antigravity Manager From This Repository
|
||||
## Debug Gemini Nexus From This Repository
|
||||
|
||||
This project has more layers than the official client. The script above is not the general launcher for this repository's development app.
|
||||
|
||||
@@ -227,7 +227,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxym
|
||||
|
||||
4. Trigger login, quota, or model-list traffic.
|
||||
|
||||
### Antigravity Manager in This Repository
|
||||
### Gemini Nexus in This Repository
|
||||
|
||||
1. Complete [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md).
|
||||
2. Open PowerShell.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Extensões permitidas
|
||||
const allowedExtensions = /\.(ts|tsx)$/;
|
||||
|
||||
function walkAndFix(dir) {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
for (const file of files) {
|
||||
if (['node_modules', '.git', 'dist', 'out'].includes(file)) continue;
|
||||
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
walkAndFix(fullPath);
|
||||
} else if (allowedExtensions.test(file)) {
|
||||
let content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const original = content;
|
||||
|
||||
// Fix specific known broken identifiers
|
||||
content = content.replace(/closeGemini Nexus/g, 'closeGeminiNexus');
|
||||
content = content.replace(/startGemini Nexus/g, 'startGeminiNexus');
|
||||
content = content.replace(/Gemini NexusVersion/g, 'GeminiNexusVersion');
|
||||
content = content.replace(/Gemini NexusDb/g, 'GeminiNexusDb');
|
||||
content = content.replace(/Gemini NexusStorage/g, 'GeminiNexusStorage');
|
||||
content = content.replace(/Gemini NexusExecutable/g, 'GeminiNexusExecutable');
|
||||
content = content.replace(/Gemini NexusCoreFeatures/g, 'GeminiNexusCoreFeatures');
|
||||
content = content.replace(/getGemini Nexus/g, 'getGeminiNexus');
|
||||
|
||||
if (content !== original) {
|
||||
fs.writeFileSync(fullPath, content, 'utf-8');
|
||||
console.log(`Fixed TS errors in: ${fullPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkAndFix(projectRoot);
|
||||
console.log('Fix complete.');
|
||||
@@ -25,7 +25,7 @@ import { stringify as yamlStringify } from 'yaml';
|
||||
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
|
||||
const ResolvedMakerAppImage = MakerAppImage;
|
||||
const keepLanguages = new Set(['en', 'en-US', 'zh-CN', 'ru']);
|
||||
const windowsExecutableName = 'antigravity-manager';
|
||||
const windowsExecutableName = 'gemini-nexus';
|
||||
|
||||
const isStartCommand = process.argv.some((arg) => arg.includes('start'));
|
||||
|
||||
@@ -204,7 +204,7 @@ const config: ForgeConfig = {
|
||||
asar: {
|
||||
unpack: '**/{better-sqlite3,keytar}/**/*',
|
||||
},
|
||||
name: 'Antigravity Manager',
|
||||
name: 'Gemini Nexus',
|
||||
executableName: windowsExecutableName,
|
||||
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
|
||||
extraResource: ['src/assets'], // Copy assets folder to resources/assets
|
||||
@@ -449,8 +449,7 @@ const config: ForgeConfig = {
|
||||
makers: [
|
||||
new MakerSquirrel({
|
||||
setupIcon: 'images/icon.ico',
|
||||
iconUrl:
|
||||
'https://raw.githubusercontent.com/Draculabo/AntigravityManager/main/images/icon.ico',
|
||||
iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
|
||||
}),
|
||||
...(process.platform === 'win32' && process.arch === 'x64'
|
||||
? [
|
||||
@@ -484,8 +483,8 @@ const config: ForgeConfig = {
|
||||
name: '@electron-forge/publisher-github',
|
||||
config: {
|
||||
repository: {
|
||||
owner: 'Draculabo',
|
||||
name: 'AntigravityManager',
|
||||
owner: 'evandrodevbr',
|
||||
name: 'GeminiNexus',
|
||||
},
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
|
||||
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 864 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 390 KiB |
@@ -2,7 +2,8 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Antigravity Manager</title>
|
||||
<title>Gemini Nexus</title>
|
||||
<link rel="icon" type="image/png" href="/src/assets/icon.png" />
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src 'self';" />
|
||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||
</head>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- Affected code:
|
||||
- `src/ipc/database/cloudHandler.ts`
|
||||
- `src/ipc/database/handler.ts`
|
||||
- `src/utils/antigravityVersion.ts`
|
||||
- `src/utils/geminiNexusVersion.ts`
|
||||
- `src/utils/paths.ts` (this stage covers default runtime configuration path only)
|
||||
- `src/types/db.ts`
|
||||
|
||||
|
||||
@@ -38,5 +38,5 @@ The system SHALL use a strategy pattern to split new/legacy/dual-write injection
|
||||
The system SHALL prefer new-format injection when new-format keys are detected.
|
||||
|
||||
#### Scenario: New-format key exists
|
||||
- **WHEN** `antigravityUnifiedStateSync.oauthToken` exists
|
||||
- **WHEN** `geminiNexusUnifiedStateSync.oauthToken` exists
|
||||
- **THEN** injection uses new format with higher priority
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Add multilingual support to Antigravity Manager and automatically set the default UI language based on the user's OS language. If the OS language is unsupported, fall back to English. This includes updating i18n configuration, adding Chinese translation resources, and integrating a language detection plugin.
|
||||
Add multilingual support to Gemini Nexus and automatically set the default UI language based on the user's OS language. If the OS language is unsupported, fall back to English. This includes updating i18n configuration, adding Chinese translation resources, and integrating a language detection plugin.
|
||||
|
||||
## Motivation
|
||||
|
||||
|
||||
@@ -2,32 +2,32 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Enable Antigravity Manager running in WSL (Windows Subsystem for Linux) to manage the Antigravity application installed on the Windows host.
|
||||
Enable Gemini Nexus running in WSL (Windows Subsystem for Linux) to manage the Gemini Nexus application installed on the Windows host.
|
||||
|
||||
## Motivation
|
||||
|
||||
Users running the manager in a WSL development environment need to interact with the actual Antigravity instance running on Windows, as Antigravity is a Windows application.
|
||||
Users running the manager in a WSL development environment need to interact with the actual Gemini Nexus instance running on Windows, as Gemini Nexus is a Windows application.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Path Resolution (`src/utils/paths.ts`)
|
||||
|
||||
* Implement `isWsl()` utility function.
|
||||
* In `getAppDataDir()`: If WSL, return the Windows AppData path (e.g., `/mnt/c/Users/<User>/AppData/Roaming/Antigravity`).
|
||||
* In `getAntigravityExecutablePath()`: If WSL, return the Windows executable path (e.g., `/mnt/c/Users/<User>/AppData/Local/Programs/Antigravity/Antigravity.exe`).
|
||||
* In `getAppDataDir()`: If WSL, return the Windows AppData path (e.g., `/mnt/c/Users/<User>/AppData/Roaming/GeminiNexus`).
|
||||
* In `getGeminiNexusExecutablePath()`: If WSL, return the Windows executable path (e.g., `/mnt/c/Users/<User>/AppData/Local/Programs/GeminiNexus/GeminiNexus.exe`).
|
||||
* **Challenge:** Determining the correct Windows username.
|
||||
* **Strategy:** Assume Windows username matches WSL username, or scan `/mnt/c/Users` for a likely candidate (excluding Public, Default, etc.).
|
||||
|
||||
### 2. Process Management (`src/ipc/process/handler.ts`)
|
||||
|
||||
* In `isProcessRunning()`: If WSL, execute `tasklist.exe` (via `/mnt/c/Windows/System32/tasklist.exe`) to check for `Antigravity.exe`.
|
||||
* In `startAntigravity()`: If WSL, execute the Windows executable directly.
|
||||
* In `closeAntigravity()`: If WSL, use `taskkill.exe` (via `/mnt/c/Windows/System32/taskkill.exe`).
|
||||
* In `isProcessRunning()`: If WSL, execute `tasklist.exe` (via `/mnt/c/Windows/System32/tasklist.exe`) to check for `GeminiNexus.exe`.
|
||||
* In `startGeminiNexus()`: If WSL, execute the Windows executable directly.
|
||||
* In `closeGeminiNexus()`: If WSL, use `taskkill.exe` (via `/mnt/c/Windows/System32/taskkill.exe`).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
* \[ ] Application detects it is running in WSL.
|
||||
* \[ ] Database file is correctly located on the Windows file system.
|
||||
* \[ ] "Start" button successfully launches Antigravity on Windows.
|
||||
* \[ ] "Stop" button successfully terminates Antigravity on Windows.
|
||||
* \[ ] "Start" button successfully launches Gemini Nexus on Windows.
|
||||
* \[ ] "Stop" button successfully terminates Gemini Nexus on Windows.
|
||||
* \[ ] Process status (Running/Stopped) is correctly reflected in the UI.
|
||||
@@ -1,6 +1,6 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: New-Format Injection for 1.16.5+
|
||||
The system SHALL write only the new format `antigravityUnifiedStateSync.oauthToken` when IDE version is >= 1.16.5.
|
||||
The system SHALL write only the new format `geminiNexusUnifiedStateSync.oauthToken` when IDE version is >= 1.16.5.
|
||||
|
||||
#### Scenario: New-version injection
|
||||
- **WHEN** IDE version is 1.16.5 or above
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Antigravity Manager is a comprehensive enhancement tool for Antigravity, designed to remove AI development resource limits. It not only solves the lack of native multi-account switching in Antigravity IDE, but also introduces enterprise-grade AI account pool management. By taking control of app configuration state and cloud API resources, it allows users to switch seamlessly between unlimited local accounts and cloud AI services for an effectively "unlimited" AI usage experience.
|
||||
Gemini Nexus is a comprehensive enhancement tool for Gemini Nexus, designed to remove AI development resource limits. It not only solves the lack of native multi-account switching in Gemini Nexus IDE, but also introduces enterprise-grade AI account pool management. By taking control of app configuration state and cloud API resources, it allows users to switch seamlessly between unlimited local accounts and cloud AI services for an effectively "unlimited" AI usage experience.
|
||||
|
||||
**Core goals:**
|
||||
|
||||
* **Local multi-account switching**: Seamlessly switch Antigravity IDE local user state while protecting data integrity (automatic backup).
|
||||
* **Local multi-account switching**: Seamlessly switch Gemini Nexus IDE local user state while protecting data integrity (automatic backup).
|
||||
* **AI resource pool management (new)**: Centrally manage account pools for Google Gemini, Claude 3.5, and similar services.
|
||||
* **Intelligent quota monitoring (new)**: Monitor API quota and health in real time with a visual dashboard.
|
||||
* **Automatic resource scheduling (new)**: Automatically switch to the best available account when the current one runs out of quota (token keepalive + polling).
|
||||
@@ -88,17 +88,17 @@ src/
|
||||
|
||||
## Domain Context
|
||||
|
||||
### 1. Antigravity IDE Data Structure (Local)
|
||||
### 1. Gemini Nexus IDE Data Structure (Local)
|
||||
|
||||
**Database locations:**
|
||||
|
||||
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
|
||||
* Windows: `%APPDATA%/Antigravity/User/globalStorage/state.vscdb`
|
||||
* Linux: `~/.config/Antigravity/User/globalStorage/state.vscdb`
|
||||
* macOS: `~/Library/Application Support/GeminiNexus/User/globalStorage/state.vscdb`
|
||||
* Windows: `%APPDATA%/GeminiNexus/User/globalStorage/state.vscdb`
|
||||
* Linux: `~/.config/GeminiNexus/User/globalStorage/state.vscdb`
|
||||
|
||||
**Key database fields:**
|
||||
|
||||
* `antigravityAuthStatus`: Authentication status and user information.
|
||||
* `geminiNexusAuthStatus`: Authentication status and user information.
|
||||
|
||||
### 2. AI Service Resource Structure (Cloud / New)
|
||||
|
||||
@@ -141,7 +141,7 @@ src/
|
||||
|
||||
### System Dependencies
|
||||
|
||||
* **Antigravity IDE**: Local target to manage.
|
||||
* **Gemini Nexus IDE**: Local target to manage.
|
||||
* **SQLite**: Local data reads.
|
||||
|
||||
### Node.js Dependencies (Key)
|
||||
@@ -159,7 +159,7 @@ src/
|
||||
|
||||
### Filesystem Dependencies
|
||||
|
||||
* `~/.antigravity-agent/`
|
||||
* `~/.geminiNexus-agent/`
|
||||
* `accounts.json`: Local IDE account index.
|
||||
* `cloud_accounts.db`: (new) Cloud account pool database (SQLite).
|
||||
* `backups/`: Backup files.
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"productName": "Antigravity Manager",
|
||||
"version": "0.11.0",
|
||||
"description": "Antigravity Manager - Electron App",
|
||||
"name": "gemini-nexus",
|
||||
"productName": "Gemini Nexus",
|
||||
"version": "0.11.1",
|
||||
"description": "Gemini Nexus - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"start": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"make:win": "node scripts/build-windows.js",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check .",
|
||||
@@ -20,21 +22,21 @@
|
||||
"test:all": "vitest run && playwright test",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"author": "Draculabo",
|
||||
"author": "evandrodevbr",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.10.2",
|
||||
"@electron-forge/maker-deb": "^7.10.2",
|
||||
"@electron-forge/maker-dmg": "^7.10.2",
|
||||
"@electron-forge/maker-rpm": "^7.10.2",
|
||||
"@electron-forge/maker-squirrel": "^7.10.2",
|
||||
"@electron-forge/maker-wix": "^7.10.2",
|
||||
"@electron-forge/maker-zip": "^7.10.2",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
|
||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
||||
"@electron-forge/plugin-vite": "^7.10.2",
|
||||
"@electron-forge/publisher-github": "^7.10.2",
|
||||
"@electron-forge/shared-types": "^7.8.1",
|
||||
"@electron-forge/cli": "^7.11.1",
|
||||
"@electron-forge/maker-deb": "^7.11.1",
|
||||
"@electron-forge/maker-dmg": "^7.11.1",
|
||||
"@electron-forge/maker-rpm": "^7.11.1",
|
||||
"@electron-forge/maker-squirrel": "^7.11.1",
|
||||
"@electron-forge/maker-wix": "^7.11.1",
|
||||
"@electron-forge/maker-zip": "^7.11.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
|
||||
"@electron-forge/plugin-fuses": "^7.11.1",
|
||||
"@electron-forge/plugin-vite": "^7.11.1",
|
||||
"@electron-forge/publisher-github": "^7.11.1",
|
||||
"@electron-forge/shared-types": "^7.11.1",
|
||||
"@electron/fuses": "~1.8.0",
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"@eslint/compat": "^1.4.1",
|
||||
@@ -43,10 +45,10 @@
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^12.0.2",
|
||||
"@semantic-release/github": "^12.0.6",
|
||||
"@sentry/vite-plugin": "^4.8.0",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/router-plugin": "^1.135.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -62,7 +64,8 @@
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"electron": "^37.3.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.8.5",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-packager-languages": "^0.6.0",
|
||||
"electron-playwright-helpers": "^1.8.2",
|
||||
@@ -73,12 +76,13 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^16.5.0",
|
||||
"happy-dom": "^20.8.9",
|
||||
"icon-gen": "^5.0.0",
|
||||
"jsdom": "^27.1.0",
|
||||
"node-addon-api": "^8.3.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"semantic-release": "^25.0.2",
|
||||
"semantic-release": "^25.0.3",
|
||||
"semantic-release-config-gitmoji": "^1.5.3",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.1.16",
|
||||
@@ -86,20 +90,24 @@
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vite": "^5.4.21",
|
||||
"vite": "^6.4.2",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||
"@nestjs/common": "^11.1.10",
|
||||
"@nestjs/core": "^11.1.10",
|
||||
"@nestjs/microservices": "^11.1.11",
|
||||
"@nestjs/platform-express": "^11.1.11",
|
||||
"@nestjs/platform-fastify": "^11.1.10",
|
||||
"@orpc/client": "^1.10.4",
|
||||
"@orpc/server": "^1.11.3",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/microservices": "^11.1.18",
|
||||
"@nestjs/platform-express": "^11.1.18",
|
||||
"@nestjs/platform-fastify": "^11.1.18",
|
||||
"@nivo/bar": "^0.99.0",
|
||||
"@nivo/core": "^0.99.0",
|
||||
"@nivo/pie": "^0.99.0",
|
||||
"@orpc/client": "^1.13.14",
|
||||
"@orpc/server": "^1.13.14",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -120,23 +128,22 @@
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-router": "^1.139.3",
|
||||
"@tanstack/react-router-devtools": "^1.135.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.15.0",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.39.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify": "^5.8.5",
|
||||
"find-process": "^2.0.0",
|
||||
"happy-dom": "^20.0.10",
|
||||
"gpt-tokenizer": "^3.4.0",
|
||||
"i18next": "^25.6.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"keytar": "^7.9.0",
|
||||
"lodash-es": "^4.17.22",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -148,7 +155,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"undici": "^7.16.0",
|
||||
"update-electron-app": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
@@ -160,5 +167,17 @@
|
||||
"@sentry/cli",
|
||||
"@swc/core",
|
||||
"protobufjs"
|
||||
]
|
||||
],
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"fastify": "^5.8.5",
|
||||
"tar": "^7.5.13",
|
||||
"@octokit/request-error": "^5.1.1",
|
||||
"@octokit/plugin-paginate-rest": "^9.2.2",
|
||||
"@octokit/request": "^8.4.1",
|
||||
"tmp": "^0.2.4",
|
||||
"@tootallnate/once": "^3.0.1",
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
allowBuilds:
|
||||
'@bitdisaster/exe-icon-extractor': true
|
||||
'@nestjs/core': true
|
||||
'@sentry/cli': true
|
||||
'@swc/core': true
|
||||
better-sqlite3: true
|
||||
electron: true
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
keytar: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
@@ -0,0 +1,117 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Extensões permitidas
|
||||
const allowedExtensions = /\.(ts|tsx|js|jsx|json|md|html|jsonc|yml|yaml|css)$/;
|
||||
|
||||
// Arquivos/Pastas a ignorar (já curados ou irrelevantes)
|
||||
const ignoreDirs = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.gemini',
|
||||
'dist',
|
||||
'out',
|
||||
'build',
|
||||
'images',
|
||||
'docs',
|
||||
'.planning',
|
||||
];
|
||||
const ignoreFiles = [
|
||||
'README.md',
|
||||
'README.pt-BR.md',
|
||||
'CHANGELOG.md',
|
||||
'forge.config.ts',
|
||||
'package.json',
|
||||
'rename.js',
|
||||
'rename-deep.js',
|
||||
'task.md',
|
||||
'walkthrough.md',
|
||||
];
|
||||
|
||||
function walkAndReplace(dir) {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
for (const file of files) {
|
||||
if (ignoreDirs.includes(file)) continue;
|
||||
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
walkAndReplace(fullPath);
|
||||
|
||||
// Rename directory if it contains 'antigravity'
|
||||
if (file.toLowerCase().includes('antigravity')) {
|
||||
const newName = file.replace(/antigravity/gi, (match) => {
|
||||
if (match === 'antigravity') return 'geminiNexus';
|
||||
if (match === 'Antigravity') return 'GeminiNexus';
|
||||
return 'geminiNexus';
|
||||
});
|
||||
const newPath = path.join(dir, newName);
|
||||
fs.renameSync(fullPath, newPath);
|
||||
console.log(`Renamed directory: ${fullPath} -> ${newPath}`);
|
||||
}
|
||||
} else {
|
||||
if (ignoreFiles.includes(file)) continue;
|
||||
|
||||
if (allowedExtensions.test(file)) {
|
||||
let content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const originalContent = content;
|
||||
|
||||
// Save original GitHub links
|
||||
const repoUrlRegex = /github\.com\/Draculabo\/AntigravityManager/gi;
|
||||
const tempUrlMarker = '___TEMP_GITHUB_URL___';
|
||||
content = content.replace(repoUrlRegex, tempUrlMarker);
|
||||
|
||||
// 1. Strings exact match "Antigravity" -> "Gemini Nexus"
|
||||
content = content.replace(/['"]Antigravity['"]/g, (match) => {
|
||||
return match[0] + 'Gemini Nexus' + match[0];
|
||||
});
|
||||
|
||||
// 2. String "Antigravity " -> "Gemini Nexus "
|
||||
content = content.replace(/Antigravity /g, 'Gemini Nexus ');
|
||||
|
||||
// 3. String " Antigravity" -> " Gemini Nexus"
|
||||
content = content.replace(/ Antigravity/g, ' Gemini Nexus');
|
||||
|
||||
// 4. Identifiers Antigravity -> GeminiNexus
|
||||
content = content.replace(/Antigravity/g, 'GeminiNexus');
|
||||
|
||||
// 5. Identifiers antigravity -> geminiNexus
|
||||
content = content.replace(/antigravity/g, 'geminiNexus');
|
||||
|
||||
// Restore GitHub links
|
||||
content = content.replace(
|
||||
new RegExp(tempUrlMarker, 'g'),
|
||||
'github.com/Draculabo/AntigravityManager',
|
||||
);
|
||||
|
||||
// Cleanup any double replacements
|
||||
content = content.replace(/GeminiNexus Nexus/g, 'Gemini Nexus');
|
||||
|
||||
if (content !== originalContent) {
|
||||
fs.writeFileSync(fullPath, content, 'utf-8');
|
||||
console.log(`Updated file content: ${fullPath}`);
|
||||
}
|
||||
|
||||
// Rename file if it contains 'antigravity'
|
||||
if (file.toLowerCase().includes('antigravity')) {
|
||||
const newName = file.replace(/antigravity/gi, (match) => {
|
||||
if (match === 'antigravity') return 'geminiNexus'; // For files like antigravityVersion.ts -> geminiNexusVersion.ts
|
||||
if (match === 'Antigravity') return 'GeminiNexus';
|
||||
return 'geminiNexus';
|
||||
});
|
||||
const newPath = path.join(dir, newName);
|
||||
fs.renameSync(fullPath, newPath);
|
||||
console.log(`Renamed file: ${fullPath} -> ${newPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Starting global deep rename of Antigravity to GeminiNexus...');
|
||||
walkAndReplace(projectRoot);
|
||||
console.log('Rename complete.');
|
||||
@@ -0,0 +1,50 @@
|
||||
const { execSync, spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const WIX_ZIP = path.join(process.cwd(), 'wix314-binaries.zip');
|
||||
const WIX_DIR = path.join(process.cwd(), 'tools', 'wix314');
|
||||
|
||||
function extractWix() {
|
||||
if (!fs.existsSync(WIX_ZIP)) {
|
||||
console.error(`WiX zip not found: ${WIX_ZIP}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (fs.existsSync(WIX_DIR)) {
|
||||
console.log(`WiX already extracted at ${WIX_DIR}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Extracting WiX binaries to ${WIX_DIR}...`);
|
||||
fs.mkdirSync(WIX_DIR, { recursive: true });
|
||||
|
||||
// Use PowerShell Expand-Archive (built into Windows)
|
||||
const psCommand = `Expand-Archive -Path "${WIX_ZIP}" -DestinationPath "${WIX_DIR}" -Force`;
|
||||
execSync(psCommand, { shell: 'powershell.exe', stdio: 'inherit' });
|
||||
|
||||
console.log('WiX extraction complete.');
|
||||
}
|
||||
|
||||
function setupWixPath() {
|
||||
// electron-wix-msi's detect-wix.js runs execSync('candle -?') which relies on PATH
|
||||
process.env.PATH = `${WIX_DIR};${process.env.PATH}`;
|
||||
console.log(`Added ${WIX_DIR} to PATH`);
|
||||
}
|
||||
|
||||
function runMake() {
|
||||
console.log('Running electron-forge make...');
|
||||
const make = spawn('npx', ['electron-forge', 'make'], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
make.on('exit', (code) => {
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
extractWix();
|
||||
setupWixPath();
|
||||
runMake();
|
||||
@@ -4,10 +4,10 @@ export function isProcessRunning() {
|
||||
return ipc.client.proc.isProcessRunning();
|
||||
}
|
||||
|
||||
export function closeAntigravity() {
|
||||
return ipc.client.proc.closeAntigravity();
|
||||
export function closeGeminiNexus() {
|
||||
return ipc.client.proc.closeGeminiNexus();
|
||||
}
|
||||
|
||||
export function startAntigravity() {
|
||||
return ipc.client.proc.startAntigravity();
|
||||
export function startGeminiNexus() {
|
||||
return ipc.client.proc.startGeminiNexus();
|
||||
}
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 659 B After Width: | Height: | Size: 2.1 KiB |
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { formatDistanceToNow, type Locale } from 'date-fns';
|
||||
import { enUS, zhCN, ru } from 'date-fns/locale';
|
||||
import { enUS, zhCN, ru, vi } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -22,6 +22,7 @@ const DATE_LOCALE_MAP: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
'zh-CN': zhCN,
|
||||
ru: ru,
|
||||
vi: vi,
|
||||
};
|
||||
|
||||
interface AccountCardProps {
|
||||
@@ -162,7 +163,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('common.openMenu')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCloudAccounts, useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { isValidProxyUrl } from '@/utils/url';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
function AccountProxyItem({ account }: { account: CloudAccount }) {
|
||||
const { t } = useTranslation();
|
||||
const setAccountProxy = useSetAccountProxy();
|
||||
const [proxyUrl, setProxyUrl] = useState(account.proxy_url || '');
|
||||
const [proxySaved, setProxySaved] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-b py-3 last:border-0">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
{account.avatar_url ? (
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
alt={account.name || ''}
|
||||
className="bg-muted h-8 w-8 shrink-0 rounded-full border"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary/10 text-primary flex h-8 w-8 shrink-0 items-center justify-center rounded-full border">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">{account.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={proxyUrl}
|
||||
onChange={(e) => {
|
||||
setProxyUrl(e.target.value);
|
||||
setProxySaved(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const trimmed = proxyUrl.trim();
|
||||
if (trimmed && !isValidProxyUrl(trimmed)) {
|
||||
setProxyUrl(account.proxy_url || '');
|
||||
return;
|
||||
}
|
||||
if (trimmed !== (account.proxy_url || '')) {
|
||||
setAccountProxy.mutate({
|
||||
accountId: account.id,
|
||||
proxyUrl: trimmed || null,
|
||||
});
|
||||
setProxySaved(true);
|
||||
setTimeout(() => setProxySaved(false), 2000);
|
||||
}
|
||||
}}
|
||||
placeholder={t('cloud.card.proxyPlaceholder') || 'e.g. http://127.0.0.1:7890'}
|
||||
className="h-8 w-48 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="w-16">
|
||||
{proxySaved && (
|
||||
<span className="text-[10px] whitespace-nowrap text-green-500">
|
||||
{t('cloud.card.proxySaved')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountProxySettings() {
|
||||
const { t } = useTranslation();
|
||||
const { data: accounts, isLoading } = useCloudAccounts();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.proxy.accountProxies.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.proxy.accountProxies.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center p-6">
|
||||
<Loader2 className="text-muted-foreground animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.proxy.accountProxies.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.proxy.accountProxies.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col">
|
||||
{accounts.map((account) => (
|
||||
<AccountProxyItem key={account.id} account={account} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -29,14 +29,12 @@ import { useProviderGrouping } from '@/hooks/useProviderGrouping';
|
||||
import { ProviderGroup } from '@/components/ProviderGroup';
|
||||
import {
|
||||
clampQuotaPercentage,
|
||||
formatAiCreditsAmount,
|
||||
formatResetTimeLabel,
|
||||
formatResetTimeTitle,
|
||||
getQuotaStatus,
|
||||
type QuotaStatus,
|
||||
} from '@/utils/quota-display';
|
||||
import { useState } from 'react';
|
||||
import { useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||
import { isValidProxyUrl } from '@/utils/url';
|
||||
import { getValidationBlockedStatusLabel } from '@/components/accountValidationStatus';
|
||||
|
||||
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
||||
@@ -82,6 +80,19 @@ function isGeminiProHighModel(modelName: string): boolean {
|
||||
return normalizedModelName.includes('gemini-3.1-pro-high');
|
||||
}
|
||||
|
||||
function formatCreditsExpiry(expiryDate: string): string {
|
||||
if (!expiryDate) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const date = new Date(expiryDate);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return expiryDate;
|
||||
}
|
||||
}
|
||||
|
||||
function isGeminiProFamilyModel(modelName: string): boolean {
|
||||
const normalizedModelName = modelName.toLowerCase();
|
||||
return normalizedModelName.includes('gemini-3.1-pro');
|
||||
@@ -172,9 +183,6 @@ export function CloudAccountCard({
|
||||
isProviderCollapsed,
|
||||
toggleProviderCollapse,
|
||||
} = useProviderGrouping();
|
||||
const setAccountProxy = useSetAccountProxy();
|
||||
const [proxyUrl, setProxyUrl] = useState(account.proxy_url || '');
|
||||
const [proxySaved, setProxySaved] = useState(false);
|
||||
|
||||
const getQuotaTextColorClass = (percentage: number) => {
|
||||
const quotaStatus = getQuotaStatus(percentage);
|
||||
@@ -325,17 +333,8 @@ export function CloudAccountCard({
|
||||
);
|
||||
|
||||
const aiCredits = account.quota?.ai_credits;
|
||||
const shouldShowAiCredits = !!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
|
||||
|
||||
const formatCreditsExpiry = (expiryDate: string) => {
|
||||
if (!expiryDate) return '';
|
||||
try {
|
||||
const date = new Date(expiryDate);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return expiryDate;
|
||||
}
|
||||
};
|
||||
const shouldShowAiCredits =
|
||||
!!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
|
||||
|
||||
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||
account.status,
|
||||
@@ -378,8 +377,12 @@ export function CloudAccountCard({
|
||||
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
||||
|
||||
{shouldShowAiCredits && aiCredits && (
|
||||
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-amber-500">
|
||||
<span>${aiCredits.credits.toFixed(2)}</span>
|
||||
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-blue-500">
|
||||
<span>
|
||||
{t('cloud.card.aiCreditsValue', {
|
||||
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||
})}
|
||||
</span>
|
||||
{aiCredits.expiryDate && (
|
||||
<span className="text-muted-foreground opacity-70">
|
||||
·{' '}
|
||||
@@ -549,45 +552,11 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="bg-muted/20 text-muted-foreground justify-between border-t p-2 px-4 text-xs">
|
||||
<CardFooter className="bg-muted/20 text-muted-foreground justify-center border-t p-2 px-4 text-xs">
|
||||
<span>
|
||||
{t('cloud.card.used')}{' '}
|
||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={proxyUrl}
|
||||
onChange={(e) => {
|
||||
setProxyUrl(e.target.value);
|
||||
setProxySaved(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const trimmed = proxyUrl.trim();
|
||||
if (trimmed && !isValidProxyUrl(trimmed)) {
|
||||
setProxyUrl(account.proxy_url || '');
|
||||
return;
|
||||
}
|
||||
if (trimmed !== (account.proxy_url || '')) {
|
||||
setAccountProxy.mutate({
|
||||
accountId: account.id,
|
||||
proxyUrl: trimmed || null,
|
||||
});
|
||||
setProxySaved(true);
|
||||
setTimeout(() => setProxySaved(false), 2000);
|
||||
}
|
||||
}}
|
||||
placeholder={t('cloud.card.proxyPlaceholder')}
|
||||
className="h-6 w-40 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{proxySaved && (
|
||||
<span className="text-[10px] text-green-500">{t('cloud.card.proxySaved')}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
@@ -635,17 +604,8 @@ export function CompactCloudAccountCard({
|
||||
);
|
||||
|
||||
const aiCredits = account.quota?.ai_credits;
|
||||
const shouldShowAiCredits = !!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
|
||||
|
||||
const formatCreditsExpiry = (expiryDate: string) => {
|
||||
if (!expiryDate) return '';
|
||||
try {
|
||||
const date = new Date(expiryDate);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return expiryDate;
|
||||
}
|
||||
};
|
||||
const shouldShowAiCredits =
|
||||
!!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
|
||||
|
||||
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||
account.status,
|
||||
@@ -695,12 +655,16 @@ export function CompactCloudAccountCard({
|
||||
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||
<span className="truncate">{account.email}</span>
|
||||
{validationBlockedStatusLabel && (
|
||||
<span className="text-destructive shrink-0 font-medium">{validationBlockedStatusLabel}</span>
|
||||
<span className="text-destructive shrink-0 font-medium">
|
||||
{validationBlockedStatusLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{shouldShowAiCredits && aiCredits && (
|
||||
<span className="shrink-0 text-amber-500">
|
||||
${aiCredits.credits.toFixed(2)}
|
||||
<span className="shrink-0 text-blue-500">
|
||||
{t('cloud.card.aiCreditsValue', {
|
||||
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||
})}
|
||||
{aiCredits.expiryDate && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
|
||||
@@ -71,9 +71,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es';
|
||||
import { filter, flatMap, isEmpty, isNumber, size, sumBy } from 'lodash-es';
|
||||
import {
|
||||
clampQuotaPercentage,
|
||||
formatAiCreditsAmount,
|
||||
getQuotaStatus,
|
||||
roundQuotaPercentage,
|
||||
getAccountSortValue,
|
||||
@@ -313,17 +314,19 @@ export function CloudAccountList() {
|
||||
{
|
||||
onSuccess: (updatedAccount) => {
|
||||
const credits = updatedAccount.quota?.ai_credits?.credits;
|
||||
if (typeof credits === 'number') {
|
||||
if (isNumber(credits)) {
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: `AI credits: $${credits.toFixed(2)}`,
|
||||
description: t('cloud.toast.refreshCreditsAvailable', {
|
||||
amount: formatAiCreditsAmount(credits),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: 'AI credits not available for this refresh.',
|
||||
description: t('cloud.toast.refreshCreditsUnavailable'),
|
||||
});
|
||||
},
|
||||
onError: () => toast({ title: t('cloud.toast.refreshFailed'), variant: 'destructive' }),
|
||||
@@ -483,7 +486,7 @@ export function CloudAccountList() {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: 'File size exceeds 5MB limit',
|
||||
description: t('cloud.exportImport.fileTooLarge'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
@@ -496,10 +499,10 @@ export function CloudAccountList() {
|
||||
const content = event.target?.result as string;
|
||||
JSON.parse(content);
|
||||
setImportFileContent(content);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: 'Invalid JSON file format',
|
||||
description: t('cloud.exportImport.invalidJson'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
setImportFileName('');
|
||||
@@ -509,7 +512,7 @@ export function CloudAccountList() {
|
||||
reader.onerror = () => {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: 'Failed to read file',
|
||||
description: t('cloud.exportImport.readFileFailed'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
@@ -588,12 +591,15 @@ export function CloudAccountList() {
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: `Successfully refreshed ${successful} accounts.`,
|
||||
description: t('cloud.toast.batchRefreshSuccess', { count: successful }),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Refresh completed with issues`,
|
||||
description: `Refreshed ${successful} accounts, ${failed} failed.`,
|
||||
title: t('cloud.toast.batchRefreshPartial.title'),
|
||||
description: t('cloud.toast.batchRefreshPartial.description', {
|
||||
successful,
|
||||
failed,
|
||||
}),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -614,12 +620,15 @@ export function CloudAccountList() {
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
title: t('cloud.toast.deleted'),
|
||||
description: `Successfully deleted ${successful} accounts.`,
|
||||
description: t('cloud.toast.batchDeleteSuccess', { count: successful }),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Deletion completed with issues`,
|
||||
description: `Deleted ${successful} accounts, ${failed} failed.`,
|
||||
title: t('cloud.toast.batchDeletePartial.title'),
|
||||
description: t('cloud.toast.batchDeletePartial.description', {
|
||||
successful,
|
||||
failed,
|
||||
}),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type JSX, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Fingerprint,
|
||||
@@ -48,11 +48,14 @@ function formatError(error: unknown): string {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function renderProfile(profile?: DeviceProfile): JSX.Element {
|
||||
function renderProfile(
|
||||
profile: DeviceProfile | undefined,
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
): JSX.Element {
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
|
||||
N/A
|
||||
{t('common.notAvailable')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,41 +98,44 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
const history = useMemo(() => sortHistory(snapshot?.history || []), [snapshot?.history]);
|
||||
const showLoadingPlaceholder = initialLoading && !snapshot;
|
||||
|
||||
const refreshProfiles = async (options?: { silent?: boolean }) => {
|
||||
if (!account) {
|
||||
setSnapshot(null);
|
||||
return;
|
||||
}
|
||||
const silent = options?.silent === true;
|
||||
if (silent) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setInitialLoading(true);
|
||||
}
|
||||
try {
|
||||
const result = await getCloudIdentityProfiles({ accountId: account.id });
|
||||
setSnapshot(result);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }),
|
||||
description: formatError(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
if (silent) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setInitialLoading(false);
|
||||
const refreshProfiles = useCallback(
|
||||
async (options?: { silent?: boolean }) => {
|
||||
if (!account) {
|
||||
setSnapshot(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
const silent = options?.silent === true;
|
||||
if (silent) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setInitialLoading(true);
|
||||
}
|
||||
try {
|
||||
const result = await getCloudIdentityProfiles({ accountId: account.id });
|
||||
setSnapshot(result);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('cloud.toast.actionFailed'),
|
||||
description: formatError(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
if (silent) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[account, t, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
refreshProfiles();
|
||||
}, [open, account?.id]);
|
||||
void refreshProfiles();
|
||||
}, [open, refreshProfiles]);
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||
if (actionLockRef.current) {
|
||||
@@ -142,7 +148,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
await refreshProfiles({ silent: true });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }),
|
||||
title: t('cloud.toast.actionFailed'),
|
||||
description: formatError(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -247,7 +253,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
<CardTitle className="text-sm">{t('cloud.identity.previewTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{renderProfile(previewProfile)}
|
||||
{renderProfile(previewProfile, t)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -295,7 +301,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
{t('cloud.identity.loading')}
|
||||
</div>
|
||||
) : (
|
||||
renderProfile(snapshot?.currentStorage)
|
||||
renderProfile(snapshot?.currentStorage, t)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -313,7 +319,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
{t('cloud.identity.loading')}
|
||||
</div>
|
||||
) : (
|
||||
renderProfile(snapshot?.boundProfile)
|
||||
renderProfile(snapshot?.boundProfile, t)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -353,7 +359,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{renderProfile(version.profile)}
|
||||
{renderProfile(version.profile, t)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -402,15 +408,13 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">
|
||||
{t('cloud.identity.baseline', { defaultValue: 'Reference Profile' })}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-sm">{t('cloud.identity.baseline')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showLoadingPlaceholder ? (
|
||||
<div className="text-muted-foreground text-xs">{t('cloud.identity.loading')}</div>
|
||||
) : (
|
||||
renderProfile(snapshot?.baseline)
|
||||
renderProfile(snapshot?.baseline, t)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Search, RotateCcw, Save } from 'lucide-react';
|
||||
import { Loader2, Search, RotateCcw, Save, Copy, CheckCircle } from 'lucide-react';
|
||||
import { filter, flatMap, includes, size, sortBy, sumBy, uniq, values } from 'lodash-es';
|
||||
import type { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
@@ -44,6 +44,7 @@ export function ModelVisibilitySettings() {
|
||||
const [modelVisibilityMap, setModelVisibilityMap] = useState<Record<string, boolean>>({});
|
||||
const [providerGroupingEnabled, setProviderGroupingEnabled] = useState(false);
|
||||
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||
const [copiedModel, setCopiedModel] = useState<string | null>(null);
|
||||
|
||||
// Initialize model visibility and provider groupings from config
|
||||
useEffect(() => {
|
||||
@@ -114,6 +115,14 @@ export function ModelVisibilitySettings() {
|
||||
}));
|
||||
};
|
||||
|
||||
const copyModelId = (modelId: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(modelId);
|
||||
setCopiedModel(modelId);
|
||||
setTimeout(() => setCopiedModel(null), 2000);
|
||||
};
|
||||
|
||||
if (accountsLoading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -130,7 +139,9 @@ export function ModelVisibilitySettings() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>{t('settings.modelVisibility.title')}</span>
|
||||
<Badge variant="secondary">{filteredModelIds.length} models</Badge>
|
||||
<Badge variant="secondary">
|
||||
{t('settings.providerGroupings.models', { count: filteredModelIds.length })}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>{t('settings.modelVisibility.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -186,12 +197,25 @@ export function ModelVisibilitySettings() {
|
||||
handleModelVisibilityChange(modelId, checked as boolean)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`model-${modelId}`}
|
||||
className="flex-1 cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{modelId}
|
||||
</label>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<label
|
||||
htmlFor={`model-${modelId}`}
|
||||
className="cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{modelId}
|
||||
</label>
|
||||
<button
|
||||
onClick={(e) => copyModelId(modelId, e)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('proxy.copy')}
|
||||
>
|
||||
{copiedModel === modelId ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!isVisible && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t('settings.modelVisibility.hidden')}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isProcessRunning, startAntigravity, closeAntigravity } from '@/actions/process';
|
||||
import { isProcessRunning, startGeminiNexus, closeGeminiNexus } from '@/actions/process';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Play, Square, Loader2, Power } from 'lucide-react';
|
||||
@@ -18,18 +18,18 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
||||
const { data: isRunning, isLoading } = useQuery({
|
||||
queryKey: ['process', 'status'],
|
||||
queryFn: isProcessRunning,
|
||||
refetchInterval: 10000,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: startAntigravity,
|
||||
mutationFn: startGeminiNexus,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: closeAntigravity,
|
||||
mutationFn: closeGeminiNexus,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['process', 'status'] });
|
||||
},
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { MonitorTab } from './advanced/MonitorTab';
|
||||
import { ToolsTab } from './advanced/ToolsTab';
|
||||
import { ReplayTab } from './advanced/ReplayTab';
|
||||
import { CapabilitiesTab } from './advanced/CapabilitiesTab';
|
||||
import type { ProxyConfig } from '@/types/config';
|
||||
|
||||
interface ProxyAdvancedTabProps {
|
||||
proxyConfig: ProxyConfig;
|
||||
}
|
||||
|
||||
export const ProxyAdvancedTab: React.FC<ProxyAdvancedTabProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="monitor" className="space-y-5">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="monitor">{t('proxy.advanced.monitor')}</TabsTrigger>
|
||||
<TabsTrigger value="tools">{t('proxy.advanced.tabs.tools')}</TabsTrigger>
|
||||
<TabsTrigger value="replay">{t('proxy.advanced.tabs.replay')}</TabsTrigger>
|
||||
<TabsTrigger value="capabilities">{t('proxy.advanced.tabs.capabilities')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="monitor">
|
||||
<MonitorTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="tools">
|
||||
<ToolsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="replay">
|
||||
<ReplayTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="capabilities">
|
||||
<CapabilitiesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Eye, Zap, Code, Mic, Image } from 'lucide-react';
|
||||
|
||||
interface ModelCapability {
|
||||
id: string;
|
||||
object: string;
|
||||
capabilities: {
|
||||
vision: boolean;
|
||||
streaming: boolean;
|
||||
jsonMode: boolean;
|
||||
audio: boolean;
|
||||
imageGeneration: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const CAPABILITY_ICONS = {
|
||||
vision: Eye,
|
||||
streaming: Zap,
|
||||
jsonMode: Code,
|
||||
audio: Mic,
|
||||
imageGeneration: Image,
|
||||
} as const;
|
||||
|
||||
const CAPABILITY_LABELS = {
|
||||
vision: 'Vision',
|
||||
streaming: 'Streaming',
|
||||
jsonMode: 'JSON Mode',
|
||||
audio: 'Audio',
|
||||
imageGeneration: 'Image Generation',
|
||||
} as const;
|
||||
|
||||
export const CapabilitiesTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: capabilitiesData, isLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'modelCapabilities'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getModelCapabilities(),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const models: ModelCapability[] = capabilitiesData?.success
|
||||
? (capabilitiesData.data as ModelCapability[])
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{t('proxy.advanced.capabilities.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('proxy.advanced.capabilities.description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Capabilities Grid */}
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{models.map((model) => (
|
||||
<Card key={model.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{model.id}</CardTitle>
|
||||
<Badge variant="outline">{model.object}</Badge>
|
||||
</div>
|
||||
<CardDescription>Model capabilities and features</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(model.capabilities).map(([key, supported]) => {
|
||||
const iconKey = key as keyof typeof CAPABILITY_ICONS;
|
||||
const Icon = CAPABILITY_ICONS[iconKey];
|
||||
const label = CAPABILITY_LABELS[iconKey];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-center gap-2 rounded-md border p-2 ${
|
||||
supported
|
||||
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 ${
|
||||
supported ? 'text-green-600 dark:text-green-400' : 'text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
supported
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{supported && (
|
||||
<Badge variant="default" className="ml-auto h-5 bg-green-600 text-[10px]">
|
||||
✓
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { TrafficLogTable } from './TrafficLogTable';
|
||||
import { Loader2, Zap, Clock, AlertTriangle, Network, HardDrive } from 'lucide-react';
|
||||
|
||||
export const MonitorTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [direction, setDirection] = useState<string>('all');
|
||||
|
||||
const { data: metricsData, isLoading: metricsLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'metrics'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: cbData, isLoading: cbLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'circuitBreaker'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getCircuitBreakerStatus(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'logs', direction],
|
||||
queryFn: () =>
|
||||
ipc.client.proxyAdvanced.getRecentTrafficLogs({
|
||||
limit: 50,
|
||||
direction: direction === 'all' ? undefined : direction,
|
||||
}),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: statsData } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'stats'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getTrafficLogStats(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const metrics = metricsData?.success ? metricsData.data : undefined;
|
||||
const circuitBreakers = cbData?.success ? cbData.data : undefined;
|
||||
const logs = logsData?.success ? logsData.data : [];
|
||||
const stats = statsData?.success ? statsData.data : undefined;
|
||||
|
||||
const handleExport = async (format: 'json' | 'csv') => {
|
||||
const result = await ipc.client.proxyAdvanced.exportTrafficLogs({ format });
|
||||
if (result.success && result.data) {
|
||||
navigator.clipboard.writeText(result.data);
|
||||
toast({ title: t('proxy.advanced.tools.copyConfig') });
|
||||
} else {
|
||||
toast({ title: t('common.error'), description: result.error });
|
||||
}
|
||||
};
|
||||
|
||||
const getCircuitBadgeVariant = (state: string): React.ComponentProps<typeof Badge>['variant'] => {
|
||||
if (state === 'closed') return 'default';
|
||||
if (state === 'open') return 'destructive';
|
||||
return 'secondary';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.requestsPerMinute')}
|
||||
value={metrics ? Math.round(metrics.requestsPerMinute) : 0}
|
||||
icon={Zap}
|
||||
isLoading={metricsLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.avgLatency')}
|
||||
value={metrics ? Math.round(metrics.avgLatency) : 0}
|
||||
icon={Clock}
|
||||
isLoading={metricsLoading}
|
||||
accent="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.errorRate')}
|
||||
value={metrics ? Math.round(metrics.errorRate * 100) : 0}
|
||||
icon={AlertTriangle}
|
||||
isLoading={metricsLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.activeConnections')}
|
||||
value={metrics?.activeConnections ?? 0}
|
||||
icon={Network}
|
||||
isLoading={metricsLoading}
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.cacheStatus')}
|
||||
value={metrics?.cacheHitRate ? `${Math.round(metrics.cacheHitRate * 100)}%` : 'N/A'}
|
||||
icon={HardDrive}
|
||||
isLoading={metricsLoading}
|
||||
accent="purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Circuit Breakers */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
{t('proxy.advanced.circuitBreaker.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cbLoading ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !circuitBreakers ? (
|
||||
<p className="text-sm text-muted-foreground">No data</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Object.entries(circuitBreakers.states).map(([api, state]) => (
|
||||
<Card key={api} className="bg-muted/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{api}</span>
|
||||
<Badge variant={getCircuitBadgeVariant(state.state)}>
|
||||
{state.state}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
|
||||
</div>
|
||||
{state.lastFailure && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
|
||||
{new Date(state.lastFailure).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Traffic Logs */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('proxy.advanced.trafficLog.title')}
|
||||
</h3>
|
||||
{stats && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Total: {stats.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={direction} onValueChange={setDirection}>
|
||||
<SelectTrigger className="h-8 w-[140px] text-xs">
|
||||
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('proxy.advanced.trafficLog.all')}
|
||||
</SelectItem>
|
||||
<SelectItem value="inbound">
|
||||
{t('proxy.advanced.trafficLog.inbound')}
|
||||
</SelectItem>
|
||||
<SelectItem value="outbound">
|
||||
{t('proxy.advanced.trafficLog.outbound')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => handleExport('json')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.exportJson')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => handleExport('csv')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.exportCsv')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TrafficLogTable logs={logs || []} isLoading={logsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Loader2, Play, RefreshCw, Clock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface ReplayEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
method: string;
|
||||
endpoint: string;
|
||||
status?: number;
|
||||
duration?: number;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export const ReplayTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedRequestId, setSelectedRequestId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: requestsData,
|
||||
isLoading: requestsLoading,
|
||||
refetch: refetchRequests,
|
||||
} = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'recentRequests'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getRecentRequests(),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const replayMutation = useMutation({
|
||||
mutationFn: (requestId: string) => ipc.client.proxyAdvanced.replayRequest({ requestId }),
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast({
|
||||
title: t('proxy.advanced.replay.success'),
|
||||
description: t('proxy.advanced.replay.replayedSuccessfully'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t('proxy.advanced.replay.error'),
|
||||
description: data.error || t('proxy.advanced.replay.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
setSelectedRequestId(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('proxy.advanced.replay.error'),
|
||||
description: error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
setSelectedRequestId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const requests: ReplayEntry[] = requestsData?.success ? (requestsData.data as ReplayEntry[]) : [];
|
||||
|
||||
const handleReplay = (requestId: string) => {
|
||||
setSelectedRequestId(requestId);
|
||||
replayMutation.mutate(requestId);
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
};
|
||||
|
||||
const getStatusBadge = (status?: number) => {
|
||||
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
|
||||
if (status >= 200 && status < 300) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-600 hover:bg-green-700">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status >= 400) {
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{t('proxy.advanced.replay.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('proxy.advanced.replay.description')}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchRequests()}
|
||||
disabled={requestsLoading}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${requestsLoading ? 'animate-spin' : ''}`} />
|
||||
{t('proxy.advanced.replay.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Requests List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('proxy.advanced.replay.recentRequests')}</CardTitle>
|
||||
<CardDescription>{t('proxy.advanced.replay.clickToReplay')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{requestsLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed">
|
||||
<Clock className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('proxy.advanced.replay.noRequests')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{requests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(request.status)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{request.method} {request.endpoint}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(request.timestamp)}
|
||||
{request.duration && ` · ${request.duration}ms`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleReplay(request.id)}
|
||||
disabled={replayMutation.isPending && selectedRequestId === request.id}
|
||||
>
|
||||
{replayMutation.isPending && selectedRequestId === request.id ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('proxy.advanced.replay.replay')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { Loader2, Copy, CheckCircle, ShieldAlert, XCircle, Terminal, Code2, Braces } from 'lucide-react';
|
||||
|
||||
const IDE_CONFIGS = [
|
||||
{ key: 'vscode', label: 'VS Code', icon: Code2 },
|
||||
{ key: 'cursor', label: 'Cursor', icon: Terminal },
|
||||
{ key: 'claude-code', label: 'Claude Code', icon: Braces },
|
||||
] as const;
|
||||
|
||||
export const ToolsTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: parityData, isLoading: parityLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'parityCounters'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getParityCounters(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const ideQueries = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['proxyAdvanced', 'ideConfig', 'vscode'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'vscode' }),
|
||||
},
|
||||
{
|
||||
queryKey: ['proxyAdvanced', 'ideConfig', 'cursor'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'cursor' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const parity = parityData?.success ? parityData.data : undefined;
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast({ title: t('proxy.advanced.tools.copyConfig') });
|
||||
};
|
||||
|
||||
const getIdeInstructions = (key: string): string => {
|
||||
if (key === 'claude-code') {
|
||||
return 'export ANTHROPIC_BASE_URL=http://localhost:8045/v1\nexport ANTHROPIC_API_KEY=YOUR_API_KEY';
|
||||
}
|
||||
const index = key === 'vscode' ? 0 : 1;
|
||||
const result = ideQueries[index].data;
|
||||
if (result?.success && result.data) {
|
||||
const d = result.data;
|
||||
return `${d.instructions}\n${d.proxySetting}: http://localhost:8045\n${d.apiKeySetting}: YOUR_API_KEY`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const isIdeLoading = ideQueries.some((q) => q.isLoading);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Parity Counters */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('proxy.advanced.tools.totalRequests')}
|
||||
value={parity?.totalRequests ?? 0}
|
||||
icon={CheckCircle}
|
||||
isLoading={parityLoading}
|
||||
accent="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.tools.matchedRequests')}
|
||||
value={parity?.matchedRequests ?? 0}
|
||||
icon={ShieldAlert}
|
||||
isLoading={parityLoading}
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.tools.parityViolations')}
|
||||
value={parity?.parityViolations ?? 0}
|
||||
icon={XCircle}
|
||||
isLoading={parityLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* IDE Quick Setup */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('proxy.advanced.tools.ideSetup')}</CardTitle>
|
||||
<CardDescription>Quick configuration for popular IDEs</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{isIdeLoading ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
IDE_CONFIGS.map(({ key, label, icon: Icon }) => {
|
||||
const instructions = getIdeInstructions(key);
|
||||
return (
|
||||
<div key={key} className="rounded-lg border">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => copyToClipboard(instructions)}
|
||||
>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('proxy.advanced.tools.copyConfig')}
|
||||
</Button>
|
||||
</div>
|
||||
{instructions && (
|
||||
<pre className="overflow-x-auto border-t bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
|
||||
{instructions}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface TrafficLogEntry {
|
||||
timestamp: string;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface TrafficLogTableProps {
|
||||
logs: TrafficLogEntry[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'method' | 'status' | 'duration';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedLogs = useMemo(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return [...logs].sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'timestamp':
|
||||
return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) * dir;
|
||||
case 'direction':
|
||||
return (a.direction || '').localeCompare(b.direction || '') * dir;
|
||||
case 'endpoint':
|
||||
return (a.endpoint || '').localeCompare(b.endpoint || '') * dir;
|
||||
case 'method':
|
||||
return (a.method || '').localeCompare(b.method || '') * dir;
|
||||
case 'status': {
|
||||
const aStatus = a.status ?? -1;
|
||||
const bStatus = b.status ?? -1;
|
||||
return (aStatus - bStatus) * dir;
|
||||
}
|
||||
case 'duration': {
|
||||
const aDur = a.durationMs ?? -1;
|
||||
const bDur = b.durationMs ?? -1;
|
||||
return (aDur - bDur) * dir;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [logs, sortKey, sortDir]);
|
||||
|
||||
const SortIcon = ({ column }: { column: SortKey }) => {
|
||||
if (sortKey !== column) return <span className="ml-1 inline-block w-3" />;
|
||||
return sortDir === 'asc' ? (
|
||||
<ArrowUp className="ml-1 inline h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="ml-1 inline h-3 w-3" />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">
|
||||
{t('proxy.advanced.trafficLog.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t('proxy.advanced.trafficLog.noLogs')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('timestamp')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.timestamp')}
|
||||
<SortIcon column="timestamp" />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('direction')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.direction')}
|
||||
<SortIcon column="direction" />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('endpoint')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.endpoint')}
|
||||
<SortIcon column="endpoint" />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('method')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.method')}
|
||||
<SortIcon column="method" />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('status')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.status')}
|
||||
<SortIcon column="status" />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 font-medium select-none"
|
||||
onClick={() => toggleSort('duration')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.duration')}
|
||||
<SortIcon column="duration" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedLogs.map((log, i) => (
|
||||
<tr
|
||||
key={`${log.requestId}-${i}`}
|
||||
className="border-b border-border/40 last:border-0"
|
||||
>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
log.direction === 'inbound'
|
||||
? 'border-blue-500/30 text-blue-400'
|
||||
: log.direction === 'outbound'
|
||||
? 'border-emerald-500/30 text-emerald-400'
|
||||
: 'border-slate-500/30 text-slate-400'
|
||||
}
|
||||
>
|
||||
{log.direction}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{log.endpoint}</td>
|
||||
<td className="py-2 pr-4 text-xs">{log.method || '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{log.status != null ? (
|
||||
<Badge
|
||||
variant={log.status >= 400 ? 'destructive' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{log.status}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
{log.durationMs != null ? `${log.durationMs}ms` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { isNumber, isObjectLike } from 'lodash-es';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -52,11 +53,11 @@ const DropdownMenuContent = React.forwardRef<
|
||||
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
|
||||
}
|
||||
>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => {
|
||||
if (typeof collisionPadding === 'number') {
|
||||
if (isNumber(collisionPadding)) {
|
||||
if (collisionPadding < 0) {
|
||||
throw new Error('collisionPadding must be a non-negative number');
|
||||
}
|
||||
} else if (typeof collisionPadding === 'object' && collisionPadding !== null) {
|
||||
} else if (isObjectLike(collisionPadding)) {
|
||||
for (const key of Object.keys(collisionPadding)) {
|
||||
if ((collisionPadding as any)[key] < 0) {
|
||||
throw new Error(`collisionPadding.${key} must be a non-negative number`);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('bg-muted animate-pulse rounded-md', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, BarChart3, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface ChartStatesProps {
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
isEmpty: boolean;
|
||||
onRetry: () => void;
|
||||
title: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
errorMessage: string;
|
||||
retryLabel: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ChartCard: React.FC<ChartStatesProps> = ({
|
||||
isLoading,
|
||||
error,
|
||||
isEmpty,
|
||||
onRetry,
|
||||
title,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
errorMessage,
|
||||
retryLabel,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/60 flex flex-col">
|
||||
<CardContent className="flex flex-1 flex-col p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-foreground text-sm font-semibold tracking-tight">{title}</h3>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex h-[280px] flex-col gap-3">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
|
||||
<AlertTriangle className="text-destructive h-5 w-5" />
|
||||
<p className="text-muted-foreground text-sm">{errorMessage}</p>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
|
||||
<BarChart3 className="text-muted-foreground/50 h-6 w-6" />
|
||||
<p className="text-foreground text-sm font-medium">{emptyTitle}</p>
|
||||
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
export const COLOR_PROMPT = 'var(--chart-2)';
|
||||
export const COLOR_COMPLETION = 'var(--chart-1)';
|
||||
export const COLOR_TOTAL = 'var(--chart-3)';
|
||||
export const PIE_COLORS = [
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
];
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
Prompt: COLOR_PROMPT,
|
||||
Completion: COLOR_COMPLETION,
|
||||
Total: COLOR_TOTAL,
|
||||
};
|
||||
|
||||
export function BarTooltip({ indexValue, data }: any) {
|
||||
const keys = Object.keys(data).filter((k: string) => k !== 'name' && colorMap[k]);
|
||||
|
||||
return (
|
||||
<div className="bg-popover border-border rounded-md border px-3 py-2 shadow-md">
|
||||
<p className="text-foreground mb-1.5 text-xs font-medium">{indexValue}</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{keys.map((key: string) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: colorMap[key] }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{key}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PieTooltip({ datum }: any) {
|
||||
return (
|
||||
<div className="bg-popover border-border rounded-md border px-3 py-2 shadow-md">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: datum.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground">{datum.id}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon: LucideIcon;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
accent?: 'blue' | 'green' | 'amber' | 'slate' | 'purple';
|
||||
}
|
||||
|
||||
const ACCENT_MAP = {
|
||||
blue: 'text-blue-400',
|
||||
green: 'text-emerald-400',
|
||||
amber: 'text-amber-400',
|
||||
slate: 'text-slate-400',
|
||||
purple: 'text-purple-400',
|
||||
};
|
||||
|
||||
const BG_MAP = {
|
||||
blue: 'bg-blue-500/5',
|
||||
green: 'bg-emerald-500/5',
|
||||
amber: 'bg-amber-500/5',
|
||||
slate: 'bg-slate-500/5',
|
||||
purple: 'bg-purple-500/5',
|
||||
};
|
||||
|
||||
export const StatCard: React.FC<StatCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
isLoading,
|
||||
className,
|
||||
accent = 'slate',
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'border-border/60 bg-card/60 hover:bg-card backdrop-blur-none transition-colors',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{label}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-7 w-24" />
|
||||
) : (
|
||||
<span className="text-foreground font-mono text-2xl font-semibold tracking-tight">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-md',
|
||||
BG_MAP[accent],
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4', ACCENT_MAP[accent])} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
@@ -9,6 +10,7 @@ import { AppConfig } from '@/types/config';
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
export function useAppConfig() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
@@ -57,8 +59,8 @@ export function useAppConfig() {
|
||||
queryClient.setQueryData(['appConfig'], savedConfig);
|
||||
lastStableConfigRef.current = savedConfig;
|
||||
toast({
|
||||
title: 'Settings saved',
|
||||
description: 'Your configuration has been updated.',
|
||||
title: t('settings.toast.saved.title'),
|
||||
description: t('settings.toast.saved.description'),
|
||||
});
|
||||
for (const item of pendingBatch) {
|
||||
item.resolve();
|
||||
@@ -72,7 +74,7 @@ export function useAppConfig() {
|
||||
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||
}
|
||||
toast({
|
||||
title: 'Error saving settings',
|
||||
title: t('settings.toast.saveFailed.title'),
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -80,7 +82,7 @@ export function useAppConfig() {
|
||||
item.reject(error);
|
||||
}
|
||||
}
|
||||
}, [queryClient, updateConfig]);
|
||||
}, [queryClient, t, updateConfig]);
|
||||
|
||||
const saveConfig = useCallback(
|
||||
(newConfig: AppConfig) => {
|
||||
|
||||
@@ -23,7 +23,7 @@ function getQuickConfig() {
|
||||
if (getQuickConfig()) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
release: `antigravity-manager@${app.getVersion()}`,
|
||||
release: `gemini-nexus@${app.getVersion()}`,
|
||||
beforeSend(event) {
|
||||
if (event.exception?.values?.[0]?.value) {
|
||||
event.exception.values[0].value = event.exception.values[0].value.replace(
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function addAccountSnapshot(): Promise<Account> {
|
||||
const info = getCurrentAccountInfo();
|
||||
if (!info.isAuthenticated) {
|
||||
const errorMsg =
|
||||
'No authenticated account found. Please ensure Antigravity is running and you are logged in.';
|
||||
'No authenticated account found. Please ensure Gemini Nexus is running and you are logged in.';
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export class AuthServer {
|
||||
<html>
|
||||
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
||||
<h1>Login Successful</h1>
|
||||
<p>You can close this window and return to Antigravity Manager.</p>
|
||||
<p>You can close this window and return to Gemini Nexus.</p>
|
||||
<script>
|
||||
setTimeout(() => window.close(), 3000);
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { logger } from '../../utils/logger';
|
||||
|
||||
import { shell } from 'electron';
|
||||
import fs from 'fs';
|
||||
import { isEmpty, isString } from 'lodash-es';
|
||||
import { updateTrayMenu } from '../../ipc/tray/handler';
|
||||
import {
|
||||
ensureGlobalOriginalFromCurrentStorage,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
readCurrentDeviceProfile,
|
||||
saveGlobalOriginalProfile,
|
||||
} from '../../ipc/device/handler';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import { runWithSwitchGuard } from '../../ipc/switchGuard';
|
||||
import { executeSwitchFlow } from '../../ipc/switchFlow';
|
||||
import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account';
|
||||
@@ -36,7 +37,7 @@ function notifyTrayUpdate(account: CloudAccount) {
|
||||
|
||||
const ACTIVE_OAUTH_CLIENT_KEY_SETTING = 'active_oauth_client_key';
|
||||
const OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING = 'oauth_client_key_backfill_v1_done';
|
||||
const ENTERPRISE_OAUTH_CLIENT_KEY = 'antigravity_enterprise';
|
||||
const ENTERPRISE_OAUTH_CLIENT_KEY = 'geminiNexus_enterprise';
|
||||
|
||||
function isEnterpriseClient(clientKey?: string): boolean {
|
||||
if (!clientKey) {
|
||||
@@ -46,14 +47,17 @@ function isEnterpriseClient(clientKey?: string): boolean {
|
||||
}
|
||||
|
||||
function normalizeProjectId(projectId?: string): string | null {
|
||||
if (typeof projectId !== 'string') {
|
||||
if (!isString(projectId)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = projectId.trim();
|
||||
return normalized === '' ? null : normalized;
|
||||
}
|
||||
|
||||
function recoverCachedQuotaOnRateLimit(account: CloudAccount, error: unknown): CloudAccount['quota'] | null {
|
||||
function recoverCachedQuotaOnRateLimit(
|
||||
account: CloudAccount,
|
||||
error: unknown,
|
||||
): CloudAccount['quota'] | null {
|
||||
const classified = classifyAccountStatusFromError(error);
|
||||
if (!classified || classified.status !== 'rate_limited') {
|
||||
return null;
|
||||
@@ -147,8 +151,11 @@ async function clearAccountStatus(account: CloudAccount): Promise<void> {
|
||||
}
|
||||
|
||||
function hydrateActiveOAuthClientFromSettings(): void {
|
||||
const preferredClientKey = CloudAccountRepo.getSetting<string>(ACTIVE_OAUTH_CLIENT_KEY_SETTING, '');
|
||||
if (preferredClientKey && preferredClientKey.trim() !== '') {
|
||||
const preferredClientKey = CloudAccountRepo.getSetting<string>(
|
||||
ACTIVE_OAUTH_CLIENT_KEY_SETTING,
|
||||
'',
|
||||
);
|
||||
if (isString(preferredClientKey) && !isEmpty(preferredClientKey.trim())) {
|
||||
try {
|
||||
GoogleAPIService.setActiveOAuthClientKey(preferredClientKey);
|
||||
} catch (error) {
|
||||
@@ -164,7 +171,10 @@ function hydrateActiveOAuthClientFromSettings(): void {
|
||||
async function backfillMissingOAuthClientKeyForLegacyAccounts(
|
||||
accounts: CloudAccount[],
|
||||
): Promise<boolean> {
|
||||
const backfillDone = CloudAccountRepo.getSetting<boolean>(OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING, false);
|
||||
const backfillDone = CloudAccountRepo.getSetting<boolean>(
|
||||
OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING,
|
||||
false,
|
||||
);
|
||||
if (backfillDone) {
|
||||
return false;
|
||||
}
|
||||
@@ -195,7 +205,7 @@ async function backfillMissingOAuthClientKeyForLegacyAccounts(
|
||||
}
|
||||
|
||||
const projectMissing =
|
||||
typeof account.token.project_id !== 'string' || account.token.project_id.trim() === '';
|
||||
!isString(account.token.project_id) || isEmpty(account.token.project_id.trim());
|
||||
if (activeClientKey === ENTERPRISE_OAUTH_CLIENT_KEY && projectMissing) {
|
||||
skippedEnterpriseGuardCount += 1;
|
||||
continue;
|
||||
@@ -232,7 +242,7 @@ export async function addGoogleAccount(
|
||||
oauthClientKey?: string,
|
||||
): Promise<CloudAccount> {
|
||||
try {
|
||||
if (oauthClientKey && oauthClientKey.trim() !== '') {
|
||||
if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) {
|
||||
setActiveOAuthClient(oauthClientKey);
|
||||
} else {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
@@ -281,7 +291,10 @@ export async function addGoogleAccount(
|
||||
try {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
try {
|
||||
const aiCredits = await GoogleAPIService.fetchAICredits(account.token.access_token);
|
||||
const aiCredits = await GoogleAPIService.fetchAICredits(
|
||||
account.token.access_token,
|
||||
undefined,
|
||||
);
|
||||
if (aiCredits) {
|
||||
quota.ai_credits = aiCredits;
|
||||
} else {
|
||||
@@ -535,7 +548,7 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
processExitTimeoutMs: 10000,
|
||||
performSwitch: async () => {
|
||||
// 3. Backup Database (Optimized to avoid race conditions)
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
for (const dbPath of dbPaths) {
|
||||
try {
|
||||
const backupPath = `${dbPath}.backup`;
|
||||
@@ -684,7 +697,7 @@ export async function forcePollCloudMonitor(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function startAuthFlow(oauthClientKey?: string): Promise<void> {
|
||||
if (oauthClientKey && oauthClientKey.trim() !== '') {
|
||||
if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) {
|
||||
setActiveOAuthClient(oauthClientKey);
|
||||
} else {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
@@ -707,7 +720,10 @@ export function getActiveOAuthClient(): string {
|
||||
|
||||
export function setActiveOAuthClient(clientKey: string): void {
|
||||
GoogleAPIService.setActiveOAuthClientKey(clientKey);
|
||||
CloudAccountRepo.setSetting(ACTIVE_OAUTH_CLIENT_KEY_SETTING, GoogleAPIService.getActiveOAuthClientKey());
|
||||
CloudAccountRepo.setSetting(
|
||||
ACTIVE_OAUTH_CLIENT_KEY_SETTING,
|
||||
GoogleAPIService.getActiveOAuthClientKey(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportCloudAccounts(stripTokens = false): Promise<string> {
|
||||
|
||||
@@ -86,7 +86,7 @@ export function toSyncLocalAccountORPCError(error: unknown): ORPCError<string, u
|
||||
normalized.includes('unauthenticated') ||
|
||||
normalized.includes('unauthorized') ||
|
||||
normalized.includes('token may be expired') ||
|
||||
normalized.includes('re-login in antigravity ide')
|
||||
normalized.includes('re-login in geminiNexus ide')
|
||||
) {
|
||||
return new ORPCError('UNAUTHORIZED', { message });
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function toSyncLocalAccountORPCError(error: unknown): ORPCError<string, u
|
||||
if (
|
||||
normalized.includes('no cloud account found in ide') ||
|
||||
normalized.includes('no oauth token found in ide state') ||
|
||||
normalized.includes('antigravity database not found')
|
||||
normalized.includes('geminiNexus database not found')
|
||||
) {
|
||||
return new ORPCError('BAD_REQUEST', { message });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { isNumber, isObjectLike, isPlainObject, isString } from 'lodash-es';
|
||||
import { getCloudAccountsDbPath, getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
CloudAccount,
|
||||
@@ -17,7 +18,7 @@ import { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db';
|
||||
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
||||
import { ProtobufUtils } from '../../utils/protobuf';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { getAntigravityVersion, isNewVersion } from '../../utils/antigravityVersion';
|
||||
import { getGeminiNexusVersion, isNewVersion } from '../../utils/geminiNexusVersion';
|
||||
import { parseRow, parseRows } from '../../utils/sqlite';
|
||||
import { configureDatabase, openDrizzleConnection } from './dbConnection';
|
||||
import { accounts, itemTable, settings } from './schema';
|
||||
@@ -35,14 +36,14 @@ type DrizzleExecutor = Pick<
|
||||
>;
|
||||
|
||||
function isSqliteBusyError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
if (!isObjectLike(error)) {
|
||||
return false;
|
||||
}
|
||||
const err = error as { code?: string; message?: string };
|
||||
if (err.code && SQLITE_BUSY_CODES.has(err.code)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof err.message === 'string') {
|
||||
if (isString(err.message)) {
|
||||
return err.message.includes('SQLITE_BUSY') || err.message.includes('SQLITE_LOCKED');
|
||||
}
|
||||
return false;
|
||||
@@ -58,7 +59,7 @@ function sleepSync(ms: number): void {
|
||||
* Ensures that the cloud database file and schema exist.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
*/
|
||||
function ensureDatabaseInitialized(dbPath: string): void {
|
||||
export function ensureDatabaseInitialized(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
@@ -67,6 +68,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
configureDatabase(db, { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS });
|
||||
|
||||
// Create accounts table
|
||||
@@ -125,6 +127,40 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Create token_usage table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS token_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
timestamp INTEGER NOT NULL,
|
||||
request_type TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: Check if request_type column exists on token_usage
|
||||
const tokenUsageTableInfoRaw = db.pragma('table_info(token_usage)') as any[];
|
||||
const tokenUsageTableInfo = parseRows(
|
||||
TableInfoRowSchema,
|
||||
tokenUsageTableInfoRaw,
|
||||
'cloud.token_usage.tableInfo',
|
||||
);
|
||||
const hasRequestType = tokenUsageTableInfo.some((col) => col.name === 'request_type');
|
||||
if (!hasRequestType) {
|
||||
db.exec('ALTER TABLE token_usage ADD COLUMN request_type TEXT');
|
||||
}
|
||||
|
||||
// Create indexes for token_usage
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_account ON token_usage(account_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_timestamp ON token_usage(timestamp);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_model ON token_usage(model);`);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_token_usage_account_timestamp ON token_usage(account_id, timestamp);`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize cloud database schema', error);
|
||||
throw error;
|
||||
@@ -136,7 +172,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
/**
|
||||
* Gets a connection to the cloud accounts database.
|
||||
*/
|
||||
function getCloudDb(): {
|
||||
export function getCloudDb(): {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof drizzleSchema>;
|
||||
} {
|
||||
@@ -168,17 +204,13 @@ interface MigrationStats {
|
||||
failedFields: number;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readStringCandidate(
|
||||
source: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const candidate = source[key];
|
||||
if (typeof candidate === 'string' && candidate.length > 0) {
|
||||
if (isString(candidate) && candidate.length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
@@ -186,14 +218,15 @@ function readStringCandidate(
|
||||
}
|
||||
|
||||
function normalizeDeviceProfile(value: unknown): DeviceProfile | undefined {
|
||||
if (!isRecord(value)) {
|
||||
if (!isPlainObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const valueRecord = value as Record<string, unknown>;
|
||||
|
||||
const machineId = readStringCandidate(value, 'machineId', 'machine_id');
|
||||
const macMachineId = readStringCandidate(value, 'macMachineId', 'mac_machine_id');
|
||||
const devDeviceId = readStringCandidate(value, 'devDeviceId', 'dev_device_id');
|
||||
const sqmId = readStringCandidate(value, 'sqmId', 'sqm_id');
|
||||
const machineId = readStringCandidate(valueRecord, 'machineId', 'machine_id');
|
||||
const macMachineId = readStringCandidate(valueRecord, 'macMachineId', 'mac_machine_id');
|
||||
const devDeviceId = readStringCandidate(valueRecord, 'devDeviceId', 'dev_device_id');
|
||||
const sqmId = readStringCandidate(valueRecord, 'sqmId', 'sqm_id');
|
||||
|
||||
if (!machineId || !macMachineId || !devDeviceId || !sqmId) {
|
||||
return undefined;
|
||||
@@ -217,45 +250,47 @@ function areDeviceProfilesEqual(left: DeviceProfile, right: DeviceProfile): bool
|
||||
}
|
||||
|
||||
function readVersionedProfilePayload(value: unknown): unknown {
|
||||
if (!isRecord(value)) {
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!('schemaVersion' in value)) {
|
||||
const valueRecord = value as Record<string, unknown>;
|
||||
if (!('schemaVersion' in valueRecord)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const schemaVersion = value.schemaVersion;
|
||||
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
|
||||
const schemaVersion = valueRecord.schemaVersion;
|
||||
if (!isNumber(schemaVersion) || !Number.isFinite(schemaVersion)) {
|
||||
throw new Error('invalid_device_profile_schema_version');
|
||||
}
|
||||
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
|
||||
throw new Error(`unsupported_device_profile_schema_version:${schemaVersion}`);
|
||||
}
|
||||
if (!('profile' in value)) {
|
||||
if (!('profile' in valueRecord)) {
|
||||
throw new Error('invalid_device_profile_payload');
|
||||
}
|
||||
return value.profile;
|
||||
return valueRecord.profile;
|
||||
}
|
||||
|
||||
function readVersionedHistoryPayload(value: unknown): unknown {
|
||||
if (!isRecord(value)) {
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!('schemaVersion' in value)) {
|
||||
const valueRecord = value as Record<string, unknown>;
|
||||
if (!('schemaVersion' in valueRecord)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const schemaVersion = value.schemaVersion;
|
||||
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
|
||||
const schemaVersion = valueRecord.schemaVersion;
|
||||
if (!isNumber(schemaVersion) || !Number.isFinite(schemaVersion)) {
|
||||
throw new Error('invalid_device_history_schema_version');
|
||||
}
|
||||
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
|
||||
throw new Error(`unsupported_device_history_schema_version:${schemaVersion}`);
|
||||
}
|
||||
if (!('history' in value)) {
|
||||
if (!('history' in valueRecord)) {
|
||||
throw new Error('invalid_device_history_payload');
|
||||
}
|
||||
return value.history;
|
||||
return valueRecord.history;
|
||||
}
|
||||
|
||||
function serializeDeviceProfile(profile: DeviceProfile | undefined): string | null {
|
||||
@@ -285,23 +320,25 @@ function normalizeDeviceHistory(value: unknown): DeviceProfileVersion[] | undefi
|
||||
|
||||
const normalized: DeviceProfileVersion[] = [];
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) {
|
||||
if (!isPlainObject(item)) {
|
||||
continue;
|
||||
}
|
||||
const itemRecord = item as Record<string, unknown>;
|
||||
|
||||
const profile = normalizeDeviceProfile(item.profile);
|
||||
const profile = normalizeDeviceProfile(itemRecord.profile);
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = typeof item.id === 'string' && item.id.length > 0 ? item.id : uuidv4();
|
||||
const createdAtCandidate = item.createdAt;
|
||||
const id = isString(itemRecord.id) && itemRecord.id.length > 0 ? itemRecord.id : uuidv4();
|
||||
const createdAtCandidate = itemRecord.createdAt;
|
||||
const createdAt =
|
||||
typeof createdAtCandidate === 'number' && Number.isFinite(createdAtCandidate)
|
||||
isNumber(createdAtCandidate) && Number.isFinite(createdAtCandidate)
|
||||
? Math.floor(createdAtCandidate)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const label = typeof item.label === 'string' && item.label.length > 0 ? item.label : 'legacy';
|
||||
const isCurrent = item.isCurrent === true;
|
||||
const label =
|
||||
isString(itemRecord.label) && itemRecord.label.length > 0 ? itemRecord.label : 'legacy';
|
||||
const isCurrent = itemRecord.isCurrent === true;
|
||||
|
||||
normalized.push({
|
||||
id,
|
||||
@@ -519,12 +556,18 @@ export class CloudAccountRepo {
|
||||
try {
|
||||
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
|
||||
|
||||
// DEBUG LOGS
|
||||
const activeRows = rows.filter((r) => r.isActive);
|
||||
logger.info(
|
||||
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
||||
);
|
||||
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
||||
logger.debug(`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`);
|
||||
|
||||
// Auto-activate the single account if none are active
|
||||
if (rows.length > 0 && activeRows.length === 0) {
|
||||
const targetRow = rows[0];
|
||||
logger.info(
|
||||
`No active account found. Auto-activating account: ${targetRow.email} (${targetRow.id})`,
|
||||
);
|
||||
orm.update(accounts).set({ isActive: 1 }).where(eq(accounts.id, targetRow.id)).run();
|
||||
targetRow.isActive = 1;
|
||||
}
|
||||
|
||||
const cloudAccounts: CloudAccount[] = [];
|
||||
for (const normalizedRow of rows) {
|
||||
@@ -1035,9 +1078,9 @@ export class CloudAccountRepo {
|
||||
apiKey: account.token.access_token,
|
||||
};
|
||||
|
||||
this.upsertItemValue(db, 'antigravityAuthStatus', JSON.stringify(authStatus));
|
||||
this.upsertItemValue(db, 'antigravityOnboarding', 'true');
|
||||
db.delete(itemTable).where(eq(itemTable.key, 'google.antigravity')).run();
|
||||
this.upsertItemValue(db, 'geminiNexusAuthStatus', JSON.stringify(authStatus));
|
||||
this.upsertItemValue(db, 'geminiNexusOnboarding', 'true');
|
||||
db.delete(itemTable).where(eq(itemTable.key, 'google.geminiNexus')).run();
|
||||
}
|
||||
|
||||
private static getItemValue(db: DrizzleExecutor, key: string, context: string): string | null {
|
||||
@@ -1068,23 +1111,18 @@ export class CloudAccountRepo {
|
||||
const normalizedProjectId = account.token.project_id?.trim();
|
||||
|
||||
orm.transaction((tx) => {
|
||||
this.upsertItemValue(tx, 'antigravityUnifiedStateSync.oauthToken', oauthToken);
|
||||
this.upsertItemValue(tx, 'antigravityUnifiedStateSync.userStatus', userStatusEntry);
|
||||
this.upsertItemValue(tx, 'geminiNexusUnifiedStateSync.oauthToken', oauthToken);
|
||||
this.upsertItemValue(tx, 'geminiNexusUnifiedStateSync.userStatus', userStatusEntry);
|
||||
if (normalizedProjectId) {
|
||||
const projectPayload = ProtobufUtils.createStringValuePayload(normalizedProjectId);
|
||||
const projectEntry = ProtobufUtils.createUnifiedStateEntry(
|
||||
'enterpriseGcpProjectId',
|
||||
projectPayload,
|
||||
);
|
||||
this.upsertItemValue(
|
||||
tx,
|
||||
'antigravityUnifiedStateSync.enterprisePreferences',
|
||||
projectEntry,
|
||||
);
|
||||
this.upsertItemValue(tx, 'geminiNexusUnifiedStateSync.enterprisePreferences', projectEntry);
|
||||
} else {
|
||||
tx
|
||||
.delete(itemTable)
|
||||
.where(eq(itemTable.key, 'antigravityUnifiedStateSync.enterprisePreferences'))
|
||||
tx.delete(itemTable)
|
||||
.where(eq(itemTable.key, 'geminiNexusUnifiedStateSync.enterprisePreferences'))
|
||||
.run();
|
||||
}
|
||||
this.writeAuthStatusAndCleanup(tx, account);
|
||||
@@ -1143,8 +1181,8 @@ export class CloudAccountRepo {
|
||||
private static detectFormatCapability(db: DrizzleExecutor): 'new' | 'old' | null {
|
||||
const unifiedValue = this.getItemValue(
|
||||
db,
|
||||
'antigravityUnifiedStateSync.oauthToken',
|
||||
'ide.itemTable.antigravityUnifiedStateSync.oauthToken',
|
||||
'geminiNexusUnifiedStateSync.oauthToken',
|
||||
'ide.itemTable.geminiNexusUnifiedStateSync.oauthToken',
|
||||
);
|
||||
if (unifiedValue) {
|
||||
return 'new';
|
||||
@@ -1167,7 +1205,7 @@ export class CloudAccountRepo {
|
||||
reason: string;
|
||||
} {
|
||||
try {
|
||||
const version = getAntigravityVersion();
|
||||
const version = getGeminiNexusVersion();
|
||||
return {
|
||||
name: isNewVersion(version) ? 'new' : 'old',
|
||||
reason: `version:${version.shortVersion}`,
|
||||
@@ -1251,16 +1289,16 @@ export class CloudAccountRepo {
|
||||
}
|
||||
|
||||
static injectCloudToken(account: CloudAccount): void {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
const dbPath = dbPaths.find((p) => fs.existsSync(p)) ?? null;
|
||||
|
||||
if (!dbPath) {
|
||||
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
||||
throw new Error(`Gemini Nexus database not found. Checked paths: ${dbPaths.join(', ')}`);
|
||||
}
|
||||
|
||||
const result = this.injectWithRetry(dbPath, account);
|
||||
logger.info(
|
||||
`Successfully injected cloud token and identity for ${account.email} into Antigravity database at ${dbPath} (strategy=${result.strategy}, attempts=${result.attempts}).`,
|
||||
`Successfully injected cloud token and identity for ${account.email} into Gemini Nexus database at ${dbPath} (strategy=${result.strategy}, attempts=${result.attempts}).`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1310,8 +1348,8 @@ export class CloudAccountRepo {
|
||||
const enterpriseProjectId = this.readEnterpriseProjectIdFromDb(db);
|
||||
const unifiedValue = this.getItemValue(
|
||||
db,
|
||||
'antigravityUnifiedStateSync.oauthToken',
|
||||
'ide.itemTable.antigravityUnifiedStateSync.oauthToken',
|
||||
'geminiNexusUnifiedStateSync.oauthToken',
|
||||
'ide.itemTable.geminiNexusUnifiedStateSync.oauthToken',
|
||||
);
|
||||
|
||||
let tokenInfo: { accessToken: string; refreshToken: string } | null = null;
|
||||
@@ -1334,7 +1372,7 @@ export class CloudAccountRepo {
|
||||
|
||||
if (!value) {
|
||||
const errorMsg =
|
||||
'No cloud account found in IDE. Please login to a Google account in Antigravity IDE first.';
|
||||
'No cloud account found in IDE. Please login to a Google account in Gemini Nexus IDE first.';
|
||||
logger.warn(`SyncLocal: ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
@@ -1346,7 +1384,7 @@ export class CloudAccountRepo {
|
||||
|
||||
if (!tokenInfo) {
|
||||
const errorMsg =
|
||||
'No OAuth token found in IDE state. Please login to a Google account in Antigravity IDE first.';
|
||||
'No OAuth token found in IDE state. Please login to a Google account in Gemini Nexus IDE first.';
|
||||
logger.warn(`SyncLocal: ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
@@ -1385,8 +1423,8 @@ export class CloudAccountRepo {
|
||||
private static readEnterpriseProjectIdFromDb(db: DrizzleExecutor): string | undefined {
|
||||
const enterprisePreferencesValue = this.getItemValue(
|
||||
db,
|
||||
'antigravityUnifiedStateSync.enterprisePreferences',
|
||||
'ide.itemTable.antigravityUnifiedStateSync.enterprisePreferences',
|
||||
'geminiNexusUnifiedStateSync.enterprisePreferences',
|
||||
'ide.itemTable.geminiNexusUnifiedStateSync.enterprisePreferences',
|
||||
);
|
||||
if (!enterprisePreferencesValue) {
|
||||
return undefined;
|
||||
@@ -1419,7 +1457,7 @@ export class CloudAccountRepo {
|
||||
|
||||
static async syncFromIDE(): Promise<CloudAccount | null> {
|
||||
// Try all possible database paths
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
|
||||
|
||||
const dbPath =
|
||||
@@ -1429,12 +1467,12 @@ export class CloudAccountRepo {
|
||||
}) ?? null;
|
||||
|
||||
if (!dbPath) {
|
||||
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
||||
const errorMsg = `Gemini Nexus database not found. Please ensure Gemini Nexus IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
||||
logger.info(`SyncLocal: Using Gemini Nexus database at: ${dbPath}`);
|
||||
try {
|
||||
const tokenInfo = this.readTokenInfoWithRetry(dbPath);
|
||||
|
||||
@@ -1444,7 +1482,7 @@ export class CloudAccountRepo {
|
||||
try {
|
||||
userInfo = await GoogleAPIService.getUserInfo(tokenInfo.accessToken);
|
||||
} catch (apiError: any) {
|
||||
const errorMsg = `Failed to validate token with Google API. The token may be expired. Please re-login in Antigravity IDE. Error: ${apiError.message}`;
|
||||
const errorMsg = `Failed to validate token with Google API. The token may be expired. Please re-login in Gemini Nexus IDE. Error: ${apiError.message}`;
|
||||
logger.error(`SyncLocal: ${errorMsg}`, apiError);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,19 @@ import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { isString } from 'lodash-es';
|
||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { getGeminiNexusDbPaths } from '../../utils/paths';
|
||||
import { parseRow } from '../../utils/sqlite';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { itemTable } from './schema';
|
||||
|
||||
const KEYS_TO_BACKUP: ItemTableKey[] = [
|
||||
'antigravityAuthStatus',
|
||||
'geminiNexusAuthStatus',
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'antigravityUnifiedStateSync.oauthToken',
|
||||
'geminiNexusUnifiedStateSync.oauthToken',
|
||||
];
|
||||
|
||||
function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDrizzleConnection> {
|
||||
@@ -30,7 +31,7 @@ function openIdeDb(dbPath: string, readOnly = false): ReturnType<typeof openDriz
|
||||
*/
|
||||
export function initDatabase(): void {
|
||||
try {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -85,10 +86,10 @@ function ensureDatabaseExists(dbPath: string): void {
|
||||
* @returns {ReturnType<typeof openDrizzleConnection>} The database connection.
|
||||
*/
|
||||
export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDrizzleConnection> {
|
||||
const targetPath = dbPath || getAntigravityDbPaths()[0];
|
||||
const targetPath = dbPath || getGeminiNexusDbPaths()[0];
|
||||
|
||||
if (!targetPath) {
|
||||
throw new Error('No Antigravity database path found');
|
||||
throw new Error('No Gemini Nexus database path found');
|
||||
}
|
||||
|
||||
ensureDatabaseExists(targetPath);
|
||||
@@ -98,7 +99,7 @@ export function getDatabaseConnection(dbPath?: string): ReturnType<typeof openDr
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||
throw new Error('Database is locked. Please close Antigravity before proceeding.');
|
||||
throw new Error('Database is locked. Please close Gemini Nexus before proceeding.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -132,8 +133,8 @@ export function getCurrentAccountInfo(): AccountInfo {
|
||||
// Query for auth status
|
||||
const authValue = readItemValue(
|
||||
orm,
|
||||
'antigravityAuthStatus',
|
||||
'ide.itemTable.antigravityAuthStatus',
|
||||
'geminiNexusAuthStatus',
|
||||
'ide.itemTable.geminiNexusAuthStatus',
|
||||
);
|
||||
let authStatus = null;
|
||||
if (authValue) {
|
||||
@@ -159,11 +160,11 @@ export function getCurrentAccountInfo(): AccountInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Query for google.antigravity
|
||||
// Query for google.geminiNexus
|
||||
const googleValue = readItemValue(
|
||||
orm,
|
||||
'google.antigravity',
|
||||
'ide.itemTable.google.antigravity',
|
||||
'google.geminiNexus',
|
||||
'ide.itemTable.google.geminiNexus',
|
||||
);
|
||||
let googleState = null;
|
||||
if (googleValue) {
|
||||
@@ -174,11 +175,11 @@ export function getCurrentAccountInfo(): AccountInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Query for antigravityUserSettings.allUserSettings
|
||||
// Query for geminiNexusUserSettings.allUserSettings
|
||||
const settingsValue = readItemValue(
|
||||
orm,
|
||||
'antigravityUserSettings.allUserSettings',
|
||||
'ide.itemTable.antigravityUserSettings.allUserSettings',
|
||||
'geminiNexusUserSettings.allUserSettings',
|
||||
'ide.itemTable.geminiNexusUserSettings.allUserSettings',
|
||||
);
|
||||
let settingsState = null;
|
||||
if (settingsValue) {
|
||||
@@ -192,8 +193,8 @@ export function getCurrentAccountInfo(): AccountInfo {
|
||||
// Helper to find email in object
|
||||
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
|
||||
if (!obj) return '';
|
||||
if (typeof obj.email === 'string') return obj.email;
|
||||
if (obj.user && typeof obj.user.email === 'string') return obj.user.email;
|
||||
if (isString(obj.email)) return obj.email;
|
||||
if (obj.user && isString(obj.user.email)) return obj.user.email;
|
||||
return '';
|
||||
};
|
||||
|
||||
@@ -272,9 +273,9 @@ export function backupAccount(account: AccountBackupData['account']): AccountBac
|
||||
* @throws {Error} If the backup data cannot be restored.
|
||||
*/
|
||||
export function restoreAccount(backup: AccountBackupData): void {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
const dbPaths = getGeminiNexusDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
throw new Error('No Antigravity database paths found');
|
||||
throw new Error('No Gemini Nexus database paths found');
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
@@ -323,7 +324,7 @@ function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
if (key in backup.data) {
|
||||
const value = backup.data[key];
|
||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const stringValue = isString(value) ? value : JSON.stringify(value);
|
||||
tx.insert(itemTable)
|
||||
.values({ key, value: stringValue })
|
||||
.onConflictDoUpdate({
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema';
|
||||
|
||||
export interface RecordProxyRequestParams {
|
||||
endpoint: string;
|
||||
statusCode: number;
|
||||
durationMs: number;
|
||||
timestamp: number;
|
||||
accountId?: string | null;
|
||||
tokensPrompt?: number | null;
|
||||
tokensCompletion?: number | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
let proxyMetricsDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getProxyMetricsDb() {
|
||||
if (!proxyMetricsDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
const conn = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
ensureProxyMetricsTable(conn.raw);
|
||||
proxyMetricsDbConnection = conn;
|
||||
}
|
||||
return proxyMetricsDbConnection;
|
||||
}
|
||||
|
||||
export function ensureProxyMetricsTable(rawDb: Database.Database): void {
|
||||
try {
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_request_metrics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
endpoint TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
account_id TEXT,
|
||||
tokens_prompt INTEGER,
|
||||
tokens_completion INTEGER,
|
||||
error_message TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proxy_connection_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`,
|
||||
);
|
||||
|
||||
rawDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS traffic_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
method TEXT,
|
||||
status INTEGER,
|
||||
headers_json TEXT,
|
||||
body_json TEXT,
|
||||
chunk_json TEXT,
|
||||
duration_ms INTEGER,
|
||||
error_message TEXT,
|
||||
error_stack TEXT,
|
||||
error_status INTEGER,
|
||||
metadata_json TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure proxy_request_metrics table', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProxyMetricsRepo {
|
||||
static recordRequest(params: RecordProxyRequestParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(proxyRequestMetrics)
|
||||
.values({
|
||||
endpoint: params.endpoint,
|
||||
statusCode: params.statusCode,
|
||||
durationMs: params.durationMs,
|
||||
timestamp: params.timestamp,
|
||||
accountId: params.accountId ?? null,
|
||||
tokensPrompt: params.tokensPrompt ?? null,
|
||||
tokensCompletion: params.tokensCompletion ?? null,
|
||||
errorMessage: params.errorMessage ?? null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record proxy request metric', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecentAggregates(): {
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60_000;
|
||||
const fiveMinutesAgo = now - 5 * 60_000;
|
||||
|
||||
try {
|
||||
const requestsPerMinuteRow = raw
|
||||
.prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`)
|
||||
.get(oneMinuteAgo) as { count: number } | undefined;
|
||||
const requestsPerMinute = requestsPerMinuteRow?.count ?? 0;
|
||||
|
||||
const aggregatesRow = raw
|
||||
.prepare(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
AVG(duration_ms) as avgLatency,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics
|
||||
WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(fiveMinutesAgo) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0;
|
||||
const errorRatePercent = aggregatesRow?.errorRate ?? 0;
|
||||
|
||||
return {
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent proxy metrics aggregates', error);
|
||||
return {
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static recordConnectionSnapshot(count: number): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record connection snapshot', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): {
|
||||
totalRequests: number;
|
||||
requestsPerMinute: number;
|
||||
avgLatencyMs: number;
|
||||
errorRatePercent: number;
|
||||
avgActiveConnections: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
const now = Date.now();
|
||||
const cutoff = now - windowMs;
|
||||
|
||||
try {
|
||||
const reqRow = raw
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
||||
CASE WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(cutoff) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| undefined;
|
||||
|
||||
const totalRequests = reqRow?.total ?? 0;
|
||||
const minutes = Math.max(windowMs / 60_000, 1);
|
||||
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
|
||||
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
||||
const errorRatePercent = reqRow?.errorRate ?? 0;
|
||||
|
||||
const connRow = raw
|
||||
.prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`)
|
||||
.get(cutoff) as { avg: number | null } | undefined;
|
||||
const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
requestsPerMinute,
|
||||
avgLatencyMs,
|
||||
errorRatePercent,
|
||||
avgActiveConnections,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get historical proxy metrics aggregates', error);
|
||||
return {
|
||||
totalRequests: 0,
|
||||
requestsPerMinute: 0,
|
||||
avgLatencyMs: 0,
|
||||
errorRatePercent: 0,
|
||||
avgActiveConnections: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOldRecords(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old proxy metrics records', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface InsertTrafficLogParams {
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class TrafficLogsRepo {
|
||||
static insert(params: InsertTrafficLogParams): boolean {
|
||||
const { orm } = getProxyMetricsDb();
|
||||
try {
|
||||
orm
|
||||
.insert(trafficLogs)
|
||||
.values({
|
||||
timestamp: params.timestamp,
|
||||
direction: params.direction,
|
||||
requestId: params.requestId,
|
||||
endpoint: params.endpoint,
|
||||
method: params.method ?? null,
|
||||
status: params.status ?? null,
|
||||
headersJson: params.headers ? JSON.stringify(params.headers) : null,
|
||||
bodyJson: params.body != null ? JSON.stringify(params.body) : null,
|
||||
chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null,
|
||||
durationMs: params.durationMs ?? null,
|
||||
errorMessage: params.error?.message ?? null,
|
||||
errorStack: params.error?.stack ?? null,
|
||||
errorStatus: params.error?.status ?? null,
|
||||
metadataJson: params.metadata ? JSON.stringify(params.metadata) : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to insert traffic log', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getRecent(limit: number = 50, direction?: string): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
let sql = `SELECT * FROM traffic_logs`;
|
||||
const args: (number | string)[] = [];
|
||||
if (direction) {
|
||||
sql += ` WHERE direction = ?`;
|
||||
args.push(direction);
|
||||
}
|
||||
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
||||
args.push(limit);
|
||||
const rows = raw.prepare(sql).all(...args) as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent traffic logs', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
|
||||
direction: string;
|
||||
endpoint: string;
|
||||
cnt: number;
|
||||
}>;
|
||||
const byDirection: Record<string, number> = {};
|
||||
const byEndpoint: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
total += r.cnt;
|
||||
byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt;
|
||||
byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt;
|
||||
}
|
||||
return { byDirection, byEndpoint, total };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get traffic log stats', error);
|
||||
return { byDirection: {}, byEndpoint: {}, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
static getAllForExport(): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: { message: string; stack?: string; status?: number; body?: unknown };
|
||||
metadata?: Record<string, unknown>;
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
endpoint: string;
|
||||
method: string | null;
|
||||
status: number | null;
|
||||
headers_json: string | null;
|
||||
body_json: string | null;
|
||||
chunk_json: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
error_stack: string | null;
|
||||
error_status: number | null;
|
||||
metadata_json: string | null;
|
||||
}>;
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
direction: r.direction,
|
||||
requestId: r.request_id,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method ?? undefined,
|
||||
status: r.status ?? undefined,
|
||||
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all traffic logs for export', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static deleteOld(cutoffTimestamp: number): boolean {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old traffic logs', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,51 @@ export const itemTable = sqliteTable('ItemTable', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value'),
|
||||
});
|
||||
|
||||
export const tokenUsage = sqliteTable('token_usage', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
accountId: text('account_id').notNull(),
|
||||
model: text('model').notNull(),
|
||||
promptTokens: integer('prompt_tokens').notNull().default(0),
|
||||
completionTokens: integer('completion_tokens').notNull().default(0),
|
||||
totalTokens: integer('total_tokens').notNull().default(0),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
requestType: text('request_type'),
|
||||
isEstimated: integer('is_estimated'),
|
||||
});
|
||||
|
||||
export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
statusCode: integer('status_code').notNull(),
|
||||
durationMs: integer('duration_ms').notNull(),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
accountId: text('account_id'),
|
||||
tokensPrompt: integer('tokens_prompt'),
|
||||
tokensCompletion: integer('tokens_completion'),
|
||||
errorMessage: text('error_message'),
|
||||
});
|
||||
|
||||
export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
count: integer('count').notNull().default(0),
|
||||
});
|
||||
|
||||
export const trafficLogs = sqliteTable('traffic_logs', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer('timestamp').notNull(),
|
||||
direction: text('direction').notNull(),
|
||||
requestId: text('request_id').notNull(),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
method: text('method'),
|
||||
status: integer('status'),
|
||||
headersJson: text('headers_json'),
|
||||
bodyJson: text('body_json'),
|
||||
chunkJson: text('chunk_json'),
|
||||
durationMs: integer('duration_ms'),
|
||||
errorMessage: text('error_message'),
|
||||
errorStack: text('error_stack'),
|
||||
errorStatus: integer('error_status'),
|
||||
metadataJson: text('metadata_json'),
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import Database from 'better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { openDrizzleConnection } from './dbConnection';
|
||||
import { getCloudAccountsDbPath } from '../../utils/paths';
|
||||
import { ensureDatabaseInitialized } from './cloudHandler';
|
||||
import { tokenUsage } from './schema';
|
||||
import {
|
||||
HourlyUsageSchema,
|
||||
DailyUsageSchema,
|
||||
WeeklyUsageSchema,
|
||||
MonthlyUsageSchema,
|
||||
ModelUsageSchema,
|
||||
} from '../../types/db';
|
||||
|
||||
export interface RecordUsageParams {
|
||||
accountId: string;
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
timestamp: number;
|
||||
requestType?: string;
|
||||
isEstimated?: boolean;
|
||||
}
|
||||
|
||||
let usageDbConnection: {
|
||||
raw: Database.Database;
|
||||
orm: BetterSQLite3Database<typeof import('./schema')>;
|
||||
} | null = null;
|
||||
|
||||
function getUsageDb() {
|
||||
if (!usageDbConnection) {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
usageDbConnection = openDrizzleConnection(
|
||||
dbPath,
|
||||
{ readonly: false, fileMustExist: false },
|
||||
{ busyTimeoutMs: 3000 },
|
||||
);
|
||||
}
|
||||
return usageDbConnection;
|
||||
}
|
||||
|
||||
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
|
||||
const conditions: string[] = [];
|
||||
const params: number[] = [];
|
||||
if (start !== undefined) {
|
||||
conditions.push(`timestamp >= ?`);
|
||||
params.push(start);
|
||||
}
|
||||
if (end !== undefined) {
|
||||
conditions.push(`timestamp <= ?`);
|
||||
params.push(end);
|
||||
}
|
||||
return {
|
||||
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
|
||||
return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] };
|
||||
}
|
||||
|
||||
export class TokenUsageRepo {
|
||||
static recordUsage(params: RecordUsageParams): boolean {
|
||||
const { orm } = getUsageDb();
|
||||
try {
|
||||
orm
|
||||
.insert(tokenUsage)
|
||||
.values({
|
||||
accountId: params.accountId,
|
||||
model: params.model,
|
||||
promptTokens: params.promptTokens,
|
||||
completionTokens: params.completionTokens,
|
||||
totalTokens: params.totalTokens,
|
||||
timestamp: params.timestamp,
|
||||
requestType: params.requestType ?? null,
|
||||
isEstimated: params.isEstimated ? 1 : null,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record token usage', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByHour(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => HourlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get hourly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByDay(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => DailyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get daily usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByWeek(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => WeeklyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get weekly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByMonth(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
bucket: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => MonthlyUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get monthly usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static getUsageByModel(
|
||||
accountId?: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
): Array<{
|
||||
model: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
requests: number;
|
||||
}> {
|
||||
const { raw } = getUsageDb();
|
||||
try {
|
||||
const accountFilter = buildAccountFilter(accountId);
|
||||
const dateFilter = buildDateFilter(start, end);
|
||||
const sql = `
|
||||
SELECT
|
||||
model,
|
||||
SUM(prompt_tokens) as promptTokens,
|
||||
SUM(completion_tokens) as completionTokens,
|
||||
SUM(total_tokens) as totalTokens,
|
||||
COUNT(*) as requests
|
||||
FROM token_usage
|
||||
WHERE 1=1
|
||||
${accountFilter.sql}
|
||||
${dateFilter.sql}
|
||||
GROUP BY model
|
||||
ORDER BY totalTokens DESC
|
||||
`;
|
||||
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
|
||||
return rows.map((row) => ModelUsageSchema.parse(row));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model usage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { isBoolean, isNumber, isObjectLike, isString } from 'lodash-es';
|
||||
import { type DeviceProfile } from '../../types/account';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getAgentDir, getAntigravityDbPaths, getAntigravityStoragePaths } from '../../utils/paths';
|
||||
import { getAgentDir, getGeminiNexusDbPaths, getGeminiNexusStoragePaths } from '../../utils/paths';
|
||||
|
||||
const GLOBAL_BASELINE_FILE = 'device_original.json';
|
||||
const SQLITE_RETRY_COUNT = 3;
|
||||
@@ -76,14 +77,14 @@ const deviceHardeningState: DeviceHardeningState = {
|
||||
};
|
||||
|
||||
function isSqliteBusyError(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') {
|
||||
if (!isObjectLike(error)) {
|
||||
return false;
|
||||
}
|
||||
const candidate = error as { code?: string; message?: string };
|
||||
if (candidate.code === 'SQLITE_BUSY' || candidate.code === 'SQLITE_LOCKED') {
|
||||
return true;
|
||||
}
|
||||
if (typeof candidate.message === 'string') {
|
||||
if (isString(candidate.message)) {
|
||||
return candidate.message.includes('SQLITE_BUSY') || candidate.message.includes('SQLITE_LOCKED');
|
||||
}
|
||||
return false;
|
||||
@@ -139,8 +140,8 @@ function readLastKnownGoodMarker(): LastKnownGoodMarker | null {
|
||||
if (
|
||||
parsed &&
|
||||
parsed.version === 1 &&
|
||||
typeof parsed.savedAt === 'number' &&
|
||||
typeof parsed.hasStateDb === 'boolean'
|
||||
isNumber(parsed.savedAt) &&
|
||||
isBoolean(parsed.hasStateDb)
|
||||
) {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -157,7 +158,7 @@ function readLastKnownGoodMarker(): LastKnownGoodMarker | null {
|
||||
function parseStorageJson(storagePath: string): Record<string, unknown> {
|
||||
const content = fs.readFileSync(storagePath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
if (!isObjectLike(parsed) || Array.isArray(parsed)) {
|
||||
throw new Error('storage.json top-level value must be an object');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
@@ -165,15 +166,15 @@ function parseStorageJson(storagePath: string): Record<string, unknown> {
|
||||
|
||||
function getTelemetryField(storage: Record<string, unknown>, key: string): string | null {
|
||||
const telemetry = storage.telemetry;
|
||||
if (telemetry && typeof telemetry === 'object' && !Array.isArray(telemetry)) {
|
||||
if (isObjectLike(telemetry) && !Array.isArray(telemetry)) {
|
||||
const nestedValue = (telemetry as Record<string, unknown>)[key];
|
||||
if (typeof nestedValue === 'string' && nestedValue.length > 0) {
|
||||
if (isString(nestedValue) && nestedValue.length > 0) {
|
||||
return nestedValue;
|
||||
}
|
||||
}
|
||||
|
||||
const flatValue = storage[`telemetry.${key}`];
|
||||
if (typeof flatValue === 'string' && flatValue.length > 0) {
|
||||
if (isString(flatValue) && flatValue.length > 0) {
|
||||
return flatValue;
|
||||
}
|
||||
|
||||
@@ -181,11 +182,7 @@ function getTelemetryField(storage: Record<string, unknown>, key: string): strin
|
||||
}
|
||||
|
||||
function ensureTelemetryObject(storage: Record<string, unknown>): Record<string, unknown> {
|
||||
if (
|
||||
!storage.telemetry ||
|
||||
typeof storage.telemetry !== 'object' ||
|
||||
Array.isArray(storage.telemetry)
|
||||
) {
|
||||
if (!storage.telemetry || !isObjectLike(storage.telemetry) || Array.isArray(storage.telemetry)) {
|
||||
storage.telemetry = {};
|
||||
}
|
||||
return storage.telemetry as Record<string, unknown>;
|
||||
@@ -223,7 +220,7 @@ function readStateServiceMachineIdValue(dbPath: string): string | null {
|
||||
const row = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'storage.serviceMachineId' LIMIT 1")
|
||||
.get() as { value?: unknown } | undefined;
|
||||
if (!row || typeof row.value !== 'string' || row.value.length === 0) {
|
||||
if (!row || !isString(row.value) || row.value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return row.value;
|
||||
@@ -507,7 +504,7 @@ export function isIdentityProfileApplyEnabled(): boolean {
|
||||
}
|
||||
|
||||
export function getStoragePath(): string {
|
||||
const storagePath = resolveExistingPath(getAntigravityStoragePaths());
|
||||
const storagePath = resolveExistingPath(getGeminiNexusStoragePaths());
|
||||
if (!storagePath) {
|
||||
throw new Error('storage_json_not_found');
|
||||
}
|
||||
@@ -593,7 +590,7 @@ export function generateDeviceProfile(): DeviceProfile {
|
||||
}
|
||||
|
||||
export function syncStateServiceMachineIdValue(serviceMachineId: string, dbPath?: string): void {
|
||||
const targetDbPath = dbPath || resolveExistingPath(getAntigravityDbPaths());
|
||||
const targetDbPath = dbPath || resolveExistingPath(getGeminiNexusDbPaths());
|
||||
if (!targetDbPath) {
|
||||
throw new Error('state_vscdb_not_found');
|
||||
}
|
||||
|
||||
@@ -1,114 +1,16 @@
|
||||
import { IPC_CHANNELS } from '@/constants';
|
||||
import { createORPCClient } from '@orpc/client';
|
||||
import { RPCLink } from '@orpc/client/message-port';
|
||||
import type { RouterClient } from '@orpc/server';
|
||||
import type { router } from './router';
|
||||
|
||||
// Custom IPC Client that correctly builds method paths
|
||||
function createIPCClient(port: MessagePort) {
|
||||
let requestId = 0;
|
||||
const pendingRequests = new Map<
|
||||
string,
|
||||
{ resolve: (v: any) => void; reject: (e: any) => void }
|
||||
>();
|
||||
|
||||
port.onmessage = (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
|
||||
const id = data.i || data.id;
|
||||
const pending = pendingRequests.get(id);
|
||||
if (pending) {
|
||||
pendingRequests.delete(id);
|
||||
if (data.e || data.error) {
|
||||
const errorMsg = data.e?.m || data.error?.message || JSON.stringify(data.e || data.error);
|
||||
console.error('[IPCClient] Error response:', errorMsg);
|
||||
pending.reject(new Error(errorMsg));
|
||||
} else {
|
||||
// ORPC response format: { i, p: { b: { json: value }, s?: statusCode } }
|
||||
// Or error: { i, p: { s: 500, b: { json: { code, message } } } }
|
||||
let result;
|
||||
const payload = data.p;
|
||||
|
||||
// Check for error status
|
||||
if (payload?.s && payload.s >= 400) {
|
||||
const errorData = payload.b?.json;
|
||||
const errorMsg = errorData?.message || errorData?.code || 'Server error';
|
||||
console.error('[IPCClient] Server error:', errorData);
|
||||
pending.reject(new Error(errorMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract successful result
|
||||
if (payload?.b?.json !== undefined) {
|
||||
result = payload.b.json;
|
||||
} else if (data.r !== undefined) {
|
||||
// Fallback to 'r' field if present
|
||||
result = data.r?.b?.json ?? data.r;
|
||||
} else if ('result' in data) {
|
||||
result = data.result;
|
||||
}
|
||||
|
||||
pending.resolve(result);
|
||||
}
|
||||
} else {
|
||||
console.warn('[IPCClient] No pending request for id:', id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[IPCClient] Error parsing response:', e, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
async function callMethod(path: string[], input?: unknown): Promise<any> {
|
||||
const id = String(++requestId);
|
||||
const methodPath = path.join('/');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.set(id, { resolve, reject });
|
||||
|
||||
// ORPC MessagePort protocol format
|
||||
const payload = {
|
||||
i: id,
|
||||
p: {
|
||||
u: `orpc://localhost/${methodPath}`,
|
||||
b: { json: input ?? null },
|
||||
},
|
||||
};
|
||||
|
||||
port.postMessage(JSON.stringify(payload));
|
||||
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(id)) {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request /${methodPath} timed out`));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
// Create a Proxy that builds the path chain
|
||||
function createProxyChain(pathSoFar: string[] = []): any {
|
||||
return new Proxy(() => {}, {
|
||||
get(_target, prop: string) {
|
||||
if (prop === 'then' || prop === 'catch' || prop === 'finally') {
|
||||
// Don't intercept promise methods
|
||||
return undefined;
|
||||
}
|
||||
// Build deeper path
|
||||
return createProxyChain([...pathSoFar, prop]);
|
||||
},
|
||||
apply(_target, _thisArg, args) {
|
||||
// Called as function - execute RPC
|
||||
const input = args[0];
|
||||
return callMethod(pathSoFar, input);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createProxyChain();
|
||||
}
|
||||
type IPCClient = RouterClient<typeof router>;
|
||||
|
||||
class IPCManager {
|
||||
private readonly clientPort: MessagePort;
|
||||
private readonly serverPort: MessagePort;
|
||||
|
||||
public readonly client: any; // Will be typed via usage
|
||||
public readonly client: IPCClient;
|
||||
|
||||
private initialized: boolean = false;
|
||||
|
||||
@@ -117,7 +19,11 @@ class IPCManager {
|
||||
this.clientPort = port1;
|
||||
this.serverPort = port2;
|
||||
|
||||
this.client = createIPCClient(this.clientPort);
|
||||
this.client = createORPCClient<IPCClient>(
|
||||
new RPCLink({
|
||||
port: this.clientPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { exec, execSync, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import findProcess, { ProcessInfo } from 'find-process';
|
||||
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
|
||||
import { isNumber } from 'lodash-es';
|
||||
import { getGeminiNexusExecutablePath, isWsl } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -60,24 +61,24 @@ function isPgrepNoMatchError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
const hasPgrep = message.includes('pgrep') && message.includes('antigravity');
|
||||
const hasPgrep = message.includes('pgrep') && message.includes('geminiNexus');
|
||||
const code = (error as { code?: number }).code;
|
||||
return hasPgrep && code === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Antigravity process is running.
|
||||
* Checks if the Gemini Nexus process is running.
|
||||
* Uses find-process package for robust cross-platform process detection.
|
||||
* @returns {boolean} True if the Antigravity process is running, false otherwise.
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function isProcessRunning(): Promise<boolean> {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
const currentPid = process.pid;
|
||||
|
||||
// Use find-process to search for Antigravity processes
|
||||
// Use find-process to search for Gemini Nexus processes
|
||||
const allMatches: ProcessInfo[] = [];
|
||||
const searchNames = ['Antigravity', 'antigravity'];
|
||||
const searchNames = ['Gemini Nexus', 'geminiNexus'];
|
||||
if (platform === 'linux') {
|
||||
searchNames.push('electron');
|
||||
}
|
||||
@@ -98,17 +99,17 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
for (const proc of allMatches) {
|
||||
if (typeof proc.pid === 'number') {
|
||||
if (isNumber(proc.pid)) {
|
||||
processMap.set(proc.pid, proc);
|
||||
}
|
||||
}
|
||||
|
||||
const processes = Array.from(processMap.values());
|
||||
if (processes.length === 0 && sawNoMatch) {
|
||||
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||
logger.debug('No Gemini Nexus process found (pgrep returned 1)');
|
||||
}
|
||||
|
||||
logger.debug(`Found ${processes.length} processes matching 'Antigravity/antigravity'`);
|
||||
logger.debug(`Found ${processes.length} processes matching 'GeminiNexus/geminiNexus'`);
|
||||
|
||||
for (const proc of processes) {
|
||||
// Skip self
|
||||
@@ -120,11 +121,7 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
const cmd = proc.cmd?.toLowerCase() || '';
|
||||
|
||||
// Skip manager process
|
||||
if (
|
||||
name.includes('manager') ||
|
||||
cmd.includes('manager') ||
|
||||
cmd.includes('antigravity-manager')
|
||||
) {
|
||||
if (name.includes('manager') || cmd.includes('manager') || cmd.includes('gemini-nexus')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -134,22 +131,22 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// macOS: Check for Antigravity.app in path
|
||||
if (cmd.includes('antigravity.app')) {
|
||||
// macOS: Check for Gemini Nexus.app in path
|
||||
if (cmd.includes('gemininexus.app')) {
|
||||
logger.debug(
|
||||
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Also check if the process name is exactly 'Antigravity' (main process)
|
||||
if (name === 'antigravity' && !isHelperProcess(name, cmd)) {
|
||||
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
|
||||
// Also check if the process name is exactly 'gemininexus' (main process)
|
||||
if (name === 'gemininexus' && !isHelperProcess(name, cmd)) {
|
||||
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
||||
return true;
|
||||
}
|
||||
} else if (platform === 'win32') {
|
||||
// Windows: Check for Antigravity.exe
|
||||
if (name === 'antigravity.exe' || name === 'antigravity') {
|
||||
logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`);
|
||||
// Windows: Check for gemininexus.exe
|
||||
if (name === 'gemininexus.exe' || name === 'gemininexus') {
|
||||
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
@@ -158,28 +155,28 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
|
||||
if (nameLower === 'electron') {
|
||||
// Stricter check for AUR/Electron wrapper:
|
||||
// Must include antigravity in command line but NOT manager or tools
|
||||
const isAntigravityApp =
|
||||
(cmdLower.includes('/antigravity') ||
|
||||
cmdLower.includes(' antigravity') ||
|
||||
cmdLower.endsWith('antigravity')) &&
|
||||
// Must include gemininexus in command line but NOT manager or tools
|
||||
const isGeminiNexusApp =
|
||||
(cmdLower.includes('/gemininexus') ||
|
||||
cmdLower.includes(' gemininexus') ||
|
||||
cmdLower.endsWith('gemininexus')) &&
|
||||
!cmdLower.includes('manager') &&
|
||||
!cmdLower.includes('tools');
|
||||
|
||||
if (isAntigravityApp) {
|
||||
if (isGeminiNexusApp) {
|
||||
logger.debug(
|
||||
`Found Antigravity (AUR/electron) process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
`Found Gemini Nexus (AUR/electron) process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
||||
(name.includes('gemininexus') || cmd.includes('/gemininexus')) &&
|
||||
!name.includes('tools')
|
||||
) {
|
||||
logger.debug(
|
||||
`Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -194,11 +191,11 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the Antigravity process.
|
||||
* @returns {boolean} True if the Antigravity process is running, false otherwise.
|
||||
* Closes the Gemini Nexus process.
|
||||
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
|
||||
*/
|
||||
export async function closeAntigravity(): Promise<void> {
|
||||
logger.info('Closing Antigravity...');
|
||||
export async function closeGeminiNexus(): Promise<void> {
|
||||
logger.info('Closing Gemini Nexus...');
|
||||
const platform = process.platform;
|
||||
|
||||
try {
|
||||
@@ -207,7 +204,7 @@ export async function closeAntigravity(): Promise<void> {
|
||||
// macOS: Use AppleScript to quit gracefully
|
||||
try {
|
||||
logger.info('Attempting graceful exit via AppleScript...');
|
||||
execSync('osascript -e \'tell application "Antigravity" to quit\'', {
|
||||
execSync('osascript -e \'tell application "Gemini Nexus" to quit\'', {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
@@ -222,7 +219,7 @@ export async function closeAntigravity(): Promise<void> {
|
||||
logger.info('Attempting graceful exit via taskkill...');
|
||||
// /T = Tree (child processes), /IM = Image Name
|
||||
// We do not wait long here.
|
||||
execSync('taskkill /IM "Antigravity.exe" /T', {
|
||||
execSync('taskkill /IM "GeminiNexus.exe" /T', {
|
||||
stdio: 'ignore',
|
||||
timeout: 2000,
|
||||
});
|
||||
@@ -242,7 +239,7 @@ export async function closeAntigravity(): Promise<void> {
|
||||
let output = '';
|
||||
if (platform === 'win32') {
|
||||
const psCommand = (cmdlet: string) =>
|
||||
`powershell -NoProfile -Command "${cmdlet} Win32_Process -Filter \\"Name like 'Antigravity%'\\" | Select-Object ProcessId, Name, CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
||||
`powershell -NoProfile -Command "${cmdlet} Win32_Process -Filter \\"Name like 'GeminiNexus%'\\" | Select-Object ProcessId, Name, CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
||||
|
||||
try {
|
||||
output = execSync(psCommand('Get-CimInstance'), {
|
||||
@@ -303,7 +300,7 @@ export async function closeAntigravity(): Promise<void> {
|
||||
const pid = parseInt(parts[0]);
|
||||
if (isNaN(pid)) continue;
|
||||
const rest = parts.slice(1).join(' ');
|
||||
if (rest.includes('Antigravity') || rest.includes('antigravity')) {
|
||||
if (rest.includes('Gemini Nexus') || rest.includes('geminiNexus')) {
|
||||
processList.push({ pid, name: parts[1], cmd: rest });
|
||||
}
|
||||
}
|
||||
@@ -320,31 +317,31 @@ export async function closeAntigravity(): Promise<void> {
|
||||
if (p.pid === currentPid) {
|
||||
return false;
|
||||
}
|
||||
// Exclude this electron app (if named Antigravity Manager or antigravity-manager)
|
||||
if (p.cmd.includes('Antigravity Manager') || p.cmd.includes('antigravity-manager')) {
|
||||
// Exclude this electron app (if named Gemini Nexus or gemini-nexus)
|
||||
if (p.cmd.includes('Gemini Nexus') || p.cmd.includes('gemini-nexus')) {
|
||||
return false;
|
||||
}
|
||||
// Match Antigravity (but not manager)
|
||||
// Match Gemini Nexus (but not manager)
|
||||
if (platform === 'win32') {
|
||||
return (
|
||||
p.cmd.includes('Antigravity.exe') ||
|
||||
(p.cmd.includes('antigravity') && !p.cmd.includes('manager'))
|
||||
p.cmd.includes('GeminiNexus.exe') ||
|
||||
(p.cmd.includes('geminiNexus') && !p.cmd.includes('manager'))
|
||||
);
|
||||
} else {
|
||||
// Explicit !manager check for Linux/macOS to be defensive
|
||||
return (
|
||||
(p.cmd.includes('Antigravity') || p.cmd.includes('antigravity')) &&
|
||||
(p.cmd.includes('Gemini Nexus') || p.cmd.includes('geminiNexus')) &&
|
||||
!p.cmd.includes('manager')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (targetProcessList.length === 0) {
|
||||
logger.info('No Antigravity processes found running.');
|
||||
logger.info('No Gemini Nexus processes found running.');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Found ${targetProcessList.length} remaining Antigravity processes. Killing...`);
|
||||
logger.info(`Found ${targetProcessList.length} remaining Gemini Nexus processes. Killing...`);
|
||||
|
||||
for (const p of targetProcessList) {
|
||||
try {
|
||||
@@ -354,13 +351,13 @@ export async function closeAntigravity(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error closing Antigravity', error);
|
||||
logger.error('Error closing Gemini Nexus', error);
|
||||
// Fallback to simple kill if everything fails
|
||||
try {
|
||||
if (platform === 'win32') {
|
||||
execSync('taskkill /F /IM "Antigravity.exe" /T', { stdio: 'ignore' });
|
||||
execSync('taskkill /F /IM "GeminiNexus.exe" /T', { stdio: 'ignore' });
|
||||
} else {
|
||||
execSync('pkill -9 -f Antigravity', { stdio: 'ignore' });
|
||||
execSync('pkill -9 -f Gemini Nexus', { stdio: 'ignore' });
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
@@ -369,7 +366,7 @@ export async function closeAntigravity(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the Antigravity process to exit.
|
||||
* Waits for the Gemini Nexus process to exit.
|
||||
* @param timeoutMs {number} The timeout in milliseconds.
|
||||
* @returns {Promise<void>} A promise that resolves when the process exits.
|
||||
*/
|
||||
@@ -384,7 +381,7 @@ export async function _waitForProcessExit(
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
throw new Error(`Antigravity process did not exit within ${timeoutMs}ms`);
|
||||
throw new Error(`Gemini Nexus process did not exit within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -417,7 +414,7 @@ async function openUri(uri: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAntigravityStartup(
|
||||
async function waitForGeminiNexusStartup(
|
||||
timeoutMs = PROCESS_STARTUP_TIMEOUT_MS,
|
||||
pollIntervalMs = PROCESS_STARTUP_POLL_INTERVAL_MS,
|
||||
): Promise<boolean> {
|
||||
@@ -440,15 +437,15 @@ function shouldUseLinuxGpuSafeLaunchArgs(): boolean {
|
||||
return enableLinuxGpuRaw !== '1' && enableLinuxGpuRaw !== 'true';
|
||||
}
|
||||
|
||||
async function startAntigravityByExecutable(execPath: string): Promise<void> {
|
||||
async function startGeminiNexusByExecutable(execPath: string): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
await execAsync(`open -a Antigravity`);
|
||||
await execAsync(`open -a Gemini Nexus`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Antigravity executable path');
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
// Use start command to detach
|
||||
await execAsync(`start "" "${execPath}"`);
|
||||
@@ -457,7 +454,7 @@ async function startAntigravityByExecutable(execPath: string): Promise<void> {
|
||||
|
||||
if (isWsl()) {
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Antigravity executable path');
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
// In WSL, convert path and use cmd.exe
|
||||
const winPath = execPath
|
||||
@@ -469,7 +466,7 @@ async function startAntigravityByExecutable(execPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Antigravity executable path');
|
||||
throw new Error('Unable to locate Gemini Nexus executable path');
|
||||
}
|
||||
|
||||
const launchArgs = shouldUseLinuxGpuSafeLaunchArgs() ? [...LINUX_GPU_SAFE_LAUNCH_ARGS] : [];
|
||||
@@ -485,36 +482,36 @@ async function startAntigravityByExecutable(execPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Antigravity process.
|
||||
* @param useUri {boolean} Whether to use the URI protocol to start Antigravity.
|
||||
* Starts the Gemini Nexus process.
|
||||
* @param useUri {boolean} Whether to use the URI protocol to start Gemini Nexus.
|
||||
* @returns {Promise<void>} A promise that resolves when the process starts.
|
||||
*/
|
||||
export async function startAntigravity(useUri = true): Promise<void> {
|
||||
logger.info('Starting Antigravity...');
|
||||
export async function startGeminiNexus(useUri = true): Promise<void> {
|
||||
logger.info('Starting Gemini Nexus...');
|
||||
|
||||
if (await isProcessRunning()) {
|
||||
logger.info('Antigravity is already running');
|
||||
logger.info('Gemini Nexus is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
if (useUri) {
|
||||
logger.info('Using URI protocol to start...');
|
||||
const uri = 'antigravity://oauth-success';
|
||||
const uri = 'geminiNexus://oauth-success';
|
||||
|
||||
if (await openUri(uri)) {
|
||||
logger.info('Antigravity URI launch command sent');
|
||||
logger.info('Gemini Nexus URI launch command sent');
|
||||
|
||||
if (process.platform !== 'linux' || isWsl()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await waitForAntigravityStartup()) {
|
||||
logger.info('Antigravity process detected after URI launch');
|
||||
if (await waitForGeminiNexusStartup()) {
|
||||
logger.info('Gemini Nexus process detected after URI launch');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
'URI launch did not keep Antigravity running on Linux. Falling back to executable launch.',
|
||||
'URI launch did not keep Gemini Nexus running on Linux. Falling back to executable launch.',
|
||||
);
|
||||
} else {
|
||||
logger.warn('URI launch failed, trying executable path...');
|
||||
@@ -523,22 +520,22 @@ export async function startAntigravity(useUri = true): Promise<void> {
|
||||
|
||||
// Fallback to executable path
|
||||
logger.info('Using executable path to start...');
|
||||
const execPath = getAntigravityExecutablePath();
|
||||
const execPath = getGeminiNexusExecutablePath();
|
||||
|
||||
try {
|
||||
await startAntigravityByExecutable(execPath);
|
||||
logger.info('Antigravity launch command sent');
|
||||
await startGeminiNexusByExecutable(execPath);
|
||||
logger.info('Gemini Nexus launch command sent');
|
||||
|
||||
if (process.platform === 'linux' && !isWsl()) {
|
||||
const started = await waitForAntigravityStartup();
|
||||
const started = await waitForGeminiNexusStartup();
|
||||
if (!started) {
|
||||
logger.warn(
|
||||
'Antigravity launch command completed, but process startup could not be confirmed on Linux.',
|
||||
'Gemini Nexus launch command completed, but process startup could not be confirmed on Linux.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to start Antigravity via executable', error);
|
||||
logger.error('Failed to start Gemini Nexus via executable', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import { proxyAdvancedRegistry } from './service-registry';
|
||||
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
|
||||
|
||||
export interface TrafficLogEntryParsed {
|
||||
timestamp: string;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
chunk?: unknown;
|
||||
durationMs?: number;
|
||||
error?: {
|
||||
message: string;
|
||||
stack?: string;
|
||||
status?: number;
|
||||
body?: unknown;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function getRecentTrafficLogs(
|
||||
limit: number = 50,
|
||||
direction?: string,
|
||||
): Promise<{ success: boolean; data?: TrafficLogEntryParsed[]; error?: string }> {
|
||||
try {
|
||||
const rows = TrafficLogsRepo.getRecent(limit, direction);
|
||||
const entries: TrafficLogEntryParsed[] = rows.map((r) => ({
|
||||
timestamp: new Date(r.timestamp).toISOString(),
|
||||
direction: r.direction,
|
||||
requestId: r.requestId,
|
||||
endpoint: r.endpoint,
|
||||
method: r.method,
|
||||
status: r.status,
|
||||
headers: r.headers,
|
||||
body: r.body,
|
||||
chunk: r.chunk,
|
||||
durationMs: r.durationMs,
|
||||
error: r.error,
|
||||
metadata: r.metadata,
|
||||
}));
|
||||
|
||||
return { success: true, data: entries };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get recent traffic logs', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrafficLogStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const stats = TrafficLogsRepo.getStats();
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get traffic log stats', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportTrafficLogs(
|
||||
format: 'json' | 'csv',
|
||||
): Promise<{ success: boolean; data?: string; error?: string }> {
|
||||
try {
|
||||
const rows = TrafficLogsRepo.getAllForExport();
|
||||
|
||||
if (format === 'json') {
|
||||
return { success: true, data: JSON.stringify(rows, null, 2) };
|
||||
}
|
||||
|
||||
// CSV format
|
||||
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
|
||||
const rowsCsv = rows.map((e) =>
|
||||
[
|
||||
new Date(e.timestamp).toISOString(),
|
||||
e.direction,
|
||||
e.requestId,
|
||||
e.endpoint,
|
||||
e.method || '',
|
||||
e.status != null ? e.status : '',
|
||||
e.durationMs != null ? e.durationMs : '',
|
||||
]
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(','),
|
||||
);
|
||||
|
||||
return { success: true, data: [headers.join(','), ...rowsCsv].join('\n') };
|
||||
} catch (error) {
|
||||
logger.error('Failed to export traffic logs', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getParityCounters(): Promise<{
|
||||
success: boolean;
|
||||
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = proxyAdvancedRegistry.tokenManager;
|
||||
if (!tokenManager) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalRequests: 0,
|
||||
matchedRequests: 0,
|
||||
parityViolations: 0,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: tokenManager.getParityCounters(),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCircuitBreakerStatus(): Promise<{
|
||||
success: boolean;
|
||||
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = proxyAdvancedRegistry.tokenManager;
|
||||
if (!tokenManager) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
states: {},
|
||||
globalEnabled: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const rawStatus = tokenManager.getCircuitBreakerStatus();
|
||||
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
|
||||
for (const [accountId, entry] of Object.entries(rawStatus)) {
|
||||
states[accountId] = {
|
||||
state: entry.state,
|
||||
failures: entry.failureCount,
|
||||
lastFailure: entry.lastFailureTime ? new Date(entry.lastFailureTime).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
states,
|
||||
globalEnabled: true,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProxyMetrics(): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
uptimeSeconds: number;
|
||||
totalRequests: number;
|
||||
activeConnections: number;
|
||||
avgLatency: number;
|
||||
errorRate: number;
|
||||
requestsPerMinute: number;
|
||||
cacheHitRate: number;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyMetrics = proxyAdvancedRegistry.proxyMetrics;
|
||||
if (!proxyMetrics) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
uptimeSeconds: 0,
|
||||
totalRequests: 0,
|
||||
activeConnections: 0,
|
||||
avgLatency: 0,
|
||||
errorRate: 0,
|
||||
requestsPerMinute: 0,
|
||||
cacheHitRate: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const metrics = proxyMetrics.getMetrics();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
totalRequests: metrics.totalRequests,
|
||||
activeConnections: metrics.activeConnections,
|
||||
avgLatency: metrics.avgLatency,
|
||||
errorRate: metrics.errorRate,
|
||||
requestsPerMinute: metrics.requestsPerMinute,
|
||||
cacheHitRate: metrics.cacheHitRate,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get proxy metrics', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
|
||||
try {
|
||||
const proxyReplay = proxyAdvancedRegistry.proxyReplay;
|
||||
if (!proxyReplay) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: proxyReplay.getRecentRequests(20),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function replayRequest(
|
||||
requestId: string,
|
||||
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
|
||||
try {
|
||||
const proxyReplay = proxyAdvancedRegistry.proxyReplay;
|
||||
if (!proxyReplay) {
|
||||
return { success: false, error: 'Replay service not available' };
|
||||
}
|
||||
const result = await proxyReplay.replayRequest(requestId);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModelCapabilities(): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string; object: string; capabilities: { vision: boolean; streaming: boolean; jsonMode: boolean; audio: boolean; imageGeneration: boolean } }[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const capabilitiesMap: Record<string, { vision: boolean; streaming: boolean; jsonMode: boolean; audio: boolean; imageGeneration: boolean }> = {
|
||||
'gemini-3-flash': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'gemini-3.1-pro-low': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'gemini-3.1-pro-high': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'claude-sonnet-4-6-thinking': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
'claude-opus-4-6-thinking': { vision: true, streaming: true, jsonMode: true, audio: false, imageGeneration: false },
|
||||
};
|
||||
|
||||
const models = [
|
||||
{ id: 'gemini-3-flash', object: 'model' },
|
||||
{ id: 'gemini-3.1-pro-low', object: 'model' },
|
||||
{ id: 'gemini-3.1-pro-high', object: 'model' },
|
||||
{ id: 'claude-sonnet-4-6-thinking', object: 'model' },
|
||||
{ id: 'claude-opus-4-6-thinking', object: 'model' },
|
||||
];
|
||||
|
||||
const data = models.map((m) => {
|
||||
const baseId = Object.keys(capabilitiesMap).find((key) => m.id.includes(key)) ?? 'gemini-3-flash';
|
||||
return {
|
||||
id: m.id,
|
||||
object: m.object,
|
||||
capabilities: capabilitiesMap[baseId] ?? capabilitiesMap['gemini-3-flash'],
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function mapIdeConfigResult(
|
||||
result: IdeConfigResult,
|
||||
): {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
proxySetting: string;
|
||||
apiKeySetting: string;
|
||||
instructions: string;
|
||||
} {
|
||||
switch (result.ide) {
|
||||
case 'vscode':
|
||||
return {
|
||||
name: 'VS Code',
|
||||
settingsPath: 'settings.json',
|
||||
proxySetting: 'openai.url',
|
||||
apiKeySetting: 'openai.apiKey',
|
||||
instructions: 'Add these settings to your VS Code settings.json',
|
||||
};
|
||||
case 'cursor':
|
||||
return {
|
||||
name: 'Cursor',
|
||||
settingsPath: 'settings.json',
|
||||
proxySetting: 'OPENAI_BASE_URL',
|
||||
apiKeySetting: 'OPENAI_API_KEY',
|
||||
instructions: 'Set these environment variables for Cursor',
|
||||
};
|
||||
case 'claude-code':
|
||||
return {
|
||||
name: 'Claude Code',
|
||||
settingsPath: 'Shell environment',
|
||||
proxySetting: 'ANTHROPIC_BASE_URL',
|
||||
apiKeySetting: 'ANTHROPIC_API_KEY',
|
||||
instructions: 'Run these export commands before starting Claude Code',
|
||||
};
|
||||
case 'jetbrains':
|
||||
return {
|
||||
name: 'JetBrains',
|
||||
settingsPath: 'IDE Settings',
|
||||
proxySetting: 'Base URL',
|
||||
apiKeySetting: 'API Key',
|
||||
instructions: String(result.content),
|
||||
};
|
||||
case 'opencide':
|
||||
return {
|
||||
name: 'Opencide',
|
||||
settingsPath: '.env',
|
||||
proxySetting: 'OPENAI_BASE_URL',
|
||||
apiKeySetting: 'OPENAI_API_KEY',
|
||||
instructions: 'Add these lines to your .env file',
|
||||
};
|
||||
default: {
|
||||
const exhaustiveCheck: never = result.ide;
|
||||
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateIdeConfig(
|
||||
ide: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
proxySetting: string;
|
||||
apiKeySetting: string;
|
||||
instructions: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyIdeConfig = proxyAdvancedRegistry.proxyIdeConfig;
|
||||
if (!proxyIdeConfig) {
|
||||
return { success: false, error: 'IDE config service not available' };
|
||||
}
|
||||
|
||||
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
|
||||
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
|
||||
if (!supportedIdes.includes(normalizedIde)) {
|
||||
return { success: false, error: `Unsupported IDE: ${ide}` };
|
||||
}
|
||||
|
||||
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde);
|
||||
if (!result) {
|
||||
return { success: false, error: 'Failed to generate IDE config: server not configured' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: mapIdeConfigResult(result),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import {
|
||||
getRecentTrafficLogs,
|
||||
getTrafficLogStats,
|
||||
exportTrafficLogs,
|
||||
getParityCounters,
|
||||
getCircuitBreakerStatus,
|
||||
getProxyMetrics,
|
||||
getRecentRequests,
|
||||
generateIdeConfig,
|
||||
replayRequest,
|
||||
getModelCapabilities,
|
||||
} from './handler';
|
||||
|
||||
const TrafficLogEntrySchema = z.object({
|
||||
timestamp: z.string(),
|
||||
direction: z.string(),
|
||||
requestId: z.string(),
|
||||
endpoint: z.string(),
|
||||
method: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
headers: z.record(z.string(), z.unknown()).optional(),
|
||||
body: z.unknown().optional(),
|
||||
chunk: z.unknown().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
error: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
stack: z.string().optional(),
|
||||
status: z.number().optional(),
|
||||
body: z.unknown().optional(),
|
||||
})
|
||||
.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const TrafficLogsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.array(TrafficLogEntrySchema).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const TrafficLogStatsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
byDirection: z.record(z.string(), z.number()),
|
||||
byEndpoint: z.record(z.string(), z.number()),
|
||||
total: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ExportTrafficLogsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ParityCountersResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
totalRequests: z.number(),
|
||||
matchedRequests: z.number(),
|
||||
parityViolations: z.number(),
|
||||
lastUpdated: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const CircuitBreakerStatusResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
states: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
state: z.string(),
|
||||
failures: z.number(),
|
||||
lastFailure: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
globalEnabled: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ProxyMetricsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
uptimeSeconds: z.number(),
|
||||
totalRequests: z.number(),
|
||||
activeConnections: z.number(),
|
||||
avgLatency: z.number(),
|
||||
errorRate: z.number(),
|
||||
requestsPerMinute: z.number(),
|
||||
cacheHitRate: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const RecentRequestsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.array(z.unknown()).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ReplayRequestResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
original: z.unknown(),
|
||||
newResponse: z.unknown(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const ModelCapabilitiesResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
object: z.string(),
|
||||
capabilities: z.object({
|
||||
vision: z.boolean(),
|
||||
streaming: z.boolean(),
|
||||
jsonMode: z.boolean(),
|
||||
audio: z.boolean(),
|
||||
imageGeneration: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const IdeConfigResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
settingsPath: z.string(),
|
||||
proxySetting: z.string(),
|
||||
apiKeySetting: z.string(),
|
||||
instructions: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const proxyAdvancedRouter = os.router({
|
||||
getRecentTrafficLogs: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().int().min(1).max(1000).optional(),
|
||||
direction: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.output(TrafficLogsResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return getRecentTrafficLogs(input.limit, input.direction);
|
||||
}),
|
||||
|
||||
getTrafficLogStats: os.output(TrafficLogStatsResponseSchema).handler(async () => {
|
||||
return getTrafficLogStats();
|
||||
}),
|
||||
|
||||
exportTrafficLogs: os
|
||||
.input(z.object({ format: z.enum(['json', 'csv']) }))
|
||||
.output(ExportTrafficLogsResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return exportTrafficLogs(input.format);
|
||||
}),
|
||||
|
||||
getParityCounters: os.output(ParityCountersResponseSchema).handler(async () => {
|
||||
return getParityCounters();
|
||||
}),
|
||||
|
||||
getCircuitBreakerStatus: os.output(CircuitBreakerStatusResponseSchema).handler(async () => {
|
||||
return getCircuitBreakerStatus();
|
||||
}),
|
||||
|
||||
getProxyMetrics: os.output(ProxyMetricsResponseSchema).handler(async () => {
|
||||
return getProxyMetrics();
|
||||
}),
|
||||
|
||||
getRecentRequests: os.output(RecentRequestsResponseSchema).handler(async () => {
|
||||
return getRecentRequests();
|
||||
}),
|
||||
|
||||
generateIdeConfig: os
|
||||
.input(z.object({ ide: z.string() }))
|
||||
.output(IdeConfigResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return generateIdeConfig(input.ide);
|
||||
}),
|
||||
|
||||
replayRequest: os
|
||||
.input(z.object({ requestId: z.string() }))
|
||||
.output(ReplayRequestResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return replayRequest(input.requestId);
|
||||
}),
|
||||
|
||||
getModelCapabilities: os.output(ModelCapabilitiesResponseSchema).handler(async () => {
|
||||
return getModelCapabilities();
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Service registry for proxy-advanced IPC handlers to access
|
||||
* NestJS service instances without DI coupling.
|
||||
*
|
||||
* Services auto-register on construction.
|
||||
*/
|
||||
import type { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
|
||||
import type { ProxyMetricsService } from '../../server/modules/proxy/proxy-metrics.service';
|
||||
import type { ProxyReplayService } from '../../server/modules/proxy/proxy-replay.service';
|
||||
import type { ProxyIdeConfigService } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
|
||||
interface ProxyAdvancedRegistry {
|
||||
tokenManager: TokenManagerService | null;
|
||||
proxyMetrics: ProxyMetricsService | null;
|
||||
proxyReplay: ProxyReplayService | null;
|
||||
proxyIdeConfig: ProxyIdeConfigService | null;
|
||||
}
|
||||
|
||||
export const proxyAdvancedRegistry: ProxyAdvancedRegistry = {
|
||||
tokenManager: null,
|
||||
proxyMetrics: null,
|
||||
proxyReplay: null,
|
||||
proxyIdeConfig: null,
|
||||
};
|
||||
|
||||
export function registerProxyAdvancedService<K extends keyof ProxyAdvancedRegistry>(
|
||||
key: K,
|
||||
instance: ProxyAdvancedRegistry[K],
|
||||
): void {
|
||||
proxyAdvancedRegistry[key] = instance;
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import { accountRouter } from './account/router';
|
||||
import { cloudRouter } from './cloud/router';
|
||||
import { configRouter } from './config/router';
|
||||
import { gatewayRouter } from './gateway/router';
|
||||
import { usageRouter } from './usage/router';
|
||||
import { proxyAdvancedRouter } from './proxy-advanced/router';
|
||||
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { isProcessRunning, closeAntigravity, startAntigravity } from './process/handler';
|
||||
import { isProcessRunning, closeGeminiNexus, startGeminiNexus } from './process/handler';
|
||||
import { systemHandler } from './system/handler';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
@@ -41,11 +43,11 @@ export const router = os.use(logMiddleware).router({
|
||||
isProcessRunning: os.output(z.boolean()).handler(async () => {
|
||||
return await isProcessRunning();
|
||||
}),
|
||||
closeAntigravity: os.output(z.void()).handler(async () => {
|
||||
await closeAntigravity();
|
||||
closeGeminiNexus: os.output(z.void()).handler(async () => {
|
||||
await closeGeminiNexus();
|
||||
}),
|
||||
startAntigravity: os.output(z.void()).handler(async () => {
|
||||
await startAntigravity();
|
||||
startGeminiNexus: os.output(z.void()).handler(async () => {
|
||||
await startGeminiNexus();
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -53,5 +55,7 @@ export const router = os.use(logMiddleware).router({
|
||||
cloud: cloudRouter,
|
||||
config: configRouter,
|
||||
gateway: gatewayRouter,
|
||||
usage: usageRouter,
|
||||
proxyAdvanced: proxyAdvancedRouter,
|
||||
system: systemHandler,
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type DeviceProfile } from '../types/account';
|
||||
import { logger } from '../utils/logger';
|
||||
import { closeAntigravity, startAntigravity, _waitForProcessExit } from './process/handler';
|
||||
import { closeGeminiNexus, startGeminiNexus, _waitForProcessExit } from './process/handler';
|
||||
import { applyDeviceProfile } from './device/handler';
|
||||
import {
|
||||
type SwitchFailureReason,
|
||||
@@ -55,7 +55,7 @@ export async function executeSwitchFlow(options: SwitchFlowOptions): Promise<voi
|
||||
|
||||
let stage = 'close';
|
||||
try {
|
||||
await closeAntigravity();
|
||||
await closeGeminiNexus();
|
||||
try {
|
||||
await _waitForProcessExit(processExitTimeoutMs);
|
||||
} catch (error) {
|
||||
@@ -78,7 +78,7 @@ export async function executeSwitchFlow(options: SwitchFlowOptions): Promise<voi
|
||||
stage = 'switch';
|
||||
await performSwitch();
|
||||
stage = 'start';
|
||||
await startAntigravity();
|
||||
await startGeminiNexus();
|
||||
recordSwitchSuccess(scope);
|
||||
} catch (error) {
|
||||
const reason = toSwitchFailureReason(stage, error);
|
||||
|
||||
@@ -69,7 +69,7 @@ export function initTray(mainWindow: BrowserWindow) {
|
||||
}
|
||||
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('Antigravity Manager');
|
||||
tray.setToolTip('Gemini Nexus');
|
||||
|
||||
tray.on('double-click', () => {
|
||||
if (globalMainWindow) {
|
||||
|
||||
@@ -25,18 +25,48 @@ const en: TrayTexts = {
|
||||
const zh: TrayTexts = {
|
||||
current: '当前账号',
|
||||
quota: '当前额度',
|
||||
switch_next: '切换下一个账号',
|
||||
switch_next: '切换到下一个账号',
|
||||
refresh_current: '刷新当前额度',
|
||||
show_window: '显示主窗口',
|
||||
quit: '退出程序',
|
||||
quit: '退出应用',
|
||||
no_account: '无账号',
|
||||
unknown_quota: '未知',
|
||||
forbidden: '账号无权限',
|
||||
forbidden: '账号已被禁用',
|
||||
};
|
||||
|
||||
const ru: TrayTexts = {
|
||||
current: 'Текущий',
|
||||
quota: 'Квота',
|
||||
switch_next: 'Переключить на следующий аккаунт',
|
||||
refresh_current: 'Обновить текущую квоту',
|
||||
show_window: 'Показать главное окно',
|
||||
quit: 'Выйти из приложения',
|
||||
no_account: 'Нет аккаунта',
|
||||
unknown_quota: 'Неизвестно',
|
||||
forbidden: 'Аккаунт заблокирован',
|
||||
};
|
||||
|
||||
const vi: TrayTexts = {
|
||||
current: 'Hiện tại',
|
||||
quota: 'Quota',
|
||||
switch_next: 'Chuyển sang tài khoản tiếp theo',
|
||||
refresh_current: 'Làm mới quota hiện tại',
|
||||
show_window: 'Hiện cửa sổ chính',
|
||||
quit: 'Thoát ứng dụng',
|
||||
no_account: 'Không có tài khoản',
|
||||
unknown_quota: 'Không rõ',
|
||||
forbidden: 'Tài khoản bị cấm',
|
||||
};
|
||||
|
||||
export function getTrayTexts(lang: string = 'en'): TrayTexts {
|
||||
if (lang.startsWith('zh')) {
|
||||
return zh;
|
||||
}
|
||||
if (lang.startsWith('ru')) {
|
||||
return ru;
|
||||
}
|
||||
if (lang.startsWith('vi')) {
|
||||
return vi;
|
||||
}
|
||||
return en;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { TokenUsageRepo } from '../database/usageHandler';
|
||||
import {
|
||||
HourlyUsageSchema,
|
||||
DailyUsageSchema,
|
||||
WeeklyUsageSchema,
|
||||
MonthlyUsageSchema,
|
||||
ModelUsageSchema,
|
||||
} from '../../types/db';
|
||||
|
||||
const RecordUsageInputSchema = z.object({
|
||||
accountId: z.string(),
|
||||
model: z.string(),
|
||||
promptTokens: z.number().int().min(0),
|
||||
completionTokens: z.number().int().min(0),
|
||||
totalTokens: z.number().int().min(0),
|
||||
timestamp: z.number().int(),
|
||||
requestType: z.string().optional(),
|
||||
});
|
||||
|
||||
const UsageQueryInputSchema = z.object({
|
||||
accountId: z.string().optional(),
|
||||
start: z.number().int().optional(),
|
||||
end: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export const usageRouter = os.router({
|
||||
recordUsage: os
|
||||
.input(RecordUsageInputSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
TokenUsageRepo.recordUsage(input);
|
||||
}),
|
||||
|
||||
getUsageByHour: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(HourlyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByHour(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByDay: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(DailyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByDay(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByWeek: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(WeeklyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByWeek(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByMonth: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(MonthlyUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByMonth(input.accountId, input.start, input.end);
|
||||
}),
|
||||
|
||||
getUsageByModel: os
|
||||
.input(UsageQueryInputSchema)
|
||||
.output(z.array(ModelUsageSchema))
|
||||
.handler(async ({ input }) => {
|
||||
return TokenUsageRepo.getUsageByModel(input.accountId, input.start, input.end);
|
||||
}),
|
||||
});
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Network,
|
||||
Rocket,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -41,10 +41,15 @@ export const MainLayout: React.FC = () => {
|
||||
icon: LayoutDashboard,
|
||||
label: t('nav.accounts'),
|
||||
},
|
||||
{
|
||||
to: '/usage',
|
||||
icon: BarChart3,
|
||||
label: t('nav.usage'),
|
||||
},
|
||||
{
|
||||
to: '/proxy',
|
||||
icon: Network,
|
||||
label: t('nav.proxy', 'API Proxy'),
|
||||
label: t('nav.proxy'),
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
@@ -78,8 +83,12 @@ export const MainLayout: React.FC = () => {
|
||||
|
||||
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-4' : 'p-6')}>
|
||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<div className="bg-primary text-primary-foreground flex h-6 w-6 shrink-0 items-center justify-center rounded">
|
||||
<Rocket className="h-4 w-4" />
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-transparent">
|
||||
<img
|
||||
src="/src/assets/icon.png"
|
||||
alt="Gemini Nexus Logo"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -87,17 +96,9 @@ export const MainLayout: React.FC = () => {
|
||||
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
<h1 className="text-xl font-bold tracking-tight">Antigravity</h1>
|
||||
<h1 className="text-xl font-bold tracking-tight">Gemini Nexus</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-muted-foreground mt-1 overflow-hidden text-xs whitespace-nowrap transition-all duration-300',
|
||||
isCollapsed ? 'h-0 opacity-0' : 'h-auto opacity-100',
|
||||
)}
|
||||
>
|
||||
Manager
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2 px-2">
|
||||
|
||||