Compare commits

...
25 Commits
Author SHA1 Message Date
semantic-release-bot 5026be12d2 chore(release): 0.15.2
## [0.15.2](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.1...v0.15.2) (2026-05-02)

### 👷 Build System

* allow macos-alias and other native modules to build in pnpm v10 ([2e3a65b](https://github.com/evandrodevbr/GeminiNexus/commit/2e3a65b76b91012638c422f6223b79399775c635))
2026-05-02 04:57:50 +00:00
Gemini Nexus Bot c05daba98b Merge branch 'main' of https://github.com/evandrodevbr/GeminiNexus 2026-05-02 01:57:07 -03:00
Gemini Nexus Bot 2e3a65b76b build: allow macos-alias and other native modules to build in pnpm v10
- Fixes MakerDMG failure (Cannot find module '../build/Release/volume.node')
- Add macos-alias, better-sqlite3, keytar, and electron to trustedDependencies and pnpm.onlyBuiltDependencies
- Also updated pnpm-workspace.yaml allowBuilds directive
2026-05-02 01:48:15 -03:00
semantic-release-bot 62634b5511 chore(release): 0.15.1
## [0.15.1](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.0...v0.15.1) (2026-05-02)

### ✅ Tests

* add universal electron mock to fix headless CI test runs ([1d35801](https://github.com/evandrodevbr/GeminiNexus/commit/1d35801980e3e54cec1d3e52ed75850eb7eec350))
* fix test environments failing due to missing IS_DEV_ENVIRONMENT mock export ([269ad18](https://github.com/evandrodevbr/GeminiNexus/commit/269ad18ed6c09585e881d0d916558b1bcc7ad940))
* update proxy-advanced-handler tests to reflect optional service registration ([f4daaa8](https://github.com/evandrodevbr/GeminiNexus/commit/f4daaa8ca13bc75c7e5090905176c06b61ecfedc))

### 👷 Build System

* replace MakerSquirrel with MakerZIP for Windows ([27223f5](https://github.com/evandrodevbr/GeminiNexus/commit/27223f587d7facf25beef77aa6984cd248f2703d))
2026-05-02 04:46:37 +00:00
Gemini Nexus Bot 27223f587d build: replace MakerSquirrel with MakerZIP for Windows
- Remove @electron-forge/maker-squirrel dependency
- Squirrel.Windows fails on modern Windows GitHub Actions runners with Win32Exception during zip creation
- We already use MakerWix which produces a much better .msi installer for Windows users
- Add MakerZIP support for win32 to provide a portable alternative alongside the MSI
2026-05-02 01:42:46 -03:00
Gemini Nexus Bot 1d35801980 test: add universal electron mock to fix headless CI test runs
Vitest workers throw 'Electron failed to install correctly' when backend unit tests indirectly import electron via path/util modules. This mock provides stubs for ipc, app, and BrowserWindow interfaces so happy-dom environments don't crash expecting native binaries.
2026-05-02 01:27:57 -03:00
Gemini Nexus Bot 269ad18ed6 test: fix test environments failing due to missing IS_DEV_ENVIRONMENT mock export 2026-05-02 01:18:12 -03:00
Gemini Nexus Bot 9b9b977ae6 Merge branch 'main' of https://github.com/evandrodevbr/GeminiNexus 2026-05-02 01:09:55 -03:00
Gemini Nexus Bot f4daaa8ca1 test: update proxy-advanced-handler tests to reflect optional service registration
- Mock service-registry correctly exposing getServiceOptional
- Update tests to expect successful fallback resolutions when services are not yet registered
- Remove obsolete error test that is now correctly handled via ConfigManager fallback
2026-05-02 01:05:06 -03:00
semantic-release-bot e221ad14cd chore(release): 0.15.0
## [0.15.0](https://github.com/evandrodevbr/GeminiNexus/compare/v0.14.0...v0.15.0) (2026-05-02)

### ✨ Features

* bump version to 0.14.0 ([a905877](https://github.com/evandrodevbr/GeminiNexus/commit/a905877c76869a9b31b5b079d7afd86e4c905b59))
* **core:** isolate dev and production environments ([5c44a62](https://github.com/evandrodevbr/GeminiNexus/commit/5c44a622a8b4fa81246d4ca77073be50b4ddc1c9))
* **proxy:** add OpenCode IDE config and fix service availability ([713c449](https://github.com/evandrodevbr/GeminiNexus/commit/713c4497c623b0519847ed2afcc7da04997dae55))
* **proxy:** enrich traffic logs with model and token metadata ([641c739](https://github.com/evandrodevbr/GeminiNexus/commit/641c739b64150d1df77fd1a81a028b8cb4a4884d))
* **ui:** replace sidebar with compact top header navigation ([7d2977b](https://github.com/evandrodevbr/GeminiNexus/commit/7d2977b665a1a2fb0d5d8844a8e519471cdef8d8))
* **usage:** add cost reporting with custom model pricing and OpenRouter ([bc69a2e](https://github.com/evandrodevbr/GeminiNexus/commit/bc69a2e8b52e5019c45faae12771af7e40d3f26b))
* **usage:** move Monitor to Usage tab with per-request token tracking ([99e458f](https://github.com/evandrodevbr/GeminiNexus/commit/99e458f69bd5564d27f94c72c719ba49efd72abe))
* **usage:** redesign Usage Analytics with accurate token averages ([20785a8](https://github.com/evandrodevbr/GeminiNexus/commit/20785a8414bb0e364085a4a64f6a958c178d1df8))

### 🐛 Bug Fixes

* **ci:** remove explicit pnpm version from workflows ([080bf30](https://github.com/evandrodevbr/GeminiNexus/commit/080bf30e38379db5cb3c8d124f76a39aef897ccc))
* **ci:** resolve release pipeline conflicts and clean repo structure ([71bf812](https://github.com/evandrodevbr/GeminiNexus/commit/71bf8122d1a3d5bd74e3450f61d24747b50ed9d7))
* **ci:** use Node 22 for semantic-release (requires ^22.14.0) ([268428a](https://github.com/evandrodevbr/GeminiNexus/commit/268428a1193fd0bfd0559472fd50b3293d9c8ccb))
* install notice showing in Chinese and not recognizing MSI install path ([0753bbd](https://github.com/evandrodevbr/GeminiNexus/commit/0753bbdce63cffcd40fa13643e1082a67335c9c4))

### ♻️ Code Refactoring

* **accounts:** decompose CloudAccountList into focused components ([bf5a42c](https://github.com/evandrodevbr/GeminiNexus/commit/bf5a42c03e706d19033ceff332ee3609afded7ed))
2026-05-02 03:56:01 +00:00
Gemini Nexus Bot 268428a119 fix(ci): use Node 22 for semantic-release (requires ^22.14.0) 2026-05-02 00:53:33 -03:00
Gemini Nexus Bot 080bf30e38 fix(ci): remove explicit pnpm version from workflows
pnpm/action-setup@v4 reads the version from package.json packageManager
field automatically. Having both causes ERR_PNPM_BAD_PM_VERSION.
2026-05-02 00:50:12 -03:00
Gemini Nexus Bot c134a072df Merge branch 'main' of https://github.com/evandrodevbr/GeminiNexus 2026-05-02 00:37:01 -03:00
Gemini Nexus Bot a905877c76 feat: bump version to 0.14.0
Includes all changes from this release cycle:
- Top header navigation (sidebar removed)
- Accounts screen decomposition and visual redesign
- Dev/prod environment isolation
- Usage Analytics with accurate averages, cost reporting, OpenRouter pricing
- Monitor moved to Usage with per-request token/cost tracking
- Clickable traffic logs with expandable JSON body viewer
- OpenCode IDE config in Quick Setup
- API Proxy reorganization (flattened tabs)
- CI/CD pipeline fixes (release config, Node version, pnpm alignment)
- Repository cleanup (~80MB of binaries removed)
2026-05-02 00:32:21 -03:00
Gemini Nexus Bot 3752dfda91 chore: remove ~80MB of obsolete files and binaries from tracking
- Remove WiX Toolset binaries (tools/wix314/, wix314-binaries.zip) - CI installs via choco
- Remove one-shot scripts (fix-ts.js, patch-proxy.js, patch-settings.js, rename-deep.js)
- Remove AI session data (thoughts/, .impeccable.md, CLAUDE.md)
- Remove legacy launchers (agm.bat, run_cli.bat)
- Remove unused Nix config (flake.nix, flake.lock)
- Remove test-process.ts from root
- Update .gitignore to prevent re-tracking
2026-05-02 00:31:10 -03:00
Gemini Nexus Bot 71bf8122d1 fix(ci): resolve release pipeline conflicts and clean repo structure
- Delete conflicting .releaserc.json (keep release.config.cjs as single source)
- Fix repo URL from Draculabo/AntigravityManager to evandrodevbr/GeminiNexus
- Fix @semantic-release/git to commit pnpm-lock.yaml (not package-lock.json)
- Normalize Node.js version to 20 across all workflows
- Add packageManager field to package.json for pnpm consistency
- Update AGENTS.md: npm -> pnpm (18 references)
- Update .gitignore: add tools/, thoughts/, binaries, one-shot scripts
2026-05-02 00:30:44 -03:00
Gemini Nexus Bot 713c4497c6 feat(proxy): add OpenCode IDE config and fix service availability
- Add opencode to SupportedIde with JSON providers.json output
- Add defaultModel parameter for IDE config generation
- Add model selector dropdown in ToolsTab for IDE configurations
- Use getServiceOptional for graceful degradation when proxy is stopped
- Add fallback IDE config generation using ConfigManager when NestJS is offline
- Update IPC router schema to accept defaultModel parameter
2026-05-02 00:30:18 -03:00
Gemini Nexus Bot 641c739b64 feat(proxy): enrich traffic logs with model and token metadata
- Add model to logInbound metadata
- Add model, tokensPrompt, tokensCompletion, tokensTotal to logOutbound metadata
- Enrich SSE streaming with token metadata from final usage chunk
- Add metadata parameter to logOutbound method signature
- Data persisted in traffic_logs.metadata_json for per-request cost calculation
2026-05-02 00:29:55 -03:00
Gemini Nexus Bot 99e458f69b feat(usage): move Monitor to Usage tab with per-request token tracking
- Move MonitorTab and TrafficLogTable from proxy/advanced to usage/
- Add Analytics and Monitor tabs to Usage page
- Remove Monitor tab from API Proxy (flatten to 4 tabs)
- Add Model, Input/Output tokens, and Cost columns to traffic log table
- Make log rows clickable with expandable detail panel (JSON body viewer)
- Fix req/min calculation using actual request time span (MIN/MAX timestamps)
- Add word-wrap for large message bodies in expanded view
2026-05-02 00:29:32 -03:00
Gemini Nexus Bot bc69a2e8b5 feat(usage): add cost reporting with custom model pricing and OpenRouter
- Create CostReportCard with per-model input/output cost breakdown table
- Create ModelPricingDialog for custom pricing configuration
- Add 'Fetch from OpenRouter' button to auto-populate pricing from API
- Add custom_model_pricing to AppConfig (persisted in gui_config.json)
- Refactor calculateCost to accept optional custom pricing overrides
- Support Manual, OpenRouter, and Default pricing sources
2026-05-02 00:29:08 -03:00
Gemini Nexus Bot 20785a8414 feat(usage): redesign Usage Analytics with accurate token averages
- Fix token averaging: calculate over active hours/days only (not full period)
- Enable hourlyData query for all time ranges (not just 24h)
- Rename 'Usage Forecast' to 'Usage Averages' (real data, not projections)
- Add avg/request metric and active hours/days context
- Update terminology: Input/Output Tokens with info tooltips
- Add Estimated Cost stat card with per-model calculation
- Add quota warning alert banner when models exceed 80%
- Add ModelQuotaBar component with color thresholds
2026-05-02 00:28:45 -03:00
Gemini Nexus Bot 5c44a622a8 feat(core): isolate dev and production environments
- Add IS_DEV_ENVIRONMENT flag to paths.ts for automatic detection
- Dev uses 'Gemini Nexus Dev' app data dir and '.geminiNexus-agent-dev' agent dir
- Dev proxy defaults to port 8046, production stays on 8045
- Move app.setName() before packetLogPath to ensure correct userData path
- Config, DB, logs, and ORPC packets fully isolated between dev and prod
2026-05-02 00:28:22 -03:00
Gemini Nexus Bot bf5a42c03e refactor(accounts): decompose CloudAccountList into focused components
- Extract AccountsHeader, AccountsToolbar, AddAccountDialog, ExportImportDialogs, BatchActionBar, AccountsGrid
- Slim CloudAccountList from 1159 to 164 lines (orchestrator pattern)
- Improve visual hierarchy: stat cards with color-tinted borders, toolbar separators, better empty state
- All existing functionality preserved
2026-05-02 00:27:59 -03:00
Gemini Nexus Bot 7d2977b665 feat(ui): replace sidebar with compact top header navigation
- Rewrite MainLayout from vertical sidebar to horizontal header bar
- Brand left, nav tabs center, status indicator right
- Simplify StatusBar to single inline layout (remove collapsed variant)
- Widen content area from max-w-4xl to max-w-6xl
- Remove sidebar-collapsed localStorage persistence
2026-05-02 00:27:36 -03:00
Gemini Nexus Bot 0753bbdce6 fix: install notice showing in Chinese and not recognizing MSI install path
- Use app.getPreferredSystemLanguages() for reliable OS language detection
  instead of relying solely on app.getLocale() which returns Chromium's
  internal locale (was returning zh-CN on pt-BR systems)
- Support both Squirrel (%LOCALAPPDATA%) and WiX/MSI (Program Files)
  install directories when checking if app runs from expected location
- Extract matchLocaleTag helper for cleaner language resolution logic
- Add getAllExpectedInstallRoots to enumerate all valid install paths
2026-04-29 12:04:32 -03:00
363 changed files with 5091 additions and 51440 deletions

No files matched your search

-2
View File
@@ -18,8 +18,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
-2
View File
@@ -18,8 +18,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
+1 -3
View File
@@ -2,7 +2,7 @@ name: Publish Release
on:
workflow_run:
workflows: ["Release"]
workflows: ['Release']
types: [completed]
workflow_dispatch:
inputs:
@@ -92,8 +92,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install Node and NPM
uses: actions/setup-node@v4
-2
View File
@@ -23,8 +23,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
-4
View File
@@ -20,8 +20,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -44,8 +42,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
+29 -1
View File
@@ -125,4 +125,32 @@ cli/aliases.json
accounts_backup.json
# Bun lock file
bun.lock
bun.lock
# Build tools (installed via CI)
tools/
*.zip
# AI agent session data
thoughts/
.impeccable.md
CLAUDE.md
# One-shot scripts (already applied)
fix-ts.js
patch-proxy.js
patch-settings.js
rename-deep.js
test-process.ts
# Legacy CLI launchers
agm.bat
run_cli.bat
# Nix (not used)
flake.nix
flake.lock
# Planning/spec data
.planning/
openspec/
-29
View File
@@ -1,29 +0,0 @@
## 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
@@ -4,17 +4,20 @@
> 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)
@@ -24,6 +27,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- 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)
@@ -33,6 +37,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- Show exact curl equivalent for every request
### 4. Health Dashboard
- Real-time metrics cards:
- Requests/minute
- Average latency (p50, p95, p99)
@@ -43,6 +48,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- Color-coded status indicators
### 5. Model Availability Status
- Live grid showing which models are currently available
- Per-model indicators:
- Green: available
@@ -52,6 +58,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- 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
@@ -59,6 +66,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- 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
@@ -66,6 +74,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- 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
@@ -75,6 +84,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- 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
@@ -86,6 +96,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
---
## 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
@@ -4,6 +4,7 @@
> 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
@@ -14,29 +15,35 @@
## 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
@@ -44,6 +51,7 @@ Organize the page into tabs:
- 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
@@ -51,21 +59,25 @@ Organize the page into tabs:
- 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"
@@ -75,6 +87,7 @@ Organize the page into tabs:
---
## 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)
@@ -4,13 +4,16 @@
> 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
@@ -20,8 +23,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**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
@@ -30,8 +35,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**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
@@ -40,8 +47,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**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
@@ -50,8 +59,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**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
@@ -60,8 +71,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**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
@@ -69,8 +82,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**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
@@ -82,8 +97,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**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
@@ -94,8 +111,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**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
@@ -105,8 +124,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**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
@@ -117,7 +138,9 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
---
## 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)
@@ -4,7 +4,9 @@
> 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)
@@ -14,6 +16,7 @@
- Constants and helper functions
## Target Architecture
Split into **7+ files** following the project's conventions:
```
@@ -39,7 +42,9 @@ src/
## File Breakdown
### 1. `src/hooks/useProxyConfig.ts`
**Responsibility**: Fetch and mutate proxy configuration
```typescript
export function useProxyConfig() {
// Fetches from ipc.client.proxy.getConfig()
@@ -49,7 +54,9 @@ export function useProxyConfig() {
```
### 2. `src/hooks/useLocalIps.ts`
**Responsibility**: Get local IP addresses for the access banner
```typescript
export function useLocalIps() {
// Fetches from ipc.client.system.getLocalIps()
@@ -58,7 +65,9 @@ export function useLocalIps() {
```
### 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
@@ -67,7 +76,9 @@ export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
```
### 4. `src/components/proxy/ProxyServiceCard.tsx`
**Responsibility**: The main service control card
- Start/stop toggle
- Port input
- Protocol selector
@@ -75,26 +86,34 @@ export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
- 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;
@@ -102,7 +121,9 @@ 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;
@@ -141,6 +162,7 @@ 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`
@@ -149,6 +171,7 @@ export const Route = createFileRoute('/proxy')({ component: ProxyPage });
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
@@ -4,6 +4,7 @@
> Date: 2026-04-22
## Severity Legend
- 🔴 **Critical**: Bugs or crashes
- 🟡 **High**: Performance or reliability issues
- 🟢 **Medium**: Maintainability or best practice violations
@@ -11,21 +12,26 @@
## 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 {
@@ -38,18 +44,22 @@ async function copyToClipboard(text: string) {
```
### 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);
@@ -58,24 +68,29 @@ useEffect(() => {
```
### 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.
**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;
@@ -83,9 +98,11 @@ 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;
@@ -93,22 +110,27 @@ 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"
@@ -125,6 +147,7 @@ const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
---
## Testing Recommendations
- Add unit tests for `useProxyConfig` hook (mock IPC client)
- Test race conditions: rapid start/stop clicks
- Test clipboard failure path
@@ -6,41 +6,45 @@
## 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 |
| 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 |
| 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 |
| 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 |
| `src/ipc/manager.ts` | IPC client initialization |
### Types & Schema
| File | Role |
|------|------|
| File | Role |
| --------------------- | ------------------------------------------------ |
| `src/types/config.ts` | `ProxyConfig` Zod schema — reveals hidden fields |
| `src/types/proxy.ts` | Request/response types |
| `src/types/proxy.ts` | Request/response types |
## Hidden ProxyConfig Fields
@@ -124,6 +128,7 @@ interface ProxyConfig {
```
## 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
@@ -131,6 +136,7 @@ interface ProxyConfig {
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()`
-48
View File
@@ -1,48 +0,0 @@
{
"branches": ["main"],
"plugins": [
["@semantic-release/commit-analyzer", {
"preset": "conventionalcommits",
"releaseRules": [
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" },
{ "type": "refactor", "release": "patch" },
{ "type": "docs", "release": false },
{ "type": "style", "release": false },
{ "type": "chore", "release": false },
{ "type": "ci", "release": false },
{ "type": "test", "release": false },
{ "breaking": true, "release": "major" }
]
}],
["@semantic-release/release-notes-generator", {
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{ "type": "feat", "section": "🚀 Features" },
{ "type": "fix", "section": "🐛 Bug Fixes" },
{ "type": "perf", "section": "⚡ Performance" },
{ "type": "refactor", "section": "♻️ Refactors" },
{ "type": "docs", "section": "📚 Documentation", "hidden": true },
{ "type": "chore", "section": "🧰 Maintenance", "hidden": true },
{ "type": "ci", "section": "🔧 CI/CD", "hidden": true }
]
}
}],
["@semantic-release/changelog", {
"changelogFile": "CHANGELOG.md"
}],
["@semantic-release/npm", {
"npmPublish": false
}],
["@semantic-release/git", {
"assets": ["package.json", "pnpm-lock.yaml", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}],
["@semantic-release/github", {
"successComment": false,
"failTitle": false
}]
]
}
+20 -20
View File
@@ -12,7 +12,7 @@ This document is a user-level guide for AI assistants working in this repository
- **Runtime**: Node.js (Electron environment)
- **Node**: Recommended Node.js 20+
- **Package manager**: `npm` (this project includes `package-lock.json`; use npm only)
- **Package manager**: `pnpm` (this project includes `pnpm-lock.yaml`; use pnpm only)
- **Build tools**: Electron Forge + Vite
- **Terminal**: Windows (PowerShell) / VSCode MCP tools can be used safely
@@ -75,32 +75,32 @@ This document is a user-level guide for AI assistants working in this repository
## 📦 Common Scripts
Use `npm` for all commands:
Use `pnpm` for all commands:
- **Development (Dev)**:
- `npm start` - Start Electron dev environment (Electron Forge)
- `npm run lint` - Run ESLint checks
- `npm run format` - Run Prettier check
- `npm run format:write` - Auto-format with Prettier
- `npm run type-check` - Run TypeScript type check
- `pnpm start` - Start Electron dev environment (Electron Forge)
- `pnpm run lint` - Run ESLint checks
- `pnpm run format` - Run Prettier check
- `pnpm run format:write` - Auto-format with Prettier
- `pnpm run type-check` - Run TypeScript type check
- **Build**:
- `npm run package` - Package app (application bundle only)
- `npm run make` - Build and generate distributable installers
- `npm run publish` - Publish app
- `pnpm run package` - Package app (application bundle only)
- `pnpm run make` - Build and generate distributable installers
- `pnpm run publish` - Publish app
- **Testing**:
- `npm test` - Run Vitest tests
- `npm run test:watch` - Run Vitest in watch mode
- `npm run test:unit` - Same as above for unit-focused runs
- `npm run test:e2e` - Run Playwright E2E tests
- `npm run test:all` - Run all tests
- `pnpm test` - Run Vitest tests
- `pnpm run test:watch` - Run Vitest in watch mode
- `pnpm run test:unit` - Same as above for unit-focused runs
- `pnpm run test:e2e` - Run Playwright E2E tests
- `pnpm run test:all` - Run all tests
### Running a Single Test
- Unit test: `npm run test:unit path/to/test.test.ts`
- E2E test: `npm run test:e2e path/to/test.spec.ts`
- Type check: `npm run type-check`
- Unit test: `pnpm run test:unit path/to/test.test.ts`
- E2E test: `pnpm run test:e2e path/to/test.spec.ts`
- Type check: `pnpm run type-check`
## 🧪 Development Notes
@@ -171,7 +171,7 @@ export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
};
```
> Before commit, run `npm run lint` and `npm run format`.
> Before commit, run `pnpm run lint` and `pnpm run format`.
## 📝 Terminal Output and References
@@ -330,7 +330,7 @@ Summary / output:
Packages and scripts:
- `npm install` (or `npm i`)
- `pnpm install` (or `pnpm i`)
Shell:
+46
View File
@@ -1,3 +1,49 @@
<a name="readme-top"></a>
# Changelog
## [0.15.2](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.1...v0.15.2) (2026-05-02)
### 👷 Build System
* allow macos-alias and other native modules to build in pnpm v10 ([2e3a65b](https://github.com/evandrodevbr/GeminiNexus/commit/2e3a65b76b91012638c422f6223b79399775c635))
## [0.15.1](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.0...v0.15.1) (2026-05-02)
### ✅ Tests
* add universal electron mock to fix headless CI test runs ([1d35801](https://github.com/evandrodevbr/GeminiNexus/commit/1d35801980e3e54cec1d3e52ed75850eb7eec350))
* fix test environments failing due to missing IS_DEV_ENVIRONMENT mock export ([269ad18](https://github.com/evandrodevbr/GeminiNexus/commit/269ad18ed6c09585e881d0d916558b1bcc7ad940))
* update proxy-advanced-handler tests to reflect optional service registration ([f4daaa8](https://github.com/evandrodevbr/GeminiNexus/commit/f4daaa8ca13bc75c7e5090905176c06b61ecfedc))
### 👷 Build System
* replace MakerSquirrel with MakerZIP for Windows ([27223f5](https://github.com/evandrodevbr/GeminiNexus/commit/27223f587d7facf25beef77aa6984cd248f2703d))
## [0.15.0](https://github.com/evandrodevbr/GeminiNexus/compare/v0.14.0...v0.15.0) (2026-05-02)
### ✨ Features
* bump version to 0.14.0 ([a905877](https://github.com/evandrodevbr/GeminiNexus/commit/a905877c76869a9b31b5b079d7afd86e4c905b59))
* **core:** isolate dev and production environments ([5c44a62](https://github.com/evandrodevbr/GeminiNexus/commit/5c44a622a8b4fa81246d4ca77073be50b4ddc1c9))
* **proxy:** add OpenCode IDE config and fix service availability ([713c449](https://github.com/evandrodevbr/GeminiNexus/commit/713c4497c623b0519847ed2afcc7da04997dae55))
* **proxy:** enrich traffic logs with model and token metadata ([641c739](https://github.com/evandrodevbr/GeminiNexus/commit/641c739b64150d1df77fd1a81a028b8cb4a4884d))
* **ui:** replace sidebar with compact top header navigation ([7d2977b](https://github.com/evandrodevbr/GeminiNexus/commit/7d2977b665a1a2fb0d5d8844a8e519471cdef8d8))
* **usage:** add cost reporting with custom model pricing and OpenRouter ([bc69a2e](https://github.com/evandrodevbr/GeminiNexus/commit/bc69a2e8b52e5019c45faae12771af7e40d3f26b))
* **usage:** move Monitor to Usage tab with per-request token tracking ([99e458f](https://github.com/evandrodevbr/GeminiNexus/commit/99e458f69bd5564d27f94c72c719ba49efd72abe))
* **usage:** redesign Usage Analytics with accurate token averages ([20785a8](https://github.com/evandrodevbr/GeminiNexus/commit/20785a8414bb0e364085a4a64f6a958c178d1df8))
### 🐛 Bug Fixes
* **ci:** remove explicit pnpm version from workflows ([080bf30](https://github.com/evandrodevbr/GeminiNexus/commit/080bf30e38379db5cb3c8d124f76a39aef897ccc))
* **ci:** resolve release pipeline conflicts and clean repo structure ([71bf812](https://github.com/evandrodevbr/GeminiNexus/commit/71bf8122d1a3d5bd74e3450f61d24747b50ed9d7))
* **ci:** use Node 22 for semantic-release (requires ^22.14.0) ([268428a](https://github.com/evandrodevbr/GeminiNexus/commit/268428a1193fd0bfd0559472fd50b3293d9c8ccb))
* install notice showing in Chinese and not recognizing MSI install path ([0753bbd](https://github.com/evandrodevbr/GeminiNexus/commit/0753bbdce63cffcd40fa13643e1082a67335c9c4))
### ♻️ Code Refactoring
* **accounts:** decompose CloudAccountList into focused components ([bf5a42c](https://github.com/evandrodevbr/GeminiNexus/commit/bf5a42c03e706d19033ceff332ee3609afded7ed))
## [0.14.0](https://github.com/evandrodevbr/GeminiNexus/compare/v0.13.1...v0.14.0) (2026-04-29)
### 🚀 Features
-1
View File
@@ -1 +0,0 @@
Always refer to `@AGENTS.md`.
+22 -22
View File
@@ -63,17 +63,17 @@ npm start
### Available Scripts
| Command | Description |
|---------|-------------|
| `npm start` | Start the app in development mode (Electron + Vite HMR) |
| `npm run lint` | Run ESLint |
| `npm run format` | Check formatting with Prettier |
| `npm run format:write` | Auto-format code with Prettier |
| `npm run type-check` | Run TypeScript type check |
| `npm test` | Run unit tests with Vitest |
| `npm run test:e2e` | Run E2E tests with Playwright |
| `npm run test:all` | Run all tests (unit + E2E) |
| `npm run make` | Build production installers |
| Command | Description |
| ---------------------- | ------------------------------------------------------- |
| `npm start` | Start the app in development mode (Electron + Vite HMR) |
| `npm run lint` | Run ESLint |
| `npm run format` | Check formatting with Prettier |
| `npm run format:write` | Auto-format code with Prettier |
| `npm run type-check` | Run TypeScript type check |
| `npm test` | Run unit tests with Vitest |
| `npm run test:e2e` | Run E2E tests with Playwright |
| `npm run test:all` | Run all tests (unit + E2E) |
| `npm run make` | Build production installers |
### Project Structure
@@ -135,17 +135,17 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Commits
### Types
| Type | Description | Release |
|------|-------------|---------|
| `feat` | A new feature | **minor** |
| `fix` | A bug fix | **patch** |
| `perf` | Performance improvement | **patch** |
| `refactor` | Code change (no bug fix or feature) | **patch** |
| `docs` | Documentation only | no release |
| `style` | Formatting, whitespace | no release |
| `test` | Adding or fixing tests | no release |
| `chore` | Build process, tooling | no release |
| `ci` | CI/CD changes | no release |
| Type | Description | Release |
| ---------- | ----------------------------------- | ---------- |
| `feat` | A new feature | **minor** |
| `fix` | A bug fix | **patch** |
| `perf` | Performance improvement | **patch** |
| `refactor` | Code change (no bug fix or feature) | **patch** |
| `docs` | Documentation only | no release |
| `style` | Formatting, whitespace | no release |
| `test` | Adding or fixing tests | no release |
| `chore` | Build process, tooling | no release |
| `ci` | CI/CD changes | no release |
### Breaking Changes
+10 -10
View File
@@ -196,16 +196,16 @@ Model: gemini-3-flash
## 🛠️ Stack Tecnológico
| Categoría | Tecnologías |
|-----------|------------|
| **Core** | Electron, React 19, TypeScript |
| **Build** | Vite, Electron Forge |
| **Estilos** | Tailwind CSS v4, Radix UI, Lucide Icons |
| **Estado** | TanStack Query, TanStack Router |
| **Backend** | NestJS (gateway interno), ORPC (RPC type-safe) |
| **Base de Datos** | Better-SQLite3, Drizzle ORM |
| **Gráficos** | Nivo (Line, Bar, Pie) |
| **Testing** | Vitest, Playwright |
| Categoría | Tecnologías |
| ----------------- | ---------------------------------------------- |
| **Core** | Electron, React 19, TypeScript |
| **Build** | Vite, Electron Forge |
| **Estilos** | Tailwind CSS v4, Radix UI, Lucide Icons |
| **Estado** | TanStack Query, TanStack Router |
| **Backend** | NestJS (gateway interno), ORPC (RPC type-safe) |
| **Base de Datos** | Better-SQLite3, Drizzle ORM |
| **Gráficos** | Nivo (Line, Bar, Pie) |
| **Testing** | Vitest, Playwright |
---
+11 -12
View File
@@ -159,7 +159,6 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
## 📸 Screenshots
<p align="center">
<img src="docs/assets/main.png" alt="Main" width="80%" />
</p>
@@ -223,17 +222,17 @@ Model: gemini-3-flash (ou qualquer modelo mapeado)
## 🛠️ Stack Tecnológica
| Categoria | Tecnologias |
|-----------|------------|
| **Core** | [Electron](https://www.electronjs.org/), [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
| **Build** | [Vite](https://vitejs.dev/), [Electron Forge](https://www.electronforge.io/) |
| **Estilização** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) |
| **Estado** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
| **Backend** | [NestJS](https://nestjs.com/) (gateway interno), [ORPC](https://orpc.unnoq.com/) (RPC type-safe) |
| **Banco de Dados** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3), [Drizzle ORM](https://orm.drizzle.team/) |
| **Gráficos** | [Nivo](https://nivo.rocks/) (Line, Bar, Pie) |
| **i18n** | [react-i18next](https://react.i18next.com/) |
| **Testes** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
| Categoria | Tecnologias |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| **Core** | [Electron](https://www.electronjs.org/), [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
| **Build** | [Vite](https://vitejs.dev/), [Electron Forge](https://www.electronforge.io/) |
| **Estilização** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) |
| **Estado** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
| **Backend** | [NestJS](https://nestjs.com/) (gateway interno), [ORPC](https://orpc.unnoq.com/) (RPC type-safe) |
| **Banco de Dados** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3), [Drizzle ORM](https://orm.drizzle.team/) |
| **Gráficos** | [Nivo](https://nivo.rocks/) (Line, Bar, Pie) |
| **i18n** | [react-i18next](https://react.i18next.com/) |
| **Testes** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
---
+10 -10
View File
@@ -194,16 +194,16 @@ Model: gemini-3-flash
## 🛠️ 技术栈
| 分类 | 技术 |
|------|------|
| **核心** | Electron, React 19, TypeScript |
| **构建** | Vite, Electron Forge |
| **样式** | Tailwind CSS v4, Radix UI, Lucide Icons |
| **状态** | TanStack Query, TanStack Router |
| **后端** | NestJS (内部网关), ORPC (类型安全 RPC) |
| **数据库** | Better-SQLite3, Drizzle ORM |
| **图表** | Nivo (Line, Bar, Pie) |
| **测试** | Vitest, Playwright |
| 分类 | 技术 |
| ---------- | --------------------------------------- |
| **核心** | Electron, React 19, TypeScript |
| **构建** | Vite, Electron Forge |
| **样式** | Tailwind CSS v4, Radix UI, Lucide Icons |
| **状态** | TanStack Query, TanStack Router |
| **后端** | NestJS (内部网关), ORPC (类型安全 RPC) |
| **数据库** | Better-SQLite3, Drizzle ORM |
| **图表** | Nivo (Line, Bar, Pie) |
| **测试** | Vitest, Playwright |
---
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
-3
View File
@@ -1,3 +0,0 @@
@echo off
set PYTHONPATH=%~dp0
python -m cli.main %*
+48 -36
View File
@@ -2,16 +2,16 @@
## 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 |
| 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) |
| **Autor** | evandrodevbr / Draculabo |
| **Licença** | CC-BY-NC-SA-4.0 (apenas fins educacionais) |
---
@@ -20,6 +20,7 @@
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
@@ -27,6 +28,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
- **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
@@ -46,28 +48,32 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
## 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 | — |
| 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
@@ -77,45 +83,50 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
## 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) |
| 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
@@ -123,15 +134,15 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
## 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 |
| 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 |
---
@@ -140,6 +151,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
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**
-44
View File
@@ -1,44 +0,0 @@
/* eslint-disable @typescript-eslint/no-require-imports */
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.');
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1774106199,
"narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-121
View File
@@ -1,121 +0,0 @@
{
description = "Antigravity Manager";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = {
self,
nixpkgs,
}: let
supportedSystems = ["x86_64-linux" "aarch64-linux"];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
version = "0.10.0";
sources = {
"x86_64-linux" = {
url = "https://github.com/Draculabo/AntigravityManager/releases/download/v${version}/Antigravity.Manager_${version}_amd64.deb";
sha256 = "5fc08b4a9308c71fc00e44b95d4fb3178182aea8821708fa51fdf2bf5dbbb856";
};
"aarch64-linux" = {
url = "https://github.com/Draculabo/AntigravityManager/releases/download/v${version}/Antigravity.Manager_${version}_arm64.deb";
sha256 = "666c9659efa66bb6be6a8e7fb0c384ceb799a17245eab374bf9b3d7901c5c4cb";
};
};
mkPackage = pkgs: let
system = pkgs.stdenv.hostPlatform.system;
source = sources.${system} or (throw "Unsupported system: ${system}");
in
pkgs.stdenv.mkDerivation {
pname = "antigravity-manager";
inherit version;
src = pkgs.fetchurl {
inherit (source) url sha256;
};
nativeBuildInputs = with pkgs; [
autoPatchelfHook
makeWrapper
zstd
];
buildInputs = with pkgs; [
alsa-lib
at-spi2-atk
atk
cups
dbus
expat
glib
gtk3
libsecret
libdrm
libxkbcommon
mesa
nspr
nss
pango
systemd
libx11
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxcb
];
unpackPhase = ''
ar x $src
tar xf data.tar.* --no-same-permissions
'';
installPhase = ''
mkdir -p $out/bin $out/lib $out/share
cp -r usr/lib/antigravity-manager $out/lib/
cp -r usr/share/* $out/share/
ln -s $out/lib/antigravity-manager/antigravity-manager $out/bin/antigravity-manager
'';
postFixup = ''
# Wrap the executable so it finds Wayland/X11 and libsecret
wrapProgram $out/bin/antigravity-manager \
--add-flags "--enable-features=WaylandWindowDecorations" \
--add-flags "--ozone-platform-hint=auto" \
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath [pkgs.libsecret]}
if [ -e $out/share/applications/antigravity-manager.desktop ]; then
substituteInPlace $out/share/applications/antigravity-manager.desktop \
--replace "/usr/lib/antigravity-manager/antigravity-manager" "antigravity-manager"
fi
'';
meta = with pkgs.lib; {
description = "Professional multi-account manager for Google Gemini & Claude AI";
homepage = "https://github.com/Draculabo/AntigravityManager";
license = licenses.cc-by-nc-sa-40;
maintainers = [];
platforms = ["x86_64-linux" "aarch64-linux"];
mainProgram = "antigravity-manager";
};
};
in {
overlays.default = final: prev: {
antigravity-manager = mkPackage final;
};
packages = forAllSystems (system: let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
in {
default = mkPackage pkgs;
});
};
}
+1 -6
View File
@@ -10,7 +10,6 @@ import type {
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerDMG } from '@electron-forge/maker-dmg';
import { MakerRpm } from '@electron-forge/maker-rpm';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerWix } from '@electron-forge/maker-wix';
import { MakerZIP } from '@electron-forge/maker-zip';
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
@@ -441,10 +440,6 @@ const config: ForgeConfig = {
},
},
makers: [
new MakerSquirrel({
setupIcon: 'images/icon.ico',
iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
}),
...(process.platform === 'win32' && process.arch === 'x64' && isWixAvailable()
? [
new MakerWix({
@@ -463,7 +458,7 @@ const config: ForgeConfig = {
},
['darwin'],
),
new MakerZIP({}, ['darwin']),
new MakerZIP({}, ['darwin', 'win32']),
appImageMaker,
new MakerRpm({}),
new MakerDeb({}),
+22 -3
View File
@@ -1,10 +1,11 @@
{
"name": "gemini-nexus",
"productName": "Gemini Nexus",
"version": "0.14.0",
"version": "0.15.2",
"description": "Gemini Nexus - Electron App",
"main": ".vite/build/main.js",
"private": true,
"packageManager": "[email protected]",
"scripts": {
"start": "cross-env DEBUG=electron-forge:* electron-forge start",
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
@@ -31,7 +32,6 @@
"@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.9.0",
"@electron-forge/maker-zip": "^7.11.1",
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
@@ -170,9 +170,28 @@
"@nestjs/core",
"@sentry/cli",
"@swc/core",
"protobufjs"
"protobufjs",
"macos-alias",
"better-sqlite3",
"keytar",
"electron",
"electron-winstaller",
"esbuild"
],
"pnpm": {
"onlyBuiltDependencies": [
"@bitdisaster/exe-icon-extractor",
"@nestjs/core",
"@sentry/cli",
"@swc/core",
"protobufjs",
"macos-alias",
"better-sqlite3",
"keytar",
"electron",
"electron-winstaller",
"esbuild"
],
"overrides": {
"node-abi": "^4.28.0",
"fastify": "^5.8.5",
-8
View File
@@ -1,8 +0,0 @@
const fs = require('fs');
let file = fs.readFileSync('src/routes/proxy.tsx', 'utf8');
// The Proxy layout also uses heavy cards
file = file.replace(/<Card>/g, '<Card className="bg-background border border-white/5 shadow-none">');
fs.writeFileSync('src/routes/proxy.tsx', file);
-11
View File
@@ -1,11 +0,0 @@
const fs = require('fs');
let file = fs.readFileSync('src/routes/settings.tsx', 'utf8');
// The settings page uses standard cards that look bulky in minimal dark mode
file = file.replace(/<div className="flex flex-col gap-6">/g, '<div className="flex flex-col gap-2">');
file = file.replace(/<Card>/g, '<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">');
file = file.replace(/<CardHeader>/g, '<CardHeader className="px-0 pt-4">');
file = file.replace(/<CardContent>/g, '<CardContent className="px-0">');
fs.writeFileSync('src/routes/settings.tsx', file);
+7 -124
View File
@@ -227,9 +227,6 @@ importers:
'@electron-forge/maker-rpm':
specifier: ^7.11.1
version: 7.11.1
'@electron-forge/maker-squirrel':
specifier: ^7.11.1
version: 7.11.1
'@electron-forge/maker-wix':
specifier: ^7.9.0
version: 7.11.1
@@ -397,7 +394,7 @@ importers:
version: 25.0.3([email protected])
semantic-release-config-gitmoji:
specifier: ^1.5.3
version: 1.5.3(@octokit/core@7.0.6)([email protected])([email protected]([email protected]))
version: 1.5.3(@octokit/core@4.2.4)([email protected])([email protected]([email protected]))
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -663,10 +660,6 @@ packages:
resolution: {integrity: sha512-iEfJPRQQyaTqk2EbUfZgulChNWvxGXeYUH0xBX/r5cj1pL4vcJXt3jLMQBVn3mk/0Ytv9UWRs8R/XuNWX6sf2w==}
engines: {node: '>= 16.4.0'}
'@electron-forge/[email protected]':
resolution: {integrity: sha512-oSg7fgad6l+X0DjtRkSpMzB0AjzyDO4mb2gzM4kTodkP1ADeiMi08bxy0ZeCESqLm5+fG72cAPmEr3BAPvI1yw==}
engines: {node: '>= 16.4.0'}
'@electron-forge/[email protected]':
resolution: {integrity: sha512-yKsnfPQVBrjyjWndmSB0rqQSXAiIukwz5fuVyoCTnYpfJQojrEEMUN/eXSHA08d+rOPbB/hkMAR+QJcjJ8vO2Q==}
engines: {node: '>= 16.4.0'}
@@ -1160,183 +1153,155 @@ packages:
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/[email protected]':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/[email protected]':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
@@ -2710,42 +2675,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]':
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]':
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
@@ -2836,79 +2795,66 @@ packages:
resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/[email protected]':
resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/[email protected]':
resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
@@ -3180,42 +3126,36 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/[email protected]':
resolution: {integrity: sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/[email protected]':
resolution: {integrity: sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==}
engines: {node: '>=10'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@swc/[email protected]':
resolution: {integrity: sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==}
engines: {node: '>=10'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@swc/[email protected]':
resolution: {integrity: sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/[email protected]':
resolution: {integrity: sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/[email protected]':
resolution: {integrity: sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==}
@@ -3292,28 +3232,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
@@ -4961,10 +4897,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-1sZTssferjgDgaqRTc0ieP+ozzpOy7LQTPTtEW3yQFn4+ORdIAZWV5BthXPyHF7YqLvFJCUPhNhdAJQYlYUgiw==}
[email protected]:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
[email protected]:
resolution: {integrity: sha512-EYj1cm1nZoVHmIIx3o0aKt784lxdEpJnXbEnyypklUCnglqSb7ni+1xi1Vp/gtrGS/mzIxnWBT+x5fIfuDjhvA==}
engines: {node: '>=14.0.0'}
@@ -5433,10 +5365,6 @@ packages:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
[email protected]:
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
engines: {node: '>=6 <7 || >=8'}
[email protected]:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
engines: {node: '>=6 <7 || >=8'}
@@ -6296,28 +6224,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
[email protected]:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
[email protected]:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -8107,10 +8031,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
[email protected]:
resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}
engines: {node: '>=6.0.0'}
[email protected]:
resolution: {integrity: sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==}
engines: {node: '>=14.16'}
@@ -8874,10 +8794,10 @@ snapshots:
'@acemir/[email protected]': {}
'@achingbrain/[email protected](@octokit/core@7.0.6)([email protected]([email protected]))':
'@achingbrain/[email protected](@octokit/core@4.2.4)([email protected]([email protected]))':
dependencies:
'@octokit/plugin-retry': 3.0.9
'@octokit/plugin-throttling': 3.7.0(@octokit/core@7.0.6)
'@octokit/plugin-throttling': 3.7.0(@octokit/core@4.2.4)
'@octokit/rest': 19.0.13
'@semantic-release/error': 3.0.0
aggregate-error: 3.1.0
@@ -9269,17 +9189,6 @@ snapshots:
- bluebird
- supports-color
'@electron-forge/[email protected]':
dependencies:
'@electron-forge/maker-base': 7.11.1
'@electron-forge/shared-types': 7.11.1
fs-extra: 10.1.0
optionalDependencies:
electron-winstaller: 5.4.0
transitivePeerDependencies:
- bluebird
- supports-color
'@electron-forge/[email protected]':
dependencies:
'@electron-forge/core-utils': 7.11.1
@@ -10637,9 +10546,9 @@ snapshots:
'@octokit/types': 16.0.0
bottleneck: 2.19.5
'@octokit/[email protected](@octokit/core@7.0.6)':
'@octokit/[email protected](@octokit/core@4.2.4)':
dependencies:
'@octokit/core': 7.0.6
'@octokit/core': 4.2.4
'@octokit/types': 6.41.0
bottleneck: 2.19.5
@@ -14036,19 +13945,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@electron/asar': 3.4.1
debug: 4.4.3
fs-extra: 7.0.1
lodash: 4.18.1
temp: 0.9.4
optionalDependencies:
'@electron/windows-sign': 1.2.2
transitivePeerDependencies:
- supports-color
optional: true
[email protected]:
dependencies:
'@electron/windows-sign': 1.2.2
@@ -14730,13 +14626,6 @@ snapshots:
jsonfile: 6.2.1
universalify: 2.0.1
[email protected]:
dependencies:
graceful-fs: 4.2.11
jsonfile: 4.0.0
universalify: 0.1.2
optional: true
[email protected]:
dependencies:
graceful-fs: 4.2.11
@@ -16944,12 +16833,12 @@ snapshots:
[email protected]: {}
[email protected](@octokit/core@7.0.6)([email protected])([email protected]([email protected])):
[email protected](@octokit/core@4.2.4)([email protected])([email protected]([email protected])):
dependencies:
'@gitmoji/commit-types': 1.1.5
'@semantic-release/changelog': 6.0.3([email protected]([email protected]))
'@semantic-release/git': 10.0.1([email protected]([email protected]))
'@semantic-release/github': '@achingbrain/[email protected](@octokit/core@7.0.6)([email protected]([email protected]))'
'@semantic-release/github': '@achingbrain/[email protected](@octokit/core@4.2.4)([email protected]([email protected]))'
'@semrel-extra/npm': 1.2.2
'@types/semantic-release': 17.2.11
conventional-changelog-gitmoji-config: 1.5.2([email protected])
@@ -17472,12 +17361,6 @@ snapshots:
async-exit-hook: 2.0.1
fs-extra: 10.1.0
[email protected]:
dependencies:
mkdirp: 0.5.6
rimraf: 2.6.3
optional: true
[email protected]:
dependencies:
is-stream: 3.0.0
+1
View File
@@ -8,5 +8,6 @@ allowBuilds:
electron-winstaller: true
esbuild: true
keytar: true
macos-alias: true
protobufjs: true
sharp: true
+2 -2
View File
@@ -6,7 +6,7 @@
* instead of Gitmoji (since the project history doesn't use emojis).
*/
const REPO_URL = 'https://github.com/Draculabo/AntigravityManager';
const REPO_URL = 'https://github.com/evandrodevbr/GeminiNexus';
const GITHUB_BASE_URL = 'https://github.com';
const FULL_CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
@@ -244,7 +244,7 @@ const releaseConfig = {
[
'@semantic-release/git',
{
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
assets: ['CHANGELOG.md', 'package.json', 'pnpm-lock.yaml'],
message: 'chore(release): ${nextRelease.version}\n\n${nextRelease.notes}',
},
],
-118
View File
@@ -1,118 +0,0 @@
/* eslint-disable @typescript-eslint/no-require-imports */
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.');
-3
View File
@@ -1,3 +0,0 @@
@echo off
set PYTHONPATH=%~dp0
python -m cli.main %*
+75 -24
View File
@@ -41,7 +41,9 @@ function color(code, text) {
}
function header(text) {
console.log(`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`);
console.log(
`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`,
);
}
function success(text) {
@@ -145,10 +147,13 @@ function checkWindows() {
for (const vswhere of vswherePaths) {
try {
const output = execSync(`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`, {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
}).trim();
const output = execSync(
`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`,
{
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
},
).trim();
if (output) {
vsFound = true;
@@ -163,10 +168,9 @@ function checkWindows() {
// Check registry fallback
if (!vsFound) {
try {
execSync(
'reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0',
{ stdio: 'ignore' },
);
execSync('reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0', {
stdio: 'ignore',
});
vsFound = true;
success('Visual Studio 2022 found (registry)');
} catch {
@@ -198,7 +202,10 @@ function checkMacOS() {
const results = { ok: true, missing: [] };
try {
const xcodePath = execSync('xcode-select -p', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
const xcodePath = execSync('xcode-select -p', {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
}).trim();
if (xcodePath) {
success(`Xcode CLT found at: ${xcodePath}`);
}
@@ -323,14 +330,26 @@ async function installWindows() {
// Check if winget is available
if (!commandExists('winget')) {
console.log('');
console.log(color(RED, ' winget not found. Please install Visual Studio Build Tools manually:'));
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
console.log(
color(RED, ' winget not found. Please install Visual Studio Build Tools manually:'),
);
console.log(
color(
CYAN,
' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022',
),
);
console.log('');
console.log(' During installation, select:');
console.log(color(BOLD, ' "Desktop development with C++" workload'));
console.log('');
console.log(' Or install via PowerShell (as Administrator):');
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
console.log(
color(
DIM,
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
),
);
return false;
}
@@ -398,7 +417,9 @@ async function installLinux() {
}
const status = await runCommand('sudo', [
'apt-get', 'install', '-y',
'apt-get',
'install',
'-y',
'build-essential',
'python3',
'libsecret-1-dev',
@@ -421,7 +442,10 @@ async function installLinux() {
}
const status = await runCommand('sudo', [
'pacman', '-S', '--needed', '--noconfirm',
'pacman',
'-S',
'--needed',
'--noconfirm',
'base-devel',
'python',
'libsecret',
@@ -443,7 +467,9 @@ async function installLinux() {
}
const status = await runCommand('sudo', [
'dnf', 'install', '-y',
'dnf',
'install',
'-y',
'gcc-c++',
'make',
'python3',
@@ -471,7 +497,9 @@ async function installLinux() {
console.log(' - libsecret development headers (for keytar)');
console.log(' - pkg-config');
console.log('');
console.log(' Install them using your distribution package manager, then run npm install again.');
console.log(
' Install them using your distribution package manager, then run npm install again.',
);
return false;
}
@@ -520,7 +548,9 @@ async function main() {
// ── Missing tools ──
console.log('');
error('Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.');
error(
'Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.',
);
if (checkOnly) {
console.log('');
@@ -528,7 +558,9 @@ async function main() {
warn('Run "npm run setup" to install them automatically, or install manually:');
printManualInstructions(platform, results.missing);
console.log('');
dim('npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.');
dim(
'npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.',
);
// Don't exit with error - allow npm install to succeed (prebuilt binaries may work)
return;
}
@@ -572,13 +604,23 @@ function printManualInstructions(platform, missing) {
switch (platform) {
case 'win32':
console.log(' 1. Download Visual Studio 2022 Build Tools:');
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
console.log(
color(
CYAN,
' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022',
),
);
console.log('');
console.log(' 2. Run the installer and select:');
console.log(color(BOLD, ' "Desktop development with C++"'));
console.log('');
console.log(' 3. Or install via PowerShell (as Administrator):');
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
console.log(
color(
DIM,
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
),
);
console.log('');
console.log(' 4. Restart your terminal after installation.');
break;
@@ -594,13 +636,22 @@ function printManualInstructions(platform, missing) {
const distro = detectLinuxDistro();
if (distro === 'debian') {
console.log(' Run:');
console.log(color(DIM, ' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config'));
console.log(
color(
DIM,
' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config',
),
);
} else if (distro === 'arch') {
console.log(' Run:');
console.log(color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'));
console.log(
color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'),
);
} else if (distro === 'fedora') {
console.log(' Run:');
console.log(color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'));
console.log(
color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'),
);
} else {
console.log(' Install: C/C++ compiler, make, python3, libsecret-dev, pkg-config');
}
+4 -2
View File
@@ -114,8 +114,10 @@ export const AccountCard: React.FC<AccountCardProps> = ({
<Card
className={cn(
'relative overflow-hidden transition-all duration-200',
isCurrent ? 'border-primary/50 bg-primary/10' : 'hover:bg-accent/10 cursor-pointer border-white/5',
isCurrent
? 'border-primary/50 bg-primary/10'
: 'hover:bg-accent/10 cursor-pointer border-white/5',
(isSwitching || isDeleting) && 'pointer-events-none opacity-60',
)}
onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)}
+305 -257
View File
@@ -235,54 +235,51 @@ export function CloudAccountCard({
const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => {
if (models.length === 0) return null;
return (
<div key={title} className="space-y-1">
<div className="flex items-center gap-1.5 px-2 py-1">
<span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase">
{title}
</span>
<div className="h-[1px] flex-1 bg-white/[0.04]" />
</div>
{models.map(([modelName, info]) => (
<div
key={modelName}
className="group/item grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-[13px] transition-all hover:bg-white/[0.03]"
>
<span
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
title={modelName}
<div key={title} className="mt-1 space-y-1">
<h4 className="text-muted-foreground/60 mb-1.5 px-1 text-[10px] font-bold tracking-widest uppercase">
{title}
</h4>
<div className="flex flex-col gap-1">
{models.map(([modelName, info]) => (
<div
key={modelName}
className="group/item grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-1.5 py-1 text-[13px] transition-colors hover:bg-white/[0.03]"
>
{formatModelDisplayName(modelName)}
</span>
<div className="flex flex-col items-end gap-0.5">
<span
className="text-muted-foreground text-[9px] leading-none opacity-80"
title={formatResetTimeTitleText(info.resetTime)}
className="text-muted-foreground group-hover/item:text-foreground truncate font-medium transition-colors"
title={modelName}
>
{formatResetTimeLabelText(info.resetTime)}
{formatModelDisplayName(modelName)}
</span>
<div className="flex items-baseline gap-1">
<div className="flex items-center gap-2">
<span
className={`font-mono text-xs leading-none font-bold ${getQuotaTextColorClass(info.percentage)}`}
className="text-muted-foreground/70 min-w-[50px] text-right text-[9px]"
title={formatResetTimeTitleText(info.resetTime)}
>
{formatResetTimeLabelText(info.resetTime)}
</span>
<span
className={`w-9 text-right font-mono text-xs font-bold ${getQuotaTextColorClass(info.percentage)}`}
>
{info.percentage}%
</span>
<div className="bg-muted h-1 w-16 overflow-hidden rounded-full">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-white/[0.04]">
<div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
className={`h-full rounded-full transition-all duration-500 ease-out ${getQuotaBarColorClass(info.percentage)}`}
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
/>
</div>
</div>
</div>
</div>
))}
))}
</div>
</div>
);
};
const emptyQuotaState = (
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
<Box className="mb-2 h-8 w-8 opacity-20" />
<div className="text-muted-foreground flex flex-col items-center justify-center py-6">
<Box className="mb-2 h-6 w-6 opacity-20" />
<span className="text-xs">{t('cloud.card.noQuota')}</span>
</div>
);
@@ -290,18 +287,20 @@ export function CloudAccountCard({
const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null;
const providerGroupedQuotaSection =
providerStats && providerStats.visibleModels > 0 ? (
<>
<div className="flex items-center justify-between rounded-lg bg-white/[0.03] px-3 py-1.5 text-xs">
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border border-white/[0.04] bg-white/[0.02] px-3 py-2">
<span className="text-muted-foreground text-xs font-medium">
{t('settings.providerGroupings.overall')}
</span>
<div className="flex items-center gap-2">
<span
className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
className={`font-mono text-xs font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
>
{formatQuotaLabel(providerStats.overallPercentage)}
</span>
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-white/[0.04]">
<div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(providerStats.overallPercentage)}`}
className={`h-full rounded-full transition-all duration-500 ease-out ${getQuotaBarColorClass(providerStats.overallPercentage)}`}
style={{
width: `${clampQuotaPercentage(providerStats.overallPercentage)}%`,
}}
@@ -310,7 +309,7 @@ export function CloudAccountCard({
</div>
</div>
<div className="mt-3 space-y-2">
<div className="space-y-3">
{providerStats.providers.map((group) => (
<ProviderGroup
key={group.providerKey}
@@ -326,7 +325,7 @@ export function CloudAccountCard({
/>
))}
</div>
</>
</div>
) : (
emptyQuotaState
);
@@ -343,17 +342,17 @@ export function CloudAccountCard({
return (
<Card
className={`group flex h-full flex-col overflow-hidden rounded-xl border border-white/[0.06] bg-card transition-all duration-200 hover:border-white/[0.12] ${isSelected ? 'ring-accent/30 border-accent/40 ring-2' : ''}`}
className={`group bg-card relative flex h-full flex-col overflow-hidden rounded-xl border transition-all duration-300 hover:shadow-md ${isSelected ? 'border-primary/50 ring-primary/50 ring-1' : 'border-white/[0.06] hover:border-white/[0.15]'}`}
>
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
<CardHeader className="flex flex-row items-start gap-4 space-y-0 p-5 pb-4">
{onToggleSelection && (
<div
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} bg-background/90 rounded-full p-2 transition-opacity`}
className={`absolute top-3 left-3 z-10 transition-opacity duration-200 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => onToggleSelection(account.id, checked as boolean)}
className="h-5 w-5 border-2"
className="bg-background/80 data-[state=checked]:bg-primary h-4 w-4 backdrop-blur-sm"
/>
</div>
)}
@@ -362,188 +361,193 @@ export function CloudAccountCard({
<img
src={account.avatar_url}
alt={account.name || ''}
className="bg-muted h-10 w-10 rounded-full border"
className="h-11 w-11 shrink-0 rounded-full border border-white/[0.08] bg-white/[0.02] object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/[0.06] text-muted-foreground">
<div className="text-muted-foreground flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-lg font-medium">
{account.name?.[0]?.toUpperCase() || 'A'}
</div>
)}
<div className="flex-1 overflow-hidden">
<CardTitle className="truncate text-base font-semibold">
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden pt-0.5">
<CardTitle className="truncate text-base leading-tight font-semibold">
{account.name || t('cloud.card.unknown')}
</CardTitle>
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
<CardDescription className="text-muted-foreground/80 truncate text-xs font-medium">
{account.email}
</CardDescription>
{shouldShowAiCredits && aiCredits && (
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-blue-500">
<span>
{t('cloud.card.aiCreditsValue', {
amount: formatAiCreditsAmount(aiCredits.credits),
})}
</span>
{aiCredits.expiryDate && (
<span className="text-muted-foreground opacity-70">
·{' '}
{t('cloud.card.creditsExpiry', {
date: formatCreditsExpiry(aiCredits.expiryDate),
})}
</span>
)}
</div>
)}
</div>
{allModelEntries.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
{(() => {
const hiddenCount = allModelEntries.filter(
([modelName]) => config?.model_visibility?.[modelName] === false,
).length;
return hiddenCount > 0 ? (
<EyeOff className="text-muted-foreground h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
);
})()}
<span className="sr-only">{t('cloud.card.modelVisibility')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="end">
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
<div className="max-h-64 overflow-auto px-2 py-1">
{allModelEntries.map(([modelName]) => {
const isVisible = config?.model_visibility?.[modelName] !== false;
return (
<DropdownMenuItem
key={modelName}
onSelect={(e) => e.preventDefault()}
className="flex cursor-pointer items-center gap-2"
>
<Checkbox
checked={isVisible}
onCheckedChange={(checked) => {
if (config) {
const newVisibility = { ...config.model_visibility };
newVisibility[modelName] = checked as boolean;
saveConfig({ ...config, model_visibility: newVisibility });
}
}}
/>
<span className="truncate text-xs" title={modelName}>
{formatModelDisplayName(modelName)}
</span>
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
<Power className="mr-2 h-4 w-4" />
{t('cloud.card.useAccount')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('cloud.card.refresh')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
<Fingerprint className="mr-2 h-4 w-4" />
{t('cloud.card.identityProfile')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(account.id)}
className="text-destructive focus:text-destructive"
disabled={isDeleting}
>
<Trash className="mr-2 h-4 w-4" />
{t('cloud.card.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent className="flex-1 pb-2">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Badge
variant={
account.status === 'rate_limited' || account.status === 'expired'
? 'destructive'
: 'outline'
}
className="text-xs"
className="h-4 border-white/[0.1] bg-white/[0.02] px-1.5 py-0 text-[9px] uppercase"
>
{account.provider.toUpperCase()}
{account.provider}
</Badge>
{account.is_active && (
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-xs hover:bg-emerald-500/20">
<Badge
variant="default"
className="h-4 border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0 text-[9px] text-emerald-400 uppercase hover:bg-emerald-500/20"
>
{t('cloud.card.active')}
</Badge>
)}
{validationBlockedStatusLabel && (
<span className="text-destructive text-xs font-medium">
{validationBlockedStatusLabel}
</span>
{hasHighTier && (
<Badge
variant="secondary"
className="h-4 animate-pulse border-blue-500/20 bg-blue-500/10 px-1.5 py-0 text-[9px] text-blue-400 uppercase"
>
{t('cloud.card.gemini3Ready')}
</Badge>
)}
</div>
{hasHighTier && (
<Badge
variant="secondary"
className="animate-pulse border-blue-500/20 bg-blue-500/10 text-[10px] text-blue-500"
>
{t('cloud.card.gemini3Ready')}
</Badge>
{validationBlockedStatusLabel && (
<span className="text-destructive mt-1 text-xs font-medium">
{validationBlockedStatusLabel}
</span>
)}
{account.is_active ? (
<Button variant="ghost" size="sm" disabled className="text-emerald-400 opacity-100">
<Power className="mr-1 h-3 w-3" />
{t('cloud.card.active')}
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => onSwitch(account.id)}
disabled={isSwitching}
className="cursor-pointer"
>
{isSwitching ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
<Power className="mr-1 h-3 w-3" />
{shouldShowAiCredits && aiCredits && (
<div className="mt-2 flex w-fit items-center gap-1.5 rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-1">
<span className="text-[10px] font-semibold text-blue-400">
{t('cloud.card.aiCreditsValue', {
amount: formatAiCreditsAmount(aiCredits.credits),
})}
</span>
{aiCredits.expiryDate && (
<>
<span className="h-2.5 w-[1px] bg-blue-500/30"></span>
<span className="text-[9px] font-medium text-blue-400/80">
{t('cloud.card.creditsExpiry', {
date: formatCreditsExpiry(aiCredits.expiryDate),
})}
</span>
</>
)}
{t('cloud.card.use')}
</Button>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex shrink-0 items-center gap-1">
{allModelEntries.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground/70 hover:text-foreground h-7 w-7"
>
{(() => {
const hiddenCount = allModelEntries.filter(
([modelName]) => config?.model_visibility?.[modelName] === false,
).length;
return hiddenCount > 0 ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
);
})()}
<span className="sr-only">{t('cloud.card.modelVisibility')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="end">
<DropdownMenuLabel className="text-xs">
{t('cloud.card.modelVisibility')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-[300px] overflow-y-auto p-1">
{allModelEntries.map(([modelName]) => {
const isVisible = config?.model_visibility?.[modelName] !== false;
return (
<DropdownMenuItem
key={modelName}
onSelect={(e) => e.preventDefault()}
className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5"
>
<Checkbox
checked={isVisible}
onCheckedChange={(checked) => {
if (config) {
const newVisibility = { ...config.model_visibility };
newVisibility[modelName] = checked as boolean;
saveConfig({ ...config, model_visibility: newVisibility });
}
}}
className="h-3.5 w-3.5"
/>
<span className="truncate text-xs font-medium" title={modelName}>
{formatModelDisplayName(modelName)}
</span>
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground/70 hover:text-foreground h-7 w-7"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => onSwitch(account.id)}
disabled={isSwitching}
className="cursor-pointer"
>
<Power className="mr-2 h-4 w-4" />
<span className="font-medium">{t('cloud.card.useAccount')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onRefresh(account.id)}
disabled={isRefreshing}
className="cursor-pointer"
>
<RefreshCw className="mr-2 h-4 w-4" />
<span>{t('cloud.card.refresh')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onManageIdentity(account.id)}
className="cursor-pointer"
>
<Fingerprint className="mr-2 h-4 w-4" />
<span>{t('cloud.card.identityProfile')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(account.id)}
className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer"
disabled={isDeleting}
>
<Trash className="mr-2 h-4 w-4" />
<span>{t('cloud.card.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex-1 px-5 pb-5">
<div className="space-y-4">
{providerGroupingsEnabled ? (
providerGroupedQuotaSection
) : hasVisibleQuotaModels ? (
<div className="space-y-3">
{renderQuotaModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
<div className="pt-1" />
{renderQuotaModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
{renderQuotaModelGroup('Google Gemini', geminiModels)}
{claudeModels.length > 0 && renderQuotaModelGroup('Anthropic Claude', claudeModels)}
</div>
) : (
emptyQuotaState
@@ -551,11 +555,36 @@ export function CloudAccountCard({
</div>
</CardContent>
<CardFooter className="justify-center border-t border-white/[0.04] p-2 px-4 text-xs text-muted-foreground">
<span>
<CardFooter className="flex items-center justify-between border-t border-white/[0.04] bg-white/[0.01] px-5 py-3">
<span className="text-muted-foreground/70 text-[11px] font-medium">
{t('cloud.card.used')}{' '}
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
</span>
{account.is_active ? (
<span className="flex items-center gap-1.5 text-xs font-semibold text-emerald-400">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
</span>
Active
</span>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => onSwitch(account.id)}
disabled={isSwitching}
className="hover:bg-primary/10 hover:text-primary h-7 cursor-pointer px-3 text-xs font-medium transition-colors"
>
{isSwitching ? (
<RefreshCw className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Power className="mr-1.5 h-3 w-3" />
)}
{t('cloud.card.use')}
</Button>
)}
</CardFooter>
</Card>
);
@@ -613,22 +642,22 @@ export function CompactCloudAccountCard({
);
return (
<div className="group flex items-center gap-3 rounded-xl border border-white/[0.06] bg-card px-3 py-2 transition-all duration-200 hover:border-white/[0.12]">
<div className="group bg-card flex items-center gap-3 rounded-xl border border-white/[0.04] px-4 py-3 transition-all duration-200 hover:border-white/[0.1] hover:shadow-sm">
{account.avatar_url ? (
<img
src={account.avatar_url}
alt={account.name || ''}
className="bg-muted h-7 w-7 rounded-full border"
className="h-8 w-8 shrink-0 rounded-full border border-white/[0.08] bg-white/[0.02] object-cover"
/>
) : (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-white/[0.06] text-xs font-semibold text-muted-foreground">
<div className="text-muted-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-xs font-semibold">
{account.name?.[0]?.toUpperCase() || 'A'}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-semibold">
<div className="mb-0.5 flex items-center gap-2">
<span className="text-foreground truncate text-[13px] leading-none font-semibold">
{account.name || t('cloud.card.unknown')}
</span>
<Badge
@@ -637,63 +666,62 @@ export function CompactCloudAccountCard({
? 'destructive'
: 'outline'
}
className="shrink-0 text-[10px]"
className="h-4 shrink-0 border-white/[0.1] bg-white/[0.02] px-1.5 py-0 text-[9px] uppercase"
>
{account.provider.toUpperCase()}
{account.provider}
</Badge>
{account.is_active && (
<Badge
variant="default"
className="shrink-0 bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-[10px] hover:bg-emerald-500/20"
className="h-4 shrink-0 border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0 text-[9px] text-emerald-400 uppercase"
>
{t('cloud.card.active')}
</Badge>
)}
</div>
<div className="text-muted-foreground flex items-center gap-3 text-xs">
<div className="text-muted-foreground/80 flex items-center gap-3 text-[11px] font-medium">
<span className="truncate">{account.email}</span>
{validationBlockedStatusLabel && (
<span className="text-destructive shrink-0 font-medium">
{validationBlockedStatusLabel}
</span>
<span className="text-destructive shrink-0">{validationBlockedStatusLabel}</span>
)}
{shouldShowAiCredits && aiCredits && (
<span className="shrink-0 text-blue-500">
{t('cloud.card.aiCreditsValue', {
amount: formatAiCreditsAmount(aiCredits.credits),
})}
<span className="flex shrink-0 items-center gap-1.5 text-blue-400">
<span>
{t('cloud.card.aiCreditsValue', {
amount: formatAiCreditsAmount(aiCredits.credits),
})}
</span>
{aiCredits.expiryDate && (
<span className="text-muted-foreground">
{' '}
·{' '}
{t('cloud.card.creditsExpiry', {
date: formatCreditsExpiry(aiCredits.expiryDate),
})}
</span>
<>
<span className="h-2.5 w-[1px] bg-blue-500/30"></span>
<span className="text-blue-400/70">
{t('cloud.card.creditsExpiry', {
date: formatCreditsExpiry(aiCredits.expiryDate),
})}
</span>
</>
)}
</span>
)}
</div>
{compactModels.length > 0 && (
<div className="mt-1 flex items-center gap-1">
<div className="mt-2 flex items-center gap-1.5">
{compactModels.map(([modelName, info]) => (
<TooltipProvider key={modelName}>
<Tooltip>
<TooltipTrigger asChild>
<div className="bg-muted h-1.5 w-12 overflow-hidden rounded-full">
<div className="h-1.5 w-10 overflow-hidden rounded-full bg-white/[0.04]">
<div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
className={`h-full rounded-full transition-all duration-300 ease-out ${getQuotaBarColorClass(info.percentage)}`}
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{formatModelDisplayName(modelName)}: {info.percentage}%
</p>
<TooltipContent side="bottom" className="text-xs font-medium">
{formatModelDisplayName(modelName)}: {info.percentage}%
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -706,29 +734,36 @@ export function CompactCloudAccountCard({
{allModelEntries.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
<Button
variant="ghost"
size="icon"
className="text-muted-foreground/70 hover:text-foreground h-8 w-8"
>
{(() => {
const hiddenCount = allModelEntries.filter(
([modelName]) => config?.model_visibility?.[modelName] === false,
).length;
return hiddenCount > 0 ? (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-3.5 w-3.5" />
<Eye className="h-4 w-4" />
);
})()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="end">
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
<div className="max-h-64 overflow-auto px-2 py-1">
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel className="text-xs">
{t('cloud.card.modelVisibility')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-[250px] overflow-y-auto p-1">
{allModelEntries.map(([modelName]) => {
const isVisible = config?.model_visibility?.[modelName] !== false;
return (
<DropdownMenuItem
key={modelName}
onSelect={(e) => e.preventDefault()}
className="flex cursor-pointer items-center gap-2"
className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5"
>
<Checkbox
checked={isVisible}
@@ -739,8 +774,9 @@ export function CompactCloudAccountCard({
saveConfig({ ...config, model_visibility: newVisibility });
}
}}
className="h-3.5 w-3.5"
/>
<span className="truncate text-xs" title={modelName}>
<span className="truncate text-xs font-medium" title={modelName}>
{formatModelDisplayName(modelName)}
</span>
</DropdownMenuItem>
@@ -752,27 +788,27 @@ export function CompactCloudAccountCard({
)}
{account.is_active ? (
<div className="flex h-8 items-center px-2">
<span className="flex items-center gap-1.5 text-[11px] font-bold text-emerald-400">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
</span>
Active
</span>
</div>
) : (
<Button
variant="ghost"
size="sm"
disabled
className="h-7 text-xs text-emerald-400 opacity-100"
>
<Power className="mr-1 h-3 w-3" />
{t('cloud.card.active')}
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => onSwitch(account.id)}
disabled={isSwitching}
className="h-7 cursor-pointer text-xs"
className="hover:bg-primary/10 hover:text-primary h-8 cursor-pointer px-3 text-xs font-medium transition-colors"
>
{isSwitching ? (
<RefreshCw className="h-3 w-3 animate-spin" />
<RefreshCw className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Power className="mr-1 h-3 w-3" />
<Power className="mr-1.5 h-3.5 w-3.5" />
)}
{t('cloud.card.use')}
</Button>
@@ -780,35 +816,47 @@ export function CompactCloudAccountCard({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
<MoreVertical className="h-3.5 w-3.5" />
<Button
variant="ghost"
size="icon"
className="text-muted-foreground/70 hover:text-foreground h-8 w-8"
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => onSwitch(account.id)}
disabled={isSwitching}
className="cursor-pointer"
>
<Power className="mr-2 h-4 w-4" />
{t('cloud.card.useAccount')}
<span className="font-medium">{t('cloud.card.useAccount')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
<DropdownMenuItem
onClick={() => onRefresh(account.id)}
disabled={isRefreshing}
className="cursor-pointer"
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('cloud.card.refresh')}
<span>{t('cloud.card.refresh')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
<DropdownMenuItem
onClick={() => onManageIdentity(account.id)}
className="cursor-pointer"
>
<Fingerprint className="mr-2 h-4 w-4" />
{t('cloud.card.identityProfile')}
<span>{t('cloud.card.identityProfile')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(account.id)}
className="text-destructive focus:text-destructive"
className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer"
disabled={isDeleting}
>
<Trash className="mr-2 h-4 w-4" />
{t('cloud.card.delete')}
<span>{t('cloud.card.delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -134,7 +134,10 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
if (!open) {
return;
}
setTimeout(() => { void refreshProfiles(); }, 0); }, [open, refreshProfiles]);
setTimeout(() => {
void refreshProfiles();
}, 0);
}, [open, refreshProfiles]);
const runAction = async (key: string, action: () => Promise<void>) => {
if (actionLockRef.current) {
@@ -343,7 +346,10 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
{!showLoadingPlaceholder
? history.map((version) => (
<div key={version.id} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3">
<div
key={version.id}
className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3"
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{version.label}</div>
+7 -3
View File
@@ -48,8 +48,12 @@ export function ModelVisibilitySettings() {
// Initialize model visibility and provider groupings from config
useEffect(() => {
if (config?.model_visibility) { setTimeout(() => setModelVisibilityMap(config.model_visibility), 0); }
if (config?.provider_groupings_enabled !== undefined) { setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0); }
if (config?.model_visibility) {
setTimeout(() => setModelVisibilityMap(config.model_visibility), 0);
}
if (config?.provider_groupings_enabled !== undefined) {
setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0);
}
}, [config?.model_visibility, config?.provider_groupings_enabled]);
// Get all unique models from all accounts
@@ -184,7 +188,7 @@ export function ModelVisibilitySettings() {
return (
<div
key={modelId}
className="flex items-center space-x-3 rounded-lg p-2 hover:bg-white/[0.03] transition-colors"
className="flex items-center space-x-3 rounded-lg p-2 transition-colors hover:bg-white/[0.03]"
>
<Checkbox
id={`model-${modelId}`}
+26 -96
View File
@@ -3,15 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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';
import { Play, Square, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface StatusBarProps {
isCollapsed?: boolean;
}
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
export const StatusBar: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -44,112 +38,48 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
};
const isPending = startMutation.isPending || stopMutation.isPending;
// Collapsed View
if (isCollapsed) {
return (
<div className="flex justify-center py-2">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
onClick={handleToggle}
disabled={isLoading || isPending}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
isRunning
? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
: 'bg-white/[0.04] text-muted-foreground hover:bg-white/[0.08] hover:text-foreground',
)}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Power className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
<div
className={cn(
'h-1.5 w-1.5 rounded-full',
isRunning ? 'bg-emerald-400' : 'bg-muted-foreground',
)}
/>
<span>
{isLoading
? t('status.checking')
: isRunning
? t('status.running')
: t('status.stopped')}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
// Expanded View
return (
<div
className={cn(
'flex items-center justify-between rounded-lg px-3 py-2 transition-all duration-200',
isRunning
? 'bg-emerald-500/[0.08]'
: 'bg-white/[0.02]',
<div className="flex items-center gap-2 rounded-md border border-white/[0.06] bg-white/[0.02] px-3 py-2">
{isLoading ? (
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />
) : (
<div
className={cn(
'h-2 w-2 rounded-full',
isRunning
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
: 'bg-muted-foreground/50',
)}
/>
)}
>
<div className="flex items-center gap-2.5">
{isLoading ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
) : (
<div
className={cn(
'h-2 w-2 shrink-0 rounded-full transition-colors duration-300',
isRunning
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
: 'bg-muted-foreground/50',
)}
/>
<span
className={cn(
'text-xs font-medium',
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
)}
<div className="flex flex-col">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Status
</span>
<span className={cn(
'text-xs font-medium leading-tight',
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
)}>
{isLoading
? t('status.checking')
: isRunning
? t('status.running')
: t('status.stopped')}
</span>
</div>
</div>
>
{isLoading ? '...' : isRunning ? 'Running' : 'Stopped'}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleToggle}
disabled={isLoading || isPending}
className={cn(
'h-7 gap-1.5 rounded-md px-2.5 text-xs',
'h-7 w-7 rounded-md p-0',
isRunning
? 'text-emerald-400 hover:bg-emerald-500/10'
: 'text-muted-foreground hover:bg-white/[0.06]',
)}
>
{isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : isRunning ? (
<Square className="h-3 w-3 fill-current" />
<Square className="h-3.5 w-3.5 fill-current" />
) : (
<Play className="h-3 w-3 fill-current" />
<Play className="h-3.5 w-3.5 fill-current" />
)}
<span>{isRunning ? t('action.stop') : t('action.start')}</span>
<span className="sr-only">{isRunning ? t('action.stop') : t('action.start')}</span>
</Button>
</div>
);
+95
View File
@@ -0,0 +1,95 @@
import { useTranslation } from 'react-i18next';
import { Cloud } from 'lucide-react';
import { CloudAccountCard, CompactCloudAccountCard } from '@/components/CloudAccountCard';
import { type GridLayout } from '@/components/CloudAccountList';
import type { CloudAccount } from '@/types/cloudAccount';
export const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
auto: 'grid gap-3 md:grid-cols-2 xl:grid-cols-3',
'2-col': 'grid gap-3 grid-cols-2',
'3-col': 'grid gap-3 grid-cols-3',
list: 'grid gap-3 grid-cols-1',
compact: 'flex flex-col gap-2',
};
interface AccountsGridProps {
accounts: CloudAccount[];
gridLayout: GridLayout;
selectedIds: Set<string>;
onToggleSelection: (id: string, selected: boolean) => void;
onRefresh: (id: string) => void;
onDelete: (id: string) => void;
onSwitch: (id: string) => void;
onManageIdentity: (id: string) => void;
refreshingAccountId?: string;
deletingAccountId?: string;
switchingAccountId?: string;
isRefreshPending: boolean;
isDeletePending: boolean;
isSwitchPending: boolean;
}
export function AccountsGrid(props: AccountsGridProps) {
const { t } = useTranslation();
const {
accounts,
gridLayout,
selectedIds,
onToggleSelection,
onRefresh,
onDelete,
onSwitch,
onManageIdentity,
refreshingAccountId,
deletingAccountId,
switchingAccountId,
isRefreshPending,
isDeletePending,
isSwitchPending,
} = props;
return (
<div className={GRID_LAYOUT_CLASSES[gridLayout]}>
{accounts.map((account) =>
gridLayout === 'compact' ? (
<CompactCloudAccountCard
key={account.id}
account={account}
onRefresh={onRefresh}
onDelete={onDelete}
onSwitch={onSwitch}
onManageIdentity={onManageIdentity}
isRefreshing={isRefreshPending && refreshingAccountId === account.id}
isDeleting={isDeletePending && deletingAccountId === account.id}
isSwitching={isSwitchPending && switchingAccountId === account.id}
/>
) : (
<CloudAccountCard
key={account.id}
account={account}
onRefresh={onRefresh}
onDelete={onDelete}
onSwitch={onSwitch}
onManageIdentity={onManageIdentity}
isSelected={selectedIds.has(account.id)}
onToggleSelection={onToggleSelection}
isRefreshing={isRefreshPending && refreshingAccountId === account.id}
isDeleting={isDeletePending && deletingAccountId === account.id}
isSwitching={isSwitchPending && switchingAccountId === account.id}
/>
),
)}
{accounts.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center rounded-xl border border-dashed border-white/[0.08] bg-white/[0.01] py-20 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/[0.04]">
<Cloud className="text-muted-foreground/40 h-8 w-8" />
</div>
<div className="mt-4 text-sm font-medium">{t('cloud.list.noAccounts')}</div>
<p className="text-muted-foreground mt-1 max-w-xs text-xs leading-relaxed">
{t('cloud.description')}
</p>
</div>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useTranslation } from 'react-i18next';
import { clampQuotaPercentage, getQuotaStatus, type QuotaStatus } from '@/utils/quota-display';
interface AccountsHeaderProps {
totalAccounts: number;
activeAccounts: number;
rateLimitedAccounts: number;
overallQuotaPercentage: number | null;
overallQuotaStatus: QuotaStatus | null;
}
const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'bg-emerald-500',
medium: 'bg-amber-500',
low: 'bg-rose-500',
};
const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'text-emerald-400',
medium: 'text-amber-400',
low: 'text-rose-400',
};
export function AccountsHeader({
totalAccounts,
activeAccounts,
rateLimitedAccounts,
overallQuotaPercentage,
overallQuotaStatus,
}: AccountsHeaderProps) {
const { t } = useTranslation();
const quotaStatus =
overallQuotaStatus ??
(overallQuotaPercentage === null ? null : getQuotaStatus(overallQuotaPercentage));
return (
<div className="mb-8">
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
{/* Title Area */}
<div className="flex flex-col gap-1.5">
<h2 className="text-foreground text-2xl font-semibold tracking-tight">
{t('cloud.title')}
</h2>
<p className="text-muted-foreground max-w-xl text-sm leading-relaxed">
{t('cloud.description')}
</p>
</div>
{/* Stats Row */}
<div className="flex flex-wrap items-center gap-3">
{/* Total & Active combined stat */}
<div className="flex min-w-[120px] flex-col justify-center rounded-xl border border-white/[0.04] bg-white/[0.01] px-4 py-3">
<div className="flex items-baseline gap-2">
<span className="text-foreground text-2xl font-bold tracking-tight">
{totalAccounts}
</span>
<span className="text-sm font-medium text-emerald-400">{activeAccounts} active</span>
</div>
<span className="text-muted-foreground/70 mt-0.5 text-xs font-medium tracking-wider uppercase">
{t('cloud.card.actions')}
</span>
</div>
{/* Rate Limited stat (only if > 0) */}
{rateLimitedAccounts > 0 && (
<div className="flex min-w-[100px] flex-col justify-center rounded-xl border border-rose-500/10 bg-rose-500/[0.02] px-4 py-3">
<span className="text-2xl font-bold tracking-tight text-rose-400">
{rateLimitedAccounts}
</span>
<span className="mt-0.5 text-xs font-medium tracking-wider text-rose-400/60 uppercase">
{t('cloud.card.rateLimited')}
</span>
</div>
)}
{/* Global Quota */}
{overallQuotaPercentage !== null && quotaStatus && (
<div className="flex min-w-[140px] flex-col justify-center rounded-xl border border-white/[0.04] bg-white/[0.01] px-4 py-3">
<div className="flex items-baseline gap-1">
<span
className={`text-2xl font-bold tracking-tight ${GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus]}`}
>
{overallQuotaPercentage}%
</span>
</div>
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-white/[0.04]">
<div
className={`h-full rounded-full transition-all duration-700 ease-in-out ${GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus]}`}
style={{ width: `${clampQuotaPercentage(overallQuotaPercentage)}%` }}
/>
</div>
<span className="text-muted-foreground/70 mt-1.5 text-xs font-medium tracking-wider uppercase">
{t('cloud.globalQuota')}
</span>
</div>
)}
</div>
</div>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
import { useTranslation } from 'react-i18next';
import {
Check,
CheckSquare,
Columns2,
Columns3,
Download,
FileDown,
LayoutGrid,
LayoutList,
List,
MoreHorizontal,
Plus,
RefreshCcw,
SortAsc,
Upload,
Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface AccountsToolbarProps {
autoSwitchEnabled: boolean | undefined;
isSettingsLoading: boolean;
onToggleAutoSwitch: (checked: boolean) => void;
isAutoSwitchPending: boolean;
selectedCount: number;
totalCount: number;
onToggleSelectAll: () => void;
onForcePoll: () => void;
isForcePollPending: boolean;
onSyncLocal: () => void;
isSyncPending: boolean;
onOpenExportDialog: () => void;
onOpenImportDialog: () => void;
onOpenAddDialog: () => void;
currentSort: string;
sortOptions: readonly string[];
sortI18nKeys: Record<string, string>;
onSortChange: (sort: string) => void;
gridLayout: string;
onLayoutChange: (layout: string) => void;
}
export function AccountsToolbar(props: AccountsToolbarProps) {
const { t } = useTranslation();
const {
autoSwitchEnabled,
isSettingsLoading,
onToggleAutoSwitch,
isAutoSwitchPending,
selectedCount,
totalCount,
onToggleSelectAll,
onForcePoll,
isForcePollPending,
onSyncLocal,
isSyncPending,
onOpenExportDialog,
onOpenImportDialog,
onOpenAddDialog,
currentSort,
sortOptions,
sortI18nKeys,
onSortChange,
gridLayout,
onLayoutChange,
} = props;
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* Primary Actions Group */}
<div className="flex items-center gap-3">
<Button onClick={onOpenAddDialog} className="cursor-pointer shadow-sm">
<Plus className="mr-2 h-4 w-4" />
{t('cloud.addAccount')}
</Button>
<Button
variant="outline"
onClick={onToggleSelectAll}
title={t('cloud.batch.selectAll')}
className={`cursor-pointer transition-colors ${selectedCount > 0 && selectedCount === totalCount ? 'border-primary/50 bg-primary/5 text-primary' : ''}`}
>
<CheckSquare
className={`mr-2 h-4 w-4 ${selectedCount > 0 && selectedCount === totalCount ? 'text-primary' : 'opacity-70'}`}
/>
{selectedCount > 0 ? `${selectedCount} Selected` : t('cloud.batch.selectAll')}
</Button>
</div>
{/* Secondary Actions & View Options */}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
{/* Layout Toggles */}
<div className="hidden items-center gap-1 rounded-lg border border-white/[0.08] bg-white/[0.02] p-1 sm:flex">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={gridLayout === 'auto' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7 cursor-pointer rounded-md"
onClick={() => onLayoutChange('auto')}
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.auto')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={gridLayout === '2-col' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7 cursor-pointer rounded-md"
onClick={() => onLayoutChange('2-col')}
>
<Columns2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.twoCol')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={gridLayout === '3-col' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7 cursor-pointer rounded-md"
onClick={() => onLayoutChange('3-col')}
>
<Columns3 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.threeCol')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={gridLayout === 'list' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7 cursor-pointer rounded-md"
onClick={() => onLayoutChange('list')}
>
<List className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.list')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={gridLayout === 'compact' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7 cursor-pointer rounded-md"
onClick={() => onLayoutChange('compact')}
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('cloud.layout.compact')}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Sort Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="cursor-pointer gap-2 bg-white/[0.02]">
<SortAsc className="h-4 w-4 opacity-70" />
<span className="hidden text-xs sm:inline-block">{t(sortI18nKeys[currentSort])}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-muted-foreground text-xs tracking-wider uppercase">
Sort By
</DropdownMenuLabel>
<DropdownMenuSeparator />
{sortOptions.map((option) => (
<DropdownMenuItem
key={option}
className="cursor-pointer"
onClick={() => onSortChange(option)}
>
{currentSort === option ? (
<Check className="mr-2 h-4 w-4" />
) : (
<div className="mr-2 h-4 w-4" />
)}
{t(sortI18nKeys[option])}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* More Options Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="cursor-pointer bg-white/[0.02]">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* Auto-Switch Toggle embedded in menu */}
<div className="flex items-center justify-between px-3 py-2.5">
<div className="flex items-center gap-2">
<Zap
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
/>
<Label htmlFor="auto-switch" className="cursor-pointer text-sm font-medium">
{t('cloud.autoSwitch')}
</Label>
</div>
<Switch
id="auto-switch"
checked={!!autoSwitchEnabled}
onCheckedChange={onToggleAutoSwitch}
disabled={isSettingsLoading || isAutoSwitchPending}
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-muted-foreground text-xs tracking-wider uppercase">
Actions
</DropdownMenuLabel>
<DropdownMenuItem
onClick={onForcePoll}
disabled={isForcePollPending}
className="cursor-pointer"
>
<RefreshCcw className={`mr-2 h-4 w-4 ${isForcePollPending ? 'animate-spin' : ''}`} />
{t('cloud.checkQuota')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onSyncLocal}
disabled={isSyncPending}
className="cursor-pointer"
>
<Download className={`mr-2 h-4 w-4 ${isSyncPending ? 'animate-bounce' : ''}`} />
{t('cloud.syncFromIDE')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-muted-foreground text-xs tracking-wider uppercase">
Data
</DropdownMenuLabel>
<DropdownMenuItem onClick={onOpenExportDialog} className="cursor-pointer">
<FileDown className="mr-2 h-4 w-4" />
{t('cloud.exportImport.export')}
</DropdownMenuItem>
<DropdownMenuItem onClick={onOpenImportDialog} className="cursor-pointer">
<Upload className="mr-2 h-4 w-4" />
{t('cloud.exportImport.import')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
@@ -0,0 +1,115 @@
import type React from 'react';
import { useTranslation } from 'react-i18next';
import { Cloud, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface AddAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
authCode: string;
onAuthCodeChange: (code: string) => void;
selectedOAuthClientKey: string;
onOAuthClientChange: (key: string) => void;
oauthClients: Array<{ key: string; label: string; is_active: boolean }>;
isOAuthClientsLoading: boolean;
isSetActiveOAuthClientPending: boolean;
onOpenGoogleAuth: () => void;
onSubmitAuthCode: () => void;
isAddPending: boolean;
}
export function AddAccountDialog(props: AddAccountDialogProps) {
const { t } = useTranslation();
const {
open,
onOpenChange,
authCode,
onAuthCodeChange,
selectedOAuthClientKey,
onOAuthClientChange,
oauthClients,
isOAuthClientsLoading,
isSetActiveOAuthClientPending,
onOpenGoogleAuth,
onSubmitAuthCode,
isAddPending,
} = props;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<span />
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="oauth-client-select">{t('cloud.authDialog.oauthClient')}</Label>
<Select
value={selectedOAuthClientKey || undefined}
onValueChange={onOAuthClientChange}
disabled={isOAuthClientsLoading || isSetActiveOAuthClientPending}
>
<SelectTrigger id="oauth-client-select">
<SelectValue placeholder={t('cloud.authDialog.oauthClientPlaceholder')} />
</SelectTrigger>
<SelectContent>
{oauthClients.map((client) => (
<SelectItem key={client.key} value={client.key}>
{client.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Button variant="outline" className="col-span-4" onClick={onOpenGoogleAuth}>
<Cloud className="mr-2 h-4 w-4" />
{t('cloud.authDialog.openLogin')}
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="code">{t('cloud.authDialog.authCode')}</Label>
<Input
id="code"
placeholder={t('cloud.authDialog.placeholder')}
value={authCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onAuthCodeChange(e.target.value)
}
/>
<p className="text-muted-foreground text-xs">{t('cloud.authDialog.instruction')}</p>
</div>
</div>
<DialogFooter>
<Button onClick={onSubmitAuthCode} disabled={isAddPending || !authCode}>
{isAddPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cloud.authDialog.verify')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next';
import { RefreshCw, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface BatchActionBarProps {
selectedCount: number;
onClearSelection: () => void;
onRefreshSelected: () => void;
onDeleteSelected: () => void;
}
export function BatchActionBar({
selectedCount,
onClearSelection,
onRefreshSelected,
onDeleteSelected,
}: BatchActionBarProps) {
const { t } = useTranslation();
if (selectedCount <= 0) return null;
return (
<div className="bg-card animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border border-white/[0.1] px-6 py-2 shadow-2xl shadow-black/30">
<div className="flex items-center gap-2 border-r pr-4">
<span className="text-sm font-semibold">
{t('cloud.batch.selected', { count: selectedCount })}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full"
onClick={onClearSelection}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={onRefreshSelected}>
<RefreshCw className="mr-2 h-3 w-3" />
{t('cloud.batch.refresh')}
</Button>
<Button variant="destructive" size="sm" onClick={onDeleteSelected}>
<Trash2 className="mr-2 h-3 w-3" />
{t('cloud.batch.delete')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,147 @@
import type React from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface ExportImportDialogsProps {
isExportDialogOpen: boolean;
onExportDialogChange: (open: boolean) => void;
onExport: (stripTokens: boolean) => void;
isExportPending: boolean;
isImportDialogOpen: boolean;
onImportDialogChange: (open: boolean) => void;
importStrategy: 'merge' | 'overwrite' | 'skip-existing';
onImportStrategyChange: (strategy: 'merge' | 'overwrite' | 'skip-existing') => void;
importFileName: string;
onImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
importFileContent: string | null;
onImport: () => void;
isImportPending: boolean;
fileInputRef: React.RefObject<HTMLInputElement | null>;
}
export function ExportImportDialogs(props: ExportImportDialogsProps) {
const { t } = useTranslation();
const {
isExportDialogOpen,
onExportDialogChange,
onExport,
isExportPending,
isImportDialogOpen,
onImportDialogChange,
importStrategy,
onImportStrategyChange,
importFileName,
onImportFileSelect,
importFileContent,
onImport,
isImportPending,
fileInputRef,
} = props;
return (
<>
<Dialog open={isExportDialogOpen} onOpenChange={onExportDialogChange}>
<DialogTrigger asChild>
<span />
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('cloud.exportImport.exportTitle')}</DialogTitle>
<DialogDescription>{t('cloud.exportImport.exportDesc')}</DialogDescription>
</DialogHeader>
<DialogFooter className="flex w-full flex-col gap-2 sm:flex-row sm:justify-center sm:space-x-0">
<Button
variant="outline"
onClick={() => onExport(false)}
disabled={isExportPending}
className="w-full cursor-pointer sm:flex-1"
>
{isExportPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cloud.exportImport.includeTokens')}
</Button>
<Button
onClick={() => onExport(true)}
disabled={isExportPending}
className="w-full cursor-pointer sm:flex-1"
>
{isExportPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('cloud.exportImport.stripTokens')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isImportDialogOpen} onOpenChange={onImportDialogChange}>
<DialogTrigger asChild>
<span />
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('cloud.exportImport.importTitle')}</DialogTitle>
<DialogDescription>{t('cloud.exportImport.importDesc')}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>{t('cloud.exportImport.selectFile')}</Label>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={onImportFileSelect}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="cursor-pointer"
>
{importFileName || t('cloud.exportImport.selectFile')}
</Button>
</div>
<div className="grid gap-2">
<Label>{t('cloud.exportImport.importStrategy')}</Label>
<Select value={importStrategy} onValueChange={onImportStrategyChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="merge">{t('cloud.exportImport.strategyMerge')}</SelectItem>
<SelectItem value="overwrite">
{t('cloud.exportImport.strategyOverwrite')}
</SelectItem>
<SelectItem value="skip-existing">
{t('cloud.exportImport.strategySkip')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button onClick={onImport} disabled={!importFileContent || isImportPending}>
{isImportPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isImportPending ? t('cloud.exportImport.importing') : t('cloud.exportImport.import')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+1 -1
View File
@@ -35,7 +35,7 @@ export default function DragWindowRegion({ title }: DragWindowRegionProps) {
<div className="flex w-screen items-stretch justify-between">
<div className="draglayer w-full">
{title && !isMacOS && (
<div className="flex flex-1 p-2 text-xs whitespace-nowrap text-muted-foreground select-none">
<div className="text-muted-foreground flex flex-1 p-2 text-xs whitespace-nowrap select-none">
{title}
</div>
)}
-39
View File
@@ -1,39 +0,0 @@
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>
);
};
@@ -98,7 +98,7 @@ export const CapabilitiesTab: React.FC = () => {
<h3 className="text-base font-semibold">
{t('proxy.advanced.capabilities.title', 'Model Capabilities')}
</h3>
<p className="text-[13px] text-muted-foreground">
<p className="text-muted-foreground text-[13px]">
{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}
</p>
</div>
@@ -113,7 +113,7 @@ export const CapabilitiesTab: React.FC = () => {
{/* Content */}
{isLoading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : (
<div className="space-y-8">
@@ -121,11 +121,11 @@ export const CapabilitiesTab: React.FC = () => {
<div key={provider} className="space-y-3">
{/* Provider header */}
<div className="flex items-center gap-3">
<h4 className="text-[13px] font-semibold tracking-wide uppercase text-muted-foreground">
<h4 className="text-muted-foreground text-[13px] font-semibold tracking-wide uppercase">
{provider}
</h4>
<div className="h-px flex-1 bg-white/[0.06]" />
<span className="text-[11px] text-muted-foreground/60 tabular-nums">
<span className="text-muted-foreground/60 text-[11px] tabular-nums">
{groupedModels[provider].length}
</span>
</div>
@@ -140,23 +140,23 @@ export const CapabilitiesTab: React.FC = () => {
return (
<div
key={model.id}
className="group flex flex-col rounded-xl border border-white/[0.06] bg-card p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
className="group bg-card flex flex-col rounded-xl border border-white/[0.06] p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
>
{/* Model header */}
<div className="mb-3 space-y-1">
<div className="flex items-start justify-between gap-2">
<h5
className="text-[13px] font-semibold leading-tight truncate"
className="truncate text-[13px] leading-tight font-semibold"
title={model.displayName || model.id}
>
{model.displayName || model.id}
</h5>
<span className="shrink-0 text-[10px] font-medium text-muted-foreground/60 rounded-md bg-white/[0.04] px-1.5 py-0.5">
<span className="text-muted-foreground/60 shrink-0 rounded-md bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium">
{enabledCount}/{totalCount}
</span>
</div>
<p
className="font-mono text-[11px] text-muted-foreground truncate"
className="text-muted-foreground truncate font-mono text-[11px]"
title={model.id}
>
{model.id}
@@ -164,7 +164,7 @@ export const CapabilitiesTab: React.FC = () => {
</div>
{/* Capability pills */}
<div className="flex flex-wrap gap-1.5 mb-3">
<div className="mb-3 flex flex-wrap gap-1.5">
{Object.entries(model.capabilities).map(([key, supported]) => {
const iconKey = key as keyof typeof CAPABILITY_ICONS;
const Icon = CAPABILITY_ICONS[iconKey];
@@ -174,8 +174,8 @@ export const CapabilitiesTab: React.FC = () => {
key={key}
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium transition-colors ${
supported
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/15'
: 'bg-white/[0.02] text-muted-foreground/50 border border-white/[0.04]'
? 'border border-emerald-500/15 bg-emerald-500/10 text-emerald-400'
: 'text-muted-foreground/50 border border-white/[0.04] bg-white/[0.02]'
}`}
title={label}
>
@@ -190,17 +190,17 @@ export const CapabilitiesTab: React.FC = () => {
{hasLimits && (
<div className="mt-auto flex flex-wrap gap-1.5 border-t border-white/[0.04] pt-3">
{model.limits.maxTokens != null && (
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
<span className="text-muted-foreground inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px]">
CTX {formatTokenCount(model.limits.maxTokens)}
</span>
)}
{model.limits.maxOutputTokens != null && (
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
<span className="text-muted-foreground inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px]">
OUT {formatTokenCount(model.limits.maxOutputTokens)}
</span>
)}
{model.limits.thinkingBudget != null && (
<span className="inline-flex items-center rounded-md bg-blue-500/10 border border-blue-500/15 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
<span className="inline-flex items-center rounded-md border border-blue-500/15 bg-blue-500/10 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
THINK {formatTokenCount(model.limits.thinkingBudget)}
</span>
)}
@@ -215,12 +215,10 @@ export const CapabilitiesTab: React.FC = () => {
{models.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-white/[0.1] py-16 text-center">
<Layers className="h-8 w-8 text-muted-foreground/30" />
<Layers className="text-muted-foreground/30 h-8 w-8" />
<div className="space-y-1">
<p className="text-[13px] font-medium">No models found</p>
<p className="text-xs text-muted-foreground">
Check your provider configurations
</p>
<p className="text-muted-foreground text-xs">Check your provider configurations</p>
</div>
</div>
)}
+17 -9
View File
@@ -54,7 +54,8 @@ export const ReplayTab: React.FC = () => {
onError: (error) => {
toast({
title: t('proxy.advanced.replay.error'),
description: error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
description:
error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
variant: 'destructive',
});
setSelectedRequestId(null);
@@ -76,7 +77,10 @@ export const ReplayTab: React.FC = () => {
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
if (status >= 200 && status < 300) {
return (
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/20">
<Badge
variant="default"
className="border-emerald-500/20 bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20"
>
<CheckCircle className="mr-1 h-3 w-3" />
{status}
</Badge>
@@ -99,7 +103,9 @@ export const ReplayTab: React.FC = () => {
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold">{t('proxy.advanced.replay.title')}</h3>
<p className="text-[13px] text-muted-foreground">{t('proxy.advanced.replay.description')}</p>
<p className="text-muted-foreground text-[13px]">
{t('proxy.advanced.replay.description')}
</p>
</div>
<Button
variant="outline"
@@ -113,10 +119,12 @@ export const ReplayTab: React.FC = () => {
</div>
{/* Requests List */}
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<div className="space-y-1 border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.replay.recentRequests')}</h4>
<p className="text-xs text-muted-foreground">{t('proxy.advanced.replay.clickToReplay')}</p>
<p className="text-muted-foreground text-xs">
{t('proxy.advanced.replay.clickToReplay')}
</p>
</div>
<div className="p-5">
{requestsLoading ? (
@@ -125,8 +133,8 @@ export const ReplayTab: React.FC = () => {
</div>
) : requests.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-white/[0.1]">
<Clock className="h-6 w-6 text-muted-foreground/30" />
<p className="text-[13px] text-muted-foreground">
<Clock className="text-muted-foreground/30 h-6 w-6" />
<p className="text-muted-foreground text-[13px]">
{t('proxy.advanced.replay.noRequests')}
</p>
</div>
@@ -143,7 +151,7 @@ export const ReplayTab: React.FC = () => {
<span className="text-[13px] font-medium">
{request.method} {request.endpoint}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{formatTimestamp(request.timestamp)}
{request.duration && ` · ${request.duration}ms`}
</span>
+81 -29
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueries } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
@@ -6,17 +6,42 @@ import { ipc } from '@/ipc/manager';
import { Button } from '@/components/ui/button';
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';
import {
Loader2,
Copy,
CheckCircle,
ShieldAlert,
XCircle,
Terminal,
Code2,
Braces,
Sparkles,
} from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const IDE_CONFIGS = [
{ key: 'opencode', label: 'OpenCode', icon: Sparkles },
{ 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 = () => {
interface ToolsTabProps {
availableModelIds?: string[];
}
export const ToolsTab: React.FC<ToolsTabProps> = ({
availableModelIds = ['gemini-3.1-pro-high'],
}) => {
const { t } = useTranslation();
const { toast } = useToast();
const [selectedModel, setSelectedModel] = useState<string>(availableModelIds[0]);
const { data: parityData, isLoading: parityLoading } = useQuery({
queryKey: ['proxyAdvanced', 'parityCounters'],
@@ -25,16 +50,11 @@ export const ToolsTab: React.FC = () => {
});
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' }),
},
],
queries: IDE_CONFIGS.map(({ key }) => ({
queryKey: ['proxyAdvanced', 'ideConfig', key, selectedModel],
queryFn: () =>
ipc.client.proxyAdvanced.generateIdeConfig({ ide: key, defaultModel: selectedModel }),
})),
});
const parity = parityData?.success ? parityData.data : undefined;
@@ -44,15 +64,21 @@ export const ToolsTab: React.FC = () => {
toast({ title: t('proxy.advanced.tools.copyConfig') });
};
const getIdeInstructions = (key: string): string => {
const getIdeInstructions = (key: string, index: number): 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;
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`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = result.data as any;
if (d.format === 'json') {
return JSON.stringify(d.content, null, 2);
}
if (d.format === 'env' || d.format === 'shell') {
return typeof d.content === 'string' ? d.content : JSON.stringify(d.content, null, 2);
}
return `${d.instructions || ''}\n${d.proxySetting || 'URL'}: http://localhost:8045\n${d.apiKeySetting || 'Key'}: YOUR_API_KEY`;
}
return '';
};
@@ -87,24 +113,45 @@ export const ToolsTab: React.FC = () => {
</div>
{/* IDE Quick Setup */}
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
<p className="text-xs text-muted-foreground">Quick configuration for popular IDEs</p>
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
<div className="space-y-1">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
<p className="text-muted-foreground text-xs">Quick configuration for popular IDEs</p>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Default Model:</span>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModelIds.map((id) => (
<SelectItem key={id} value={id}>
{id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="p-5 space-y-3">
<div className="space-y-3 p-5">
{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);
IDE_CONFIGS.map(({ key, label, icon: Icon }, index) => {
const instructions = getIdeInstructions(key, index);
const resultData = ideQueries[index]?.data;
const hasError = resultData && !resultData.success;
const errorMsg = resultData?.error || '';
return (
<div key={key} className="overflow-hidden rounded-xl border border-white/[0.06]">
<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" />
<Icon className="text-muted-foreground h-4 w-4" />
<span className="text-[13px] font-medium">{label}</span>
</div>
<Button
@@ -112,13 +159,19 @@ export const ToolsTab: React.FC = () => {
size="sm"
className="h-8 text-xs"
onClick={() => copyToClipboard(instructions)}
disabled={!instructions || hasError}
>
<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 border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
{hasError && (
<div className="bg-card border-t border-white/[0.06] p-3 font-mono text-xs text-amber-500/80">
{errorMsg}
</div>
)}
{instructions && !hasError && (
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
{instructions}
</pre>
)}
@@ -128,7 +181,6 @@ export const ToolsTab: React.FC = () => {
)}
</div>
</div>
</div>
);
};
@@ -1,190 +0,0 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, ArrowUp, ArrowDown } from 'lucide-react';
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';
function SortIcon({ column, sortKey, sortDir }: { column: SortKey; sortKey: SortKey; sortDir: SortDir }) {
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" />
);
}
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]);
return (
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
<div className="border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">
{t('proxy.advanced.trafficLog.title')}
</h4>
</div>
<div className="p-5">
{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-[13px] 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 border-white/[0.06] text-left text-[10px] font-medium tracking-wider uppercase 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" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('direction')}
>
{t('proxy.advanced.trafficLog.direction')}
<SortIcon column="direction" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('endpoint')}
>
{t('proxy.advanced.trafficLog.endpoint')}
<SortIcon column="endpoint" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('method')}
>
{t('proxy.advanced.trafficLog.method')}
<SortIcon column="method" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('status')}
>
{t('proxy.advanced.trafficLog.status')}
<SortIcon column="status" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 font-medium select-none"
onClick={() => toggleSort('duration')}
>
{t('proxy.advanced.trafficLog.duration')}
<SortIcon column="duration" sortKey={sortKey} sortDir={sortDir} />
</th>
</tr>
</thead>
<tbody>
{sortedLogs.map((log, i) => (
<tr
key={`${log.requestId}-${i}`}
className="border-b border-white/[0.04] 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>
)}
</div>
</div>
);
};
+49
View File
@@ -0,0 +1,49 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
{...props}
/>
),
);
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };
+2 -4
View File
@@ -9,16 +9,14 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
default: 'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
destructive:
'bg-destructive/90 text-white hover:bg-destructive active:scale-[0.98] focus-visible:ring-destructive/30',
outline:
'border border-white/[0.08] bg-transparent hover:bg-white/[0.04] hover:border-white/[0.12] text-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
ghost:
'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
ghost: 'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
link: 'text-accent underline-offset-4 hover:underline',
},
size: {
+1 -1
View File
@@ -26,7 +26,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-sm font-semibold leading-none tracking-tight', className)}
className={cn('text-sm leading-none font-semibold tracking-tight', className)}
{...props}
/>
),
+1 -1
View File
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent ring-offset-background transition-colors focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background',
'peer ring-offset-background focus-visible:ring-accent/30 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
+2 -2
View File
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
'gap-4 rounded-xl border border-white/[0.08] bg-card p-6 shadow-2xl shadow-black/30',
'bg-card gap-4 rounded-xl border border-white/[0.08] p-6 shadow-2xl shadow-black/30',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
@@ -47,7 +47,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring/30 disabled:pointer-events-none">
<DialogPrimitive.Close className="focus:ring-ring/30 absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:ring-2 focus:outline-none disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
+9 -5
View File
@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] outline-none select-none transition-colors focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
inset && 'pl-8',
className,
)}
@@ -92,7 +92,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'focus:text-foreground relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
@@ -108,7 +108,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'focus:text-foreground relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
@@ -131,7 +131,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'focus:text-foreground relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
@@ -154,7 +154,11 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
className={cn(
'text-muted-foreground px-2 py-1.5 text-xs font-medium',
inset && 'pl-8',
className,
)}
{...props}
/>
));
+3 -3
View File
@@ -8,10 +8,10 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input
type={type}
className={cn(
'flex h-9 w-full rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-1 text-[13px] text-foreground shadow-none transition-colors duration-200',
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
'bg-muted/50 text-foreground flex h-9 w-full rounded-lg border border-white/[0.08] px-3 py-1 text-[13px] shadow-none transition-colors duration-200',
'file:text-foreground file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:border-accent/50 focus-visible:ring-1 focus-visible:ring-accent/20',
'focus-visible:border-accent/50 focus-visible:ring-accent/20 focus-visible:ring-1 focus-visible:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
+7 -7
View File
@@ -19,9 +19,9 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-2 text-[13px] text-foreground transition-colors duration-200',
'bg-muted/50 text-foreground flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] px-3 py-2 text-[13px] transition-colors duration-200',
'placeholder:text-muted-foreground',
'focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20',
'focus:border-accent/50 focus:ring-accent/20 focus:ring-1 focus:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&>span]:line-clamp-1',
className,
@@ -72,7 +72,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] bg-popover text-popover-foreground shadow-xl shadow-black/20',
'bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] shadow-xl shadow-black/20',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
@@ -104,7 +104,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pr-2 pl-8 text-xs font-medium text-muted-foreground', className)}
className={cn('text-muted-foreground py-1.5 pr-2 pl-8 text-xs font-medium', className)}
{...props}
/>
));
@@ -117,8 +117,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] outline-none select-none transition-colors',
'focus:bg-white/[0.06] focus:text-foreground',
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none',
'focus:text-foreground focus:bg-white/[0.06]',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
@@ -126,7 +126,7 @@ const SelectItem = React.forwardRef<
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-3.5 w-3.5 text-accent" />
<Check className="text-accent h-3.5 w-3.5" />
</SelectPrimitive.ItemIndicator>
</span>
+1 -6
View File
@@ -1,12 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)}
{...props}
/>
);
return <div className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)} {...props} />;
}
export { Skeleton };
+1 -1
View File
@@ -10,7 +10,7 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-none transition-colors duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'focus-visible:ring-ring/30 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-white/[0.1]',
className,
+4 -4
View File
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1 border border-white/[0.04]',
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg border border-white/[0.04] p-1',
className,
)}
{...props}
@@ -28,8 +28,8 @@ const TabsTrigger = React.forwardRef<
className={cn(
'inline-flex items-center justify-center rounded-md px-3 py-1 text-[13px] font-medium whitespace-nowrap transition-all duration-200',
'text-muted-foreground hover:text-foreground',
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:border data-[state=active]:border-white/[0.08]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:border data-[state=active]:border-white/[0.08] data-[state=active]:shadow-sm',
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
'disabled:pointer-events-none disabled:opacity-50',
className,
)}
@@ -45,7 +45,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-3 animate-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
'animate-in focus-visible:ring-ring/30 mt-3 focus-visible:ring-2 focus-visible:outline-none',
className,
)}
{...props}
+1 -1
View File
@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-lg border border-white/[0.08] bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lg shadow-black/20',
'bg-popover text-popover-foreground z-50 overflow-hidden rounded-lg border border-white/[0.08] px-3 py-1.5 text-xs shadow-lg shadow-black/20',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
+6 -6
View File
@@ -29,10 +29,10 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
children,
}) => {
return (
<div className="flex flex-col rounded-xl border border-white/[0.06] bg-card">
<div className="bg-card flex flex-col rounded-xl border border-white/[0.06]">
<div className="flex flex-col p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-[13px] font-semibold tracking-tight text-foreground">{title}</h3>
<h3 className="text-foreground text-[13px] font-semibold tracking-tight">{title}</h3>
</div>
<div className="flex-1">
{isLoading ? (
@@ -41,8 +41,8 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
</div>
) : error ? (
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertTriangle className="h-5 w-5 text-destructive" />
<div className="bg-destructive/10 flex h-10 w-10 items-center justify-center rounded-lg">
<AlertTriangle className="text-destructive h-5 w-5" />
</div>
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
<Button variant="outline" size="sm" onClick={onRetry}>
@@ -53,9 +53,9 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
) : isEmpty ? (
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/[0.04]">
<BarChart3 className="h-5 w-5 text-muted-foreground/60" />
<BarChart3 className="text-muted-foreground/60 h-5 w-5" />
</div>
<p className="text-[13px] font-medium text-foreground">{emptyTitle}</p>
<p className="text-foreground text-[13px] font-medium">{emptyTitle}</p>
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
{emptyDescription}
</p>
+130
View File
@@ -0,0 +1,130 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { calculateCost } from '@/constants/pricing';
export interface ModelCostEntry {
model: string;
inputTokens: number;
outputTokens: number;
inputCost: number;
outputCost: number;
totalCost: number;
}
export interface CostReportCardProps {
models: ModelCostEntry[];
totalInputCost: number;
totalOutputCost: number;
totalCost: number;
isLoading: boolean;
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
}
export const CostReportCard: React.FC<CostReportCardProps> = ({
models,
totalInputCost,
totalOutputCost,
totalCost,
isLoading,
}) => {
if (isLoading) {
return (
<Card className="bg-card/50 border-white/[0.06]">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
Loading cost data...
</div>
</CardContent>
</Card>
);
}
if (!models.length) {
return (
<Card className="bg-card/50 border-white/[0.06]">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
No usage data for cost calculation
</div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-card/50 border-white/[0.06]">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="text-muted-foreground border-b border-white/[0.06]">
<th className="py-2 pr-4 font-medium">Model</th>
<th className="py-2 pr-4 text-right font-medium">Input Tokens</th>
<th className="py-2 pr-4 text-right font-medium">Input Cost</th>
<th className="py-2 pr-4 text-right font-medium">Output Tokens</th>
<th className="py-2 pr-4 text-right font-medium">Output Cost</th>
<th className="py-2 text-right font-medium">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{models.map((m) => (
<tr key={m.model} className="hover:bg-white/[0.02]">
<td className="text-foreground py-2 pr-4">{m.model}</td>
<td className="text-muted-foreground py-2 pr-4 text-right font-mono">
{formatNumber(m.inputTokens)}
</td>
<td className="py-2 pr-4 text-right font-mono text-emerald-400">
${m.inputCost.toFixed(2)}
</td>
<td className="text-muted-foreground py-2 pr-4 text-right font-mono">
{formatNumber(m.outputTokens)}
</td>
<td className="py-2 pr-4 text-right font-mono text-emerald-400">
${m.outputCost.toFixed(2)}
</td>
<td className="py-2 text-right font-mono font-medium text-emerald-500">
${m.totalCost.toFixed(2)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-white/[0.08] font-medium">
<td className="text-foreground py-3 pr-4">Total</td>
<td className="text-muted-foreground py-3 pr-4 text-right font-mono">
{formatNumber(models.reduce((sum, m) => sum + m.inputTokens, 0))}
</td>
<td className="py-3 pr-4 text-right font-mono text-emerald-400">
${totalInputCost.toFixed(2)}
</td>
<td className="text-muted-foreground py-3 pr-4 text-right font-mono">
{formatNumber(models.reduce((sum, m) => sum + m.outputTokens, 0))}
</td>
<td className="py-3 pr-4 text-right font-mono text-emerald-400">
${totalOutputCost.toFixed(2)}
</td>
<td className="py-3 text-right font-mono font-bold text-emerald-500">
${totalCost.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
</div>
</CardContent>
</Card>
);
};
+171
View File
@@ -0,0 +1,171 @@
import { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TrendingUp, Clock, Calendar, CalendarDays } from 'lucide-react';
interface UsageBucket {
totalTokens: number;
requests: number;
}
interface ForecastCardProps {
/** Hourly buckets for the selected period (always fetched for all ranges). */
hourlyData?: UsageBucket[];
/** Daily buckets for the selected period. */
dailyData?: UsageBucket[];
range: '24h' | '7d' | '30d';
}
function formatNumber(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1)}M`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1)}K`;
}
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
/**
* Usage Averages Card.
*
* Calculates REAL averages over the time the user actually used the proxy,
* not projections over the entire selected window.
*
* Logic:
* - totalTokens = sum of all buckets in the period
* - activeHours = number of hourly buckets with totalTokens > 0
* - activeDays = number of daily buckets with totalTokens > 0
* - avgPerHour = totalTokens / activeHours (real average, no padding)
* - avgPerDay = totalTokens / activeDays (real average, no padding)
*
* For the 24h range, avgPerDay equals totalTokens (since it's a single day).
* For 7d/30d, avgPerDay = totalTokens / activeDays.
*
* The card title says "Usage Averages" (not "Forecast") because these are
* real historical averages, not projections.
*/
export function ForecastCard({ hourlyData, dailyData, range }: ForecastCardProps) {
const stats = useMemo(() => {
const hours = hourlyData ?? [];
const days = dailyData ?? [];
const totalTokens =
hours.length > 0
? hours.reduce((acc, d) => acc + d.totalTokens, 0)
: days.reduce((acc, d) => acc + d.totalTokens, 0);
const totalRequests =
hours.length > 0
? hours.reduce((acc, d) => acc + d.requests, 0)
: days.reduce((acc, d) => acc + d.requests, 0);
// Active hours = hourly buckets where there was actual usage
const activeHours = hours.filter((d) => d.totalTokens > 0).length;
// Active days = daily buckets where there was actual usage
const activeDays = days.filter((d) => d.totalTokens > 0).length;
// Real average per active hour
const avgPerHour = activeHours > 0 ? totalTokens / activeHours : 0;
// Real average per active day
const avgPerDay = activeDays > 0 ? totalTokens / activeDays : 0;
// Average tokens per request
const avgPerRequest = totalRequests > 0 ? totalTokens / totalRequests : 0;
return {
totalTokens,
totalRequests,
activeHours,
activeDays,
avgPerHour,
avgPerDay,
avgPerRequest,
};
}, [hourlyData, dailyData]);
const rangeLabel = range === '24h' ? 'Last 24h' : range === '7d' ? 'Last 7 days' : 'Last 30 days';
return (
<Card className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
Usage Averages
</CardTitle>
<span className="text-muted-foreground/70 text-[10px]">{rangeLabel}</span>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="mt-2 space-y-3">
{/* Total consumed in period */}
<div>
<div className="mb-0.5 flex items-center gap-1.5">
<TrendingUp className="h-3 w-3 text-emerald-400" />
<span className="text-muted-foreground text-xs">Total in period</span>
</div>
<div className="text-foreground font-mono text-lg font-semibold">
{formatNumber(stats.totalTokens)}{' '}
<span className="text-muted-foreground font-sans text-xs font-normal">tokens</span>
</div>
<div className="text-muted-foreground/70 mt-0.5 text-[10px]">
{stats.activeHours > 0 &&
`${stats.activeHours} active hour${stats.activeHours !== 1 ? 's' : ''}`}
{stats.activeHours > 0 && stats.activeDays > 0 && ' · '}
{stats.activeDays > 0 &&
`${stats.activeDays} active day${stats.activeDays !== 1 ? 's' : ''}`}
{stats.totalRequests > 0 && ` · ${stats.totalRequests.toLocaleString()} requests`}
</div>
</div>
<div className="h-px bg-white/[0.06]" />
{/* Average per active hour */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 text-blue-400" />
<span className="text-muted-foreground text-xs">Avg / active hour</span>
</div>
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
{formatNumber(stats.avgPerHour)}
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
tokens/h
</span>
</div>
</div>
{/* Average per active day */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3 text-violet-400" />
<span className="text-muted-foreground text-xs">Avg / active day</span>
</div>
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
{formatNumber(stats.avgPerDay)}
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
tokens/d
</span>
</div>
</div>
{/* Average per request */}
{stats.totalRequests > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<CalendarDays className="h-3 w-3 text-amber-400" />
<span className="text-muted-foreground text-xs">Avg / request</span>
</div>
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
{formatNumber(stats.avgPerRequest)}
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
tokens/req
</span>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}
+276
View File
@@ -0,0 +1,276 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Loader2, RefreshCw, RotateCcw } from 'lucide-react';
import { MODEL_PRICING } from '@/constants/pricing';
export interface ModelPricingDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
customPricing: Record<string, { input: number; output: number; source?: string }>;
onSavePricing: (
pricing: Record<string, { input: number; output: number; source: string }>,
) => void;
availableModels: string[];
}
export const ModelPricingDialog: React.FC<ModelPricingDialogProps> = ({
open,
onOpenChange,
customPricing,
onSavePricing,
availableModels,
}) => {
const [localPricing, setLocalPricing] = useState<
Record<string, { input: number; output: number; source: string }>
>({});
const [isFetching, setIsFetching] = useState(false);
useEffect(() => {
if (open) {
// Initialize local state with custom pricing or empty if default
const initial: Record<string, { input: number; output: number; source: string }> = {};
Object.entries(customPricing || {}).forEach(([model, price]) => {
initial[model] = {
input: price.input,
output: price.output,
source: price.source || 'Custom',
};
});
setLocalPricing(initial);
}
}, [open, customPricing]);
const handlePriceChange = (model: string, type: 'input' | 'output', value: string) => {
const numValue = parseFloat(value);
if (isNaN(numValue) && value !== '') return;
setLocalPricing((prev) => {
const current = prev[model] || {
input: getBasePrice(model, 'input'),
output: getBasePrice(model, 'output'),
source: 'Default',
};
return {
...prev,
[model]: {
...current,
[type]: value === '' ? 0 : numValue,
source: 'Custom',
},
};
});
};
const handleReset = (model: string) => {
setLocalPricing((prev) => {
const next = { ...prev };
delete next[model];
return next;
});
};
const getBasePrice = (model: string, type: 'input' | 'output'): number => {
const normalizedModel = model.toLowerCase().replace('models/', '');
let pricing = MODEL_PRICING['default'];
for (const [key, value] of Object.entries(MODEL_PRICING)) {
if (normalizedModel.includes(key)) {
pricing = value;
break;
}
}
return pricing[type];
};
const getDisplayPrice = (model: string, type: 'input' | 'output'): number | string => {
if (localPricing[model]) {
return localPricing[model][type];
}
return ''; // Show empty to display placeholder
};
const getSource = (model: string) => {
if (localPricing[model]) {
return localPricing[model].source;
}
return 'Default';
};
const fetchOpenRouterPricing = async () => {
setIsFetching(true);
try {
const response = await fetch('https://openrouter.ai/api/v1/models');
const data = await response.json();
if (data && data.data) {
const newPricing = { ...localPricing };
// Try to match available models with OpenRouter models
availableModels.forEach((model) => {
// Simplistic matching - OpenRouter models often have provider prefixes
const orModel = data.data.find(
(m: any) =>
m.id.endsWith(model) || m.id.includes(model) || model.includes(m.id.split('/').pop()),
);
if (orModel && orModel.pricing) {
// OpenRouter pricing is per token, we need per 1M tokens
const inputPrice = parseFloat(orModel.pricing.prompt) * 1_000_000;
const outputPrice = parseFloat(orModel.pricing.completion) * 1_000_000;
if (!isNaN(inputPrice) && !isNaN(outputPrice)) {
newPricing[model] = {
input: Number(inputPrice.toFixed(4)),
output: Number(outputPrice.toFixed(4)),
source: 'OpenRouter',
};
}
}
});
setLocalPricing(newPricing);
}
} catch (error) {
console.error('Failed to fetch OpenRouter pricing:', error);
} finally {
setIsFetching(false);
}
};
const handleSave = () => {
onSavePricing(localPricing);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col">
<DialogHeader>
<DialogTitle>Model Pricing Configuration</DialogTitle>
<DialogDescription>
Set custom pricing per model (USD per 1M tokens). These values are used to calculate
cost estimates.
</DialogDescription>
</DialogHeader>
<div className="mb-2 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={fetchOpenRouterPricing}
disabled={isFetching}
className="h-8 text-xs"
>
{isFetching ? (
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
) : (
<RefreshCw className="mr-2 h-3 w-3" />
)}
Fetch from OpenRouter
</Button>
</div>
<div className="custom-scrollbar flex-1 overflow-y-auto rounded-md border pr-2">
<table className="w-full text-left text-sm">
<thead className="bg-muted/50 sticky top-0 z-10">
<tr>
<th className="px-4 py-3 font-medium">Model</th>
<th className="w-[120px] px-4 py-3 font-medium">Input Price</th>
<th className="w-[120px] px-4 py-3 font-medium">Output Price</th>
<th className="w-[100px] px-4 py-3 font-medium">Source</th>
<th className="w-[60px] px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{availableModels.length === 0 ? (
<tr>
<td colSpan={5} className="text-muted-foreground px-4 py-8 text-center">
No models have been used yet.
</td>
</tr>
) : (
availableModels.map((model) => {
const source = getSource(model);
return (
<tr key={model} className="hover:bg-muted/30">
<td className="px-4 py-2 font-mono text-xs">{model}</td>
<td className="px-4 py-2">
<Input
type="number"
step="0.01"
min="0"
placeholder={getBasePrice(model, 'input').toString()}
value={getDisplayPrice(model, 'input')}
onChange={(e) => handlePriceChange(model, 'input', e.target.value)}
className="h-8 font-mono text-xs"
/>
</td>
<td className="px-4 py-2">
<Input
type="number"
step="0.01"
min="0"
placeholder={getBasePrice(model, 'output').toString()}
value={getDisplayPrice(model, 'output')}
onChange={(e) => handlePriceChange(model, 'output', e.target.value)}
className="h-8 font-mono text-xs"
/>
</td>
<td className="px-4 py-2">
<Badge
variant={source === 'Default' ? 'outline' : 'secondary'}
className={`text-[10px] ${
source === 'Custom'
? 'bg-blue-500/10 text-blue-500 hover:bg-blue-500/20'
: source === 'OpenRouter'
? 'bg-purple-500/10 text-purple-500 hover:bg-purple-500/20'
: ''
}`}
>
{source}
</Badge>
</td>
<td className="px-4 py-2 text-right">
{source !== 'Default' && (
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-6 w-6"
onClick={() => handleReset(model)}
title="Reset to default"
>
<RotateCcw className="h-3 w-3" />
</Button>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<DialogFooter className="mt-4">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+59
View File
@@ -0,0 +1,59 @@
import clsx from 'clsx';
interface ModelQuotaBarProps {
modelName: string;
usedTokens: number;
limitTokens?: number;
}
export function ModelQuotaBar({ modelName, usedTokens, limitTokens }: ModelQuotaBarProps) {
const formatNumber = (n: number) => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
};
const usagePercentage = limitTokens ? (usedTokens / limitTokens) * 100 : 0;
const displayPercentage = limitTokens ? Math.min(Math.round(usagePercentage), 100) : null;
const barPercentage = limitTokens ? Math.min(usagePercentage, 100) : 100;
let colorClass = 'bg-slate-500'; // Default if no limit
if (limitTokens) {
if (usagePercentage < 80) colorClass = 'bg-emerald-500';
else if (usagePercentage < 90) colorClass = 'bg-amber-500';
else colorClass = 'bg-rose-500';
}
return (
<div className="group mb-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-muted-foreground group-hover:text-foreground max-w-[140px] truncate text-xs transition-colors">
{modelName}
</span>
<div className="flex items-center gap-2">
<span className="text-foreground font-mono text-[11px] font-medium tabular-nums">
{formatNumber(usedTokens)}
{limitTokens ? ` / ${formatNumber(limitTokens)}` : ''}
</span>
{displayPercentage !== null && (
<span
className={clsx('w-8 text-right text-[10px] font-semibold', {
'text-emerald-400': usagePercentage < 80,
'text-amber-400': usagePercentage >= 80 && usagePercentage < 90,
'text-rose-400': usagePercentage >= 90,
})}
>
{displayPercentage}%
</span>
)}
</div>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/[0.04]">
<div
className={clsx('h-full rounded-full transition-all duration-500', colorClass)}
style={{ width: `${barPercentage}%`, opacity: 0.8 }}
/>
</div>
</div>
);
}
@@ -1,206 +1,204 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
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 */}
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
<div className="border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">
{t('proxy.advanced.circuitBreaker.title')}
</h4>
</div>
<div className="p-5">
{cbLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : !circuitBreakers ? (
<p className="text-[13px] 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]) => (
<div key={api} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4">
<div className="flex items-center justify-between">
<span className="text-[13px] 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>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* 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-[13px] 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>
);
};
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
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 || []).map((log) => ({
...log,
model: log.metadata?.model as string | undefined,
tokensPrompt: log.metadata?.tokensPrompt as number | undefined,
tokensCompletion: log.metadata?.tokensCompletion as number | undefined,
tokensTotal:
(((log.metadata?.tokensPrompt as number) || 0) +
((log.metadata?.tokensCompletion as number) || 0)) ||
undefined,
}))
: [];
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 */}
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<div className="border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.circuitBreaker.title')}</h4>
</div>
<div className="p-5">
{cbLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : !circuitBreakers ? (
<p className="text-muted-foreground text-[13px]">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]) => (
<div
key={api}
className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"
>
<div className="flex items-center justify-between">
<span className="text-[13px] font-medium">{api}</span>
<Badge variant={getCircuitBadgeVariant(state.state)}>{state.state}</Badge>
</div>
<div className="text-muted-foreground mt-2 text-xs">
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
</div>
{state.lastFailure && (
<div className="text-muted-foreground text-xs">
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
{new Date(state.lastFailure).toLocaleString()}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* 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-[13px] font-semibold">{t('proxy.advanced.trafficLog.title')}</h3>
{stats && <span className="text-muted-foreground text-xs">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>
);
};
+17 -17
View File
@@ -1,11 +1,18 @@
/** Premium chart palette — refined for dark mode contrast */
export const COLOR_PROMPT = '#60a5fa'; // blue-400
export const COLOR_PROMPT = '#60a5fa'; // blue-400
export const COLOR_COMPLETION = '#34d399'; // emerald-400
export const COLOR_TOTAL = '#fbbf24'; // amber-400
export const COLOR_TOTAL = '#fbbf24'; // amber-400
export const PIE_COLORS = [
'#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#f472b6',
'#38bdf8', '#4ade80', '#facc15', '#c084fc',
'#60a5fa',
'#34d399',
'#fbbf24',
'#a78bfa',
'#f472b6',
'#38bdf8',
'#4ade80',
'#facc15',
'#c084fc',
];
export const MODEL_COLORS: Record<string, string> = {
@@ -31,10 +38,7 @@ export function BarTooltip({ indexValue, data }: any) {
<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="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]}
@@ -50,10 +54,7 @@ export function PieTooltip({ datum }: any) {
return (
<div className="bg-popover rounded-lg border border-white/[0.06] 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="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}
@@ -66,12 +67,11 @@ export function PieTooltip({ datum }: any) {
export function LineTooltip({ point }: any) {
return (
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
<p className="text-foreground mb-1 text-xs font-medium">{point.data.xFormatted ?? point.data.x}</p>
<p className="text-foreground mb-1 text-xs font-medium">
{point.data.xFormatted ?? point.data.x}
</p>
<div className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: point.serieColor }}
/>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: point.serieColor }} />
<span className="text-muted-foreground">{point.serieId}:</span>
<span className="text-foreground font-mono font-medium">
{typeof point.data.y === 'number' ? point.data.y.toLocaleString() : point.data.y}
+4 -11
View File
@@ -11,7 +11,7 @@ interface TrendInfo {
}
interface StatCardProps {
label: string;
label: React.ReactNode;
value: number | string;
icon: LucideIcon;
isLoading?: boolean;
@@ -82,10 +82,7 @@ function Sparkline({ data, accent }: { data: number[]; accent: string }) {
</linearGradient>
</defs>
{/* Area fill */}
<polygon
points={`0,${h} ${points.join(' ')} ${w},${h}`}
fill={`url(#sparkGrad-${accent})`}
/>
<polygon points={`0,${h} ${points.join(' ')} ${w},${h}`} fill={`url(#sparkGrad-${accent})`} />
{/* Line */}
<polyline
points={points.join(' ')}
@@ -103,11 +100,7 @@ function TrendBadge({ trend }: { trend: TrendInfo }) {
const isUp = trend.direction === 'up';
const isNeutral = trend.direction === 'neutral' || trend.value === 0;
const Icon = isNeutral ? Minus : isUp ? ArrowUpRight : ArrowDownRight;
const color = isNeutral
? 'text-muted-foreground'
: isUp
? 'text-emerald-400'
: 'text-rose-400';
const color = isNeutral ? 'text-muted-foreground' : isUp ? 'text-emerald-400' : 'text-rose-400';
return (
<div className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
@@ -130,7 +123,7 @@ export const StatCard: React.FC<StatCardProps> = ({
return (
<div
className={cn(
'group relative rounded-xl border border-white/[0.06] bg-card p-4 transition-all duration-200',
'group bg-card relative rounded-xl border border-white/[0.06] p-4 transition-all duration-200',
'hover:border-white/[0.1] hover:bg-white/[0.02]',
className,
)}
+388
View File
@@ -0,0 +1,388 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, ArrowUp, ArrowDown, ChevronRight, ChevronDown } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { calculateCost } from '@/constants/pricing';
interface TrafficLogEntry {
timestamp: string;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
durationMs?: number;
tokensPrompt?: number;
tokensCompletion?: number;
tokensTotal?: number;
model?: string;
body?: unknown;
headers?: Record<string, unknown>;
}
interface TrafficLogTableProps {
logs: TrafficLogEntry[];
isLoading: boolean;
}
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'model' | 'method' | 'status' | 'tokens' | 'cost' | 'duration';
type SortDir = 'asc' | 'desc';
function SortIcon({
column,
sortKey,
sortDir,
}: {
column: SortKey;
sortKey: SortKey;
sortDir: SortDir;
}) {
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" />
);
}
const formatTokens = (tokens?: number) => {
if (tokens == null) return '—';
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
return tokens.toString();
};
/**
* Safely format a JSON body for display.
* Truncates very large bodies and handles non-object values.
*/
function formatBody(body: unknown, maxLength = 8000): string {
if (body == null) return 'No data';
try {
const str = typeof body === 'string' ? body : JSON.stringify(body, null, 2);
if (str.length > maxLength) {
return str.slice(0, maxLength) + `\n\n... [truncated ${str.length - maxLength} chars]`;
}
return str;
} catch {
return String(body);
}
}
/**
* Extract a short preview of the message content from a request/response body.
*/
function getBodyPreview(body: unknown): string | null {
if (!body || typeof body !== 'object') return null;
const obj = body as Record<string, unknown>;
// Request body: extract first user message
if (Array.isArray(obj.messages)) {
const userMsg = obj.messages.find(
(m: Record<string, unknown>) => m.role === 'user',
);
if (userMsg) {
const content = typeof userMsg.content === 'string'
? userMsg.content
: JSON.stringify(userMsg.content);
return content.length > 120 ? content.slice(0, 120) + '…' : content;
}
}
// Response body: extract assistant content
if (Array.isArray(obj.choices)) {
const firstChoice = obj.choices[0] as Record<string, unknown> | undefined;
const message = firstChoice?.message as Record<string, unknown> | undefined;
if (message?.content && typeof message.content === 'string') {
return message.content.length > 120
? message.content.slice(0, 120) + '…'
: message.content;
}
}
return null;
}
export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoading }) => {
const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [expandedId, setExpandedId] = useState<string | null>(null);
const toggleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
};
const toggleExpand = (logId: string) => {
setExpandedId((prev) => (prev === logId ? null : logId));
};
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 'model':
return (a.model || '').localeCompare(b.model || '') * 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 'tokens': {
const aTokens = a.tokensTotal ?? -1;
const bTokens = b.tokensTotal ?? -1;
return (aTokens - bTokens) * dir;
}
case 'cost': {
const aCost = calculateCost(a.model || '', a.tokensPrompt || 0, a.tokensCompletion || 0);
const bCost = calculateCost(b.model || '', b.tokensPrompt || 0, b.tokensCompletion || 0);
return (aCost - bCost) * dir;
}
case 'duration': {
const aDur = a.durationMs ?? -1;
const bDur = b.durationMs ?? -1;
return (aDur - bDur) * dir;
}
default:
return 0;
}
});
}, [logs, sortKey, sortDir]);
const COL_COUNT = 9;
return (
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<div className="border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.trafficLog.title')}</h4>
</div>
<div className="p-5">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : logs.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-[13px]">
{t('proxy.advanced.trafficLog.noLogs')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b border-white/[0.06] text-left text-[10px] font-medium tracking-wider uppercase">
{/* Expand indicator column */}
<th className="w-6 pb-2" />
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('timestamp')}
>
{t('proxy.advanced.trafficLog.timestamp')}
<SortIcon column="timestamp" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('direction')}
>
{t('proxy.advanced.trafficLog.direction')}
<SortIcon column="direction" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('endpoint')}
>
{t('proxy.advanced.trafficLog.endpoint')}
<SortIcon column="endpoint" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('model')}
>
Model
<SortIcon column="model" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('status')}
>
{t('proxy.advanced.trafficLog.status')}
<SortIcon column="status" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('tokens')}
>
Input / Output
<SortIcon column="tokens" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pr-4 pb-2 font-medium select-none"
onClick={() => toggleSort('duration')}
>
{t('proxy.advanced.trafficLog.duration')}
<SortIcon column="duration" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 font-medium select-none"
onClick={() => toggleSort('cost')}
>
Cost
<SortIcon column="cost" sortKey={sortKey} sortDir={sortDir} />
</th>
</tr>
</thead>
<tbody>
{sortedLogs.map((log, i) => {
const logKey = `${log.requestId}-${i}`;
const isExpanded = expandedId === logKey;
const preview = getBodyPreview(log.body);
return (
<React.Fragment key={logKey}>
<tr
className={`cursor-pointer border-b border-white/[0.04] transition-colors last:border-0 ${
isExpanded
? 'bg-white/[0.03]'
: 'hover:bg-white/[0.02]'
}`}
onClick={() => toggleExpand(logKey)}
>
{/* Expand chevron */}
<td className="py-2 pr-1">
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
)}
</td>
<td className="text-muted-foreground py-2 pr-4 font-mono text-xs">
{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.model || '—'}</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 pr-4 text-xs">
{log.tokensTotal ? (
`${formatTokens(log.tokensPrompt)} / ${formatTokens(log.tokensCompletion)}`
) : (
'—'
)}
</td>
<td className="py-2 pr-4 text-xs">
{log.durationMs != null ? `${log.durationMs}ms` : '—'}
</td>
<td className="py-2 text-xs text-emerald-400">
{log.tokensTotal && log.model
? `$${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)}`
: '—'}
</td>
</tr>
{/* Expanded detail row */}
{isExpanded && (
<tr className="border-b border-white/[0.04]">
<td colSpan={COL_COUNT} className="p-0">
<div className="border-t border-white/[0.06] bg-white/[0.01] px-6 py-4">
{/* Preview line */}
{preview && (
<div className="mb-3 rounded-lg bg-white/[0.03] px-3 py-2 text-xs text-muted-foreground italic break-words">
"{preview}"
</div>
)}
{/* Summary badges */}
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<Badge variant="outline" className="font-mono text-[10px]">
ID: {log.requestId.slice(0, 8)}
</Badge>
{log.model && (
<Badge variant="secondary" className="text-[10px]">
{log.model}
</Badge>
)}
{log.tokensPrompt != null && (
<Badge variant="outline" className="border-blue-500/20 text-blue-400 text-[10px]">
Input: {log.tokensPrompt.toLocaleString()} tokens
</Badge>
)}
{log.tokensCompletion != null && (
<Badge variant="outline" className="border-emerald-500/20 text-emerald-400 text-[10px]">
Output: {log.tokensCompletion.toLocaleString()} tokens
</Badge>
)}
{log.tokensTotal != null && log.model && (
<Badge variant="outline" className="border-amber-500/20 text-amber-400 text-[10px]">
Cost: ${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)}
</Badge>
)}
{log.durationMs != null && (
<Badge variant="outline" className="text-[10px]">
{log.durationMs}ms
</Badge>
)}
</div>
{/* Body content */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wider">
{log.direction === 'inbound' ? 'Request Body' : log.direction === 'outbound' ? 'Response Body' : 'Payload'}
</span>
</div>
<pre className="custom-scrollbar max-h-[400px] overflow-auto rounded-lg bg-black/30 p-3 font-mono text-[11px] leading-relaxed text-foreground/80 whitespace-pre-wrap break-all">
{formatBody(log.body)}
</pre>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
+36
View File
@@ -0,0 +1,36 @@
// Costs in USD per 1M tokens
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
'gemini-3.1-pro': { input: 1.25, output: 5.0 },
'gemini-3.1-pro-preview': { input: 1.25, output: 5.0 },
'gemini-3-flash': { input: 0.075, output: 0.3 },
'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
'claude-sonnet-4-6': { input: 3.0, output: 15.0 },
// fallback for unknowns
default: { input: 1.0, output: 4.0 },
};
export function calculateCost(
model: string,
inputTokens: number,
outputTokens: number,
customPricing?: Record<string, { input: number; output: number }>,
): number {
// Check custom pricing first
if (customPricing && customPricing[model]) {
const cp = customPricing[model];
return (inputTokens / 1_000_000) * cp.input + (outputTokens / 1_000_000) * cp.output;
}
// Normalize model name roughly for default matching
const normalizedModel = model.toLowerCase().replace('models/', '');
let pricing = MODEL_PRICING['default'];
for (const [key, value] of Object.entries(MODEL_PRICING)) {
if (normalizedModel.includes(key)) {
pricing = value;
break;
}
}
return (inputTokens / 1_000_000) * pricing.input + (outputTokens / 1_000_000) * pricing.output;
}
+1 -1
View File
@@ -685,7 +685,7 @@ export async function setAutoSwitchEnabled(enabled: boolean): Promise<void> {
// Trigger an immediate check if enabled
if (enabled) {
const { CloudMonitorService } = await import('../../services/CloudMonitorService');
CloudMonitorService.poll().catch((err: unknown) =>
CloudMonitorService.poll().catch((err: unknown) =>
logger.error('Failed to poll after enabling auto-switch', err),
);
}
+20 -4
View File
@@ -193,7 +193,11 @@ export const cloudRouter = os.router({
try {
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
} catch (error: unknown) {
logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
logger.error(
'[ORPC] setAccountProxy error:',
extractErrorMessage(error),
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}),
@@ -204,7 +208,11 @@ export const cloudRouter = os.router({
return result;
} catch (error: unknown) {
logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
logger.error(
'[ORPC] syncLocalAccount error:',
extractErrorMessage(error),
error instanceof Error ? error.stack : undefined,
);
throw toSyncLocalAccountORPCError(error);
}
}),
@@ -274,7 +282,11 @@ export const cloudRouter = os.router({
try {
return await exportCloudAccounts(input.stripTokens);
} catch (error: unknown) {
logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
logger.error(
'[ORPC] exportCloudAccounts error:',
extractErrorMessage(error),
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}),
@@ -298,7 +310,11 @@ export const cloudRouter = os.router({
try {
return await importCloudAccounts(input.jsonContent, input.strategy);
} catch (error: unknown) {
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
logger.error(
'[ORPC] importCloudAccounts error:',
extractErrorMessage(error),
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}),
+51 -21
View File
@@ -89,12 +89,8 @@ export function ensureProxyMetricsTable(rawDb: Database.Database): void {
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);`,
);
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;
@@ -201,17 +197,30 @@ export class ProxyMetricsRepo {
const reqRow = raw
.prepare(
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
MIN(timestamp) as minTimestamp, MAX(timestamp) as maxTimestamp,
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 }
| {
total: number;
avgLatency: number | null;
errorRate: number;
minTimestamp: number | null;
maxTimestamp: number | null;
}
| undefined;
const totalRequests = reqRow?.total ?? 0;
const minutes = Math.max(windowMs / 60_000, 1);
let minutes = Math.max(windowMs / 60_000, 1);
if (reqRow?.minTimestamp && reqRow?.maxTimestamp && totalRequests > 0) {
const spanMs = reqRow.maxTimestamp - reqRow.minTimestamp;
minutes = Math.max(spanMs / 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;
@@ -244,7 +253,9 @@ export class ProxyMetricsRepo {
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);
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);
@@ -298,7 +309,10 @@ export class TrafficLogsRepo {
}
}
static getRecent(limit: number = 50, direction?: string): Array<{
static getRecent(
limit: number = 50,
direction?: string,
): Array<{
timestamp: number;
direction: string;
requestId: string;
@@ -349,10 +363,13 @@ export class TrafficLogsRepo {
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,
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) {
@@ -361,10 +378,18 @@ export class TrafficLogsRepo {
}
}
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
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<{
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;
@@ -400,7 +425,9 @@ export class TrafficLogsRepo {
}> {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
const rows = raw
.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`)
.all() as Array<{
timestamp: number;
direction: string;
request_id: string;
@@ -427,10 +454,13 @@ export class TrafficLogsRepo {
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,
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) {
+1 -7
View File
@@ -1,9 +1,5 @@
import { logger } from '../../utils/logger';
import {
bootstrapNestServer,
isNestServerRunning,
stopNestServer,
} from '../../server/main';
import { bootstrapNestServer, isNestServerRunning, stopNestServer } from '../../server/main';
import { ConfigManager } from '../config/manager';
/**
@@ -25,8 +21,6 @@ export async function closeGeminiNexus(): Promise<void> {
await stopNestServer();
}
/**
* Waits for the Gemini Nexus process to exit.
* Polls the NestJS server status until it stops or the timeout is reached.
+202 -30
View File
@@ -1,8 +1,13 @@
import { logger } from '../../utils/logger';
import { getServiceOrThrow } from './service-registry';
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
import { getServiceOrThrow, getServiceOptional } from './service-registry';
import type {
IdeConfigResult,
SupportedIde,
} from '../../server/modules/proxy/proxy-ide-config.service';
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
import { CloudAccountRepo } from '../database/cloudHandler';
import { ConfigManager } from '../config/manager';
import { networkInterfaces } from 'os';
export interface TrafficLogEntryParsed {
timestamp: string;
@@ -84,7 +89,15 @@ export async function exportTrafficLogs(
}
// CSV format
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
const headers = [
'timestamp',
'direction',
'requestId',
'endpoint',
'method',
'status',
'durationMs',
];
const rowsCsv = rows.map((e) =>
[
new Date(e.timestamp).toISOString(),
@@ -108,11 +121,27 @@ export async function exportTrafficLogs(
export async function getParityCounters(): Promise<{
success: boolean;
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
data?: {
totalRequests: number;
matchedRequests: number;
parityViolations: number;
lastUpdated: string;
};
error?: string;
}> {
try {
const tokenManager = getServiceOrThrow('tokenManager');
const tokenManager = getServiceOptional('tokenManager');
if (!tokenManager) {
return {
success: true,
data: {
totalRequests: 0,
matchedRequests: 0,
parityViolations: 0,
lastUpdated: new Date().toISOString(),
},
};
}
return {
success: true,
data: tokenManager.getParityCounters(),
@@ -124,14 +153,24 @@ export async function getParityCounters(): Promise<{
export async function getCircuitBreakerStatus(): Promise<{
success: boolean;
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
data?: {
states: Record<string, { state: string; failures: number; lastFailure: string | null }>;
globalEnabled: boolean;
};
error?: string;
}> {
try {
const tokenManager = getServiceOrThrow('tokenManager');
const tokenManager = getServiceOptional('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 }> = {};
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> =
{};
for (const [accountId, entry] of Object.entries(rawStatus)) {
states[accountId] = {
state: entry.state,
@@ -154,7 +193,7 @@ export async function getCircuitBreakerStatus(): Promise<{
export async function getProxyMetrics(): Promise<{
success: boolean;
data?: {
data?: {
uptimeSeconds: number;
totalRequests: number;
activeConnections: number;
@@ -166,7 +205,21 @@ export async function getProxyMetrics(): Promise<{
error?: string;
}> {
try {
const proxyMetrics = getServiceOrThrow('proxyMetrics');
const proxyMetrics = getServiceOptional('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 {
@@ -187,9 +240,14 @@ export async function getProxyMetrics(): Promise<{
}
}
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
export async function getRecentRequests(): Promise<{
success: boolean;
data?: unknown[];
error?: string;
}> {
try {
const proxyReplay = getServiceOrThrow('proxyReplay');
const proxyReplay = getServiceOptional('proxyReplay');
if (!proxyReplay) return { success: true, data: [] };
return {
success: true,
data: proxyReplay.getRecentRequests(20),
@@ -201,7 +259,11 @@ export async function getRecentRequests(): Promise<{ success: boolean; data?: un
export async function replayRequest(
requestId: string,
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
): Promise<{
success: boolean;
data?: { original: unknown; newResponse: unknown };
error?: string;
}> {
try {
const proxyReplay = getServiceOrThrow('proxyReplay');
const result = await proxyReplay.replayRequest(requestId);
@@ -278,14 +340,14 @@ export async function getModelCapabilities(): Promise<{
}
}
function mapIdeConfigResult(
result: IdeConfigResult,
): {
function mapIdeConfigResult(result: IdeConfigResult): {
name: string;
settingsPath: string;
proxySetting: string;
apiKeySetting: string;
instructions: string;
proxySetting?: string;
apiKeySetting?: string;
instructions?: string;
format: 'json' | 'shell' | 'env' | 'instructions';
content: Record<string, unknown> | string;
} {
switch (result.ide) {
case 'vscode':
@@ -295,6 +357,8 @@ function mapIdeConfigResult(
proxySetting: 'openai.url',
apiKeySetting: 'openai.apiKey',
instructions: 'Add these settings to your VS Code settings.json',
format: result.format,
content: result.content as Record<string, unknown>,
};
case 'cursor':
return {
@@ -303,6 +367,8 @@ function mapIdeConfigResult(
proxySetting: 'OPENAI_BASE_URL',
apiKeySetting: 'OPENAI_API_KEY',
instructions: 'Set these environment variables for Cursor',
format: result.format,
content: result.content as Record<string, unknown>,
};
case 'claude-code':
return {
@@ -311,6 +377,8 @@ function mapIdeConfigResult(
proxySetting: 'ANTHROPIC_BASE_URL',
apiKeySetting: 'ANTHROPIC_API_KEY',
instructions: 'Run these export commands before starting Claude Code',
format: result.format,
content: result.content as string,
};
case 'jetbrains':
return {
@@ -319,6 +387,8 @@ function mapIdeConfigResult(
proxySetting: 'Base URL',
apiKeySetting: 'API Key',
instructions: String(result.content),
format: result.format,
content: result.content as string,
};
case 'opencide':
return {
@@ -327,9 +397,19 @@ function mapIdeConfigResult(
proxySetting: 'OPENAI_BASE_URL',
apiKeySetting: 'OPENAI_API_KEY',
instructions: 'Add these lines to your .env file',
format: result.format,
content: result.content as string,
};
case 'opencode':
return {
name: 'OpenCode',
settingsPath: '.config/opencode/providers.json',
instructions: 'Add this snippet to your providers.json',
format: result.format,
content: result.content as Record<string, unknown>,
};
default: {
const exhaustiveCheck: never = result.ide;
const exhaustiveCheck: never = result.ide as never;
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
}
}
@@ -337,29 +417,121 @@ function mapIdeConfigResult(
export async function generateIdeConfig(
ide: string,
defaultModel?: string,
): Promise<{
success: boolean;
data?: {
name: string;
settingsPath: string;
proxySetting: string;
apiKeySetting: string;
instructions: string;
proxySetting?: string;
apiKeySetting?: string;
instructions?: string;
format: 'json' | 'shell' | 'env' | 'instructions';
content: Record<string, unknown> | string;
};
error?: string;
}> {
try {
const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig');
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
const supportedIdes = [
'cursor',
'vscode',
'claude-code',
'jetbrains',
'opencide',
'opencode',
] 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' };
// Attempt to use NestJS service if running, fallback to manual generation
const proxyIdeConfig = getServiceOptional('proxyIdeConfig');
if (proxyIdeConfig) {
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde, defaultModel);
if (result) return { success: true, data: mapIdeConfigResult(result) };
}
// Fallback logic when proxy is stopped
const config = ConfigManager.getCachedConfig()?.proxy;
const port = config?.port || 8045;
const apiKey = config?.api_key || 'YOUR_API_KEY';
let localIp = '127.0.0.1';
const interfaces = networkInterfaces();
for (const iface of Object.values(interfaces)) {
if (iface) {
for (const entry of iface) {
if (entry.family === 'IPv4' && !entry.internal) {
localIp = entry.address;
break;
}
}
}
}
// Fallback generator mimicking the proxyIdeConfig.service
const baseUrl = `http://${localIp}:${port}/v1`;
let result: IdeConfigResult;
switch (normalizedIde) {
case 'cursor':
result = {
ide: normalizedIde,
format: 'json',
content: { OPENAI_API_KEY: apiKey, OPENAI_BASE_URL: baseUrl },
};
break;
case 'vscode':
result = {
ide: normalizedIde,
format: 'json',
content: { 'openai.url': baseUrl, 'openai.apiKey': apiKey },
};
break;
case 'claude-code':
result = {
ide: normalizedIde,
format: 'shell',
content: `export OPENAI_API_KEY=${apiKey} && export OPENAI_BASE_URL=${baseUrl}`,
};
break;
case 'jetbrains':
result = {
ide: normalizedIde,
format: 'instructions',
content: [
'JetBrains AI Assistant Setup:',
'1. Open Settings (File > Settings or Ctrl+Alt+S).',
'2. Navigate to Languages & Frameworks > AI Assistant.',
'3. Under "Custom OpenAI Endpoint", set:',
` - Base URL: ${baseUrl}`,
` - API Key: ${apiKey}`,
'4. Click "Apply" and restart the IDE if prompted.',
].join('\n'),
};
break;
case 'opencide':
result = {
ide: normalizedIde,
format: 'env',
content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`,
};
break;
case 'opencode':
result = {
ide: normalizedIde,
format: 'json',
content: {
apiType: 'openai',
apiKey: apiKey,
baseURL: baseUrl,
models: { chat: [defaultModel || 'gemini-3.1-pro-high'] },
},
};
break;
default:
throw new Error('Unsupported');
}
return {
+7 -5
View File
@@ -155,9 +155,11 @@ const IdeConfigResponseSchema = z.object({
.object({
name: z.string(),
settingsPath: z.string(),
proxySetting: z.string(),
apiKeySetting: z.string(),
instructions: z.string(),
proxySetting: z.string().optional(),
apiKeySetting: z.string().optional(),
instructions: z.string().optional(),
format: z.enum(['json', 'shell', 'env', 'instructions']).optional(),
content: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(),
})
.optional(),
error: z.string().optional(),
@@ -204,10 +206,10 @@ export const proxyAdvancedRouter = os.router({
}),
generateIdeConfig: os
.input(z.object({ ide: z.string() }))
.input(z.object({ ide: z.string(), defaultModel: z.string().optional() }))
.output(IdeConfigResponseSchema)
.handler(async ({ input }) => {
return generateIdeConfig(input.ide);
return generateIdeConfig(input.ide, input.defaultModel);
}),
replayRequest: os
@@ -37,3 +37,9 @@ export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
return svc;
}
export function getServiceOptional<K extends keyof ProxyAdvancedRegistry>(
key: K,
): ProxyAdvancedRegistry[K] {
return proxyAdvancedRegistry[key];
}
+67 -156
View File
@@ -1,19 +1,10 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useRef } from 'react';
import { Link, Outlet, useLocation } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { StatusBar } from '@/components/StatusBar';
import {
LayoutDashboard,
Settings,
Network,
ChevronLeft,
ChevronRight,
RefreshCw,
BarChart3,
} from 'lucide-react';
import { LayoutDashboard, Settings, Network, RefreshCw, BarChart3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ErrorBoundary } from 'react-error-boundary';
import { useToast } from '@/components/ui/use-toast';
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
@@ -25,17 +16,6 @@ export const MainLayout: React.FC = () => {
const { toast } = useToast();
const hasShownRouteErrorToastRef = useRef(false);
// Initialize state from localStorage if available, default to false (expanded)
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = localStorage.getItem('sidebar-collapsed');
return saved ? JSON.parse(saved) : false;
});
// Persist state changes
useEffect(() => {
localStorage.setItem('sidebar-collapsed', JSON.stringify(isCollapsed));
}, [isCollapsed]);
const navItems = [
{
to: '/',
@@ -61,146 +41,77 @@ export const MainLayout: React.FC = () => {
return (
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
<div className="flex flex-1 overflow-hidden">
{/* Sidebar */}
<aside
className={cn(
'relative flex flex-col border-r border-white/[0.06] transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
isCollapsed ? 'w-[60px]' : 'w-56',
)}
>
{/* Collapse toggle */}
<button
className={cn(
'absolute -right-3 top-7 z-10 flex h-6 w-6 items-center justify-center rounded-full',
'bg-muted border border-white/[0.1] text-muted-foreground',
'opacity-0 transition-all duration-200 hover:bg-white/[0.1] hover:text-foreground',
'group-hover:opacity-100',
)}
style={{ opacity: 0 }}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0')}
onClick={() => setIsCollapsed(!isCollapsed)}
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3" />
) : (
<ChevronLeft className="h-3 w-3" />
)}
</button>
<header className="flex h-12 items-center border-b border-white/[0.06] px-4">
<div className="flex shrink-0 items-center gap-2.5">
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-transparent">
<img src={iconPng} alt="Gemini Nexus Logo" className="h-full w-full object-contain" />
</div>
<h1 className="text-[15px] font-semibold tracking-tight whitespace-nowrap">
Gemini Nexus
</h1>
</div>
{/* Brand */}
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-3' : 'px-4 py-5')}>
<div className="flex items-center gap-2.5 overflow-hidden whitespace-nowrap">
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-transparent">
<img
src={iconPng}
alt="Gemini Nexus Logo"
className="h-full w-full object-contain"
/>
</div>
<div
<nav className="mx-6 flex flex-1 items-center justify-center gap-1 overflow-x-auto">
{navItems.map((item) => {
const isActive = location.pathname === item.to;
return (
<Link
key={item.to}
to={item.to}
className={cn(
'overflow-hidden transition-all duration-300',
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
'flex items-center gap-2 rounded-md px-3 py-2 text-[13px] font-medium whitespace-nowrap transition-colors',
isActive
? 'text-foreground bg-white/[0.08]'
: 'text-muted-foreground hover:text-foreground hover:bg-white/[0.04]',
)}
>
<h1 className="text-[15px] font-semibold tracking-tight">Gemini Nexus</h1>
<item.icon className="h-[16px] w-[16px]" />
{item.label}
</Link>
);
})}
</nav>
<div className="shrink-0">
<StatusBar />
</div>
</header>
<main className="flex-1 overflow-auto">
<ErrorBoundary
resetKeys={[location.pathname]}
onReset={() => {
hasShownRouteErrorToastRef.current = false;
}}
onError={(error) => {
if (hasShownRouteErrorToastRef.current) {
return;
}
toast({
title: t('error.generic'),
description: getLocalizedErrorMessage(error, t),
variant: 'destructive',
});
hasShownRouteErrorToastRef.current = true;
}}
fallbackRender={({ resetErrorBoundary }) => (
<div className="mx-auto max-w-3xl p-6">
<div className="rounded-xl border border-dashed border-white/[0.06] p-8 text-center">
<div className="text-sm font-semibold">{t('error.generic')}</div>
<div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div>
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('action.retry')}
</Button>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 px-2 pt-2">
<TooltipProvider>
{navItems.map((item) => {
const isActive = location.pathname === item.to;
if (isCollapsed) {
return (
<Tooltip key={item.to} delayDuration={0}>
<TooltipTrigger asChild>
<Link
to={item.to}
className={cn(
'mx-auto flex h-9 w-9 items-center justify-center rounded-lg transition-all duration-200',
isActive
? 'bg-white/[0.08] text-foreground'
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
)}
>
<item.icon className="h-[18px] w-[18px]" />
<span className="sr-only">{item.label}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
{item.label}
</TooltipContent>
</Tooltip>
);
}
return (
<Link
key={item.to}
to={item.to}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] font-medium transition-all duration-200',
isActive
? 'bg-white/[0.08] text-foreground'
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
)}
>
<item.icon className="h-[18px] w-[18px]" />
{item.label}
</Link>
);
})}
</TooltipProvider>
</nav>
{/* Status bar */}
<div className="border-t border-white/[0.06] p-2">
<StatusBar isCollapsed={isCollapsed} />
</div>
</aside>
{/* Content Area */}
<main className="flex-1 overflow-auto transition-all duration-300">
<ErrorBoundary
resetKeys={[location.pathname]}
onReset={() => {
hasShownRouteErrorToastRef.current = false;
}}
onError={(error) => {
if (hasShownRouteErrorToastRef.current) {
return;
}
toast({
title: t('error.generic'),
description: getLocalizedErrorMessage(error, t),
variant: 'destructive',
});
hasShownRouteErrorToastRef.current = true;
}}
fallbackRender={({ resetErrorBoundary }) => (
<div className="mx-auto max-w-3xl p-6">
<div className="rounded-xl border border-white/[0.06] border-dashed p-8 text-center">
<div className="text-sm font-semibold">{t('error.generic')}</div>
<div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div>
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('action.retry')}
</Button>
</div>
</div>
)}
>
<Outlet />
</ErrorBoundary>
</main>
</div>
)}
>
<Outlet />
</ErrorBoundary>
</main>
</div>
);
};
+12 -3
View File
@@ -62,7 +62,9 @@ export class QuotaService {
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
}
} catch (error: unknown) {
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`);
logger.error(
`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`,
);
}
return [undefined, undefined];
@@ -174,7 +176,9 @@ export class QuotaService {
lastError = new Error(`HTTP ${status} - ${text}`);
shouldFallback = !isNumber(status);
} else {
logger.warn(`Request Failed at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
logger.warn(
`Request Failed at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`,
);
lastError = error instanceof Error ? error : new Error(String(error));
}
@@ -182,7 +186,12 @@ export class QuotaService {
logger.warn(`Quota API request failed at ${endpoint}, falling back to next endpoint`);
await new Promise((r) => setTimeout(r, 1000));
} else {
throw lastError ?? new Error(`Quota query failed: ${error instanceof Error ? error.message : String(error)}`);
throw (
lastError ??
new Error(
`Quota query failed: ${error instanceof Error ? error.message : String(error)}`,
)
);
}
}
}
+3 -3
View File
@@ -31,7 +31,7 @@ export const nivoTheme: PartialTheme = {
},
legend: {
text: {
fontFamily: "Inter, sans-serif",
fontFamily: 'Inter, sans-serif',
fontSize: 11,
fill: '#6b7280',
},
@@ -45,7 +45,7 @@ export const nivoTheme: PartialTheme = {
},
legends: {
text: {
fontFamily: "Inter, sans-serif",
fontFamily: 'Inter, sans-serif',
fontSize: 11,
fill: '#6b7280',
},
@@ -54,7 +54,7 @@ export const nivoTheme: PartialTheme = {
container: {
background: '#16161e',
color: '#e8eaed',
fontFamily: "Inter, sans-serif",
fontFamily: 'Inter, sans-serif',
fontSize: 12,
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.06)',
+11 -6
View File
@@ -30,6 +30,12 @@ import { ConfigManager } from './ipc/config/manager';
import { AppConfig } from './types/config';
import { isAutoStartLaunch, syncAutoStart } from './utils/autoStart';
import { safeStringifyPacket } from './utils/sensitiveDataMasking';
import { IS_DEV_ENVIRONMENT } from './utils/paths';
// Set app name early so Electron's userData path reflects the environment
if (IS_DEV_ENVIRONMENT) {
app.setName('Gemini Nexus Dev');
}
const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log');
@@ -63,7 +69,7 @@ if (squirrelStartup) {
process.exit(0);
}
const inDevelopment = process.env.NODE_ENV === 'development';
const inDevelopment = IS_DEV_ENVIRONMENT;
const debugHttpProxy = process.env.http_proxy?.trim() || process.env.HTTP_PROXY?.trim();
const debugHttpsProxy = process.env.https_proxy?.trim() || process.env.HTTPS_PROXY?.trim();
const debugNoProxy = process.env.no_proxy?.trim() || process.env.NO_PROXY?.trim();
@@ -116,6 +122,8 @@ function isRunningFromExpectedInstallDir() {
localAppData: process.env.LOCALAPPDATA,
appName: app.getName(),
execPath: process.execPath,
programFiles: process.env.ProgramFiles,
programFilesX86: process.env['ProgramFiles(x86)'],
});
}
@@ -140,6 +148,7 @@ function showWindowsInstallNoticeIfNeeded() {
hasShownInstallNotice = true;
const language = resolveInstallNoticeLanguage({
configLanguage: startupConfig?.language,
preferredLanguages: app.getPreferredSystemLanguages(),
locale: app.getLocale(),
});
const text = getInstallNoticeText(language);
@@ -164,11 +173,7 @@ function showWindowsInstallNoticeIfNeeded() {
});
}
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
if (isDev) {
app.setName('Gemini Nexus Dev');
}
const isDev = IS_DEV_ENVIRONMENT;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
+5 -6
View File
@@ -10,16 +10,15 @@ import os from 'os';
let sentryEnabled = false;
try {
const home = os.homedir();
const isDev = process.env.NODE_ENV === 'development';
const appName = isDev ? 'Gemini Nexus Dev' : 'Gemini Nexus';
let appDataPath = '';
if (process.platform === 'win32') {
appDataPath = path.join(
process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
'Gemini Nexus',
);
appDataPath = path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), appName);
} else if (process.platform === 'darwin') {
appDataPath = path.join(home, 'Library', 'Application Support', 'Gemini Nexus');
appDataPath = path.join(home, 'Library', 'Application Support', appName);
} else {
appDataPath = path.join(home, '.config', 'Gemini Nexus');
appDataPath = path.join(home, '.config', appName);
}
const configPath = path.join(appDataPath, 'gui_config.json');
+1 -1
View File
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
function HomePage() {
return (
<div className="container mx-auto max-w-4xl p-6">
<div className="container mx-auto max-w-6xl p-6">
<CloudAccountList />
</div>
);
+82 -41
View File
@@ -26,7 +26,17 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Loader2, Copy, Code, Terminal, Eye, EyeOff, ServerOff, ChevronDown, ChevronUp } from 'lucide-react';
import {
Loader2,
Copy,
Code,
Terminal,
Eye,
EyeOff,
ServerOff,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -37,7 +47,7 @@ import {
} from '@/components/ui/dialog';
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
import { ProxyAdvancedTab } from '@/components/proxy/ProxyAdvancedTab';
import { ToolsTab } from '@/components/proxy/advanced/ToolsTab';
import { flatMap, sortBy, uniq } from 'lodash-es';
type ProxyProtocol = 'openai' | 'anthropic';
@@ -304,14 +314,14 @@ print(response.choices[0].message.content)`;
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="status">Status</TabsTrigger>
<TabsTrigger value="routing">Routing</TabsTrigger>
<TabsTrigger value="tools">Tools</TabsTrigger>
<TabsTrigger value="api-docs">API Docs</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
{/* Status Tab */}
<TabsContent value="status" className="space-y-5">
{/* Service Control Card */}
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<div className="flex items-center justify-between">
<div>
@@ -411,7 +421,11 @@ print(response.choices[0].message.content)`;
<Copy size={14} className="mr-1" />
{t('proxy.copy')}
</Button>
<Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}>
<Button
variant="outline"
size="sm"
onClick={() => setIsRegenerateDialogOpen(true)}
>
{t('proxy.regenerate')}
</Button>
</div>
@@ -420,7 +434,7 @@ print(response.choices[0].message.content)`;
<Badge variant="destructive">
{proxyConfig.api_key ? 'Weak key' : 'No API key'}
</Badge>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{proxyConfig.api_key
? 'API key is too short for secure operation'
: 'Generate or set an API key to secure the proxy'}
@@ -431,7 +445,9 @@ print(response.choices[0].message.content)`;
<DialogContent>
<DialogHeader>
<DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
<DialogDescription>{t('proxy.regenerateConfirm.description')}</DialogDescription>
<DialogDescription>
{t('proxy.regenerateConfirm.description')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
@@ -457,7 +473,9 @@ print(response.choices[0].message.content)`;
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-4">
<div className="space-y-1">
<Label>{t('proxy.config.auto_start')}</Label>
<p className="text-xs text-muted-foreground">{t('proxy.config.auto_start_desc')}</p>
<p className="text-muted-foreground text-xs">
{t('proxy.config.auto_start_desc')}
</p>
</div>
<Switch
checked={proxyConfig.auto_start}
@@ -470,7 +488,7 @@ print(response.choices[0].message.content)`;
</Card>
{/* Account Quota Context */}
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<CardTitle>Account Pool</CardTitle>
<CardDescription>Quota and routing context</CardDescription>
@@ -481,10 +499,14 @@ print(response.choices[0].message.content)`;
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium">Primary Account</p>
<p className="text-muted-foreground font-mono text-[13px]">{accounts[0].email}</p>
<p className="text-muted-foreground font-mono text-[13px]">
{accounts[0].email}
</p>
</div>
{accounts.length > 1 && (
<Badge variant="outline">Pool Mode: Active ({accounts.length} accounts)</Badge>
<Badge variant="outline">
Pool Mode: Active ({accounts.length} accounts)
</Badge>
)}
</div>
<Button variant="outline" size="sm" className="text-xs" asChild>
@@ -506,7 +528,7 @@ print(response.choices[0].message.content)`;
<>
{/* Local Access Info Banner */}
<div className="flex flex-col gap-2 rounded-xl border border-blue-500/20 bg-blue-500/5 p-3 text-[13px] text-blue-200">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<div className="font-semibold">{t('proxy.config.local_access')}</div>
<code className="rounded-md bg-blue-500/10 px-1.5 py-0.5 font-mono text-xs select-all">
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
@@ -516,9 +538,7 @@ print(response.choices[0].message.content)`;
size="icon"
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
onClick={() =>
copyToClipboard(
`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`,
)
copyToClipboard(`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`)
}
>
<Copy size={14} />
@@ -572,9 +592,14 @@ print(response.choices[0].message.content)`;
},
];
return items.map((metric) => (
<Card key={metric.label} className="border border-white/[0.06] bg-card shadow-none">
<Card
key={metric.label}
className="bg-card border border-white/[0.06] shadow-none"
>
<CardContent className="pt-6">
<p className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">{metric.label}</p>
<p className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
{metric.label}
</p>
<div className="mt-1 flex items-center gap-2">
<span className="font-mono text-xl font-semibold tabular-nums">
{metric.value}
@@ -592,7 +617,9 @@ print(response.choices[0].message.content)`;
<ServerOff className="text-muted-foreground/50 h-10 w-10" />
<div className="space-y-1">
<p className="text-base font-semibold">Proxy is not running</p>
<p className="text-muted-foreground text-[13px]">Start the proxy to enable local API access</p>
<p className="text-muted-foreground text-[13px]">
Start the proxy to enable local API access
</p>
</div>
<Button
onClick={async () => {
@@ -609,19 +636,19 @@ print(response.choices[0].message.content)`;
{/* Routing Tab */}
<TabsContent value="routing" className="space-y-5">
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<CardTitle>{t('proxy.mapping.title')}</CardTitle>
<CardDescription>{t('proxy.mapping.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
<div className="grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase text-muted-foreground">
<div className="text-muted-foreground grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase">
<div>Requested Model</div>
<div>Resolved Model</div>
<div>Provider</div>
</div>
<div className="grid grid-cols-3 gap-4 items-center p-3 text-[13px]">
<div className="grid grid-cols-3 items-center gap-4 p-3 text-[13px]">
<div className="font-medium">Claude Sonnet 4.6 (Thinking)</div>
<Select
value={resolveAnthropicMappingValue(
@@ -649,7 +676,7 @@ print(response.choices[0].message.content)`;
</Select>
<Badge variant="secondary">Anthropic</Badge>
</div>
<div className="grid grid-cols-3 gap-4 items-center border-t border-white/[0.06] p-3 text-[13px]">
<div className="grid grid-cols-3 items-center gap-4 border-t border-white/[0.06] p-3 text-[13px]">
<div className="font-medium">Claude Opus 4.6 (Thinking)</div>
<Select
value={resolveAnthropicMappingValue(
@@ -696,7 +723,7 @@ print(response.choices[0].message.content)`;
</Card>
{/* Current Mappings */}
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<CardTitle>{t('proxy.routing.currentMappings')}</CardTitle>
<CardDescription>{t('proxy.routing.modelMapping')}</CardDescription>
@@ -706,10 +733,12 @@ print(response.choices[0].message.content)`;
const mappingEntries = Object.entries(proxyConfig.anthropic_mapping || {});
const customEntries = Object.entries(proxyConfig.custom_mapping || {});
return mappingEntries.length === 0 && customEntries.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t('proxy.routing.noMapping')}</p>
<p className="text-muted-foreground text-[13px]">
{t('proxy.routing.noMapping')}
</p>
) : (
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
<div className="grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase text-muted-foreground">
<div className="text-muted-foreground grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase">
<div>{t('proxy.mapping.maps_to')}</div>
<div>{t('settings.modelMapping.targetGemini')}</div>
<div>Provider</div>
@@ -717,7 +746,7 @@ print(response.choices[0].message.content)`;
{mappingEntries.map(([key, value]) => (
<div
key={key}
className="grid grid-cols-3 gap-4 items-center border-b border-white/[0.06] p-3 last:border-0"
className="grid grid-cols-3 items-center gap-4 border-b border-white/[0.06] p-3 last:border-0"
>
<div className="font-mono text-xs">{key}</div>
<div className="font-mono text-xs">{value}</div>
@@ -727,7 +756,7 @@ print(response.choices[0].message.content)`;
{customEntries.map(([key, value]) => (
<div
key={key}
className="grid grid-cols-3 gap-4 items-center border-b border-white/[0.06] p-3 last:border-0"
className="grid grid-cols-3 items-center gap-4 border-b border-white/[0.06] p-3 last:border-0"
>
<div className="font-mono text-xs">{key}</div>
<div className="font-mono text-xs">{value}</div>
@@ -741,22 +770,30 @@ print(response.choices[0].message.content)`;
</Card>
{/* Upstream Proxy Status */}
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<CardTitle>{t('proxy.routing.upstreamProxy')}</CardTitle>
<CardDescription>{t('proxy.routing.upstreamProxy')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
<span className="text-[13px] text-muted-foreground">{t('proxy.routing.status')}</span>
<span className="text-muted-foreground text-[13px]">
{t('proxy.routing.status')}
</span>
<Badge variant={proxyConfig.upstream_proxy?.enabled ? 'default' : 'secondary'}>
{proxyConfig.upstream_proxy?.enabled ? t('proxy.routing.enabled') : t('proxy.routing.disabled')}
{proxyConfig.upstream_proxy?.enabled
? t('proxy.routing.enabled')
: t('proxy.routing.disabled')}
</Badge>
</div>
{proxyConfig.upstream_proxy?.enabled && (
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
<span className="text-[13px] text-muted-foreground">{t('proxy.routing.url')}</span>
<span className="font-mono text-xs">{proxyConfig.upstream_proxy?.url || '—'}</span>
<span className="text-muted-foreground text-[13px]">
{t('proxy.routing.url')}
</span>
<span className="font-mono text-xs">
{proxyConfig.upstream_proxy?.url || '—'}
</span>
</div>
)}
</CardContent>
@@ -765,7 +802,7 @@ print(response.choices[0].message.content)`;
{/* API Docs Tab */}
<TabsContent value="api-docs" className="space-y-5">
<Card className="border border-white/[0.06] bg-card shadow-none">
<Card className="bg-card border border-white/[0.06] shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code size={20} />
@@ -812,7 +849,11 @@ print(response.choices[0].message.content)`;
{provider}
</SelectLabel>
{models.map((modelId) => (
<SelectItem key={modelId} value={modelId} className="font-mono text-xs">
<SelectItem
key={modelId}
value={modelId}
className="font-mono text-xs"
>
{modelId}
</SelectItem>
))}
@@ -853,7 +894,7 @@ print(response.choices[0].message.content)`;
{/* cURL */}
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
<div
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium transition-colors hover:bg-white/[0.02]"
onClick={() => setShowCurl(!showCurl)}
>
<span className="flex items-center gap-2">
@@ -876,7 +917,7 @@ print(response.choices[0].message.content)`;
</div>
</div>
{showCurl && (
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
{getCurlExample(activeModelTab)}
</pre>
)}
@@ -885,7 +926,7 @@ print(response.choices[0].message.content)`;
{/* Python */}
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
<div
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium transition-colors hover:bg-white/[0.02]"
onClick={() => setShowPython(!showPython)}
>
<span className="flex items-center gap-2">
@@ -908,7 +949,7 @@ print(response.choices[0].message.content)`;
</div>
</div>
{showPython && (
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
{getPythonExample(activeModelTab)}
</pre>
)}
@@ -918,9 +959,9 @@ print(response.choices[0].message.content)`;
</Card>
</TabsContent>
{/* Advanced Tab */}
<TabsContent value="advanced">
<ProxyAdvancedTab proxyConfig={proxyConfig} />
{/* Tools Tab */}
<TabsContent value="tools">
<ToolsTab availableModelIds={availableModelIds} />
</TabsContent>
</Tabs>
</div>
+15 -11
View File
@@ -92,7 +92,7 @@ function SettingsPage() {
{/* --- GENERAL TAB --- */}
<TabsContent value="general" className="space-y-5">
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
<CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.appearance.title')}</CardTitle>
<CardDescription>{t('settings.appearance.description')}</CardDescription>
@@ -139,7 +139,7 @@ function SettingsPage() {
</Card>
{/* Account Settings Card */}
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
<CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.account.title')}</CardTitle>
<CardDescription>{t('settings.account.description')}</CardDescription>
@@ -149,7 +149,9 @@ function SettingsPage() {
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.account.auto_refresh')}</Label>
<p className="text-muted-foreground text-xs">{t('settings.account.auto_refresh_desc')}</p>
<p className="text-muted-foreground text-xs">
{t('settings.account.auto_refresh_desc')}
</p>
</div>
<Switch
checked={config?.auto_refresh || false}
@@ -165,7 +167,9 @@ function SettingsPage() {
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1">
<Label>{t('settings.account.auto_sync')}</Label>
<p className="text-muted-foreground text-xs">{t('settings.account.auto_sync_desc')}</p>
<p className="text-muted-foreground text-xs">
{t('settings.account.auto_sync_desc')}
</p>
</div>
<Switch
checked={config?.auto_sync || false}
@@ -180,7 +184,7 @@ function SettingsPage() {
</Card>
{isAutoStartSupported && (
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
<CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.startup.title')}</CardTitle>
<CardDescription>{t('settings.startup.description')}</CardDescription>
@@ -211,7 +215,7 @@ function SettingsPage() {
</Card>
)}
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
<CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.about.title')}</CardTitle>
<CardDescription>{t('settings.about.description')}</CardDescription>
@@ -230,7 +234,7 @@ function SettingsPage() {
<div className="text-muted-foreground">{t('action.openLogs')}</div>
<button
onClick={() => openLogDirectory()}
className="flex items-center gap-2 font-medium text-accent hover:text-accent/80 transition-colors"
className="text-accent hover:text-accent/80 flex items-center gap-2 font-medium transition-colors"
>
<FolderOpen className="h-4 w-4" />
<span>{t('settings.openLogDir')}</span>
@@ -240,7 +244,7 @@ function SettingsPage() {
</Card>
{/* Privacy & Error Reporting Card */}
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
<CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.privacy.title')}</CardTitle>
<CardDescription>{t('settings.privacy.description')}</CardDescription>
@@ -267,7 +271,7 @@ function SettingsPage() {
</Card>
{/* Notifications Card */}
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
<CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.notifications.title')}</CardTitle>
<CardDescription>{t('settings.notifications.description')}</CardDescription>
@@ -327,7 +331,7 @@ function SettingsPage() {
}
}
}}
className="w-16 rounded-lg border border-white/[0.08] bg-muted/50 px-2 py-1 text-center text-[13px] text-foreground focus:outline-none focus:border-accent/50"
className="bg-muted/50 text-foreground focus:border-accent/50 w-16 rounded-lg border border-white/[0.08] px-2 py-1 text-center text-[13px] focus:outline-none"
/>
<span className="text-muted-foreground text-sm">%</span>
</div>
@@ -343,7 +347,7 @@ function SettingsPage() {
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
<TabsContent value="proxy" className="space-y-5">
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
<CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.proxy.title')}</CardTitle>
<CardDescription>{t('settings.proxy.description')}</CardDescription>
+277 -215
View File
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
import { createFileRoute } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';
import { ResponsiveLine } from '@nivo/line';
import { ResponsiveBar } from '@nivo/bar';
import {
Select,
SelectContent,
@@ -10,18 +9,23 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { ipc } from '@/ipc/manager';
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
import { useAppConfig } from '@/hooks/useAppConfig';
import { StatCard } from '@/components/usage/StatCard';
import { ChartCard } from '@/components/usage/ChartCard';
import {
COLOR_PROMPT,
COLOR_COMPLETION,
PIE_COLORS,
LineTooltip,
} from '@/components/usage/NivoTooltip';
import { ForecastCard } from '@/components/usage/ForecastCard';
import { ModelQuotaBar } from '@/components/usage/ModelQuotaBar';
import { CostReportCard, ModelCostEntry } from '@/components/usage/CostReportCard';
import { ModelPricingDialog } from '@/components/usage/ModelPricingDialog';
import { calculateCost } from '@/constants/pricing';
import { COLOR_PROMPT, COLOR_COMPLETION, LineTooltip } from '@/components/usage/NivoTooltip';
import { nivoTheme } from '@/lib/nivo-theme';
import { MonitorTab } from '@/components/usage/MonitorTab';
import {
Activity,
Layers,
@@ -29,6 +33,10 @@ import {
MessageSquare,
Radio,
Zap,
DollarSign,
Info,
AlertTriangle,
Settings,
} from 'lucide-react';
/* ------------------------------------------------------------------ */
@@ -129,10 +137,11 @@ function UsagePage() {
const [range, setRange] = useState<TimeRange>('7d');
const ALL_ACCOUNTS = '__all__';
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
const [isPricingDialogOpen, setIsPricingDialogOpen] = useState(false);
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
const { config, saveConfig } = useAppConfig();
const accountFilter =
selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
const accountFilter = selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
/* ───── Current period data ───── */
const {
@@ -203,7 +212,6 @@ function UsagePage() {
end,
});
},
enabled: range === '24h',
refetchInterval: 15_000,
refetchIntervalInBackground: false,
staleTime: 30_000,
@@ -211,10 +219,7 @@ function UsagePage() {
/* ───── Derived data ───── */
const totals = useMemo(() => sumBuckets(dailyData || []), [dailyData]);
const prevTotals = useMemo(
() => sumBuckets(prevDailyData || []),
[prevDailyData],
);
const prevTotals = useMemo(() => sumBuckets(prevDailyData || []), [prevDailyData]);
const promptRatio = totals.totalTokens
? Math.round((totals.promptTokens / totals.totalTokens) * 100)
@@ -222,33 +227,22 @@ function UsagePage() {
const completionRatio = totals.totalTokens
? Math.round((totals.completionTokens / totals.totalTokens) * 100)
: 0;
const avgReqSize = totals.requests
? Math.round(totals.totalTokens / totals.requests)
: 0;
const avgReqSize = totals.requests ? Math.round(totals.totalTokens / totals.requests) : 0;
const sparklineTotal = useMemo(
() => (dailyData || []).map((d) => d.totalTokens),
[dailyData],
);
const sparklinePrompt = useMemo(
() => (dailyData || []).map((d) => d.promptTokens),
[dailyData],
);
const sparklineTotal = useMemo(() => (dailyData || []).map((d) => d.totalTokens), [dailyData]);
const sparklinePrompt = useMemo(() => (dailyData || []).map((d) => d.promptTokens), [dailyData]);
const sparklineCompletion = useMemo(
() => (dailyData || []).map((d) => d.completionTokens),
[dailyData],
);
const sparklineRequests = useMemo(
() => (dailyData || []).map((d) => d.requests),
[dailyData],
);
const sparklineRequests = useMemo(() => (dailyData || []).map((d) => d.requests), [dailyData]);
// Area chart — Nivo line format
const areaChartData = useMemo(() => {
const source = range === '24h' ? hourlyData || [] : dailyData || [];
return [
{
id: 'Prompt',
id: 'Input Tokens',
color: COLOR_PROMPT,
data: source.map((d) => ({
x: formatBucketLabel(d.bucket, range),
@@ -256,7 +250,7 @@ function UsagePage() {
})),
},
{
id: 'Completion',
id: 'Output Tokens',
color: COLOR_COMPLETION,
data: source.map((d) => ({
x: formatBucketLabel(d.bucket, range),
@@ -266,30 +260,73 @@ function UsagePage() {
];
}, [dailyData, hourlyData, range]);
// Model ranking bar data
const modelBarData = useMemo(() => {
if (!modelData?.length) {
return [];
}
return [...modelData]
.sort((a, b) => b.totalTokens - a.totalTokens)
.slice(0, 8)
.map((m) => ({
model:
m.model.length > 22 ? m.model.slice(0, 22) + '…' : m.model,
tokens: m.totalTokens,
}));
}, [modelData]);
// Model quota bar data and cost estimation
const { topModelsData, costReport, totalCost, hasQuotaWarning } = useMemo(() => {
let cost = 0;
let warning = false;
const report: ModelCostEntry[] = [];
// Top models mini-list
const topModels = useMemo(() => {
if (!modelData?.length) {
return [];
return { topModelsData: [], costReport: [], totalCost: 0, hasQuotaWarning: false };
}
return [...modelData]
const accountQuotas =
selectedAccountId !== ALL_ACCOUNTS
? accounts?.find((a) => a.id === selectedAccountId)?.quota?.models
: null;
const processed = [...modelData]
.sort((a, b) => b.totalTokens - a.totalTokens)
.slice(0, 5);
}, [modelData]);
.map((m) => {
const inputCost = calculateCost(m.model, m.promptTokens, 0, config?.custom_model_pricing);
const outputCost = calculateCost(
m.model,
0,
m.completionTokens,
config?.custom_model_pricing,
);
const mTotalCost = calculateCost(
m.model,
m.promptTokens,
m.completionTokens,
config?.custom_model_pricing,
);
cost += mTotalCost;
report.push({
model: m.model,
inputTokens: m.promptTokens,
outputTokens: m.completionTokens,
inputCost,
outputCost,
totalCost: mTotalCost,
});
// Use percentage if available directly from the API response
const percentage = accountQuotas?.[m.model]?.percentage;
if (percentage && percentage >= 80) {
warning = true;
}
// Since limit tokens isn't stored in quotas exactly, we just use undefined
// to not display limit stats in the bar but just usage
const limitTokens = undefined;
return {
model: m.model,
tokens: m.totalTokens,
limit: limitTokens,
};
});
return {
topModelsData: processed.slice(0, 8),
costReport: report.sort((a, b) => b.totalCost - a.totalCost),
totalCost: cost,
hasQuotaWarning: warning,
};
}, [modelData, accounts, selectedAccountId]);
const isAreaEmpty =
range === '24h'
@@ -299,6 +336,14 @@ function UsagePage() {
const areaError = range === '24h' ? hourlyError : dailyError;
const isModelEmpty = !modelLoading && !modelError && !modelData?.length;
const handleSavePricing = async (
pricing: Record<string, { input: number; output: number; source: string }>,
) => {
if (config) {
await saveConfig({ ...config, custom_model_pricing: pricing });
}
};
/* ───── Render ───── */
return (
<div className="flex h-full flex-col overflow-auto">
@@ -311,9 +356,7 @@ function UsagePage() {
</h1>
<div className="flex items-center gap-1.5 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5">
<Radio className="h-2.5 w-2.5 animate-pulse text-emerald-400" />
<span className="text-[10px] font-medium text-emerald-400">
Live
</span>
<span className="text-[10px] font-medium text-emerald-400">Live</span>
</div>
</div>
<p className="text-muted-foreground mt-0.5 text-[13px]">
@@ -347,10 +390,7 @@ function UsagePage() {
</SelectContent>
</Select>
<Tabs
value={range}
onValueChange={(v) => setRange(v as TimeRange)}
>
<Tabs value={range} onValueChange={(v) => setRange(v as TimeRange)}>
<TabsList className="h-8">
<TabsTrigger value="24h" className="px-3 text-xs">
24h
@@ -366,75 +406,147 @@ function UsagePage() {
</div>
</div>
<div className="flex-1 space-y-4 px-6 py-5">
{/* ─── Stat cards ─── */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="Total Tokens"
value={formatNumber(totals.totalTokens)}
icon={Zap}
isLoading={dailyLoading}
accent="amber"
trend={
prevDailyData
? calcTrend(totals.totalTokens, prevTotals.totalTokens)
: undefined
}
sparkline={sparklineTotal}
/>
<StatCard
label="Prompt Tokens"
value={formatNumber(totals.promptTokens)}
icon={MessageSquare}
isLoading={dailyLoading}
accent="blue"
trend={
prevDailyData
? calcTrend(totals.promptTokens, prevTotals.promptTokens)
: undefined
}
sparkline={sparklinePrompt}
/>
<StatCard
label="Completion Tokens"
value={formatNumber(totals.completionTokens)}
icon={Activity}
isLoading={dailyLoading}
accent="green"
trend={
prevDailyData
? calcTrend(
totals.completionTokens,
prevTotals.completionTokens,
)
: undefined
}
sparkline={sparklineCompletion}
/>
<StatCard
label="Total Requests"
value={totals.requests.toLocaleString()}
icon={Layers}
isLoading={dailyLoading}
accent="slate"
trend={
prevDailyData
? calcTrend(totals.requests, prevTotals.requests)
: undefined
}
sparkline={sparklineRequests}
/>
<Tabs defaultValue="analytics" className="flex-1 flex flex-col">
<div className="px-6 pt-5">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="analytics">Analytics</TabsTrigger>
<TabsTrigger value="monitor">Monitor</TabsTrigger>
</TabsList>
</div>
<TabsContent value="analytics" className="flex-1 space-y-4 px-6 py-5">
{hasQuotaWarning && (
<Alert
variant="destructive"
className="border-amber-500/50 bg-amber-500/10 text-amber-500"
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Quota Warning</AlertTitle>
<AlertDescription>
One or more models have exceeded 80% of their allocated usage limit.
</AlertDescription>
</Alert>
)}
{/* ─── Stat cards ─── */}
<TooltipProvider delayDuration={300}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
<StatCard
label="Total Tokens"
value={formatNumber(totals.totalTokens)}
icon={Zap}
isLoading={dailyLoading}
accent="amber"
trend={
prevDailyData ? calcTrend(totals.totalTokens, prevTotals.totalTokens) : undefined
}
sparkline={sparklineTotal}
/>
<div className="group relative">
<StatCard
label={
<div className="flex cursor-help items-center gap-1.5">
Input Tokens
<Tooltip>
<TooltipTrigger asChild>
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>
<p>Tokens sent to the model (prompt, context, files).</p>
</TooltipContent>
</Tooltip>
</div>
}
value={formatNumber(totals.promptTokens)}
icon={MessageSquare}
isLoading={dailyLoading}
accent="blue"
trend={
prevDailyData
? calcTrend(totals.promptTokens, prevTotals.promptTokens)
: undefined
}
sparkline={sparklinePrompt}
/>
</div>
<div className="group relative">
<StatCard
label={
<div className="flex cursor-help items-center gap-1.5">
Output Tokens
<Tooltip>
<TooltipTrigger asChild>
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>
<p>Tokens generated by the model in response.</p>
</TooltipContent>
</Tooltip>
</div>
}
value={formatNumber(totals.completionTokens)}
icon={Activity}
isLoading={dailyLoading}
accent="green"
trend={
prevDailyData
? calcTrend(totals.completionTokens, prevTotals.completionTokens)
: undefined
}
sparkline={sparklineCompletion}
/>
</div>
<StatCard
label="Total Requests"
value={totals.requests.toLocaleString()}
icon={Layers}
isLoading={dailyLoading}
accent="slate"
trend={prevDailyData ? calcTrend(totals.requests, prevTotals.requests) : undefined}
sparkline={sparklineRequests}
/>
<div className="group relative">
<StatCard
label={
<div className="flex w-full items-center justify-between">
<div className="flex cursor-help items-center gap-1.5">
Estimated Cost
<Tooltip>
<TooltipTrigger asChild>
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>
<p>Approximate cost based on configured pricing.</p>
</TooltipContent>
</Tooltip>
</div>
<Button
variant="ghost"
size="icon"
className="ml-2 h-5 w-5 hover:bg-white/10"
onClick={() => setIsPricingDialogOpen(true)}
>
<Settings className="text-muted-foreground h-3 w-3" />
</Button>
</div>
}
value={`$${totalCost.toFixed(2)}`}
icon={DollarSign}
isLoading={modelLoading}
accent="purple"
/>
</div>
</div>
</TooltipProvider>
{/* ─── Prompt/Completion ratio bar + top models ─── */}
{!dailyLoading && totals.totalTokens > 0 && (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
<div className="col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] bg-card p-4 lg:col-span-2">
<div className="bg-card col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] p-4 lg:col-span-2">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
Prompt / Completion
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
Input / Output
</span>
<span className="font-mono text-xs text-muted-foreground">
<span className="text-muted-foreground font-mono text-xs">
{formatNumber(totals.totalTokens)} total
</span>
</div>
@@ -461,76 +573,30 @@ function UsagePage() {
className="h-2 w-2 rounded-full"
style={{ backgroundColor: COLOR_PROMPT }}
/>
<span className="text-muted-foreground">Prompt</span>
<span className="font-mono font-semibold text-foreground">
{promptRatio}%
</span>
<span className="text-muted-foreground">Input</span>
<span className="text-foreground font-mono font-semibold">{promptRatio}%</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: COLOR_COMPLETION }}
/>
<span className="text-muted-foreground">Completion</span>
<span className="font-mono font-semibold text-foreground">
<span className="text-muted-foreground">Output</span>
<span className="text-foreground font-mono font-semibold">
{completionRatio}%
</span>
</div>
</div>
<span className="text-muted-foreground">
Avg/req:{' '}
<span className="font-mono font-medium text-foreground">
<span className="text-foreground font-mono font-medium">
{formatNumber(avgReqSize)}
</span>
</span>
</div>
</div>
{/* Top models */}
<div className="rounded-xl border border-white/[0.06] bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
Top Models
</span>
<span className="text-[10px] text-muted-foreground">
tokens
</span>
</div>
<div className="space-y-2">
{topModels.map((m, i) => {
const max = topModels[0]?.totalTokens || 1;
const pct = Math.max(8, (m.totalTokens / max) * 100);
return (
<div key={m.model} className="group">
<div className="mb-0.5 flex items-center justify-between">
<span className="max-w-[140px] truncate text-xs text-muted-foreground transition-colors group-hover:text-foreground">
{m.model}
</span>
<span className="font-mono text-[11px] font-medium text-foreground tabular-nums">
{formatNumber(m.totalTokens)}
</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-white/[0.04]">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${pct}%`,
backgroundColor:
PIE_COLORS[i % PIE_COLORS.length],
opacity: 0.7,
}}
/>
</div>
</div>
);
})}
{topModels.length === 0 && (
<p className="py-4 text-center text-xs text-muted-foreground">
No data
</p>
)}
</div>
</div>
<ForecastCard hourlyData={hourlyData} dailyData={dailyData} range={range} />
</div>
)}
@@ -542,9 +608,7 @@ function UsagePage() {
isLoading={areaLoading}
error={areaError}
isEmpty={isAreaEmpty}
onRetry={() =>
range === '24h' ? refetchHourly() : refetchDaily()
}
onRetry={() => (range === '24h' ? refetchHourly() : refetchDaily())}
emptyTitle="No usage data"
emptyDescription="Data will appear here once proxy requests are recorded."
errorMessage="Failed to load usage data"
@@ -579,8 +643,7 @@ function UsagePage() {
tickSize: 0,
tickPadding: 8,
tickRotation: 0,
format: (v: number) =>
v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`,
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
}}
tooltip={LineTooltip}
legends={[
@@ -617,47 +680,46 @@ function UsagePage() {
errorMessage="Failed to load model data"
retryLabel="Retry"
>
<div className="h-[320px]">
<ResponsiveBar
data={modelBarData}
keys={['tokens']}
indexBy="model"
layout="horizontal"
theme={nivoTheme}
colors={PIE_COLORS}
colorBy="indexValue"
margin={{ top: 0, right: 10, bottom: 10, left: 120 }}
padding={0.3}
borderRadius={4}
enableGridX={true}
enableGridY={false}
gridXValues={4}
axisBottom={null}
axisLeft={{ tickSize: 0, tickPadding: 8 }}
axisTop={null}
axisRight={null}
enableLabel={true}
label={(d) => formatNumber(d.value as number)}
labelSkipWidth={40}
labelTextColor="#e8eaed"
tooltip={({ indexValue, value }) => (
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
<p className="text-foreground mb-1 text-xs font-medium">
{indexValue}
</p>
<span className="text-foreground font-mono text-xs font-medium">
{(value as number).toLocaleString()} tokens
</span>
</div>
)}
animate={true}
motionConfig="gentle"
/>
<div className="custom-scrollbar h-[320px] overflow-y-auto pr-2">
{topModelsData.map((m) => (
<ModelQuotaBar
key={m.model}
modelName={m.model}
usedTokens={m.tokens}
limitTokens={m.limit}
/>
))}
</div>
</ChartCard>
</div>
</div>
</div>
{/* ─── Cost Report ─── */}
{!dailyLoading && modelData && modelData.length > 0 && (
<div className="mt-6">
<CostReportCard
models={costReport}
totalInputCost={costReport.reduce((acc, r) => acc + r.inputCost, 0)}
totalOutputCost={costReport.reduce((acc, r) => acc + r.outputCost, 0)}
totalCost={totalCost}
isLoading={modelLoading}
/>
</div>
)}
{/* ─── Modals ─── */}
<ModelPricingDialog
open={isPricingDialogOpen}
onOpenChange={setIsPricingDialogOpen}
customPricing={config?.custom_model_pricing || {}}
onSavePricing={handleSavePricing}
availableModels={modelData?.map((m) => m.model) || []}
/>
</TabsContent>
<TabsContent value="monitor" className="flex-1 px-6 py-5">
<MonitorTab />
</TabsContent>
</Tabs>
</div>
);
}
+6 -1
View File
@@ -9,11 +9,16 @@ import { ProxyConfig } from '../types/config';
import { setServerConfig } from './server-config';
import { registerApiLogging } from './logging/api-logging.plugin';
import { IS_DEV_ENVIRONMENT } from '../utils/paths';
let app: NestFastifyApplication | null = null;
let currentPort: number = 0;
/** Default port: 8046 in development, 8045 in production */
const DEFAULT_PORT = IS_DEV_ENVIRONMENT ? 8046 : 8045;
export async function bootstrapNestServer(config: ProxyConfig): Promise<boolean> {
const port = config.port || 8045;
const port = config.port || DEFAULT_PORT;
if (app) {
logger.info('NestJS server already running.');
return true;
@@ -3,12 +3,18 @@ import { networkInterfaces } from 'os';
import { getServerConfig } from '../../server-config';
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
export type SupportedIde = 'cursor' | 'vscode' | 'claude-code' | 'jetbrains' | 'opencide';
export type SupportedIde =
| 'cursor'
| 'vscode'
| 'claude-code'
| 'jetbrains'
| 'opencide'
| 'opencode';
export interface IdeConfigResult {
ide: SupportedIde;
format: 'json' | 'shell' | 'env' | 'instructions';
content: Record<string, string> | string;
content: Record<string, any> | string;
}
@Injectable()
@@ -46,6 +52,7 @@ export class ProxyIdeConfigService {
port: number,
apiKey: string,
localIp: string,
defaultModel: string = 'gemini-3.1-pro-high',
): IdeConfigResult {
const baseUrl = `http://${localIp}:${port}/v1`;
@@ -99,6 +106,20 @@ export class ProxyIdeConfigService {
content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`,
};
}
case 'opencode': {
return {
ide,
format: 'json',
content: {
apiType: 'openai',
apiKey: apiKey,
baseURL: baseUrl,
models: {
chat: [defaultModel],
},
},
};
}
default: {
// Exhaustive fallback to satisfy TypeScript
const exhaustiveCheck: never = ide;
@@ -112,7 +133,7 @@ export class ProxyIdeConfigService {
* Convenience helper that reads the current server config and local IP,
* then delegates to generateIdeConfig.
*/
generateConfigForCurrentServer(ide: SupportedIde): IdeConfigResult | null {
generateConfigForCurrentServer(ide: SupportedIde, defaultModel?: string): IdeConfigResult | null {
const config = getServerConfig();
if (!config) {
this.logger.warn('Server config not available; cannot generate IDE config');
@@ -121,6 +142,6 @@ export class ProxyIdeConfigService {
const port = config.port;
const apiKey = config.api_key;
const localIp = this.getLocalIp();
return this.generateIdeConfig(ide, port, apiKey, localIp);
return this.generateIdeConfig(ide, port, apiKey, localIp, defaultModel);
}
}
Loaded 100 of 363 files, more files were not shown because too many files have changed in this diff. Show more